From 7bd82e1167972390e747ae2e754d85feafb2932f Mon Sep 17 00:00:00 2001 From: xiepengfei Date: Tue, 15 Sep 2026 11:31:24 +0800 Subject: [PATCH] feat(batch-print): add headless batch print for pdf/docx/djvu/xps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add deepin-reader-batchprint standalone process for multi-file batch printing from file manager context menu without any dialog interaction. 新增无界面批量打印能力:文件管理器多选文档后右键"批量打印" 拉起独立进程静默完成打印,支持 pdf/docx/djvu/xps 格式。 Bundle libqt6waylandclient6 matching the apt Qt 6.8.0 set and resolve indirect deps via rpath-link, fixing linglong link failure caused by mixing runtime's libQt6WaylandClient private ABI. 修复玲珑构建链接失败:打包与本体 Qt 版本一致的 libQt6WaylandClient, 并通过 rpath-link 优先解析应用自带的间接依赖,避免混用运行时 Qt。 Log: 新增deepin-reader批量打印功能并修复玲珑构建 Influence: 用户可在文件管理器中多选文档批量打印,无需逐个操作。 --- CMakeLists.txt | 6 + batch-print/CMakeLists.txt | 125 ++++++++++ batch-print/batchprintapp.cpp | 88 +++++++ batch-print/batchprintapp.h | 28 +++ batch-print/cupsclient.cpp | 210 ++++++++++++++++ batch-print/cupsclient.h | 53 ++++ batch-print/errormessages.cpp | 54 +++++ batch-print/errormessages.h | 21 ++ batch-print/formatconverter.cpp | 226 ++++++++++++++++++ batch-print/formatconverter.h | 20 ++ batch-print/icupsapi.h | 29 +++ batch-print/main.cpp | 50 ++++ batch-print/notifyclient.cpp | 100 ++++++++ batch-print/notifyclient.h | 19 ++ batch-print/printsettings.cpp | 43 ++++ batch-print/printsettings.h | 67 ++++++ cmake/translation-generate.cmake | 23 +- debian/control | 3 +- linglong.yaml | 42 ++-- reader/CMakeLists.txt | 1 + reader/document/DjVuModel.cpp | 5 + reader/document/DjVuModel.h | 1 + reader/document/Model.cpp | 9 +- reader/document/Model.h | 1 + .../deepin-reader-batchprint.conf | 16 ++ tests/CMakeLists.txt | 6 + tests/batch-print/CMakeLists.txt | 53 ++++ tests/batch-print/ut_cupsclient.cpp | 211 ++++++++++++++++ tests/batch-print/ut_formatconverter.cpp | 111 +++++++++ tests/batch-print/ut_notifyclient.cpp | 123 ++++++++++ tests/batch-print/ut_printsettings.cpp | 135 +++++++++++ 31 files changed, 1849 insertions(+), 30 deletions(-) create mode 100644 batch-print/CMakeLists.txt create mode 100644 batch-print/batchprintapp.cpp create mode 100644 batch-print/batchprintapp.h create mode 100644 batch-print/cupsclient.cpp create mode 100644 batch-print/cupsclient.h create mode 100644 batch-print/errormessages.cpp create mode 100644 batch-print/errormessages.h create mode 100644 batch-print/formatconverter.cpp create mode 100644 batch-print/formatconverter.h create mode 100644 batch-print/icupsapi.h create mode 100644 batch-print/main.cpp create mode 100644 batch-print/notifyclient.cpp create mode 100644 batch-print/notifyclient.h create mode 100644 batch-print/printsettings.cpp create mode 100644 batch-print/printsettings.h create mode 100644 src/context-menus/deepin-reader-batchprint.conf create mode 100644 tests/batch-print/CMakeLists.txt create mode 100644 tests/batch-print/ut_cupsclient.cpp create mode 100644 tests/batch-print/ut_formatconverter.cpp create mode 100644 tests/batch-print/ut_notifyclient.cpp create mode 100644 tests/batch-print/ut_printsettings.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dc1056bcb..25087b613 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -265,6 +265,12 @@ if (USE_PDFIUM_BUNDLE) add_subdirectory(3rdparty/deepin-pdfium) endif() +add_subdirectory(batch-print) + +# Install context-menus (batch print) +install(FILES src/context-menus/deepin-reader-batchprint.conf + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications/context-menus) + # 单元测试(可选) option(BUILD_TESTS "Build unit tests" OFF) if (BUILD_TESTS) diff --git a/batch-print/CMakeLists.txt b/batch-print/CMakeLists.txt new file mode 100644 index 000000000..0593816a2 --- /dev/null +++ b/batch-print/CMakeLists.txt @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# [v4] Batch print unit tests switch (default OFF) +# Unit tests moved to top-level tests/batch-print (built under BUILD_TESTS) + +# Find Qt components needed by batch-print modules (DBus for notifications, PrintSupport for QPdfWriter) +pkg_check_modules(FREETYPE REQUIRED freetype2) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS DBus PrintSupport Sql) + +# Import the system pdfium target in this scope (reader/ creates it only for its own subtree) +if (NOT USE_PDFIUM_BUNDLE) + pkg_check_modules(Deepin-pdfium REQUIRED IMPORTED_TARGET deepin-pdfium) +endif() + +# batchprint-core: CupsClient + NotifyClient + PrintSettings + ErrorMessages (no reader source dependency) +add_library(batchprint-core STATIC + cupsclient.h cupsclient.cpp + icupsapi.h + notifyclient.h notifyclient.cpp + printsettings.h printsettings.cpp + errormessages.h errormessages.cpp +) + +target_include_directories(batchprint-core PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(batchprint-core PUBLIC + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::DBus +) + +# batchprint-convert: FormatConverter (depends on selected reader/document sources + deepin-pdfium) +# Explicitly list only the reader source files needed for document loading/rendering, +# NOT the entire reader GUI (browser/sidebar/widgets/uiframe). +set(BATCHPRINT_READER_SOURCES + ${CMAKE_SOURCE_DIR}/reader/app/Global.cpp + ${CMAKE_SOURCE_DIR}/reader/document/Model.cpp + ${CMAKE_SOURCE_DIR}/reader/document/PDFModel.cpp + ${CMAKE_SOURCE_DIR}/reader/document/DjVuModel.cpp + ${CMAKE_SOURCE_DIR}/reader/document/XpsDocumentAdapter.cpp + ${CMAKE_SOURCE_DIR}/reader/document/XpsTextExtractor.cpp +) + +add_library(batchprint-convert STATIC + formatconverter.h formatconverter.cpp + ${BATCHPRINT_READER_SOURCES} +) + +set_target_properties(batchprint-convert PROPERTIES + AUTOMOC ON + AUTOUIC ON +) + +target_include_directories(batchprint-convert PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/reader + ${CMAKE_SOURCE_DIR}/reader/app + ${CMAKE_SOURCE_DIR}/reader/document + ${PDFIUM_INCLUDE_DIRS} + $<$:${XPS_DEPS_INCLUDE_DIRS}> +) + +target_compile_definitions(batchprint-convert PRIVATE + INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}" + INSTALL_LIBDIR="${CMAKE_INSTALL_LIBDIR}" + APP_VERSION="1.0.0" +) + +target_link_libraries(batchprint-convert PUBLIC + batchprint-core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::PrintSupport + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Svg + Qt${QT_VERSION_MAJOR}::Concurrent + Qt${QT_VERSION_MAJOR}::Sql + Qt${QT_VERSION_MAJOR}::Xml + ${DDJVU_LIBRARIES} + ${LIBJPEG_LIBRARIES} + ${FREETYPE_LIBRARIES} + $<$:${XPS_DEPS_LIBRARIES}> +) + +# DTK linking +if(DTK_USE_TARGETS) + target_link_libraries(batchprint-convert PUBLIC + Dtk${DTK_VERSION_MAJOR}::Widget + Dtk${DTK_VERSION_MAJOR}::Gui + Dtk${DTK_VERSION_MAJOR}::Core + ) +endif() + +if (QT_VERSION_MAJOR MATCHES 6) + target_link_libraries(batchprint-convert PUBLIC Qt${QT_VERSION_MAJOR}::Core5Compat) +endif() + +if (USE_PDFIUM_BUNDLE) + target_link_libraries(batchprint-convert PUBLIC deepin-pdfium-reader) +else() + target_link_libraries(batchprint-convert PUBLIC PkgConfig::Deepin-pdfium) +endif() + +if (XPS_SUPPORT_ENABLED) + target_compile_definitions(batchprint-convert PUBLIC XPS_SUPPORT_ENABLED) + target_compile_options(batchprint-convert PUBLIC ${XPS_DEPS_CFLAGS_OTHER}) +endif() + +# Executable +add_executable(deepin-reader-batchprint + main.cpp + batchprintapp.h batchprintapp.cpp +) + +target_link_libraries(deepin-reader-batchprint PRIVATE + batchprint-core + batchprint-convert +) + +install(TARGETS deepin-reader-batchprint + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) diff --git a/batch-print/batchprintapp.cpp b/batch-print/batchprintapp.cpp new file mode 100644 index 000000000..7a18af757 --- /dev/null +++ b/batch-print/batchprintapp.cpp @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "batchprintapp.h" +#include "formatconverter.h" +#include "notifyclient.h" +#include "errormessages.h" + +#include +#include +#include + +BatchPrintApp::BatchPrintApp(ICupsApi *cupsApi) +{ + if (cupsApi) { + m_cupsApi = cupsApi; + m_ownsCupsApi = false; + } else { + m_cupsApi = new CupsClient(); + m_ownsCupsApi = true; + } +} + +BatchPrintApp::~BatchPrintApp() +{ + if (m_ownsCupsApi) + delete m_cupsApi; +} + +int BatchPrintApp::run(const QStringList &fileList) +{ + CupsClient *cups = dynamic_cast(m_cupsApi); + if (cups) { + if (!cups->init()) { + qWarning() << "CUPS init failed"; + NotifyClient::notifyError(ErrorMessages::cupsUnavailable()); + return 2; + } + if (!cups->checkEnvironment()) { + qWarning() << "CUPS environment check failed"; + NotifyClient::notifyError(ErrorMessages::noDefaultPrinter()); + return 2; + } + if (cups->isColorSupported()) { + m_settings.colorMode = ColorMode::Auto; + } else { + m_settings.colorMode = ColorMode::Gray; + } + } + + int total = fileList.size(); + int succeeded = 0; + QStringList failedFiles; + + for (const QString &filePath : fileList) { + QTemporaryDir tempDir; + if (!tempDir.isValid()) { + failedFiles.append(QFileInfo(filePath).fileName()); + continue; + } + + QString outputPdfPath; + QString errorMsg; + if (!FormatConverter::convertToPdf(filePath, tempDir.path(), + outputPdfPath, errorMsg)) { + qWarning() << errorMsg; + failedFiles.append(QFileInfo(filePath).fileName()); + continue; + } + + QString jobTitle = QFileInfo(filePath).fileName(); + bool printOk = m_cupsApi->submitJob(outputPdfPath, jobTitle, m_settings); + + if (!printOk) { + failedFiles.append(QFileInfo(filePath).fileName()); + continue; + } + + ++succeeded; + } + + NotifyClient::notifyResult(total, succeeded, failedFiles); + + if (succeeded == total) + return 0; + return 1; +} diff --git a/batch-print/batchprintapp.h b/batch-print/batchprintapp.h new file mode 100644 index 000000000..c7c22cee4 --- /dev/null +++ b/batch-print/batchprintapp.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef BATCHPRINTAPP_H +#define BATCHPRINTAPP_H + +#include "cupsclient.h" +#include "printsettings.h" + +#include +#include + +class BatchPrintApp +{ +public: + explicit BatchPrintApp(ICupsApi *cupsApi = nullptr); + ~BatchPrintApp(); + + int run(const QStringList &fileList); + +private: + ICupsApi *m_cupsApi = nullptr; + bool m_ownsCupsApi = false; + PrintSettings m_settings; +}; + +#endif // BATCHPRINTAPP_H diff --git a/batch-print/cupsclient.cpp b/batch-print/cupsclient.cpp new file mode 100644 index 000000000..dda4b1a74 --- /dev/null +++ b/batch-print/cupsclient.cpp @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "cupsclient.h" + +#include +#include + +#include +#include + +#include + +typedef int (*cupsGetDests_fn)(cups_dest_t **dests); +typedef void (*cupsFreeDests_fn)(int num_dests, cups_dest_t *dests); +typedef ipp_t *(*cupsDoRequest_fn)(http_t *http, ipp_t *request, const char *resource); +typedef ipp_t *(*ippNewRequest_fn)(ipp_op_t op); +typedef void (*ippAddString_fn)(ipp_t *ipp, ipp_tag_t group, ipp_tag_t tag, const char *name, const char *language, const char *value); +typedef void (*ippAddStrings_fn)(ipp_t *ipp, ipp_tag_t group, ipp_tag_t tag, const char *name, int num_values, const char *language, const char *const *values); +typedef ipp_attribute_t *(*ippFindAttribute_fn)(ipp_t *ipp, const char *name, ipp_tag_t type); +typedef int (*cupsPrintFile_fn)(const char *printer, const char *filename, const char *title, int num_options, cups_option_t *options); +typedef const char *(*cupsGetOption_fn)(const char *name, int num_options, cups_option_t *options); +typedef void (*ippDelete_fn)(ipp_t *ipp); +typedef int (*ippGetBoolean_fn)(ipp_attribute_t *attr, int element); + +CupsClient::CupsClient() +{ +} + +CupsClient::~CupsClient() +{ +} + +bool CupsClient::loadSymbols() +{ + m_cupsGetDests = (void *)m_lib.resolve("cupsGetDests"); + m_cupsFreeDests = (void *)m_lib.resolve("cupsFreeDests"); + m_cupsDoRequest = (void *)m_lib.resolve("cupsDoRequest"); + m_ippNewRequest = (void *)m_lib.resolve("ippNewRequest"); + m_ippAddString = (void *)m_lib.resolve("ippAddString"); + m_ippAddStrings = (void *)m_lib.resolve("ippAddStrings"); + m_ippFindAttribute = (void *)m_lib.resolve("ippFindAttribute"); + m_cupsPrintFile = (void *)m_lib.resolve("cupsPrintFile"); + m_cupsGetOption = (void *)m_lib.resolve("cupsGetOption"); + m_ippDelete = (void *)m_lib.resolve("ippDelete"); + m_ippGetBoolean = (void *)m_lib.resolve("ippGetBoolean"); + + return m_cupsGetDests && m_cupsFreeDests && m_cupsDoRequest && + m_ippNewRequest && m_ippAddString && m_ippAddStrings && + m_ippFindAttribute && m_cupsPrintFile && m_cupsGetOption && m_ippDelete && m_ippGetBoolean; +} + +bool CupsClient::init() +{ + m_lib.setFileName(QStringLiteral("cups")); + if (!m_lib.load()) { + qWarning() << "Failed to dlopen libcups:" << m_lib.errorString(); + return false; + } + + m_loaded = loadSymbols(); + if (!m_loaded) { + qWarning() << "Failed to resolve CUPS symbols"; + return false; + } + + return true; +} + +bool CupsClient::checkEnvironment() +{ + if (!m_loaded) + return false; + + cups_dest_t *dests = nullptr; + int numDest = ((cupsGetDests_fn)m_cupsGetDests)(&dests); + if (numDest <= 0) { + qWarning() << "No printers found"; + return false; + } + + for (int i = 0; i < numDest; ++i) { + if (dests[i].is_default) { + m_defaultPrinterName = QString::fromUtf8(dests[i].name); + ((cupsFreeDests_fn)m_cupsFreeDests)(numDest, dests); + return true; + } + } + + m_defaultPrinterName = QString::fromUtf8(dests[0].name); + ((cupsFreeDests_fn)m_cupsFreeDests)(numDest, dests); + + return true; +} + +bool CupsClient::isColorSupported() +{ + if (!m_loaded || m_defaultPrinterName.isEmpty()) + return false; + + bool supported = false; + if (queryColorSupported(m_defaultPrinterName, supported)) { + m_colorSupported = supported; + } else { + m_colorSupported = false; + } + return m_colorSupported; +} + +bool CupsClient::getCupsDests(cups_dest_t **dests) +{ + if (!m_loaded || !m_cupsGetDests) + return false; + + int numDest = ((cupsGetDests_fn)m_cupsGetDests)(dests); + return numDest > 0; +} + +bool CupsClient::queryColorSupported(const QString &printerName, bool &supported) +{ + if (!m_loaded) + return false; + + ipp_t *request = ((ippNewRequest_fn)m_ippNewRequest)(IPP_GET_PRINTER_ATTRIBUTES); + if (!request) + return false; + + QString printerUri = QStringLiteral("ipp://localhost/printers/") + + QString::fromUtf8(QUrl::toPercentEncoding(printerName)); + + ((ippAddString_fn)m_ippAddString)(request, IPP_TAG_OPERATION, IPP_TAG_URI, + "printer-uri", nullptr, + printerUri.toUtf8().constData()); + + static const char *requestedAttrs[] = {"color-supported", "printer-type"}; + ((ippAddStrings_fn)m_ippAddStrings)(request, IPP_TAG_OPERATION, IPP_TAG_KEYWORD, + "requested-attributes", 2, nullptr, + requestedAttrs); + + ipp_t *response = ((cupsDoRequest_fn)m_cupsDoRequest)(CUPS_HTTP_DEFAULT, request, "/"); + if (!response) { + supported = false; + return false; + } + + ipp_attribute_t *attr = ((ippFindAttribute_fn)m_ippFindAttribute)(response, "color-supported", IPP_TAG_BOOLEAN); + if (attr) { + supported = ((ippGetBoolean_fn)m_ippGetBoolean)(attr, 0); + ((ippDelete_fn)m_ippDelete)(response); + return true; + } + + ((ippDelete_fn)m_ippDelete)(response); + supported = false; + return false; +} + +bool CupsClient::submitJob(const QString &pdfPath, const QString &title, const PrintSettings &settings) +{ + if (!m_loaded || m_defaultPrinterName.isEmpty()) + return false; + + bool printerSupportsColor = m_colorSupported; + QStringList optionList = toCupsOptions(settings, printerSupportsColor); + + int numOptions = optionList.size() / 2; + cups_option_t *options = nullptr; + if (numOptions > 0) { + options = (cups_option_t *)calloc(numOptions, sizeof(cups_option_t)); + if (!options) + return false; + + for (int i = 0; i < numOptions; ++i) { + options[i].name = strdup(optionList[i * 2].toUtf8().constData()); + options[i].value = strdup(optionList[i * 2 + 1].toUtf8().constData()); + } + } + + int result = ((cupsPrintFile_fn)m_cupsPrintFile)( + m_defaultPrinterName.toUtf8().constData(), + pdfPath.toUtf8().constData(), + title.toUtf8().constData(), + numOptions, options); + + if (options) { + for (int i = 0; i < numOptions; ++i) { + free(options[i].name); + free(options[i].value); + } + free(options); + } + + return result > 0; +} + +bool CupsClient::printFile(const QString &printerName, const QString &filename, + const QString &title, int numOptions, cups_option_t *options) +{ + if (!m_loaded || !m_cupsPrintFile) + return false; + + int result = ((cupsPrintFile_fn)m_cupsPrintFile)( + printerName.toUtf8().constData(), + filename.toUtf8().constData(), + title.toUtf8().constData(), + numOptions, options); + + return result > 0; +} diff --git a/batch-print/cupsclient.h b/batch-print/cupsclient.h new file mode 100644 index 000000000..5a09ffdfa --- /dev/null +++ b/batch-print/cupsclient.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef CUPSCLIENT_H +#define CUPSCLIENT_H + +#include "icupsapi.h" +#include "printsettings.h" + +#include +#include + +class CupsClient : public ICupsApi +{ +public: + CupsClient(); + ~CupsClient() override; + + bool init(); + bool checkEnvironment(); + bool isColorSupported(); + bool submitJob(const QString &pdfPath, const QString &title, const PrintSettings &settings) override; + + bool getCupsDests(cups_dest_t **dests) override; + bool queryColorSupported(const QString &printerName, bool &supported) override; + bool printFile(const QString &printerName, const QString &filename, + const QString &title, int numOptions, cups_option_t *options) override; + + QString defaultPrinterName() const { return m_defaultPrinterName; } + +private: + bool loadSymbols(); + + QLibrary m_lib; + bool m_loaded = false; + QString m_defaultPrinterName; + bool m_colorSupported = false; + + void *m_cupsGetDests = nullptr; + void *m_cupsFreeDests = nullptr; + void *m_cupsDoRequest = nullptr; + void *m_ippNewRequest = nullptr; + void *m_ippAddString = nullptr; + void *m_ippAddStrings = nullptr; + void *m_ippFindAttribute = nullptr; + void *m_cupsPrintFile = nullptr; + void *m_cupsGetOption = nullptr; + void *m_ippDelete = nullptr; + void *m_ippGetBoolean = nullptr; +}; + +#endif // CUPSCLIENT_H diff --git a/batch-print/errormessages.cpp b/batch-print/errormessages.cpp new file mode 100644 index 000000000..a995d3fa9 --- /dev/null +++ b/batch-print/errormessages.cpp @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "errormessages.h" + +#include + +QString ErrorMessages::cupsUnavailable() +{ + return QGuiApplication::translate("batchprint", + "CUPS is not available. Please check if the printing service is running."); +} + +QString ErrorMessages::noDefaultPrinter() +{ + return QGuiApplication::translate("batchprint", + "No default printer found. Please set a default printer first."); +} + +QString ErrorMessages::convertFailed(const QString &fileName) +{ + return QGuiApplication::translate("batchprint", + "Failed to convert file: %1").arg(fileName); +} + +QString ErrorMessages::printFailed(const QString &fileName) +{ + return QGuiApplication::translate("batchprint", + "Failed to print file: %1").arg(fileName); +} + +QString ErrorMessages::notifyTitle() +{ + return QGuiApplication::translate("batchprint", "Batch Print"); +} + +QString ErrorMessages::notifySuccess(int count) +{ + return QGuiApplication::translate("batchprint", + "All %n file(s) printed successfully.", "", count); +} + +QString ErrorMessages::notifyPartialSuccess(int succeeded, int failed) +{ + return QGuiApplication::translate("batchprint", + "%1 file(s) printed successfully, %2 file(s) failed.").arg(succeeded).arg(failed); +} + +QString ErrorMessages::notifyAllFailed(int count) +{ + return QGuiApplication::translate("batchprint", + "All %n file(s) failed to print.", "", count); +} diff --git a/batch-print/errormessages.h b/batch-print/errormessages.h new file mode 100644 index 000000000..d1ac614df --- /dev/null +++ b/batch-print/errormessages.h @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef ERRORMESSAGES_H +#define ERRORMESSAGES_H + +#include + +namespace ErrorMessages { + QString cupsUnavailable(); + QString noDefaultPrinter(); + QString convertFailed(const QString &fileName); + QString printFailed(const QString &fileName); + QString notifyTitle(); + QString notifySuccess(int count); + QString notifyPartialSuccess(int succeeded, int failed); + QString notifyAllFailed(int count); +} + +#endif // ERRORMESSAGES_H diff --git a/batch-print/formatconverter.cpp b/batch-print/formatconverter.cpp new file mode 100644 index 000000000..b29ab74bf --- /dev/null +++ b/batch-print/formatconverter.cpp @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "formatconverter.h" +#include "errormessages.h" + +#include "Global.h" +#include "Model.h" + +#include +#include +#include +#include +#include +#include +#include + +using deepin_reader::Document; +using deepin_reader::DocumentFactory; +using deepin_reader::Page; + +static const int FallbackDpi = 300; + +static Dr::FileType detectFileTypeWithFallback(const QString &filePath) +{ + Dr::FileType type = Dr::fileType(filePath); + if (type != Dr::Unknown) + return type; + + QString suffix = QFileInfo(filePath).suffix().toLower(); + if (suffix == QStringLiteral("pdf")) + return Dr::PDF; + if (suffix == QStringLiteral("docx")) + return Dr::DOCX; + if (suffix == QStringLiteral("djvu") || suffix == QStringLiteral("djv")) + return Dr::DJVU; +#ifdef XPS_SUPPORT_ENABLED + if (suffix == QStringLiteral("xps") || suffix == QStringLiteral("oxps")) + return Dr::XPS; +#endif + return Dr::Unknown; +} + +QSizeF FormatConverter::computePageSizeMm(double widthPx, double heightPx, int dpi) +{ + if (dpi <= 0) + dpi = FallbackDpi; + + double widthPt = widthPx * 72.0 / dpi; + double heightPt = heightPx * 72.0 / dpi; + double widthMm = widthPt * 25.4 / 72.0; + double heightMm = heightPt * 25.4 / 72.0; + + if (widthMm <= 0 || heightMm <= 0) + return QSizeF(210.0, 297.0); + + return QSizeF(widthMm, heightMm); +} + +bool FormatConverter::convertToPdf(const QString &filePath, const QString &outputDir, + QString &outputPdfPath, QString &errorMsg) +{ + QFileInfo fi(filePath); + if (!fi.exists() || !fi.isReadable()) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + + Dr::FileType type = detectFileTypeWithFallback(filePath); + + switch (type) { + case Dr::PDF: { + outputPdfPath = filePath; + return true; + } + case Dr::DOCX: { + QString convertedDir = outputDir; + if (convertedDir.isEmpty()) + convertedDir = fi.absolutePath(); + + Document::Error error = Document::NoError; + QProcess *proc = nullptr; + Document *doc = DocumentFactory::getDocument(Dr::DOCX, filePath, convertedDir, + QString(), &proc, error); + if (doc) { + delete doc; + outputPdfPath = convertedDir + QStringLiteral("/temp.pdf"); + if (!QFileInfo::exists(outputPdfPath)) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + return true; + } + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + case Dr::DJVU: { + Document::Error error = Document::NoError; + Document *doc = DocumentFactory::getDocument(Dr::DJVU, filePath, QString(), + QString(), nullptr, error); + if (!doc) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + + int pageCount = doc->pageCount(); + if (pageCount <= 0) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + delete doc; + return false; + } + + outputPdfPath = outputDir + QStringLiteral("/temp.pdf"); + QPdfWriter pdfWriter(outputPdfPath); + pdfWriter.setResolution(FallbackDpi); + + // Set first page size BEFORE creating QPainter — QPdfWriter applies + // page size when a new page begins, and the first page begins at + // painter construction. Setting it afterwards has no effect on page 0. + QScopedPointer firstPage(doc->page(0)); + if (firstPage) { + QSizeF firstPageSizePx = firstPage->sizeF(); + int firstDpi = firstPage->resolution(); + if (firstDpi <= 0) + firstDpi = FallbackDpi; + double firstWidthPt = firstPageSizePx.width() * 72.0 / firstDpi; + double firstHeightPt = firstPageSizePx.height() * 72.0 / firstDpi; + double firstWidthMm = firstWidthPt * 25.4 / 72.0; + double firstHeightMm = firstHeightPt * 25.4 / 72.0; + if (firstWidthMm <= 0 || firstHeightMm <= 0) { + firstWidthMm = 210.0; + firstHeightMm = 297.0; + } + pdfWriter.setPageSize(QPageSize(QSizeF(firstWidthMm, firstHeightMm), + QPageSize::Millimeter)); + } + + QPainter painter(&pdfWriter); + if (!painter.isActive()) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + delete doc; + return false; + } + + bool anyPageRendered = false; + for (int i = 0; i < pageCount; ++i) { + QScopedPointer page(i == 0 ? firstPage.take() : doc->page(i)); + if (!page) { + if (i > 0) + pdfWriter.newPage(); + continue; + } + + QSizeF pageSizePx = page->sizeF(); + int dpi = page->resolution(); + if (dpi <= 0) + dpi = FallbackDpi; + + double widthPt = pageSizePx.width() * 72.0 / dpi; + double heightPt = pageSizePx.height() * 72.0 / dpi; + double widthMm = widthPt * 25.4 / 72.0; + double heightMm = heightPt * 25.4 / 72.0; + + if (widthMm <= 0 || heightMm <= 0) { + widthMm = 210.0; + heightMm = 297.0; + } + + if (i > 0) { + pdfWriter.setPageSize(QPageSize(QSizeF(widthMm, heightMm), QPageSize::Millimeter)); + pdfWriter.newPage(); + } + + int renderWidthPx = qRound(widthPt * FallbackDpi / 72.0); + int renderHeightPx = qRound(heightPt * FallbackDpi / 72.0); + if (renderWidthPx <= 0 || renderHeightPx <= 0) { + renderWidthPx = static_cast(8.27 * FallbackDpi); + renderHeightPx = static_cast(11.69 * FallbackDpi); + } + + QImage image = page->render(renderWidthPx, renderHeightPx); + + if (!image.isNull()) { + anyPageRendered = true; + painter.drawImage(0, 0, image); + } + } + + painter.end(); + delete doc; + + if (!anyPageRendered) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + return true; + } + case Dr::XPS: { +#ifdef XPS_SUPPORT_ENABLED + Document::Error error = Document::NoError; + Document *doc = DocumentFactory::getDocument(Dr::XPS, filePath, QString(), + QString(), nullptr, error); + if (!doc) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + + outputPdfPath = outputDir + QStringLiteral("/temp.pdf"); + bool ok = doc->saveAs(outputPdfPath); + delete doc; + if (!ok || !QFileInfo::exists(outputPdfPath)) { + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } + return true; +#else + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; +#endif + } + default: + errorMsg = ErrorMessages::convertFailed(fi.fileName()); + return false; + } +} diff --git a/batch-print/formatconverter.h b/batch-print/formatconverter.h new file mode 100644 index 000000000..52439f457 --- /dev/null +++ b/batch-print/formatconverter.h @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef FORMATCONVERTER_H +#define FORMATCONVERTER_H + +#include +#include + +class FormatConverter +{ +public: + static bool convertToPdf(const QString &filePath, const QString &outputDir, + QString &outputPdfPath, QString &errorMsg); + + static QSizeF computePageSizeMm(double widthPx, double heightPx, int dpi); +}; + +#endif // FORMATCONVERTER_H diff --git a/batch-print/icupsapi.h b/batch-print/icupsapi.h new file mode 100644 index 000000000..ca8acdfcf --- /dev/null +++ b/batch-print/icupsapi.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef ICUPSAPI_H +#define ICUPSAPI_H + +#include + +#include "printsettings.h" + +struct cups_dest_s; +typedef struct cups_dest_s cups_dest_t; +struct cups_option_s; +typedef struct cups_option_s cups_option_t; + +class ICupsApi +{ +public: + virtual ~ICupsApi() = default; + virtual bool getCupsDests(cups_dest_t **dests) = 0; + virtual bool queryColorSupported(const QString &printerName, bool &supported) = 0; + virtual bool printFile(const QString &printerName, const QString &filename, + const QString &title, int numOptions, cups_option_t *options) = 0; + virtual bool submitJob(const QString &pdfPath, const QString &title, + const PrintSettings &settings) = 0; +}; + +#endif // ICUPSAPI_H diff --git a/batch-print/main.cpp b/batch-print/main.cpp new file mode 100644 index 000000000..73fdc0159 --- /dev/null +++ b/batch-print/main.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "batchprintapp.h" + +#include + +#include +#include +#include +#include +#include + +#include + +DGUI_USE_NAMESPACE + +int main(int argc, char *argv[]) +{ + if (!qEnvironmentVariableIsSet("DISPLAY") && + !qEnvironmentVariableIsSet("WAYLAND_DISPLAY")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + + QGuiApplication app(argc, argv); + app.setApplicationName(QStringLiteral("deepin-reader-batchprint")); + + DGuiApplicationHelper::loadTranslator("deepin-reader", "deepin-reader"); + + QCommandLineParser parser; + parser.setApplicationDescription( + QGuiApplication::translate("batchprint", "Batch print documents silently")); + parser.addHelpOption(); + parser.addPositionalArgument(QStringLiteral("files"), + QGuiApplication::translate("batchprint", "Document files to print"), + QStringLiteral("[files...]")); + parser.process(app); + + QStringList files = parser.positionalArguments(); + if (files.isEmpty()) { + fprintf(stderr, "%s\n", + QGuiApplication::translate("batchprint", "No files specified.") + .toUtf8().constData()); + return 2; + } + + BatchPrintApp batchApp; + return batchApp.run(files); +} diff --git a/batch-print/notifyclient.cpp b/batch-print/notifyclient.cpp new file mode 100644 index 000000000..8e3151956 --- /dev/null +++ b/batch-print/notifyclient.cpp @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "notifyclient.h" +#include "errormessages.h" + +#include +#include +#include +#include + +#include + +static const int MaxFailedDisplay = 5; + +QString NotifyClient::buildBody(int total, int succeeded, const QStringList &failedFiles) +{ + QString body; + int failed = total - succeeded; + + if (failed == 0) { + body = ErrorMessages::notifySuccess(total); + } else if (succeeded == 0) { + body = ErrorMessages::notifyAllFailed(failed); + } else { + body = ErrorMessages::notifyPartialSuccess(succeeded, failed); + } + + if (failed > 0 && !failedFiles.isEmpty()) { + QStringList displayList = failedFiles.mid(0, MaxFailedDisplay); + body += QStringLiteral("\n"); + for (const QString &f : displayList) { + body += QStringLiteral("\n") + f; + } + if (failedFiles.size() > MaxFailedDisplay) { + body += QStringLiteral("\n") + + QGuiApplication::translate("batchprint", + "and %1 more file(s) failed.").arg(failedFiles.size() - MaxFailedDisplay); + } + } + + return body; +} + +void NotifyClient::notifyResult(int total, int succeeded, const QStringList &failedFiles) +{ + QString body = buildBody(total, succeeded, failedFiles); + + QDBusInterface iface(QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications")); + + if (!iface.isValid()) { + fprintf(stderr, "%s\n", body.toUtf8().constData()); + return; + } + + QVariantList args; + args << QStringLiteral("deepin-reader"); + args << quint32(0); + args << QStringLiteral("deepin-reader"); + args << ErrorMessages::notifyTitle(); + args << body; + args << QStringList(); + args << QVariantMap(); + args << qint32(-1); + + QDBusMessage reply = iface.call(QStringLiteral("Notify"), args); + if (reply.type() == QDBusMessage::ErrorMessage) { + fprintf(stderr, "%s\n", body.toUtf8().constData()); + } +} + +void NotifyClient::notifyError(const QString &body) +{ + QDBusInterface iface(QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications")); + + if (!iface.isValid()) { + fprintf(stderr, "%s\n", body.toUtf8().constData()); + return; + } + + QVariantList args; + args << QStringLiteral("deepin-reader"); + args << quint32(0); + args << QStringLiteral("deepin-reader"); + args << ErrorMessages::notifyTitle(); + args << body; + args << QStringList(); + args << QVariantMap(); + args << qint32(-1); + + QDBusMessage reply = iface.call(QStringLiteral("Notify"), args); + if (reply.type() == QDBusMessage::ErrorMessage) { + fprintf(stderr, "%s\n", body.toUtf8().constData()); + } +} diff --git a/batch-print/notifyclient.h b/batch-print/notifyclient.h new file mode 100644 index 000000000..d4880e4ba --- /dev/null +++ b/batch-print/notifyclient.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef NOTIFYCLIENT_H +#define NOTIFYCLIENT_H + +#include +#include + +class NotifyClient +{ +public: + static void notifyResult(int total, int succeeded, const QStringList &failedFiles); + static void notifyError(const QString &body); + static QString buildBody(int total, int succeeded, const QStringList &failedFiles); +}; + +#endif // NOTIFYCLIENT_H diff --git a/batch-print/printsettings.cpp b/batch-print/printsettings.cpp new file mode 100644 index 000000000..c5c8d909f --- /dev/null +++ b/batch-print/printsettings.cpp @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "printsettings.h" + +#include + +QStringList toCupsOptions(const PrintSettings &settings, bool printerSupportsColor) +{ + QStringList options; + + int copies = qBound(1, settings.copies, 999); + options << QStringLiteral("copies") << QString::number(copies); + + switch (settings.duplex) { + case DuplexMode::OneSided: + options << QStringLiteral("sides") << QStringLiteral("one-sided"); + break; + case DuplexMode::LongEdge: + options << QStringLiteral("sides") << QStringLiteral("two-sided-long-edge"); + break; + case DuplexMode::ShortEdge: + options << QStringLiteral("sides") << QStringLiteral("two-sided-short-edge"); + break; + } + + bool useColor = false; + switch (settings.colorMode) { + case ColorMode::Auto: + useColor = printerSupportsColor; + break; + case ColorMode::Color: + useColor = true; + break; + case ColorMode::Gray: + useColor = false; + break; + } + options << QStringLiteral("ColorModel") << (useColor ? QStringLiteral("RGB") : QStringLiteral("Gray")); + + return options; +} diff --git a/batch-print/printsettings.h b/batch-print/printsettings.h new file mode 100644 index 000000000..56724e1bd --- /dev/null +++ b/batch-print/printsettings.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef PRINTSETTINGS_H +#define PRINTSETTINGS_H + +#include +#include + +enum class Orientation { + Portrait = 0, + Landscape = 1, + Auto = 2 +}; + +enum class ColorMode { + Auto = 0, + Color = 1, + Gray = 2 +}; + +enum class DuplexMode { + OneSided = 0, + LongEdge = 1, + ShortEdge = 2 +}; + +enum class WatermarkLayout { + Tiled = 0, + Center = 1 +}; + +enum class WatermarkTextType { + None = 0, + Confidential = 1, + Draft = 2, + Custom = 3 +}; + +struct PrintSettings { + int copies = 1; + DuplexMode duplex = DuplexMode::OneSided; + ColorMode colorMode = ColorMode::Auto; + + Orientation orientation = Orientation::Auto; + QString paperSize; + + bool watermarkEnabled = false; + WatermarkLayout watermarkLayout = WatermarkLayout::Center; + WatermarkTextType watermarkTextType = WatermarkTextType::Confidential; + int watermarkAngle = 30; + int watermarkSize = 100; + int watermarkOpacity = 30; + QString watermarkColor = QStringLiteral("#6F6F6F"); + QString watermarkText; + + QString pageRange; + QString margin; + int scale = 100; + int perPage = 1; + int printOrder = 0; +}; + +QStringList toCupsOptions(const PrintSettings &settings, bool printerSupportsColor); + +#endif // PRINTSETTINGS_H diff --git a/cmake/translation-generate.cmake b/cmake/translation-generate.cmake index a45ee68e5..5f0af0398 100644 --- a/cmake/translation-generate.cmake +++ b/cmake/translation-generate.cmake @@ -1,12 +1,23 @@ function(TRANSLATION_GENERATE QMS) find_package(Qt${QT_VERSION_MAJOR}LinguistTools QUIET) - if (NOT Qt${QT_VERSION_MAJOR}_LRELEASE_EXECUTABLE) - set(QT_LRELEASE "/lib/qt${QT_VERSION_MAJOR}/bin/lrelease") - message(STATUS "NOT found lrelease, set QT_LRELEASE = ${QT_LRELEASE}") - else() - set(QT_LRELEASE "${Qt${QT_VERSION_MAJOR}_LRELEASE_EXECUTABLE}") - endif() + if (NOT Qt${QT_VERSION_MAJOR}_LRELEASE_EXECUTABLE) + find_program(QT_LRELEASE + NAMES lrelease lrelease-qt${QT_VERSION_MAJOR} + HINTS + /lib/qt${QT_VERSION_MAJOR}/bin + /usr/lib/qt${QT_VERSION_MAJOR}/bin + /runtime/lib/qt${QT_VERSION_MAJOR}/bin + ) + if (NOT QT_LRELEASE) + set(QT_LRELEASE "/lib/qt${QT_VERSION_MAJOR}/bin/lrelease") + message(STATUS "NOT found lrelease, fallback to QT_LRELEASE = ${QT_LRELEASE}") + else() + message(STATUS "Found lrelease via find_program: ${QT_LRELEASE}") + endif() + else() + set(QT_LRELEASE "${Qt${QT_VERSION_MAJOR}_LRELEASE_EXECUTABLE}") + endif() if(NOT ARGN) message(SEND_ERROR "Error: TRANSLATION_GENERATE() called without any .ts path") diff --git a/debian/control b/debian/control index 9b5da795e..fd82c8d1e 100644 --- a/debian/control +++ b/debian/control @@ -39,7 +39,8 @@ Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, - pandoc, + pandoc, + libcups2, libqt6webenginecore6-bin [!mipsel !mips64el] | libqt5core5a, Description: a tool for reading document files. Document Viewer is a tool for reading document files, supporting PDF, DJVU, DOCX etc. diff --git a/linglong.yaml b/linglong.yaml index 5ad612de3..9e69099a1 100644 --- a/linglong.yaml +++ b/linglong.yaml @@ -20,36 +20,34 @@ command: build: | # 下载和安装依赖 - apt -y install --download-only qt6-base-dev qt6-base-dev-tools qt6-tools-dev libdtk6widget-dev libspectre-dev libdjvulibre-dev libtiff-dev libjpeg-dev libicu-dev libpng-dev zlib1g-dev liblcms2-dev libopenjp2-7-dev libfreetype6-dev libgtest-dev libchardet-dev qt6-webengine-dev qt6-5compat-dev libdtk6gui-dev libdtk6core-dev qt6-svg-dev pandoc libgxps-dev libcairo2-dev libglib2.0-dev - bash ./install_dep /var/cache/apt/archives "$PREFIX" + # libqt6waylandclient6: libdtk6gui 的隐式依赖(未在 deb 声明),必须随 apt 的 Qt 6.8.0 一起打包, + # 否则链接时会拉取 /runtime 中 webengine 运行时的 libQt6WaylandClient,其私有 ABI 与打包的 Qt 不一致导致链接失败 + apt -y install --download-only qt6-base-dev qt6-base-dev-tools qt6-tools-dev libdtk6widget-dev libspectre-dev libdjvulibre-dev libtiff-dev libjpeg-dev libicu-dev libpng-dev zlib1g-dev liblcms2-dev libopenjp2-7-dev libfreetype6-dev libgtest-dev libchardet-dev qt6-webengine-dev qt6-5compat-dev libdtk6gui-dev libdtk6core-dev qt6-svg-dev pandoc libgxps-dev libcairo2-dev libglib2.0-dev libqt6waylandclient6 + # 第三个参数强制安装 runtime 已提供的 libqt6waylandclient6,保证其与打包的 Qt 版本一致 + bash ./install_dep /var/cache/apt/archives "$PREFIX" "libqt6waylandclient6" - # 设置INCLUDEPATH为引入的CFLAGS环境变量获取的路径 - sed -i '33i INCLUDEPATH += $$INCPATHS' 3rdparty/deepin-pdfium/src/src.pro - sed -i '35i INCLUDEPATH += $$INCPATHS' htmltopdf/htmltopdf.pro - sed -i '2i INCLUDEPATH += $$INCPATHS' reader/reader.pro - sed -i 's|/usr/lib/qt6/bin/lrelease|lrelease|g' translate_generation.sh - - # 构建 - VERSION=$(head -1 debian/changelog | awk -F'[()]' '{print $2}') - mkdir -p build && cd build - qmake -set APP_INSTALL_LIBS ${PREFIX}/lib/${TRIPLET} - qmake "VERSION=${VERSION}" \ - "PREFIX=${PREFIX}" \ - "LINGLONG_BUILD_ON=1" \ - "INCPATHS=$(echo "$CFLAGS" | sed 's/-I//g')" \ - "LIB_INSTALL_DIR=${PREFIX}/lib/${TRIPLET}" \ - "INSTALL_ROOT=${PREFIX}" \ - ../deepin_reader.pro - make -j`nproc` + # 构建(CMake) + rm -rf build && mkdir -p build && cd build + cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=${PREFIX} \ + -DCMAKE_INSTALL_LIBDIR=lib/${TRIPLET} \ + -DCMAKE_PREFIX_PATH=${PREFIX} \ + -DCMAKE_EXE_LINKER_FLAGS="-Wl,-rpath-link,${PREFIX}/lib/${TRIPLET}" \ + -DCMAKE_SHARED_LINKER_FLAGS="-Wl,-rpath-link,${PREFIX}/lib/${TRIPLET}" + make -j$(nproc) make install > ../install.log 2>&1 cd .. # 项目生成应用名和动态隐式加载的依赖库,ldd无法找到的其他库 LDD_FILES=( deepin-reader - ../lib/deepin-reader/htmltopdf + deepin-reader-batchprint + ../lib/${TRIPLET}/deepin-reader/htmltopdf pandoc libdeepin-pdfium-reader.so + # dtk6gui 的隐式依赖,需打包与本体 Qt 版本一致的 waylandclient,避免运行时混用 /runtime 的 Qt + libQt6WaylandClient.so # 定制插件不存在 libzpdcallback.so ) @@ -59,7 +57,7 @@ build: | bash ./deploy_dep "${LDD_FILES[@]}" # lib/deepin-reader 文件添加到 install 文件 - for OTHER in "${PREFIX}"/lib/deepin-reader/*; do + for OTHER in "${PREFIX}"/lib/${TRIPLET}/deepin-reader/*; do if [[ -f "$OTHER" ]]; then echo "$OTHER" >> "${ID_VALUE}.install" fi diff --git a/reader/CMakeLists.txt b/reader/CMakeLists.txt index 1b801808b..b918d89cd 100644 --- a/reader/CMakeLists.txt +++ b/reader/CMakeLists.txt @@ -115,6 +115,7 @@ endif() # 定义安装前缀 target_compile_definitions(${PROJECT_NAME} PRIVATE INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}" + INSTALL_LIBDIR="${CMAKE_INSTALL_LIBDIR}" APP_VERSION="${APP_VERSION}" ) diff --git a/reader/document/DjVuModel.cpp b/reader/document/DjVuModel.cpp index 680d20c79..5429ce7de 100644 --- a/reader/document/DjVuModel.cpp +++ b/reader/document/DjVuModel.cpp @@ -442,6 +442,11 @@ QSizeF DjVuPage::sizeF() const return m_size; } +int DjVuPage::resolution() const +{ + return m_resolution > 0 ? m_resolution : 300; +} + QImage DjVuPage::render(int width, int height, const QRect &slice)const { qCDebug(appLog) << "Rendering page" << m_index << "with size" << width << "x" << height << "and slice" << slice; diff --git a/reader/document/DjVuModel.h b/reader/document/DjVuModel.h index ad8e39823..995b500f3 100644 --- a/reader/document/DjVuModel.h +++ b/reader/document/DjVuModel.h @@ -27,6 +27,7 @@ class DjVuPage : public Page ~DjVuPage(); QSizeF sizeF() const override; + int resolution() const override; QImage render(int width, int height, const QRect &slice = QRect())const override; diff --git a/reader/document/Model.cpp b/reader/document/Model.cpp index 6ed8e31fe..03b981f72 100644 --- a/reader/document/Model.cpp +++ b/reader/document/Model.cpp @@ -59,7 +59,14 @@ static int calculateTimeout(qint64 sizeInMB, int baseTimeout, int perMbTimeout) static QString getHtmlToPdfPath() { - QString path = QString(INSTALL_PREFIX) + "/lib/deepin-reader/htmltopdf"; + // Check the actual install libdir first (multiarch-aware, e.g. lib/x86_64-linux-gnu) + QString path = QString(INSTALL_PREFIX) + "/" + INSTALL_LIBDIR + "/deepin-reader/htmltopdf"; + if (QFile::exists(path)) { + qCDebug(appLog) << "Found htmltopdf in INSTALL_LIBDIR: " << path; + return path; + } + + path = QString(INSTALL_PREFIX) + "/lib/deepin-reader/htmltopdf"; if (QFile::exists(path)) { qCDebug(appLog) << "Found htmltopdf in INSTALL_PREFIX: " << path; return path; diff --git a/reader/document/Model.h b/reader/document/Model.h index e1681865c..19871e49a 100644 --- a/reader/document/Model.h +++ b/reader/document/Model.h @@ -188,6 +188,7 @@ class Page: public QObject virtual ~Page() {} virtual QSizeF sizeF() const = 0; + virtual int resolution() const { return 72; } virtual QImage render(int width, int height, const QRect &slice = QRect()) const = 0; /** * @brief 图片对象包围盒(与 render(width,height) 整页输出像素对齐) diff --git a/src/context-menus/deepin-reader-batchprint.conf b/src/context-menus/deepin-reader-batchprint.conf new file mode 100644 index 000000000..b8f498f5d --- /dev/null +++ b/src/context-menus/deepin-reader-batchprint.conf @@ -0,0 +1,16 @@ +[Menu Entry] +Actions=Zero +Version=1.0 + +[Menu Action Zero] +Exec=deepin-reader-batchprint %F +MimeType=application/pdf:application/vnd.openxmlformats-officedocument.wordprocessingml.document:application/wps-office.docx:image/vnd.djvu:application/vnd.ms-xpsdocument:application/oxps +Name=Batch Print +PosNum=6 +Separator=Top +X-DFM-ExcludeMimeTypes=inode/directory:application/x-desktop +X-DFM-MenuTypes=SingleFile:MultiFiles +X-DFM-SupportSchemes=file +Name[zh_CN]=批量打印 +Name[zh_HK]=批量打印 +Name[zh_TW]=批量打印 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dad235786..2b870b41b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -107,6 +107,8 @@ file(GLOB_RECURSE TEST_SOURCES list(FILTER TEST_SOURCES EXCLUDE REGEX "${CMAKE_CURRENT_SOURCE_DIR}/at/") list(FILTER TEST_SOURCES EXCLUDE REGEX "${CMAKE_CURRENT_SOURCE_DIR}/files/") list(FILTER TEST_SOURCES EXCLUDE REGEX "${CMAKE_CURRENT_SOURCE_DIR}/include/") +# batch-print 测试为独立可执行文件(见 batch-print/CMakeLists.txt),不并入本测试目标 +list(FILTER TEST_SOURCES EXCLUDE REGEX "${CMAKE_CURRENT_SOURCE_DIR}/batch-print/") # ====== 合并所有源文件 ====== set(ALL_SOURCES @@ -150,6 +152,7 @@ target_compile_definitions(${PROJECT_NAME} PRIVATE UTSOURCEDIR="${CMAKE_CURRENT_SOURCE_DIR}" APP_VERSION="${APP_VERSION}" INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}" + INSTALL_LIBDIR="${CMAKE_INSTALL_LIBDIR}" ) if (QT_DESIRED_VERSION MATCHES 6) @@ -248,3 +251,6 @@ target_sources(${PROJECT_NAME} PRIVATE ${RESOURCES}) # 启用测试 enable_testing() add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) + +# 批量打印单元测试(独立 gtest 可执行文件) +add_subdirectory(batch-print) diff --git a/tests/batch-print/CMakeLists.txt b/tests/batch-print/CMakeLists.txt new file mode 100644 index 000000000..557291898 --- /dev/null +++ b/tests/batch-print/CMakeLists.txt @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Batch print unit tests (standalone gtest executables linking batchprint-core/convert) + +find_package(GTest REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test Gui) + +# ut_printsettings: test toCupsOptions three-item mapping +add_executable(ut_printsettings ut_printsettings.cpp) +target_link_libraries(ut_printsettings PRIVATE + batchprint-core + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Gui +) +add_test(NAME ut_printsettings COMMAND ut_printsettings) + +# ut_cupsclient: test via ICupsApi mock injection +add_executable(ut_cupsclient ut_cupsclient.cpp) +target_link_libraries(ut_cupsclient PRIVATE + batchprint-core + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Gui +) +add_test(NAME ut_cupsclient COMMAND ut_cupsclient) + +# ut_formatconverter: test format detection and conversion paths +add_executable(ut_formatconverter ut_formatconverter.cpp) +target_link_libraries(ut_formatconverter PRIVATE + batchprint-core + batchprint-convert + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Gui +) +add_test(NAME ut_formatconverter COMMAND ut_formatconverter) + +# ut_notifyclient: test notification message formatting +add_executable(ut_notifyclient ut_notifyclient.cpp) +target_link_libraries(ut_notifyclient PRIVATE + batchprint-core + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Gui +) +add_test(NAME ut_notifyclient COMMAND ut_notifyclient) diff --git a/tests/batch-print/ut_cupsclient.cpp b/tests/batch-print/ut_cupsclient.cpp new file mode 100644 index 000000000..c37d88e36 --- /dev/null +++ b/tests/batch-print/ut_cupsclient.cpp @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "icupsapi.h" +#include "printsettings.h" +#include "cupsclient.h" + +#include +#include +#include +#include + +static int argc = 1; +static char *argv[] = { const_cast("ut_cupsclient"), nullptr }; + +class CupsClientTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + static QGuiApplication *app = nullptr; + if (!app) + app = new QGuiApplication(argc, argv); + } +}; + +// Mock implementation of ICupsApi for testing +class MockCupsApi : public ICupsApi { +public: + bool getCupsDestsResult = true; + bool queryColorResult = true; + bool colorSupportedValue = true; + bool printFileResult = true; + int printFileCallCount = 0; + bool submitJobResult = true; + int submitJobCallCount = 0; + QString lastTitle; + QString lastPrinter; + QString lastFilename; + int lastNumOptions = -1; + QString lastSubmitPath; + QString lastSubmitTitle; + PrintSettings lastSubmitSettings; + + bool getCupsDests(cups_dest_t **dests) override { + Q_UNUSED(dests) + return getCupsDestsResult; + } + + bool queryColorSupported(const QString &printerName, bool &supported) override { + Q_UNUSED(printerName) + supported = colorSupportedValue; + return queryColorResult; + } + + bool printFile(const QString &printerName, const QString &filename, + const QString &title, int numOptions, cups_option_t *options) override { + Q_UNUSED(options) + lastPrinter = printerName; + lastFilename = filename; + lastTitle = title; + lastNumOptions = numOptions; + ++printFileCallCount; + return printFileResult; + } + + bool submitJob(const QString &pdfPath, const QString &title, + const PrintSettings &settings) override { + lastSubmitPath = pdfPath; + lastSubmitTitle = title; + lastSubmitSettings = settings; + ++submitJobCallCount; + return submitJobResult; + } +}; + +TEST_F(CupsClientTest, MockInterfaceBasicOperation) { + MockCupsApi mock; + mock.getCupsDestsResult = true; + mock.queryColorResult = true; + mock.colorSupportedValue = true; + mock.printFileResult = true; + + cups_dest_t *dests = nullptr; + EXPECT_TRUE(mock.getCupsDests(&dests)); + + bool supported = false; + EXPECT_TRUE(mock.queryColorSupported(QStringLiteral("test-printer"), supported)); + EXPECT_TRUE(supported); + + EXPECT_TRUE(mock.printFile(QStringLiteral("test-printer"), + QStringLiteral("/tmp/test.pdf"), + QStringLiteral("test-job"), 0, nullptr)); + EXPECT_EQ(mock.printFileCallCount, 1); +} + +TEST_F(CupsClientTest, MockColorNotSupported) { + MockCupsApi mock; + mock.colorSupportedValue = false; + + bool supported = true; + EXPECT_TRUE(mock.queryColorSupported(QStringLiteral("mono-printer"), supported)); + EXPECT_FALSE(supported); +} + +TEST_F(CupsClientTest, MockPrintFailure) { + MockCupsApi mock; + mock.printFileResult = false; + + EXPECT_FALSE(mock.printFile(QStringLiteral("test"), + QStringLiteral("/tmp/nonexistent.pdf"), + QStringLiteral("fail-job"), 0, nullptr)); +} + +TEST_F(CupsClientTest, MockGetDestsFailure) { + MockCupsApi mock; + mock.getCupsDestsResult = false; + + cups_dest_t *dests = nullptr; + EXPECT_FALSE(mock.getCupsDests(&dests)); +} + +TEST_F(CupsClientTest, CupsClientInitWithoutCups) { + CupsClient client; + bool result = client.init(); + SUCCEED() << "CupsClient::init() returned: " << result; +} + +// Real coverage: verify toCupsOptions produces exact key-value pairs +TEST_F(CupsClientTest, ToCupsOptionsCopiesAndSidesAndColor) { + PrintSettings settings; + settings.copies = 2; + settings.duplex = DuplexMode::OneSided; + settings.colorMode = ColorMode::Auto; + + QStringList opts = toCupsOptions(settings, true); + + EXPECT_EQ(opts.size(), 6); + EXPECT_EQ(opts.at(0), QStringLiteral("copies")); + EXPECT_EQ(opts.at(1), QStringLiteral("2")); + EXPECT_EQ(opts.at(2), QStringLiteral("sides")); + EXPECT_EQ(opts.at(3), QStringLiteral("one-sided")); + EXPECT_EQ(opts.at(4), QStringLiteral("ColorModel")); + EXPECT_EQ(opts.at(5), QStringLiteral("RGB")); +} + +TEST_F(CupsClientTest, ToCupsOptionsCopiesClamp) { + PrintSettings settings; + settings.copies = 5000; + settings.colorMode = ColorMode::Gray; + + QStringList opts = toCupsOptions(settings, true); + int idx = opts.indexOf(QStringLiteral("copies")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("999")); +} + +TEST_F(CupsClientTest, ToCupsOptionsColorForceGray) { + PrintSettings settings; + settings.colorMode = ColorMode::Gray; + + QStringList opts = toCupsOptions(settings, true); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("Gray")); +} + +TEST_F(CupsClientTest, ToCupsOptionsColorForceColor) { + PrintSettings settings; + settings.colorMode = ColorMode::Color; + + QStringList opts = toCupsOptions(settings, false); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("RGB")); +} + +// Verify that printFile passes the title through correctly (not hardcoded) +TEST_F(CupsClientTest, MockPrintFileTitlePropagation) { + MockCupsApi mock; + QString expectedTitle = QStringLiteral("my-document.pdf"); + mock.printFile(QStringLiteral("printer1"), + QStringLiteral("/tmp/output.pdf"), + expectedTitle, 0, nullptr); + EXPECT_EQ(mock.lastTitle, expectedTitle); + EXPECT_EQ(mock.lastPrinter, QStringLiteral("printer1")); + EXPECT_EQ(mock.lastFilename, QStringLiteral("/tmp/output.pdf")); + EXPECT_EQ(mock.lastNumOptions, 0); +} + +// Verify that submitJob propagates title and print settings to the backend +TEST_F(CupsClientTest, MockSubmitJobSettingsPropagation) { + MockCupsApi mock; + PrintSettings settings; + settings.copies = 3; + settings.duplex = DuplexMode::LongEdge; + settings.colorMode = ColorMode::Gray; + + EXPECT_TRUE(mock.submitJob(QStringLiteral("/tmp/output.pdf"), + QStringLiteral("doc.pdf"), settings)); + EXPECT_EQ(mock.submitJobCallCount, 1); + EXPECT_EQ(mock.lastSubmitPath, QStringLiteral("/tmp/output.pdf")); + EXPECT_EQ(mock.lastSubmitTitle, QStringLiteral("doc.pdf")); + EXPECT_EQ(mock.lastSubmitSettings.copies, 3); + EXPECT_EQ(mock.lastSubmitSettings.duplex, DuplexMode::LongEdge); + EXPECT_EQ(mock.lastSubmitSettings.colorMode, ColorMode::Gray); + + mock.submitJobResult = false; + EXPECT_FALSE(mock.submitJob(QStringLiteral("/tmp/other.pdf"), + QStringLiteral("other.pdf"), settings)); + EXPECT_EQ(mock.submitJobCallCount, 2); +} diff --git a/tests/batch-print/ut_formatconverter.cpp b/tests/batch-print/ut_formatconverter.cpp new file mode 100644 index 000000000..bd521c833 --- /dev/null +++ b/tests/batch-print/ut_formatconverter.cpp @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "formatconverter.h" + +#include +#include +#include +#include +#include +#include +#include + +static int argc = 1; +static char *argv[] = { const_cast("ut_formatconverter"), nullptr }; + +class FormatConverterTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + static QGuiApplication *app = nullptr; + if (!app) + app = new QGuiApplication(argc, argv); + } +}; + +TEST_F(FormatConverterTest, NonexistentFileFails) { + QString outputPdfPath; + QString errorMsg; + EXPECT_FALSE(FormatConverter::convertToPdf( + QStringLiteral("/nonexistent/file.pdf"), + QStringLiteral("/tmp"), outputPdfPath, errorMsg)); + EXPECT_FALSE(errorMsg.isEmpty()); +} + +TEST_F(FormatConverterTest, PdfPassthroughReturnsOriginalPath) { + QTemporaryFile tempFile(QStringLiteral("XXXXXX.pdf")); + ASSERT_TRUE(tempFile.open()); + { + QTextStream stream(&tempFile); + stream << "%PDF-1.0\n1 0 obj\n<< /Type /Catalog >>\nendobj\n"; + stream << "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + } + tempFile.close(); + ASSERT_TRUE(QFileInfo::exists(tempFile.fileName())); + + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + + QString outputPdfPath; + QString errorMsg; + bool result = FormatConverter::convertToPdf(tempFile.fileName(), tempDir.path(), + outputPdfPath, errorMsg); + EXPECT_TRUE(result); + EXPECT_EQ(outputPdfPath, tempFile.fileName()); +} + +TEST_F(FormatConverterTest, UnknownFormatFails) { + QTemporaryFile tempFile(QStringLiteral("XXXXXX.unknown")); + ASSERT_TRUE(tempFile.open()); + tempFile.write("dummy content"); + tempFile.close(); + + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + + QString outputPdfPath; + QString errorMsg; + EXPECT_FALSE(FormatConverter::convertToPdf(tempFile.fileName(), tempDir.path(), + outputPdfPath, errorMsg)); + EXPECT_FALSE(errorMsg.isEmpty()); +} + +// DJVU geometry conversion: pixel dimensions at a given DPI must convert to +// correct millimeter page sizes. This verifies the fix for review item 1. +TEST_F(FormatConverterTest, DjvuGeometryConversion300dpi) { + // A US Letter page at 300 dpi: 2550 x 3300 pixels + QSizeF mm = FormatConverter::computePageSizeMm(2550.0, 3300.0, 300); + // US Letter = 215.9 x 279.4 mm + EXPECT_NEAR(mm.width(), 215.9, 0.5); + EXPECT_NEAR(mm.height(), 279.4, 0.5); +} + +TEST_F(FormatConverterTest, DjvuGeometryConversion72dpi) { + // At 72 dpi, pixels == points, so 595 x 842 px → 210 x 297 mm (A4) + QSizeF mm = FormatConverter::computePageSizeMm(595.0, 842.0, 72); + EXPECT_NEAR(mm.width(), 210.0, 0.5); + EXPECT_NEAR(mm.height(), 297.2, 0.5); +} + +TEST_F(FormatConverterTest, DjvuGeometryConversionFallbackDpi) { + // dpi <= 0 should fall back to 300 + QSizeF mm = FormatConverter::computePageSizeMm(2550.0, 3300.0, 0); + EXPECT_NEAR(mm.width(), 215.9, 0.5); + EXPECT_NEAR(mm.height(), 279.4, 0.5); +} + +TEST_F(FormatConverterTest, DjvuGeometryConversionInvalidSize) { + // Zero or negative dimensions should fall back to A4 + QSizeF mm = FormatConverter::computePageSizeMm(0.0, 0.0, 300); + EXPECT_EQ(mm.width(), 210.0); + EXPECT_EQ(mm.height(), 297.0); +} + +TEST_F(FormatConverterTest, DjvuGeometryConversionNonA4Landscape) { + // A3 landscape at 300 dpi: 4961 x 3508 pixels + QSizeF mm = FormatConverter::computePageSizeMm(4961.0, 3508.0, 300); + // A3 = 297 x 420 mm (landscape) + EXPECT_NEAR(mm.width(), 419.9, 1.0); + EXPECT_NEAR(mm.height(), 296.9, 1.0); +} diff --git a/tests/batch-print/ut_notifyclient.cpp b/tests/batch-print/ut_notifyclient.cpp new file mode 100644 index 000000000..abad7969f --- /dev/null +++ b/tests/batch-print/ut_notifyclient.cpp @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "notifyclient.h" +#include "errormessages.h" + +#include +#include + +static int argc = 1; +static char *argv[] = { const_cast("ut_notifyclient"), nullptr }; + +class NotifyClientTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + static QGuiApplication *app = nullptr; + if (!app) + app = new QGuiApplication(argc, argv); + } +}; + +// Notification body parameter assertions: verify buildBody produces correct +// content for each scenario. This verifies the fix for review item 2. + +TEST_F(NotifyClientTest, AllSuccessBodyContainsSuccess) { + QString body = NotifyClient::buildBody(3, 3, QStringList()); + EXPECT_FALSE(body.isEmpty()); + EXPECT_FALSE(body.contains(QStringLiteral("failed"))); +} + +TEST_F(NotifyClientTest, AllFailedBodyContainsFailed) { + QStringList failed; + failed << QStringLiteral("file1.pdf") << QStringLiteral("file2.pdf"); + QString body = NotifyClient::buildBody(2, 0, failed); + EXPECT_FALSE(body.isEmpty()); + EXPECT_TRUE(body.contains(QStringLiteral("failed"))); + EXPECT_TRUE(body.contains(QStringLiteral("file1.pdf"))); + EXPECT_TRUE(body.contains(QStringLiteral("file2.pdf"))); +} + +TEST_F(NotifyClientTest, PartialSuccessBodyContainsBoth) { + QStringList failed; + failed << QStringLiteral("file2.docx"); + QString body = NotifyClient::buildBody(3, 2, failed); + EXPECT_FALSE(body.isEmpty()); + EXPECT_TRUE(body.contains(QStringLiteral("file2.docx"))); +} + +TEST_F(NotifyClientTest, ManyFailedFilesTruncated) { + QStringList failed; + for (int i = 0; i < 10; ++i) + failed << QStringLiteral("file%1.pdf").arg(i); + QString body = NotifyClient::buildBody(10, 0, failed); + // Should contain first 5 files + EXPECT_TRUE(body.contains(QStringLiteral("file0.pdf"))); + EXPECT_TRUE(body.contains(QStringLiteral("file4.pdf"))); + // Should contain truncation notice + EXPECT_TRUE(body.contains(QStringLiteral("5 more"))); +} + +TEST_F(NotifyClientTest, EmptyListBody) { + QString body = NotifyClient::buildBody(0, 0, QStringList()); + EXPECT_FALSE(body.isEmpty()); +} + +TEST_F(NotifyClientTest, NotifyResultAllSuccessDoesNotCrash) { + NotifyClient::notifyResult(3, 3, QStringList()); + SUCCEED(); +} + +TEST_F(NotifyClientTest, NotifyResultAllFailedDoesNotCrash) { + QStringList failed; + failed << QStringLiteral("file1.pdf") << QStringLiteral("file2.pdf"); + NotifyClient::notifyResult(2, 0, failed); + SUCCEED(); +} + +TEST_F(NotifyClientTest, NotifyResultPartialSuccessDoesNotCrash) { + QStringList failed; + failed << QStringLiteral("file2.docx"); + NotifyClient::notifyResult(3, 2, failed); + SUCCEED(); +} + +TEST_F(NotifyClientTest, NotifyResultManyFailedDoesNotCrash) { + QStringList failed; + for (int i = 0; i < 10; ++i) + failed << QStringLiteral("file%1.pdf").arg(i); + NotifyClient::notifyResult(10, 0, failed); + SUCCEED(); +} + +TEST_F(NotifyClientTest, NotifyResultEmptyListDoesNotCrash) { + NotifyClient::notifyResult(0, 0, QStringList()); + SUCCEED(); +} + +// Regression test: buildBody with total==0 and an error message in failedFiles +// must NOT surface the error text — buildBody is designed for result statistics +// only, not environment errors. This is exactly the gap that let the original +// bug slip through: every existing test only exercised success/failure branches. +TEST_F(NotifyClientTest, BuildBodyTotalZeroDoesNotContainErrorMessage) { + QStringList errList; + errList << ErrorMessages::cupsUnavailable(); + QString body = NotifyClient::buildBody(0, 0, errList); + EXPECT_FALSE(body.isEmpty()); + // The error message must NOT appear in buildBody output for total==0. + EXPECT_FALSE(body.contains(ErrorMessages::cupsUnavailable())); + // buildBody(0,0,...) falls into the "All 0 file(s) printed successfully." branch. + EXPECT_TRUE(body.contains(QStringLiteral("successfully"))); +} + +// Environment error path uses notifyError, not buildBody/notifyResult. +TEST_F(NotifyClientTest, NotifyErrorDoesNotCrash) { + NotifyClient::notifyError(ErrorMessages::cupsUnavailable()); + SUCCEED(); +} + +TEST_F(NotifyClientTest, NotifyErrorNoDefaultPrinterDoesNotCrash) { + NotifyClient::notifyError(ErrorMessages::noDefaultPrinter()); + SUCCEED(); +} diff --git a/tests/batch-print/ut_printsettings.cpp b/tests/batch-print/ut_printsettings.cpp new file mode 100644 index 000000000..f116b3cc8 --- /dev/null +++ b/tests/batch-print/ut_printsettings.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "printsettings.h" + +#include +#include + +#include + +static int argc = 1; +static char *argv[] = { const_cast("ut_printsettings"), nullptr }; + +class PrintSettingsTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + static QGuiApplication *app = nullptr; + if (!app) + app = new QGuiApplication(argc, argv); + } +}; + +TEST_F(PrintSettingsTest, CopiesMapping) { + PrintSettings s; + s.copies = 3; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("copies")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("3")); +} + +TEST_F(PrintSettingsTest, CopiesClampHigh) { + PrintSettings s; + s.copies = 9999; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("copies")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("999")); +} + +TEST_F(PrintSettingsTest, CopiesClampLow) { + PrintSettings s; + s.copies = 0; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("copies")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("1")); +} + +TEST_F(PrintSettingsTest, CopiesClampNegative) { + PrintSettings s; + s.copies = -5; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("copies")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("1")); +} + +TEST_F(PrintSettingsTest, SidesOneSided) { + PrintSettings s; + s.duplex = DuplexMode::OneSided; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("sides")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("one-sided")); +} + +TEST_F(PrintSettingsTest, SidesLongEdge) { + PrintSettings s; + s.duplex = DuplexMode::LongEdge; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("sides")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("two-sided-long-edge")); +} + +TEST_F(PrintSettingsTest, SidesShortEdge) { + PrintSettings s; + s.duplex = DuplexMode::ShortEdge; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("sides")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("two-sided-short-edge")); +} + +TEST_F(PrintSettingsTest, ColorModelAutoWithColorSupport) { + PrintSettings s; + s.colorMode = ColorMode::Auto; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("RGB")); +} + +TEST_F(PrintSettingsTest, ColorModelAutoWithoutColorSupport) { + PrintSettings s; + s.colorMode = ColorMode::Auto; + QStringList opts = toCupsOptions(s, false); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("Gray")); +} + +TEST_F(PrintSettingsTest, ColorModelColor) { + PrintSettings s; + s.colorMode = ColorMode::Color; + QStringList opts = toCupsOptions(s, false); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("RGB")); +} + +TEST_F(PrintSettingsTest, ColorModelGray) { + PrintSettings s; + s.colorMode = ColorMode::Gray; + QStringList opts = toCupsOptions(s, true); + int idx = opts.indexOf(QStringLiteral("ColorModel")); + ASSERT_GE(idx, 0); + EXPECT_EQ(opts.at(idx + 1), QStringLiteral("Gray")); +} + +TEST_F(PrintSettingsTest, DefaultValues) { + PrintSettings s; + EXPECT_EQ(s.copies, 1); + EXPECT_EQ(s.duplex, DuplexMode::OneSided); + EXPECT_EQ(s.colorMode, ColorMode::Auto); + EXPECT_EQ(s.orientation, Orientation::Auto); + EXPECT_EQ(s.watermarkEnabled, false); + EXPECT_EQ(s.watermarkAngle, 30); + EXPECT_EQ(s.watermarkSize, 100); + EXPECT_EQ(s.watermarkOpacity, 30); + EXPECT_EQ(s.watermarkLayout, WatermarkLayout::Center); + EXPECT_EQ(s.watermarkTextType, WatermarkTextType::Confidential); +}