diff --git a/.gitignore b/.gitignore
index b64ad0460cec03eb7eba934931ade8b5882fed36..6ce27b3dbb51a3328bed0e93d3240ecc2c8c2dbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,3 +77,4 @@ Thumbs.db
CMakeFiles
cmake_install.cmake
*_autogen
+.worktrees/*
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cbadf34c337c9c40bbad436b22200362b9aeabdd..94aef609527040f8d695283843584dead078545a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -40,14 +40,27 @@ set(REQUIRED_QT_VERSION 5.12.8)
find_package(QT NAMES Qt6 Qt5 COMPONENTS Core Gui Widgets REQUIRED)
find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui Widgets REQUIRED)
+
+set(POPPLER_LIB "")
+if(QT_VERSION_MAJOR EQUAL 5)
+ set(POPPLER_LIB poppler-qt5)
+elseif (QT_VERSION_MAJOR EQUAL 6)
+ set(POPPLER_LIB poppler-qt6)
+endif ()
+
find_package(PkgConfig REQUIRED)
-pkg_search_module(POPPLER REQUIRED poppler-qt5)
-pkg_search_module(UCHARDET REQUIRED IMPORTED_TARGET uchardet)
-pkg_search_module(AVCODEC REQUIRED libavcodec)
-pkg_search_module(AVFORMAT REQUIRED libavformat)
-pkg_search_module(AVUTIL REQUIRED libavutil)
-pkg_search_module(SWSCALE REQUIRED libswscale)
-pkg_search_module(TAGLIB REQUIRED taglib)
+
+set(UKUI_FILE_METADATA_PC_PKGS uchardet libavcodec libavformat libavutil libswscale taglib minizip tesseract lept)
+foreach(PC_LIB IN LISTS UKUI_FILE_METADATA_PC_PKGS)
+ string(TOUPPER "${PC_LIB}" PC_PREFIX)
+ # Normalize libav* package names to the shorter imported-target prefixes
+ # used elsewhere in the tree, e.g. libavcodec -> AVCODEC.
+ string(REGEX REPLACE "^LIB" "" PC_PREFIX "${PC_PREFIX}")
+ pkg_check_modules(${PC_PREFIX} REQUIRED IMPORTED_TARGET ${PC_LIB})
+endforeach()
+# poppler-qt5 / poppler-qt6 do not fit the generic prefix normalization above,
+# so keep a stable POPPLER imported-target prefix for consumers.
+pkg_check_modules(POPPLER REQUIRED IMPORTED_TARGET ${POPPLER_LIB})
enable_testing()
add_subdirectory(src)
@@ -57,4 +70,3 @@ add_subdirectory(tests)
endif()
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
-
diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt
index ed063c857350787ebfc4acb80b4b58acd397e762..d53410f29095c89186cdb6d319ba0ec11f4e7693 100644
--- a/autotests/CMakeLists.txt
+++ b/autotests/CMakeLists.txt
@@ -14,15 +14,14 @@ include_directories(../src)
add_executable(ffmpegExtractorTest
ffmpeg-extractortest.cpp
../src/extractors/ffmpeg-extractor.cpp)
-target_include_directories(ffmpegExtractorTest SYSTEM PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS})
target_link_libraries(ffmpegExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- ${AVCODEC_LIBRARIES}
- ${AVFORMAT_LIBRARIES}
- ${AVUTIL_LIBRARIES}
- ${SWSCALE_LIBRARIES}
+ PkgConfig::AVCODEC
+ PkgConfig::AVFORMAT
+ PkgConfig::AVUTIL
+ PkgConfig::SWSCALE
)
add_test(ffmpegExtractorTest ${CMAKE_BINARY_DIR}/autotests/ffmpegExtractorTest)
@@ -39,6 +38,16 @@ target_link_libraries(OfficeExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
)
+
+add_executable(OfficeExtractorHelper
+ ../src/extractors/office-extractor.cpp
+ ../src/extractors/binary-parser.cpp
+ office-extractor-helper.cpp)
+target_link_libraries(OfficeExtractorHelper PUBLIC
+ Qt${QT_VERSION_MAJOR}::Core
+ ukui-file-metadata
+ )
+add_dependencies(OfficeExtractorTest OfficeExtractorHelper)
add_test(OfficeExtractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTest)
#
@@ -52,9 +61,8 @@ target_link_libraries(Office2007ExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- libquazip5.so
)
-add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTest)
+add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/Office2007ExtractorTest)
#
# pdf test
@@ -63,12 +71,11 @@ add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTe
add_executable(PdfExtractorTest
../src/extractors/pdf-extractor.cpp
pdf-extractortest.cpp)
-target_include_directories(PdfExtractorTest SYSTEM PRIVATE ${POPPLER_INCLUDE_DIRS})
target_link_libraries(PdfExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- ${POPPLER_LIBRARIES}
+ PkgConfig::POPPLER
)
add_test(PdfExtractortest ${CMAKE_BINARY_DIR}/autotests/PdfExtractorTest)
@@ -79,12 +86,11 @@ add_test(PdfExtractortest ${CMAKE_BINARY_DIR}/autotests/PdfExtractorTest)
add_executable(TextExtractorTest
../src/extractors/text-extractor.cpp
text-extractortest.cpp)
-target_include_directories(TextExtractorTest SYSTEM PRIVATE ${UCHARDET_INCLUDE_DIRS})
target_link_libraries(TextExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- ${UCHARDET_LIBRARIES}
+ PkgConfig::UCHARDET
)
add_test(TextExtractortest ${CMAKE_BINARY_DIR}/autotests/TextExtractorTest)
@@ -99,7 +105,6 @@ target_link_libraries(OfdExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- quazip5
)
add_test(OfdExtractorTest ${CMAKE_BINARY_DIR}/autotests/OfdExtractorTest)
@@ -114,7 +119,6 @@ target_link_libraries(UofExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- quazip5
)
add_test(UofExtractorTest ${CMAKE_BINARY_DIR}/autotests/UofExtractorTest)
@@ -129,8 +133,8 @@ target_link_libraries(PngExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- tesseract
- leptonica
+ PkgConfig::TESSERACT
+ PkgConfig::LEPT
)
add_test(PngExtractortest ${CMAKE_BINARY_DIR}/autotests/PngExtractorTest)
@@ -141,12 +145,11 @@ add_test(PngExtractortest ${CMAKE_BINARY_DIR}/autotests/PngExtractorTest)
add_executable(TaglibExtractorTest
../src/extractors/taglib-extractor.cpp
taglib-extractortest.cpp)
-target_include_directories(TaglibExtractorTest SYSTEM PRIVATE ${TAGLIB_INCLUDE_DIRS})
target_link_libraries(TaglibExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- ${TAGLIB_LIBRARIES}
+ PkgConfig::TAGLIB
)
add_test(TaglibExtractorTest ${CMAKE_BINARY_DIR}/autotests/TaglibExtractorTest)
@@ -157,11 +160,49 @@ add_test(TaglibExtractorTest ${CMAKE_BINARY_DIR}/autotests/TaglibExtractorTest)
add_executable(ImageExtractorTest
../src/extractors/image-extractor.cpp
image-extractortest.cpp)
-target_include_directories(ImageExtractorTest SYSTEM PRIVATE ${IMAGE_INCLUDE_DIRS})
target_link_libraries(ImageExtractorTest PUBLIC
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::Test
ukui-file-metadata
- ${IMAGE_LIBRARIES}
)
-add_test(ImageExtractorTest ${CMAKE_BINARY_DIR}/autotests/ImageExtractorTest)
\ No newline at end of file
+add_test(ImageExtractorTest ${CMAKE_BINARY_DIR}/autotests/ImageExtractorTest)
+
+#
+# bookmarks test
+#
+
+add_executable(BookMarksExtractorTest
+ ../src/extractors/bookmarks-extractor.cpp
+ bookmarks-extractortest.cpp)
+target_link_libraries(BookMarksExtractorTest PUBLIC
+ Qt${QT_VERSION_MAJOR}::Core
+ Qt${QT_VERSION_MAJOR}::Test
+ ukui-file-metadata
+)
+add_test(BookMarksExtractorTest ${CMAKE_BINARY_DIR}/autotests/BookMarksExtractorTest)
+
+#
+# bookmark class test
+#
+
+add_executable(BookMarkTest
+ bookmarktest.cpp)
+target_link_libraries(BookMarkTest PUBLIC
+ Qt${QT_VERSION_MAJOR}::Core
+ Qt${QT_VERSION_MAJOR}::Test
+ ukui-file-metadata
+)
+add_test(BookMarkTest ${CMAKE_BINARY_DIR}/autotests/BookMarkTest)
+
+#
+# zip reader test
+#
+
+add_executable(ZipReaderTest
+ zip-readertest.cpp)
+target_link_libraries(ZipReaderTest PUBLIC
+ Qt${QT_VERSION_MAJOR}::Core
+ Qt${QT_VERSION_MAJOR}::Test
+ ukui-file-metadata
+)
+add_test(ZipReaderTest ${CMAKE_BINARY_DIR}/autotests/ZipReaderTest)
diff --git a/autotests/bookmarks-extractortest.cpp b/autotests/bookmarks-extractortest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..667d6db9bc09d171bf7cf6a6571de1f1abc89003
--- /dev/null
+++ b/autotests/bookmarks-extractortest.cpp
@@ -0,0 +1,149 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+#include "bookmarks-extractortest.h"
+#include "extractors/bookmarks-extractor.h"
+#include "indexerextractortestsconfig.h"
+#include "mime-utils.h"
+#include "simple-extraction-result.h"
+
+#include
+
+using namespace UkuiFileMetadata;
+
+QString testFilePath(const QString& name)
+{
+ return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + QLatin1String("/bookmarks/") + name;
+}
+
+static QStringList expectedContainsLines()
+{
+ QStringList lines;
+ lines << QStringLiteral("dbusName: org.example.bookmarks");
+ lines << QStringLiteral("file:///home/user/Documents");
+ lines << QStringLiteral("https://example.com");
+ lines << QStringLiteral("showintoolbar: yes");
+ lines << QStringLiteral("folded: no");
+ lines << QStringLiteral("toolbar: yes");
+ lines << QStringLiteral("Docs");
+ lines << QStringLiteral("Favorites");
+ lines << QStringLiteral("Example");
+ lines << QStringLiteral("Documents folder");
+ lines << QStringLiteral("Example site");
+ lines << QStringLiteral("icon: folder-documents");
+ lines << QStringLiteral("mime-type: inode/directory");
+ lines << QStringLiteral("time_added: 1234567890");
+ lines << QStringLiteral("visit_count: 5");
+ lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating: favorite");
+ lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating@score: 5");
+ lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].flags.public: true");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].groups.group: pluma");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Firefox");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'firefox-esr %u'");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@modified: 2026-02-06T09:50:28.485017Z");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@count: 1");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Pluma");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'pluma %u'");
+ lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@modified: 2026-02-06T09:50:33.347434Z");
+ return lines;
+}
+
+static bool containsAny(const QStringList &parsedLines, const QStringList &candidates)
+{
+ for (const QString &candidate : candidates) {
+ if (parsedLines.contains(candidate)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static void assertParsedTextLines(const QStringList &parsedLines)
+{
+ for (const QString &line : expectedContainsLines()) {
+ QVERIFY2(parsedLines.contains(line), qPrintable(QStringLiteral("Missing extracted line: %1").arg(line)));
+ }
+ QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[xbel].app:profile: work"),
+ QStringLiteral("attribute[xbel].profile: work")}));
+ QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[bookmark].app:priority: high"),
+ QStringLiteral("attribute[bookmark].priority: high")}));
+ QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[folder].app:color: blue"),
+ QStringLiteral("attribute[folder].color: blue")}));
+ QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[alias].app:aliasType: quicklink"),
+ QStringLiteral("attribute[alias].aliasType: quicklink")}));
+ QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[separator].app:marker: main"),
+ QStringLiteral("attribute[separator].marker: main")}));
+}
+
+void BookMarksExtractorTest::testMetaDataOnly()
+{
+ BookMarksExtractor plugin{this};
+
+ QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ QMimeDatabase mimeDb;
+ QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+ QVERIFY(plugin.mimetypes().contains(mimeType));
+
+ SimpleExtractionResult result(fileName, mimeType, ExtractionResult::ExtractMetaData);
+ plugin.extract(&result);
+
+ QCOMPARE(result.types().size(), 1);
+ QCOMPARE(result.types().constFirst(), Type::Text);
+ QCOMPARE(result.text(), QString());
+ QCOMPARE(result.properties().value(Property::Title), QVariant(QStringLiteral("Root Bookmarks")));
+}
+
+void BookMarksExtractorTest::testPlainText()
+{
+ BookMarksExtractor plugin{this};
+
+ QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ QMimeDatabase mimeDb;
+ QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+ QVERIFY(plugin.mimetypes().contains(mimeType));
+
+ SimpleExtractionResult result(fileName, mimeType, ExtractionResult::ExtractPlainText);
+ plugin.extract(&result);
+
+ QCOMPARE(result.types().size(), 1);
+ QCOMPARE(result.types().constFirst(), Type::Text);
+ const QStringList parsedLines = result.text().trimmed().split(QLatin1Char('\n'));
+ assertParsedTextLines(parsedLines);
+ QVERIFY(result.properties().isEmpty());
+}
+
+void BookMarksExtractorTest::testAllData()
+{
+ BookMarksExtractor plugin{this};
+
+ QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ QMimeDatabase mimeDb;
+ QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+ QVERIFY(plugin.mimetypes().contains(mimeType));
+
+ SimpleExtractionResult result(fileName, mimeType);
+ plugin.extract(&result);
+
+ QCOMPARE(result.types().size(), 1);
+ QCOMPARE(result.types().constFirst(), Type::Text);
+ const QStringList parsedLines = result.text().trimmed().split(QLatin1Char('\n'));
+ assertParsedTextLines(parsedLines);
+ QCOMPARE(result.properties().value(Property::Title), QVariant(QStringLiteral("Root Bookmarks")));
+}
+
+QTEST_GUILESS_MAIN(BookMarksExtractorTest)
diff --git a/autotests/bookmarks-extractortest.h b/autotests/bookmarks-extractortest.h
new file mode 100644
index 0000000000000000000000000000000000000000..1a49ba034a39b70f9aa907dc467ed3e78d7445cb
--- /dev/null
+++ b/autotests/bookmarks-extractortest.h
@@ -0,0 +1,37 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+#ifndef BOOKMARKSEXTRACTORTEST_H
+#define BOOKMARKSEXTRACTORTEST_H
+
+#include
+
+namespace UkuiFileMetadata {
+
+class BookMarksExtractorTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testMetaDataOnly();
+ void testPlainText();
+ void testAllData();
+};
+}
+
+#endif // BOOKMARKSEXTRACTORTEST_H
diff --git a/autotests/bookmarktest.cpp b/autotests/bookmarktest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..3199700bcfc40a849d390df5d41929ab05565afe
--- /dev/null
+++ b/autotests/bookmarktest.cpp
@@ -0,0 +1,346 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+#include "bookmarktest.h"
+#include "bookmark.h"
+#include "bookmarks-manager.h"
+#include "indexerextractortestsconfig.h"
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace UkuiFileMetadata;
+
+QString testFilePath(const QString& name)
+{
+ return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + QLatin1String("/bookmarks/") + name;
+}
+
+void BookMarkTest::testParse()
+{
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark.xbel")));
+
+ QVERIFY(manager.isValid());
+ QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks"));
+ QCOMPARE(manager.dbusName(), QStringLiteral("org.example.bookmarks"));
+
+ const BookMarksGroup &root = manager.rootGroup();
+ QCOMPARE(root.bookmarks().size(), 1);
+ QCOMPARE(root.groups().size(), 1);
+
+ const BookMark &topBookmark = root.bookmarks().constFirst();
+ QCOMPARE(topBookmark.href(), QStringLiteral("file:///home/user/Documents"));
+ QCOMPARE(topBookmark.title(), QStringLiteral("Docs"));
+ QCOMPARE(topBookmark.description(), QStringLiteral("Documents folder"));
+ QCOMPARE(topBookmark.showInToolbar(), QStringLiteral("yes"));
+ QCOMPARE(topBookmark.iconName(), QStringLiteral("folder-documents"));
+ QCOMPARE(topBookmark.iconHref(), QStringLiteral("file:///usr/share/icons/breeze/places/64/folder-documents.svg"));
+ QCOMPARE(topBookmark.iconType(), QStringLiteral("image/svg+xml"));
+ QCOMPARE(topBookmark.mimeType(), QStringLiteral("inode/directory"));
+
+ const BookMarksGroup &folder = root.groups().constFirst();
+ QCOMPARE(folder.title(), QStringLiteral("Favorites"));
+ QCOMPARE(folder.folded(), QStringLiteral("no"));
+ QCOMPARE(folder.toolbar(), QStringLiteral("yes"));
+ QCOMPARE(folder.bookmarks().size(), 1);
+
+ const BookMark &nestedBookmark = folder.bookmarks().constFirst();
+ QCOMPARE(nestedBookmark.href(), QStringLiteral("https://example.com"));
+ QCOMPARE(nestedBookmark.title(), QStringLiteral("Example"));
+ QCOMPARE(nestedBookmark.description(), QStringLiteral("Example site"));
+
+ const QHash kdeMeta = topBookmark.kdeMetaData();
+ QCOMPARE(kdeMeta.value(QStringLiteral("time_added")).size(), 1);
+ QCOMPARE(kdeMeta.value(QStringLiteral("visit_count")).size(), 1);
+
+ const QStringList lines = manager.textLines();
+ auto containsAny = [&lines](const QStringList &candidates) {
+ for (const QString &candidate : candidates) {
+ if (lines.contains(candidate)) {
+ return true;
+ }
+ }
+ return false;
+ };
+ QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating: favorite")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating@score: 5")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].flags.public: true")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].groups.group: pluma")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Firefox")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'firefox-esr %u'")));
+ QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@count: 1")));
+ QVERIFY(lines.contains(QStringLiteral("icon-href: file:///usr/share/icons/breeze/places/64/folder-documents.svg")));
+ QVERIFY(lines.contains(QStringLiteral("icon-type: image/svg+xml")));
+ QVERIFY(containsAny({QStringLiteral("attribute[xbel].app:profile: work"),
+ QStringLiteral("attribute[xbel].profile: work")}));
+ QVERIFY(containsAny({QStringLiteral("attribute[bookmark].app:priority: high"),
+ QStringLiteral("attribute[bookmark].priority: high")}));
+ QVERIFY(containsAny({QStringLiteral("attribute[folder].app:color: blue"),
+ QStringLiteral("attribute[folder].color: blue")}));
+ QVERIFY(containsAny({QStringLiteral("attribute[alias].app:aliasType: quicklink"),
+ QStringLiteral("attribute[alias].aliasType: quicklink")}));
+ QVERIFY(containsAny({QStringLiteral("attribute[separator].app:marker: main"),
+ QStringLiteral("attribute[separator].marker: main")}));
+
+ const QString href = QStringLiteral("file:///home/user/Documents");
+ const QStringList freedesktopGroups = manager.freedesktopGroups(href);
+ QVERIFY(freedesktopGroups.contains(QStringLiteral("pluma")));
+
+ const QList freedesktopApps = manager.freedesktopApplications(href);
+ QVERIFY(!freedesktopApps.isEmpty());
+ bool hasFirefox = false;
+ bool hasPluma = false;
+ for (const FreedesktopApplicationInfo &app : freedesktopApps) {
+ if (app.name() == QStringLiteral("Firefox")
+ && app.exec() == QStringLiteral("'firefox-esr %u'")
+ && app.count() == 1) {
+ hasFirefox = true;
+ }
+ if (app.name() == QStringLiteral("Pluma")
+ && app.exec() == QStringLiteral("'pluma %u'")
+ && app.count() == 1) {
+ hasPluma = true;
+ }
+ }
+ QVERIFY(hasFirefox);
+ QVERIFY(hasPluma);
+}
+
+void BookMarkTest::testSettersWrite()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ const QString target = tempDir.path() + QLatin1String("/test_bookmark.xbel");
+ QVERIFY(QFile::copy(source, target));
+
+ BookMarksManager manager(target);
+ QVERIFY(manager.isValid());
+
+ manager.setFilePath(QString());
+ QVERIFY(!manager.isValid());
+ QVERIFY(!manager.lastError().isEmpty());
+
+ manager.setFilePath(target);
+ QVERIFY2(manager.isValid(), qPrintable(manager.lastError()));
+ QCOMPARE(manager.lastError(), QString());
+}
+
+void BookMarkTest::testSave()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ const QString target = tempDir.path() + QLatin1String("/save_test_bookmark.xbel");
+ QVERIFY(QFile::copy(source, target));
+
+ BookMarksManager manager(target);
+ QVERIFY(manager.isValid());
+
+ const QString saveAsPath = tempDir.path() + QLatin1String("/saved_as.xbel");
+ QVERIFY(manager.saveAs(saveAsPath));
+ QVERIFY(QFile::exists(saveAsPath));
+
+ QVERIFY(manager.save());
+}
+
+void BookMarkTest::testSignals()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ const QString target = tempDir.path() + QLatin1String("/signal_test_bookmark.xbel");
+ QVERIFY(QFile::copy(source, target));
+
+ BookMarksManager manager;
+ QSignalSpy changedSpy(&manager, &BookMarksManager::changed);
+ QSignalSpy errorSpy(&manager, &BookMarksManager::errorOccurred);
+ int reentrantReadCount = 0;
+ QObject::connect(&manager, &BookMarksManager::changed, &manager, [&manager, &reentrantReadCount]() {
+ (void)manager.isValid();
+ (void)manager.rootTitle();
+ ++reentrantReadCount;
+ });
+
+ manager.setFilePath(target);
+ QTRY_VERIFY(changedSpy.count() >= 1);
+ QCOMPARE(errorSpy.count(), 0);
+ QVERIFY(reentrantReadCount >= 1);
+
+ manager.setFilePath(QString());
+ QTRY_VERIFY(errorSpy.count() >= 1);
+}
+
+void BookMarkTest::testReparseSamePathAfterFailure()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString target = tempDir.path() + QLatin1String("/broken_then_fixed.xbel");
+ QFile file(target);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text));
+ QVERIFY(file.write("") > 0);
+ file.close();
+
+ BookMarksManager manager(target);
+ QVERIFY(!manager.isValid());
+ QVERIFY(!manager.lastError().isEmpty());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ QVERIFY(QFile::remove(target));
+ QVERIFY(QFile::copy(source, target));
+
+ manager.setFilePath(target);
+ QVERIFY(manager.isValid());
+ QCOMPARE(manager.lastError(), QString());
+}
+
+void BookMarkTest::testAutoReloadOnFileChange()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ const QString target = tempDir.path() + QLatin1String("/watch_test_bookmark.xbel");
+ QVERIFY(QFile::copy(source, target));
+
+ BookMarksManager manager(target);
+ QVERIFY(manager.isValid());
+ QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks"));
+
+ QFile file(target);
+ QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
+ QString content = QString::fromUtf8(file.readAll());
+ file.close();
+ content.replace(QStringLiteral("Root Bookmarks"), QStringLiteral("Updated Bookmarks"));
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text));
+ QVERIFY(file.write(content.toUtf8()) > 0);
+ file.close();
+
+ QTRY_COMPARE_WITH_TIMEOUT(manager.rootTitle(), QStringLiteral("Updated Bookmarks"), 3000);
+}
+
+void BookMarkTest::testAutoReloadOnFileDeleteAndRecreate()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString source = testFilePath(QStringLiteral("test_bookmark.xbel"));
+ const QString target = tempDir.path() + QLatin1String("/watch_delete_recreate_bookmark.xbel");
+ QVERIFY(QFile::copy(source, target));
+
+ BookMarksManager manager(target);
+ QVERIFY(manager.isValid());
+
+ QVERIFY(QFile::remove(target));
+ QTRY_VERIFY_WITH_TIMEOUT(!QFileInfo::exists(target), 3000);
+
+ QVERIFY(QFile::copy(source, target));
+ QTRY_VERIFY_WITH_TIMEOUT(manager.isValid(), 3000);
+ QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks"));
+}
+
+void BookMarkTest::testFreedesktopMetadataCompatibility()
+{
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_freedesktop_metadata.xbel")));
+ QVERIFY(manager.isValid());
+
+ const QList bookmarks = manager.rootGroup().bookmarks();
+ QCOMPARE(bookmarks.size(), 1);
+ const BookMark bookmark = bookmarks.constFirst();
+ QCOMPARE(bookmark.href(), QStringLiteral("file:///tmp/freedesktop-meta"));
+ QCOMPARE(bookmark.iconName(), QStringLiteral("folder-remote"));
+ QCOMPARE(bookmark.iconHref(), QStringLiteral("file:///usr/share/icons/hicolor/64x64/places/folder-remote.png"));
+ QCOMPARE(bookmark.iconType(), QStringLiteral("image/png"));
+ QCOMPARE(bookmark.mimeType(), QStringLiteral("inode/directory"));
+ QCOMPARE(bookmark.privateFlag(), true);
+
+ const QStringList lines = manager.textLines();
+ QVERIFY(lines.contains(QStringLiteral("icon: folder-remote")));
+ QVERIFY(lines.contains(QStringLiteral("icon-href: file:///usr/share/icons/hicolor/64x64/places/folder-remote.png")));
+ QVERIFY(lines.contains(QStringLiteral("icon-type: image/png")));
+ QVERIFY(lines.contains(QStringLiteral("mime-type: inode/directory")));
+ QVERIFY(lines.contains(QStringLiteral("private: true")));
+
+ const QString href = QStringLiteral("file:///tmp/freedesktop-meta");
+ const QStringList groups = manager.freedesktopGroups(href);
+ QCOMPARE(groups, QStringList{QStringLiteral("files")});
+
+ const QList apps = manager.freedesktopApplications(href);
+ QCOMPARE(apps.size(), 2);
+ QCOMPARE(apps.at(0).name(), QStringLiteral("Files"));
+ QCOMPARE(apps.at(0).exec(), QStringLiteral("Files %u"));
+ QCOMPARE(apps.at(0).count(), 3);
+ QCOMPARE(apps.at(1).name(), QStringLiteral("Viewer"));
+ QCOMPARE(apps.at(1).exec(), QStringLiteral("'viewer %u'"));
+ QCOMPARE(apps.at(1).count(), 1);
+}
+
+void BookMarkTest::testEdgeCases()
+{
+ {
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_ns_root.xbel")));
+ QVERIFY(manager.isValid());
+ QCOMPARE(manager.rootTitle(), QStringLiteral("NS Root"));
+ QCOMPARE(manager.rootGroup().bookmarks().size(), 1);
+ }
+
+ {
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_invalid_count.xbel")));
+ QVERIFY(manager.isValid());
+ const QList apps = manager.freedesktopApplications(QStringLiteral("file:///tmp/count"));
+ QCOMPARE(apps.size(), 2);
+ QCOMPARE(apps.at(0).count(), -1);
+ QCOMPARE(apps.at(1).count(), 1);
+ }
+
+ {
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_group_duplicate_meta.xbel")));
+ QVERIFY(manager.isValid());
+ const QStringList custom = manager.customMetaLines();
+ QCOMPARE(custom.count(QStringLiteral("metadata[unknown-owner].k: v")), 2);
+ }
+
+ {
+ const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_owner_variants.xbel")));
+ QVERIFY(manager.isValid());
+
+ const QList bookmarks = manager.rootGroup().bookmarks();
+ QCOMPARE(bookmarks.size(), 1);
+ const BookMark bookmark = bookmarks.constFirst();
+ const QHash kdeMeta = bookmark.kdeMetaData();
+ QCOMPARE(kdeMeta.value(QStringLiteral("visit_count")).constFirst(), QStringLiteral("42"));
+
+ const QString href = QStringLiteral("file:///tmp/owner-variants");
+ const QStringList groups = manager.freedesktopGroups(href);
+ QVERIFY(groups.contains(QStringLiteral("variant-group")));
+
+ const QList apps = manager.freedesktopApplications(href);
+ QCOMPARE(apps.size(), 1);
+ QCOMPARE(apps.constFirst().name(), QStringLiteral("VariantApp"));
+ QCOMPARE(apps.constFirst().count(), 2);
+ }
+}
+
+QTEST_GUILESS_MAIN(BookMarkTest)
diff --git a/autotests/bookmarktest.h b/autotests/bookmarktest.h
new file mode 100644
index 0000000000000000000000000000000000000000..2f6742deed328d3038d678b8b3754bb1963eb16e
--- /dev/null
+++ b/autotests/bookmarktest.h
@@ -0,0 +1,43 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+#ifndef BOOKMARKTEST_H
+#define BOOKMARKTEST_H
+
+#include
+
+namespace UkuiFileMetadata {
+
+class BookMarkTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testParse();
+ void testSettersWrite();
+ void testSave();
+ void testSignals();
+ void testReparseSamePathAfterFailure();
+ void testAutoReloadOnFileChange();
+ void testAutoReloadOnFileDeleteAndRecreate();
+ void testFreedesktopMetadataCompatibility();
+ void testEdgeCases();
+};
+}
+
+#endif // BOOKMARKTEST_H
diff --git a/autotests/office-extractor-helper.cpp b/autotests/office-extractor-helper.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2c4fd9ea56c0ce067fec9a9e9b1de69cc7b1bbd7
--- /dev/null
+++ b/autotests/office-extractor-helper.cpp
@@ -0,0 +1,34 @@
+#include "extractors/office-extractor.h"
+#include "mime-utils.h"
+#include "simple-extraction-result.h"
+
+#include
+#include
+#include
+
+#include
+
+using namespace UkuiFileMetadata;
+
+int main(int argc, char **argv)
+{
+ QCoreApplication app(argc, argv);
+ if (argc != 2) {
+ std::fprintf(stderr, "usage: %s \n", argv[0]);
+ return 2;
+ }
+
+ const QString fileName = QFileInfo(QString::fromLocal8Bit(argv[1])).absoluteFilePath();
+ QMimeDatabase mimeDb;
+ const QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+
+ OfficeExtractor plugin;
+ SimpleExtractionResult result(fileName, mimeType,
+ ExtractionResult::ExtractPlainText | ExtractionResult::ExtractMetaData);
+ plugin.extract(&result);
+
+ std::printf("mime=%s\ntext_len=%d\n",
+ mimeType.toLocal8Bit().constData(),
+ result.text().size());
+ return 0;
+}
diff --git a/autotests/office-extractortest.cpp b/autotests/office-extractortest.cpp
index 9fe2832a38eda60f799c3822cd5c448acd53c577..1f8ccfc33b691cbcd48b6737750709dd169e3ba5 100644
--- a/autotests/office-extractortest.cpp
+++ b/autotests/office-extractortest.cpp
@@ -24,10 +24,104 @@
#include "simple-extraction-result.h"
#include "mime-utils.h"
+#include
+#include
+#include
+#include
#include
+#include
using namespace UkuiFileMetadata;
+QString testFilePath(const QString& baseName, const QString& extension);
+
+namespace {
+
+QString shellQuote(const QString &value)
+{
+ QString escaped = value;
+ escaped.replace(QLatin1Char('\''), QLatin1String("'\\''"));
+ return QLatin1Char('\'') + escaped + QLatin1Char('\'');
+}
+
+QString officeHelperPath()
+{
+ return QCoreApplication::applicationDirPath()
+ + QLatin1String("/OfficeExtractorHelper");
+}
+
+void runOfficeHelperWithMemoryLimit(const QString &filePath,
+ int memoryLimitKb = 1048576,
+ QString *stderrText = nullptr)
+{
+ const QString helperPath = officeHelperPath();
+ QVERIFY2(QFileInfo::exists(helperPath), qPrintable(helperPath));
+
+ QProcess process;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ process.setProcessEnvironment(env);
+
+ const QString command = QStringLiteral("ulimit -v %1; exec %2 %3")
+ .arg(QString::number(memoryLimitKb), shellQuote(helperPath), shellQuote(filePath));
+ process.start(QStringLiteral("/bin/bash"), QStringList{QStringLiteral("-lc"), command});
+ QVERIFY(process.waitForStarted());
+ QVERIFY(process.waitForFinished(10000));
+
+ if(stderrText != nullptr) {
+ *stderrText = QString::fromLocal8Bit(process.readAllStandardError());
+ }
+ QCOMPARE(process.exitStatus(), QProcess::NormalExit);
+ QCOMPARE(process.exitCode(), 0);
+}
+
+QByteArray littleEndian32(quint32 value)
+{
+ QByteArray bytes(4, Qt::Uninitialized);
+ bytes[0] = static_cast(value & 0xff);
+ bytes[1] = static_cast((value >> 8) & 0xff);
+ bytes[2] = static_cast((value >> 16) & 0xff);
+ bytes[3] = static_cast((value >> 24) & 0xff);
+ return bytes;
+}
+
+void createPatchedOfficeFile(const QString &targetPath, qsizetype offset, const QByteArray &payload)
+{
+ const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc"));
+ QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath));
+
+ QFile file(targetPath);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+ QVERIFY(offset >= 0);
+ QVERIFY(offset + payload.size() <= file.size());
+ QVERIFY(file.seek(offset));
+ QCOMPARE(file.write(payload), qint64(payload.size()));
+}
+
+void createResizedOfficeFile(const QString &targetPath, qint64 finalSize)
+{
+ const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc"));
+ QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath));
+
+ QFile file(targetPath);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+ QVERIFY2(file.resize(finalSize), qPrintable(file.errorString()));
+}
+
+quint32 readLittleEndian32(QFile &file, qint64 offset)
+{
+ Q_ASSERT(offset >= 0);
+ QByteArray bytes(4, Qt::Uninitialized);
+ if (!file.seek(offset) || file.read(bytes.data(), 4) != 4) {
+ return 0;
+ }
+ return static_cast(static_cast(bytes[0]))
+ | (static_cast(static_cast(bytes[1])) << 8)
+ | (static_cast(static_cast(bytes[2])) << 16)
+ | (static_cast(static_cast(bytes[3])) << 24);
+}
+
+}
+
QString testFilePath(const QString& baseName, const QString& extension)
{
return QLatin1String(INDEXER_TESTS_OFFICE_FILES_PATH)
@@ -77,6 +171,258 @@ void OfficeExtractorTest::testContent()
SimpleExtractionResult result(fileName, mimeType);
plugin.extract(&result);
- QCOMPARE(result.text(), QStringLiteral("hwf"));
+ QCOMPARE(result.text(), QStringLiteral("hwf "));
+}
+
+void OfficeExtractorTest::testControlCharacterNormalization_data()
+{
+ QTest::addColumn("fileType");
+ QTest::addColumn("expectedContains");
+ QTest::addColumn("expectedNotContains");
+
+ QTest::addRow("Doc")
+ << QStringLiteral("doc")
+ << (QStringList{}
+ << QStringLiteral("DOC_VISIBLE")
+ << QStringLiteral("DOC_SOFTHYPHEN")
+ << QStringLiteral("DOC_NON-BREAK")
+ << QStringLiteral("DOC_VTA")
+ << QStringLiteral("VTB")
+ << QStringLiteral("DOC_FORMA")
+ << QStringLiteral("FORMB")
+ << QStringLiteral("DOC_COLA")
+ << QStringLiteral("COLB")
+ << QStringLiteral("DOC_TABLE_A DOC_TABLE_B"))
+ << (QStringList{}
+ << QString(QChar(0x001F))
+ << QStringLiteral("DOC_SOFT-HYPHEN")
+ << QStringLiteral("DOC_SOFTQHYPHEN")
+ << QStringLiteral("DOC_NONQBREAK")
+ << QStringLiteral("DOC_VTAQVTB")
+ << QStringLiteral("DOC_VTAVTB")
+ << QStringLiteral("DOC_FORMAQFORMB")
+ << QStringLiteral("DOC_FORMAFORMB")
+ << QStringLiteral("DOC_COLAQCOLB")
+ << QStringLiteral("DOC_COLACOLB"));
+
+ QTest::addRow("Xls")
+ << QStringLiteral("xls")
+ << (QStringList{}
+ << QStringLiteral("A\rB"))
+ << (QStringList{}
+ << QString(QChar(0x000B))
+ << QStringLiteral("AB"));
+
+ QTest::addRow("PPT")
+ << QStringLiteral("ppt")
+ << (QStringList{}
+ << QStringLiteral("PPT_VISIBLE_中")
+ << QStringLiteral("PPT_DROPA")
+ << QStringLiteral("DROPB_中")
+ << QStringLiteral("PPT_SEPA")
+ << QStringLiteral("SEPB_中")
+ << QStringLiteral("PPT_FORMA")
+ << QStringLiteral("FORMB_中"))
+ << (QStringList{}
+ << QString(QChar(0x0002))
+ << QStringLiteral("PPT_DROPAQDROPB_中")
+ << QStringLiteral("PPT_SEPAQSEPB_中")
+ << QStringLiteral("PPT_SEPASEPB_中")
+ << QStringLiteral("PPT_FORMAQFORMB_中")
+ << QStringLiteral("PPT_FORMAFORMB_中"));
}
+
+void OfficeExtractorTest::testControlCharacterNormalization()
+{
+ QFETCH(QString, fileType);
+ QFETCH(QStringList, expectedContains);
+ QFETCH(QStringList, expectedNotContains);
+
+ OfficeExtractor plugin{this};
+
+ QMimeDatabase mimeDb;
+ QString fileName = testFilePath(QStringLiteral("control-chars"), fileType);
+ QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+ QVERIFY(plugin.mimetypes().contains(plugin.getSupportedMimeType(mimeType)));
+
+ SimpleExtractionResult result(fileName, mimeType);
+ plugin.extract(&result);
+
+ const QString text = result.text();
+ for (const QString &expected : expectedContains) {
+ QVERIFY2(text.contains(expected),
+ qPrintable(QStringLiteral("Expected extracted text to contain '%1', got '%2'")
+ .arg(expected, text)));
+ }
+ for (const QString &unexpected : expectedNotContains) {
+ QVERIFY2(!text.contains(unexpected),
+ qPrintable(QStringLiteral("Expected extracted text not to contain '%1', got '%2'")
+ .arg(unexpected, text)));
+ }
+}
+
+void OfficeExtractorTest::testRejectsOversizedClx()
+{
+ static const int kFibClxOffset = 0x1a6;
+ static const QByteArray kOversizedClx = QByteArray::fromHex("00000001");
+ static const QList kFibSignatures = {
+ QByteArray::fromHex("9880"),
+ QByteArray::fromHex("9980"),
+ QByteArray::fromHex("dca5"),
+ QByteArray::fromHex("eca5"),
+ QByteArray::fromHex("97a6"),
+ QByteArray::fromHex("99a6"),
+ };
+
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc"));
+ const QString targetPath = tempDir.filePath(QStringLiteral("oversized-clx.doc"));
+ QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath));
+
+ QFile file(targetPath);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+
+ const QByteArray content = file.readAll();
+ QVERIFY(!content.isEmpty());
+
+ int fibOffset = -1;
+ for (const QByteArray &signature : kFibSignatures) {
+ fibOffset = content.indexOf(signature);
+ if (fibOffset >= 0) {
+ break;
+ }
+ }
+
+ QVERIFY2(fibOffset >= 0, "Failed to locate the WordDocument FIB header");
+ QVERIFY(fibOffset + kFibClxOffset + kOversizedClx.size() <= content.size());
+
+ file.seek(fibOffset + kFibClxOffset);
+ QCOMPARE(file.write(kOversizedClx), qint64(kOversizedClx.size()));
+ file.close();
+
+ const QString helperPath = QCoreApplication::applicationDirPath()
+ + QLatin1String("/OfficeExtractorHelper");
+ QVERIFY2(QFileInfo::exists(helperPath), qPrintable(helperPath));
+
+ QProcess process;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("GCOV_PREFIX"), tempDir.filePath(QStringLiteral("gcov")));
+ env.insert(QStringLiteral("GCOV_PREFIX_STRIP"), QStringLiteral("0"));
+ process.setProcessEnvironment(env);
+ process.start(helperPath, QStringList{targetPath});
+ QVERIFY(process.waitForStarted());
+ QVERIFY(process.waitForFinished(10000));
+
+ QCOMPARE(process.exitStatus(), QProcess::NormalExit);
+ QCOMPARE(process.exitCode(), 0);
+}
+
+void OfficeExtractorTest::testRejectsOversizedBbdCount()
+{
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString targetPath = tempDir.filePath(QStringLiteral("oversized-bbd-count.doc"));
+ createPatchedOfficeFile(targetPath, 0x2c, littleEndian32(0x20000000u));
+
+ runOfficeHelperWithMemoryLimit(targetPath);
+}
+
+void OfficeExtractorTest::testRejectsForgedSbdLength()
+{
+ // OLE 头部常量:BIG_BLOCK_SIZE=512,根目录起始块号在头部 0x30 处;
+ // root entry 内偏移 0x78 存的是 small-block 流的字节长度,解析时会被
+ // 除以 SMALL_BLOCK_SIZE(64) 得到 tSBDLen,再用于 xcalloc(tSBDLen, 8)。
+ //
+ // 该字段是 32 位,伪造的最大值 0xFFFFFFFF 会让解析尝试分配
+ // (0xFFFFFFFF / 64) * sizeof(ULONG) 字节。ULONG 在工程里定义为
+ // unsigned long,因此 32 位和 64 位平台上的申请大小不同。
+ // 为了让本用例真正锁住回归(而不是无论有无防护都通过),把 helper 的
+ // 虚拟内存限制设为这次伪造申请量的 3/4:
+ // - 正常解析 test.doc 只需不到 100 MiB,不受影响;
+ // - 这次伪造的分配在不同字长平台上都会失败。
+ // 若“字段一致性校验”和“分配判空”任一存在,解析会优雅返回;两者都被
+ // 移除时,xcalloc 失败后的空指针访问会让进程 SIGSEGV,用例随即失败。
+ static const qint64 kBigBlockSize = 512;
+ static const qint64 kRootStartOffset = 0x30;
+ static const qint64 kSbdSizeFieldOffset = 0x78;
+ static const quint32 kForgedStreamBytes = 0xFFFFFFFFu;
+ const quint64 forgedAllocationBytes =
+ (static_cast(kForgedStreamBytes) / 64) * sizeof(unsigned long);
+ const int kMemoryLimitKb = static_cast(forgedAllocationBytes * 3 / 4 / 1024);
+
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc"));
+ const QString targetPath = tempDir.filePath(QStringLiteral("forged-sbd-length.doc"));
+ QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath));
+
+ QFile file(targetPath);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+
+ const quint32 rootStart = readLittleEndian32(file, kRootStartOffset);
+ const qint64 sbdSizeOffset =
+ (static_cast(rootStart) + 1) * kBigBlockSize + kSbdSizeFieldOffset;
+ QVERIFY2(sbdSizeOffset + 4 <= file.size(),
+ "Patched SBD length field falls outside the sample file");
+
+ QVERIFY(file.seek(sbdSizeOffset));
+ QCOMPARE(file.write(littleEndian32(kForgedStreamBytes)), qint64(4));
+ file.close();
+
+ runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb);
+}
+
+void OfficeExtractorTest::testRejectsSparseOleWithInsufficientFat()
+{
+ static const qint64 kSparseOleSize = 5ll * 1024 * 1024 * 1024;
+ static const int kMemoryLimitKb = 393216;
+
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString targetPath = tempDir.filePath(QStringLiteral("sparse-insufficient-fat.doc"));
+ createResizedOfficeFile(targetPath, kSparseOleSize);
+
+ QString stderrText;
+ runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb, &stderrText);
+ QVERIFY2(stderrText.contains(QStringLiteral("Rejecting inconsistent OLE FAT")),
+ qPrintable(stderrText));
+}
+
+void OfficeExtractorTest::testRejectsOversizedOleDepotAllocation()
+{
+ if(sizeof(long) < 8) {
+ QSKIP("This regression path needs long to represent a sparse OLE file larger than 4 GiB.");
+ }
+
+ static const qint64 kSparseOleSize = 5ll * 1024 * 1024 * 1024;
+ static const qint64 kBigBlockSize = 512;
+ static const qint64 kBbdCountOffset = 0x2c;
+ static const int kMemoryLimitKb = 393216;
+
+ QTemporaryDir tempDir;
+ QVERIFY(tempDir.isValid());
+
+ const QString targetPath = tempDir.filePath(QStringLiteral("oversized-depot-allocation.doc"));
+ createResizedOfficeFile(targetPath, kSparseOleSize);
+
+ QFile file(targetPath);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+
+ const quint64 bbdLen = static_cast(kSparseOleSize / kBigBlockSize - 1);
+ const quint32 bbdBlocks = static_cast((bbdLen + 127) / 128);
+ QVERIFY(file.seek(kBbdCountOffset));
+ QCOMPARE(file.write(littleEndian32(bbdBlocks)), qint64(4));
+ file.close();
+
+ QString stderrText;
+ runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb, &stderrText);
+ QVERIFY2(stderrText.contains(QStringLiteral("Rejecting oversized OLE depot allocation")),
+ qPrintable(stderrText));
+}
+
QTEST_GUILESS_MAIN(OfficeExtractorTest)
diff --git a/autotests/office-extractortest.h b/autotests/office-extractortest.h
index 6252516fded17efb705025165c7646755c4a2cc1..9ff03d2beca93d21f5c18dec02fd4f02419a0e80 100644
--- a/autotests/office-extractortest.h
+++ b/autotests/office-extractortest.h
@@ -32,6 +32,13 @@ class OfficeExtractorTest : public QObject
private Q_SLOTS:
void testContent();
void testContent_data();
+ void testControlCharacterNormalization();
+ void testControlCharacterNormalization_data();
+ void testRejectsOversizedClx();
+ void testRejectsOversizedBbdCount();
+ void testRejectsForgedSbdLength();
+ void testRejectsSparseOleWithInsufficientFat();
+ void testRejectsOversizedOleDepotAllocation();
};
}
diff --git a/autotests/samplefiles/bookmarks/test_bookmark.xbel b/autotests/samplefiles/bookmarks/test_bookmark.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..2e122939fea170fbde02246e0a124f40e5a07eb2
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark.xbel
@@ -0,0 +1,45 @@
+
+
+ Root Bookmarks
+
+ Docs
+ Documents folder
+
+
+
+
+
+ pluma
+
+
+
+
+
+
+
+ favorite
+
+ true
+
+
+
+
+ 1234567890
+ 5
+
+
+
+ Favorites
+
+ Example
+ Example site
+
+
+
+
+
diff --git a/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel b/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..9c961153cd4f7df3a01ad03a1004698f7e94121c
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel
@@ -0,0 +1,27 @@
+
+
+
+ Freedesktop Metadata Bookmark
+ Spec-defined freedesktop metadata placement
+
+
+
+
+
+
+ true
+
+ files
+
+
+
+
+
+
+
+
+
diff --git a/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel b/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..a84efd671b125c09137e9208d7871f8370f66c72
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel
@@ -0,0 +1,15 @@
+
+
+
+ G1
+
+ v
+
+
+
+ G2
+
+ v
+
+
+
diff --git a/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel b/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..df9dadb3dedc28517e828bb78a0b6056c021b959
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel
@@ -0,0 +1,14 @@
+
+
+
+ Count
+
+
+
+
+
+
+
+
+
+
diff --git a/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel b/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..9b73256ae94efe08aa0d6243c7d6cc6eceb5f179
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel
@@ -0,0 +1,7 @@
+
+
+ NS Root
+
+ Entry
+
+
diff --git a/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel b/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel
new file mode 100644
index 0000000000000000000000000000000000000000..d29f25cadc820ac4454f1bff19ee6e841cc2eb2e
--- /dev/null
+++ b/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel
@@ -0,0 +1,20 @@
+
+
+ Owner Variants
+
+ Owner Variants Bookmark
+
+
+
+ variant-group
+
+
+
+
+
+
+
+ 42
+
+
+
diff --git a/autotests/samplefiles/officefiles/control-chars.doc b/autotests/samplefiles/officefiles/control-chars.doc
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/officefiles/control-chars.ppt b/autotests/samplefiles/officefiles/control-chars.ppt
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/officefiles/control-chars.xls b/autotests/samplefiles/officefiles/control-chars.xls
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/test_zipreader_cp437_default_names.zip b/autotests/samplefiles/test_zipreader_cp437_default_names.zip
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/test_zipreader_gbk_names.zip b/autotests/samplefiles/test_zipreader_gbk_names.zip
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/test_zipreader_unicode_path_extra.zip b/autotests/samplefiles/test_zipreader_unicode_path_extra.zip
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/test_zipreader_unicode_path_extra_bad_crc.zip b/autotests/samplefiles/test_zipreader_unicode_path_extra_bad_crc.zip
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/samplefiles/test_zipreader_utf8_names.zip b/autotests/samplefiles/test_zipreader_utf8_names.zip
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/autotests/zip-readertest.cpp b/autotests/zip-readertest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..eb17c4bedae0f7f273076361356003ce2b607d0e
--- /dev/null
+++ b/autotests/zip-readertest.cpp
@@ -0,0 +1,179 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#include "zip-readertest.h"
+#include "indexerextractortestsconfig.h"
+#include "zip-reader.h"
+
+#include
+#include
+#include
+
+using namespace UkuiFileMetadata;
+
+static_assert(!std::is_copy_constructible::value,
+ "ZipReader must not be copy constructible");
+static_assert(!std::is_copy_assignable::value,
+ "ZipReader must not be copy assignable");
+
+namespace {
+
+QString testFilePath(const QString &baseName, const QString &extension)
+{
+ return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH)
+ + QLatin1Char('/')
+ + baseName
+ + QLatin1Char('.')
+ + extension;
+}
+
+class ScopedEnvironmentValue
+{
+public:
+ ScopedEnvironmentValue(const char *name, const QByteArray &value)
+ : variableName(name)
+ , hadValue(qEnvironmentVariableIsSet(name))
+ , previousValue(qgetenv(name))
+ {
+ qputenv(variableName.constData(), value);
+ }
+
+ ~ScopedEnvironmentValue()
+ {
+ if (hadValue) {
+ qputenv(variableName.constData(), previousValue);
+ } else {
+ qunsetenv(variableName.constData());
+ }
+ }
+
+private:
+ QByteArray variableName;
+ bool hadValue = false;
+ QByteArray previousValue;
+};
+
+}
+
+void ZipReaderTest::testReadEntryReturnsFullContent()
+{
+ ZipReader reader(testFilePath(QStringLiteral("test_libreoffice"), QStringLiteral("docx")));
+ QVERIFY(reader.open());
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(QStringLiteral("word/document.xml"), &data, Qt::CaseSensitive));
+ QVERIFY(!data.isEmpty());
+ QVERIFY(data.contains("KFileMetaData"));
+}
+
+void ZipReaderTest::testProcessEntryCanStopEarly()
+{
+ ZipReader reader(testFilePath(QStringLiteral("test_libreoffice"), QStringLiteral("docx")));
+ QVERIFY(reader.open());
+
+ QByteArray prefix;
+ const bool ok = reader.processEntry(QStringLiteral("word/document.xml"),
+ [&prefix](QIODevice *device) {
+ prefix = device->read(32);
+ return prefix.size() == 32;
+ },
+ Qt::CaseSensitive);
+
+ QVERIFY(ok);
+ QCOMPARE(prefix.size(), 32);
+}
+
+void ZipReaderTest::testCp437EntryNamesUseZipDefaultCodec()
+{
+ const QString expectedEntryName = QStringLiteral("ü@.txt");
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_cp437_default_names"), QStringLiteral("zip")));
+ QVERIFY(reader.open());
+
+ QCOMPARE(reader.entryNames(), QStringList({expectedEntryName}));
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(expectedEntryName, &data));
+ QCOMPARE(data, QByteArray("cp437-default-content"));
+}
+
+void ZipReaderTest::testDefaultFallbackCodecsDoNotDependOnLocaleLanguage()
+{
+ ScopedEnvironmentValue lcAll("LC_ALL", QByteArrayLiteral("C.UTF-8"));
+ ScopedEnvironmentValue lang("LANG", QByteArrayLiteral("zh_CN.UTF-8"));
+
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_cp437_default_names"), QStringLiteral("zip")));
+ QCOMPARE(reader.fallbackFileNameCodecs(), QList({QByteArrayLiteral("CP437")}));
+}
+
+void ZipReaderTest::testUnicodePathExtraFieldTakesPrecedence()
+{
+ const QString expectedEntryName = QStringLiteral("目录/测试.txt");
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_unicode_path_extra"), QStringLiteral("zip")));
+ reader.setFallbackFileNameCodecs({QByteArrayLiteral("CP437")});
+ QVERIFY(reader.open());
+
+ QCOMPARE(reader.entryNames(), QStringList({expectedEntryName}));
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(expectedEntryName, &data));
+ QCOMPARE(data, QByteArray("unicode-extra-content"));
+}
+
+void ZipReaderTest::testInvalidUnicodePathExtraFieldFallsBackToConfiguredCodec()
+{
+ const QString expectedEntryName = QStringLiteral("目录/测试.txt");
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_unicode_path_extra_bad_crc"), QStringLiteral("zip")));
+ reader.setFallbackFileNameCodecs({QByteArrayLiteral("GB18030")});
+ QVERIFY(reader.open());
+
+ QCOMPARE(reader.entryNames(), QStringList({expectedEntryName}));
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(expectedEntryName, &data));
+ QCOMPARE(data, QByteArray("unicode-extra-bad-crc-content"));
+}
+
+void ZipReaderTest::testUtf8EntryNamesRoundTrip()
+{
+ const QString expectedEntryName = QStringLiteral("目录/测试.txt");
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_utf8_names"), QStringLiteral("zip")));
+ QVERIFY(reader.open());
+
+ QCOMPARE(reader.entryNames(), QStringList({expectedEntryName}));
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(expectedEntryName, &data));
+ QCOMPARE(data, QByteArray("utf8-content"));
+}
+
+void ZipReaderTest::testLegacyEntryNamesUseFallbackCodecs()
+{
+ const QString expectedEntryName = QStringLiteral("目录/测试.txt");
+ ZipReader reader(testFilePath(QStringLiteral("test_zipreader_gbk_names"), QStringLiteral("zip")));
+ reader.setFallbackFileNameCodecs({QByteArrayLiteral("GB18030")});
+ QVERIFY(reader.open());
+
+ QCOMPARE(reader.entryNames(), QStringList({expectedEntryName}));
+
+ QByteArray data;
+ QVERIFY(reader.readEntry(expectedEntryName, &data));
+ QCOMPARE(data, QByteArray("gbk-content"));
+}
+
+QTEST_GUILESS_MAIN(ZipReaderTest)
diff --git a/autotests/zip-readertest.h b/autotests/zip-readertest.h
new file mode 100644
index 0000000000000000000000000000000000000000..16ea0bbc61fa201087d95fbeaee7e19b2fa1b53d
--- /dev/null
+++ b/autotests/zip-readertest.h
@@ -0,0 +1,44 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef UKUI_FILE_METADATA_ZIPREADERTEST_H
+#define UKUI_FILE_METADATA_ZIPREADERTEST_H
+
+#include
+
+namespace UkuiFileMetadata {
+
+class ZipReaderTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testReadEntryReturnsFullContent();
+ void testProcessEntryCanStopEarly();
+ void testCp437EntryNamesUseZipDefaultCodec();
+ void testDefaultFallbackCodecsDoNotDependOnLocaleLanguage();
+ void testUnicodePathExtraFieldTakesPrecedence();
+ void testInvalidUnicodePathExtraFieldFallsBackToConfiguredCodec();
+ void testUtf8EntryNamesRoundTrip();
+ void testLegacyEntryNamesUseFallbackCodecs();
+};
+
+}
+
+#endif // UKUI_FILE_METADATA_ZIPREADERTEST_H
diff --git a/debian/control b/debian/control
index ca9bdf10c89b15e0d2fa266ca3f684386f9dea6b..1d8f6c6fbfa0cc7b53751d7b4b6c503ba4a89476 100644
--- a/debian/control
+++ b/debian/control
@@ -7,7 +7,6 @@ Build-Depends: debhelper-compat (= 12),
qtbase5-dev,
qtchooser,
qtscript5-dev,
- libquazip5-dev(>=0.7.6-6build1),
libuchardet-dev,
libpoppler-qt5-dev,
libavcodec-dev,
@@ -16,6 +15,7 @@ Build-Depends: debhelper-compat (= 12),
libswscale-dev,
libtesseract-dev,
libleptonica-dev,
+ libminizip-dev,
cmake,
qttools5-dev,
libtag1-dev
@@ -29,6 +29,7 @@ Package: libukui-file-metadata1
Section: libs
Architecture: any
Depends: libukui-file-metadata-bin (= ${binary:Version}),
+ tesseract-ocr-chi-sim,
${misc:Depends},
${shlibs:Depends},
Description: The file metadata utils of UKUI desktop environment.
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 019344854ec88466225f62eaf2867b595b3060ec..5afab4578eb5614c26dc995d9aa9fa461a6644c7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -33,6 +33,9 @@ set(HEADERS
property-info.h
thumbnail.h
ocr-utils.h
+ zip-reader.h
+ bookmark.h
+ bookmarks-manager.h
)
set(ukui-file-metadata_SRCS
@@ -51,20 +54,36 @@ set(ukui-file-metadata_SRCS
thumbnail-utils.cpp
thumbnail-utils.h
ocr-utils.cpp
- ocr-utils.h)
+ ocr-utils.h
+ zip-reader.cpp
+ zip-reader.h
+ bookmark.cpp
+ bookmark.h
+ bookmarks-manager.cpp
+ bookmarks-manager.h)
add_library(ukui-file-metadata SHARED
${ukui-file-metadata_SRCS}
)
-target_link_libraries(ukui-file-metadata PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::Widgets tesseract)
+target_link_libraries(ukui-file-metadata PUBLIC
+ Qt${QT_VERSION_MAJOR}::Core
+ Qt${QT_VERSION_MAJOR}::Gui
+ Qt${QT_VERSION_MAJOR}::Xml
+ Qt${QT_VERSION_MAJOR}::Widgets
+ PRIVATE
+ PkgConfig::TESSERACT
+ PkgConfig::LEPT
+ PkgConfig::MINIZIP)
include(CMakePackageConfigHelpers)
set(CMAKE_CONFIG_INSTALL_DIR "/usr/share/cmake/ukui-file-metadata")
set(HEADERS_INSTALL_DIR /usr/include/ukui-file-metadata)
set(PC_INSTALL_DIR "/usr/lib/pkgconfig")
-target_include_directories(ukui-file-metadata PUBLIC $)
+target_include_directories(ukui-file-metadata PUBLIC
+ $
+ $)
configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/pkgconfig/ukui-file-metadata.pc.in
${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata.pc
@@ -86,6 +105,9 @@ set_target_properties(ukui-file-metadata PROPERTIES
OUTPUT_NAME ukui-file-metadata
)
+export(TARGETS ukui-file-metadata
+ FILE "${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-targets.cmake")
+
if(COMMAND qt_create_translation)
qt_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES})
else()
@@ -107,4 +129,3 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata.pc DESTINATION ${PC
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-config.cmake
${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-config-version.cmake
DESTINATION ${CMAKE_CONFIG_INSTALL_DIR})
-
diff --git a/src/bookmark.cpp b/src/bookmark.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..59e02d1134c6342e12fa87ea72b51b617017b995
--- /dev/null
+++ b/src/bookmark.cpp
@@ -0,0 +1,422 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#include "bookmark.h"
+
+#include
+
+namespace UkuiFileMetadata {
+
+class FreedesktopApplicationInfoPrivate : public QSharedData
+{
+public:
+ QString href;
+ QString name;
+ QString exec;
+ QString modified;
+ int count = -1;
+};
+
+FreedesktopApplicationInfo::FreedesktopApplicationInfo()
+ : d(new FreedesktopApplicationInfoPrivate)
+{
+}
+
+FreedesktopApplicationInfo::FreedesktopApplicationInfo(const FreedesktopApplicationInfo &other) = default;
+
+FreedesktopApplicationInfo &FreedesktopApplicationInfo::operator=(const FreedesktopApplicationInfo &other) = default;
+
+FreedesktopApplicationInfo::FreedesktopApplicationInfo(FreedesktopApplicationInfo &&other) noexcept = default;
+
+FreedesktopApplicationInfo &FreedesktopApplicationInfo::operator=(FreedesktopApplicationInfo &&other) noexcept = default;
+
+FreedesktopApplicationInfo::~FreedesktopApplicationInfo() = default;
+
+QString FreedesktopApplicationInfo::href() const
+{
+ return d->href;
+}
+
+QString FreedesktopApplicationInfo::name() const
+{
+ return d->name;
+}
+
+QString FreedesktopApplicationInfo::exec() const
+{
+ return d->exec;
+}
+
+QString FreedesktopApplicationInfo::modified() const
+{
+ return d->modified;
+}
+
+int FreedesktopApplicationInfo::count() const
+{
+ return d->count;
+}
+
+void FreedesktopApplicationInfo::setHref(const QString &value)
+{
+ d->href = value;
+}
+
+void FreedesktopApplicationInfo::setName(const QString &value)
+{
+ d->name = value;
+}
+
+void FreedesktopApplicationInfo::setExec(const QString &value)
+{
+ d->exec = value;
+}
+
+void FreedesktopApplicationInfo::setModified(const QString &value)
+{
+ d->modified = value;
+}
+
+void FreedesktopApplicationInfo::setCount(int value)
+{
+ d->count = value;
+}
+
+class BookMarkPrivate : public QSharedData
+{
+public:
+ QString id;
+ QString added;
+ QString modified;
+ QString visited;
+ QString href;
+ QString title;
+ QString description;
+ QString showInToolbar;
+ QString iconName;
+ QString iconHref;
+ QString iconType;
+ QString mimeType;
+ bool privateFlag = false;
+ QHash kdeMetaData;
+};
+
+BookMark::BookMark()
+ : d(new BookMarkPrivate)
+{
+}
+
+BookMark::BookMark(const BookMark &other) = default;
+
+BookMark &BookMark::operator=(const BookMark &other) = default;
+
+BookMark::BookMark(BookMark &&other) noexcept = default;
+
+BookMark &BookMark::operator=(BookMark &&other) noexcept = default;
+
+BookMark::~BookMark() = default;
+
+QString BookMark::id() const
+{
+ return d->id;
+}
+
+QString BookMark::added() const
+{
+ return d->added;
+}
+
+QString BookMark::modified() const
+{
+ return d->modified;
+}
+
+QString BookMark::visited() const
+{
+ return d->visited;
+}
+
+QString BookMark::href() const
+{
+ return d->href;
+}
+
+QString BookMark::title() const
+{
+ return d->title;
+}
+
+QString BookMark::description() const
+{
+ return d->description;
+}
+
+QString BookMark::showInToolbar() const
+{
+ return d->showInToolbar;
+}
+
+QString BookMark::iconName() const
+{
+ return d->iconName;
+}
+
+QString BookMark::iconHref() const
+{
+ return d->iconHref;
+}
+
+QString BookMark::iconType() const
+{
+ return d->iconType;
+}
+
+QString BookMark::mimeType() const
+{
+ return d->mimeType;
+}
+
+bool BookMark::privateFlag() const
+{
+ return d->privateFlag;
+}
+
+const QHash& BookMark::kdeMetaData() const
+{
+ return d->kdeMetaData;
+}
+
+void BookMark::setId(const QString &value)
+{
+ d->id = value;
+}
+
+void BookMark::setAdded(const QString &value)
+{
+ d->added = value;
+}
+
+void BookMark::setModified(const QString &value)
+{
+ d->modified = value;
+}
+
+void BookMark::setVisited(const QString &value)
+{
+ d->visited = value;
+}
+
+void BookMark::setHref(const QString &value)
+{
+ d->href = value;
+}
+
+void BookMark::setTitle(const QString &value)
+{
+ d->title = value;
+}
+
+void BookMark::setDescription(const QString &value)
+{
+ d->description = value;
+}
+
+void BookMark::setShowInToolbar(const QString &value)
+{
+ d->showInToolbar = value;
+}
+
+void BookMark::setIconName(const QString &value)
+{
+ d->iconName = value;
+}
+
+void BookMark::setIconHref(const QString &value)
+{
+ d->iconHref = value;
+}
+
+void BookMark::setIconType(const QString &value)
+{
+ d->iconType = value;
+}
+
+void BookMark::setMimeType(const QString &value)
+{
+ d->mimeType = value;
+}
+
+void BookMark::setPrivateFlag(bool value)
+{
+ d->privateFlag = value;
+}
+
+void BookMark::addKdeMetaValue(const QString &key, const QString &value)
+{
+ if (key.isEmpty() || value.isEmpty()) {
+ return;
+ }
+ d->kdeMetaData[key].append(value);
+}
+
+class BookMarksGroupPrivate : public QSharedData
+{
+public:
+ QString id;
+ QString added;
+ QString modified;
+ QString visited;
+ QString title;
+ QString description;
+ QString folded;
+ QString toolbar;
+ QList bookmarks;
+ QList groups;
+ QHash kdeMetaData;
+};
+
+BookMarksGroup::BookMarksGroup()
+ : d(new BookMarksGroupPrivate)
+{
+}
+
+BookMarksGroup::BookMarksGroup(const BookMarksGroup &other) = default;
+
+BookMarksGroup &BookMarksGroup::operator=(const BookMarksGroup &other) = default;
+
+BookMarksGroup::BookMarksGroup(BookMarksGroup &&other) noexcept = default;
+
+BookMarksGroup &BookMarksGroup::operator=(BookMarksGroup &&other) noexcept = default;
+
+BookMarksGroup::~BookMarksGroup() = default;
+
+QString BookMarksGroup::id() const
+{
+ return d->id;
+}
+
+QString BookMarksGroup::added() const
+{
+ return d->added;
+}
+
+QString BookMarksGroup::modified() const
+{
+ return d->modified;
+}
+
+QString BookMarksGroup::visited() const
+{
+ return d->visited;
+}
+
+QString BookMarksGroup::title() const
+{
+ return d->title;
+}
+
+QString BookMarksGroup::description() const
+{
+ return d->description;
+}
+
+QString BookMarksGroup::folded() const
+{
+ return d->folded;
+}
+
+QString BookMarksGroup::toolbar() const
+{
+ return d->toolbar;
+}
+
+const QList& BookMarksGroup::bookmarks() const
+{
+ return d->bookmarks;
+}
+
+const QList& BookMarksGroup::groups() const
+{
+ return d->groups;
+}
+
+const QHash& BookMarksGroup::kdeMetaData() const
+{
+ return d->kdeMetaData;
+}
+
+void BookMarksGroup::setId(const QString &value)
+{
+ d->id = value;
+}
+
+void BookMarksGroup::setAdded(const QString &value)
+{
+ d->added = value;
+}
+
+void BookMarksGroup::setModified(const QString &value)
+{
+ d->modified = value;
+}
+
+void BookMarksGroup::setVisited(const QString &value)
+{
+ d->visited = value;
+}
+
+void BookMarksGroup::setTitle(const QString &value)
+{
+ d->title = value;
+}
+
+void BookMarksGroup::setDescription(const QString &value)
+{
+ d->description = value;
+}
+
+void BookMarksGroup::setFolded(const QString &value)
+{
+ d->folded = value;
+}
+
+void BookMarksGroup::setToolbar(const QString &value)
+{
+ d->toolbar = value;
+}
+
+void BookMarksGroup::addBookmark(const BookMark &bookmark)
+{
+ d->bookmarks.append(bookmark);
+}
+
+void BookMarksGroup::addGroup(const BookMarksGroup &group)
+{
+ d->groups.append(group);
+}
+
+void BookMarksGroup::addKdeMetaValue(const QString &key, const QString &value)
+{
+ if (key.isEmpty() || value.isEmpty()) {
+ return;
+ }
+ d->kdeMetaData[key].append(value);
+}
+
+} // namespace UkuiFileMetadata
diff --git a/src/bookmark.h b/src/bookmark.h
new file mode 100644
index 0000000000000000000000000000000000000000..6e8df3c81fbd8d75dd922572fa1e379ec160c4d6
--- /dev/null
+++ b/src/bookmark.h
@@ -0,0 +1,201 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef UKUI_FILE_METADATA_BOOKMARK_H
+#define UKUI_FILE_METADATA_BOOKMARK_H
+
+#include "ukui-file-metadata_global.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace UkuiFileMetadata {
+
+class FreedesktopApplicationInfoPrivate;
+
+class UKUIFILEMETADATA_EXPORT FreedesktopApplicationInfo
+{
+public:
+ FreedesktopApplicationInfo();
+ FreedesktopApplicationInfo(const FreedesktopApplicationInfo &other);
+ FreedesktopApplicationInfo &operator=(const FreedesktopApplicationInfo &other);
+ FreedesktopApplicationInfo(FreedesktopApplicationInfo &&other) noexcept;
+ FreedesktopApplicationInfo &operator=(FreedesktopApplicationInfo &&other) noexcept;
+ ~FreedesktopApplicationInfo();
+
+ QString href() const;
+ QString name() const;
+ QString exec() const;
+ QString modified() const;
+ int count() const;
+
+ void setHref(const QString &value);
+ void setName(const QString &value);
+ void setExec(const QString &value);
+ void setModified(const QString &value);
+ void setCount(int value);
+
+private:
+ QSharedDataPointer d;
+};
+
+class BookMarkPrivate;
+
+class UKUIFILEMETADATA_EXPORT BookMark
+{
+public:
+ BookMark();
+ BookMark(const BookMark &other);
+ BookMark &operator=(const BookMark &other);
+ BookMark(BookMark &&other) noexcept;
+ BookMark &operator=(BookMark &&other) noexcept;
+ ~BookMark();
+
+ QString id() const;
+ QString added() const;
+ QString modified() const;
+ QString visited() const;
+
+ QString href() const;
+ QString title() const;
+ QString description() const;
+ QString showInToolbar() const;
+
+ /**
+ * @brief 获取 freedesktop `bookmark:icon` 的主题图标名称。
+ * @return `name` 属性值;若不存在返回空字符串。
+ */
+ QString iconName() const;
+
+ /**
+ * @brief 获取 freedesktop `bookmark:icon` 的图标资源地址。
+ * @return `href` 属性值;若不存在返回空字符串。
+ */
+ QString iconHref() const;
+
+ /**
+ * @brief 获取 freedesktop `bookmark:icon` 的图标 MIME 类型。
+ * @return `type` 属性值;若不存在返回空字符串。
+ */
+ QString iconType() const;
+
+ QString mimeType() const;
+
+ /**
+ * @brief 获取 freedesktop `bookmark:private` 标记。
+ * @return `true` 表示书签被标记为 private,否则返回 `false`。
+ */
+ bool privateFlag() const;
+
+ const QHash& kdeMetaData() const;
+
+ void setId(const QString &value);
+ void setAdded(const QString &value);
+ void setModified(const QString &value);
+ void setVisited(const QString &value);
+
+ void setHref(const QString &value);
+ void setTitle(const QString &value);
+ void setDescription(const QString &value);
+ void setShowInToolbar(const QString &value);
+
+ /**
+ * @brief 设置 freedesktop `bookmark:icon` 的主题图标名称。
+ * @param value `name` 属性值。
+ */
+ void setIconName(const QString &value);
+
+ /**
+ * @brief 设置 freedesktop `bookmark:icon` 的图标资源地址。
+ * @param value `href` 属性值。
+ */
+ void setIconHref(const QString &value);
+
+ /**
+ * @brief 设置 freedesktop `bookmark:icon` 的图标 MIME 类型。
+ * @param value `type` 属性值。
+ */
+ void setIconType(const QString &value);
+
+ void setMimeType(const QString &value);
+
+ /**
+ * @brief 设置 freedesktop `bookmark:private` 标记。
+ * @param value 是否标记为 private。
+ */
+ void setPrivateFlag(bool value);
+
+ void addKdeMetaValue(const QString &key, const QString &value);
+
+private:
+ QSharedDataPointer d;
+};
+
+class BookMarksGroupPrivate;
+
+class UKUIFILEMETADATA_EXPORT BookMarksGroup
+{
+public:
+ BookMarksGroup();
+ BookMarksGroup(const BookMarksGroup &other);
+ BookMarksGroup &operator=(const BookMarksGroup &other);
+ BookMarksGroup(BookMarksGroup &&other) noexcept;
+ BookMarksGroup &operator=(BookMarksGroup &&other) noexcept;
+ ~BookMarksGroup();
+
+ QString id() const;
+ QString added() const;
+ QString modified() const;
+ QString visited() const;
+
+ QString title() const;
+ QString description() const;
+ QString folded() const;
+ QString toolbar() const;
+
+ const QList& bookmarks() const;
+ const QList& groups() const;
+
+ const QHash& kdeMetaData() const;
+
+ void setId(const QString &value);
+ void setAdded(const QString &value);
+ void setModified(const QString &value);
+ void setVisited(const QString &value);
+
+ void setTitle(const QString &value);
+ void setDescription(const QString &value);
+ void setFolded(const QString &value);
+ void setToolbar(const QString &value);
+
+ void addBookmark(const BookMark &bookmark);
+ void addGroup(const BookMarksGroup &group);
+
+ void addKdeMetaValue(const QString &key, const QString &value);
+
+private:
+ QSharedDataPointer d;
+};
+
+} // namespace UkuiFileMetadata
+
+#endif // UKUI_FILE_METADATA_BOOKMARK_H
diff --git a/src/bookmarks-manager.cpp b/src/bookmarks-manager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5fd0937ba6fcd40198e0430fee53288db228cf08
--- /dev/null
+++ b/src/bookmarks-manager.cpp
@@ -0,0 +1,1389 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#include "bookmarks-manager.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace UkuiFileMetadata {
+
+static const QString KDE_OWNER_HOST = QStringLiteral("kde.org");
+static const QString FREEDESKTOP_OWNER_HOST = QStringLiteral("freedesktop.org");
+
+static QString normalizeOwnerHost(const QString &host)
+{
+ QString normalized = host.trimmed().toLower();
+ if (normalized.startsWith(QLatin1String("www."))) {
+ normalized = normalized.mid(4);
+ }
+ return normalized;
+}
+
+static QString normalizedOwnerHost(const QString &owner)
+{
+ const QString trimmed = owner.trimmed();
+ if (trimmed.isEmpty()) {
+ return QString();
+ }
+
+ const QUrl url(trimmed);
+ if (url.isValid() && !url.scheme().isEmpty() && !url.host().isEmpty()) {
+ return normalizeOwnerHost(url.host());
+ }
+
+ QString fallback = trimmed;
+ if (fallback.startsWith(QLatin1String("http://"), Qt::CaseInsensitive)) {
+ fallback = fallback.mid(7);
+ } else if (fallback.startsWith(QLatin1String("https://"), Qt::CaseInsensitive)) {
+ fallback = fallback.mid(8);
+ }
+
+ int pathStart = fallback.indexOf(QLatin1Char('/'));
+ const int queryStart = fallback.indexOf(QLatin1Char('?'));
+ const int fragmentStart = fallback.indexOf(QLatin1Char('#'));
+ if (queryStart >= 0 && (pathStart < 0 || queryStart < pathStart)) {
+ pathStart = queryStart;
+ }
+ if (fragmentStart >= 0 && (pathStart < 0 || fragmentStart < pathStart)) {
+ pathStart = fragmentStart;
+ }
+ if (pathStart >= 0) {
+ fallback.truncate(pathStart);
+ }
+
+ const int portSeparator = fallback.indexOf(QLatin1Char(':'));
+ if (portSeparator >= 0) {
+ fallback.truncate(portSeparator);
+ }
+
+ return normalizeOwnerHost(fallback);
+}
+
+static bool ownerMatchesHost(const QString &owner, const QString &host)
+{
+ return normalizedOwnerHost(owner) == host;
+}
+
+static QString elementName(const QDomElement &element)
+{
+ QString name = element.localName();
+ if (!name.isEmpty()) {
+ return name;
+ }
+
+ name = element.tagName();
+ const int colon = name.indexOf(QLatin1Char(':'));
+ return colon < 0 ? name : name.mid(colon + 1);
+}
+
+static bool isStandardAttributeForElement(const QString &elementKind, const QString &attributeName)
+{
+ if (elementKind == QLatin1String("xbel")) {
+ static const QSet xbelAttrs = {
+ QStringLiteral("version"), QStringLiteral("id"),
+ QStringLiteral("added"), QStringLiteral("dbusName")
+ };
+ return xbelAttrs.contains(attributeName);
+ }
+ if (elementKind == QLatin1String("folder")) {
+ static const QSet folderAttrs = {
+ QStringLiteral("id"), QStringLiteral("added"),
+ QStringLiteral("modified"), QStringLiteral("visited"),
+ QStringLiteral("folded"), QStringLiteral("toolbar")
+ };
+ return folderAttrs.contains(attributeName);
+ }
+ if (elementKind == QLatin1String("bookmark")) {
+ static const QSet bookmarkAttrs = {
+ QStringLiteral("id"), QStringLiteral("added"),
+ QStringLiteral("modified"), QStringLiteral("visited"),
+ QStringLiteral("href"), QStringLiteral("showintoolbar")
+ };
+ return bookmarkAttrs.contains(attributeName);
+ }
+ if (elementKind == QLatin1String("alias")) {
+ return attributeName == QLatin1String("ref");
+ }
+ return false;
+}
+
+static bool parseFreedesktopBooleanValue(const QString &value, bool &result)
+{
+ const QString normalized = value.trimmed().toLower();
+ if (normalized == QLatin1String("true") || normalized == QLatin1String("yes") || normalized == QLatin1String("1")) {
+ result = true;
+ return true;
+ }
+ if (normalized == QLatin1String("false") || normalized == QLatin1String("no") || normalized == QLatin1String("0")) {
+ result = false;
+ return true;
+ }
+ return false;
+}
+
+class BookMarksTextCollector
+{
+public:
+ void reset();
+ void appendRawTextLine(const QString &value);
+ void appendLabeledTextLine(const QString &label, const QString &value);
+ void appendCustomMetaLine(const QString &owner, const QString &href, const QString &nodePath, const QString &path, const QString &value);
+ void appendExtendedAttributeLine(const QString &elementKind, const QString &attributeName, const QString &value);
+ QStringList textLines(const QString &dbusName) const;
+ QStringList customMetaLines() const;
+ QStringList extendedAttributeLines() const;
+
+private:
+ QStringList m_textLines;
+ QStringList m_customMetaLines;
+ QStringList m_extendedAttributeLines;
+ QSet m_seenCustomMetaKeys;
+ QSet m_seenExtendedAttributeLines;
+};
+
+class BookMarksManagerPrivate
+{
+public:
+ explicit BookMarksManagerPrivate(BookMarksManager *q);
+
+ bool isValid() const;
+ QString filePath() const;
+ void setFilePath(const QString &filePath);
+ bool save();
+ bool saveAs(const QString &filePath);
+ QString rootTitle() const;
+ QString dbusName() const;
+ BookMarksGroup rootGroup() const;
+ QStringList textLines() const;
+ QStringList customMetaLines() const;
+ QStringList extendedAttributeLines() const;
+ QHash freedesktopGroups() const;
+ QStringList freedesktopGroups(const QString &href) const;
+ QList freedesktopApplications() const;
+ QList freedesktopApplications(const QString &href) const;
+ QString lastError() const;
+ int errorLine() const;
+ int errorColumn() const;
+
+private:
+ // Watcher
+ void initializeFileWatcher();
+ void updateWatchedFile(const QString &filePath);
+ void handleWatchedFileChanged(const QString &changedPath);
+ void handleWatchedDirectoryChanged(const QString &changedPath);
+ bool sendError(const QString &message, int line = -1, int column = -1);
+ void emitParseError();
+ void emitChanged();
+
+ // Parser
+ bool parse(bool &contentChanged);
+ bool parseFile(const QString &filePath);
+ void reset();
+ void parseXbelAttributes(const QDomElement &root);
+ void collectExtendedAttributes(const QDomElement &element, const QString &elementKind);
+ void parseInfoElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath);
+ void parseInfoElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath);
+ void parseMetaDataElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath);
+ void parseMetaDataElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath);
+ BookMarksGroup parseGroupElement(const QDomElement &element, const QString &groupPath);
+ BookMark parseBookmarkElement(const QDomElement &element, const QString &bookmarkPath);
+ void collectMetadataContent(const QDomElement &metadataElement, const QString &owner, const QString &href, const QString &nodePath, const QString &basePath);
+ void collectMetadataElementAttributes(const QDomElement &element, const QString &owner, const QString &href, const QString &nodePath, const QString &path);
+ void parseFreedesktopMetadataNode(const QDomElement &element, BookMark &bookmark);
+ void addFreedesktopGroup(const QString &href, const QString &group);
+ void addFreedesktopApplication(const FreedesktopApplicationInfo &appInfo);
+
+ // State
+ // Watcher state
+ QFileSystemWatcher m_fileWatcher;
+
+ // Parse state
+ BookMarksManager *q = nullptr;
+ mutable QReadWriteLock m_lock;
+ QString m_filePath;
+ bool m_valid = false;
+ QString m_lastError;
+ int m_errorLine = -1;
+ int m_errorColumn = -1;
+
+ QString m_rootTitle;
+ QString m_dbusName;
+ BookMarksGroup m_rootGroup;
+ QDomDocument m_document;
+ QByteArray m_contentDigest;
+ BookMarksTextCollector m_textCollector;
+ QHash m_freedesktopGroupsByHref;
+ QList m_freedesktopApplications;
+};
+
+void BookMarksTextCollector::reset()
+{
+ m_textLines.clear();
+ m_customMetaLines.clear();
+ m_extendedAttributeLines.clear();
+ m_seenCustomMetaKeys.clear();
+ m_seenExtendedAttributeLines.clear();
+}
+
+void BookMarksTextCollector::appendRawTextLine(const QString &value)
+{
+ if (!value.isEmpty()) {
+ m_textLines.append(value);
+ }
+}
+
+void BookMarksTextCollector::appendLabeledTextLine(const QString &label, const QString &value)
+{
+ if (!label.isEmpty() && !value.isEmpty()) {
+ m_textLines.append(label + QLatin1String(": ") + value);
+ }
+}
+
+void BookMarksTextCollector::appendCustomMetaLine(
+ const QString &owner, const QString &href, const QString &nodePath, const QString &path, const QString &value)
+{
+ if (path.isEmpty() || value.isEmpty()) {
+ return;
+ }
+
+ const QString ownerPart = owner.isEmpty() ? QStringLiteral("unknown-owner") : owner;
+ const QString line = !href.isEmpty()
+ ? QStringLiteral("metadata[%1][%2].%3: %4").arg(ownerPart, href, path, value)
+ : QStringLiteral("metadata[%1].%2: %3").arg(ownerPart, path, value);
+ const QString dedupeKey = ownerPart + QLatin1Char('|') + href + QLatin1Char('|') + nodePath + QLatin1Char('|')
+ + path + QLatin1Char('|') + value;
+ if (m_seenCustomMetaKeys.contains(dedupeKey)) {
+ return;
+ }
+ m_seenCustomMetaKeys.insert(dedupeKey);
+ m_customMetaLines.append(line);
+}
+
+void BookMarksTextCollector::appendExtendedAttributeLine(const QString &elementKind, const QString &attributeName, const QString &value)
+{
+ if (elementKind.isEmpty() || attributeName.isEmpty() || value.isEmpty()) {
+ return;
+ }
+
+ const QString line = QStringLiteral("attribute[%1].%2: %3").arg(elementKind, attributeName, value);
+ if (m_seenExtendedAttributeLines.contains(line)) {
+ return;
+ }
+
+ m_seenExtendedAttributeLines.insert(line);
+ m_extendedAttributeLines.append(line);
+}
+
+QStringList BookMarksTextCollector::textLines(const QString &dbusName) const
+{
+ QStringList lines;
+ if (!dbusName.isEmpty()) {
+ lines.append(QStringLiteral("dbusName: ") + dbusName);
+ }
+ lines.append(m_textLines);
+ lines.append(m_customMetaLines);
+ lines.append(m_extendedAttributeLines);
+ return lines;
+}
+
+QStringList BookMarksTextCollector::customMetaLines() const
+{
+ return m_customMetaLines;
+}
+
+QStringList BookMarksTextCollector::extendedAttributeLines() const
+{
+ return m_extendedAttributeLines;
+}
+
+static void applyBookmarkIconFields(BookMark &bookmark, BookMarksTextCollector &collector, const QDomElement &element, bool onlyIfUnset)
+{
+ const QString iconName = element.attribute(QLatin1String("name")).trimmed();
+ const QString iconHref = element.attribute(QLatin1String("href")).trimmed();
+ const QString iconType = element.attribute(QLatin1String("type")).trimmed();
+
+ if (!iconName.isEmpty() && (!onlyIfUnset || bookmark.iconName().isEmpty())) {
+ bookmark.setIconName(iconName);
+ collector.appendLabeledTextLine(QStringLiteral("icon"), iconName);
+ }
+ if (!iconHref.isEmpty() && (!onlyIfUnset || bookmark.iconHref().isEmpty())) {
+ bookmark.setIconHref(iconHref);
+ collector.appendLabeledTextLine(QStringLiteral("icon-href"), iconHref);
+ }
+ if (!iconType.isEmpty() && (!onlyIfUnset || bookmark.iconType().isEmpty())) {
+ bookmark.setIconType(iconType);
+ collector.appendLabeledTextLine(QStringLiteral("icon-type"), iconType);
+ }
+}
+
+static void applyBookmarkMimeType(BookMark &bookmark, BookMarksTextCollector &collector, const QString &mimeType, bool onlyIfUnset)
+{
+ if (mimeType.isEmpty() || (onlyIfUnset && !bookmark.mimeType().isEmpty())) {
+ return;
+ }
+
+ bookmark.setMimeType(mimeType);
+ collector.appendLabeledTextLine(QStringLiteral("mime-type"), mimeType);
+}
+
+static void applyBookmarkPrivateFlag(BookMark &bookmark, BookMarksTextCollector &collector, bool isPrivate)
+{
+ bookmark.setPrivateFlag(isPrivate);
+ collector.appendLabeledTextLine(QStringLiteral("private"), isPrivate ? QStringLiteral("true") : QStringLiteral("false"));
+}
+
+BookMarksManagerPrivate::BookMarksManagerPrivate(BookMarksManager *manager)
+ : q(manager)
+{
+ initializeFileWatcher();
+}
+
+// Watcher
+void BookMarksManagerPrivate::initializeFileWatcher()
+{
+ QObject::connect(&m_fileWatcher, &QFileSystemWatcher::fileChanged, &m_fileWatcher,
+ [this](const QString &changedPath) { handleWatchedFileChanged(changedPath); });
+ QObject::connect(&m_fileWatcher, &QFileSystemWatcher::directoryChanged, &m_fileWatcher,
+ [this](const QString &changedPath) { handleWatchedDirectoryChanged(changedPath); });
+}
+
+void BookMarksManagerPrivate::updateWatchedFile(const QString &filePath)
+{
+ QWriteLocker locker(&m_lock);
+
+ const QStringList watchedFiles = m_fileWatcher.files();
+ const QStringList watchedDirectories = m_fileWatcher.directories();
+ if (!watchedFiles.isEmpty()) {
+ m_fileWatcher.removePaths(watchedFiles);
+ }
+ if (!watchedDirectories.isEmpty()) {
+ m_fileWatcher.removePaths(watchedDirectories);
+ }
+
+ if (filePath.isEmpty()) {
+ return;
+ }
+
+ const QFileInfo fileInfo(filePath);
+ const QString parentPath = fileInfo.absolutePath();
+ if (!QFileInfo::exists(parentPath)) {
+ return;
+ }
+ if (!m_fileWatcher.directories().contains(parentPath)) {
+ m_fileWatcher.addPath(parentPath);
+ }
+
+ if (fileInfo.exists() && fileInfo.isFile() && !m_fileWatcher.files().contains(filePath)) {
+ m_fileWatcher.addPath(filePath);
+ }
+}
+
+void BookMarksManagerPrivate::handleWatchedFileChanged(const QString &changedPath)
+{
+ const QString currentPath = filePath();
+ if (currentPath.isEmpty() || changedPath != currentPath || !QFileInfo::exists(currentPath)) {
+ return;
+ }
+
+ bool contentChanged = false;
+ if (parse(contentChanged)) {
+ if (contentChanged) {
+ emitChanged();
+ }
+ } else {
+ emitParseError();
+ }
+}
+
+void BookMarksManagerPrivate::handleWatchedDirectoryChanged(const QString &changedPath)
+{
+ const QString currentPath = filePath();
+ if (currentPath.isEmpty()) {
+ return;
+ }
+
+ const QFileInfo fileInfo(currentPath);
+ if (fileInfo.absolutePath() != changedPath) {
+ return;
+ }
+
+ updateWatchedFile(currentPath);
+ bool contentChanged = false;
+ if (parse(contentChanged)) {
+ if (contentChanged) {
+ emitChanged();
+ }
+ } else {
+ emitParseError();
+ }
+}
+
+bool BookMarksManagerPrivate::sendError(const QString &message, int line, int column)
+{
+ {
+ QWriteLocker locker(&m_lock);
+ m_lastError = message;
+ m_errorLine = line;
+ m_errorColumn = column;
+ }
+ emitParseError();
+ return false;
+}
+
+void BookMarksManagerPrivate::emitParseError()
+{
+ if (q) {
+ QString error;
+ {
+ QReadLocker locker(&m_lock);
+ error = m_lastError;
+ }
+ Q_EMIT q->errorOccurred(error);
+ }
+}
+
+void BookMarksManagerPrivate::emitChanged()
+{
+ if (q) {
+ Q_EMIT q->changed();
+ }
+}
+
+// Public API
+bool BookMarksManagerPrivate::isValid() const
+{
+ QReadLocker locker(&m_lock);
+ return m_valid;
+}
+
+QString BookMarksManagerPrivate::filePath() const
+{
+ QReadLocker locker(&m_lock);
+ return m_filePath;
+}
+
+void BookMarksManagerPrivate::setFilePath(const QString &filePath)
+{
+ {
+ QWriteLocker locker(&m_lock);
+ // Allow retry on the same path when current state is invalid.
+ if (m_filePath == filePath && m_valid) {
+ return;
+ }
+ m_filePath = filePath;
+ }
+
+ bool contentChanged = false;
+ if (parse(contentChanged)) {
+ if (contentChanged) {
+ emitChanged();
+ }
+ } else {
+ emitParseError();
+ }
+}
+
+bool BookMarksManagerPrivate::save()
+{
+ QString path;
+ {
+ QReadLocker locker(&m_lock);
+ path = m_filePath;
+ }
+ return saveAs(path);
+}
+
+bool BookMarksManagerPrivate::saveAs(const QString &filePath)
+{
+ if (filePath.isEmpty()) {
+ return sendError(QStringLiteral("stage=saveAs, reason=Target file path is empty"));
+ }
+
+ QByteArray content;
+ {
+ QReadLocker locker(&m_lock);
+ if (!m_valid || m_document.isNull()) {
+ locker.unlock();
+ return sendError(QStringLiteral("stage=saveAs, path=%1, reason=No valid parsed document to save").arg(filePath));
+ }
+ content = m_document.toByteArray(2);
+ }
+
+ QSaveFile file(filePath);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ return sendError(QStringLiteral("stage=saveAs/open, path=%1, reason=%2").arg(filePath, file.errorString()));
+ }
+
+ if (file.write(content) < 0) {
+ return sendError(QStringLiteral("stage=saveAs/write, path=%1, reason=%2").arg(filePath, file.errorString()));
+ }
+ if (!file.commit()) {
+ return sendError(QStringLiteral("stage=saveAs/commit, path=%1, reason=%2").arg(filePath, file.errorString()));
+ }
+
+ QString watchedPath;
+ {
+ QWriteLocker locker(&m_lock);
+ if (m_filePath != filePath) {
+ m_filePath = filePath;
+ watchedPath = m_filePath;
+ }
+ }
+
+ if (!watchedPath.isEmpty()) {
+ updateWatchedFile(watchedPath);
+ }
+ return true;
+}
+
+QString BookMarksManagerPrivate::rootTitle() const
+{
+ QReadLocker locker(&m_lock);
+ return m_rootTitle;
+}
+
+QString BookMarksManagerPrivate::dbusName() const
+{
+ QReadLocker locker(&m_lock);
+ return m_dbusName;
+}
+
+BookMarksGroup BookMarksManagerPrivate::rootGroup() const
+{
+ QReadLocker locker(&m_lock);
+ return m_rootGroup;
+}
+
+QStringList BookMarksManagerPrivate::textLines() const
+{
+ QReadLocker locker(&m_lock);
+ return m_textCollector.textLines(m_dbusName);
+}
+
+QStringList BookMarksManagerPrivate::customMetaLines() const
+{
+ QReadLocker locker(&m_lock);
+ return m_textCollector.customMetaLines();
+}
+
+QStringList BookMarksManagerPrivate::extendedAttributeLines() const
+{
+ QReadLocker locker(&m_lock);
+ return m_textCollector.extendedAttributeLines();
+}
+
+QHash BookMarksManagerPrivate::freedesktopGroups() const
+{
+ QReadLocker locker(&m_lock);
+ return m_freedesktopGroupsByHref;
+}
+
+QStringList BookMarksManagerPrivate::freedesktopGroups(const QString &href) const
+{
+ QReadLocker locker(&m_lock);
+ return m_freedesktopGroupsByHref.value(href);
+}
+
+QList BookMarksManagerPrivate::freedesktopApplications() const
+{
+ QReadLocker locker(&m_lock);
+ return m_freedesktopApplications;
+}
+
+QList BookMarksManagerPrivate::freedesktopApplications(const QString &href) const
+{
+ QReadLocker locker(&m_lock);
+ QList filtered;
+ for (const FreedesktopApplicationInfo &item : m_freedesktopApplications) {
+ if (item.href() == href) {
+ filtered.append(item);
+ }
+ }
+ return filtered;
+}
+
+QString BookMarksManagerPrivate::lastError() const
+{
+ QReadLocker locker(&m_lock);
+ return m_lastError;
+}
+
+int BookMarksManagerPrivate::errorLine() const
+{
+ QReadLocker locker(&m_lock);
+ return m_errorLine;
+}
+
+int BookMarksManagerPrivate::errorColumn() const
+{
+ QReadLocker locker(&m_lock);
+ return m_errorColumn;
+}
+
+// Parser
+bool BookMarksManagerPrivate::parse(bool &contentChanged)
+{
+ QWriteLocker locker(&m_lock);
+ const QString sourceFilePath = m_filePath;
+ const bool oldValid = m_valid;
+ const QByteArray oldDigest = m_contentDigest;
+
+ reset();
+
+ const bool parseOk = parseFile(sourceFilePath);
+
+ contentChanged = (oldValid != m_valid) || (oldDigest != m_contentDigest);
+ locker.unlock();
+ updateWatchedFile(sourceFilePath);
+ return parseOk;
+}
+
+bool BookMarksManagerPrivate::parseFile(const QString &filePath)
+{
+ if (filePath.isEmpty()) {
+ m_lastError = QStringLiteral("stage=parse/open, reason=Source file path is empty");
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ return false;
+ }
+
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ m_lastError = QStringLiteral("stage=parse/open, path=%1, reason=%2").arg(filePath, file.errorString());
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ return false;
+ }
+
+ QCryptographicHash hash(QCryptographicHash::Sha1);
+ if (!hash.addData(&file)) {
+ m_lastError = QStringLiteral("stage=parse/digest, path=%1, reason=Failed to read file for digest").arg(filePath);
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ return false;
+ }
+ m_contentDigest = hash.result();
+ if (!file.seek(0)) {
+ m_lastError = QStringLiteral("stage=parse/rewind, path=%1, reason=Failed to rewind file after digest").arg(filePath);
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ return false;
+ }
+
+ QDomDocument doc;
+ QString errorMessage;
+ int errorLine = 0;
+ int errorColumn = 0;
+ if (!doc.setContent(&file, true, &errorMessage, &errorLine, &errorColumn)) {
+ const QString reason = errorMessage.isEmpty() ? QStringLiteral("XML parse error") : errorMessage;
+ m_lastError = QStringLiteral("stage=parse/xml, path=%1, line=%2, column=%3, reason=%4")
+ .arg(filePath)
+ .arg(errorLine)
+ .arg(errorColumn)
+ .arg(reason);
+ m_errorLine = errorLine;
+ m_errorColumn = errorColumn;
+ return false;
+ }
+
+ const QDomElement root = doc.documentElement();
+ if (root.isNull() || elementName(root) != QLatin1String("xbel")) {
+ m_lastError = QStringLiteral("stage=parse/validate, path=%1, reason=Root element is not ").arg(filePath);
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ return false;
+ }
+
+ parseXbelAttributes(root);
+
+ int rootFolderIndex = 0;
+ int rootBookmarkIndex = 0;
+
+ for (QDomNode node = root.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement element = node.toElement();
+ const QString tagName = elementName(element);
+
+ if (tagName == QLatin1String("title")) {
+ const QString title = element.text().trimmed();
+ if (!title.isEmpty()) {
+ m_rootTitle = title;
+ m_rootGroup.setTitle(title);
+ m_textCollector.appendRawTextLine(title);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) {
+ const QString description = element.text().trimmed();
+ if (!description.isEmpty()) {
+ m_rootGroup.setDescription(description);
+ m_textCollector.appendRawTextLine(description);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("info")) {
+ parseInfoElement(element, m_rootGroup, QStringLiteral("root"));
+ continue;
+ }
+
+ if (tagName == QLatin1String("metadata")) {
+ parseMetaDataElement(element, m_rootGroup, QStringLiteral("root"));
+ continue;
+ }
+
+ if (tagName == QLatin1String("folder")) {
+ m_rootGroup.addGroup(parseGroupElement(element, QStringLiteral("root.folder[%1]").arg(rootFolderIndex++)));
+ continue;
+ }
+
+ if (tagName == QLatin1String("bookmark")) {
+ m_rootGroup.addBookmark(parseBookmarkElement(element, QStringLiteral("root.bookmark[%1]").arg(rootBookmarkIndex++)));
+ continue;
+ }
+
+ if (tagName == QLatin1String("alias") || tagName == QLatin1String("separator")) {
+ collectExtendedAttributes(element, tagName);
+ continue;
+ }
+ }
+
+ m_valid = true;
+ m_document = doc;
+ return true;
+}
+
+void BookMarksManagerPrivate::reset()
+{
+ m_valid = false;
+ m_lastError.clear();
+ m_errorLine = -1;
+ m_errorColumn = -1;
+ m_rootTitle.clear();
+ m_dbusName.clear();
+ m_rootGroup = BookMarksGroup();
+ m_document = QDomDocument();
+ m_contentDigest.clear();
+
+ m_textCollector.reset();
+ m_freedesktopGroupsByHref.clear();
+ m_freedesktopApplications.clear();
+}
+
+void BookMarksManagerPrivate::parseXbelAttributes(const QDomElement &root)
+{
+ const QDomNamedNodeMap attrs = root.attributes();
+ for (int i = 0; i < attrs.size(); ++i) {
+ const QDomAttr attr = attrs.item(i).toAttr();
+ if (attr.isNull()) {
+ continue;
+ }
+
+ const QString attributeName = attr.name();
+ const QString value = attr.value().trimmed();
+ if (attributeName == QLatin1String("id")) {
+ m_rootGroup.setId(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("added")) {
+ m_rootGroup.setAdded(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("modified")) {
+ m_rootGroup.setModified(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("visited")) {
+ m_rootGroup.setVisited(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("dbusName")) {
+ m_dbusName = value;
+ continue;
+ }
+ }
+
+ collectExtendedAttributes(root, QStringLiteral("xbel"));
+}
+
+void BookMarksManagerPrivate::collectExtendedAttributes(const QDomElement &element, const QString &elementKind)
+{
+ const QDomNamedNodeMap attrs = element.attributes();
+ for (int i = 0; i < attrs.size(); ++i) {
+ const QDomAttr attr = attrs.item(i).toAttr();
+ if (attr.isNull()) {
+ continue;
+ }
+
+ const QString attributeName = attr.name();
+ if (attributeName == QLatin1String("xmlns")
+ || attributeName.startsWith(QLatin1String("xmlns:"))
+ || isStandardAttributeForElement(elementKind, attributeName)) {
+ continue;
+ }
+
+ const QString value = attr.value().trimmed();
+ if (!value.isEmpty()) {
+ m_textCollector.appendExtendedAttributeLine(elementKind, attributeName, value);
+ }
+ }
+}
+
+void BookMarksManagerPrivate::parseInfoElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath)
+{
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ if (elementName(child) == QLatin1String("metadata")) {
+ parseMetaDataElement(child, group, nodePath);
+ }
+ }
+}
+
+void BookMarksManagerPrivate::parseInfoElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath)
+{
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ if (elementName(child) == QLatin1String("metadata")) {
+ parseMetaDataElement(child, bookmark, nodePath);
+ }
+ }
+}
+
+void BookMarksManagerPrivate::parseMetaDataElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath)
+{
+ const QString owner = element.attribute(QLatin1String("owner")).trimmed();
+ const bool isKdeOwner = ownerMatchesHost(owner, KDE_OWNER_HOST);
+
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ const QString key = elementName(child);
+ const QString value = child.text().trimmed();
+ if (isKdeOwner && !value.isEmpty()) {
+ group.addKdeMetaValue(key, value);
+ m_textCollector.appendLabeledTextLine(key, value);
+ }
+
+ if (!isKdeOwner) {
+ collectMetadataContent(child, owner, QString(), nodePath, key);
+ }
+ }
+}
+
+void BookMarksManagerPrivate::parseMetaDataElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath)
+{
+ const QString owner = element.attribute(QLatin1String("owner")).trimmed();
+ const bool isKdeOwner = ownerMatchesHost(owner, KDE_OWNER_HOST);
+ const bool isFreedesktopOwner = ownerMatchesHost(owner, FREEDESKTOP_OWNER_HOST);
+
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ const QString key = elementName(child);
+ const QString value = child.text().trimmed();
+ if (isKdeOwner && !value.isEmpty()) {
+ bookmark.addKdeMetaValue(key, value);
+ m_textCollector.appendLabeledTextLine(key, value);
+ }
+
+ if (!isKdeOwner) {
+ collectMetadataContent(child, owner, bookmark.href(), nodePath, key);
+ }
+
+ if (isFreedesktopOwner) {
+ parseFreedesktopMetadataNode(child, bookmark);
+ }
+ }
+}
+
+BookMarksGroup BookMarksManagerPrivate::parseGroupElement(const QDomElement &element, const QString &groupPath)
+{
+ BookMarksGroup group;
+
+ const QDomNamedNodeMap attrs = element.attributes();
+ for (int i = 0; i < attrs.size(); ++i) {
+ const QDomAttr attr = attrs.item(i).toAttr();
+ if (attr.isNull()) {
+ continue;
+ }
+
+ const QString attributeName = attr.name();
+ const QString value = attr.value().trimmed();
+ if (attributeName == QLatin1String("id")) {
+ group.setId(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("added")) {
+ group.setAdded(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("modified")) {
+ group.setModified(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("visited")) {
+ group.setVisited(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("folded")) {
+ if (!value.isEmpty()) {
+ group.setFolded(value);
+ m_textCollector.appendLabeledTextLine(QStringLiteral("folded"), value);
+ }
+ continue;
+ }
+ if (attributeName == QLatin1String("toolbar")) {
+ if (!value.isEmpty()) {
+ group.setToolbar(value);
+ m_textCollector.appendLabeledTextLine(QStringLiteral("toolbar"), value);
+ }
+ continue;
+ }
+ }
+
+ collectExtendedAttributes(element, QStringLiteral("folder"));
+
+ int subgroupIndex = 0;
+ int bookmarkIndex = 0;
+
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ const QString tagName = elementName(child);
+
+ if (tagName == QLatin1String("title")) {
+ const QString title = child.text().trimmed();
+ if (!title.isEmpty()) {
+ group.setTitle(title);
+ m_textCollector.appendRawTextLine(title);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) {
+ const QString description = child.text().trimmed();
+ if (!description.isEmpty()) {
+ group.setDescription(description);
+ m_textCollector.appendRawTextLine(description);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("info")) {
+ parseInfoElement(child, group, groupPath);
+ continue;
+ }
+
+ if (tagName == QLatin1String("metadata")) {
+ parseMetaDataElement(child, group, groupPath);
+ continue;
+ }
+
+ if (tagName == QLatin1String("folder")) {
+ group.addGroup(parseGroupElement(child, QStringLiteral("%1.folder[%2]").arg(groupPath).arg(subgroupIndex++)));
+ continue;
+ }
+
+ if (tagName == QLatin1String("bookmark")) {
+ group.addBookmark(parseBookmarkElement(child, QStringLiteral("%1.bookmark[%2]").arg(groupPath).arg(bookmarkIndex++)));
+ continue;
+ }
+
+ if (tagName == QLatin1String("alias") || tagName == QLatin1String("separator")) {
+ collectExtendedAttributes(child, tagName);
+ continue;
+ }
+ }
+
+ return group;
+}
+
+BookMark BookMarksManagerPrivate::parseBookmarkElement(const QDomElement &element, const QString &bookmarkPath)
+{
+ BookMark bookmark;
+
+ const QDomNamedNodeMap attrs = element.attributes();
+ for (int i = 0; i < attrs.size(); ++i) {
+ const QDomAttr attr = attrs.item(i).toAttr();
+ if (attr.isNull()) {
+ continue;
+ }
+
+ const QString attributeName = attr.name();
+ const QString value = attr.value().trimmed();
+ if (attributeName == QLatin1String("id")) {
+ bookmark.setId(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("added")) {
+ bookmark.setAdded(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("modified")) {
+ bookmark.setModified(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("visited")) {
+ bookmark.setVisited(value);
+ continue;
+ }
+ if (attributeName == QLatin1String("href")) {
+ if (!value.isEmpty()) {
+ bookmark.setHref(value);
+ m_textCollector.appendRawTextLine(value);
+ }
+ continue;
+ }
+ if (attributeName == QLatin1String("showintoolbar")) {
+ if (!value.isEmpty()) {
+ bookmark.setShowInToolbar(value);
+ m_textCollector.appendLabeledTextLine(QStringLiteral("showintoolbar"), value);
+ }
+ continue;
+ }
+ }
+
+ collectExtendedAttributes(element, QStringLiteral("bookmark"));
+
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+
+ const QDomElement child = node.toElement();
+ const QString tagName = elementName(child);
+ const QString qualifiedName = child.tagName();
+
+ if (tagName == QLatin1String("title")) {
+ const QString title = child.text().trimmed();
+ if (!title.isEmpty()) {
+ bookmark.setTitle(title);
+ m_textCollector.appendRawTextLine(title);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) {
+ const QString description = child.text().trimmed();
+ if (!description.isEmpty()) {
+ bookmark.setDescription(description);
+ m_textCollector.appendRawTextLine(description);
+ }
+ continue;
+ }
+
+ if (tagName == QLatin1String("info")) {
+ parseInfoElement(child, bookmark, bookmarkPath);
+ continue;
+ }
+
+ if (tagName == QLatin1String("metadata")) {
+ parseMetaDataElement(child, bookmark, bookmarkPath);
+ continue;
+ }
+
+ if (qualifiedName == QLatin1String("bookmark:icon")
+ || (tagName == QLatin1String("icon")
+ && child.namespaceURI() == QLatin1String("http://www.freedesktop.org/standards/desktop-bookmarks"))) {
+ applyBookmarkIconFields(bookmark, m_textCollector, child, true);
+ continue;
+ }
+
+ if (qualifiedName == QLatin1String("mime:mime-type")
+ || (tagName == QLatin1String("mime-type")
+ && child.namespaceURI() == QLatin1String("http://www.freedesktop.org/standards/shared-mime-info"))) {
+ applyBookmarkMimeType(bookmark, m_textCollector, child.attribute(QLatin1String("type")).trimmed(), true);
+ continue;
+ }
+ }
+
+ return bookmark;
+}
+
+void BookMarksManagerPrivate::collectMetadataContent(const QDomElement &metadataElement, const QString &owner, const QString &href, const QString &nodePath, const QString &basePath)
+{
+ bool hasElementChild = false;
+ for (QDomNode node = metadataElement.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (node.isElement()) {
+ hasElementChild = true;
+ break;
+ }
+ }
+
+ if (!hasElementChild) {
+ const QString value = metadataElement.text().trimmed();
+ if (!value.isEmpty() && !basePath.isEmpty()) {
+ m_textCollector.appendCustomMetaLine(owner, href, nodePath, basePath, value);
+ }
+ }
+
+ collectMetadataElementAttributes(metadataElement, owner, href, nodePath, basePath);
+}
+
+void BookMarksManagerPrivate::collectMetadataElementAttributes(const QDomElement &element, const QString &owner, const QString &href, const QString &nodePath, const QString &path)
+{
+ const QDomNamedNodeMap attrs = element.attributes();
+ for (int i = 0; i < attrs.size(); ++i) {
+ const QDomAttr attr = attrs.item(i).toAttr();
+ if (attr.isNull() || attr.name() == QLatin1String("owner")) {
+ continue;
+ }
+ const QString attrValue = attr.value().trimmed();
+ if (!attrValue.isEmpty()) {
+ m_textCollector.appendCustomMetaLine(owner, href, nodePath, path + QLatin1Char('@') + attr.name(), attrValue);
+ }
+ }
+
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+ const QDomElement child = node.toElement();
+ const QString childName = elementName(child);
+ const QString childPath = path.isEmpty() ? childName : path + QLatin1Char('.') + childName;
+ collectMetadataContent(child, owner, href, nodePath, childPath);
+ }
+}
+
+void BookMarksManagerPrivate::parseFreedesktopMetadataNode(const QDomElement &element, BookMark &bookmark)
+{
+ const QString tagName = elementName(element);
+ const QString href = bookmark.href();
+ if (tagName == QLatin1String("groups")) {
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+ const QDomElement child = node.toElement();
+ if (elementName(child) != QLatin1String("group")) {
+ continue;
+ }
+ const QString group = child.text().trimmed();
+ if (!group.isEmpty()) {
+ addFreedesktopGroup(href, group);
+ }
+ }
+ return;
+ }
+
+ if (tagName == QLatin1String("icon")) {
+ applyBookmarkIconFields(bookmark, m_textCollector, element, false);
+ return;
+ }
+
+ if (tagName == QLatin1String("mime-type")) {
+ applyBookmarkMimeType(bookmark, m_textCollector, element.attribute(QLatin1String("type")).trimmed(), false);
+ return;
+ }
+
+ if (tagName == QLatin1String("private")) {
+ bool isPrivate = false;
+ if (parseFreedesktopBooleanValue(element.text(), isPrivate)) {
+ applyBookmarkPrivateFlag(bookmark, m_textCollector, isPrivate);
+ }
+ return;
+ }
+
+ if (tagName == QLatin1String("applications")) {
+ for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) {
+ if (!node.isElement()) {
+ continue;
+ }
+ const QDomElement child = node.toElement();
+ if (elementName(child) != QLatin1String("application")) {
+ continue;
+ }
+
+ FreedesktopApplicationInfo info;
+ info.setHref(href);
+ const QString name = child.attribute(QLatin1String("name")).trimmed();
+ QString exec = child.attribute(QLatin1String("exec")).trimmed();
+ info.setName(name);
+ if (exec.isEmpty() && !name.isEmpty()) {
+ exec = name + QStringLiteral(" %u");
+ }
+ info.setExec(exec);
+ info.setModified(child.attribute(QLatin1String("modified")).trimmed());
+ bool ok = false;
+ const QString countText = child.attribute(QLatin1String("count")).trimmed();
+ const int parsedCount = countText.toInt(&ok);
+ info.setCount(countText.isEmpty() ? 1 : (ok ? parsedCount : -1));
+ addFreedesktopApplication(info);
+ }
+ }
+}
+
+void BookMarksManagerPrivate::addFreedesktopGroup(const QString &href, const QString &group)
+{
+ if (href.isEmpty() || group.isEmpty()) {
+ return;
+ }
+ if (m_freedesktopGroupsByHref.value(href).contains(group)) {
+ return;
+ }
+ m_freedesktopGroupsByHref[href].append(group);
+}
+
+void BookMarksManagerPrivate::addFreedesktopApplication(const FreedesktopApplicationInfo &appInfo)
+{
+ if (appInfo.href().isEmpty()) {
+ return;
+ }
+ if (appInfo.name().isEmpty() && appInfo.exec().isEmpty() && appInfo.modified().isEmpty() && appInfo.count() < 0) {
+ return;
+ }
+ m_freedesktopApplications.append(appInfo);
+}
+
+BookMarksManager::BookMarksManager(const QString &filePath, QObject *parent)
+ : QObject(parent)
+ , d(new BookMarksManagerPrivate(this))
+{
+ if (!filePath.isEmpty()) {
+ d->setFilePath(filePath);
+ }
+}
+
+BookMarksManager::~BookMarksManager()
+{
+ delete d;
+}
+
+bool BookMarksManager::isValid() const
+{
+ return d->isValid();
+}
+
+QString BookMarksManager::filePath() const
+{
+ return d->filePath();
+}
+
+void BookMarksManager::setFilePath(const QString &filePath)
+{
+ d->setFilePath(filePath);
+}
+
+bool BookMarksManager::save()
+{
+ return d->save();
+}
+
+bool BookMarksManager::saveAs(const QString &filePath)
+{
+ return d->saveAs(filePath);
+}
+
+QString BookMarksManager::lastError() const
+{
+ return d->lastError();
+}
+
+int BookMarksManager::errorLine() const
+{
+ return d->errorLine();
+}
+
+int BookMarksManager::errorColumn() const
+{
+ return d->errorColumn();
+}
+
+QString BookMarksManager::rootTitle() const
+{
+ return d->rootTitle();
+}
+
+QString BookMarksManager::dbusName() const
+{
+ return d->dbusName();
+}
+
+BookMarksGroup BookMarksManager::rootGroup() const
+{
+ return d->rootGroup();
+}
+
+QStringList BookMarksManager::textLines() const
+{
+ return d->textLines();
+}
+
+QStringList BookMarksManager::customMetaLines() const
+{
+ return d->customMetaLines();
+}
+
+QStringList BookMarksManager::extendedAttributeLines() const
+{
+ return d->extendedAttributeLines();
+}
+
+QHash BookMarksManager::freedesktopGroups() const
+{
+ return d->freedesktopGroups();
+}
+
+QStringList BookMarksManager::freedesktopGroups(const QString &href) const
+{
+ return d->freedesktopGroups(href);
+}
+
+QList BookMarksManager::freedesktopApplications() const
+{
+ return d->freedesktopApplications();
+}
+
+QList BookMarksManager::freedesktopApplications(const QString &href) const
+{
+ return d->freedesktopApplications(href);
+}
+
+} // namespace UkuiFileMetadata
diff --git a/src/bookmarks-manager.h b/src/bookmarks-manager.h
new file mode 100644
index 0000000000000000000000000000000000000000..d66ad85908ad20c48d9d0e90cebd4190f6ac6ea6
--- /dev/null
+++ b/src/bookmarks-manager.h
@@ -0,0 +1,205 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H
+#define UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H
+
+#include "bookmark.h"
+#include "ukui-file-metadata_global.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace UkuiFileMetadata {
+
+class BookMarksManagerPrivate;
+
+/**
+ * @brief XBEL 书签管理器。
+ *
+ * 负责解析 XBEL 文件并提供统一访问接口,包含:
+ * 1. 书签树结构(根节点、分组、书签基本字段);
+ * 2. 可用于全文索引的文本行输出;
+ * 3. 自定义 metadata 与扩展属性提取结果;
+ * 4. freedesktop recently-used 相关扩展字段(groups / applications)。
+ *
+ * 该类可直接用于安装后的公共 API,具体解析实现通过 private 类封装。
+ */
+class UKUIFILEMETADATA_EXPORT BookMarksManager : public QObject
+{
+ Q_OBJECT
+
+public:
+ /**
+ * @brief 构造函数。
+ * @param filePath XBEL 文件路径。非空时会立即尝试解析。
+ */
+ explicit BookMarksManager(const QString &filePath = QString(), QObject *parent = nullptr);
+
+ /**
+ * @brief 析构函数。
+ */
+ ~BookMarksManager() override;
+
+ /**
+ * @brief 当前解析结果是否有效。
+ * @return `true` 表示文件可读且解析成功,`false` 表示失败或未设置有效文件。
+ */
+ bool isValid() const;
+
+ /**
+ * @brief 获取当前文件路径。
+ * @return 当前设置的 XBEL 文件路径。
+ */
+ QString filePath() const;
+
+ /**
+ * @brief 设置并重新解析文件路径。
+ * @param filePath 新的 XBEL 文件路径。与当前路径相同则不触发重新解析。
+ */
+ void setFilePath(const QString &filePath);
+
+ /**
+ * @brief 将当前解析文档保存回当前文件路径。
+ * @return `true` 表示保存成功,`false` 表示保存失败。
+ */
+ bool save();
+
+ /**
+ * @brief 将当前解析文档另存到指定文件路径。
+ * @param filePath 目标文件路径。
+ * @return `true` 表示保存成功,`false` 表示保存失败。
+ */
+ bool saveAs(const QString &filePath);
+
+ /**
+ * @brief 获取最近一次解析错误信息。
+ * @return 解析失败时的错误文本;当前结果有效或无错误时返回空字符串。
+ */
+ QString lastError() const;
+
+ /**
+ * @brief 获取最近一次 XML 解析错误的行号。
+ * @return 若无可用行号返回 -1。
+ */
+ int errorLine() const;
+
+ /**
+ * @brief 获取最近一次 XML 解析错误的列号。
+ * @return 若无可用列号返回 -1。
+ */
+ int errorColumn() const;
+
+ /**
+ * @brief 获取根节点标题。
+ * @return `` 内容,若不存在返回空字符串。
+ */
+ QString rootTitle() const;
+
+ /**
+ * @brief 获取根节点 dbusName。
+ * @return 根节点 `dbusName` 属性值,若不存在返回空字符串。
+ */
+ QString dbusName() const;
+
+ /**
+ * @brief 获取解析后的根分组对象。
+ * @return 根分组副本。
+ */
+ BookMarksGroup rootGroup() const;
+
+ /**
+ * @brief 获取用于全文索引/文本提取的聚合行。
+ *
+ * 包含基础字段、自定义 metadata、扩展属性等可检索文本。
+ *
+ * @return 文本行列表。
+ */
+ QStringList textLines() const;
+
+ /**
+ * @brief 获取自定义 metadata 解析结果。
+ *
+ * 格式示例:`metadata[owner].path: value`、
+ * `metadata[owner].path@attr: value`、
+ * `metadata[owner][href].path: value`。
+ *
+ * @return 自定义 metadata 文本行列表。
+ */
+ QStringList customMetaLines() const;
+
+ /**
+ * @brief 获取扩展属性解析结果。
+ *
+ * 格式示例:`attribute[element].attr: value`。
+ *
+ * @return 扩展属性文本行列表。
+ */
+ QStringList extendedAttributeLines() const;
+
+ /**
+ * @brief 获取全部 freedesktop 分组信息(按 href 聚合)。
+ * @return `href -> group 列表` 的映射。
+ */
+ QHash freedesktopGroups() const;
+
+ /**
+ * @brief 获取指定 href 的 freedesktop 分组。
+ * @param href 书签 href。
+ * @return 指定 href 的 group 列表;若不存在返回空列表。
+ */
+ QStringList freedesktopGroups(const QString &href) const;
+
+ /**
+ * @brief 获取全部 freedesktop 应用信息。
+ * @return `FreedesktopApplicationInfo` 列表。
+ */
+ QList freedesktopApplications() const;
+
+ /**
+ * @brief 获取指定 href 的 freedesktop 应用信息。
+ * @param href 书签 href。
+ * @return 指定 href 对应的应用信息列表;若不存在返回空列表。
+ */
+ QList freedesktopApplications(const QString &href) const;
+
+Q_SIGNALS:
+ /**
+ * @brief 解析内容已更新(包括文件监听触发的重解析)。
+ */
+ void changed();
+
+ /**
+ * @brief 解析或保存失败。
+ * @param message 错误信息。
+ */
+ void errorOccurred(const QString &message);
+
+private:
+ Q_DISABLE_COPY(BookMarksManager)
+
+ BookMarksManagerPrivate *d;
+};
+
+} // namespace UkuiFileMetadata
+
+#endif // UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H
diff --git a/src/extractors/CMakeLists.txt b/src/extractors/CMakeLists.txt
index 62dfc925034920af088f87d9222725071dbc0fd0..124cffd588a05cb2c764365e8a571f876ae48d0c 100644
--- a/src/extractors/CMakeLists.txt
+++ b/src/extractors/CMakeLists.txt
@@ -1,26 +1,19 @@
include_directories(../)
-include_directories(${POPPLER_INCLUDE_DIRS})
-include_directories(${UCHARDET_INCLUDE_DIRS})
-include_directories(${TAGLIB_INCLUDE_DIRS})
-
-if(AVCODEC_FOUND AND AVFORMAT_FOUND AND AVUTIL_FOUND AND SWSCALE_FOUND)
- add_library(ukuifilemetadata_ffmpegextractor MODULE
- ffmpeg-extractor.cpp
- )
- target_include_directories(ukuifilemetadata_ffmpegextractor SYSTEM PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS})
- target_link_libraries(ukuifilemetadata_ffmpegextractor
- ukui-file-metadata
- ${AVCODEC_LIBRARIES}
- ${AVFORMAT_LIBRARIES}
- ${AVUTIL_LIBRARIES}
- ${SWSCALE_LIBRARIES}
- )
- set_target_properties(ukuifilemetadata_ffmpegextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
- install(
- TARGETS ukuifilemetadata_ffmpegextractor
- DESTINATION "${PLUGIN_INSTALL_DIR}")
-endif()
+add_library(ukuifilemetadata_ffmpegextractor MODULE
+ ffmpeg-extractor.cpp
+ )
+target_link_libraries(ukuifilemetadata_ffmpegextractor
+ ukui-file-metadata
+ PkgConfig::AVCODEC
+ PkgConfig::AVFORMAT
+ PkgConfig::AVUTIL
+ PkgConfig::SWSCALE
+ )
+set_target_properties(ukuifilemetadata_ffmpegextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
+install(
+ TARGETS ukuifilemetadata_ffmpegextractor
+ DESTINATION "${PLUGIN_INSTALL_DIR}")
#
#office (binary)
@@ -40,8 +33,7 @@ install(
add_library(ukuifilemetadata_office2007extractor MODULE office2007-extractor.cpp)
target_link_libraries(ukuifilemetadata_office2007extractor
- ukui-file-metadata
- quazip5)
+ ukui-file-metadata)
set_target_properties(ukuifilemetadata_office2007extractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
TARGETS ukuifilemetadata_office2007extractor
@@ -54,7 +46,7 @@ install(
add_library(ukuifilemetadata_textextractor MODULE text-extractor.cpp)
target_link_libraries(ukuifilemetadata_textextractor
ukui-file-metadata
- ${UCHARDET_LIBRARIES}
+ PkgConfig::UCHARDET
)
set_target_properties(ukuifilemetadata_textextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
@@ -68,7 +60,7 @@ install(
add_library(ukuifilemetadata_pdfextractor MODULE pdf-extractor.cpp)
target_link_libraries(ukuifilemetadata_pdfextractor
ukui-file-metadata
- ${POPPLER_LIBRARIES}
+ PkgConfig::POPPLER
)
set_target_properties(ukuifilemetadata_pdfextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
@@ -81,8 +73,7 @@ install(
add_library(ukuifilemetadata_uofextractor MODULE uof-extractor.cpp)
target_link_libraries(ukuifilemetadata_uofextractor
- ukui-file-metadata
- quazip5)
+ ukui-file-metadata)
set_target_properties(ukuifilemetadata_uofextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
TARGETS ukuifilemetadata_uofextractor
@@ -94,8 +85,7 @@ install(
add_library(ukuifilemetadata_ofdextractor MODULE ofd-extractor.cpp)
target_link_libraries(ukuifilemetadata_ofdextractor
- ukui-file-metadata
- quazip5)
+ ukui-file-metadata)
set_target_properties(ukuifilemetadata_ofdextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
TARGETS ukuifilemetadata_ofdextractor
@@ -104,6 +94,7 @@ install(
#
#png
#
+
add_library(ukuifilemetadata_pngextractor MODULE png-extractor.cpp)
target_link_libraries(ukuifilemetadata_pngextractor
ukui-file-metadata)
@@ -119,7 +110,7 @@ install(
add_library(ukuifilemetadata_taglibextractor MODULE taglib-extractor.cpp)
target_link_libraries( ukuifilemetadata_taglibextractor
ukui-file-metadata
- ${TAGLIB_LIBRARIES}
+ PkgConfig::TAGLIB
)
set_target_properties(ukuifilemetadata_taglibextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
@@ -128,12 +119,25 @@ install(
DESTINATION "${PLUGIN_INSTALL_DIR}")
#
-#png
+#image
#
+
add_library(ukuifilemetadata_imageextractor MODULE image-extractor.cpp)
target_link_libraries(ukuifilemetadata_imageextractor
ukui-file-metadata)
set_target_properties(ukuifilemetadata_imageextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
install(
TARGETS ukuifilemetadata_imageextractor
- DESTINATION "${PLUGIN_INSTALL_DIR}")
\ No newline at end of file
+ DESTINATION "${PLUGIN_INSTALL_DIR}")
+
+#
+#bookmarks
+#
+
+add_library(ukuifilemetadata_bookmarksextractor MODULE bookmarks-extractor.cpp)
+target_link_libraries(ukuifilemetadata_bookmarksextractor
+ ukui-file-metadata)
+set_target_properties(ukuifilemetadata_bookmarksextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata")
+install(
+ TARGETS ukuifilemetadata_bookmarksextractor
+ DESTINATION "${PLUGIN_INSTALL_DIR}")
diff --git a/src/extractors/binary-parser.cpp b/src/extractors/binary-parser.cpp
index b88211b1111b70f43b2841f479a4957782042519..7080964229951d86b15af8fb38be4e28116d33e0 100644
--- a/src/extractors/binary-parser.cpp
+++ b/src/extractors/binary-parser.cpp
@@ -19,9 +19,11 @@
*/
#include "binary-parser.h"
#include
+#include
#include
#include
#include
+#include
#include "limits.h"
#include
#include "common.h"
@@ -4347,11 +4349,22 @@ static inline const Properties *qGetProp(ushort ucs2) {
#define BIG_BLOCK_SIZE 512
#define PROPERTY_SET_STORAGE_SIZE 128
#define SMALL_BLOCK_SIZE 64
+/*
+ * 解析 OLE 时会把 FAT/depot 表一次性读到内存。攻击者可以伪造文件大小或
+ * depot 长度,让这里尝试申请很大的内存,所以需要设置一个上限。
+ *
+ * 这个上限可能会拒绝极少数“格式合法但特别大”的旧版 OLE 文件。若以后必须
+ * 支持这类文件,正确做法是把 FAT/depot 改成按需读取或分块读取;不要只是
+ * 调大这个值,否则会重新引入内存耗尽风险。
+ */
+static const size_t MAX_OLE_DEPOT_ALLOCATION_BYTES = 64u * 1024u * 1024u;
/* PPT Data*/
#define PPT_RECORD_HEADER 8
-#define PPT_TEXTCHARATOM 0x0FA0
-#define PPT_TEXTBYTEATOM 0x0FA8
+#define PPT_TEXT_CHARS_ATOM 0x0FA0
+#define PPT_TEXT_BYTES_ATOM 0x0FA8
+#define PPT_SLIDE_MASTER 0x03F8
+#define PPT_SLIDE 0x03EE
/* Special block numbers */
#define END_OF_CHAIN 0xfffffffeUL
@@ -4413,6 +4426,10 @@ void* xcalloc(size_t tNmemb, size_t tSize) {
tNmemb = 1;
tSize = 1;
}
+ /* 防止 32 位平台或畸形输入下 nmemb * size 发生回绕。 */
+ if(tNmemb > std::numeric_limits::max() / tSize) {
+ return NULL;
+ }
pvTmp = calloc(tNmemb, tSize);
if(pvTmp == NULL) {
return NULL;
@@ -4420,6 +4437,16 @@ void* xcalloc(size_t tNmemb, size_t tSize) {
return pvTmp;
} /* end of xcalloc */
+static bool bOleAllocationWithinBudget(size_t tNmemb, size_t tSize) {
+ if(tNmemb == 0 || tSize == 0) {
+ return true;
+ }
+ if(tNmemb > std::numeric_limits::max() / tSize) {
+ return false;
+ }
+ return tNmemb * tSize <= MAX_OLE_DEPOT_ALLOCATION_BYTES;
+}
+
void* xrealloc(void *pvArg, size_t tSize) {
void *pvTmp;
@@ -4579,6 +4606,9 @@ bool vAdd2PropModList(const UCHAR *aucPropMod) {
tLen = 2 + (size_t)usGetWord(0, aucPropMod);
ppAnchor[tNextFree] = (UCHAR*)xmalloc(tLen);
+ if(ppAnchor[tNextFree] == NULL) {
+ return false;
+ }
memcpy(ppAnchor[tNextFree], aucPropMod, tLen);
tNextFree++;
return true;
@@ -4631,8 +4661,25 @@ bool KBinaryParser::bGetPPS(FILE *pFile,
/* Read and store all the Property Set Storage entries */
+ if(tRootListLen > std::numeric_limits::max() / BIG_BLOCK_SIZE) {
+ return false;
+ }
tNbrOfPPS = tRootListLen * BIG_BLOCK_SIZE / PROPERTY_SET_STORAGE_SIZE;
+ if(!bOleAllocationWithinBudget(tNbrOfPPS, sizeof(ppsEntryType))) {
+ qWarning() << "Rejecting oversized OLE PPS allocation:"
+ << "ppsCount=" << tNbrOfPPS
+ << "entrySize=" << sizeof(ppsEntryType);
+ return false;
+ }
atPPSlist = (ppsEntryType*)xcalloc(tNbrOfPPS, sizeof(ppsEntryType));
+ if(atPPSlist == NULL) {
+ /*
+ * tNbrOfPPS 由 tRootListLen 推导,最终受文件大小约束;但在内存
+ * 紧张或字段被恶意拉大时 xcalloc 仍可能返回空。补上判空,避免下面
+ * 的循环对 atPPSlist[iIndex] 做空指针访问。
+ */
+ return false;
+ }
iRootIndex = 0;
for(iIndex = 0; iIndex < (int)tNbrOfPPS; iIndex++) {
@@ -4648,6 +4695,15 @@ bool KBinaryParser::bGetPPS(FILE *pFile,
}
tNameSize = (size_t)usGetWord(0x40, aucBytes);
tNameSize = (tNameSize + 1) / 2;
+ /*
+ * PPS names are stored in a fixed 64-byte UTF-16 field. Keep the
+ * decoded name within szName[32] even if the file advertises a
+ * malformed length.
+ */
+ if(tNameSize > sizeof(atPPSlist[iIndex].szName)) {
+ atPPSlist = (ppsEntryType*)xfree(atPPSlist);
+ return false;
+ }
vName2String(atPPSlist[iIndex].szName, aucBytes, tNameSize);
atPPSlist[iIndex].ucType = ucGetByte(0x42, aucBytes);
if(atPPSlist[iIndex].ucType == 5) {
@@ -4681,7 +4737,8 @@ bool KBinaryParser::bGetPPS(FILE *pFile,
for(iIndex = 0; iIndex < (int)tNbrOfPPS; iIndex++) {
if(atPPSlist[iIndex].szName[0] == '\0' ||
- atPPSlist[iIndex].ulSize == 0) {
+ atPPSlist[iIndex].ulSize == 0 ||
+ atPPSlist[iIndex].iLevel > 1) { // 过滤掉level大于1(文件里嵌套了子文件)的情况,只处理最外层文件内容的解析
/* This entry can be ignored */
continue;
}
@@ -4779,6 +4836,10 @@ bool bCreateSmallBlockList(ULONG ulStartblock, const ULONG *aulBBD, size_t tBBDL
xfree(aulSmallBlockList);
aulSmallBlockList = NULL;
aulSmallBlockList = (ULONG*)xmalloc(tSize);
+ if(aulSmallBlockList == NULL) {
+ /* 分配失败时直接返回,避免下面对空指针做写入。 */
+ return false;
+ }
for(iIndex = 0, ulTmp = ulStartblock;
iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN;
iIndex++, ulTmp = aulBBD[ulTmp]) {
@@ -4888,6 +4949,206 @@ static int ucstrncmp(const QChar *a, const QChar *b, int l) {
return a->unicode() - b->unicode();
}
+// ── Word 文档文本归一化 ──────────────────────────────────────────────────────
+//
+// Word97-2003 (.doc) 文本流中混杂着非文本的结构字符(图片占位、对象锚点、
+// 域标记等)。这些字符在 MS-DOC 规范中仅当字符属性 fSpec=1 时才具有特殊含义。
+//
+// 当前实现采用字符值检测策略:遇到这些字符值就当作结构字符处理。
+// 理论上存在误判风险(正常文本中极少出现 U+0001 等控制字符),实际可忽略。
+//
+// 域处理策略:丢弃域代码,保留域结果。这样能保留用户可见文本
+// (例如超链接显示文字),同时去掉 HYPERLINK、PAGE 等域指令本身。
+//
+// TODO: 如果需要 100% 准确,应读取 CHPX(字符属性表)查询每个字符的 fSpec 标志。
+// 参考:https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/
+
+static bool isDroppableUnknownControlCharacter(const QChar &ch)
+{
+ // 调用方必须先处理当前格式中有语义的控制字符;这里只清理剩余未知 Cc 字符。
+ const ushort code = ch.unicode();
+ return ch.category() == QChar::Other_Control
+ && code != '\t'
+ && code != '\r'
+ && code != '\n';
+}
+
+static void appendWordTableSeparator(QString &out)
+{
+ if (!out.isEmpty() && !out.back().isSpace()) {
+ out.append(QLatin1Char(' '));
+ }
+}
+
+static void appendOfficeParagraphSeparator(QString &out)
+{
+ if (out.isEmpty()) {
+ return;
+ }
+
+ const ushort last = out.back().unicode();
+ if (last == '\r' || last == '\n' || last == '\v' || last == '\f') {
+ return;
+ }
+
+ out.append(QLatin1Char('\r'));
+}
+
+static void appendNormalizedOfficeText(QString &out, const QString &text)
+{
+ for (const QChar ch : text) {
+ const ushort code = ch.unicode();
+ if (code == '\v' || code == '\f') {
+ appendOfficeParagraphSeparator(out);
+ continue;
+ }
+ if (isDroppableUnknownControlCharacter(ch)) {
+ continue;
+ }
+
+ out.append(ch);
+ }
+}
+
+void KBinaryParser::normalizeWordText(const QString &text,
+ WordFieldStateStack &fieldStack,
+ int &codeDepth,
+ QString &out)
+{
+ constexpr uchar kFieldInResult = 0;
+ constexpr uchar kFieldInCode = 1;
+
+ for (const QChar ch : text) {
+ const ushort code = ch.unicode();
+
+ if (code == kWordCharInlinePicture || code == kWordCharFloatingObject) {
+ continue; // Word 专有的对象占位符,用户不可见,直接丢弃
+ }
+ if (code == kWordCharTableCellMark) {
+ if (codeDepth == 0) {
+ appendWordTableSeparator(out);
+ }
+ continue;
+ }
+ if (code == '\v' || code == '\f' || code == kWordCharColumnBreak) {
+ if (codeDepth == 0) {
+ appendOfficeParagraphSeparator(out);
+ }
+ continue;
+ }
+ if (code == kWordCharFieldBegin) {
+ fieldStack.append(kFieldInCode);
+ ++codeDepth;
+ continue;
+ }
+ if (code == kWordCharFieldSeparator) {
+ if (!fieldStack.isEmpty() && fieldStack.back() == kFieldInCode) {
+ fieldStack.back() = kFieldInResult;
+ --codeDepth;
+ }
+ continue;
+ }
+ if (code == kWordCharFieldEnd) {
+ if (!fieldStack.isEmpty()) {
+ if (fieldStack.back() == kFieldInCode)
+ --codeDepth;
+ fieldStack.removeLast();
+ }
+ continue;
+ }
+
+ if (code == kWordCharNonBreakingHyphen) {
+ if (codeDepth == 0) {
+ out.append(QLatin1Char('-'));
+ }
+ continue;
+ }
+ if (code == kWordCharOptionalHyphen) {
+ continue;
+ }
+
+ // Word 中有语义的控制字符要在这里先处理,剩余未知控制字符才可清理。
+ if (isDroppableUnknownControlCharacter(ch)) {
+ continue;
+ }
+
+ // 只要没有任何层仍处于 field code 中,就保留字符。
+ if (codeDepth == 0)
+ out.append(ch);
+ }
+}
+
+// 判断字符是否为段落分隔符
+static bool isParagraphSeparator(QChar ch)
+{
+ const ushort code = ch.unicode();
+ return code == '\r' || code == '\n' || code == '\v' || code == '\f';
+}
+
+// 清理只包含空白字符的空段落
+// 规则:删除开头和结尾的空段落,保留中间所有内容(包括空段落的分隔符)。
+// 最后一个有内容段落之后不保留末尾分隔符。
+static QString cleanEmptyParagraphs(const QString &text)
+{
+ if (text.isEmpty())
+ return QString();
+
+ // 找到第一个非空段落的起始位置
+ int firstNonEmptyStart = 0;
+ while (firstNonEmptyStart < text.size()) {
+ int paraEnd = firstNonEmptyStart;
+ while (paraEnd < text.size() && !isParagraphSeparator(text[paraEnd]))
+ ++paraEnd;
+
+ // 检查段落是否有非空白内容
+ bool hasContent = false;
+ for (int i = firstNonEmptyStart; i < paraEnd; ++i) {
+ if (!text[i].isSpace()) {
+ hasContent = true;
+ break;
+ }
+ }
+
+ if (hasContent)
+ break;
+
+ firstNonEmptyStart = (paraEnd < text.size()) ? paraEnd + 1 : text.size();
+ }
+
+ if (firstNonEmptyStart >= text.size())
+ return QString(); // 全是空段落
+
+ // 从后往前找最后一个非空段落的结束位置
+ int lastNonEmptyEnd = text.size();
+ while (lastNonEmptyEnd > firstNonEmptyStart) {
+ // 找到当前段落的起始位置
+ int paraStart = lastNonEmptyEnd - 1;
+ if (paraStart >= 0 && isParagraphSeparator(text[paraStart]))
+ --paraStart;
+ while (paraStart > firstNonEmptyStart && !isParagraphSeparator(text[paraStart - 1]))
+ --paraStart;
+
+ // 检查段落是否有非空白内容
+ bool hasContent = false;
+ for (int i = paraStart; i < lastNonEmptyEnd && !isParagraphSeparator(text[i]); ++i) {
+ if (!text[i].isSpace()) {
+ hasContent = true;
+ break;
+ }
+ }
+
+ if (hasContent) {
+ // 找到段落内容的实际结束位置(不含分隔符)
+ while (lastNonEmptyEnd > paraStart && isParagraphSeparator(text[lastNonEmptyEnd - 1]))
+ --lastNonEmptyEnd;
+ break;
+ }
+
+ lastNonEmptyEnd = paraStart;
+ }
+
+ return text.mid(firstNonEmptyStart, lastNonEmptyEnd - firstNonEmptyStart);
+}
inline uint _Ucs2ToUcs4(ushort lead, ushort trail) {
unsigned int ucs4 = (trail - 0xDC00); //low 10 bit
ucs4 |= ((lead - 0xD800)) << 10; //high 10 bit
@@ -4919,6 +5180,18 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS,
if(pPPS->tTable.ulSize == 0)
return false;
+ // lcbClx/fcClx 来自文件头,必须先限制在 Table 流范围内,
+ // 否则恶意文件可伪造超大长度触发异常读取或超大内存申请。
+ const size_t tTableSize = static_cast(pPPS->tTable.ulSize);
+ const size_t tTextInfoOffset = static_cast(ulBeginTextInfo);
+ if(tTextInfoOffset > tTableSize || tTextInfoLen > tTableSize - tTextInfoOffset) {
+ qWarning() << "Invalid Word table text info range:" << m_strFileName
+ << "offset=" << tTextInfoOffset
+ << "length=" << tTextInfoLen
+ << "tableSize=" << tTableSize;
+ return false;
+ }
+
if(pPPS->tTable.ulSize < MIN_SIZE_FOR_BBD_USE) {
/* Use the Small Block Depot */
aulBlockDepot = aulSBD;
@@ -4931,23 +5204,24 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS,
tBlockSize = BIG_BLOCK_SIZE;
}
- UCHAR aucBuffer[tTextInfoLen];
+ // 改为堆分配,避免按外部可控长度在栈上创建 VLA。
+ std::vector aucBuffer(tTextInfoLen);
if(!bReadBuffer(pFile, pPPS->tTable.ulSB,
aulBlockDepot, tBlockDepotLen, tBlockSize,
- aucBuffer, ulBeginTextInfo, tTextInfoLen))
+ aucBuffer.data(), ulBeginTextInfo, tTextInfoLen))
return false;
lOff = 0;
while(lOff < (long)tTextInfoLen) {
- iType = (int)ucGetByte(lOff, aucBuffer);
+ iType = (int)ucGetByte(lOff, aucBuffer.data());
lOff++;
if(iType == 0) {
lOff++;
continue;
}
if(iType == 1) {
- iLen = (int)usGetWord(lOff, aucBuffer);
- if(!vAdd2PropModList(aucBuffer + lOff)) {
+ iLen = (int)usGetWord(lOff, aucBuffer.data());
+ if(!vAdd2PropModList(aucBuffer.data() + lOff)) {
return false;
}
lOff += (long)iLen + 2;
@@ -4964,6 +5238,8 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS,
lOff += 4;
lPieces = (long)((ulLen - 4) / 12);
+ WordFieldStateStack fieldStack;
+ int codeDepth = 0;
for(lIndex = 0; lIndex < lPieces; lIndex++) {
ulTextOffset = ulGetLong(lOff + (lPieces + 1) * 4 + lIndex * 8 + 2, aucBuffer);
usPropMod = usGetWord(lOff + (lPieces + 1) * 4 + lIndex * 8 + 6, aucBuffer);
@@ -5000,7 +5276,8 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS,
if(bUsesUnicode) {
ushort* usAucData = (ushort*)ptaucBytes;
- content.append(QString::fromUtf16(usAucData, iAllocSize/2));//char num/2=short num
+ QString rawText = QString::fromUtf16(usAucData, iAllocSize/2);
+ normalizeWordText(rawText, fieldStack, codeDepth, content);
usAucData = (ushort*)xfree((void*)usAucData);
ptaucBytes = NULL;
if(content.length() >= 682666) //20480000/3
@@ -5017,6 +5294,9 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS,
break;
}
+ // 清理只包含空白字符的段落(例如:只有图片的段落)
+ content = cleanEmptyParagraphs(content);
+
return false;
}/* end of bGet8DocumentText */
@@ -5047,6 +5327,9 @@ int KBinaryParser:: readSSTRecord(readDataParam &rdParam, ppsInfoType PPS_info,
if(!eRrd.bUni)
ustotalLen += uscharlen;
UCHAR* chData = (UCHAR*)xmalloc(ustotalLen);
+ if(chData == NULL) {
+ break;
+ }
ushort ustotalLenTmp = ustotalLen;
if(ulNextOff < usPartLen && (ulNextOff + ustotalLen) >= usPartLen) {
ushort usIdf = usPartLen - ulNextOff;
@@ -5107,7 +5390,10 @@ int KBinaryParser:: readSSTRecord(readDataParam &rdParam, ppsInfoType PPS_info,
qWarning() << "Unsupport excel type:" << m_strFileName;
} else {
ushort* usData = (ushort*)chData;
- content.append(QString::fromUtf16(usData, ustotalLenTmp/2)).append(" ");//每个单元格数据之间使用空格,//char num/2=short num
+ if (!content.isEmpty()) {
+ content.append(" ");
+ }
+ appendNormalizedOfficeText(content, QString::fromUtf16(usData, ustotalLenTmp/2));//每个单元格数据之间使用空格,//char num/2=short num
usData = (ushort*)xfree((void*)usData);
chData = NULL;
if(content.length() >= 682666) //20480000/3
@@ -5152,7 +5438,7 @@ ULONG KBinaryParser::readPPtRecord(FILE* pFile, ppsInfoType* PPS_info, ULONG* au
if(!bReadBuffer(pFile, PPS_info->tPPTDocument.ulSB,
aulBBD, tBBDLen, BIG_BLOCK_SIZE,
aucHeader, ulOff, PPT_RECORD_HEADER))
- return -1;
+ return static_cast(-1);
ulOff += PPT_RECORD_HEADER;
USHORT usVersion = usGetWord(0x00, aucHeader);
@@ -5160,21 +5446,37 @@ ULONG KBinaryParser::readPPtRecord(FILE* pFile, ppsInfoType* PPS_info, ULONG* au
ULONG ulLen = ulGetLong(0x04, aucHeader);
USHORT usVer = usVersion & 0xF;
if(usVer == 0xF) {
+ bool isMasterContainer = false;
+ if (usType == PPT_SLIDE_MASTER) {
+ m_pptEnterSlideMaster = true;
+ isMasterContainer = true;
+ }
+
while(ulOff < ulLen) {
ulOff = readPPtRecord(pFile, PPS_info, aulBBD, tBBDLen, ulOff, content);
}
+
+ if (isMasterContainer) {
+ m_pptEnterSlideMaster = false;
+ }
} else {
- if(usType == PPT_TEXTBYTEATOM || usType == PPT_TEXTCHARATOM) {
+ if((usType == PPT_TEXT_BYTES_ATOM || usType == PPT_TEXT_CHARS_ATOM) && !m_pptEnterSlideMaster) {
long llen = (long)ulLen;
long llenTmp = llen;
UCHAR* chData = (UCHAR*)xmalloc(llen);
+ if(chData == NULL) {
+ /* ulLen 来自文件且可被恶意拉大,分配失败时直接放弃该记录。 */
+ return static_cast(-1);
+ }
if(!bReadBuffer(pFile, PPS_info->tPPTDocument.ulSB,
aulBBD, tBBDLen, BIG_BLOCK_SIZE,
- chData, ulOff, llen))
- return -1;
+ chData, ulOff, llen)) {
+ chData = (UCHAR*)xfree(chData);
+ return static_cast(-1);
+ }
ushort* usData = (ushort*)chData;
- content.append(QString::fromUtf16(usData, llenTmp/2));//char num/2=short num
+ appendNormalizedOfficeText(content, QString::fromUtf16(usData, llenTmp/2));//char num/2=short num
usData = (ushort*)xfree((void*)usData);
chData = NULL;
@@ -5208,11 +5510,61 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) {
ulRootStartblock = ulReadLong(pFile, 0x30);
ulSbdStartblock = ulReadLong(pFile, 0x3c);
ulAdditionalBBDlist = ulReadLong(pFile, 0x44);
+ /*
+ * 这些计数和块索引都直接来自 OLE 文件头,可被恶意构造。
+ * tBBDLen 由真实文件大小推导,不做人为上限(否则会误伤合法大文档);
+ * 这里只校验头部字段与文件实际容量是否自洽:BBD 块数不能超过文件中
+ * 的大块总数,根目录/附加 BBD 链的起始块号必须落在大块范围内。
+ */
+ if(tNumBbdBlocks > tBBDLen ||
+ ulRootStartblock == ULONG_MAX ||
+ ulRootStartblock == END_OF_CHAIN ||
+ ulRootStartblock >= (ULONG)tBBDLen ||
+ (ulAdditionalBBDlist != END_OF_CHAIN &&
+ (ulAdditionalBBDlist == ULONG_MAX ||
+ ulAdditionalBBDlist >= (ULONG)tBBDLen))) {
+ qWarning() << "Rejecting inconsistent OLE header:" << m_strFileName
+ << "bbdLen=" << tBBDLen
+ << "bbdBlocks=" << tNumBbdBlocks
+ << "rootStart=" << ulRootStartblock
+ << "additionalBbd=" << ulAdditionalBBDlist;
+ return -1;
+ }
+ const size_t tFatEntriesPerBlock = BIG_BLOCK_SIZE / 4;
+ if(tNumBbdBlocks > std::numeric_limits::max() / tFatEntriesPerBlock ||
+ tBBDLen > tNumBbdBlocks * tFatEntriesPerBlock) {
+ qWarning() << "Rejecting inconsistent OLE FAT:" << m_strFileName
+ << "bbdLen=" << tBBDLen
+ << "bbdBlocks=" << tNumBbdBlocks
+ << "fatEntriesPerBlock=" << tFatEntriesPerBlock;
+ return -1;
+ }
ulSBLstartblock = ulReadLong(pFile,
(ulRootStartblock + 1) * BIG_BLOCK_SIZE + 0x74);
tSBDLen = (size_t)(ulReadLong(pFile,
(ulRootStartblock + 1) * BIG_BLOCK_SIZE + 0x78) /
SMALL_BLOCK_SIZE);
+ /*
+ * small-block depot 的条目数受文件物理容量约束:每个 small block 占
+ * SMALL_BLOCK_SIZE 字节,数量不可能超过 文件大小 / SMALL_BLOCK_SIZE。
+ */
+ if(tSBDLen > (size_t)(lFilesize / SMALL_BLOCK_SIZE)) {
+ qWarning() << "Rejecting inconsistent OLE small block depot:" << m_strFileName
+ << "sbdLen=" << tSBDLen
+ << "fileSize=" << lFilesize;
+ return -1;
+ }
+ if(!bOleAllocationWithinBudget(tNumBbdBlocks, sizeof(ULONG)) ||
+ !bOleAllocationWithinBudget(tBBDLen, sizeof(ULONG)) ||
+ !bOleAllocationWithinBudget(tSBDLen, sizeof(ULONG))) {
+ qWarning() << "Rejecting oversized OLE depot allocation:" << m_strFileName
+ << "bbdLen=" << tBBDLen
+ << "bbdBlocks=" << tNumBbdBlocks
+ << "sbdLen=" << tSBDLen
+ << "entrySize=" << sizeof(ULONG)
+ << "budget=" << MAX_OLE_DEPOT_ALLOCATION_BYTES;
+ return -1;
+ }
/* All to be xcalloc-ed pointers to NULL */
aulRootList = NULL;
@@ -5223,6 +5575,11 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) {
aulBbdList = (ULONG*)xcalloc(tNumBbdBlocks, sizeof(ULONG));
aulBBD = (ULONG*)xcalloc(tBBDLen, sizeof(ULONG));
+ if(aulBbdList == NULL || aulBBD == NULL) {
+ xfree(aulBbdList);
+ xfree(aulBBD);
+ return -1;
+ }
iToGo = (int)tNumBbdBlocks;
vGetBbdList(pFile, min(iToGo, 109), aulBbdList, 0x4c);
ulStart = 109;
@@ -5247,6 +5604,12 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) {
/* Small Block Depot */
aulSbdList = (unsigned long*)xcalloc(tBBDLen, sizeof(ULONG));
aulSBD = (unsigned long*)xcalloc(tSBDLen, sizeof(ULONG));
+ if(aulSbdList == NULL || aulSBD == NULL) {
+ aulSbdList = (ULONG*)xfree(aulSbdList);
+ aulSBD = (ULONG*)xfree(aulSBD);
+ aulBBD = (ULONG*)xfree(aulBBD);
+ return -1;
+ }
for(iIndex = 0, ulTmp = ulSbdStartblock;
iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN;
@@ -5287,6 +5650,11 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) {
}
aulRootList = (ULONG*)xcalloc(tRootListLen, sizeof(ULONG));
+ if(aulRootList == NULL) {
+ aulSBD = (ULONG*)xfree(aulSBD);
+ aulBBD = (ULONG*)xfree(aulBBD);
+ return -1;
+ }
for(iIndex = 0, ulTmp = ulRootStartblock;
iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN;
iIndex++, ulTmp = aulBBD[ulTmp]) {
@@ -5438,4 +5806,3 @@ bool KBinaryParser::RunParser(QString strFile, QString &content) {
fclose(pFile);
return true;
}
-
diff --git a/src/extractors/binary-parser.h b/src/extractors/binary-parser.h
index f588bd308f0028d8fa6c3925ff936cf8806eb5c7..53eb36493daafbe28fad44886f6bbb9134b01dd2 100644
--- a/src/extractors/binary-parser.h
+++ b/src/extractors/binary-parser.h
@@ -103,6 +103,27 @@ public:
bool RunParser(QString strFile, QString &content);
private:
+ // Word 文本流中的 legacy 控制字符。部分 fSpec 特殊字符需要配合字符属性判断;
+ // 当前解析器只按字符值做保守归一化。
+ static constexpr ushort kWordCharInlinePicture = 0x0001; // 内联图片 / 嵌入对象
+ static constexpr ushort kWordCharTableCellMark = 0x0007; // 表格单元格 / 行结束标记
+ static constexpr ushort kWordCharFloatingObject = 0x0008; // 浮动对象锚点
+ static constexpr ushort kWordCharColumnBreak = 0x000E; // 分栏符
+ static constexpr ushort kWordCharFieldBegin = 0x0013; // 域开始
+ static constexpr ushort kWordCharFieldSeparator = 0x0014; // 域分隔(代码 | 结果)
+ static constexpr ushort kWordCharFieldEnd = 0x0015; // 域结束
+ static constexpr ushort kWordCharNonBreakingHyphen = 0x001E; // 不换行连字符
+ static constexpr ushort kWordCharOptionalHyphen = 0x001F; // 可选连字符
+
+ // 1 = 该层 field 仍在代码段,0 = 已进入结果段
+ using WordFieldStateStack = QVarLengthArray;
+
+ // 归一化 Word 文本(删除结构字符 + 处理域),状态需跨 piece 传递
+ static void normalizeWordText(const QString &text,
+ WordFieldStateStack &fieldStack,
+ int &codeDepth,
+ QString &out);
+
bool bGetPPS(FILE *pFile,
const ULONG *aulRootList, size_t tRootListLen, ppsInfoType *pPPS);
@@ -121,6 +142,7 @@ private:
size_t tBBDLen, ULONG ulPos, QString &content);
QString m_strFileName;
+ bool m_pptEnterSlideMaster = false;
};
#endif // SEARCHHELPER_H
diff --git a/src/extractors/bookmarks-extractor.cpp b/src/extractors/bookmarks-extractor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..438285cea07e8cd3165c1140f6d9cd4718ac1262
--- /dev/null
+++ b/src/extractors/bookmarks-extractor.cpp
@@ -0,0 +1,68 @@
+/*
+*
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#include "bookmarks-extractor.h"
+#include "bookmarks-manager.h"
+
+using namespace UkuiFileMetadata;
+
+const QStringList supportedMimeTypes = {
+ QStringLiteral("application/x-xbel")
+};
+
+BookMarksExtractor::BookMarksExtractor(QObject* parent) : ExtractorPlugin(parent)
+{
+
+}
+
+void BookMarksExtractor::extract(ExtractionResult* result)
+{
+ if (!supportedMimeTypes.contains(result->inputMimetype())) {
+ return;
+ }
+
+ result->addType(Type::Text);
+
+ if (!(result->inputFlags() & (ExtractionResult::ExtractMetaData | ExtractionResult::ExtractPlainText))) {
+ return;
+ }
+
+ const BookMarksManager manager(result->inputUrl());
+ if (!manager.isValid()) {
+ return;
+ }
+
+ if (result->inputFlags() & ExtractionResult::ExtractMetaData) {
+ if (!manager.rootTitle().isEmpty()) {
+ result->add(Property::Title, manager.rootTitle());
+ }
+ }
+
+ if (result->inputFlags() & ExtractionResult::ExtractPlainText) {
+ const QStringList lines = manager.textLines();
+ if (!lines.isEmpty()) {
+ result->append(lines.join(QLatin1Char('\n')));
+ }
+ }
+}
+
+QStringList BookMarksExtractor::mimetypes() const
+{
+ return supportedMimeTypes;
+}
diff --git a/src/extractors/bookmarks-extractor.h b/src/extractors/bookmarks-extractor.h
new file mode 100644
index 0000000000000000000000000000000000000000..5e98b4d11c2dc404f69812fd3a663cf65ba8d956
--- /dev/null
+++ b/src/extractors/bookmarks-extractor.h
@@ -0,0 +1,44 @@
+/*
+*
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H
+#define UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H
+
+
+#include "extractor-plugin.h"
+
+namespace UkuiFileMetadata
+{
+class BookMarksExtractor : public ExtractorPlugin
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID "org.ukui.ukuifilemetadata.ExtractorPlugin"
+ FILE "bookmarks-extractor.json")
+ Q_INTERFACES(UkuiFileMetadata::ExtractorPlugin)
+
+public:
+ explicit BookMarksExtractor(QObject* parent = nullptr);
+
+ void extract(ExtractionResult* result) override;
+ QStringList mimetypes() const override;
+};
+}
+
+
+#endif //UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H
diff --git a/src/extractors/bookmarks-extractor.json b/src/extractors/bookmarks-extractor.json
new file mode 100644
index 0000000000000000000000000000000000000000..57d72bfd6649668b97c0a4e6d3138bcc16f57366
--- /dev/null
+++ b/src/extractors/bookmarks-extractor.json
@@ -0,0 +1,9 @@
+{
+ "Type" : "UKUI_FILE_METADATA_EXTRACTOR",
+ "Version" : "1.0.0",
+ "Name" : "BookMarksExtractor",
+ "Id" : "org.ukui.bookmarksextractor",
+ "MimeTypes" : {
+ "application/x-xbel" : { "Version" : "0.0" }
+ }
+}
diff --git a/src/extractors/ofd-extractor.cpp b/src/extractors/ofd-extractor.cpp
index 03730834761201dbc463d7e0c5195a15523490e7..a976c6e351b87ece2103bf06985344eeefbdda23 100644
--- a/src/extractors/ofd-extractor.cpp
+++ b/src/extractors/ofd-extractor.cpp
@@ -20,11 +20,13 @@
*/
#include "ofd-extractor.h"
-#include "quazip5/quazip.h"
-#include "quazip5/quazipfile.h"
+#include "zip-reader.h"
+#include
#include
#include
+#include
#include
+#include
#define MAX_CONTENT_LENGTH 20480000
@@ -53,100 +55,106 @@ void OfdExtractor::extract(ExtractionResult *result) {
return;
}
- QuaZip zipfile(result->inputUrl());
- if (!zipfile.open(QuaZip::mdUnzip)) {
+ ZipReader archive(result->inputUrl());
+ if (!archive.open()) {
return;
}
result->addType(Type::Document);
-
- QXmlStreamReader reader;
if (result->inputFlags() & ExtractionResult::Flag::ExtractMetaData) {
- if (zipfile.setCurrentFile("OFD.xml")) {
- QuaZipFile fileR(&zipfile);
- if (fileR.open(QIODevice::ReadOnly)) {
- reader.setDevice(&fileR);
- QStringList keywords;
- while (!reader.atEnd()) {
- if (reader.readNextStartElement()) {
- if (reader.name().toString() == "Title") {
- result->add(Property::Title, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "Author") {
- result->add(Property::Author, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "Subject") {
- result->add(Property::Subject, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "Abstract") {
- result->add(Property::Description, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "CreationDate") {
- result->add(Property::CreationDate, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "Creator") {
- result->add(Property::Generator, reader.readElementText());
- continue;
- }
-
- if (reader.name().toString() == "Keyword") {
- keywords.append(reader.readElementText());
- }
+ QByteArray xmlData;
+ if (archive.readEntry(QStringLiteral("OFD.xml"), &xmlData)) {
+ QXmlStreamReader reader(xmlData);
+ QStringList keywords;
+ while (!reader.atEnd()) {
+ if (reader.readNextStartElement()) {
+ if (reader.name().toString() == "Title") {
+ result->add(Property::Title, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "Author") {
+ result->add(Property::Author, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "Subject") {
+ result->add(Property::Subject, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "Abstract") {
+ result->add(Property::Description, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "CreationDate") {
+ result->add(Property::CreationDate, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "Creator") {
+ result->add(Property::Generator, reader.readElementText());
+ continue;
}
- }
- fileR.close();
- if (!keywords.isEmpty()) {
- result->add(Property::Keywords, keywords);
+ if (reader.name().toString() == "Keyword") {
+ keywords.append(reader.readElementText());
+ }
}
}
+
+ if (!keywords.isEmpty()) {
+ result->add(Property::Keywords, keywords);
+ }
}
}
if (!(result->inputFlags() & ExtractionResult::Flag::ExtractPlainText)) {
return;
}
- // GB/T 33190-2016规范定义可以存在多个Doc_x目录,暂时只取第一个目录的内容
- QString prefix("Doc_0/Pages/");
- QStringList fileList;
- for (const auto &file: zipfile.getFileNameList()) {
- if (file.startsWith(prefix)) {
- fileList << file;
+
+ // GB/T 33190-2016允许多个 Doc_x 目录,这里保持现有行为,仅处理 Doc_0。
+ const QRegularExpression pagePattern(QStringLiteral("^Doc_0/Pages/Page_(\\d+)/Content\\.xml$"));
+ QStringList pageEntries;
+ for (const auto &file : archive.entryNames()) {
+ if (pagePattern.match(file).hasMatch()) {
+ pageEntries << file;
}
}
+ std::sort(pageEntries.begin(), pageEntries.end(), [&pagePattern](const QString &left, const QString &right) {
+ const auto leftMatch = pagePattern.match(left);
+ const auto rightMatch = pagePattern.match(right);
+ return leftMatch.captured(1).toInt() < rightMatch.captured(1).toInt();
+ });
QString textContent;
- for (int i = 0; i < fileList.count(); ++i) {
- QString filename = prefix + "Page_" + QString::number(i) + "/Content.xml";
- if (!zipfile.setCurrentFile(filename)) {
+ for (const QString &filename : pageEntries) {
+ bool limitReached = false;
+ if (!archive.processEntry(filename,
+ [&textContent, &limitReached](QIODevice *device) {
+ QXmlStreamReader reader(device);
+ while (!reader.atEnd() && !reader.hasError()) {
+ if (!reader.readNextStartElement()) {
+ if (reader.hasError()) {
+ qWarning() << reader.errorString() << reader.error() << "line:" << reader.lineNumber() << "column:" << reader.columnNumber();
+ break;
+ }
+ }
+
+ if (reader.name().toString() == "TextCode") {
+ textContent.append(reader.readElementText());
+ if (textContent.length() >= MAX_CONTENT_LENGTH / 3) {
+ limitReached = true;
+ break;
+ }
+ }
+ }
+ return true;
+ })) {
continue;
}
- QuaZipFile fileR(&zipfile);
- if (!fileR.open(QIODevice::ReadOnly)) {
- continue;
- }
- reader.setDevice(&fileR);
-
- while (!reader.atEnd()) {
- if (reader.readNextStartElement() && reader.name().toString() == "TextCode") {
- textContent.append(reader.readElementText());
- if (textContent.length() >= MAX_CONTENT_LENGTH / 3) {
- fileR.close();
- zipfile.close();
- result->append(textContent);
- return;
- }
- }
+ if (limitReached) {
+ result->append(textContent);
+ return;
}
- fileR.close();
}
- zipfile.close();
result->append(textContent);
}
diff --git a/src/extractors/office-extractor.cpp b/src/extractors/office-extractor.cpp
index 1e0854e64f7f4c3632b29d632d71460819891760..fda399f59c90f2d487b33bd521162b857c7590d6 100644
--- a/src/extractors/office-extractor.cpp
+++ b/src/extractors/office-extractor.cpp
@@ -20,20 +20,49 @@
*/
#include "office-extractor.h"
#include "binary-parser.h"
+#include
using namespace UkuiFileMetadata;
const QStringList supportedMimeTypes = {
+ // Ambiguous legacy Office MIME aliases from different desktops/mime databases.
+ // Keep them mapped here so OLE-backed files still reach the legacy extractor.
+ QStringLiteral("application/msword"),
+ QStringLiteral("application/vnd.ms-word"),
+ QStringLiteral("application/x-msword"),
+ QStringLiteral("application/vnd.ms-excel"),
+ QStringLiteral("application/msexcel"),
+ QStringLiteral("application/x-msexcel"),
+ QStringLiteral("application/vnd.ms-powerpoint"),
+ QStringLiteral("application/powerpoint"),
+ QStringLiteral("application/mspowerpoint"),
+ QStringLiteral("application/x-mspowerpoint"),
QStringLiteral("application/wps-office.doc"), //2003word
QStringLiteral("application/wps-office.dot"),
QStringLiteral("application/wps-office.wps"),
QStringLiteral("application/wps-office.et"), //2003excel
QStringLiteral("application/wps-office.xls"),
QStringLiteral("application/wps-office.dps"), //2003powerpoint
+ QStringLiteral("application/wps-office.ppt"),
QStringLiteral("application/x-ole-storage"), //OLE file,
//file ends with pps&ppt is identifier as ole file, but ole file has too many sub types, need a filter
};
+static bool isOleStorageFile(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ qWarning() << "OLE check failed! Cannot open file: " << path;
+ return false;
+ }
+ const QByteArray header = file.read(8);
+ if (header.size() != 8) {
+ qWarning() << "OLE check failed! Invalid Header size of" << path;
+ return false;
+ }
+ return header == QByteArray::fromHex("D0CF11E0A1B11AE1");
+}
+
OfficeExtractor::OfficeExtractor(QObject *parent) : ExtractorPlugin(parent)
{
@@ -45,6 +74,10 @@ void OfficeExtractor::extract(ExtractionResult *result)
QString suffix = info.suffix();
if (suffix == "doc" || suffix == "dot" || suffix == "wps" || suffix == "ppt" ||
suffix == "pps" || suffix == "dps" || suffix == "et" || suffix == "xls") {
+ if (!isOleStorageFile(result->inputUrl())) {
+ qDebug() << "Skip non-OLE file for office extractor:" << result->inputUrl();
+ return;
+ }
result->addType(Type::Document);
if (result->inputFlags() & ExtractionResult::ExtractPlainText) {
QString contents;
diff --git a/src/extractors/office-extractor.json b/src/extractors/office-extractor.json
index d1d148fa1be4e66a3d4fac1932731cf915f1c33e..f0fc942fe39aa552b25b0c203fd900a333cc098a 100644
--- a/src/extractors/office-extractor.json
+++ b/src/extractors/office-extractor.json
@@ -4,13 +4,23 @@
"Name" : "OfficeExtractor",
"Id" : "org.ukui.officeextractor",
"MimeTypes" : {
+ "application/msword" : { "Version" : "0.0" },
+ "application/vnd.ms-word" : { "Version" : "0.0" },
+ "application/x-msword" : { "Version" : "0.0" },
+ "application/vnd.ms-excel" : { "Version" : "0.0" },
+ "application/msexcel" : { "Version" : "0.0" },
+ "application/x-msexcel" : { "Version" : "0.0" },
+ "application/vnd.ms-powerpoint" : { "Version" : "0.0" },
+ "application/powerpoint" : { "Version" : "0.0" },
+ "application/mspowerpoint" : { "Version" : "0.0" },
+ "application/x-mspowerpoint" : { "Version" : "0.0" },
"application/wps-office.doc" : { "Version" : "0.0" },
"application/wps-office.dot" : { "Version" : "0.0" },
"application/wps-office.wps" : { "Version" : "0.0" },
"application/wps-office.et" : { "Version" : "0.0" },
"application/wps-office.xls" : { "Version" : "0.0" },
+ "application/wps-office.ppt" : { "Version" : "0.0" },
"application/wps-office.dps" : { "Version" : "0.0" },
"application/x-ole-storage" : { "Version" : "0.0" }
}
}
-
diff --git a/src/extractors/office2007-extractor.cpp b/src/extractors/office2007-extractor.cpp
index 1f51e6b6183f82f59cd2de2d7109d12dc2b651be..9623903ef544abb3be9f15b957d917f997db7764 100644
--- a/src/extractors/office2007-extractor.cpp
+++ b/src/extractors/office2007-extractor.cpp
@@ -20,19 +20,53 @@
*/
#include "office2007-extractor.h"
#include "thumbnail-utils.h"
+#include "zip-reader.h"
#include
-#include
-#include
-#include
#include
+#include
+#include
using namespace UkuiFileMetadata;
static const QString VERSION = "1.0";
static const QString PLUGIN_NAME = "Office2007";
+namespace {
+
+bool appendOfficeXmlText(ZipReader &archive, const QString &entryName, ExtractionResult *result)
+{
+ return archive.processEntry(entryName,
+ [result](QIODevice *device) {
+ QXmlStreamReader reader(device);
+ while (!reader.atEnd()) {
+ if (reader.readNextStartElement() && reader.name().toString() == QLatin1String("t")) {
+ result->append(reader.readElementText());
+ }
+ }
+ return true;
+ },
+ Qt::CaseSensitive);
+}
+
+}
+
const QStringList supportedMimeTypes = {
+ // Ambiguous legacy Office MIME aliases can still point to OOXML content
+ // when the filename keeps an old suffix such as .doc/.xls/.ppt.
+ QStringLiteral("application/msword"),
+ QStringLiteral("application/vnd.ms-word"),
+ QStringLiteral("application/x-msword"),
+ QStringLiteral("application/vnd.ms-excel"),
+ QStringLiteral("application/msexcel"),
+ QStringLiteral("application/x-msexcel"),
+ QStringLiteral("application/vnd.ms-powerpoint"),
+ QStringLiteral("application/powerpoint"),
+ QStringLiteral("application/mspowerpoint"),
+ QStringLiteral("application/x-mspowerpoint"),
+ QStringLiteral("application/wps-office.doc"),
+ QStringLiteral("application/wps-office.xls"),
+ QStringLiteral("application/wps-office.ppt"),
QStringLiteral("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
QStringLiteral("application/vnd.openxmlformats-officedocument.wordprocessingml.template"),
QStringLiteral("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
@@ -55,21 +89,18 @@ void Office2007Extractor::extract(ExtractionResult *result)
return;
}
- QuaZip file(result->inputUrl());
- if (!file.open(QuaZip::mdUnzip)) {
+ ZipReader archive(result->inputUrl());
+ if (!archive.open()) {
return;
}
- QuaZipFile fileR(&file);
- QXmlStreamReader reader(&fileR);
-
if (result->inputFlags() & ExtractionResult::ExtractThumbnail) {
QString thumbnailPath;
- bool needExtract = ThumbnailUtils::needGenerateThumbnail(result, PLUGIN_NAME, VERSION) &&
- file.setCurrentFile(QStringLiteral("_rels/.rels"), QuaZip::csSensitive) &&
- fileR.open(QIODevice::ReadOnly);
-
+ QByteArray relsData;
+ const bool needExtract = ThumbnailUtils::needGenerateThumbnail(result, PLUGIN_NAME, VERSION) &&
+ archive.readEntry(QStringLiteral("_rels/.rels"), &relsData, Qt::CaseSensitive);
if (needExtract) {
+ QXmlStreamReader reader(relsData);
while (!reader.atEnd()) {
if (reader.readNextStartElement() && reader.name().toString() == QLatin1String("Relationship")) {
const auto attributes = reader.attributes();
@@ -79,23 +110,22 @@ void Office2007Extractor::extract(ExtractionResult *result)
}
}
}
- fileR.close();
}
if (!thumbnailPath.isEmpty()) {
- if (file.setCurrentFile(thumbnailPath) && fileR.open(QIODevice::ReadOnly)) {
+ QByteArray thumbnailData;
+ if (archive.readEntry(thumbnailPath, &thumbnailData, Qt::CaseSensitive)) {
QImage thumbnail;
- thumbnail.loadFromData(fileR.readAll());
+ thumbnail.loadFromData(thumbnailData);
ThumbnailUtils::setThumbnail(result, thumbnail, PLUGIN_NAME, VERSION);
- fileR.close();
}
}
}
if (result->inputFlags() & ExtractionResult::ExtractMetaData) {
- if (file.setCurrentFile("docProps/core.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) {
- reader.clear();
- reader.setDevice(&fileR);
+ QByteArray xmlData;
+ if (archive.readEntry(QStringLiteral("docProps/core.xml"), &xmlData, Qt::CaseSensitive)) {
+ QXmlStreamReader reader(xmlData);
while (!reader.atEnd()) {
if (reader.readNextStartElement()) {
if (reader.name().toString() == "description") {
@@ -143,12 +173,10 @@ void Office2007Extractor::extract(ExtractionResult *result)
}
}
}
- fileR.close();
}
- if (file.setCurrentFile("docProps/app.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) {
- reader.clear();
- reader.setDevice(&fileR);
+ if (archive.readEntry(QStringLiteral("docProps/app.xml"), &xmlData, Qt::CaseSensitive)) {
+ QXmlStreamReader reader(xmlData);
while (!reader.atEnd()) {
if (reader.readNextStartElement()) {
if (this->getSupportedMimeType(result->inputMimetype()) ==
@@ -176,63 +204,36 @@ void Office2007Extractor::extract(ExtractionResult *result)
}
}
}
- fileR.close();
}
}
//extract document content
if (result->inputFlags() & ExtractionResult::ExtractPlainText) {
//word
- if (file.setCurrentFile("word/document.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) {
- reader.clear();
- reader.setDevice(&fileR);
+ if (appendOfficeXmlText(archive, QStringLiteral("word/document.xml"), result)) {
result->addType(Type::Document);
- while (!reader.atEnd()) {
- if (reader.readNextStartElement() and reader.name().toString() == "t") {
- result->append(reader.readElementText());
- }
- }
- fileR.close();
//excel
- } else if (file.setCurrentFile("xl/sharedStrings.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) {
- reader.clear();
- reader.setDevice(&fileR);
+ } else if (appendOfficeXmlText(archive, QStringLiteral("xl/sharedStrings.xml"), result)) {
result->addType(Type::Document);
result->addType(Type::Spreadsheet);
- while (!reader.atEnd()) {
- if (reader.readNextStartElement() and reader.name().toString() == "t") {
- result->append(reader.readElementText());
- }
- }
- fileR.close();
} else {
//powerpoint
QStringList slideXmlList;
- for (const QString &slideFile : file.getFileNameList()) {
- if (slideFile.contains(QRegExp("ppt/slides/slide*"))) {
+ const QRegularExpression slidePattern(QStringLiteral("^ppt/slides/slide[^/]*$"));
+ for (const QString &slideFile : archive.entryNames()) {
+ if (slidePattern.match(slideFile).hasMatch()) {
slideXmlList << slideFile;
}
}
if (!slideXmlList.isEmpty()) {
result->addType(Type::Document);
result->addType(Type::Presentation);
- for (QString slideXmlFile : slideXmlList) {
- if (file.setCurrentFile(slideXmlFile, QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) {
- reader.clear();
- reader.setDevice(&fileR);
- while (!reader.atEnd()) {
- if(reader.readNextStartElement() and reader.name().toString() == "t"){
- result->append(reader.readElementText());
- }
- }
- fileR.close();
- }
+ for (const QString &slideXmlFile : slideXmlList) {
+ appendOfficeXmlText(archive, slideXmlFile, result);
}
}
}
}
-
- file.close();
}
QStringList Office2007Extractor::mimetypes() const
diff --git a/src/extractors/office2007-extractor.json b/src/extractors/office2007-extractor.json
index fc9f1d6ac7dbf5e432c96d7fb2cc06e869edd864..ff1c33f3101535a021d7efd9545d4a3b002cdd71 100644
--- a/src/extractors/office2007-extractor.json
+++ b/src/extractors/office2007-extractor.json
@@ -4,6 +4,19 @@
"Name" : "Office2007Extractor",
"Id" : "org.ukui.office2007extractor",
"MimeTypes" : {
+ "application/msword" : { "Version" : "0.0" },
+ "application/vnd.ms-word" : { "Version" : "0.0" },
+ "application/x-msword" : { "Version" : "0.0" },
+ "application/vnd.ms-excel" : { "Version" : "0.0" },
+ "application/msexcel" : { "Version" : "0.0" },
+ "application/x-msexcel" : { "Version" : "0.0" },
+ "application/vnd.ms-powerpoint" : { "Version" : "0.0" },
+ "application/powerpoint" : { "Version" : "0.0" },
+ "application/mspowerpoint" : { "Version" : "0.0" },
+ "application/x-mspowerpoint" : { "Version" : "0.0" },
+ "application/wps-office.doc" : { "Version" : "0.0" },
+ "application/wps-office.xls" : { "Version" : "0.0" },
+ "application/wps-office.ppt" : { "Version" : "0.0" },
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" : { "Version" : "0.0" },
"application/vnd.openxmlformats-officedocument.wordprocessingml.template" : { "Version" : "0.0" },
"application/vnd.openxmlformats-officedocument.presentationml.presentation" : { "Version" : "0.0" },
diff --git a/src/extractors/pdf-extractor.h b/src/extractors/pdf-extractor.h
index 806fba0591c0f6a2343d08ba9dd54f23ed32129c..497a3fafea0262620dd0e9a0ead0cf70ac5c2450 100644
--- a/src/extractors/pdf-extractor.h
+++ b/src/extractors/pdf-extractor.h
@@ -23,7 +23,11 @@
#include "extractor-plugin.h"
+#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
#include
+#else
+#include
+#endif
namespace UkuiFileMetadata {
diff --git a/src/extractors/taglib-extractor.cpp b/src/extractors/taglib-extractor.cpp
index f69b7e0edec1e134862cc77b97af24a454ac9d24..31277bc0733bef51033224e0fb1d29d28e10a788 100644
--- a/src/extractors/taglib-extractor.cpp
+++ b/src/extractors/taglib-extractor.cpp
@@ -36,6 +36,7 @@
#include
#include
#include
+#include
using namespace UkuiFileMetadata;
@@ -47,6 +48,7 @@ const QStringList supportedMimeTypes = {
QStringLiteral("audio/ogg"),
QStringLiteral("audio/opus"),
QStringLiteral("audio/wav"),
+ QStringLiteral("audio/vnd.wave"),
QStringLiteral("audio/vnd.audible.aax"),
QStringLiteral("audio/x-aiff"),
QStringLiteral("audio/x-aifc"),
@@ -248,7 +250,7 @@ void readGenericProperties(const TagLib::PropertyMap &savedProperties, Extractio
void extractId3Tags(TagLib::ID3v2::Tag* Id3Tags, ExtractionResult* result)
{
- if (!(result->inputFlags() & ExtractionResult::ExtractMetaData) || Id3Tags->isEmpty()) {
+ if (!(result->inputFlags() & ExtractionResult::ExtractMetaData) || !Id3Tags || Id3Tags->isEmpty()) {
return;
}
@@ -352,7 +354,7 @@ extractId3Cover(const TagLib::ID3v2::Tag* Id3Tags,
const EmbeddedImageData::ImageTypes types)
{
QMap images;
- if (!types || Id3Tags->isEmpty()) {
+ if (!types || !Id3Tags || Id3Tags->isEmpty()) {
return images;
}
@@ -362,6 +364,9 @@ extractId3Cover(const TagLib::ID3v2::Tag* Id3Tags,
using PictureFrame = TagLib::ID3v2::AttachedPictureFrame;
for (const auto& frame : std::as_const(lstID3v2)) {
const auto *coverFrame = dynamic_cast(frame);
+ if (!coverFrame) {
+ continue;
+ }
const auto imageType = mapTaglibType(coverFrame->type());
if (types & imageType) {
const auto& picture = coverFrame->picture();
@@ -655,15 +660,18 @@ void TaglibExtractor::extract(ExtractionResult *result) {
extractId3Tags(file.tag(), result);
}
}
- } else if (mimeType == QLatin1String("audio/wav") || mimeType == QLatin1String("audio/x-wav")) {
+ } else if (mimeType == QLatin1String("audio/wav")
+ || mimeType == QLatin1String("audio/vnd.wave")
+ || mimeType == QLatin1String("audio/x-wav")) {
TagLib::RIFF::WAV::File file(&stream, true);
if (file.isValid()) {
extractAudioProperties(&file, result);
readGenericProperties(file.properties(), result);
if (file.hasID3v2Tag()) {
- result->addImageData(extractId3Cover(file.tag(), imageTypes));
- extractId3Thumbnails(file.tag(), result);
- extractId3Tags(file.tag(), result);
+ auto *id3v2Tag = file.ID3v2Tag();
+ result->addImageData(extractId3Cover(id3v2Tag, imageTypes));
+ extractId3Thumbnails(id3v2Tag, result);
+ extractId3Tags(id3v2Tag, result);
}
}
} else if (mimeType == QLatin1String("audio/x-musepack")) {
diff --git a/src/extractors/taglib-extractor.json b/src/extractors/taglib-extractor.json
index b894a8e681e25fa87aec4d6535d3d65696627602..4fae8cbab63696af0852b74979aac9921cf0b984 100644
--- a/src/extractors/taglib-extractor.json
+++ b/src/extractors/taglib-extractor.json
@@ -11,6 +11,7 @@
"audio/ogg" : { "Version" : "0.0" },
"audio/opus" : { "Version" : "0.0" },
"audio/wav" : { "Version" : "0.0" },
+ "audio/vnd.wave" : { "Version" : "0.0" },
"audio/vnd.audible.aax" : { "Version" : "0.0" },
"audio/x-aiff" : { "Version" : "0.0" },
"audio/x-aifc" : { "Version" : "0.0" },
@@ -24,4 +25,4 @@
"audio/x-wav" : { "Version" : "0.0" },
"audio/x-wavpack" : { "Version" : "0.0" }
}
-}
\ No newline at end of file
+}
diff --git a/src/extractors/text-extractor.cpp b/src/extractors/text-extractor.cpp
index 38950276ceaabb649629fba0d82c33f2507b3119..fde3520b6a9fc60b07f4796b3577afe6a8d3f324 100644
--- a/src/extractors/text-extractor.cpp
+++ b/src/extractors/text-extractor.cpp
@@ -22,7 +22,9 @@
#include "thumbnail-utils.h"
#include
#include
+#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
#include
+#endif
#include
#include
#include
@@ -69,11 +71,20 @@ void TextExtractor::extract(ExtractionResult *result)
uchardet_data_end(chardet);
const char *codec = uchardet_get_charset(chardet);
+ QTextStream stream(encodedString, QIODevice::ReadOnly);
+
+#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0))
if (QTextCodec::codecForName(codec) == nullptr)
qWarning() << "Unsupported Text encoding format" << result->inputUrl() << QString::fromLocal8Bit(codec);
-
- QTextStream stream(encodedString, QIODevice::ReadOnly);
stream.setCodec(codec);
+#else
+ auto encoding = QStringConverter::encodingForName(codec);
+ if (!encoding) {
+ qWarning() << "Unsupported Text encoding format" << result->inputUrl() << QString::fromLocal8Bit(codec);
+ encoding = QStringConverter::Utf8;
+ }
+ stream.setEncoding(encoding.value());
+#endif
uchardet_delete(chardet);
int lines = 0;
diff --git a/src/extractors/uof-extractor.cpp b/src/extractors/uof-extractor.cpp
index e6b6c1709191ef3a37885cac3edafade8f7ff8d3..19529f52abe6d47b2072621c7f6e3775c84adf98 100644
--- a/src/extractors/uof-extractor.cpp
+++ b/src/extractors/uof-extractor.cpp
@@ -20,8 +20,7 @@
*/
#include "uof-extractor.h"
-#include "quazip5/quazip.h"
-#include "quazip5/quazipfile.h"
+#include "zip-reader.h"
#include
#include
#include
@@ -38,6 +37,7 @@ const QStringList supportedMimeTypes = {
QStringLiteral("application/wps-office.uop"),
QStringLiteral("application/wps-office.uos"),
QStringLiteral("application/wps-office.uot"),
+ QStringLiteral("application/wps-office.uot3"),
QStringLiteral("application/xml")
};
@@ -59,54 +59,50 @@ void UofExtractor::extract(ExtractionResult *result) {
} else {
//参考标准 GJB/Z 165-2012 https://www.doc88.com/p-9089133923912.html
//解析UOF2.0文件的元数据
- QuaZip file(result->inputUrl());
- if (!file.open(QuaZip::mdUnzip)) {
+ ZipReader archive(result->inputUrl());
+ if (!archive.open()) {
return;
}
- QuaZipFile fileR;
- QXmlStreamReader reader;
//parse meta info
- if ((result->inputFlags() & ExtractionResult::Flag::ExtractMetaData) && file.setCurrentFile("_meta/meta.xml")) {
- fileR.setZip(&file);
- if (fileR.open(QIODevice::ReadOnly)) {
- reader.setDevice(&fileR);
- while (!reader.atEnd()) {
- if (reader.readNextStartElement()) {
- if (reader.name().toString() == "作者_5204") {
- result->add(Property::Author, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "标题_5201") {
- result->add(Property::Title, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "主题_5202") {
- result->add(Property::Subject, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "摘要_5206") {
- result->add(Property::Description, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "创建日期_5207") {
- result->add(Property::CreationDate, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "创建应用程序_520A") {
- result->add(Property::Generator, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "页数_5215") {
- result->add(Property::PageCount, reader.readElementText());
- continue;
- }
- if (reader.name().toString() == "字数_5216") {
- result->add(Property::WordCount, reader.readElementText());
- }
+ QByteArray xmlData;
+ if ((result->inputFlags() & ExtractionResult::Flag::ExtractMetaData)
+ && archive.readEntry(QStringLiteral("_meta/meta.xml"), &xmlData)) {
+ QXmlStreamReader reader(xmlData);
+ while (!reader.atEnd()) {
+ if (reader.readNextStartElement()) {
+ if (reader.name().toString() == "作者_5204") {
+ result->add(Property::Author, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "标题_5201") {
+ result->add(Property::Title, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "主题_5202") {
+ result->add(Property::Subject, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "摘要_5206") {
+ result->add(Property::Description, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "创建日期_5207") {
+ result->add(Property::CreationDate, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "创建应用程序_520A") {
+ result->add(Property::Generator, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "页数_5215") {
+ result->add(Property::PageCount, reader.readElementText());
+ continue;
+ }
+ if (reader.name().toString() == "字数_5216") {
+ result->add(Property::WordCount, reader.readElementText());
}
}
- fileR.close();
}
}
@@ -116,29 +112,25 @@ void UofExtractor::extract(ExtractionResult *result) {
}
if (suffix == "uot" ||suffix == "uos") {
- if (file.setCurrentFile("content.xml")) {
- fileR.setZip(&file);
- if (!fileR.open(QIODevice::ReadOnly)) {
- file.close();
- return;
- }
- reader.setDevice(&fileR);
-
- QString textContent;
- while (!reader.atEnd()) {
- if (reader.readNextStartElement() && reader.name().toString() == "文本串_415B") {
- textContent.append(reader.readElementText());
- if (textContent.length() >= MAX_CONTENT_LENGTH / 3) {
- break;
- }
- }
- }
- fileR.close();
- file.close();
+ QString textContent;
+ if (archive.processEntry(QStringLiteral("content.xml"),
+ [&textContent](QIODevice *device) {
+ QXmlStreamReader reader(device);
+ while (!reader.atEnd()) {
+ if (reader.readNextStartElement()
+ && reader.name().toString() == "文本串_415B") {
+ textContent.append(reader.readElementText());
+ if (textContent.length() >= MAX_CONTENT_LENGTH / 3) {
+ break;
+ }
+ }
+ }
+ return true;
+ })) {
result->append(textContent);
}
} else if (suffix == "uop") {
- parsePptOfUof2(result);
+ parsePptOfUof2(result, archive);
}
}
@@ -366,37 +358,26 @@ void UofExtractor::parseUofFile(ExtractionResult *result) {
file.close();
}
-bool loadZipFileToDoc(QuaZip &zipFile, QDomDocument &doc, const QString &fileName)
+bool loadZipFileToDoc(ZipReader &zipReader, QDomDocument &doc, const QString &fileName)
{
- if (!zipFile.isOpen() && !zipFile.open(QuaZip::mdUnzip)) {
- return false;
- }
-
- if (!zipFile.setCurrentFile(fileName)) {
- return false;
- }
-
- QuaZipFile file(&zipFile);
- if (!file.open(QIODevice::ReadOnly)) {
+ QByteArray xmlData;
+ if (!zipReader.readEntry(fileName, &xmlData)) {
return false;
}
doc.clear();
- if (!doc.setContent(&file)) {
- file.close();
+ if (!doc.setContent(xmlData)) {
return false;
}
- file.close();
return true;
}
//ppt文档的内容存放在graphics.xml中,需要先解析content中的引用再解析graphics内容
-void UofExtractor::parsePptOfUof2(ExtractionResult *result) {
- QuaZip zipFile(result->inputUrl());
+void UofExtractor::parsePptOfUof2(ExtractionResult *result, ZipReader &zipReader) {
QDomDocument doc;
- if (!loadZipFileToDoc(zipFile, doc, "content.xml")) {
+ if (!loadZipFileToDoc(zipReader, doc, "content.xml")) {
return;
}
@@ -421,7 +402,7 @@ void UofExtractor::parsePptOfUof2(ExtractionResult *result) {
return;
}
- if (!loadZipFileToDoc(zipFile, doc, "graphics.xml")) {
+ if (!loadZipFileToDoc(zipReader, doc, "graphics.xml")) {
return;
}
@@ -454,4 +435,4 @@ void UofExtractor::parsePptOfUof2(ExtractionResult *result) {
}
}
result->append(textContent);
-}
\ No newline at end of file
+}
diff --git a/src/extractors/uof-extractor.h b/src/extractors/uof-extractor.h
index c64a9ce1ca033088bfc0e63f4394080bce3fb65e..ffb18960f94f0a7ff20525c493f4cb9a459c006f 100644
--- a/src/extractors/uof-extractor.h
+++ b/src/extractors/uof-extractor.h
@@ -25,6 +25,9 @@
#include "extractor-plugin.h"
namespace UkuiFileMetadata {
+
+class ZipReader;
+
class UofExtractor : public ExtractorPlugin
{
Q_OBJECT
@@ -38,7 +41,7 @@ public:
QStringList mimetypes() const override;
private:
void parseUofFile(ExtractionResult *result);
- void parsePptOfUof2(ExtractionResult *result);
+ void parsePptOfUof2(ExtractionResult *result, ZipReader &zipReader);
friend class UofExtractorTest;
};
diff --git a/src/extractors/uof-extractor.json b/src/extractors/uof-extractor.json
index 24416137faf8efef838d46daa1068f91a31ab97d..1ccfbc09cefd6b4fca9cfcac376fff114b2a5b62 100644
--- a/src/extractors/uof-extractor.json
+++ b/src/extractors/uof-extractor.json
@@ -7,6 +7,7 @@
"application/wps-office.uof" : { "Version" : "0.0" },
"application/wps-office.uop" : { "Version" : "0.0" },
"application/wps-office.uos" : { "Version" : "0.0" },
- "application/wps-office.uot" : { "Version" : "0.0" }
+ "application/wps-office.uot" : { "Version" : "0.0" },
+ "application/wps-office.uot3" : { "Version" : "0.0" }
}
-}
\ No newline at end of file
+}
diff --git a/src/ukui-file-metadata-config.cmake.in b/src/ukui-file-metadata-config.cmake.in
index 7e42a19063e095e552b8c17d348ef66a3d494c30..2fbf32899808bec848b10e8db9427e651407e3f5 100644
--- a/src/ukui-file-metadata-config.cmake.in
+++ b/src/ukui-file-metadata-config.cmake.in
@@ -1,9 +1,6 @@
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
-find_dependency(Qt@QT_VERSION_MAJOR@Core "@REQUIRED_QT_VERSION@")
-if(TARGET Qt6::Core)
- find_dependency(Qt6Core5Compat @REQUIRED_QT_VERSION@)
-endif()
+find_dependency(Qt@QT_VERSION_MAJOR@ "@REQUIRED_QT_VERSION@" COMPONENTS Core Gui Xml Widgets)
-include("${CMAKE_CURRENT_LIST_DIR}/ukui-file-metadata-targets.cmake")
\ No newline at end of file
+include("${CMAKE_CURRENT_LIST_DIR}/ukui-file-metadata-targets.cmake")
diff --git a/src/zip-reader.cpp b/src/zip-reader.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e0a37a9df40cf889ebfa2da9903e0465721eea58
--- /dev/null
+++ b/src/zip-reader.cpp
@@ -0,0 +1,641 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#include "zip-reader.h"
+
+#include
+#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
+#include
+#else
+#include
+#endif
+#include
+#include
+#include
+
+#include
+#include
+
+namespace UkuiFileMetadata {
+
+namespace {
+
+constexpr int kReadBufferSize = 8192;
+constexpr uLong kUtf8NameFlag = 1u << 11;
+constexpr quint16 kUnicodePathExtraFieldId = 0x7075;
+constexpr quint8 kUnicodePathExtraFieldVersion = 1;
+
+struct EntryRecord
+{
+ QByteArray rawName;
+ QString decodedName;
+ unz64_file_pos position {};
+ quint64 uncompressedSize = 0;
+ uLong flags = 0;
+};
+
+void appendUniqueCodecName(QList *codecNames, const QByteArray &codecName)
+{
+ if (codecNames == nullptr) {
+ return;
+ }
+
+ const QByteArray trimmedName = codecName.trimmed();
+ if (trimmedName.isEmpty()) {
+ return;
+ }
+
+ if (!codecNames->contains(trimmedName)) {
+ codecNames->append(trimmedName);
+ }
+}
+
+QList normalizeCodecNames(const QList &codecNames)
+{
+ QList normalizedNames;
+ for (const QByteArray &codecName : codecNames) {
+ appendUniqueCodecName(&normalizedNames, codecName);
+ }
+ return normalizedNames;
+}
+
+QByteArray systemCodecName()
+{
+#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
+ const char *codecName = QStringConverter::nameForEncoding(QStringConverter::System);
+ return codecName ? QByteArray(codecName) : QByteArray();
+#else
+ const QTextCodec *localeCodec = QTextCodec::codecForLocale();
+ return localeCodec ? localeCodec->name() : QByteArray();
+#endif
+}
+
+QList defaultFallbackCodecNames()
+{
+ QList codecNames;
+ appendUniqueCodecName(&codecNames, QByteArrayLiteral("CP437"));
+ return codecNames;
+}
+
+quint16 readLittleEndian16(const uchar *data)
+{
+ return static_cast(data[0])
+ | (static_cast(data[1]) << 8);
+}
+
+quint32 readLittleEndian32(const uchar *data)
+{
+ return static_cast(data[0])
+ | (static_cast(data[1]) << 8)
+ | (static_cast(data[2]) << 16)
+ | (static_cast(data[3]) << 24);
+}
+
+quint32 rawNameCrc32(const QByteArray &rawName)
+{
+ const auto *bytes = reinterpret_cast(rawName.constData());
+ return crc32(0L, bytes, static_cast(rawName.size()));
+}
+
+bool tryDecodeWithCodec(const QByteArray &rawName, const char *codecName, QString *decodedName)
+{
+ if (codecName == nullptr || decodedName == nullptr) {
+ return false;
+ }
+
+#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
+ QStringDecoder decoder(codecName);
+ if (!decoder.isValid()) {
+ return false;
+ }
+
+ const QString text = decoder(rawName);
+ if (decoder.hasError()) {
+ return false;
+ }
+
+ QStringEncoder encoder(codecName);
+ if (!encoder.isValid()) {
+ return false;
+ }
+
+ const QByteArray roundTrip = encoder(text);
+ if (roundTrip != rawName) {
+ return false;
+ }
+#else
+ QTextCodec *codec = QTextCodec::codecForName(codecName);
+ if (codec == nullptr) {
+ return false;
+ }
+
+ QTextCodec::ConverterState decodeState;
+ const QString text = codec->toUnicode(rawName.constData(), rawName.size(), &decodeState);
+ if (decodeState.invalidChars != 0) {
+ return false;
+ }
+
+ if (codec->fromUnicode(text) != rawName) {
+ return false;
+ }
+#endif
+
+ *decodedName = text;
+ return true;
+}
+
+bool tryDecodeUnicodePathExtraField(const QByteArray &rawName,
+ const QByteArray &extraField,
+ QString *decodedName)
+{
+ if (decodedName == nullptr || extraField.isEmpty()) {
+ return false;
+ }
+
+ const auto *extraBytes = reinterpret_cast(extraField.constData());
+ int offset = 0;
+ while (offset + 4 <= extraField.size()) {
+ const quint16 headerId = readLittleEndian16(extraBytes + offset);
+ const quint16 dataSize = readLittleEndian16(extraBytes + offset + 2);
+ offset += 4;
+
+ if (offset + dataSize > extraField.size()) {
+ break;
+ }
+
+ if (headerId == kUnicodePathExtraFieldId) {
+ if (dataSize < 5 || extraBytes[offset] != kUnicodePathExtraFieldVersion) {
+ offset += dataSize;
+ continue;
+ }
+
+ const quint32 storedNameCrc = readLittleEndian32(extraBytes + offset + 1);
+ if (storedNameCrc != rawNameCrc32(rawName)) {
+ offset += dataSize;
+ continue;
+ }
+
+ const QByteArray utf8Name(reinterpret_cast(extraBytes + offset + 5),
+ dataSize - 5);
+ return tryDecodeWithCodec(utf8Name, "UTF-8", decodedName);
+ }
+
+ offset += dataSize;
+ }
+
+ return false;
+}
+
+class EntryDevice final : public QIODevice
+{
+public:
+ explicit EntryDevice(unzFile archiveHandle, quint64 uncompressedSizeValue)
+ : archive(archiveHandle)
+ , uncompressedSize(uncompressedSizeValue)
+ {
+ }
+
+ bool isSequential() const override
+ {
+ return true;
+ }
+
+ qint64 size() const override
+ {
+ if (uncompressedSize > static_cast(std::numeric_limits::max())) {
+ return -1;
+ }
+ return static_cast(uncompressedSize);
+ }
+
+ bool reachedEnd() const
+ {
+ return endReached;
+ }
+
+ bool hasReadError() const
+ {
+ return readError;
+ }
+
+protected:
+ qint64 readData(char *data, qint64 maxSize) override
+ {
+ if (archive == nullptr || maxSize <= 0) {
+ return 0;
+ }
+
+ const auto chunkSize = static_cast(std::min(
+ maxSize, static_cast(std::numeric_limits::max())));
+ const int bytesRead = unzReadCurrentFile(archive, data, chunkSize);
+ if (bytesRead < 0) {
+ readError = true;
+ setErrorString(QStringLiteral("Failed to read ZIP entry data"));
+ return -1;
+ }
+
+ if (bytesRead == 0) {
+ endReached = true;
+ }
+
+ return bytesRead;
+ }
+
+ qint64 writeData(const char *, qint64) override
+ {
+ return -1;
+ }
+
+private:
+ unzFile archive = nullptr;
+ quint64 uncompressedSize = 0;
+ bool endReached = false;
+ bool readError = false;
+};
+
+}
+
+class ZipReader::Private
+{
+public:
+ explicit Private(QString archivePathValue)
+ : archivePath(std::move(archivePathValue))
+ , fallbackCodecs(defaultFallbackCodecNames())
+ {
+ }
+
+ ~Private()
+ {
+ close();
+ }
+
+ void setArchivePath(const QString &archivePathValue)
+ {
+ if (archivePath == archivePathValue) {
+ return;
+ }
+
+ close();
+ archivePath = archivePathValue;
+ }
+
+ QList fallbackFileNameCodecs() const
+ {
+ return fallbackCodecs;
+ }
+
+ void setFallbackFileNameCodecs(const QList &codecNames)
+ {
+ const QList normalizedCodecs = normalizeCodecNames(codecNames);
+ if (fallbackCodecs == normalizedCodecs) {
+ return;
+ }
+
+ close();
+ fallbackCodecs = normalizedCodecs;
+ }
+
+ bool open()
+ {
+ if (archive != nullptr) {
+ return true;
+ }
+
+ if (archivePath.isEmpty()) {
+ return false;
+ }
+
+ const QByteArray encodedPath = QFile::encodeName(archivePath);
+ auto *encodedArchivePath = const_cast(encodedPath.constData());
+ archive = unzOpen64(encodedArchivePath);
+ if (archive == nullptr) {
+ archive = unzOpen(encodedArchivePath);
+ }
+ if (archive == nullptr) {
+ return false;
+ }
+
+ entryRecordsCached = false;
+ entryRecords.clear();
+
+ const int result = unzGoToFirstFile(archive);
+ if (result != UNZ_OK && result != UNZ_END_OF_LIST_OF_FILE) {
+ close();
+ return false;
+ }
+
+ return true;
+ }
+
+ bool isOpen() const
+ {
+ return archive != nullptr;
+ }
+
+ void close()
+ {
+ if (archive != nullptr) {
+ unzClose(archive);
+ archive = nullptr;
+ }
+
+ entryRecordsCached = false;
+ entryRecords.clear();
+ }
+
+ QStringList entryNamesList()
+ {
+ if (!rebuildEntryRecords()) {
+ return {};
+ }
+
+ QStringList entryNames;
+ entryNames.reserve(entryRecords.size());
+ for (const EntryRecord &entryRecord : entryRecords) {
+ entryNames.append(entryRecord.decodedName);
+ }
+ return entryNames;
+ }
+
+ bool readEntry(const QString &entryName, QByteArray *data,
+ Qt::CaseSensitivity caseSensitivity)
+ {
+ if (data == nullptr) {
+ return false;
+ }
+
+ data->clear();
+ return processEntry(entryName,
+ [data](QIODevice *device) {
+ const qint64 entrySize = device->size();
+ if (entrySize > 0
+ && entrySize <= static_cast(std::numeric_limits::max())) {
+ data->reserve(static_cast(entrySize));
+ }
+
+ while (true) {
+ const QByteArray chunk = device->read(kReadBufferSize);
+ if (chunk.isEmpty()) {
+ break;
+ }
+ data->append(chunk);
+ }
+ return true;
+ }, caseSensitivity);
+ }
+
+ bool processEntry(const QString &entryName, const std::function &processor,
+ Qt::CaseSensitivity caseSensitivity)
+ {
+ unz_file_info64 fileInfo {};
+ if (!processor || !openEntry(entryName, caseSensitivity, &fileInfo)) {
+ return false;
+ }
+
+ EntryDevice device(archive, fileInfo.uncompressed_size);
+ device.open(QIODevice::ReadOnly);
+
+ const bool processed = processor(&device) && !device.hasReadError();
+
+ device.close();
+ const bool closed = closeCurrentEntry(device.reachedEnd());
+ return processed && closed;
+ }
+
+ QString decodeEntryName(const QByteArray &rawName,
+ const QByteArray &extraField,
+ uLong flags) const
+ {
+ QString decodedName;
+ if (tryDecodeUnicodePathExtraField(rawName, extraField, &decodedName)) {
+ return decodedName;
+ }
+
+ if ((flags & kUtf8NameFlag) != 0
+ && tryDecodeWithCodec(rawName, "UTF-8", &decodedName)) {
+ return decodedName;
+ }
+
+ const QByteArray localeCodecName = systemCodecName();
+ if (!localeCodecName.isEmpty() && tryDecodeWithCodec(rawName, localeCodecName.constData(), &decodedName)) {
+ return decodedName;
+ }
+
+ for (const QByteArray &codecName : fallbackCodecs) {
+ if (tryDecodeWithCodec(rawName, codecName.constData(), &decodedName)) {
+ return decodedName;
+ }
+ }
+
+ return QString::fromLatin1(rawName.toHex());
+ }
+
+ const EntryRecord *findEntryRecord(const QString &entryName,
+ Qt::CaseSensitivity caseSensitivity)
+ {
+ if (!rebuildEntryRecords()) {
+ return nullptr;
+ }
+
+ const auto it = std::find_if(entryRecords.cbegin(), entryRecords.cend(),
+ [&entryName, caseSensitivity](const EntryRecord &entryRecord) {
+ return QString::compare(entryRecord.decodedName, entryName,
+ caseSensitivity) == 0;
+ });
+ if (it == entryRecords.cend()) {
+ return nullptr;
+ }
+
+ return &(*it);
+ }
+
+ bool openEntry(const QString &entryName, Qt::CaseSensitivity caseSensitivity, unz_file_info64 *fileInfo)
+ {
+ if (fileInfo == nullptr || !open()) {
+ return false;
+ }
+
+ const EntryRecord *entryRecord = findEntryRecord(entryName, caseSensitivity);
+ if (entryRecord == nullptr) {
+ return false;
+ }
+
+ if (unzGoToFilePos64(archive, &entryRecord->position) != UNZ_OK) {
+ return false;
+ }
+
+ if (unzGetCurrentFileInfo64(archive, fileInfo, nullptr, 0, nullptr, 0, nullptr, 0) != UNZ_OK) {
+ return false;
+ }
+
+ return unzOpenCurrentFile(archive) == UNZ_OK;
+ }
+
+ bool closeCurrentEntry(bool requireCompleteRead = true)
+ {
+ const int closeResult = unzCloseCurrentFile(archive);
+ return !requireCompleteRead || closeResult == UNZ_OK;
+ }
+
+ bool rebuildEntryRecords()
+ {
+ if (entryRecordsCached) {
+ return true;
+ }
+
+ if (!open()) {
+ return false;
+ }
+
+ unz64_file_pos savedPosition {};
+ const bool hasSavedPosition = unzGetFilePos64(archive, &savedPosition) == UNZ_OK;
+
+ QList refreshedEntryRecords;
+ int result = unzGoToFirstFile(archive);
+ while (result == UNZ_OK) {
+ EntryRecord entryRecord;
+ if (!currentEntryRecord(&entryRecord)) {
+ return false;
+ }
+ refreshedEntryRecords.append(entryRecord);
+ result = unzGoToNextFile(archive);
+ }
+
+ if (result != UNZ_END_OF_LIST_OF_FILE) {
+ return false;
+ }
+
+ if (hasSavedPosition) {
+ unzGoToFilePos64(archive, &savedPosition);
+ } else {
+ unzGoToFirstFile(archive);
+ }
+
+ entryRecords = std::move(refreshedEntryRecords);
+ entryRecordsCached = true;
+ return true;
+ }
+
+ bool currentEntryRecord(EntryRecord *entryRecord) const
+ {
+ if (archive == nullptr || entryRecord == nullptr) {
+ return false;
+ }
+
+ unz_file_info64 fileInfo {};
+ if (unzGetCurrentFileInfo64(archive, &fileInfo, nullptr, 0, nullptr, 0, nullptr, 0) != UNZ_OK) {
+ return false;
+ }
+
+ QByteArray rawName(static_cast(fileInfo.size_filename) + 1, '\0');
+ QByteArray extraField(static_cast(fileInfo.size_file_extra), '\0');
+ if (unzGetCurrentFileInfo64(archive,
+ nullptr,
+ rawName.data(),
+ rawName.size(),
+ extraField.isEmpty() ? nullptr : extraField.data(),
+ extraField.size(),
+ nullptr,
+ 0)
+ != UNZ_OK) {
+ return false;
+ }
+
+ if (unzGetFilePos64(archive, &entryRecord->position) != UNZ_OK) {
+ return false;
+ }
+
+ rawName.chop(1);
+ entryRecord->rawName = rawName;
+ entryRecord->decodedName = decodeEntryName(entryRecord->rawName, extraField, fileInfo.flag);
+ entryRecord->uncompressedSize = fileInfo.uncompressed_size;
+ entryRecord->flags = fileInfo.flag;
+ return true;
+ }
+
+ QString archivePath;
+ unzFile archive = nullptr;
+ QList entryRecords;
+ bool entryRecordsCached = false;
+ QList fallbackCodecs;
+};
+
+ZipReader::ZipReader(QString archivePath)
+ : d(new Private(std::move(archivePath)))
+{
+}
+
+ZipReader::~ZipReader()
+{
+}
+
+void ZipReader::setArchivePath(const QString &archivePath)
+{
+ d->setArchivePath(archivePath);
+}
+
+QString ZipReader::archivePath() const
+{
+ return d->archivePath;
+}
+
+QList ZipReader::fallbackFileNameCodecs() const
+{
+ return d->fallbackFileNameCodecs();
+}
+
+void ZipReader::setFallbackFileNameCodecs(const QList &codecNames)
+{
+ d->setFallbackFileNameCodecs(codecNames);
+}
+
+bool ZipReader::open()
+{
+ return d->open();
+}
+
+bool ZipReader::isOpen() const
+{
+ return d->isOpen();
+}
+
+void ZipReader::close()
+{
+ d->close();
+}
+
+QStringList ZipReader::entryNames()
+{
+ return d->entryNamesList();
+}
+
+bool ZipReader::readEntry(const QString &entryName, QByteArray *data,
+ Qt::CaseSensitivity caseSensitivity)
+{
+ return d->readEntry(entryName, data, caseSensitivity);
+}
+
+bool ZipReader::processEntry(const QString &entryName,
+ const std::function &processor,
+ Qt::CaseSensitivity caseSensitivity)
+{
+ return d->processEntry(entryName, processor, caseSensitivity);
+}
+
+} // namespace UkuiFileMetadata
diff --git a/src/zip-reader.h b/src/zip-reader.h
new file mode 100644
index 0000000000000000000000000000000000000000..b51122efeaddc84e76281ee6b5c7a14e500acee2
--- /dev/null
+++ b/src/zip-reader.h
@@ -0,0 +1,158 @@
+/*
+ *
+ * Copyright (C) 2026, KylinSoft Co., Ltd.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+#ifndef UKUI_FILE_METADATA_ZIP_READER_H
+#define UKUI_FILE_METADATA_ZIP_READER_H
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "ukui-file-metadata_global.h"
+
+namespace UkuiFileMetadata {
+
+/**
+ * Lightweight ZIP archive reader for metadata extractors and external callers.
+ *
+ * The reader opens archives lazily. Constructing the object or changing the
+ * archive path does not open the archive immediately. Callers can use `open()`
+ * proactively or rely on `entryNames()` / `readEntry()` to open the archive on
+ * demand.
+ */
+
+class UKUIFILEMETADATA_EXPORT ZipReader
+{
+public:
+ /**
+ * Create a reader bound to `archivePath`.
+ *
+ * The path may be empty. In that case `open()`, `entryNames()`, and
+ * `readEntry()` will fail until a non-empty path is set.
+ */
+ explicit ZipReader(QString archivePath = {});
+
+ /**
+ * Destroy the reader and close the archive if it is still open.
+ */
+ ~ZipReader();
+
+ /**
+ * Replace the archive path used by this reader.
+ *
+ * If the reader is currently open, the current archive is closed first and
+ * any cached entry name list is discarded.
+ */
+ void setArchivePath(const QString &archivePath);
+
+ /**
+ * Return the archive path currently associated with the reader.
+ */
+ QString archivePath() const;
+
+ /**
+ * Return the fallback codec names used for non-UTF8 ZIP entry names.
+ *
+ * `ZipReader` always tries the system codec before this list. The default
+ * fallback list contains only `CP437`, which is the ZIP specification
+ * default when no Unicode metadata is present. Use
+ * `setFallbackFileNameCodecs()` to provide additional legacy codecs when
+ * the creating system's encoding is known.
+ */
+ QList fallbackFileNameCodecs() const;
+
+ /**
+ * Replace the fallback codec chain used for non-UTF8 ZIP entry names.
+ *
+ * Changing the codec list closes the current archive and clears cached
+ * entry names so subsequent lookups use the new decoding policy.
+ */
+ void setFallbackFileNameCodecs(const QList &codecNames);
+
+ /**
+ * Open the archive referenced by `archivePath()`.
+ *
+ * Returns `true` if the archive is already open or if opening succeeds.
+ * Returns `false` when the path is empty, the archive cannot be opened, or
+ * the archive cannot be positioned on its first entry.
+ */
+ bool open();
+
+ /**
+ * Return whether the archive is currently open.
+ */
+ bool isOpen() const;
+
+ /**
+ * Close the current archive and clear any cached entry names.
+ *
+ * Calling `close()` on an already closed reader is safe.
+ */
+ void close();
+
+ /**
+ * Return all entry names in the archive.
+ *
+ * The list is cached after the first successful scan and rebuilt whenever
+ * the archive path changes or the reader is closed. An empty list is
+ * returned if the archive cannot be opened or scanned.
+ */
+ QStringList entryNames();
+
+ /**
+ * Read the uncompressed contents of `entryName` into `data`.
+ *
+ * `data` must be non-null. The archive is opened automatically if needed.
+ * `caseSensitivity` controls how the entry lookup is performed. On success,
+ * `data` is replaced with the full entry contents and the method returns
+ * `true`. On failure, `data` is cleared and the method returns `false`.
+ */
+ bool readEntry(const QString &entryName, QByteArray *data,
+ Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive);
+
+ /**
+ * Process the uncompressed contents of `entryName` through a sequential device.
+ *
+ * `processor` must be callable. The archive is opened automatically if needed.
+ * `caseSensitivity` controls how the entry lookup is performed. The method
+ * returns `true` only when the entry is found, the callback returns `true`,
+ * no read error occurs, and the entry closes successfully. Returning
+ * `false` from the callback is treated as a processing failure even if the
+ * caller intentionally stops reading early; the entry is still closed
+ * before returning.
+ */
+ bool processEntry(const QString &entryName,
+ const std::function &processor,
+ Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive);
+
+private:
+ Q_DISABLE_COPY(ZipReader)
+
+ class Private;
+ QScopedPointer d;
+};
+
+} // namespace UkuiFileMetadata
+
+#endif
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5b16e5b7dd228b559347f38003a3d49d78d8f3c1..66b91f0fb21f1605907c519fb887b60a9f48ecbe 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -5,6 +5,12 @@ add_executable(dump dump.cpp)
target_include_directories(dump PUBLIC "$/src")
target_link_libraries(dump
- Qt${QT_MAJOR_VERSION}::Core
+ Qt${QT_VERSION_MAJOR}::Core
ukui-file-metadata
)
+
+configure_file(package-config-test.cmake.in
+ ${CMAKE_CURRENT_BINARY_DIR}/package-config-test.cmake
+ @ONLY)
+add_test(PackageConfigTest
+ ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/package-config-test.cmake)
diff --git a/tests/auto_test.sh b/tests/auto_test.sh
new file mode 100755
index 0000000000000000000000000000000000000000..b08457d739510d78a4009ea084d9f50746588c41
--- /dev/null
+++ b/tests/auto_test.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+install_build_dependencies()
+{
+ if [ "${SKIP_APT_INSTALL:-0}" = "1" ]; then
+ return
+ fi
+
+ if ! command -v apt-get >/dev/null 2>&1; then
+ echo "apt-get not found, skip dependency installation."
+ return
+ fi
+
+ local packages=(
+ build-essential
+ file
+ gcovr
+ lcov
+ debhelper-compat
+ pkgconf
+ qtbase5-dev
+ qtchooser
+ qtscript5-dev
+ libuchardet-dev
+ libpoppler-qt5-dev
+ libavcodec-dev
+ libavformat-dev
+ libavutil-dev
+ libswscale-dev
+ libtesseract-dev
+ libleptonica-dev
+ libminizip-dev
+ cmake
+ qttools5-dev
+ libtag1-dev
+ )
+
+ if [ "$(id -u)" -eq 0 ]; then
+ apt-get update
+ apt-get install -y "${packages[@]}"
+ else
+ sudo apt-get update
+ sudo apt-get install -y "${packages[@]}"
+ fi
+}
+
+install_build_dependencies
diff --git a/tests/package-config-test.cmake.in b/tests/package-config-test.cmake.in
new file mode 100644
index 0000000000000000000000000000000000000000..b9e5239736eed3b3faa17813da22fad59226a025
--- /dev/null
+++ b/tests/package-config-test.cmake.in
@@ -0,0 +1,42 @@
+set(test_root "@CMAKE_CURRENT_BINARY_DIR@/package-config-test")
+file(REMOVE_RECURSE "${test_root}")
+file(MAKE_DIRECTORY "${test_root}")
+
+set(consumer_source_dir "${test_root}/consumer")
+file(MAKE_DIRECTORY "${consumer_source_dir}")
+
+file(WRITE "${consumer_source_dir}/main.cpp" "
+ #include
+ int main() { return 0; }
+ ")
+
+file(WRITE "${consumer_source_dir}/CMakeLists.txt" [=[
+cmake_minimum_required(VERSION 3.14)
+project(ukui_file_metadata_package_consumer LANGUAGES CXX)
+
+find_package(ukui-file-metadata CONFIG REQUIRED)
+
+add_executable(package-consumer main.cpp)
+target_link_libraries(package-consumer PRIVATE ukui-file-metadata)
+]=])
+
+function(run_checked)
+ execute_process(
+ COMMAND ${ARGN}
+ RESULT_VARIABLE result
+ OUTPUT_VARIABLE stdout
+ ERROR_VARIABLE stderr
+ )
+ if (NOT result EQUAL 0)
+ message(FATAL_ERROR "Command failed: ${ARGN}\nstdout:\n${stdout}\nstderr:\n${stderr}")
+ endif()
+endfunction()
+
+set(build_tree_package_dir "@CMAKE_BINARY_DIR@/src")
+set(build_tree_consumer_dir "${test_root}/build-tree-consumer")
+run_checked("@CMAKE_COMMAND@" -S "${consumer_source_dir}" -B "${build_tree_consumer_dir}"
+ "-Dukui-file-metadata_DIR=${build_tree_package_dir}")
+run_checked("@CMAKE_COMMAND@" --build "${build_tree_consumer_dir}")
+
+set(install_root "${test_root}/install-root")
+run_checked("@CMAKE_COMMAND@" -E env "DESTDIR=${install_root}" "@CMAKE_COMMAND@" --install "@CMAKE_BINARY_DIR@")
diff --git a/tests/runUnitTest.sh b/tests/runUnitTest.sh
new file mode 100755
index 0000000000000000000000000000000000000000..582656e15f8eca60b655b3f302ab969f4c3a1e1e
--- /dev/null
+++ b/tests/runUnitTest.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SOURCE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+BUILD_DIR="${SCRIPT_DIR}/build"
+
+PACKAGE_NAME="$(awk '/^Source:/ {print $2; exit}' "${SOURCE_DIR}/debian/control" 2>/dev/null)"
+if [ -z "${PACKAGE_NAME}" ]; then
+ PACKAGE_NAME="$(basename "${SOURCE_DIR}")"
+fi
+
+COVERAGE_XML="${SOURCE_DIR}/${PACKAGE_NAME}.xml"
+COVERAGE_OUTPUT="${SOURCE_DIR}/${PACKAGE_NAME}.output"
+
+cmake -S "${SOURCE_DIR}" -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE=Debug \
+ -DBUILD_TESTING=ON \
+ -DCMAKE_C_FLAGS_DEBUG="-O0 -g --coverage" \
+ -DCMAKE_CXX_FLAGS_DEBUG="-O0 -g --coverage" \
+ -DCMAKE_EXE_LINKER_FLAGS_DEBUG="--coverage" \
+ -DCMAKE_SHARED_LINKER_FLAGS_DEBUG="--coverage"
+
+cmake --build "${BUILD_DIR}" -j"$(nproc)"
+
+find "${BUILD_DIR}" -name '*.gcda' -type f -delete
+
+export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
+export QT_FATAL_WARNINGS="${QT_FATAL_WARNINGS:-0}"
+export QT_LOGGING_RULES="${QT_LOGGING_RULES:-*.debug=false;qt.qpa.*=false}"
+export CTEST_OUTPUT_ON_FAILURE=1
+
+ctest --test-dir "${BUILD_DIR}" --output-on-failure
+
+if ! command -v gcovr >/dev/null 2>&1; then
+ echo "gcovr not found, cannot generate coverage xml."
+ exit 1
+fi
+
+gcovr -r "${SOURCE_DIR}" \
+ -x \
+ --object-directory "${BUILD_DIR}" \
+ -o "${COVERAGE_OUTPUT}" \
+ --sonarqube "${COVERAGE_XML}" \
+ --exclude-branches-by-pattern=".*"
+
+echo "Coverage xml generated: ${COVERAGE_XML}"
+exit 0