From aa7b08323b708d16476c5317504275c9afedda02 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Mon, 7 Sep 2026 16:53:34 +0800 Subject: [PATCH 01/19] feat(reader): add OFD document support via rofd C ABI Add a read-only OFD (GB/T 33190-2016) backend built on the rofd library's stable C ABI (librofd_ffi), following the XPS adapter integration pattern: - OfdDocument/OfdPage implement the Document/Page interfaces: open, page count, page size (mm to logical px via screen DPI), and page rendering through rofd_renderer_render_page_cairo into a QImage. Text extraction and search return empty until the rofd C ABI exposes text APIs. - New Dr::OFD file type detected by .ofd extension, factory branch, open-dialog filter, sidebar (thumbnail | bookmark), big-image rendering path, title widget enablement, save-as filter and format string. - CMake option OFD_SUPPORT (default ON) locates rofd.h and librofd_ffi via -DROFD_ROOT, degrading gracefully when absent; qmake gains a matching ofd_support CONFIG block. - Install application/ofd MIME definition and register it in the desktop file. - Add GTest coverage (open, page size, out-of-range, render content check, invalid size, missing/broken file) with tests/files/normal.ofd. Verified: full build with OFD on/off, 7 OFD unit tests pass, and the application opens and renders an OFD invoice end to end. --- CMakeLists.txt | 39 ++++ assets/mimetype/ofd.xml | 14 ++ reader/CMakeLists.txt | 22 +++ reader/app/Global.cpp | 6 + reader/app/Global.h | 5 +- reader/browser/BrowserPage.cpp | 3 + reader/deepin-reader.desktop | 2 +- reader/document/Model.cpp | 8 + reader/document/OfdModel.cpp | 294 ++++++++++++++++++++++++++++++ reader/document/OfdModel.h | 78 ++++++++ reader/document/document.pri | 7 + reader/uiframe/Central.cpp | 3 + reader/uiframe/CentralDocPage.cpp | 15 +- reader/uiframe/DocSheet.cpp | 13 ++ reader/uiframe/TitleWidget.cpp | 3 + tests/CMakeLists.txt | 13 ++ tests/document/ut_ofdmodel.cpp | 141 ++++++++++++++ tests/files/normal.ofd | Bin 0 -> 23064 bytes 18 files changed, 656 insertions(+), 10 deletions(-) create mode 100644 assets/mimetype/ofd.xml create mode 100644 reader/document/OfdModel.cpp create mode 100644 reader/document/OfdModel.h create mode 100644 tests/document/ut_ofdmodel.cpp create mode 100644 tests/files/normal.ofd diff --git a/CMakeLists.txt b/CMakeLists.txt index fd7fe65bf..16a1a75f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,45 @@ else() unset(XPS_DEPS_CFLAGS_OTHER) endif() +# OFD支持选项(根据 rofd C ABI 库检测自动启用) +option(OFD_SUPPORT "Enable OFD document format support" ON) +set(ROFD_ROOT "" CACHE PATH "Path to the rofd repository checkout or install prefix") + +set(OFD_SUPPORT_RESOLVED ${OFD_SUPPORT}) + +if (OFD_SUPPORT) + set(ROFD_ROOT_HINTS "") + if (ROFD_ROOT) + list(APPEND ROFD_ROOT_HINTS ${ROFD_ROOT}) + endif() + if (EXISTS "${CMAKE_SOURCE_DIR}/../rofd") + list(APPEND ROFD_ROOT_HINTS "${CMAKE_SOURCE_DIR}/../rofd") + endif() + + find_path(ROFD_INCLUDE_DIR rofd.h + HINTS ${ROFD_ROOT_HINTS} + PATH_SUFFIXES crates/rofd-ffi/include include + ) + find_library(ROFD_FFI_LIBRARY NAMES rofd_ffi + HINTS ${ROFD_ROOT_HINTS} + PATH_SUFFIXES target/release target/debug lib + ) + pkg_check_modules(OFD_CAIRO QUIET cairo) + + if (ROFD_INCLUDE_DIR AND ROFD_FFI_LIBRARY AND OFD_CAIRO_FOUND) + message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") + add_compile_definitions(OFD_SUPPORT_ENABLED) + set(OFD_SUPPORT_RESOLVED ON) + else() + message(WARNING ">>> OFD support disabled: set -DROFD_ROOT to the rofd checkout and build rofd-ffi first (cargo build -p rofd-ffi)") + set(OFD_SUPPORT_RESOLVED OFF) + endif() +else() + message(STATUS ">>> OFD support disabled by configuration") +endif() + +set(OFD_SUPPORT_ENABLED ${OFD_SUPPORT_RESOLVED}) + include(GNUInstallDirs) if (CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX /usr) diff --git a/assets/mimetype/ofd.xml b/assets/mimetype/ofd.xml new file mode 100644 index 000000000..b82752654 --- /dev/null +++ b/assets/mimetype/ofd.xml @@ -0,0 +1,14 @@ + + + + + OFD document + OFD 版式文档 + OFD Document + OFD + Open Fixed-layout Document + + + + + diff --git a/reader/CMakeLists.txt b/reader/CMakeLists.txt index 0acef2ce2..1b801808b 100644 --- a/reader/CMakeLists.txt +++ b/reader/CMakeLists.txt @@ -12,6 +12,10 @@ if (XPS_SUPPORT_ENABLED) pkg_check_modules(XPS_DEPS REQUIRED libgxps cairo glib-2.0 gobject-2.0 freetype2) endif() +if (OFD_SUPPORT_ENABLED) + pkg_check_modules(OFD_CAIRO REQUIRED cairo) +endif() + # 添加位置无关代码编译标志 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIE") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIE") @@ -91,6 +95,8 @@ target_include_directories(deepin-reader PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/eyeprotection ${PDFIUM_INCLUDE_DIRS} $<$:${XPS_DEPS_INCLUDE_DIRS}> + $<$:${ROFD_INCLUDE_DIR}> + $<$:${OFD_CAIRO_INCLUDE_DIRS}> ) # 使用DTK变量包含目录 @@ -116,6 +122,10 @@ if (XPS_SUPPORT_ENABLED) target_compile_options(deepin-reader PRIVATE ${XPS_DEPS_CFLAGS_OTHER}) endif() +if (OFD_SUPPORT_ENABLED) + target_compile_options(deepin-reader PRIVATE ${OFD_CAIRO_CFLAGS_OTHER}) +endif() + # 添加资源文件 set(READER_LINK_LIBS ${LINK_LIBS} @@ -127,6 +137,10 @@ if (XPS_SUPPORT_ENABLED) list(APPEND READER_LINK_LIBS ${XPS_DEPS_LIBRARIES}) endif() +if (OFD_SUPPORT_ENABLED) + list(APPEND READER_LINK_LIBS ${ROFD_FFI_LIBRARY} ${OFD_CAIRO_LIBRARIES}) +endif() + if (QT_VERSION_MAJOR MATCHES 6) target_link_libraries(deepin-reader PRIVATE ${READER_LINK_LIBS}) @@ -184,6 +198,14 @@ if (XPS_SUPPORT_ENABLED) message(STATUS ">>> Note: After installation, please run 'sudo update-mime-database ${CMAKE_INSTALL_DATADIR}/mime' to update MIME database") endif() +if (OFD_SUPPORT_ENABLED) + install(FILES + ${CMAKE_SOURCE_DIR}/assets/mimetype/ofd.xml + DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages + ) + message(STATUS ">>> OFD MIME type definition will be installed to ${CMAKE_INSTALL_DATADIR}/mime/packages") +endif() + # 安装帮助文件 install(DIRECTORY ${CMAKE_SOURCE_DIR}/assets/deepin-reader diff --git a/reader/app/Global.cpp b/reader/app/Global.cpp index 335200c23..b7463a1eb 100644 --- a/reader/app/Global.cpp +++ b/reader/app/Global.cpp @@ -40,6 +40,12 @@ FileType fileType(const QString &filePath) } else if (mimeType.name() == QLatin1String("application/vnd.ms-xpsdocument")) { qCDebug(appLog) << "Matched XPS file type by MIME type"; fileType = XPS; +#endif +#ifdef OFD_SUPPORT_ENABLED + } else if (filePath.right(4).toLower() == ".ofd") { + // OFD 是 ZIP 容器,按内容探测会得到 application/zip,统一按后缀判断 + qCDebug(appLog) << "Matched OFD file type by extension"; + fileType = OFD; #endif } else if (mimeType.name() == QLatin1String("application/zip") && filePath.right(4) == "pptx") { qCDebug(appLog) << "Matched PPTX file type"; diff --git a/reader/app/Global.h b/reader/app/Global.h index f34d8c3b1..603d506c0 100644 --- a/reader/app/Global.h +++ b/reader/app/Global.h @@ -42,7 +42,10 @@ enum FileType { DOC = 5, PPTX = 6, #ifdef XPS_SUPPORT_ENABLED - XPS = 7 + XPS = 7, +#endif +#ifdef OFD_SUPPORT_ENABLED + OFD = 8 #endif }; FileType fileType(const QString &filePath); diff --git a/reader/browser/BrowserPage.cpp b/reader/browser/BrowserPage.cpp index a72defc9e..2df1063f7 100644 --- a/reader/browser/BrowserPage.cpp +++ b/reader/browser/BrowserPage.cpp @@ -1326,6 +1326,9 @@ bool BrowserPage::isBigDoc() bool supportedType = (Dr::PDF == m_sheet->fileType()); #ifdef XPS_SUPPORT_ENABLED supportedType = supportedType || (Dr::XPS == m_sheet->fileType()); +#endif +#ifdef OFD_SUPPORT_ENABLED + supportedType = supportedType || (Dr::OFD == m_sheet->fileType()); #endif bool isBig = supportedType && boundingRect().width() > 1000 && boundingRect().height() > 1000; qCDebug(appLog) << "Checking if document is big:" << isBig; diff --git a/reader/deepin-reader.desktop b/reader/deepin-reader.desktop index 931ae5be4..e479ab30c 100644 --- a/reader/deepin-reader.desktop +++ b/reader/deepin-reader.desktop @@ -3,7 +3,7 @@ Categories=Office; Exec=deepin-reader %F GenericName=Document Viewer Icon=deepin-reader -MimeType=application/pdf;image/vnd.djvu;image/vnd.djvu+multipage;application/wps-office.docx;application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-xpsdocument;application/oxps; +MimeType=application/pdf;image/vnd.djvu;image/vnd.djvu+multipage;application/wps-office.docx;application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-xpsdocument;application/oxps;application/ofd; Name=Document Viewer StartupNotify=true TryExec=deepin-reader diff --git a/reader/document/Model.cpp b/reader/document/Model.cpp index b768b79af..6ed8e31fe 100644 --- a/reader/document/Model.cpp +++ b/reader/document/Model.cpp @@ -6,6 +6,9 @@ #ifdef XPS_SUPPORT_ENABLED #include "XpsDocumentAdapter.h" #endif +#ifdef OFD_SUPPORT_ENABLED +#include "OfdModel.h" +#endif #include "PDFModel.h" #include "DjVuModel.h" #include "dpdfannot.h" @@ -115,6 +118,11 @@ deepin_reader::Document *deepin_reader::DocumentFactory::getDocument(const int & } else if (Dr::XPS == fileType) { qCDebug(appLog) << "Handling XPS document"; document = deepin_reader::XpsDocumentAdapter::loadDocument(filePath, error); +#endif +#ifdef OFD_SUPPORT_ENABLED + } else if (Dr::OFD == fileType) { + qCDebug(appLog) << "Handling OFD document"; + document = deepin_reader::OfdDocument::loadDocument(filePath, error); #endif } else if (Dr::DOCX == fileType) { qCDebug(appLog) << "Starting DOCX document conversion process"; diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp new file mode 100644 index 000000000..13b55bc7a --- /dev/null +++ b/reader/document/OfdModel.cpp @@ -0,0 +1,294 @@ +// Copyright (C) 2019 - 2026 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "OfdModel.h" + +#ifdef OFD_SUPPORT_ENABLED + +#include "ddlog.h" + +#include +#include +#include +#include + +namespace deepin_reader { + +static constexpr qreal kMillimetresPerInch = 25.4; + +OfdDocument *OfdDocument::loadDocument(const QString &filePath, Document::Error &error) +{ + qCInfo(appLog) << "Loading OFD document from:" << filePath; + + rofd_load_options_t loadOptions; + rofd_load_options_init(&loadOptions, sizeof(loadOptions)); + + rofd_document_t *document = nullptr; + rofd_error_t *rofdError = nullptr; + const QByteArray nativePath = QFile::encodeName(filePath); + + rofd_status_t status = rofd_document_open(nativePath.constData(), &loadOptions, &document, &rofdError); + if (status != ROFD_STATUS_OK || nullptr == document) { + qCWarning(appLog) << "Failed to open OFD document:" << filePath + << "status:" << status + << "message:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); + rofd_error_free(rofdError); + error = Document::FileError; + return nullptr; + } + + rofd_renderer_t *renderer = nullptr; + status = rofd_renderer_new(nullptr, &renderer, &rofdError); + if (status != ROFD_STATUS_OK || nullptr == renderer) { + qCWarning(appLog) << "Failed to create OFD renderer, status:" << status + << "message:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); + rofd_error_free(rofdError); + rofd_document_free(document); + error = Document::FileError; + return nullptr; + } + + error = Document::NoError; + return new OfdDocument(filePath, document, renderer); +} + +OfdDocument::OfdDocument(const QString &filePath, rofd_document_t *document, rofd_renderer_t *renderer) + : m_filePath(filePath) + , m_document(document) + , m_renderer(renderer) +{ + size_t count = 0; + if (ROFD_STATUS_OK != rofd_document_get_page_count(m_document, &count, nullptr)) { + qCWarning(appLog) << "Failed to query OFD page count:" << m_filePath; + count = 0; + } + m_pageCount = static_cast(count); + + QScreen *srn = QApplication::screens().value(0); + if (nullptr != srn) { + m_xRes = srn->logicalDotsPerInchX(); // 获取屏幕的横纵向逻辑dpi + m_yRes = srn->logicalDotsPerInchY(); + } + + qCInfo(appLog) << "OFD document loaded, pages:" << m_pageCount << "dpi:" << m_xRes << m_yRes; +} + +OfdDocument::~OfdDocument() +{ + qCDebug(appLog) << "Destroying OFD document:" << m_filePath; + rofd_renderer_free(m_renderer); + rofd_document_free(m_document); +} + +int OfdDocument::pageCount() const +{ + return m_pageCount; +} + +Page *OfdDocument::page(int index) const +{ + if (index < 0 || index >= m_pageCount) { + qCWarning(appLog) << "OFD page index out of range:" << index << "count:" << m_pageCount; + return nullptr; + } + + rofd_page_t *pageHandle = nullptr; + rofd_error_t *rofdError = nullptr; + rofd_status_t status = rofd_document_get_page(m_document, static_cast(index), &pageHandle, &rofdError); + if (status != ROFD_STATUS_OK || nullptr == pageHandle) { + qCWarning(appLog) << "Failed to load OFD page:" << index + << "message:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); + rofd_error_free(rofdError); + return nullptr; + } + + return new OfdPage(this, pageHandle, index); +} + +QStringList OfdDocument::saveFilter() const +{ + return QStringList() << QLatin1String("OFD (*.ofd)"); +} + +bool OfdDocument::save() const +{ + // OFD 后端为只读,文档不会产生需要落盘的修改 + return true; +} + +bool OfdDocument::saveAs(const QString &filePath) const +{ + qCInfo(appLog) << "Saving OFD document copy to:" << filePath; + + if (QFile::exists(filePath) && !QFile::remove(filePath)) { + qCWarning(appLog) << "Failed to remove existing target file:" << filePath; + return false; + } + + if (!QFile::copy(m_filePath, filePath)) { + qCWarning(appLog) << "Failed to copy OFD document to:" << filePath; + return false; + } + + return true; +} + +Properties OfdDocument::properties() const +{ + Properties props; + props["Format"] = QStringLiteral("OFD"); + props["FilePath"] = m_filePath; + return props; +} + +QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const +{ + if (nullptr == pageHandle || width <= 0 || height <= 0) { + qCWarning(appLog) << "Invalid OFD render request, handle:" << pageHandle << "size:" << width << height; + return QImage(); + } + + rofd_rect_t pageRect = {0.0, 0.0, 0.0, 0.0}; + if (ROFD_STATUS_OK != rofd_page_get_size_mm(pageHandle, &pageRect, nullptr) || pageRect.width_mm <= 0.0) { + qCWarning(appLog) << "Failed to query OFD page size for rendering"; + return QImage(); + } + + // rofd 按 毫米 -> 像素 的单一比例渲染,目标整页宽度为 width 像素 + const double pixelsPerMm = static_cast(width) / pageRect.width_mm; + + rofd_render_options_t options; + rofd_render_options_init(&options, sizeof(options)); + options.dpi = pixelsPerMm * kMillimetresPerInch; + options.scale = 1.0; + + int32_t pixelWidth = 0; + int32_t pixelHeight = 0; + rofd_error_t *rofdError = nullptr; + if (ROFD_STATUS_OK != rofd_renderer_get_pixel_size(m_renderer, pageHandle, &options, &pixelWidth, &pixelHeight, &rofdError) + || pixelWidth <= 0 || pixelHeight <= 0) { + qCWarning(appLog) << "Failed to compute OFD pixel size:" + << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); + rofd_error_free(rofdError); + return QImage(); + } + + QImage image(pixelWidth, pixelHeight, QImage::Format_ARGB32_Premultiplied); + if (image.isNull()) { + qCWarning(appLog) << "Failed to allocate OFD render image:" << pixelWidth << pixelHeight; + return QImage(); + } + image.fill(Qt::white); + + cairo_surface_t *surface = cairo_image_surface_create_for_data(image.bits(), + CAIRO_FORMAT_ARGB32, + pixelWidth, + pixelHeight, + image.bytesPerLine()); + if (CAIRO_STATUS_SUCCESS != cairo_surface_status(surface)) { + qCWarning(appLog) << "Failed to create Cairo surface for OFD render"; + cairo_surface_destroy(surface); + return QImage(); + } + + cairo_t *cr = cairo_create(surface); + if (CAIRO_STATUS_SUCCESS != cairo_status(cr)) { + qCWarning(appLog) << "Failed to create Cairo context for OFD render"; + cairo_destroy(cr); + cairo_surface_destroy(surface); + return QImage(); + } + + rofd_render_report_t *report = nullptr; + rofd_status_t status = rofd_renderer_render_page_cairo(m_renderer, pageHandle, cr, &options, &report, &rofdError); + + if (nullptr != report) { + size_t diagnosticCount = 0; + if (ROFD_STATUS_OK == rofd_render_report_get_count(report, &diagnosticCount, nullptr) && diagnosticCount > 0) { + for (size_t i = 0; i < diagnosticCount; ++i) { + rofd_render_diagnostic_t diagnostic; + diagnostic.struct_size = sizeof(diagnostic); + if (ROFD_STATUS_OK == rofd_render_report_get_diagnostic(report, i, &diagnostic, nullptr)) { + qCWarning(appLog) << "OFD render diagnostic, kind:" << diagnostic.kind + << "object:" << diagnostic.object_id + << "message:" << (diagnostic.message ? diagnostic.message : ""); + } + } + } + rofd_render_report_free(report); + } + + cairo_destroy(cr); + cairo_surface_destroy(surface); + + if (status != ROFD_STATUS_OK) { + qCWarning(appLog) << "OFD page render failed, status:" << status + << "message:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); + rofd_error_free(rofdError); + return QImage(); + } + + // rofd 的 clip 只限制绘制范围、不改变坐标映射,切片通过整页渲染后裁剪实现 + if (slice.isValid()) { + const QRect bounded = slice.intersected(image.rect()); + if (bounded.isValid() && bounded.size() != image.size()) { + return image.copy(bounded); + } + } + + return image; +} + +OfdPage::OfdPage(const OfdDocument *document, rofd_page_t *pageHandle, int pageIndex) + : m_document(document) + , m_page(pageHandle) + , m_pageIndex(pageIndex) +{ + rofd_rect_t pageRect = {0.0, 0.0, 0.0, 0.0}; + if (ROFD_STATUS_OK == rofd_page_get_size_mm(m_page, &pageRect, nullptr)) { + m_sizePixel = QSizeF(pageRect.width_mm * m_document->xRes() / kMillimetresPerInch, + pageRect.height_mm * m_document->yRes() / kMillimetresPerInch); + } else { + qCWarning(appLog) << "Failed to query OFD page size, page:" << m_pageIndex; + } +} + +OfdPage::~OfdPage() +{ + rofd_page_free(m_page); +} + +QSizeF OfdPage::sizeF() const +{ + return m_sizePixel; +} + +QImage OfdPage::render(int width, int height, const QRect &slice) const +{ + if (nullptr == m_document || nullptr == m_page) { + return QImage(); + } + return m_document->renderPage(m_page, width, height, slice); +} + +QString OfdPage::text(const QRectF &rect) const +{ + // rofd C ABI 暂不提供文本提取接口 + Q_UNUSED(rect) + return QString(); +} + +QVector OfdPage::search(const QString &text, bool matchCase, bool wholeWords) const +{ + // rofd C ABI 暂不提供文本搜索接口 + Q_UNUSED(text) + Q_UNUSED(matchCase) + Q_UNUSED(wholeWords) + return QVector(); +} + +} // namespace deepin_reader + +#endif // OFD_SUPPORT_ENABLED diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h new file mode 100644 index 000000000..6c54e0de5 --- /dev/null +++ b/reader/document/OfdModel.h @@ -0,0 +1,78 @@ +// Copyright (C) 2019 - 2026 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef OFDMODEL_H +#define OFDMODEL_H + +#include "Model.h" + +#ifdef OFD_SUPPORT_ENABLED + +#include +#include + +#include + +namespace deepin_reader { + +class OfdPage; + +class OfdDocument : public Document +{ + Q_OBJECT +public: + static OfdDocument *loadDocument(const QString &filePath, Document::Error &error); + + ~OfdDocument() override; + + int pageCount() const override; + Page *page(int index) const override; + QStringList saveFilter() const override; + bool save() const override; + bool saveAs(const QString &filePath) const override; + Properties properties() const override; + + QString filePath() const { return m_filePath; } + qreal xRes() const { return m_xRes; } + qreal yRes() const { return m_yRes; } + + // 供 OfdPage 使用的渲染接口 + QImage renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const; + +private: + OfdDocument(const QString &filePath, rofd_document_t *document, rofd_renderer_t *renderer); + + QString m_filePath; + rofd_document_t *m_document = nullptr; + rofd_renderer_t *m_renderer = nullptr; + int m_pageCount = 0; + qreal m_xRes = 96.0; + qreal m_yRes = 96.0; +}; + +class OfdPage : public Page +{ + Q_OBJECT +public: + OfdPage(const OfdDocument *document, rofd_page_t *pageHandle, int pageIndex); + ~OfdPage() override; + + QSizeF sizeF() const override; + QImage render(int width, int height, const QRect &slice = QRect()) const override; + QString text(const QRectF &rect) const override; + QVector search(const QString &text, bool matchCase, bool wholeWords) const override; + +private: + const OfdDocument *m_document; + rofd_page_t *m_page = nullptr; + int m_pageIndex = -1; + QSizeF m_sizePixel; +}; + +} // namespace deepin_reader + +#endif // OFD_SUPPORT_ENABLED + +#endif // OFDMODEL_H diff --git a/reader/document/document.pri b/reader/document/document.pri index 9b2041461..0066d411c 100644 --- a/reader/document/document.pri +++ b/reader/document/document.pri @@ -11,6 +11,13 @@ xps_support { SOURCES += $$PWD/XpsTextExtractor.cpp } +# OFD支持文件(条件包含,需要 rofd-ffi 头文件与库,见顶层 CMake 的 ROFD_ROOT) +ofd_support { + HEADERS += $$PWD/OfdModel.h + SOURCES += $$PWD/OfdModel.cpp + DEFINES += OFD_SUPPORT_ENABLED +} + SOURCES += \ $$PWD/PDFModel.cpp \ $$PWD/DjVuModel.cpp \ diff --git a/reader/uiframe/Central.cpp b/reader/uiframe/Central.cpp index 98984f159..47a314c96 100644 --- a/reader/uiframe/Central.cpp +++ b/reader/uiframe/Central.cpp @@ -177,6 +177,9 @@ void Central::addFilesWithDialog() QStringList filters = {"*.pdf", "*.djvu", "*.docx"}; #ifdef XPS_SUPPORT_ENABLED filters << "*.xps"; +#endif +#ifdef OFD_SUPPORT_ENABLED + filters << "*.ofd"; #endif dialog.setNameFilter(tr("Documents") + QStringLiteral(" (") + filters.join(' ') + QLatin1Char(')')); dialog.setDirectory(QDir::homePath()); diff --git a/reader/uiframe/CentralDocPage.cpp b/reader/uiframe/CentralDocPage.cpp index cf553f22a..4cd838bbb 100644 --- a/reader/uiframe/CentralDocPage.cpp +++ b/reader/uiframe/CentralDocPage.cpp @@ -234,21 +234,20 @@ void CentralDocPage::addFileAsync(const QString &filePath) } Dr::FileType fileType = Dr::fileType(filePath); + bool supported = Dr::PDF == fileType || Dr::DJVU == fileType || Dr::DOCX == fileType; #ifdef XPS_SUPPORT_ENABLED - if (Dr::PDF != fileType && Dr::DJVU != fileType && Dr::DOCX != fileType && Dr::XPS != fileType) { -#else - if (Dr::PDF != fileType && Dr::DJVU != fileType && Dr::DOCX != fileType) { + supported = supported || Dr::XPS == fileType; #endif +#ifdef OFD_SUPPORT_ENABLED + supported = supported || Dr::OFD == fileType; +#endif + if (!supported) { if (pathControl(filePath)) { qCInfo(appLog) << "没有权限读取该文件"; return; } showTips(m_stackedLayout->currentWidget(), tr("The format is not supported"), 1); -#ifdef XPS_SUPPORT_ENABLED - qCWarning(appLog) << "不支持该文件格式!(仅支持PDF、DJVU、DOCX、XPS)文件格式:" << fileType << "(Unknown = 0, PDF = 1, DJVU = 2, DOCX = 3, PS = 4, DOC = 5, PPTX = 6, XPS = 7)"; -#else - qCWarning(appLog) << "不支持该文件格式!(仅支持PDF、DJVU、DOCX)文件格式:" << fileType << "(Unknown = 0, PDF = 1, DJVU = 2, DOCX = 3, PS = 4, DOC = 5, PPTX = 6, XPS = 7)"; -#endif + qCWarning(appLog) << "不支持该文件格式!(仅支持PDF、DJVU、DOCX、XPS、OFD)文件格式:" << fileType << "(Unknown = 0, PDF = 1, DJVU = 2, DOCX = 3, PS = 4, DOC = 5, PPTX = 6, XPS = 7, OFD = 8)"; return; } diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index 6a4cdf584..7b9ced0cf 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -87,6 +87,10 @@ DocSheet::DocSheet(const Dr::FileType &fileType, const QString &filePath, QWidg #ifdef XPS_SUPPORT_ENABLED else if (Dr::XPS == fileType) m_sidebar = new SheetSidebar(this, PREVIEW_THUMBNAIL | PREVIEW_CATALOG | PREVIEW_BOOKMARK); +#endif +#ifdef OFD_SUPPORT_ENABLED + else if (Dr::OFD == fileType) + m_sidebar = new SheetSidebar(this, PREVIEW_THUMBNAIL | PREVIEW_BOOKMARK); #endif else m_sidebar = new SheetSidebar(this); @@ -867,6 +871,10 @@ QString DocSheet::filter() return QStringLiteral("XPS Files (*.xps);;Pdf Files (*.pdf)"); } #endif +#ifdef OFD_SUPPORT_ENABLED + else if (Dr::OFD == m_fileType) + return "OFD Files (*.ofd)"; +#endif qCDebug(appLog) << "filter end, return:"; return ""; @@ -887,6 +895,11 @@ QString DocSheet::format() qCDebug(appLog) << "filter end, return:"; return QString("DJVU"); } +#ifdef OFD_SUPPORT_ENABLED + else if (Dr::OFD == m_fileType) { + return QString("OFD"); + } +#endif qCDebug(appLog) << "format end, return:"; return ""; } diff --git a/reader/uiframe/TitleWidget.cpp b/reader/uiframe/TitleWidget.cpp index 35d4c0465..dc5d0750d 100644 --- a/reader/uiframe/TitleWidget.cpp +++ b/reader/uiframe/TitleWidget.cpp @@ -141,6 +141,9 @@ void TitleWidget::onCurSheetChanged(DocSheet *sheet) || Dr::DOCX == m_curSheet->fileType() #ifdef XPS_SUPPORT_ENABLED || Dr::XPS == m_curSheet->fileType() +#endif +#ifdef OFD_SUPPORT_ENABLED + || Dr::OFD == m_curSheet->fileType() #endif ) { if (m_curSheet->opened()) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 892e928e6..dad235786 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,11 @@ endif() set(XPS_SUPPORT_ENABLED ${XPS_SUPPORT_RESOLVED}) +# OFD 支持(检测结果继承自顶层 CMakeLists.txt 的 OFD_SUPPORT_ENABLED / ROFD_*) +if (OFD_SUPPORT_ENABLED) + pkg_check_modules(OFD_CAIRO REQUIRED cairo) +endif() + # ====== 收集 reader 源文件 ====== # 递归收集 reader/ 下所有源文件,排除 main.cpp # 新增子模块时无需修改此文件 @@ -126,6 +131,8 @@ target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include/gtest ${PDFIUM_INCLUDE_DIRS} $<$:${XPS_DEPS_INCLUDE_DIRS}> + $<$:${ROFD_INCLUDE_DIR}> + $<$:${OFD_CAIRO_INCLUDE_DIRS}> ) # DTK 包含目录(Qt5 使用变量,Qt6 使用目标) @@ -155,6 +162,7 @@ target_compile_options(${PROJECT_NAME} PRIVATE -fprofile-arcs -ftest-coverage -fstack-protector-strong -D_FORTIFY_SOURCE=1 -fPIC $<$:${XPS_DEPS_CFLAGS_OTHER}> + $<$:${OFD_CAIRO_CFLAGS_OTHER}> ) target_link_options(${PROJECT_NAME} PRIVATE @@ -212,6 +220,11 @@ if (XPS_SUPPORT_ENABLED) list(APPEND TEST_LINK_LIBS ${XPS_DEPS_LIBRARIES}) endif() +# OFD 链接 +if (OFD_SUPPORT_ENABLED) + list(APPEND TEST_LINK_LIBS ${ROFD_FFI_LIBRARY} ${OFD_CAIRO_LIBRARIES}) +endif() + # PDFium 链接 if (USE_PDFIUM_BUNDLE) target_include_directories(${PROJECT_NAME} PUBLIC diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp new file mode 100644 index 000000000..c89fec1ea --- /dev/null +++ b/tests/document/ut_ofdmodel.cpp @@ -0,0 +1,141 @@ +// Copyright (C) 2019 - 2026 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "OfdModel.h" + +#ifdef OFD_SUPPORT_ENABLED + +#include "Model.h" +#include "ut_defines.h" + +#include +#include +#include + +#include + +using namespace deepin_reader; + +namespace { + +QString ofdFilePath() +{ + QString path = QString(UTSOURCEDIR) + "/files/normal.ofd"; + if (QFile(path).exists()) + return path; + + path = QCoreApplication::applicationDirPath() + "/files/normal.ofd"; + return path; +} + +bool hasOfdFile() +{ + return QFile(ofdFilePath()).exists(); +} + +} // namespace + +class TestOfdModel : public ::testing::Test +{ +public: + void SetUp() override + { + if (!hasOfdFile()) + GTEST_SKIP() << "normal.ofd not available, skipping OFD model tests"; + + Document::Error error = Document::NoError; + m_doc.reset(OfdDocument::loadDocument(ofdFilePath(), error)); + ASSERT_NE(m_doc, nullptr); + EXPECT_EQ(error, Document::NoError); + } + + void TearDown() override {} + + QString m_path; + std::unique_ptr m_doc; +}; + +TEST_F(TestOfdModel, loadDocument) +{ + EXPECT_GT(m_doc->pageCount(), 0); +} + +TEST_F(TestOfdModel, pageSize) +{ + Page *page = m_doc->page(0); + ASSERT_NE(page, nullptr); + + const QSizeF size = page->sizeF(); + EXPECT_GT(size.width(), 0.0); + EXPECT_GT(size.height(), 0.0); + + delete page; +} + +TEST_F(TestOfdModel, pageOutOfRange) +{ + EXPECT_EQ(m_doc->page(-1), nullptr); + EXPECT_EQ(m_doc->page(m_doc->pageCount()), nullptr); +} + +TEST_F(TestOfdModel, renderPage) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + const QImage image = page->render(400, 400); + ASSERT_FALSE(image.isNull()); + EXPECT_GT(image.width(), 0); + EXPECT_GT(image.height(), 0); + + // 渲染结果不应是纯白页面 + bool hasContentPixel = false; + for (int y = 0; y < image.height() && !hasContentPixel; ++y) { + for (int x = 0; x < image.width(); ++x) { + const QRgb pixel = image.pixel(x, y); + if (qRed(pixel) < 240 || qGreen(pixel) < 240 || qBlue(pixel) < 240) { + hasContentPixel = true; + break; + } + } + } + EXPECT_TRUE(hasContentPixel); +} + +TEST_F(TestOfdModel, renderInvalidSize) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + EXPECT_TRUE(page->render(0, 0).isNull()); + EXPECT_TRUE(page->render(-10, 100).isNull()); +} + +TEST_F(TestOfdModel, loadMissingFile) +{ + Document::Error error = Document::NoError; + std::unique_ptr doc(OfdDocument::loadDocument(QStringLiteral("/nonexistent/path/to.ofd"), error)); + EXPECT_EQ(doc, nullptr); + EXPECT_EQ(error, Document::FileError); +} + +TEST_F(TestOfdModel, loadBrokenFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString brokenPath = dir.filePath(QStringLiteral("broken.ofd")); + QFile file(brokenPath); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write("this is not an ofd zip archive"); + file.close(); + + Document::Error error = Document::NoError; + std::unique_ptr doc(OfdDocument::loadDocument(brokenPath, error)); + EXPECT_EQ(doc, nullptr); + EXPECT_EQ(error, Document::FileError); +} + +#endif // OFD_SUPPORT_ENABLED diff --git a/tests/files/normal.ofd b/tests/files/normal.ofd new file mode 100644 index 0000000000000000000000000000000000000000..e5e35522866743f7d69a39fa732284dd0ce8173e GIT binary patch literal 23064 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 IwCz>% literal 0 HcmV?d00001 From 89d489e6e0bb835904bb4b8f59481e026ffebb55 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Wed, 9 Sep 2026 14:21:49 +0800 Subject: [PATCH 02/19] build: depend on distro librofd-ffi packages for OFD support OFD support now uses the Debian packages shipped by upstream rofd instead of a local rofd checkout: - debian/control Build-Depends on librofd-ffi-dev; the runtime dependency on librofd-ffi0 (>= 0.2.2) is derived automatically by dh_shlibdeps from the library's shlibs file. - CMake keeps locating rofd.h and librofd_ffi through find_path and find_library, now also looking in lib/ so a custom install prefix works, and still degrades gracefully (OFD disabled with a warning) when the dependency is absent; -DROFD_ROOT remains available for local development against a rofd checkout. - qmake CONFIG+=ofd_support links the system librofd_ffi. - Document librofd-ffi-dev in both README dependency lists. Verified with librofd-ffi-dev 0.2.2-1: full build, TestOfdModel 7/7, 1116 unit tests pass, the application opens and renders tests/files/normal.ofd, and dpkg-shlibdeps resolves librofd-ffi0 (>= 0.2.2). --- CMakeLists.txt | 14 ++++++++++---- README.md | 2 +- README.zh_CN.md | 2 +- debian/control | 1 + reader/document/document.pri | 2 +- reader/reader.pro | 6 ++++++ 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 16a1a75f4..1604166d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,13 +62,14 @@ else() unset(XPS_DEPS_CFLAGS_OTHER) endif() -# OFD支持选项(根据 rofd C ABI 库检测自动启用) +# OFD支持选项(依赖发行版提供的 rofd C ABI 库,见 debian/control 的 librofd-ffi-dev) option(OFD_SUPPORT "Enable OFD document format support" ON) -set(ROFD_ROOT "" CACHE PATH "Path to the rofd repository checkout or install prefix") +set(ROFD_ROOT "" CACHE PATH "Path to the rofd repository checkout or install prefix, overrides the system install") set(OFD_SUPPORT_RESOLVED ${OFD_SUPPORT}) if (OFD_SUPPORT) + # 默认走系统路径:librofd-ffi-dev 提供 /usr/include/rofd.h 与 lib//librofd_ffi.so set(ROFD_ROOT_HINTS "") if (ROFD_ROOT) list(APPEND ROFD_ROOT_HINTS ${ROFD_ROOT}) @@ -77,13 +78,18 @@ if (OFD_SUPPORT) list(APPEND ROFD_ROOT_HINTS "${CMAKE_SOURCE_DIR}/../rofd") endif() + set(ROFD_LIB_SUFFIXES target/release target/debug lib) + if (CMAKE_LIBRARY_ARCHITECTURE) + list(APPEND ROFD_LIB_SUFFIXES "lib/${CMAKE_LIBRARY_ARCHITECTURE}") + endif() + find_path(ROFD_INCLUDE_DIR rofd.h HINTS ${ROFD_ROOT_HINTS} PATH_SUFFIXES crates/rofd-ffi/include include ) find_library(ROFD_FFI_LIBRARY NAMES rofd_ffi HINTS ${ROFD_ROOT_HINTS} - PATH_SUFFIXES target/release target/debug lib + PATH_SUFFIXES ${ROFD_LIB_SUFFIXES} ) pkg_check_modules(OFD_CAIRO QUIET cairo) @@ -92,7 +98,7 @@ if (OFD_SUPPORT) add_compile_definitions(OFD_SUPPORT_ENABLED) set(OFD_SUPPORT_RESOLVED ON) else() - message(WARNING ">>> OFD support disabled: set -DROFD_ROOT to the rofd checkout and build rofd-ffi first (cargo build -p rofd-ffi)") + message(WARNING ">>> OFD support disabled: install librofd-ffi-dev, or pass -DROFD_ROOT=") set(OFD_SUPPORT_RESOLVED OFF) endif() else() diff --git a/README.md b/README.md index 21309b3d3..21a84febd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ deepin-reader is a small, fast and full-featured tool for viewing documents,supp In debian, use below command to install compile dependencies: -`sudo apt install debhelper (>= 11),pkg-config,libspectre-dev, libdjvulibre-dev, qt5-qmake, qt5-default,libtiff-dev, libkf5archive-dev, libdtkwidget-dev,qttools5-dev-tools,qtbase5-private-dev,libjpeg-dev,libicu-dev,libpng-dev,zlib1g-dev` +`sudo apt install debhelper (>= 11),pkg-config,libspectre-dev, libdjvulibre-dev, qt5-qmake, qt5-default,libtiff-dev, libkf5archive-dev, libdtkwidget-dev,qttools5-dev-tools,qtbase5-private-dev,libjpeg-dev,libicu-dev,libpng-dev,zlib1g-dev,librofd-ffi-dev` ## Install diff --git a/README.zh_CN.md b/README.zh_CN.md index 91c74fae0..c3ea98da8 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -6,7 +6,7 @@ deepin-reader是一款小型、快速、功能齐全的工具,用于查看文 In debian, use below command to install compile dependencies: -`sudo apt install debhelper (>= 11),pkg-config,libspectre-dev, libdjvulibre-dev, qt5-qmake, qt5-default,libtiff-dev, libkf5archive-dev, libdtkwidget-dev,qttools5-dev-tools,qtbase5-private-dev,libjpeg-dev,libicu-dev,libpng-dev,zlib1g-dev` +`sudo apt install debhelper (>= 11),pkg-config,libspectre-dev, libdjvulibre-dev, qt5-qmake, qt5-default,libtiff-dev, libkf5archive-dev, libdtkwidget-dev,qttools5-dev-tools,qtbase5-private-dev,libjpeg-dev,libicu-dev,libpng-dev,zlib1g-dev,librofd-ffi-dev` ## 安装 diff --git a/debian/control b/debian/control index 786e1d7c8..f1eb2ab21 100644 --- a/debian/control +++ b/debian/control @@ -18,6 +18,7 @@ Build-Depends: libdtk6core-dev [!mipsel !mips64el] | libdtkcore-dev, libgxps-dev, libcairo2-dev, + librofd-ffi-dev, libglib2.0-dev, libdjvulibre-dev, libtiff-dev, diff --git a/reader/document/document.pri b/reader/document/document.pri index 0066d411c..acd8aff52 100644 --- a/reader/document/document.pri +++ b/reader/document/document.pri @@ -11,7 +11,7 @@ xps_support { SOURCES += $$PWD/XpsTextExtractor.cpp } -# OFD支持文件(条件包含,需要 rofd-ffi 头文件与库,见顶层 CMake 的 ROFD_ROOT) +# OFD支持文件(条件包含,需要系统安装的 librofd-ffi-dev,见 debian/control) ofd_support { HEADERS += $$PWD/OfdModel.h SOURCES += $$PWD/OfdModel.cpp diff --git a/reader/reader.pro b/reader/reader.pro index a1810e982..eb591b1db 100755 --- a/reader/reader.pro +++ b/reader/reader.pro @@ -67,6 +67,12 @@ if(contains(DEFINES, CMAKE_COVERAGE_ARG_ON)){ CONFIG += c++11 link_pkgconfig +# OFD支持(qmake CONFIG+=ofd_support),链接系统安装的 librofd-ffi(librofd-ffi-dev) +ofd_support { + message(">>> OFD support enabled (system librofd-ffi)") + LIBS += -lrofd_ffi +} + TARGET = deepin-reader TEMPLATE = app From b9f51208353ddf564d70536113462b1c53928cd2 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Thu, 10 Sep 2026 05:08:06 +0800 Subject: [PATCH 03/19] fix(sidebar): align thumbnail appearance with eye protection mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thumbnail delegate inverted the page pixmap whenever the system theme was dark, while BrowserPage only inverts in the night eye-protection mode. Under a dark system theme with eye protection off the main view kept white pages but the sidebar thumbnails turned black, so the two disagreed - most visible with white OFD/PDF pages. - ThumbnailDelegate now follows the EyeProtectionManager mode instead of the system theme and mirrors BrowserPage::paint exactly: night mode inverts luminance and dims with the page colour, classic/green multiply-tint with the page colour, off draws the original pixmap. - Extract the HSL luminance inversion into EyeProtectionManager::invertLuminance and let BrowserPage::applyNightMode delegate to it so both paths share one algorithm; the inverted thumbnail is cached by source pixmap key to avoid per-pixel work on every repaint. - SideBarImageListView refreshes visible thumbnails on modeChanged instead of themeTypeChanged. - Cover the inversion helper, the mode-driven delegate painting and the modeChanged refresh in unit tests. Verified under a dark system theme: thumbnails render white with eye protection off (matching the main view, previously black), dark in night mode and tinted in classic mode; targeted unit tests pass. Log: 修复深色主题下OFD/PDF侧边栏缩略图黑底与主视图白底不一致的问题 Influence: 侧边栏缩略图外观改为跟随护眼模式(夜间反色、经典/绿色染色),与主视图页面保持一致;深色系统主题且未开启护眼时缩略图恢复白底 --- reader/sidebar/SideBarImageListview.cpp | 6 +- reader/sidebar/ThumbnailDelegate.cpp | 89 ++++++++++++----------- reader/sidebar/ThumbnailDelegate.h | 12 +++ tests/sidebar-appearance/CMakeLists.txt | 24 ++++++ tests/sidebar-appearance/main.cc | 56 ++++++++++++++ tests/sidebar/ut_sidebarimagelistview.cpp | 6 +- tests/sidebar/ut_thumbnaildelegate.cpp | 51 +++++++++++++ 7 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 tests/sidebar-appearance/CMakeLists.txt create mode 100644 tests/sidebar-appearance/main.cc diff --git a/reader/sidebar/SideBarImageListview.cpp b/reader/sidebar/SideBarImageListview.cpp index a09126617..464cb4cf9 100644 --- a/reader/sidebar/SideBarImageListview.cpp +++ b/reader/sidebar/SideBarImageListview.cpp @@ -7,6 +7,7 @@ #include "DocSheet.h" #include "SideBarImageViewModel.h" #include "Application.h" +#include "EyeProtectionManager.h" #include "MsgHeader.h" #include "ThumbnailWidget.h" #include "ddlog.h" @@ -46,7 +47,10 @@ SideBarImageListView::SideBarImageListView(DocSheet *sheet, QWidget *parent) connect(verticalScrollBar(), &QScrollBar::sliderReleased, this, &SideBarImageListView::onSetThumbnailListSlideGesture); qCDebug(appLog) << "Connected scrollbar signals"; - // 主题切换时刷新可见缩略图,使 ThumbnailDelegate 按新主题反色重绘 + // 护眼模式切换时刷新可见缩略图,使 ThumbnailDelegate 按新的页面外观重绘 + connect(EyeProtectionManager::instance(), &EyeProtectionManager::modeChanged, + this, [this]() { this->viewport()->update(); }); + // 页面内容跟随护眼模式,边框/文字等界面装饰仍跟随系统主题。 connect(DGuiApplicationHelper::instance(), &DGuiApplicationHelper::themeTypeChanged, this, [this]() { this->viewport()->update(); }); } diff --git a/reader/sidebar/ThumbnailDelegate.cpp b/reader/sidebar/ThumbnailDelegate.cpp index b4fccc9f7..d5b199e3f 100644 --- a/reader/sidebar/ThumbnailDelegate.cpp +++ b/reader/sidebar/ThumbnailDelegate.cpp @@ -5,6 +5,8 @@ #include "ThumbnailDelegate.h" #include "SideBarImageViewModel.h" +#include "EyeProtectionManager.h" +#include "NightFilter.h" #include "Utils.h" #include "Application.h" #include "ddlog.h" @@ -41,7 +43,9 @@ void ThumbnailDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt transform.rotate(rotate); - const QPixmap &pixmap = index.data(ImageinfoType_e::IMAGE_PIXMAP).value().transformed(transform); + const QPixmap &rawPixmap = index.data(ImageinfoType_e::IMAGE_PIXMAP).value(); + + const QPixmap &pixmap = rawPixmap.transformed(transform); const int borderRadius = 6; @@ -63,47 +67,33 @@ void ThumbnailDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QPainterPath clipPath; clipPath.addRoundedRect(rect, borderRadius, borderRadius); painter->setClipPath(clipPath); - // 深色系统主题下,缩略图卡片需与侧边栏深色背景协调:将文档原始白底黑字 - // 的缩略图反色为黑底白字;浅色主题保持原样。反色仅在绘制时进行, - // 不修改 DocSheet 中缓存的真实缩略图(始终为白底),避免主题切换时双重反色。 - // 采用与 BrowserPage::applyNightMode 相同的 HSL 亮度反转算法: - // 仅反转 Lightness 通道,保留 Hue/Saturation,避免图片色相偏移 180°。 - // 两端收敛:下限钳制 37(#252525),反转后 ≥192 提亮纯白 - // 白底黑字 → 黑底白字(文字/背景正确反色) - // 彩色图片/链接 → 仅变暗,色相保持 - if (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType) { - QImage img = pixmap.toImage(); - if (!img.isNull()) { - if (img.format() != QImage::Format_ARGB32) - img = img.convertToFormat(QImage::Format_ARGB32); - const int w = img.width(); - const int h = img.height(); - const int kMinLightAfterInvert = 37; // #252525 - const int kMaxLightBoostThreshold = 192; // 0xC0,提亮阈值 - for (int y = 0; y < h; ++y) { - QRgb *line = reinterpret_cast(img.scanLine(y)); - for (int x = 0; x < w; ++x) { - const QRgb px = line[x]; - const int alpha = qAlpha(px); - QColor c = QColor::fromRgb(qRed(px), qGreen(px), qBlue(px)); - int hue, sat, light, dummy; - c.getHsl(&hue, &sat, &light, &dummy); - light = 255 - light; - if (light >= kMaxLightBoostThreshold) - light = 255; - light = qMax(light, kMinLightAfterInvert); - c.setHsl(hue, sat, light); - line[x] = qRgba(c.red(), c.green(), c.blue(), alpha); - } - } - QPixmap invertedPixmap = QPixmap::fromImage(img); - invertedPixmap.setDevicePixelRatio(pixmap.devicePixelRatio()); - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), invertedPixmap); - } else { - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), pixmap); - } - } else { - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), pixmap); + // 缩略图外观必须与主视图(BrowserPage::paint)保持一致,因此跟随护眼模式而非 + // 系统深浅主题:此前按系统深色主题反色,深色主题 + 无护眼时会出现主视图 + // 仍是白底、侧边栏缩略图却是黑底的不一致(尤其白底的 OFD/PDF 文档)。 + // 使用主干的夜间滤镜;缩略图尚无图片对象蒙版,采用其整页回退路径: + // Night → CIELAB 明度反转 + 深色压暗 + // Classic/Green → 正片叠底(Multiply)染色 + // Off → 原图 + EyeProtectionManager *epMgr = EyeProtectionManager::instance(); + const EyeProtectionManager::Mode mode = epMgr->mode(); + + // 反色结果按未旋转的原始缩略图缓存,再叠加旋转,避免每次重绘都逐像素反色 + const QPixmap displayPixmap = (mode == EyeProtectionManager::Night) + ? nightPixmap(rawPixmap).transformed(transform) + : pixmap; + + painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), displayPixmap); + + if (mode == EyeProtectionManager::Night) { + // 与主视图一致:叠加轻微深色半透明层降低整体亮度 + QColor dark = epMgr->pageBackgroundColor(); + dark.setAlpha(60); + painter->fillRect(rect, dark); + } else if (mode != EyeProtectionManager::Off) { + // 与主视图一致:经典/绿色护眼用正片叠底染色 + painter->setCompositionMode(QPainter::CompositionMode_Multiply); + painter->fillRect(rect, epMgr->pageBackgroundColor()); + painter->setCompositionMode(QPainter::CompositionMode_SourceOver); } painter->restore(); } @@ -138,6 +128,21 @@ QSize ThumbnailDelegate::sizeHint(const QStyleOptionViewItem &option, const QMod return DStyledItemDelegate::sizeHint(option, index); } +QPixmap ThumbnailDelegate::nightPixmap(const QPixmap &src) const +{ + if (src.isNull()) + return src; + + // 滚动/选中时同一张缩略图会被反复重绘,缓存反色结果避免逐像素重复计算 + if (m_nightSourceCache.cacheKey() == src.cacheKey() && !m_nightPixmapCache.isNull()) + return m_nightPixmapCache; + + m_nightSourceCache = src; + m_nightPixmapCache = QPixmap::fromImage(NightFilter::applyPage(src.toImage(), {})); + m_nightPixmapCache.setDevicePixelRatio(src.devicePixelRatio()); + return m_nightPixmapCache; +} + void ThumbnailDelegate::drawBookMark(QPainter *painter, const QRect &rect, bool visible) const { // qCDebug(appLog) << "Drawing bookmark at:" << rect; diff --git a/reader/sidebar/ThumbnailDelegate.h b/reader/sidebar/ThumbnailDelegate.h index 258f79018..80f6bdd4c 100644 --- a/reader/sidebar/ThumbnailDelegate.h +++ b/reader/sidebar/ThumbnailDelegate.h @@ -7,6 +7,7 @@ #define IMAGEVIEWDELEGATE_H #include +#include DWIDGET_USE_NAMESPACE /** @@ -47,8 +48,19 @@ class ThumbnailDelegate : public DStyledItemDelegate */ void drawBookMark(QPainter *painter, const QRect &rect, bool visible) const; + /** + * @brief nightPixmap + * 夜间护眼模式下缩略图的智能反色结果(带缓存) + * @param src 原始缩略图 + * @return 反色后的缩略图 + */ + QPixmap nightPixmap(const QPixmap &src) const; + private: QAbstractItemView *m_parent = nullptr; + + mutable QPixmap m_nightSourceCache; // 反色缓存的源缩略图 + mutable QPixmap m_nightPixmapCache; // 反色后的缩略图 }; #endif // IMAGEVIEWDELEGATE_H diff --git a/tests/sidebar-appearance/CMakeLists.txt b/tests/sidebar-appearance/CMakeLists.txt new file mode 100644 index 000000000..a40a50e6f --- /dev/null +++ b/tests/sidebar-appearance/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.16) +project(reader-sidebar-appearance-tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(PkgConfig REQUIRED) +pkg_check_modules(DTK REQUIRED IMPORTED_TARGET dtk6widget) + +set(READER_DIR "${CMAKE_CURRENT_LIST_DIR}/../../reader") +add_executable(test-sidebar-appearance + main.cc + ${READER_DIR}/sidebar/ThumbnailDelegate.cpp + ${READER_DIR}/eyeprotection/EyeProtectionManager.cpp + ${READER_DIR}/eyeprotection/EyeProtectionManager.h + ${READER_DIR}/browser/NightFilter.cpp) +target_include_directories(test-sidebar-appearance PRIVATE + ${READER_DIR} ${READER_DIR}/app ${READER_DIR}/sidebar + ${READER_DIR}/eyeprotection ${READER_DIR}/browser) +target_link_libraries(test-sidebar-appearance PRIVATE Qt6::Widgets PkgConfig::DTK) + +enable_testing() +add_test(NAME sidebar-appearance COMMAND test-sidebar-appearance) +set_tests_properties(sidebar-appearance PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") diff --git a/tests/sidebar-appearance/main.cc b/tests/sidebar-appearance/main.cc new file mode 100644 index 000000000..2d9378f7e --- /dev/null +++ b/tests/sidebar-appearance/main.cc @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include + +// Exercise the production thumbnail cache without linking the full reader. +#define private public +#include "ThumbnailDelegate.h" +#undef private +#include "NightFilter.h" + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + QListView view; + ThumbnailDelegate delegate(&view); + int failures = 0; + int checks = 0; + const auto check = [&](bool result, const char *message) { + ++checks; + if (!result) { + ++failures; + qCritical() << message; + } + }; + check(delegate.nightPixmap(QPixmap()).isNull(), "Null thumbnail must stay null"); + + for (qreal dpr : {1.0, 2.0}) { + for (const QColor &color : {QColor(Qt::white), QColor(Qt::black), + QColor(90, 140, 200), QColor(180, 80, 40, 128)}) { + QPixmap source(24, 32); + source.setDevicePixelRatio(dpr); + source.fill(color); + const QPixmap actual = delegate.nightPixmap(source); + // BrowserPage also converts the filter result back to a QPixmap; + // include Qt's premultiplied-alpha rounding in the comparison. + const QImage expected = QPixmap::fromImage(NightFilter::applyPage(source.toImage(), {})).toImage(); + check(actual.toImage() == expected, "Thumbnail must use master NightFilter pixels"); + check(actual.devicePixelRatio() == dpr, "Thumbnail must preserve device pixel ratio"); + check(delegate.nightPixmap(source).cacheKey() == actual.cacheKey(), + "Unchanged source must reuse cached night thumbnail"); + check(delegate.nightPixmap(source).transformed(QTransform().rotate(90)).toImage() + == QPixmap::fromImage(expected).transformed(QTransform().rotate(90)).toImage(), + "Rotation must be applied after filtering"); + source.fill(Qt::green); + check(delegate.nightPixmap(source).toImage() == NightFilter::applyPage(source.toImage(), {}), + "Changed source must invalidate night thumbnail cache"); + } + } + qInfo() << checks << "checks," << failures << "failures"; + return failures ? 1 : 0; +} diff --git a/tests/sidebar/ut_sidebarimagelistview.cpp b/tests/sidebar/ut_sidebarimagelistview.cpp index d7069523b..45459e814 100644 --- a/tests/sidebar/ut_sidebarimagelistview.cpp +++ b/tests/sidebar/ut_sidebarimagelistview.cpp @@ -5,6 +5,7 @@ #include "SideBarImageListview.h" #include "DocSheet.h" +#include "EyeProtectionManager.h" #include "SideBarImageViewModel.h" #include "stub.h" @@ -241,7 +242,10 @@ TEST_F(TestSideBarImageListView, testkeyPressEvent) TEST_F(TestSideBarImageListView, testThemeChanged_lambda) { - // 触发构造函数中注册的主题切换 lambda(刷新缩略图) emit DGuiApplicationHelper::instance()->themeTypeChanged(DGuiApplicationHelper::LightType); + const auto previousMode = EyeProtectionManager::instance()->mode(); + EyeProtectionManager::instance()->setMode(EyeProtectionManager::Night); + EyeProtectionManager::instance()->setMode(EyeProtectionManager::Off); + EyeProtectionManager::instance()->setMode(previousMode); SUCCEED(); } diff --git a/tests/sidebar/ut_thumbnaildelegate.cpp b/tests/sidebar/ut_thumbnaildelegate.cpp index b0f3be0d2..119fe170e 100644 --- a/tests/sidebar/ut_thumbnaildelegate.cpp +++ b/tests/sidebar/ut_thumbnaildelegate.cpp @@ -5,6 +5,7 @@ #include "ThumbnailDelegate.h" #include "DocSheet.h" +#include "EyeProtectionManager.h" #include "SideBarImageListview.h" #include "SideBarImageViewModel.h" @@ -15,6 +16,16 @@ #include #include +namespace { + +// 未打开文档时渲染器没有页面尺寸,桩掉以得到稳定的缩略图卡片区域 +QSizeF pageSizeByIndex_stub(DocSheet *, int) +{ + return QSizeF(210, 297); +} + +} // namespace + class UT_ThumbnailDelegate : public ::testing::Test { public: @@ -84,3 +95,43 @@ TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_sizeHint) QSize size = m_tester->sizeHint(option, index); EXPECT_FALSE(size.isEmpty()); } + +// 缩略图外观跟随护眼模式(而非系统深浅主题),与主视图保持一致: +// 无护眼时保持文档原始白底,夜间护眼时反转为深色底 +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintFollowsEyeProtectionMode) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + m_pView->getImageModel()->insertPageIndex(0); + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + m_sheet->setThumbnail(0, whiteThumb); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 240, 300); + + QImage canvas(240, 300, QImage::Format_ARGB32_Premultiplied); + + const auto previousMode = EyeProtectionManager::instance()->mode(); + EyeProtectionManager::instance()->setMode(EyeProtectionManager::Off); + canvas.fill(Qt::red); + QPainter offPainter(&canvas); + m_tester->paint(&offPainter, option, index); + offPainter.end(); + EXPECT_GT(canvas.pixelColor(120, 150).lightness(), 239); + + EyeProtectionManager::instance()->setMode(EyeProtectionManager::Night); + canvas.fill(Qt::red); + QPainter nightPainter(&canvas); + m_tester->paint(&nightPainter, option, index); + nightPainter.end(); + EXPECT_LT(canvas.pixelColor(120, 150).lightness(), 32); + + EyeProtectionManager::instance()->setMode(previousMode); +} From e96ea46eafa9dc71823558894d7fdcac2fcedcb6 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Thu, 10 Sep 2026 22:56:16 +0800 Subject: [PATCH 04/19] feat(ofd): integrate semantic text support --- debian/control | 2 +- reader/document/OfdModel.cpp | 227 +++++++++++++++++++++++++++++++-- reader/document/OfdModel.h | 5 + tests/document/ut_ofdmodel.cpp | 53 ++++++++ 4 files changed, 274 insertions(+), 13 deletions(-) diff --git a/debian/control b/debian/control index f1eb2ab21..40c6e0ead 100644 --- a/debian/control +++ b/debian/control @@ -18,7 +18,7 @@ Build-Depends: libdtk6core-dev [!mipsel !mips64el] | libdtkcore-dev, libgxps-dev, libcairo2-dev, - librofd-ffi-dev, + librofd-ffi-dev (>= 0.3.0), libglib2.0-dev, libdjvulibre-dev, libtiff-dev, diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 13b55bc7a..275664bcd 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -14,10 +14,40 @@ #include #include +#include + namespace deepin_reader { static constexpr qreal kMillimetresPerInch = 25.4; +namespace { + +QString takeRofdString(rofd_string_t *text) +{ + if (nullptr == text) { + return QString(); + } + + const char *data = rofd_string_get_data(text); + const size_t length = rofd_string_get_length(text); + QString result; + if (nullptr != data && length <= static_cast(std::numeric_limits::max())) { + result = QString::fromUtf8(data, static_cast(length)); + } + rofd_string_free(text); + return result; +} + +void logSemanticError(const char *operation, int pageIndex, rofd_status_t status, rofd_error_t *error) +{ + qCWarning(appLog) << operation << "failed for OFD page:" << pageIndex + << "status:" << status + << "message:" << (error ? rofd_error_get_message(error) : "unknown"); + rofd_error_free(error); +} + +} // namespace + OfdDocument *OfdDocument::loadDocument(const QString &filePath, Document::Error &error) { qCInfo(appLog) << "Loading OFD document from:" << filePath; @@ -246,10 +276,9 @@ OfdPage::OfdPage(const OfdDocument *document, rofd_page_t *pageHandle, int pageI , m_page(pageHandle) , m_pageIndex(pageIndex) { - rofd_rect_t pageRect = {0.0, 0.0, 0.0, 0.0}; - if (ROFD_STATUS_OK == rofd_page_get_size_mm(m_page, &pageRect, nullptr)) { - m_sizePixel = QSizeF(pageRect.width_mm * m_document->xRes() / kMillimetresPerInch, - pageRect.height_mm * m_document->yRes() / kMillimetresPerInch); + if (ROFD_STATUS_OK == rofd_page_get_size_mm(m_page, &m_pageRectMm, nullptr)) { + m_sizePixel = QSizeF(m_pageRectMm.width_mm * m_document->xRes() / kMillimetresPerInch, + m_pageRectMm.height_mm * m_document->yRes() / kMillimetresPerInch); } else { qCWarning(appLog) << "Failed to query OFD page size, page:" << m_pageIndex; } @@ -275,18 +304,192 @@ QImage OfdPage::render(int width, int height, const QRect &slice) const QString OfdPage::text(const QRectF &rect) const { - // rofd C ABI 暂不提供文本提取接口 - Q_UNUSED(rect) - return QString(); + if (nullptr == m_page) { + return QString(); + } + + rofd_string_t *result = nullptr; + rofd_error_t *error = nullptr; + rofd_status_t status = ROFD_STATUS_OK; + if (rect.isNull()) { + status = rofd_page_get_text(m_page, &result, &error); + } else { + const rofd_rect_t area = toMillimetres(rect.normalized()); + status = rofd_page_get_text_for_area(m_page, &area, &result, &error); + } + + if (ROFD_STATUS_OK != status || nullptr == result) { + logSemanticError("Text extraction", m_pageIndex, status, error); + rofd_string_free(result); + return QString(); + } + + rofd_error_free(error); + return takeRofdString(result).simplified(); } QVector OfdPage::search(const QString &text, bool matchCase, bool wholeWords) const { - // rofd C ABI 暂不提供文本搜索接口 - Q_UNUSED(text) - Q_UNUSED(matchCase) - Q_UNUSED(wholeWords) - return QVector(); + QVector sections; + if (nullptr == m_page || text.isEmpty()) { + return sections; + } + + rofd_find_options_t options; + rofd_find_options_init(&options, sizeof(options)); + if (matchCase) { + options.flags |= ROFD_FIND_CASE_SENSITIVE; + } + if (wholeWords) { + options.flags |= ROFD_FIND_WHOLE_WORDS; + } + + const QByteArray query = text.toUtf8(); + rofd_text_search_t *searchResult = nullptr; + rofd_error_t *error = nullptr; + rofd_status_t status = rofd_page_find_text_with_options(m_page, + query.constData(), + &options, + &searchResult, + &error); + if (ROFD_STATUS_OK != status || nullptr == searchResult) { + logSemanticError("Text search", m_pageIndex, status, error); + rofd_text_search_free(searchResult); + return sections; + } + rofd_error_free(error); + + size_t matchCount = 0; + error = nullptr; + status = rofd_text_search_get_count(searchResult, &matchCount, &error); + if (ROFD_STATUS_OK != status) { + logSemanticError("Search result enumeration", m_pageIndex, status, error); + rofd_text_search_free(searchResult); + return sections; + } + rofd_error_free(error); + + sections.reserve(static_cast(qMin(matchCount, + static_cast(std::numeric_limits::max())))); + for (size_t index = 0; index < matchCount; ++index) { + rofd_text_match_t match; + match.struct_size = sizeof(match); + error = nullptr; + status = rofd_text_search_get_match(searchResult, index, &match, &error); + if (ROFD_STATUS_OK != status) { + logSemanticError("Search match extraction", m_pageIndex, status, error); + continue; + } + rofd_error_free(error); + + const QRectF matchRect = toPixels(match.rect_mm); + if (matchRect.isValid()) { + sections.append(PageSection{PageLine{QString(), matchRect}}); + } + } + + rofd_text_search_free(searchResult); + return sections; +} + +QList OfdPage::words() +{ + QList words; + if (nullptr == m_page) { + return words; + } + + rofd_string_t *pageText = nullptr; + rofd_error_t *error = nullptr; + rofd_status_t status = rofd_page_get_text(m_page, &pageText, &error); + if (ROFD_STATUS_OK != status || nullptr == pageText) { + logSemanticError("Page text extraction", m_pageIndex, status, error); + rofd_string_free(pageText); + return words; + } + rofd_error_free(error); + + rofd_text_layout_t *layout = nullptr; + error = nullptr; + status = rofd_page_get_text_layout(m_page, &layout, &error); + if (ROFD_STATUS_OK != status || nullptr == layout) { + logSemanticError("Text layout extraction", m_pageIndex, status, error); + rofd_string_free(pageText); + rofd_text_layout_free(layout); + return words; + } + rofd_error_free(error); + + size_t characterCount = 0; + error = nullptr; + status = rofd_text_layout_get_count(layout, &characterCount, &error); + if (ROFD_STATUS_OK != status) { + logSemanticError("Text layout enumeration", m_pageIndex, status, error); + rofd_string_free(pageText); + rofd_text_layout_free(layout); + return words; + } + rofd_error_free(error); + + const char *utf8 = rofd_string_get_data(pageText); + const size_t utf8Length = rofd_string_get_length(pageText); + for (size_t index = 0; index < characterCount; ++index) { + rofd_text_char_t character; + character.struct_size = sizeof(character); + error = nullptr; + status = rofd_text_layout_get_char(layout, index, &character, &error); + if (ROFD_STATUS_OK != status) { + logSemanticError("Text character extraction", m_pageIndex, status, error); + continue; + } + rofd_error_free(error); + + if (character.flags & ROFD_TEXT_CHAR_SYNTHESIZED_SEPARATOR) { + continue; + } + if (nullptr == utf8 + || 0 == character.utf8_length + || character.utf8_offset > utf8Length + || character.utf8_length > utf8Length - character.utf8_offset + || character.utf8_length > static_cast(std::numeric_limits::max())) { + qCWarning(appLog) << "Invalid OFD text character span, page:" << m_pageIndex + << "index:" << index; + continue; + } + + const QRectF boundingBox = toPixels(character.rect_mm); + if (!boundingBox.isValid()) { + continue; + } + + words.append(Word(QString::fromUtf8(utf8 + character.utf8_offset, + static_cast(character.utf8_length)), + boundingBox)); + } + + rofd_string_free(pageText); + rofd_text_layout_free(layout); + return words; +} + +rofd_rect_t OfdPage::toMillimetres(const QRectF &rect) const +{ + const qreal xScale = kMillimetresPerInch / m_document->xRes(); + const qreal yScale = kMillimetresPerInch / m_document->yRes(); + return rofd_rect_t{m_pageRectMm.x_mm + rect.x() * xScale, + m_pageRectMm.y_mm + rect.y() * yScale, + rect.width() * xScale, + rect.height() * yScale}; +} + +QRectF OfdPage::toPixels(const rofd_rect_t &rect) const +{ + const qreal xScale = m_document->xRes() / kMillimetresPerInch; + const qreal yScale = m_document->yRes() / kMillimetresPerInch; + return QRectF((rect.x_mm - m_pageRectMm.x_mm) * xScale, + (rect.y_mm - m_pageRectMm.y_mm) * yScale, + rect.width_mm * xScale, + rect.height_mm * yScale); } } // namespace deepin_reader diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h index 6c54e0de5..dff0c73d9 100644 --- a/reader/document/OfdModel.h +++ b/reader/document/OfdModel.h @@ -63,11 +63,16 @@ class OfdPage : public Page QImage render(int width, int height, const QRect &slice = QRect()) const override; QString text(const QRectF &rect) const override; QVector search(const QString &text, bool matchCase, bool wholeWords) const override; + QList words() override; private: + rofd_rect_t toMillimetres(const QRectF &rect) const; + QRectF toPixels(const rofd_rect_t &rect) const; + const OfdDocument *m_document; rofd_page_t *m_page = nullptr; int m_pageIndex = -1; + rofd_rect_t m_pageRectMm = {0.0, 0.0, 0.0, 0.0}; QSizeF m_sizePixel; }; diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index c89fec1ea..b6a1a28ee 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -14,6 +14,7 @@ #include #include +#include #include using namespace deepin_reader; @@ -113,6 +114,58 @@ TEST_F(TestOfdModel, renderInvalidSize) EXPECT_TRUE(page->render(-10, 100).isNull()); } +TEST_F(TestOfdModel, semanticFullText) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + const QString text = page->text(QRectF()); + EXPECT_FALSE(text.isEmpty()); + EXPECT_TRUE(text.contains(QStringLiteral("电子发票"))); +} + +TEST_F(TestOfdModel, semanticSearch) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + const QVector matches = page->search(QStringLiteral("电子发票"), true, false); + ASSERT_FALSE(matches.isEmpty()); + ASSERT_FALSE(matches.first().isEmpty()); + EXPECT_TRUE(matches.first().first().rect.isValid()); + + EXPECT_TRUE(page->search(QStringLiteral("不存在的文本"), false, false).isEmpty()); +} + +TEST_F(TestOfdModel, semanticWholeWordSearch) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + EXPECT_FALSE(page->search(QStringLiteral("电子"), false, false).isEmpty()); + EXPECT_TRUE(page->search(QStringLiteral("电子"), false, true).isEmpty()); +} + +TEST_F(TestOfdModel, semanticWordsAndAreaText) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + + const QList words = page->words(); + ASSERT_FALSE(words.isEmpty()); + + const auto character = std::find_if(words.cbegin(), words.cend(), [](const Word &word) { + return word.text == QStringLiteral("电"); + }); + ASSERT_NE(character, words.cend()); + EXPECT_TRUE(character->boundingBox.isValid()); + EXPECT_GT(character->boundingBox.width(), 0.0); + EXPECT_GT(character->boundingBox.height(), 0.0); + + const QRectF selection = character->boundingBox.adjusted(-0.1, -0.1, 0.1, 0.1); + EXPECT_TRUE(page->text(selection).contains(character->text)); +} + TEST_F(TestOfdModel, loadMissingFile) { Document::Error error = Document::NoError; From 919671c33a171a0a99ca4be8b12990d907d94586 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Thu, 10 Sep 2026 22:56:40 +0800 Subject: [PATCH 05/19] fix(ofd): expose search in reader UI --- reader/Application.cpp | 4 +++- reader/app/Global.cpp | 18 ++++++++++++++++++ reader/app/Global.h | 1 + reader/browser/BrowserMenu.cpp | 14 +++----------- reader/browser/SheetBrowser.cpp | 9 ++------- reader/uiframe/CentralNavPage.cpp | 14 ++++++++------ reader/uiframe/TitleMenu.cpp | 8 ++------ tests/app/ut_global.cpp | 15 +++++++++++++++ 8 files changed, 52 insertions(+), 31 deletions(-) diff --git a/reader/Application.cpp b/reader/Application.cpp index 0d819fb12..faf628a23 100644 --- a/reader/Application.cpp +++ b/reader/Application.cpp @@ -35,6 +35,9 @@ Application::Application(int &argc, char **argv) QStringLiteral("DOCX") #ifdef XPS_SUPPORT_ENABLED , QStringLiteral("XPS") +#endif +#ifdef OFD_SUPPORT_ENABLED + , QStringLiteral("OFD") #endif }; setApplicationDescription(tr("Document Viewer is a tool for reading document files, supporting %1.") @@ -130,4 +133,3 @@ bool Application::notify(QObject *object, QEvent *event) return DApplication::notify(object, event); } - diff --git a/reader/app/Global.cpp b/reader/app/Global.cpp index b7463a1eb..01dcde1cd 100644 --- a/reader/app/Global.cpp +++ b/reader/app/Global.cpp @@ -64,6 +64,24 @@ FileType fileType(const QString &filePath) return fileType; } +bool supportsSearch(FileType fileType) +{ + if (fileType == PDF || fileType == DOCX) + return true; + +#ifdef XPS_SUPPORT_ENABLED + if (fileType == XPS) + return true; +#endif + +#ifdef OFD_SUPPORT_ENABLED + if (fileType == OFD) + return true; +#endif + + return false; +} + bool isNetworkPath(const QString &filePath) { // gvfs(smb/nfs 等用户态挂载)路径特征明确,先按字符串判断。 diff --git a/reader/app/Global.h b/reader/app/Global.h index 603d506c0..0ecf7ba3f 100644 --- a/reader/app/Global.h +++ b/reader/app/Global.h @@ -49,6 +49,7 @@ enum FileType { #endif }; FileType fileType(const QString &filePath); +bool supportsSearch(FileType fileType); bool isNetworkPath(const QString &filePath); /** diff --git a/reader/browser/BrowserMenu.cpp b/reader/browser/BrowserMenu.cpp index a81db40f8..549ce6895 100644 --- a/reader/browser/BrowserMenu.cpp +++ b/reader/browser/BrowserMenu.cpp @@ -92,11 +92,7 @@ void BrowserMenu::initActions(DocSheet *sheet, int index, SheetMenuType_e type, } } else if (type == DOC_MENU_KEY) { qCDebug(appLog) << "BrowserMenu::initActions() - Processing DOC_MENU_KEY"; - if (sheet->fileType() == Dr::FileType::PDF || sheet->fileType() == Dr::FileType::DOCX -#ifdef XPS_SUPPORT_ENABLED - || sheet->fileType() == Dr::FileType::XPS -#endif - ) { + if (Dr::supportsSearch(sheet->fileType())) { createAction(tr("Search"), "Search"); this->addSeparator(); } @@ -148,12 +144,8 @@ void BrowserMenu::initActions(DocSheet *sheet, int index, SheetMenuType_e type, createAction(tr("Document info"), "DocumentInfo"); } else { qCDebug(appLog) << "BrowserMenu::initActions() - Processing default menu type"; - if (sheet->fileType() == Dr::FileType::PDF || sheet->fileType() == Dr::FileType::DOCX -#ifdef XPS_SUPPORT_ENABLED - || sheet->fileType() == Dr::FileType::XPS -#endif - ) { - qCDebug(appLog) << "BrowserMenu::initActions() - Adding search action for PDF/DOCX/XPS"; + if (Dr::supportsSearch(sheet->fileType())) { + qCDebug(appLog) << "BrowserMenu::initActions() - Adding search action for searchable document"; createAction(tr("Search"), "Search"); this->addSeparator(); } diff --git a/reader/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index d61023445..bb764ba03 100644 --- a/reader/browser/SheetBrowser.cpp +++ b/reader/browser/SheetBrowser.cpp @@ -1915,13 +1915,8 @@ void SheetBrowser::showEvent(QShowEvent *event) void SheetBrowser::handlePrepareSearch() { qCDebug(appLog) << "Preparing search for file type:" << m_sheet->fileType(); - - //目前只有PDF、DOCX和XPS开放搜索功能 - if (m_sheet->fileType() != Dr::FileType::PDF && m_sheet->fileType() != Dr::FileType::DOCX -#ifdef XPS_SUPPORT_ENABLED - && m_sheet->fileType() != Dr::FileType::XPS -#endif - ) { + + if (!Dr::supportsSearch(m_sheet->fileType())) { qCDebug(appLog) << "Search not supported for current file type"; return; } diff --git a/reader/uiframe/CentralNavPage.cpp b/reader/uiframe/CentralNavPage.cpp index f3458ad09..4437d15fd 100644 --- a/reader/uiframe/CentralNavPage.cpp +++ b/reader/uiframe/CentralNavPage.cpp @@ -25,13 +25,16 @@ CentralNavPage::CentralNavPage(DWidget *parent) tipsLabel->setForegroundRole(DPalette::TextTips); DFontSizeManager::instance()->bind(tipsLabel, DFontSizeManager::T8); - constexpr auto kFormatsWithXps = "PDF,DJVU,DOCX,XPS"; - constexpr auto kFormatsWithoutXps = "PDF,DJVU,DOCX"; + QStringList formats = {QStringLiteral("PDF"), + QStringLiteral("DJVU"), + QStringLiteral("DOCX")}; #ifdef XPS_SUPPORT_ENABLED - auto supportedFormats = QString::fromLatin1(kFormatsWithXps); -#else - auto supportedFormats = QString::fromLatin1(kFormatsWithoutXps); + formats.append(QStringLiteral("XPS")); #endif +#ifdef OFD_SUPPORT_ENABLED + formats.append(QStringLiteral("OFD")); +#endif + const QString supportedFormats = formats.join(QLatin1Char(',')); auto formatLabel = new DLabel(tr("Format supported: %1").arg(supportedFormats), this); formatLabel->setAccessibleName(QString("Label_format supported: %1").arg(supportedFormats)); @@ -98,4 +101,3 @@ void CentralNavPage::onThemeChanged() l->setForegroundRole(DPalette::TextTips); } } - diff --git a/reader/uiframe/TitleMenu.cpp b/reader/uiframe/TitleMenu.cpp index 6b0ce5836..a3da277ba 100644 --- a/reader/uiframe/TitleMenu.cpp +++ b/reader/uiframe/TitleMenu.cpp @@ -71,12 +71,8 @@ void TitleMenu::onCurSheetChanged(DocSheet *sheet) QAction *searchAction = this->findChild("Search"); if (searchAction) { - if (sheet->fileType() == Dr::PDF || sheet->fileType() == Dr::DOCX -#ifdef XPS_SUPPORT_ENABLED - || sheet->fileType() == Dr::XPS -#endif - ) { - qCDebug(appLog) << "Enabling search for PDF/DOCX"; + if (Dr::supportsSearch(sheet->fileType())) { + qCDebug(appLog) << "Enabling search for searchable document"; searchAction->setVisible(true); } else { qCDebug(appLog) << "Disabling search for other formats"; diff --git a/tests/app/ut_global.cpp b/tests/app/ut_global.cpp index 5b3c3998e..02048fbe7 100644 --- a/tests/app/ut_global.cpp +++ b/tests/app/ut_global.cpp @@ -85,3 +85,18 @@ TEST_F(TestGlobal, UT_Global_fileType_006) EXPECT_TRUE(fileType("1.docx") == DOCX); } + +TEST_F(TestGlobal, UT_Global_supportsSearch) +{ + EXPECT_TRUE(supportsSearch(PDF)); + EXPECT_TRUE(supportsSearch(DOCX)); +#ifdef XPS_SUPPORT_ENABLED + EXPECT_TRUE(supportsSearch(XPS)); +#endif +#ifdef OFD_SUPPORT_ENABLED + EXPECT_TRUE(supportsSearch(OFD)); +#endif + + EXPECT_FALSE(supportsSearch(Unknown)); + EXPECT_FALSE(supportsSearch(DJVU)); +} From 041595215667e641b1b1de40bceddb63d4dd27a5 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 07:50:55 +0800 Subject: [PATCH 06/19] feat(ofd): render page regions with bounded memory Use rofd pixel canvas and region rendering APIs instead of rendering and cropping a full page. Validate tile bounds and raster budgets before allocation, probe the required API at configure time, and add a lightweight OFD adapter test target. --- CMakeLists.txt | 23 ++++++++ reader/document/OfdModel.cpp | 57 ++++++++++++++----- tests/document/ut_ofdmodel.cpp | 100 +++++++++++++++++++++++++++++++++ tests/ofd-model/CMakeLists.txt | 43 ++++++++++++++ tests/ofd-model/README.md | 24 ++++++++ tests/ofd-model/main.cc | 12 ++++ 6 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 tests/ofd-model/CMakeLists.txt create mode 100644 tests/ofd-model/README.md create mode 100644 tests/ofd-model/main.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 1604166d7..d86e8e8bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,29 @@ if (OFD_SUPPORT) pkg_check_modules(OFD_CAIRO QUIET cairo) if (ROFD_INCLUDE_DIR AND ROFD_FFI_LIBRARY AND OFD_CAIRO_FOUND) + # These APIs were added after 0.3.0 without a version bump. Probe both + # declarations and linked symbols instead of trusting the package version. + include(CheckCSourceCompiles) + include(CMakePushCheckState) + cmake_push_check_state(RESET) + set(CMAKE_REQUIRED_INCLUDES ${ROFD_INCLUDE_DIR} ${OFD_CAIRO_INCLUDE_DIRS}) + set(CMAKE_REQUIRED_LIBRARIES ${ROFD_FFI_LIBRARY} ${OFD_CAIRO_LIBRARIES}) + unset(ROFD_READER_APIS_AVAILABLE CACHE) + check_c_source_compiles([=[ + #include + int main(void) { + rofd_pixel_rect_t viewport; + int32_t w = 0, h = 0; + rofd_pixel_rect_init(&viewport, sizeof(viewport)); + rofd_renderer_get_pixel_canvas_size(0, 0, 0, &w, &h, 0); + rofd_renderer_render_page_region_cairo(0, 0, 0, 0, &viewport, 0, 0); + return 0; + } + ]=] ROFD_READER_APIS_AVAILABLE) + cmake_pop_check_state() + if (NOT ROFD_READER_APIS_AVAILABLE) + message(FATAL_ERROR "rofd headers/library lack the region rendering APIs. Use a matching rofd build containing commit 56da925 (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") + endif() message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") add_compile_definitions(OFD_SUPPORT_ENABLED) set(OFD_SUPPORT_RESOLVED ON) diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 275664bcd..22c16b254 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -175,7 +175,8 @@ Properties OfdDocument::properties() const QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const { - if (nullptr == pageHandle || width <= 0 || height <= 0) { + if (nullptr == pageHandle || width <= 0 || height <= 0 + || (!slice.isNull() && !slice.isValid())) { qCWarning(appLog) << "Invalid OFD render request, handle:" << pageHandle << "size:" << width << height; return QImage(); } @@ -197,7 +198,11 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c int32_t pixelWidth = 0; int32_t pixelHeight = 0; rofd_error_t *rofdError = nullptr; - if (ROFD_STATUS_OK != rofd_renderer_get_pixel_size(m_renderer, pageHandle, &options, &pixelWidth, &pixelHeight, &rofdError) + // A tile only needs canvas geometry; the full-page query enforces a full + // raster budget and would reject high zoom even for a tiny visible region. + const auto sizeQuery = slice.isValid() ? rofd_renderer_get_pixel_canvas_size + : rofd_renderer_get_pixel_size; + if (ROFD_STATUS_OK != sizeQuery(m_renderer, pageHandle, &options, &pixelWidth, &pixelHeight, &rofdError) || pixelWidth <= 0 || pixelHeight <= 0) { qCWarning(appLog) << "Failed to compute OFD pixel size:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); @@ -205,17 +210,33 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c return QImage(); } - QImage image(pixelWidth, pixelHeight, QImage::Format_ARGB32_Premultiplied); + const QRect canvas(0, 0, pixelWidth, pixelHeight); + const QRect target = slice.isValid() ? slice : canvas; + // Do not silently clamp: the caller places the result at the requested + // origin and expects exactly the requested dimensions. + if (!canvas.contains(target)) { + qCWarning(appLog) << "OFD render region outside canvas:" << target << canvas; + return QImage(); + } + const int argbStride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, target.width()); + const int maskStride = cairo_format_stride_for_width(CAIRO_FORMAT_A8, target.width()); + if (target.width() > 32767 || target.height() > 32767 || argbStride < 0 || maskStride < 0 + || (quint64(argbStride) * 2 + quint64(maskStride)) * quint64(target.height()) > options.max_raster_bytes) { + qCWarning(appLog) << "OFD render target exceeds raster limits:" << target.size(); + return QImage(); + } + + QImage image(target.size(), QImage::Format_ARGB32_Premultiplied); if (image.isNull()) { - qCWarning(appLog) << "Failed to allocate OFD render image:" << pixelWidth << pixelHeight; + qCWarning(appLog) << "Failed to allocate OFD render image:" << target.size(); return QImage(); } image.fill(Qt::white); cairo_surface_t *surface = cairo_image_surface_create_for_data(image.bits(), CAIRO_FORMAT_ARGB32, - pixelWidth, - pixelHeight, + image.width(), + image.height(), image.bytesPerLine()); if (CAIRO_STATUS_SUCCESS != cairo_surface_status(surface)) { qCWarning(appLog) << "Failed to create Cairo surface for OFD render"; @@ -232,7 +253,19 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c } rofd_render_report_t *report = nullptr; - rofd_status_t status = rofd_renderer_render_page_cairo(m_renderer, pageHandle, cr, &options, &report, &rofdError); + rofd_status_t status; + if (slice.isValid()) { + rofd_pixel_rect_t viewport; + rofd_pixel_rect_init(&viewport, sizeof(viewport)); + viewport.x = target.x(); + viewport.y = target.y(); + viewport.width = target.width(); + viewport.height = target.height(); + status = rofd_renderer_render_page_region_cairo(m_renderer, pageHandle, cr, &options, + &viewport, &report, &rofdError); + } else { + status = rofd_renderer_render_page_cairo(m_renderer, pageHandle, cr, &options, &report, &rofdError); + } if (nullptr != report) { size_t diagnosticCount = 0; @@ -250,6 +283,7 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c rofd_render_report_free(report); } + cairo_surface_flush(surface); cairo_destroy(cr); cairo_surface_destroy(surface); @@ -260,14 +294,7 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c return QImage(); } - // rofd 的 clip 只限制绘制范围、不改变坐标映射,切片通过整页渲染后裁剪实现 - if (slice.isValid()) { - const QRect bounded = slice.intersected(image.rect()); - if (bounded.isValid() && bounded.size() != image.size()) { - return image.copy(bounded); - } - } - + rofd_error_free(rofdError); return image; } diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index b6a1a28ee..83093bd58 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -36,6 +37,35 @@ bool hasOfdFile() return QFile(ofdFilePath()).exists(); } +QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, + const QByteArray &pageArea = QByteArray(), + const QByteArray &documentExtras = QByteArray()) +{ + const QMap entries = { + {"OFD.xml", "" + info + + "Document.xml"}, + {"Document.xml", "7 11 210 297" + "" + + documentExtras + ""}, + {"Page.xml", "" + pageArea + "" + "M 0 0 L 40 0 L 40 25 L 0 25 C" + ""} + }; + for (auto it = entries.cbegin(); it != entries.cend(); ++it) { + QFile file(dir.filePath(it.key())); + if (!file.open(QIODevice::WriteOnly) || file.write(it.value()) != it.value().size()) + return {}; + } + QProcess archive; + archive.setWorkingDirectory(dir.path()); + archive.start(QStringLiteral("cmake"), {"-E", "tar", "cf", "fixture.ofd", "--format=zip", + "OFD.xml", "Document.xml", "Page.xml"}); + if (!archive.waitForFinished() || archive.exitCode() != 0) + return {}; + return dir.filePath("fixture.ofd"); +} + } // namespace class TestOfdModel : public ::testing::Test @@ -114,6 +144,76 @@ TEST_F(TestOfdModel, renderInvalidSize) EXPECT_TRUE(page->render(-10, 100).isNull()); } +TEST_F(TestOfdModel, renderSmallRegionOnHugeCanvas) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + // A full 42300 x 28000 image exceeds 4 GiB; only a 64 x 48 tile is needed. + const QImage tile = page->render(42300, 28000, QRect(97, 42, 64, 48)); + ASSERT_FALSE(tile.isNull()); + EXPECT_EQ(tile.size(), QSize(64, 48)); +} + +TEST_F(TestOfdModel, rejectInvalidRegions) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + EXPECT_TRUE(page->render(423, 280, QRect(900, 0, 20, 20)).isNull()); + EXPECT_TRUE(page->render(423, 280, QRect(-1, 0, 20, 20)).isNull()); + EXPECT_TRUE(page->render(423, 280, QRect(420, 270, 20, 20)).isNull()); + EXPECT_TRUE(page->render(423, 280, QRect(1, 1, 0, 20)).isNull()); + EXPECT_TRUE(page->render(42300, 28000, QRect(0, 0, 16000, 16000)).isNull()); +} + +TEST(OfdApi, regionMatchesFullPageWithNonzeroPhysicalOrigin) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, "region"); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const QImage full = page->render(420, 594); + ASSERT_FALSE(full.isNull()); + const QRect region(17, 19, 160, 150); + const QImage tile = page->render(420, 594, region); + ASSERT_FALSE(tile.isNull()); + EXPECT_EQ(tile, full.copy(region)); + EXPECT_EQ(tile.pixelColor(40, 40), QColor(Qt::red)); + + const QImage largeCanvasTile = page->render(42000, 59400, QRect(4000, 5000, 64, 48)); + ASSERT_EQ(largeCanvasTile.size(), QSize(64, 48)); + EXPECT_EQ(largeCanvasTile.pixelColor(32, 24), QColor(Qt::red)); +} + +TEST_F(TestOfdModel, realInvoiceRegionMatchesFullPage) +{ + std::unique_ptr page(m_doc->page(0)); + ASSERT_NE(page, nullptr); + const QImage full = page->render(423, 280); + const QRect region(97, 42, 129, 71); + const QImage tile = page->render(423, 280, region); + ASSERT_FALSE(full.isNull()); + ASSERT_EQ(tile.size(), region.size()); + int differingChannels = 0; + int largestDelta = 0; + for (int y = 0; y < tile.height(); ++y) { + const uchar *expected = full.constScanLine(region.y() + y) + region.x() * 4; + const uchar *actual = tile.constScanLine(y); + for (int x = 0; x < tile.width() * 4; ++x) { + const int delta = qAbs(int(expected[x]) - int(actual[x])); + differingChannels += delta != 0; + largestDelta = qMax(largestDelta, delta); + } + } + // Cairo glyph/curve antialiasing may differ slightly with target extents. + EXPECT_LE(largestDelta, 3); + EXPECT_LE(differingChannels, 100); +} + TEST_F(TestOfdModel, semanticFullText) { std::unique_ptr page(m_doc->page(0)); diff --git a/tests/ofd-model/CMakeLists.txt b/tests/ofd-model/CMakeLists.txt new file mode 100644 index 000000000..fa68f75f3 --- /dev/null +++ b/tests/ofd-model/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.16) +project(ofd-model-check LANGUAGES CXX) + +# Configure this directory directly to check OFD without linking all reader tests. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) +find_package(PkgConfig REQUIRED) +pkg_check_modules(CAIRO REQUIRED IMPORTED_TARGET cairo) +if (QT_VERSION_MAJOR EQUAL 6) + pkg_check_modules(DTKCORE REQUIRED IMPORTED_TARGET dtk6core) +else() + pkg_check_modules(DTKCORE REQUIRED IMPORTED_TARGET dtkcore) +endif() +find_package(GTest REQUIRED) +find_path(ROFD_INCLUDE_DIR rofd.h) +find_library(ROFD_FFI_LIBRARY NAMES rofd_ffi) +if (NOT ROFD_INCLUDE_DIR OR NOT ROFD_FFI_LIBRARY) + message(FATAL_ERROR "Set ROFD_INCLUDE_DIR and ROFD_FFI_LIBRARY to the matching rofd build") +endif() + +get_filename_component(READER_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +add_executable(ofd-model-check main.cc + ../document/ut_ofdmodel.cpp + ${READER_ROOT}/reader/document/OfdModel.cpp + ${READER_ROOT}/reader/document/OfdModel.h + ${READER_ROOT}/reader/document/Model.h) +target_compile_definitions(ofd-model-check PRIVATE + OFD_SUPPORT_ENABLED UTSOURCEDIR="${READER_ROOT}/tests") +target_include_directories(ofd-model-check PRIVATE + ${ROFD_INCLUDE_DIR} + ${READER_ROOT}/reader ${READER_ROOT}/reader/document ${READER_ROOT}/reader/app + ${READER_ROOT}/tests ${READER_ROOT}/3rdparty/deepin-pdfium/include) +target_link_libraries(ofd-model-check PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets PkgConfig::CAIRO PkgConfig::DTKCORE + GTest::gtest ${ROFD_FFI_LIBRARY}) + +enable_testing() +add_test(NAME ofd-model COMMAND ofd-model-check) +set_tests_properties(ofd-model PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_LOGGING_RULES=org.deepin.reader=false") diff --git a/tests/ofd-model/README.md b/tests/ofd-model/README.md new file mode 100644 index 000000000..1dff6b520 --- /dev/null +++ b/tests/ofd-model/README.md @@ -0,0 +1,24 @@ +# Focused OFD adapter checks + +This independent CMake project runs `tests/document/ut_ofdmodel.cpp` against the +real reader adapter and a shared rofd library. It does not link PDFium or the +monolithic `test-deepin-reader` executable. The `.cc` entry point is intentionally +outside the main test project's `.cpp`/`.h` source glob. + +```sh +cmake -S tests/ofd-model -B build/ofd-model \ + -DROFD_INCLUDE_DIR=/path/to/rofd/crates/rofd-ffi/include \ + -DROFD_FFI_LIBRARY=/path/to/lib/librofd_ffi.so +cmake --build build/ofd-model -j1 +ctest --test-dir build/ofd-model --output-on-failure +``` + +The library must provide the region APIs added in rofd +commit `56da925`; the initial 0.3.0 release does not include them. +Its runtime SONAME (`librofd_ffi.so.0`) must resolve to the same library. +Qt Widgets, DTK Core, Cairo, Google Test and CMake are required. Fixtures are +created in temporary directories using `cmake -E tar --format=zip`. + +Coverage includes full and tiled rendering, nonzero physical page origins, +small tiles on canvases larger than 4 GiB, pre-allocation raster limits, +real-invoice pixel comparisons, and search/selection. diff --git a/tests/ofd-model/main.cc b/tests/ofd-model/main.cc new file mode 100644 index 000000000..ad3a4da43 --- /dev/null +++ b/tests/ofd-model/main.cc @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 07ec6a5c587240605fbd84c12b7f0cd189427c37 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 07:51:36 +0800 Subject: [PATCH 07/19] feat(ofd): expose document metadata and identifiers Map rofd metadata snapshots to reader properties, preserve raw dates, and use valid document dates in file attributes. Add metadata and identifier coverage and extend the required API probe. --- CMakeLists.txt | 6 ++- reader/document/OfdModel.cpp | 71 ++++++++++++++++++++++++++++++++-- reader/document/OfdModel.h | 3 ++ reader/uiframe/DocSheet.cpp | 10 +++++ tests/document/ut_ofdmodel.cpp | 44 +++++++++++++++++++++ tests/ofd-model/README.md | 7 ++-- 6 files changed, 133 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d86e8e8bf..9336ced93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,16 +106,20 @@ if (OFD_SUPPORT) #include int main(void) { rofd_pixel_rect_t viewport; + rofd_metadata_t *metadata = 0; int32_t w = 0, h = 0; rofd_pixel_rect_init(&viewport, sizeof(viewport)); rofd_renderer_get_pixel_canvas_size(0, 0, 0, &w, &h, 0); rofd_renderer_render_page_region_cairo(0, 0, 0, 0, &viewport, 0, 0); + rofd_document_get_metadata(0, &metadata, 0); + rofd_metadata_get_document_id(metadata); + rofd_metadata_free(metadata); return 0; } ]=] ROFD_READER_APIS_AVAILABLE) cmake_pop_check_state() if (NOT ROFD_READER_APIS_AVAILABLE) - message(FATAL_ERROR "rofd headers/library lack the region rendering APIs. Use a matching rofd build containing commit 56da925 (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") + message(FATAL_ERROR "rofd headers/library lack the region or metadata APIs. Use a matching rofd build containing commits 56da925 and 6db9bac (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") endif() message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") add_compile_definitions(OFD_SUPPORT_ENABLED) diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 22c16b254..21fa1e29e 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace deepin_reader { @@ -102,6 +103,7 @@ OfdDocument::OfdDocument(const QString &filePath, rofd_document_t *document, rof m_yRes = srn->logicalDotsPerInchY(); } + loadMetadata(); qCInfo(appLog) << "OFD document loaded, pages:" << m_pageCount << "dpi:" << m_xRes << m_yRes; } @@ -167,10 +169,71 @@ bool OfdDocument::saveAs(const QString &filePath) const Properties OfdDocument::properties() const { - Properties props; - props["Format"] = QStringLiteral("OFD"); - props["FilePath"] = m_filePath; - return props; + return m_properties; +} + +QString OfdDocument::fileIdentifier() const +{ + return m_properties.value("DocumentId").toString(); +} + +void OfdDocument::loadMetadata() +{ + m_properties["Format"] = QStringLiteral("OFD"); + m_properties["FilePath"] = m_filePath; + m_properties["PageCount"] = m_pageCount; + + rofd_metadata_t *raw = nullptr; + rofd_error_t *error = nullptr; + const rofd_status_t status = rofd_document_get_metadata(m_document, &raw, &error); + const std::unique_ptr metadata(raw, rofd_metadata_free); + if (status != ROFD_STATUS_OK || !metadata) { + qCWarning(appLog) << "Failed to read OFD metadata:" << status + << (error ? rofd_error_get_message(error) : "unknown"); + rofd_error_free(error); + return; + } + rofd_error_free(error); + + const auto put = [this](const char *key, const char *value) { + if (value) + m_properties[QLatin1String(key)] = QString::fromUtf8(value); + }; + put("DocumentId", rofd_metadata_get_document_id(raw)); + put("Title", rofd_metadata_get_title(raw)); + put("Author", rofd_metadata_get_author(raw)); + put("Subject", rofd_metadata_get_subject(raw)); + put("Description", rofd_metadata_get_abstract(raw)); + put("Creator", rofd_metadata_get_creator(raw)); + put("CreatorVersion", rofd_metadata_get_creator_version(raw)); + // OFD identifies its producing application with Creator/CreatorVersion. + const QString creator = m_properties.value("Creator").toString(); + if (!creator.isEmpty()) { + const QString version = m_properties.value("CreatorVersion").toString(); + m_properties["Producer"] = version.isEmpty() ? creator : creator + QLatin1Char(' ') + version; + } + + const auto putDate = [this, &put](const char *key, const char *rawKey, const char *value) { + put(rawKey, value); + if (value) { + const QDateTime date = QDateTime::fromString(QString::fromUtf8(value), Qt::ISODate); + if (date.isValid()) + m_properties[QLatin1String(key)] = date; + } + }; + putDate("CreationDate", "CreationDateRaw", rofd_metadata_get_creation_date(raw)); + putDate("ModificationDate", "ModificationDateRaw", rofd_metadata_get_modification_date(raw)); + + size_t count = 0; + if (rofd_metadata_get_keyword_count(raw, &count, nullptr) == ROFD_STATUS_OK) { + QStringList keywords; + for (size_t i = 0; i < count; ++i) { + const char *keyword = nullptr; + if (rofd_metadata_get_keyword(raw, i, &keyword, nullptr) == ROFD_STATUS_OK && keyword) + keywords.append(QString::fromUtf8(keyword)); + } + m_properties["KeyWords"] = keywords.join(QStringLiteral("; ")); + } } QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h index dff0c73d9..6f5609ee1 100644 --- a/reader/document/OfdModel.h +++ b/reader/document/OfdModel.h @@ -33,6 +33,7 @@ class OfdDocument : public Document bool save() const override; bool saveAs(const QString &filePath) const override; Properties properties() const override; + QString fileIdentifier() const override; QString filePath() const { return m_filePath; } qreal xRes() const { return m_xRes; } @@ -43,10 +44,12 @@ class OfdDocument : public Document private: OfdDocument(const QString &filePath, rofd_document_t *document, rofd_renderer_t *renderer); + void loadMetadata(); QString m_filePath; rofd_document_t *m_document = nullptr; rofd_renderer_t *m_renderer = nullptr; + Properties m_properties; int m_pageCount = 0; qreal m_xRes = 96.0; qreal m_yRes = 96.0; diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index 7b9ced0cf..a8f65d1ac 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -1492,6 +1492,16 @@ void DocSheet::docBasicInfo(deepin_reader::FileInfo &tFileInfo) const Properties &propertys = m_renderer->properties(); tFileInfo.format = format(); +#ifdef OFD_SUPPORT_ENABLED + if (m_fileType == Dr::OFD) { + const QDateTime created = propertys.value("CreationDate").toDateTime(); + const QDateTime modified = propertys.value("ModificationDate").toDateTime(); + if (created.isValid()) + tFileInfo.createTime = created; + if (modified.isValid()) + tFileInfo.changeTime = modified; + } +#endif tFileInfo.optimization = propertys.value("Linearized").toBool(); QString keywords = propertys.value("KeyWords").toString(); if (keywords.isEmpty()) { diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index 83093bd58..fbc9d78c4 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -214,6 +214,50 @@ TEST_F(TestOfdModel, realInvoiceRegionMatchesFullPage) EXPECT_LE(differingChannels, 100); } +TEST(OfdApi, metadataAndIdentifier) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, QStringLiteral( + "文档-ID标题作者主题" + "摘要应用1.2" + "2026-09-092026-09-10T12:34:56+08:00" + "oneone").toUtf8()); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const Properties props = doc->properties(); + EXPECT_EQ(doc->fileIdentifier(), QStringLiteral("文档-ID")); + EXPECT_EQ(props.value("Title").toString(), QStringLiteral("标题")); + EXPECT_EQ(props.value("Author").toString(), QStringLiteral("作者")); + EXPECT_EQ(props.value("Subject").toString(), QStringLiteral("主题")); + EXPECT_EQ(props.value("Description").toString(), QStringLiteral("摘要")); + EXPECT_EQ(props.value("Creator").toString(), QStringLiteral("应用")); + EXPECT_EQ(props.value("CreatorVersion").toString(), QStringLiteral("1.2")); + EXPECT_EQ(props.value("KeyWords").toString(), QStringLiteral("one; 二; one")); + EXPECT_EQ(props.value("CreationDate").toDateTime().date(), QDate(2026, 9, 9)); + EXPECT_EQ(props.value("ModificationDate").toDateTime().offsetFromUtc(), 8 * 3600); + EXPECT_EQ(props.value("PageCount").toInt(), 1); + EXPECT_EQ(doc->fileIdentifier(), QStringLiteral("文档-ID")); +} + +TEST(OfdApi, missingMetadataAndInvalidDate) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, "not-a-date"); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const Properties props = doc->properties(); + EXPECT_TRUE(doc->fileIdentifier().isEmpty()); + EXPECT_FALSE(props.contains("Title")); + EXPECT_FALSE(props.value("CreationDate").toDateTime().isValid()); + EXPECT_EQ(props.value("CreationDateRaw").toString(), QStringLiteral("not-a-date")); +} + TEST_F(TestOfdModel, semanticFullText) { std::unique_ptr page(m_doc->page(0)); diff --git a/tests/ofd-model/README.md b/tests/ofd-model/README.md index 1dff6b520..3d5c9c692 100644 --- a/tests/ofd-model/README.md +++ b/tests/ofd-model/README.md @@ -13,12 +13,13 @@ cmake --build build/ofd-model -j1 ctest --test-dir build/ofd-model --output-on-failure ``` -The library must provide the region APIs added in rofd -commit `56da925`; the initial 0.3.0 release does not include them. +The library must provide the region and metadata APIs added in rofd +commits `56da925` and `6db9bac`; the initial 0.3.0 release does not include them. Its runtime SONAME (`librofd_ffi.so.0`) must resolve to the same library. Qt Widgets, DTK Core, Cairo, Google Test and CMake are required. Fixtures are created in temporary directories using `cmake -E tar --format=zip`. Coverage includes full and tiled rendering, nonzero physical page origins, small tiles on canvases larger than 4 GiB, pre-allocation raster limits, -real-invoice pixel comparisons, and search/selection. +real-invoice pixel comparisons, search/selection, metadata/DocID, missing or +invalid dates. From 90ded8f027d60ae6485e8df9a1e0528da32e32fa Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 07:52:08 +0800 Subject: [PATCH 08/19] feat(ofd): surface document parsing warnings Refresh owned warning snapshots after lazy page loads and rendering, log each warning once, and display warning details in file attributes. Add Chinese translations and lazy-load warning coverage. --- CMakeLists.txt | 5 +++- reader/document/OfdModel.cpp | 43 +++++++++++++++++++++++++++- reader/document/OfdModel.h | 4 +++ reader/widgets/AttrScrollWidget.cpp | 14 +++++++++ tests/document/ut_ofdmodel.cpp | 44 +++++++++++++++++++++++++++++ tests/ofd-model/README.md | 4 +-- translations/deepin-reader_zh_CN.ts | 6 +++- translations/deepin-reader_zh_HK.ts | 4 +++ translations/deepin-reader_zh_TW.ts | 4 +++ 9 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9336ced93..0408ed649 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,7 @@ if (OFD_SUPPORT) int main(void) { rofd_pixel_rect_t viewport; rofd_metadata_t *metadata = 0; + rofd_warning_list_t *warnings = 0; int32_t w = 0, h = 0; rofd_pixel_rect_init(&viewport, sizeof(viewport)); rofd_renderer_get_pixel_canvas_size(0, 0, 0, &w, &h, 0); @@ -114,12 +115,14 @@ if (OFD_SUPPORT) rofd_document_get_metadata(0, &metadata, 0); rofd_metadata_get_document_id(metadata); rofd_metadata_free(metadata); + rofd_document_get_warnings(0, &warnings, 0); + rofd_warning_list_free(warnings); return 0; } ]=] ROFD_READER_APIS_AVAILABLE) cmake_pop_check_state() if (NOT ROFD_READER_APIS_AVAILABLE) - message(FATAL_ERROR "rofd headers/library lack the region or metadata APIs. Use a matching rofd build containing commits 56da925 and 6db9bac (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") + message(FATAL_ERROR "rofd headers/library lack the region, metadata or warning APIs. Use a matching rofd build containing commits 56da925 and 6db9bac (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") endif() message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") add_compile_definitions(OFD_SUPPORT_ENABLED) diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 21fa1e29e..3451230f8 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -104,6 +104,7 @@ OfdDocument::OfdDocument(const QString &filePath, rofd_document_t *document, rof } loadMetadata(); + warningDetails(); qCInfo(appLog) << "OFD document loaded, pages:" << m_pageCount << "dpi:" << m_xRes << m_yRes; } @@ -129,6 +130,7 @@ Page *OfdDocument::page(int index) const rofd_page_t *pageHandle = nullptr; rofd_error_t *rofdError = nullptr; rofd_status_t status = rofd_document_get_page(m_document, static_cast(index), &pageHandle, &rofdError); + warningDetails(); if (status != ROFD_STATUS_OK || nullptr == pageHandle) { qCWarning(appLog) << "Failed to load OFD page:" << index << "message:" << (rofdError ? rofd_error_get_message(rofdError) : "unknown"); @@ -169,7 +171,11 @@ bool OfdDocument::saveAs(const QString &filePath) const Properties OfdDocument::properties() const { - return m_properties; + Properties props = m_properties; + // Warnings grow during lazy page/content loading; never cache this snapshot + // together with the immutable metadata. Each entry owns Code/Path/Message. + props["Warnings"] = warningDetails(); + return props; } QString OfdDocument::fileIdentifier() const @@ -236,6 +242,40 @@ void OfdDocument::loadMetadata() } } +QVariantList OfdDocument::warningDetails() const +{ + QMutexLocker lock(&m_warningMutex); + rofd_warning_list_t *raw = nullptr; + rofd_error_t *error = nullptr; + const rofd_status_t status = rofd_document_get_warnings(m_document, &raw, &error); + const std::unique_ptr warnings(raw, rofd_warning_list_free); + if (status != ROFD_STATUS_OK || !warnings) { + qCWarning(appLog) << "Failed to read OFD warnings:" << status + << (error ? rofd_error_get_message(error) : "unknown"); + rofd_error_free(error); + return {}; + } + rofd_error_free(error); + + QVariantList result; + size_t count = 0; + if (rofd_warning_list_get_count(raw, &count, nullptr) != ROFD_STATUS_OK) + return result; + for (size_t i = 0; i < count; ++i) { + rofd_warning_t warning = {}; + warning.struct_size = sizeof(warning); + if (rofd_warning_list_get_warning(raw, i, &warning, nullptr) != ROFD_STATUS_OK) + continue; + const QString path = QString::fromUtf8(warning.path ? warning.path : ""); + const QString message = QString::fromUtf8(warning.message ? warning.message : ""); + result.append(QVariantMap{{"Code", warning.code}, {"Path", path}, {"Message", message}}); + if (i >= m_loggedWarningCount) + qCWarning(appLog) << "OFD parse warning:" << warning.code << path << message; + } + m_loggedWarningCount = count; + return result; +} + QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const { if (nullptr == pageHandle || width <= 0 || height <= 0 @@ -329,6 +369,7 @@ QImage OfdDocument::renderPage(rofd_page_t *pageHandle, int width, int height, c } else { status = rofd_renderer_render_page_cairo(m_renderer, pageHandle, cr, &options, &report, &rofdError); } + warningDetails(); if (nullptr != report) { size_t diagnosticCount = 0; diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h index 6f5609ee1..b9d232023 100644 --- a/reader/document/OfdModel.h +++ b/reader/document/OfdModel.h @@ -11,6 +11,7 @@ #ifdef OFD_SUPPORT_ENABLED #include +#include #include #include @@ -45,11 +46,14 @@ class OfdDocument : public Document private: OfdDocument(const QString &filePath, rofd_document_t *document, rofd_renderer_t *renderer); void loadMetadata(); + QVariantList warningDetails() const; QString m_filePath; rofd_document_t *m_document = nullptr; rofd_renderer_t *m_renderer = nullptr; Properties m_properties; + mutable QMutex m_warningMutex; + mutable size_t m_loggedWarningCount = 0; int m_pageCount = 0; qreal m_xRes = 96.0; qreal m_yRes = 96.0; diff --git a/reader/widgets/AttrScrollWidget.cpp b/reader/widgets/AttrScrollWidget.cpp index 9c11cf1e5..1d2d145d2 100644 --- a/reader/widgets/AttrScrollWidget.cpp +++ b/reader/widgets/AttrScrollWidget.cpp @@ -6,6 +6,7 @@ #include "AttrScrollWidget.h" #include "Utils.h" #include "DocSheet.h" +#include "SheetRenderer.h" #include "WordWrapLabel.h" #include "ddlog.h" #include @@ -47,6 +48,19 @@ AttrScrollWidget::AttrScrollWidget(DocSheet *sheet, DWidget *parent) createLabel(gridLayout, 12, tr("Page size"), sPaperSize); createLabel(gridLayout, 13, tr("File size"), Utils::getInputDataSize(static_cast(fileInfo.size))); + const auto properties = sheet->renderer()->properties(); + const QVariantList warnings = properties.value("Warnings").toList(); + if (!warnings.isEmpty()) { + QStringList messages; + for (const QVariant &value : warnings) { + const QVariantMap warning = value.toMap(); + const QString path = warning.value("Path").toString(); + const QString message = warning.value("Message").toString(); + messages.append(path.isEmpty() ? message : path + QStringLiteral(": ") + message); + } + createLabel(gridLayout, 14, tr("Warnings"), messages.join(QLatin1Char('\n'))); + } + auto vLayout = new QVBoxLayout; vLayout->setContentsMargins(10, 10, 10, 10); diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index fbc9d78c4..4f90c8623 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -258,6 +258,50 @@ TEST(OfdApi, missingMetadataAndInvalidDate) EXPECT_EQ(props.value("CreationDateRaw").toString(), QStringLiteral("not-a-date")); } +TEST(OfdApi, warningSnapshotRefreshesAfterLazyPageLoad) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, "warnings"); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const QVariantList before = doc->properties().value("Warnings").toList(); + EXPECT_TRUE(before.isEmpty()); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const QVariantList after = doc->properties().value("Warnings").toList(); + ASSERT_EQ(after.size(), 1); + EXPECT_EQ(after.first().toMap().value("Code").toUInt(), ROFD_WARNING_PAGE_AREA_FALLBACK); + EXPECT_EQ(after.first().toMap().value("Path").toString(), QStringLiteral("Page.xml")); + EXPECT_FALSE(after.first().toMap().value("Message").toString().isEmpty()); + EXPECT_TRUE(before.isEmpty()); + std::unique_ptr again(doc->page(0)); + EXPECT_EQ(doc->properties().value("Warnings").toList(), after); +} + +TEST(OfdApi, warningsRefreshAfterRenderingAnnotations) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, "annotation-warning", + "7 11 210 297", + "missing.xml"); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + EXPECT_TRUE(doc->properties().value("Warnings").toList().isEmpty()); + ASSERT_FALSE(page->render(420, 594, QRect(17, 19, 160, 150)).isNull()); + const QVariantList warnings = doc->properties().value("Warnings").toList(); + ASSERT_EQ(warnings.size(), 1); + EXPECT_EQ(warnings.first().toMap().value("Code").toUInt(), ROFD_WARNING_ANNOTATION_SKIPPED); + EXPECT_EQ(warnings.first().toMap().value("Path").toString(), QStringLiteral("missing.xml")); +} + TEST_F(TestOfdModel, semanticFullText) { std::unique_ptr page(m_doc->page(0)); diff --git a/tests/ofd-model/README.md b/tests/ofd-model/README.md index 3d5c9c692..0de5e7cff 100644 --- a/tests/ofd-model/README.md +++ b/tests/ofd-model/README.md @@ -13,7 +13,7 @@ cmake --build build/ofd-model -j1 ctest --test-dir build/ofd-model --output-on-failure ``` -The library must provide the region and metadata APIs added in rofd +The library must provide the region, metadata and warning APIs added in rofd commits `56da925` and `6db9bac`; the initial 0.3.0 release does not include them. Its runtime SONAME (`librofd_ffi.so.0`) must resolve to the same library. Qt Widgets, DTK Core, Cairo, Google Test and CMake are required. Fixtures are @@ -22,4 +22,4 @@ created in temporary directories using `cmake -E tar --format=zip`. Coverage includes full and tiled rendering, nonzero physical page origins, small tiles on canvases larger than 4 GiB, pre-allocation raster limits, real-invoice pixel comparisons, search/selection, metadata/DocID, missing or -invalid dates. +invalid dates, and fresh warning snapshots after lazy page and annotation loads. diff --git a/translations/deepin-reader_zh_CN.ts b/translations/deepin-reader_zh_CN.ts index fbf90658d..85b2adc90 100755 --- a/translations/deepin-reader_zh_CN.ts +++ b/translations/deepin-reader_zh_CN.ts @@ -85,6 +85,10 @@ File size 文件大小 + + Warnings + 文档警告 + Basic info @@ -945,4 +949,4 @@ 无界面批量打印文档。 - \ No newline at end of file + diff --git a/translations/deepin-reader_zh_HK.ts b/translations/deepin-reader_zh_HK.ts index 0f5ac29fe..913799c59 100755 --- a/translations/deepin-reader_zh_HK.ts +++ b/translations/deepin-reader_zh_HK.ts @@ -86,6 +86,10 @@ File size 文件大小 + + Warnings + 文件警告 + Basic info diff --git a/translations/deepin-reader_zh_TW.ts b/translations/deepin-reader_zh_TW.ts index fd44f2c35..24a783ab8 100755 --- a/translations/deepin-reader_zh_TW.ts +++ b/translations/deepin-reader_zh_TW.ts @@ -86,6 +86,10 @@ File size 檔案大小 + + Warnings + 文件警告 + Basic info From 020b7d94541708a34c5305dec05d3399e63f79f1 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 08:00:37 +0800 Subject: [PATCH 09/19] docs(ofd): record semantic text integration design and plan --- .../plans/2026-09-10-ofd-semantic-text.md | 106 ++++++++++++++++++ .../2026-09-10-ofd-semantic-text-design.md | 62 ++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-10-ofd-semantic-text.md create mode 100644 docs/superpowers/specs/2026-09-10-ofd-semantic-text-design.md diff --git a/docs/superpowers/plans/2026-09-10-ofd-semantic-text.md b/docs/superpowers/plans/2026-09-10-ofd-semantic-text.md new file mode 100644 index 000000000..9ceaf16d4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-ofd-semantic-text.md @@ -0,0 +1,106 @@ +# OFD Semantic Text Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OFD documents participate in the reader's existing text extraction, search, selection, and copy workflows using the current `rofd` semantic C ABI. + +**Architecture:** Keep semantic adaptation inside `OfdPage`. Convert between physical-page millimetres and reader logical pixels at the boundary, use owned result handles per query, and expose character layout through the existing `Word` list. + +**Tech Stack:** C++17, Qt Core/Gui, GoogleTest, `rofd` stable C ABI + +--- + +### Task 1: Fixture-backed semantic behavior + +**Files:** +- Modify: `tests/document/ut_ofdmodel.cpp` + +- [ ] **Step 1: Add failing tests** + +Add tests that load `normal.ofd`, require non-empty full-page text, extract text +from the first returned word rectangle, verify case-sensitive and whole-word +search filtering, and require non-empty `words()` entries with valid geometry. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +QT_QPA_PLATFORM=offscreen build-semantic/tests/test-deepin-reader \ + --gtest_filter='TestOfdModel.semantic*' +``` + +Expected: the text, search, and words assertions fail because `OfdPage` still +returns empty values. + +### Task 2: Coordinate-safe semantic adapter + +**Files:** +- Modify: `reader/document/OfdModel.h` +- Modify: `reader/document/OfdModel.cpp` +- Test: `tests/document/ut_ofdmodel.cpp` + +- [ ] **Step 1: Store physical page geometry and declare `words()`** + +Add `QList words() override`, store the page's complete `rofd_rect_t`, and +declare private `toMillimetres` and `toPixels` rectangle helpers. + +- [ ] **Step 2: Implement owned-string extraction** + +Use `rofd_page_get_text` for a null rectangle and +`rofd_page_get_text_for_area` otherwise. Convert the borrowed UTF-8 bytes with +an explicit length before freeing `rofd_string_t`. + +- [ ] **Step 3: Implement semantic search** + +Initialize `rofd_find_options_t`, set `ROFD_FIND_CASE_SENSITIVE` and +`ROFD_FIND_WHOLE_WORDS` as requested, enumerate matches, convert each match +rectangle, and append `PageSection{PageLine{QString(), rect}}`. + +- [ ] **Step 4: Implement selectable character layout** + +Acquire canonical text and a layout snapshot, skip synthesized separators and +zero-area geometry, validate every UTF-8 span, and append one `Word` per scalar +using the converted rectangle. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run the semantic test filter and expect every new test to pass. + +### Task 3: Build contract and regression verification + +**Files:** +- Modify only if required by configure checks: `CMakeLists.txt` +- Modify: `debian/control` only after the semantic package version is known + +- [ ] **Step 1: Configure against local current `rofd main`** + +Build `rofd-ffi` into an isolated `/tmp` target, then configure a fresh reader +build using `-DROFD_ROOT=/home/hualet/projects/hualet/rofd` and the matching +library directory. + +- [ ] **Step 2: Build the reader and tests** + +Run the normal CMake build for `test-deepin-reader` and `deepin-reader`. + +- [ ] **Step 3: Run all OFD model tests** + +Run: + +```bash +QT_QPA_PLATFORM=offscreen build-semantic/tests/test-deepin-reader \ + --gtest_filter='TestOfdModel.*' +``` + +Expected: all OFD model tests pass. + +- [ ] **Step 4: Run the complete document-model test subset** + +Run the repository's document model test filters and report any unrelated +pre-existing failures separately. + +- [ ] **Step 5: Inspect the final diff** + +Confirm only the OFD adapter, OFD tests, and approved build-contract changes are +present. Leave implementation changes uncommitted until the user requests a +commit. diff --git a/docs/superpowers/specs/2026-09-10-ofd-semantic-text-design.md b/docs/superpowers/specs/2026-09-10-ofd-semantic-text-design.md new file mode 100644 index 000000000..240cbec98 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-ofd-semantic-text-design.md @@ -0,0 +1,62 @@ +# OFD Semantic Text Integration Design + +## Scope + +Connect the semantic text API already available in the current `rofd` C ABI to +the existing `deepin_reader::Page` abstraction. The change implements OFD page +text extraction, text search, and selectable character geometry. It does not +add OFD-specific UI or attempt metadata, outline, links, annotations, native +writing, or tiled rendering, because those still require new `rofd` APIs. + +## Architecture + +`OfdPage` remains the only adapter between `deepin-reader` page semantics and +`rofd`. It stores the physical page rectangle returned by +`rofd_page_get_size_mm`, then uses small private conversion helpers for physical +millimetres and the logical page-pixel coordinate system used by the reader. + +Each public query creates and frees its own immutable `rofd` result handle. No +mutable semantic cache is shared between render and search worker threads. +This follows the existing adapter structure and the C ABI's concurrent +read-only handle contract. + +## Data Flow + +- `text(rect)` converts the requested logical-pixel rectangle to physical-page + millimetres and calls `rofd_page_get_text_for_area`. A null rectangle requests + the full canonical page text through `rofd_page_get_text`. +- `search(query, matchCase, wholeWords)` initializes + `rofd_find_options_t`, maps the two reader flags to `ROFD_FIND_*`, and turns + every match rectangle into one `PageSection` containing one `PageLine`. +- `words()` gets canonical UTF-8 text plus its layout snapshot. Every + non-synthesized scalar with valid geometry becomes one `Word`; its text is + decoded from the canonical string using the layout's byte offset and length. + One scalar per `Word` matches the existing selection overlay contract. + +## Coordinates + +The reader expresses page text geometry in logical pixels at the document's X +and Y logical DPI. `rofd` expresses geometry in physical-page millimetres. +Conversions include the physical page rectangle's X/Y origin: + +``` +mm.x = physical.x + px.x * 25.4 / xDpi +px.x = (mm.x - physical.x) * xDpi / 25.4 +``` + +The same formula applies independently to Y, width, and height. + +## Error Handling + +Invalid handles, empty search input, invalid rectangles, invalid UTF-8 spans, +and failed C ABI queries return the empty result expected by `Page`. Owned +`rofd` handles are always freed. ABI failures are logged with page index, +status, and the optional error message. + +## Verification + +Fixture-backed tests prove full and area text extraction, case-sensitive and +whole-word search behavior, search rectangles, and selectable character text +and geometry. The tests are first run against the current stubs to demonstrate +the intended failures, then rerun against the implementation using a local +build of the latest `rofd main` semantic ABI. From 817e47ffe9b79a4acd2d75b4fcaa92b9bf933400 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 09:19:07 +0800 Subject: [PATCH 10/19] docs(ofd): define outline and page link integration --- .../specs/2026-09-11-ofd-navigation-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-ofd-navigation-design.md diff --git a/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md b/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md new file mode 100644 index 000000000..1ff769215 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md @@ -0,0 +1,146 @@ +# OFD 目录与页面链接接入设计 + +## 范围与基线 + +在现有 OFD 渲染、语义文本、元数据和警告支持上,接入 rofd 的目录树、 +跳转目标和页面链接,复用 reader 的侧栏、页面交互及安全确认界面。 + +本次核对的 rofd main 为 `2331199`,包含: + +- `56da925`:像素区域渲染,reader 已接入。 +- `6db9bac`:元数据和警告,reader 已接入。 +- `9e4a169`:目录、动作与目标,本次接入。 +- `2331199`:页面链接映射,本次接入。 + +不增加附件提取、附件执行、自动动作、脚本、注释编辑或文件写回。 +不重构 PDF/XPS 后端,也不把 rofd 的矩形链接范围描述为精确路径命中。 + +## 架构与兼容性 + +为公共模型增加可选的导航目标,由目录项和页面链接共同使用。目标区分 +页内跳转与外部 URI;页内目标保存零基页码、目标模式、可选坐标和缩放。 +可选值必须区分缺省与显式零,不能只靠浮点数是否为零判断字段存在。 + +`Section`、`Link` 保留原有字段和默认行为。PDF/XPS 等未提供新目标的 +对象继续走原路径;OFD 使用新路径,不复用目录跳转中 PDF 风格的 Y 轴翻转。 +目录项另保存可选的初始展开状态,避免改变其它格式的默认展开行为。 + +职责分配: + +- `OfdDocument`:获取目录快照、转换树和导航目标,缓存目标页尺寸。 +- `OfdPage`:获取并缓存页面链接,完成命中查询,不执行任何动作。 +- 公共导航转换/计算辅助代码:校验目标、转换坐标、计算缩放与滚动位置; + 将可独立验证的计算从大型浏览器类中分离。 +- `CatalogTreeView`:完整展示树,将目录用户操作交给导航入口。 +- `DocSheet` / `SheetBrowser`:执行目标,更新缩放、布局、滚动及当前页; + 外链复用 `SecurityDialog`,适配层不调用桌面服务。 + +## 目录树 + +实现 `OfdDocument::outline()`,并为 OFD 开启 `PREVIEW_CATALOG`。 +按 rofd 的父子关系与源顺序转换完整目录,不依赖源文件的建议子节点数量。 +校验关系索引,遍历必须有界,不因深层目录造成无限递归。 + +目录展示不再限制为三层。没有动作、目标失效、动作不支持的节点仍保留标题 +和子树,页码栏留空,不能把 `-1` 或 `ROFD_NO_INDEX` 显示或解释为第一页。 +这些节点可以展开和选择,但选择不触发跳转。 + +首次显示使用 OFD 的 `expanded` 值;已有阅读记录中的展开状态优先。 +刷新模型、恢复展开状态、随当前页同步选中项均不能执行动作。 +用户鼠标或键盘激活每次最多执行一次;外链不能因选中项变化而自动弹窗。 + +## 目标语义与坐标 + +rofd 已解析 PageID 和命名书签。仅在 `HAS_PAGE_INDEX` 存在、索引未越界 +且模式受支持时建立可执行的页内目标;未知模式与未解析目标不执行。 +零基页码仅在兼容旧 `Link.page` 等一基字段时加一。 + +坐标先转换为目标页未旋转、未缩放的逻辑像素: + +```text +x = (left_mm - physical_page.x) * xDpi / 25.4 +y = (top_mm - physical_page.y) * yDpi / 25.4 +``` + +使用目标页的物理原点,而不是源链接所在页的原点。右、下边界做同样转换。 +缺省坐标不参与换算,不能把缺省的零减去物理原点。旋转只在视图映射时处理, +通过页面到场景的变换定位,避免重复旋转或重复应用对象 CTM。 + +执行模式: + +- `XYZ`:应用明确指定的坐标;缺省轴保留激活前视口相对当前页的位置, + 映射到目标页后受页面和滚动范围限制。缩放缺省或为零时保持当前比例; + 正值作为倍率交给 reader 的比例限制逻辑,负值或非有限值拒绝。 +- `Fit`:按目标整页与可用视口计算比例,定位目标页。 +- `FitH`:按目标页宽度适配,应用可选 Top;Top 缺省时沿用上述缺省轴策略。 +- `FitV`:按目标页高度适配,应用可选 Left;Left 缺省时沿用上述缺省轴策略。 +- `FitR`:要求四条边界均存在、有限且形成正面积矩形;适配并定位该区域。 + 不完整或退化矩形不执行,不悄悄变成整页或第一页跳转。 + +所有模式遵守 reader 当前缩放上下限,不绕过大页面的安全比例限制。 +适配比例根据目标页/目标区域计算,不直接借用基于文档最大页尺寸的全局 +适配结果。旋转和双页布局纳入可用视口与场景变换计算。 + +## 页面链接与动作策略 + +实现 `OfdPage::getLinkAtPoint()`。首次查询生成 Qt 自有的不可变链接缓存, +包含命中范围和可执行目标;空列表也作为已加载状态缓存,鼠标移动不重复 +构造快照或解析页面。初始化同步保护,失败保留失败状态并记录诊断,避免 +每次悬停重复失败和刷日志。 + +仅考虑 `CLICK` 动作。按 rofd 返回的源顺序选择第一个命中且受支持的动作, +每次激活最多执行一个,不连带执行同区域的其它动作。目录中的动作使用相同 +筛选策略。文档打开、页面打开和未知事件始终不执行。 + +一个链接的多个矩形独立参与命中,不能合并成覆盖中间空白的大包围盒。 +矩形已经包含 rofd 应用的对象与祖先变换,只做页面原点和 DPI 换算。 +命中能力以 rofd 提供的保守矩形为界,不承诺裁剪、遮挡或曲线路径的精确命中。 + +URI 先使用显式 Base 解析,再校验最终地址。首轮只允许有效的绝对 +`http`、`https`、`mailto` 地址;没有可用 Base 的相对地址不猜测为本地文件。 +`file`、其它协议、附件 GotoA 和未知动作均不可执行。 +安全确认对话框显示最终将打开的地址;用户取消时不调用外部程序。 +此限制只作用于新 OFD 导航,不改变其它格式已有的外链策略。 + +## 资源、警告与错误 + +所有 rofd 输出记录初始化 `struct_size`,遵守 `HAS_*` 和 `ROFD_NO_INDEX`。 +快照用 RAII 释放;标题、URI 和其它借用字符串在释放前复制为 Qt 自有数据。 +文档/页面所有者销毁前结束相关查询,不跨线程共享可变输出槽。 + +目录、目标页尺寸和链接查询可能触发惰性解析。完成查询后刷新现有警告接口, +复用文档警告去重与属性展示。导航失败不影响已有渲染或文本查询。 +目录与页面链接分别缓存,目录失败不能阻止 rofd 本可独立提供的链接查询。 + +## 构建与交付边界 + +更新本地构建使用的配套 rofd 头文件和共享库;CMake 增加目录、目标与链接 +符号检查,避免旧库带着相同版本号通过配置后才在链接阶段失败。 +不在源码提交中加入生成的共享库。rofd 当前仍标为 0.3.0,不虚构软件包版本; +发行版依赖下限在上游正式抬升版本后再更新。 + +实现分两项审查与交付: + +1. 目录、公共导航目标与跳转执行,包括侧栏、完整树、坐标、模式及共享动作 + 安全策略;目录外链同样需要显式激活和确认。 +2. 页面链接与命中缓存,复用第一项的页内跳转及外链安全策略,并补充页面 + 点击和悬停场景的验证。 + +## 验收 + +先增加回归测试并确认未接入时失败,再实现;继续使用不链接 PDFium 的轻量 +OFD 测试目标。为目录模型和纯导航计算增加独立轻量覆盖,不构建或链接 +`test-deepin-reader` 聚合测试程序。 + +必须覆盖: + +- 空目录、无目标父节点、四层以上目录、初始展开状态和恢复状态优先级。 +- PageID 与命名书签、缺失目标、未知模式、缺省坐标、显式零、`Zoom=0`。 +- 非零物理原点、跨不同尺寸页面、五种目标模式、旋转与双页布局。 +- 单链接多个矩形、中间空白不命中、重叠链接的确定性选择、空/失败缓存。 +- 非 CLICK 事件、附件动作、URI/Base 解析、协议限制和用户取消外链确认。 +- 新增惰性导航警告,以及未设置新目标时 PDF/XPS 仍走原有路径。 + +最终只以 `cmake --build build --target deepin-reader -j1` 构建 reader 本体, +检查实际加载的 rofd 库,并用受控 OFD 做真实窗口的目录/链接交互验证。 +界面验证与后台测试分别报告,不把适配层测试通过当成用户交互已验证。 From b34a55a36d45ac51eed8ca89ddd629c233f4411f Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 09:31:21 +0800 Subject: [PATCH 11/19] docs(ofd): plan navigation and page link integration --- .../plans/2026-09-11-ofd-navigation.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-11-ofd-navigation.md diff --git a/docs/superpowers/plans/2026-09-11-ofd-navigation.md b/docs/superpowers/plans/2026-09-11-ofd-navigation.md new file mode 100644 index 000000000..046566ccd --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-ofd-navigation.md @@ -0,0 +1,162 @@ +# OFD Navigation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Expose OFD outlines and clickable page links without losing destination semantics or enabling automatic actions. + +**Architecture:** Add optional typed navigation values to the existing model. A pure view calculator and URI validator are shared by outline and page-link activation; rofd remains behind the OFD adapter. Preserve legacy PDF/XPS navigation when the optional value is absent. + +**Tech Stack:** C++17, Qt Widgets, rofd 0.4.0 C ABI, GoogleTest, CMake. + +## Execution and build boundaries + +Use the approved design at `docs/superpowers/specs/2026-09-11-ofd-navigation-design.md`. +The isolated worktree is `/tmp/deepin-reader-ofd-nav.bx98sU/tree`; the baseline is +`e50708ff`. The paired rofd header/library are in the sibling `rofd/` directory. +Upstream advanced to `35cb164` / 0.4.0 after design approval, so update the package +minimum instead of retaining the design's historical 0.3.0 limitation. + +Run only focused tests and the reader target, with `-j1`. Never build the +`test-deepin-reader` aggregate. The clean baseline passes all 19 OFD tests. + +```sh +cmake -S tests/ofd-model -B build/ofd-model \ + -DROFD_INCLUDE_DIR=/tmp/deepin-reader-ofd-nav.bx98sU/rofd/include \ + -DROFD_FFI_LIBRARY=/tmp/deepin-reader-ofd-nav.bx98sU/rofd/librofd_ffi.so +cmake --build build/ofd-model -j1 +ctest --test-dir build/ofd-model --output-on-failure +``` + +### Task 1: Typed targets and pure navigation calculations + +**Files:** Create `reader/document/Navigation.h`, `Navigation.cpp`, +`tests/document/ut_navigation.cpp`; modify `reader/document/Model.h` and +`tests/ofd-model/CMakeLists.txt`. + +- [ ] Add tests first for missing/zero fields, all five modes, invalid rectangles, + scale limits, 90/180/270-degree rotation, double-page viewport allocation, + allowed URI resolution, rejected file/relative/unknown URI, and legacy defaults. +- [ ] Run the focused target and capture the missing-behavior failure before implementation. +- [ ] Implement this shared contract (namespace `deepin_reader`): + +```cpp +enum class DestinationMode { XYZ, Fit, FitH, FitV, FitR }; +struct NavigationDestination { + int pageIndex = -1; + DestinationMode mode = DestinationMode::XYZ; + std::optional left, top, right, bottom, zoom; + bool isValid() const; +}; +struct NavigationTarget { + std::optional destination; + QUrl uri; + bool isValid() const; +}; +struct NavigationView { + qreal scale = 1; + QRectF focusRect; // unscaled page coordinates; zero-size rectangle for a point +}; +QUrl resolveNavigationUri(const QString &uri, const QString &base = QString()); +std::optional navigationView( + const NavigationDestination &destination, const QSizeF &pageSize, + const QSizeF &viewportSize, const QPointF ¤tPosition, + qreal currentScale, qreal maximumScale, int rotationDegrees, bool twoPages); +``` + + `Section` and `Link` gain `std::optional navigation`; + `Section` additionally gains `std::optional expanded`. Register the + target metatype. Absent navigation retains old `Link::isValid()` behavior; + present navigation is checked on its own, never falling back to stale fields. + Calculate fit scale using rotated bounds and half viewport width for two pages, + clamp to `[0.1, maximumScale]`, reject non-finite inputs and invalid FitR. + XYZ defaults use currentPosition; zoom absent/zero preserves currentScale. + URI resolution accepts only absolute HTTP/HTTPS with host and nonempty mailto, + uses QUrl strict parsing and explicit Base, and never performs I/O. +- [ ] Verify all tests, including `Link legacy; legacy.page = 1; EXPECT_TRUE(legacy.isValid());`. +- [ ] Review the exact diff for spec compliance, then code quality, before consuming the contract. + +### Task 2: OFD outlines and destinations + +**Files:** Modify `reader/document/OfdModel.h`, `OfdModel.cpp`, +`tests/document/ut_ofdmodel.cpp`, `CMakeLists.txt`, `debian/control`. + +- [ ] Add controlled two-page OFD fixtures with page origins `(7,11)` and `(3,5)`, + nested titles four levels deep, a non-clickable parent, named and explicit + destinations, zero/omitted fields, invalid targets and non-CLICK actions. + First assertion against the current stub is `ASSERT_EQ(doc->outline().size(), 1);`. +- [ ] Run and observe the empty-outline failure. +- [ ] Implement `Outline outline() const override` with a mutex-protected cache, + including empty/failed results. Convert preorder nodes in reverse index order + into the tree after validating parent-before-child indices; retain invalid-target + nodes without a page number. Copy all strings before releasing the RAII snapshot. + A document helper maps rofd actions/destinations to Task 1's values and lazily + caches target-page physical rectangles. Only CLICK Goto or allowed URI actions + become targets; preserve optional coordinates and use target-page origins. +- [ ] Probe outline/destination functions at configure time and require + `librofd-ffi-dev (>= 0.4.0)`; refresh warnings after lazy navigation queries. +- [ ] Verify geometry with `EXPECT_NEAR(*target.destination->left, (13-3)*dpi/25.4, 1e-6);`, + unresolved page targets remain absent, and repeated outline calls are stable. +- [ ] Run spec review then quality review; checkpoint the scoped backend change. + +### Task 3: Complete catalog and view execution + +**Files:** Create `reader/sidebar/CatalogOutlineModel.h/.cpp` and +`tests/document/ut_catalogoutlinemodel.cpp`; modify `CatalogTreeView.h/.cpp`, +`reader/uiframe/DocSheet.h/.cpp`, `reader/browser/SheetBrowser.h/.cpp`, +`tests/ofd-model/CMakeLists.txt`. + +- [ ] Test model construction first: a targetless root with a four-level child + must remain present, have a blank page column, retain navigation/expansion roles, + and expose the leaf. Preserve legacy page and offset roles. +- [ ] Observe a failing model test, then implement reusable iterative tree-to-item + population. Bind both columns to the same target. CatalogTreeView uses it, + expands OFD defaults, and does not activate targets while populating or syncing. +- [ ] Add `bool navigateTo(const deepin_reader::NavigationTarget &)` to SheetBrowser + and a forwarding DocSheet method. Validate page count before changing state. + Compute currentPosition by mapping viewport origin to the current page and + dividing by current scale. Apply Task 1's view, call setScaleFactor, and map + focusRect at the actual resulting scale through the target item's scene transform + before setting scrollbars and notifying the current page. +- [ ] For a typed URI, revalidate, display the final URL in SecurityDialog, and + call QDesktopServices only after Accepted. Share this path with Task 4. +- [ ] Enable PREVIEW_CATALOG for OFD. Typed outline actions execute on explicit + click or keyboard activation exactly once, not currentChanged; legacy paths + remain unchanged. Stored expansion state, including all-collapsed state, wins + over defaults when the catalog is opened lazily. +- [ ] Verify model/calculator tests and reader compilation; exercise actual + outline activation, rotation and cancellation in a controlled app window. +- [ ] Spec review then quality review; checkpoint directory/navigation integration. + +### Task 4: Page link adapter and shared activation + +**Files:** Modify `reader/document/OfdModel.h/.cpp`, +`tests/document/ut_ofdmodel.cpp`, `reader/browser/SheetBrowser.cpp`, `CMakeLists.txt`. + +- [ ] Add links to controlled fixtures with separated regions, overlaps, + transformed bounds, CLICK/PO/DO events, URI/Base and GotoA actions. + First assertion against the stub is `EXPECT_TRUE(page->getLinkAtPoint(hit).isValid());`. +- [ ] Run and observe that missing-link failure. +- [ ] Implement a per-page immutable Qt-owned link cache guarded during init. + Preserve source order, use the first supported hit, and union independent + rectangles as paths without filling the gaps. Copy borrowed data before freeing + the snapshot. Empty/failed lists are cached. Reuse Task 2 conversion and refresh + warnings after rofd_page_get_links. No adapter action executes external code. +- [ ] `SheetBrowser::jump2Link` calls navigateTo when `link.navigation` exists; + otherwise preserves the old PDF/XPS path. Keep resolved URI text for hover tips. +- [ ] Extend CMake symbol checks and tests for gaps, deterministic overlap, + unsafe targets, failure isolation from outlines and repeated queries. +- [ ] Run spec review then quality review; checkpoint page-link integration. + +### Task 5: Integrated validation and delivery + +**Files:** Update `tests/ofd-model/README.md` and this checklist with actual results. + +- [ ] Run `git diff --check`, all focused tests, and inspect linked rofd SONAME. +- [ ] Build only `deepin-reader -j1`, reusing the existing PDFium build where possible. +- [ ] Verify real window catalog depth/activation, page link hover/click, rotation, + scale changes and external-link cancellation. Do not visit test URLs. +- [ ] Check legacy PDF/XPS targets keep their prior path and no new automatic actions + occur on opening a document, restoring state or selecting a catalog item. +- [ ] Review the entire diff after task reviews, report exact evidence and any + unverified conditions. Keep outline/navigation and page links separable in history; + do not push or merge to main without the user's request. From 308e70d0d3ab714b928b677e256289eed435771f Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 09:56:51 +0800 Subject: [PATCH 12/19] feat(reader): add typed navigation targets and view calculations --- reader/document/Model.h | 6 + reader/document/Navigation.cpp | 153 +++++++++++ reader/document/Navigation.h | 57 ++++ tests/document/ut_navigation.cpp | 436 +++++++++++++++++++++++++++++++ tests/ofd-model/CMakeLists.txt | 3 + 5 files changed, 655 insertions(+) create mode 100644 reader/document/Navigation.cpp create mode 100644 reader/document/Navigation.h create mode 100644 tests/document/ut_navigation.cpp diff --git a/reader/document/Model.h b/reader/document/Model.h index 604ef74ba..e1681865c 100644 --- a/reader/document/Model.h +++ b/reader/document/Model.h @@ -7,6 +7,7 @@ #define DOCUMENTMODEL_H #include "Global.h" +#include "Navigation.h" #include "dpdfpage.h" #include @@ -40,6 +41,7 @@ struct Link { qreal left = 0; qreal top = 0; QString urlOrFileName; + std::optional navigation; Link() : boundary(), page(-1), left(0.0), top(0.0), urlOrFileName() {} Link(const QPainterPath &boundary, int page, qreal left = 0.0, qreal top = 0.0) : boundary(boundary), page(page), left(left), top(top), urlOrFileName() {} Link(const QRectF &boundingRect, int page, qreal left = 0.0, qreal top = 0.0) : boundary(), page(page), left(left), top(top), urlOrFileName() { boundary.addRect(boundingRect); } @@ -50,6 +52,8 @@ struct Link { bool isValid() const { + if (navigation) + return navigation->isValid(); return page >= 1 || !urlOrFileName.isEmpty(); } }; @@ -67,6 +71,8 @@ struct Section { QPointF offsetPointF; QString title; Outline children; + std::optional navigation; + std::optional expanded; }; struct Word { diff --git a/reader/document/Navigation.cpp b/reader/document/Navigation.cpp new file mode 100644 index 000000000..61d29de27 --- /dev/null +++ b/reader/document/Navigation.cpp @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "Navigation.h" + +#include +#include + +namespace deepin_reader { + +namespace { + +constexpr qreal minimumScale = 0.1; + +bool finiteOptional(const std::optional &value) +{ + return !value || std::isfinite(*value); +} + +bool positiveFiniteSize(const QSizeF &size) +{ + return std::isfinite(size.width()) && size.width() > 0 + && std::isfinite(size.height()) && size.height() > 0; +} + +bool allowedUri(const QUrl &uri) +{ + if (!uri.isValid() || uri.isEmpty() || uri.isRelative()) + return false; + + const QString scheme = uri.scheme(); + if (scheme == QLatin1String("http") || scheme == QLatin1String("https")) + return !uri.host().isEmpty(); + if (scheme == QLatin1String("mailto")) + return !uri.path().trimmed().isEmpty(); + return false; +} + +} // namespace + +bool NavigationDestination::isValid() const +{ + if (pageIndex < 0 || !finiteOptional(left) || !finiteOptional(top) + || !finiteOptional(right) || !finiteOptional(bottom) || !finiteOptional(zoom) + || (zoom && *zoom < 0)) { + return false; + } + + switch (mode) { + case DestinationMode::XYZ: + case DestinationMode::Fit: + case DestinationMode::FitH: + case DestinationMode::FitV: + return true; + case DestinationMode::FitR: + return left && top && right && bottom && *right > *left && *bottom > *top + && std::isfinite(*right - *left) && std::isfinite(*bottom - *top); + } + return false; +} + +bool NavigationTarget::isValid() const +{ + if (destination) + return uri.isEmpty() && destination->isValid(); + return allowedUri(uri); +} + +QUrl resolveNavigationUri(const QString &uri, const QString &base) +{ + if (uri.isEmpty()) + return {}; + + QUrl resolved(uri, QUrl::StrictMode); + if (!resolved.isValid()) + return {}; + if (resolved.isRelative()) { + const QUrl baseUrl(base, QUrl::StrictMode); + if (!allowedUri(baseUrl) + || (baseUrl.scheme() != QLatin1String("http") + && baseUrl.scheme() != QLatin1String("https"))) { + return {}; + } + resolved = baseUrl.resolved(resolved); + } + return allowedUri(resolved) ? resolved : QUrl(); +} + +std::optional navigationView(const NavigationDestination &destination, + const QSizeF &pageSize, const QSizeF &viewportSize, + const QPointF ¤tPosition, qreal currentScale, + qreal maximumScale, int rotationDegrees, bool twoPages) +{ + if (!destination.isValid() || !positiveFiniteSize(pageSize) || !positiveFiniteSize(viewportSize) + || !std::isfinite(currentPosition.x()) || !std::isfinite(currentPosition.y()) + || !std::isfinite(currentScale) || currentScale <= 0 + || !std::isfinite(maximumScale) || maximumScale < minimumScale + || rotationDegrees % 90 != 0) { + return std::nullopt; + } + + const qreal availableWidth = viewportSize.width() / (twoPages ? 2 : 1); + if (availableWidth <= 0) + return std::nullopt; + + const bool quarterTurn = rotationDegrees % 180 != 0; + const qreal pageWidth = quarterTurn ? pageSize.height() : pageSize.width(); + const qreal pageHeight = quarterTurn ? pageSize.width() : pageSize.height(); + QPointF position(destination.left.value_or(currentPosition.x()), + destination.top.value_or(currentPosition.y())); + NavigationView result; + result.scale = currentScale; + + switch (destination.mode) { + case DestinationMode::XYZ: + if (destination.zoom && *destination.zoom > 0) + result.scale = *destination.zoom; + break; + case DestinationMode::Fit: + result.scale = std::min(availableWidth / pageWidth, viewportSize.height() / pageHeight); + result.focusRect = QRectF(QPointF(), pageSize); + break; + case DestinationMode::FitH: + result.scale = availableWidth / pageWidth; + position.setX(0); + break; + case DestinationMode::FitV: + result.scale = viewportSize.height() / pageHeight; + position.setY(0); + break; + case DestinationMode::FitR: { + const qreal width = *destination.right - *destination.left; + const qreal height = *destination.bottom - *destination.top; + result.scale = std::min(availableWidth / (quarterTurn ? height : width), + viewportSize.height() / (quarterTurn ? width : height)); + result.focusRect = QRectF(*destination.left, *destination.top, width, height); + break; + } + } + + if (destination.mode == DestinationMode::XYZ || destination.mode == DestinationMode::FitH + || destination.mode == DestinationMode::FitV) { + position.setX(std::clamp(position.x(), qreal(0), pageSize.width())); + position.setY(std::clamp(position.y(), qreal(0), pageSize.height())); + result.focusRect = QRectF(position, QSizeF(0, 0)); + } + // Finite positive inputs can still overflow a fitting ratio; the reader limit + // bounds that ratio before it reaches any caller or graphics transform. + result.scale = std::clamp(result.scale, minimumScale, maximumScale); + return result; +} + +} // namespace deepin_reader diff --git a/reader/document/Navigation.h b/reader/document/Navigation.h new file mode 100644 index 000000000..e71188f24 --- /dev/null +++ b/reader/document/Navigation.h @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef DOCUMENTNAVIGATION_H +#define DOCUMENTNAVIGATION_H + +#include +#include +#include +#include +#include +#include + +#include + +namespace deepin_reader { + +enum class DestinationMode { XYZ, Fit, FitH, FitV, FitR }; + +struct NavigationDestination { + // Zero-based page index and unscaled page coordinates. Missing fields retain + // their presence semantics; in XYZ, absent or zero zoom keeps the current scale. + int pageIndex = -1; + DestinationMode mode = DestinationMode::XYZ; + std::optional left, top, right, bottom, zoom; + + bool isValid() const; +}; + +struct NavigationTarget { + std::optional destination; + QUrl uri; + + bool isValid() const; +}; + +struct NavigationView { + qreal scale = 1; + // Unscaled page coordinates; a point is represented by a zero-size rectangle. + QRectF focusRect; +}; + +QUrl resolveNavigationUri(const QString &uri, const QString &base = QString()); + +// Fits use rotated page bounds and half the viewport width in two-page mode. +// The returned scale is bounded to [0.1, maximumScale], and focusRect remains in +// unscaled page coordinates for the caller to map through the actual scene item. +std::optional navigationView(const NavigationDestination &destination, + const QSizeF &pageSize, const QSizeF &viewportSize, + const QPointF ¤tPosition, qreal currentScale, + qreal maximumScale, int rotationDegrees, bool twoPages); + +} // namespace deepin_reader + +Q_DECLARE_METATYPE(deepin_reader::NavigationTarget) + +#endif // DOCUMENTNAVIGATION_H diff --git a/tests/document/ut_navigation.cpp b/tests/document/ut_navigation.cpp new file mode 100644 index 000000000..616e14cae --- /dev/null +++ b/tests/document/ut_navigation.cpp @@ -0,0 +1,436 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "Navigation.h" +#include "Model.h" + +#include +#include +#include + +using namespace deepin_reader; + +namespace { + +NavigationDestination destination(DestinationMode mode = DestinationMode::XYZ) +{ + NavigationDestination result; + result.pageIndex = 0; + result.mode = mode; + return result; +} + +std::optional view(const NavigationDestination &target, int rotation = 0, + bool twoPages = false) +{ + return navigationView(target, QSizeF(200, 400), QSizeF(800, 600), QPointF(31, 47), + 1.25, 10, rotation, twoPages); +} + +constexpr qreal infinity = std::numeric_limits::infinity(); +constexpr qreal notANumber = std::numeric_limits::quiet_NaN(); + +} // namespace + +TEST(NavigationValue, preservesMissingAndExplicitZeroFields) +{ + NavigationDestination target; + EXPECT_EQ(target.pageIndex, -1); + EXPECT_EQ(target.mode, DestinationMode::XYZ); + EXPECT_FALSE(target.isValid()); + EXPECT_FALSE(target.left.has_value()); + EXPECT_FALSE(target.top.has_value()); + EXPECT_FALSE(target.right.has_value()); + EXPECT_FALSE(target.bottom.has_value()); + EXPECT_FALSE(target.zoom.has_value()); + + target = destination(); + EXPECT_TRUE(target.isValid()); + target.left = target.top = target.zoom = 0; + EXPECT_TRUE(target.isValid()); + EXPECT_EQ(target.left, std::optional(0)); + EXPECT_EQ(target.top, std::optional(0)); + EXPECT_EQ(target.zoom, std::optional(0)); +} + +TEST(NavigationValue, validatesEveryModeAndRejectsUnknownMode) +{ + for (auto mode : {DestinationMode::XYZ, DestinationMode::Fit, + DestinationMode::FitH, DestinationMode::FitV}) { + EXPECT_TRUE(destination(mode).isValid()); + } + EXPECT_FALSE(destination(static_cast(999)).isValid()); + auto target = destination(); + target.pageIndex = -2; + EXPECT_FALSE(target.isValid()); +} + +TEST(NavigationValue, fitRectangleRequiresFourOrderedFiniteEdges) +{ + auto target = destination(DestinationMode::FitR); + target.left = 10; + target.top = 20; + target.right = 110; + target.bottom = 220; + ASSERT_TRUE(target.isValid()); + for (auto field : {&NavigationDestination::left, &NavigationDestination::top, + &NavigationDestination::right, &NavigationDestination::bottom}) { + auto incomplete = target; + (incomplete.*field).reset(); + EXPECT_FALSE(incomplete.isValid()); + } + auto invalid = target; + invalid.right = 10; + EXPECT_FALSE(invalid.isValid()); + invalid.right = 9; + EXPECT_FALSE(invalid.isValid()); + invalid = target; + invalid.bottom = 20; + EXPECT_FALSE(invalid.isValid()); + invalid.bottom = 19; + EXPECT_FALSE(invalid.isValid()); + invalid.left = -std::numeric_limits::max(); + invalid.right = std::numeric_limits::max(); + invalid.bottom = 220; + EXPECT_FALSE(invalid.isValid()); +} + +TEST(NavigationValue, rejectsNonfiniteFieldsAndNegativeZoom) +{ + for (auto mode : {DestinationMode::XYZ, DestinationMode::Fit, + DestinationMode::FitH, DestinationMode::FitV, DestinationMode::FitR}) { + auto target = destination(mode); + target.left = 0; + target.top = 0; + target.right = 100; + target.bottom = 200; + ASSERT_TRUE(target.isValid()); + for (auto field : {&NavigationDestination::left, &NavigationDestination::top, + &NavigationDestination::right, &NavigationDestination::bottom, + &NavigationDestination::zoom}) { + for (qreal value : {notANumber, infinity, -infinity}) { + auto invalid = target; + invalid.*field = value; + EXPECT_FALSE(invalid.isValid()); + } + } + target.zoom = -0.5; + EXPECT_FALSE(target.isValid()); + } +} + +TEST(NavigationValue, targetRequiresExactlyOneValidAction) +{ + NavigationTarget target; + EXPECT_FALSE(target.isValid()); + target.destination = destination(); + EXPECT_TRUE(target.isValid()); + target.uri = QUrl(QStringLiteral("https://example.org/document")); + EXPECT_FALSE(target.isValid()); + target.destination.reset(); + EXPECT_TRUE(target.isValid()); + target.uri = QUrl(QStringLiteral("file:///tmp/document.ofd")); + EXPECT_FALSE(target.isValid()); + target.uri = QUrl(QStringLiteral("next.html")); + EXPECT_FALSE(target.isValid()); + target.uri.clear(); + target.destination = NavigationDestination(); + EXPECT_FALSE(target.isValid()); +} + +TEST(NavigationValue, targetCanRoundTripThroughItemData) +{ + NavigationTarget target; + target.destination = destination(DestinationMode::FitH); + target.destination->top = 0; + const auto restored = QVariant::fromValue(target).value(); + ASSERT_TRUE(restored.destination.has_value()); + EXPECT_TRUE(restored.isValid()); + EXPECT_EQ(restored.destination->mode, DestinationMode::FitH); + EXPECT_EQ(restored.destination->top, std::optional(0)); + EXPECT_FALSE(restored.destination->left.has_value()); +} + +TEST(NavigationView, xyzRetainsOmittedPositionAndScale) +{ + const auto target = destination(); + const auto result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 1.25); + EXPECT_EQ(result->focusRect, QRectF(31, 47, 0, 0)); + EXPECT_FALSE(target.left.has_value()); + EXPECT_FALSE(target.top.has_value()); + EXPECT_FALSE(target.zoom.has_value()); +} + +TEST(NavigationView, xyzDistinguishesZeroPositionFromMissingPosition) +{ + auto target = destination(); + target.left = 0; + target.zoom = 0; + auto result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 1.25); + EXPECT_EQ(result->focusRect, QRectF(0, 47, 0, 0)); + target.left.reset(); + target.top = 0; + result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(31, 0, 0, 0)); + target.left = 0; + target.zoom = 2; + result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 2); + EXPECT_EQ(result->focusRect, QRectF(0, 0, 0, 0)); +} + +TEST(NavigationView, xyzClampsLogicalPointToPageBounds) +{ + auto target = destination(); + target.left = -12; + target.top = 450; + const auto result = view(target, 270); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(0, 400, 0, 0)); + const auto retained = navigationView(destination(), QSizeF(200, 400), QSizeF(800, 600), + QPointF(500, -1), 1, 10, 0, false); + ASSERT_TRUE(retained.has_value()); + EXPECT_EQ(retained->focusRect, QRectF(200, 0, 0, 0)); +} + +TEST(NavigationView, fitUsesEntireLogicalPageAndRotatedBounds) +{ + for (int rotation : {0, 90, 180, 270, -90, 450}) { + const auto result = view(destination(DestinationMode::Fit), rotation); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, rotation % 180 == 0 ? 1.5 : 2.0); + EXPECT_EQ(result->focusRect, QRectF(0, 0, 200, 400)); + } +} + +TEST(NavigationView, fitHUsesRotatedWidthAndOptionalTop) +{ + auto target = destination(DestinationMode::FitH); + for (int rotation : {0, 90, 180, 270}) { + const auto result = view(target, rotation); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, rotation % 180 == 0 ? 4.0 : 2.0); + EXPECT_EQ(result->focusRect, QRectF(0, 47, 0, 0)); + } + target.top = 0; + target.left = 91; + auto result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(0, 0, 0, 0)); + target.top = 999; + result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(0, 400, 0, 0)); +} + +TEST(NavigationView, fitVUsesRotatedHeightAndOptionalLeft) +{ + auto target = destination(DestinationMode::FitV); + for (int rotation : {0, 90, 180, 270}) { + const auto result = view(target, rotation); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, rotation % 180 == 0 ? 1.5 : 3.0); + EXPECT_EQ(result->focusRect, QRectF(31, 0, 0, 0)); + } + target.left = 0; + target.top = 91; + auto result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(0, 0, 0, 0)); + target.left = -5; + result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->focusRect, QRectF(0, 0, 0, 0)); +} + +TEST(NavigationView, fitRUsesEntireLogicalRegionAndRotatedBounds) +{ + auto target = destination(DestinationMode::FitR); + target.left = 20; + target.top = 30; + target.right = 120; + target.bottom = 230; + for (int rotation : {0, 90, 180, 270}) { + const auto result = view(target, rotation); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, rotation % 180 == 0 ? 3.0 : 4.0); + EXPECT_EQ(result->focusRect, QRectF(20, 30, 100, 200)); + } + target.bottom.reset(); + EXPECT_FALSE(view(target).has_value()); + target.bottom = 30; + EXPECT_FALSE(view(target).has_value()); +} + +TEST(NavigationView, twoPagesAllocateHalfViewportWidth) +{ + auto result = view(destination(DestinationMode::Fit), 90, true); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 1); + result = view(destination(DestinationMode::FitH), 0, true); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 2); + result = view(destination(DestinationMode::FitV), 0, true); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 1.5); + auto target = destination(DestinationMode::FitR); + target.left = target.top = 0; + target.right = 200; + target.bottom = 100; + result = view(target, 0, true); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 2); + result = view(destination(), 0, true); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 1.25); +} + +TEST(NavigationView, clampsScalesToReaderLimits) +{ + auto target = destination(); + target.zoom = 0.001; + auto result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 0.1); + target.zoom = 100; + result = view(target); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 10); + result = navigationView(destination(DestinationMode::Fit), QSizeF(10000, 10000), + QSizeF(10, 10), QPointF(), 1, 10, 0, false); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 0.1); + result = navigationView(destination(DestinationMode::Fit), QSizeF(1, 1), + QSizeF(1000, 1000), QPointF(), 1, 2, 0, false); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 2); + result = navigationView(destination(), QSizeF(200, 400), QSizeF(800, 600), + QPointF(), 20, 0.1, 0, false); + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->scale, 0.1); +} + +TEST(NavigationView, rejectsInvalidPageAndViewportSizes) +{ + for (const auto &size : {QSizeF(), QSizeF(0, 1), QSizeF(1, 0), QSizeF(-1, 1), + QSizeF(1, -1), QSizeF(notANumber, 1), QSizeF(1, notANumber), + QSizeF(infinity, 1), QSizeF(1, infinity)}) { + EXPECT_FALSE(navigationView(destination(), size, QSizeF(800, 600), QPointF(), + 1, 10, 0, false).has_value()); + EXPECT_FALSE(navigationView(destination(), QSizeF(200, 400), size, QPointF(), + 1, 10, 0, false).has_value()); + } +} + +TEST(NavigationView, rejectsInvalidCurrentStateLimitsAndRotation) +{ + for (qreal value : {qreal(0), qreal(-1), notANumber, infinity}) { + EXPECT_FALSE(navigationView(destination(), QSizeF(200, 400), QSizeF(800, 600), + QPointF(), value, 10, 0, false).has_value()); + } + for (qreal value : {qreal(0.09), qreal(0), qreal(-1), notANumber, infinity}) { + EXPECT_FALSE(navigationView(destination(), QSizeF(200, 400), QSizeF(800, 600), + QPointF(), 1, value, 0, false).has_value()); + } + for (const auto &point : {QPointF(notANumber, 0), QPointF(0, notANumber), + QPointF(infinity, 0), QPointF(0, infinity)}) { + EXPECT_FALSE(navigationView(destination(), QSizeF(200, 400), QSizeF(800, 600), + point, 1, 10, 0, false).has_value()); + } + EXPECT_FALSE(view(destination(), 45).has_value()); + EXPECT_FALSE(view(destination(), -1).has_value()); + EXPECT_FALSE(view(NavigationDestination()).has_value()); + EXPECT_FALSE(view(destination(static_cast(999))).has_value()); +} + +TEST(NavigationUri, acceptsAllowedAbsoluteTargets) +{ + for (const auto &uri : {QStringLiteral("http://example.org/"), + QStringLiteral("https://example.org:443/a%20b?q=x#section"), + QStringLiteral("mailto:reader@example.org"), + QStringLiteral("mailto:reader@example.org?subject=Document")}) { + const QUrl result = resolveNavigationUri(uri); + EXPECT_EQ(result, QUrl(uri, QUrl::StrictMode)); + NavigationTarget target; + target.uri = result; + EXPECT_TRUE(target.isValid()); + } +} + +TEST(NavigationUri, resolvesRelativeTargetsOnlyWithUsableExplicitBase) +{ + EXPECT_EQ(resolveNavigationUri("../next.html#heading", "https://example.org/docs/book/"), + QUrl("https://example.org/docs/next.html#heading")); + EXPECT_EQ(resolveNavigationUri("/page", "http://example.org/docs/book"), + QUrl("http://example.org/page")); + EXPECT_EQ(resolveNavigationUri("#part", "https://example.org/document.html"), + QUrl("https://example.org/document.html#part")); + EXPECT_EQ(resolveNavigationUri("//cdn.example.org/image", "https://example.org/"), + QUrl("https://cdn.example.org/image")); + EXPECT_TRUE(resolveNavigationUri("next.html").isEmpty()); + EXPECT_TRUE(resolveNavigationUri("next.html", "docs/").isEmpty()); + EXPECT_TRUE(resolveNavigationUri("next.html", "file:///tmp/docs/").isEmpty()); + EXPECT_TRUE(resolveNavigationUri("next.html", "mailto:user@example.org").isEmpty()); + EXPECT_TRUE(resolveNavigationUri("next.html", "https://").isEmpty()); +} + +TEST(NavigationUri, rejectsUnsafeMalformedAndEmptyTargets) +{ + for (const auto &uri : {QString(), QStringLiteral("file:///tmp/document.ofd"), + QStringLiteral("javascript:alert(1)"), QStringLiteral("data:text/plain,hi"), + QStringLiteral("ftp://example.org/file"), QStringLiteral("custom:target"), + QStringLiteral("https://"), QStringLiteral("https:example.org"), + QStringLiteral("https:///path"), QStringLiteral("http://[bad]"), + QStringLiteral("http://example.org/%zz"), QStringLiteral("http://exa mple.org/"), + QStringLiteral("https://example.org/a b"), QStringLiteral("\nhttps://example.org/"), + QStringLiteral("mailto:"), QStringLiteral("mailto:?subject=Document"), + QStringLiteral("mailto:%20")}) { + EXPECT_TRUE(resolveNavigationUri(uri).isEmpty()) << uri.toStdString(); + EXPECT_TRUE(resolveNavigationUri(uri, "https://example.org/docs/").isEmpty()) + << uri.toStdString(); + } +} + +TEST(NavigationCompatibility, preservesLegacyLinkDefaultsAndValidity) +{ + const Link empty; + EXPECT_EQ(empty.page, -1); + EXPECT_DOUBLE_EQ(empty.left, 0); + EXPECT_DOUBLE_EQ(empty.top, 0); + EXPECT_TRUE(empty.urlOrFileName.isEmpty()); + EXPECT_FALSE(empty.navigation.has_value()); + EXPECT_FALSE(empty.isValid()); + EXPECT_FALSE(Link(QRectF(1, 2, 3, 4), 0).isValid()); + const Link page(QRectF(1, 2, 3, 4), 1); + EXPECT_TRUE(page.isValid()); + EXPECT_FALSE(page.navigation.has_value()); + EXPECT_TRUE(Link(QPainterPath(), QStringLiteral("legacy-local.pdf")).isValid()); + const Section section; + EXPECT_EQ(section.nIndex, -1); + EXPECT_EQ(section.offsetPointF, QPointF()); + EXPECT_FALSE(section.navigation.has_value()); + EXPECT_FALSE(section.expanded.has_value()); +} + +TEST(NavigationCompatibility, typedTargetTakesExclusivePrecedenceOverLegacyFields) +{ + Link link(QPainterPath(), QStringLiteral("https://legacy.example.org")); + link.page = 1; + link.navigation = NavigationTarget(); + EXPECT_FALSE(link.isValid()); + link.navigation->uri = QUrl(QStringLiteral("file:///tmp/document.ofd")); + EXPECT_FALSE(link.isValid()); + link.navigation->uri.clear(); + link.navigation->destination = destination(); + link.page = -1; + link.urlOrFileName.clear(); + EXPECT_TRUE(link.isValid()); + link.navigation.reset(); + EXPECT_FALSE(link.isValid()); +} diff --git a/tests/ofd-model/CMakeLists.txt b/tests/ofd-model/CMakeLists.txt index fa68f75f3..aacb7c800 100644 --- a/tests/ofd-model/CMakeLists.txt +++ b/tests/ofd-model/CMakeLists.txt @@ -24,6 +24,9 @@ endif() get_filename_component(READER_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) add_executable(ofd-model-check main.cc ../document/ut_ofdmodel.cpp + ../document/ut_navigation.cpp + ${READER_ROOT}/reader/document/Navigation.cpp + ${READER_ROOT}/reader/document/Navigation.h ${READER_ROOT}/reader/document/OfdModel.cpp ${READER_ROOT}/reader/document/OfdModel.h ${READER_ROOT}/reader/document/Model.h) From 050eeb21dfe88fb62bd36d666aedebbbdf48c3db Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 10:10:41 +0800 Subject: [PATCH 13/19] feat(ofd): adapt outline trees and navigation destinations --- CMakeLists.txt | 15 +- debian/control | 2 +- reader/document/OfdModel.cpp | 171 ++++++++++++++++++++++ reader/document/OfdModel.h | 13 ++ tests/document/ut_ofdmodel.cpp | 254 ++++++++++++++++++++++++++++++++- 5 files changed, 446 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0408ed649..16b7cd959 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,8 +94,8 @@ if (OFD_SUPPORT) pkg_check_modules(OFD_CAIRO QUIET cairo) if (ROFD_INCLUDE_DIR AND ROFD_FFI_LIBRARY AND OFD_CAIRO_FOUND) - # These APIs were added after 0.3.0 without a version bump. Probe both - # declarations and linked symbols instead of trusting the package version. + # Require the reader APIs shipped in 0.4.0. Probe declarations and linked + # symbols as well, so mismatched headers and libraries fail at configure. include(CheckCSourceCompiles) include(CMakePushCheckState) cmake_push_check_state(RESET) @@ -108,6 +108,8 @@ if (OFD_SUPPORT) rofd_pixel_rect_t viewport; rofd_metadata_t *metadata = 0; rofd_warning_list_t *warnings = 0; + rofd_outline_t *outline = 0; + rofd_destination_t destination = {0}; int32_t w = 0, h = 0; rofd_pixel_rect_init(&viewport, sizeof(viewport)); rofd_renderer_get_pixel_canvas_size(0, 0, 0, &w, &h, 0); @@ -117,12 +119,19 @@ if (OFD_SUPPORT) rofd_metadata_free(metadata); rofd_document_get_warnings(0, &warnings, 0); rofd_warning_list_free(warnings); + (void)&rofd_document_get_outline; + (void)&rofd_outline_get_action_destination; + (void)&rofd_outline_free; + rofd_document_get_outline(0, &outline, 0); + destination.struct_size = sizeof(destination); + rofd_outline_get_action_destination(outline, 0, 0, &destination, 0); + rofd_outline_free(outline); return 0; } ]=] ROFD_READER_APIS_AVAILABLE) cmake_pop_check_state() if (NOT ROFD_READER_APIS_AVAILABLE) - message(FATAL_ERROR "rofd headers/library lack the region, metadata or warning APIs. Use a matching rofd build containing commits 56da925 and 6db9bac (0.3.0 alone is insufficient), and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") + message(FATAL_ERROR "rofd headers/library lack the required region, metadata, warning or outline APIs. Install matching rofd >= 0.4.0 headers and library, and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") endif() message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") add_compile_definitions(OFD_SUPPORT_ENABLED) diff --git a/debian/control b/debian/control index 40c6e0ead..9b5da795e 100644 --- a/debian/control +++ b/debian/control @@ -18,7 +18,7 @@ Build-Depends: libdtk6core-dev [!mipsel !mips64el] | libdtkcore-dev, libgxps-dev, libcairo2-dev, - librofd-ffi-dev (>= 0.3.0), + librofd-ffi-dev (>= 0.4.0), libglib2.0-dev, libdjvulibre-dev, libtiff-dev, diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 3451230f8..4dfa76736 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -14,6 +14,8 @@ #include #include +#include +#include #include #include @@ -169,6 +171,175 @@ bool OfdDocument::saveAs(const QString &filePath) const return true; } +Outline OfdDocument::outline() const +{ + QMutexLocker lock(&m_outlineMutex); + if (m_outlineLoaded) + return m_outline; + // Empty and failed snapshots are cached as well as populated ones. + m_outlineLoaded = true; + m_outline = [this]() -> Outline { + rofd_outline_t *raw = nullptr; + rofd_error_t *error = nullptr; + const rofd_status_t status = rofd_document_get_outline(m_document, &raw, &error); + const std::unique_ptr snapshot(raw, rofd_outline_free); + if (status != ROFD_STATUS_OK || !snapshot) { + logSemanticError("Outline loading", -1, status, error); + return {}; + } + rofd_error_free(error); + size_t count = 0; + if (rofd_outline_get_count(raw, &count, nullptr) != ROFD_STATUS_OK + || count > static_cast(std::numeric_limits::max())) { + qCWarning(appLog) << "Invalid OFD outline node count"; + return {}; + } + + Outline nodes(static_cast(count)); + QVector parents(static_cast(count), -1); + for (int i = 0; i < nodes.size(); ++i) { + rofd_outline_node_t node = {}; + node.struct_size = sizeof(node); + if (rofd_outline_get_node(raw, static_cast(i), &node, nullptr) != ROFD_STATUS_OK) + continue; + Section §ion = nodes[i]; + section.title = QString::fromUtf8(node.title ? node.title : ""); + section.expanded = node.expanded != 0; + // A preorder parent must precede its child. Invalid relations become + // inert roots, retaining the node and any valid descendants. + if (node.parent != ROFD_NO_INDEX) { + if (node.parent >= static_cast(i)) + continue; + parents[i] = static_cast(node.parent); + } + for (size_t actionIndex = 0; actionIndex < node.action_count; ++actionIndex) { + rofd_action_t action = {}; + action.struct_size = sizeof(action); + if (rofd_outline_get_action(raw, static_cast(i), actionIndex, &action, nullptr) != ROFD_STATUS_OK) + continue; + rofd_destination_t destination = {}; + destination.struct_size = sizeof(destination); + const rofd_destination_t *target = nullptr; + if (action.event == ROFD_ACTION_EVENT_CLICK && action.kind == ROFD_ACTION_GOTO + && rofd_outline_get_action_destination(raw, static_cast(i), actionIndex, + &destination, nullptr) == ROFD_STATUS_OK) { + target = &destination; + } + section.navigation = navigationTarget(action, target); + if (!section.navigation) + continue; + if (section.navigation->destination) { + const NavigationDestination &value = *section.navigation->destination; + section.nIndex = value.pageIndex; + section.offsetPointF = QPointF(value.left.value_or(0), value.top.value_or(0)); + } + break; + } + } + + // Each child is complete before its parent. Reverse each collected group + // once to restore the original preorder without recursive traversal or + // repeated insertion at the front of a wide sibling list. + Outline roots; + for (int i = nodes.size(); i-- > 0;) { + std::reverse(nodes[i].children.begin(), nodes[i].children.end()); + if (parents[i] >= 0) + nodes[parents[i]].children.append(std::move(nodes[i])); + else + roots.append(std::move(nodes[i])); + } + std::reverse(roots.begin(), roots.end()); + return roots; + }(); + const Outline result = m_outline; + lock.unlock(); + warningDetails(); + return result; +} + +std::optional OfdDocument::navigationTarget(const rofd_action_t &action, + const rofd_destination_t *destination) const +{ + if (action.event != ROFD_ACTION_EVENT_CLICK) + return std::nullopt; + if (action.kind == ROFD_ACTION_URI) { + NavigationTarget target; + target.uri = resolveNavigationUri(QString::fromUtf8(action.uri ? action.uri : ""), + QString::fromUtf8(action.uri_base ? action.uri_base : "")); + return target.isValid() ? std::make_optional(target) : std::nullopt; + } + if (action.kind != ROFD_ACTION_GOTO || !destination + || !(destination->flags & ROFD_DESTINATION_HAS_PAGE_INDEX) + || destination->page_index >= static_cast(m_pageCount) + || destination->page_index > static_cast(std::numeric_limits::max())) { + return std::nullopt; + } + + NavigationDestination value; + value.pageIndex = static_cast(destination->page_index); + switch (destination->kind) { + case ROFD_DESTINATION_XYZ: value.mode = DestinationMode::XYZ; break; + case ROFD_DESTINATION_FIT: value.mode = DestinationMode::Fit; break; + case ROFD_DESTINATION_FIT_H: value.mode = DestinationMode::FitH; break; + case ROFD_DESTINATION_FIT_V: value.mode = DestinationMode::FitV; break; + case ROFD_DESTINATION_FIT_R: value.mode = DestinationMode::FitR; break; + default: return std::nullopt; + } + const auto pageRect = navigationPageRect(value.pageIndex); + if (!pageRect) + return std::nullopt; + if (destination->flags & ROFD_DESTINATION_HAS_LEFT) + value.left = (destination->left_mm - pageRect->x_mm) * m_xRes / kMillimetresPerInch; + if (destination->flags & ROFD_DESTINATION_HAS_TOP) + value.top = (destination->top_mm - pageRect->y_mm) * m_yRes / kMillimetresPerInch; + if (destination->flags & ROFD_DESTINATION_HAS_RIGHT) + value.right = (destination->right_mm - pageRect->x_mm) * m_xRes / kMillimetresPerInch; + if (destination->flags & ROFD_DESTINATION_HAS_BOTTOM) + value.bottom = (destination->bottom_mm - pageRect->y_mm) * m_yRes / kMillimetresPerInch; + if (destination->flags & ROFD_DESTINATION_HAS_ZOOM) + value.zoom = destination->zoom; + if (!value.isValid()) + return std::nullopt; + NavigationTarget target; + target.destination = value; + return target; +} + +std::optional OfdDocument::navigationPageRect(int pageIndex) const +{ + QMutexLocker lock(&m_navigationGeometryMutex); + const auto cached = m_navigationPageRects.constFind(pageIndex); + if (cached != m_navigationPageRects.cend()) + return cached.value(); + + // Query a temporary rofd page directly: constructing OfdPage here would + // couple navigation conversion to page-link loading and risk recursion. + rofd_page_t *raw = nullptr; + rofd_error_t *error = nullptr; + rofd_status_t status = rofd_document_get_page(m_document, static_cast(pageIndex), &raw, &error); + const std::unique_ptr page(raw, rofd_page_free); + rofd_rect_t rect = {}; + if (status == ROFD_STATUS_OK && page) { + rofd_error_free(error); + error = nullptr; + status = rofd_page_get_size_mm(raw, &rect, &error); + } + std::optional result; + if (status == ROFD_STATUS_OK && page && std::isfinite(rect.x_mm) && std::isfinite(rect.y_mm) + && std::isfinite(rect.width_mm) && rect.width_mm > 0 + && std::isfinite(rect.height_mm) && rect.height_mm > 0) { + result = rect; + rofd_error_free(error); + } else { + logSemanticError("Navigation page geometry", pageIndex, status, error); + } + m_navigationPageRects.insert(pageIndex, result); + lock.unlock(); + // No warning-mutex holder acquires either navigation cache mutex. + warningDetails(); + return result; +} + Properties OfdDocument::properties() const { Properties props = m_properties; diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h index b9d232023..227b79304 100644 --- a/reader/document/OfdModel.h +++ b/reader/document/OfdModel.h @@ -33,6 +33,7 @@ class OfdDocument : public Document QStringList saveFilter() const override; bool save() const override; bool saveAs(const QString &filePath) const override; + Outline outline() const override; Properties properties() const override; QString fileIdentifier() const override; @@ -44,9 +45,16 @@ class OfdDocument : public Document QImage renderPage(rofd_page_t *pageHandle, int width, int height, const QRect &slice) const; private: + friend class OfdPage; + OfdDocument(const QString &filePath, rofd_document_t *document, rofd_renderer_t *renderer); void loadMetadata(); QVariantList warningDetails() const; + // Shared by outline and page-link snapshots. All borrowed action strings + // are copied; GOTO callers supply their snapshot's destination record. + std::optional navigationTarget(const rofd_action_t &action, + const rofd_destination_t *destination = nullptr) const; + std::optional navigationPageRect(int pageIndex) const; QString m_filePath; rofd_document_t *m_document = nullptr; @@ -54,6 +62,11 @@ class OfdDocument : public Document Properties m_properties; mutable QMutex m_warningMutex; mutable size_t m_loggedWarningCount = 0; + mutable QMutex m_outlineMutex; + mutable bool m_outlineLoaded = false; + mutable Outline m_outline; + mutable QMutex m_navigationGeometryMutex; + mutable QMap> m_navigationPageRects; int m_pageCount = 0; qreal m_xRes = 96.0; qreal m_yRes = 96.0; diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index 4f90c8623..2b57eaa71 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -16,6 +16,7 @@ #include #include +#include #include using namespace deepin_reader; @@ -39,19 +40,24 @@ bool hasOfdFile() QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, const QByteArray &pageArea = QByteArray(), - const QByteArray &documentExtras = QByteArray()) + const QByteArray &documentExtras = QByteArray(), + const QByteArray &secondPage = QByteArray()) { - const QMap entries = { + QMap entries = { {"OFD.xml", "" + info + "Document.xml"}, {"Document.xml", "7 11 210 297" - "" + "" + + (secondPage.isEmpty() ? QByteArray() : QByteArray("")) + + "" + documentExtras + ""}, {"Page.xml", "" + pageArea + "" "M 0 0 L 40 0 L 40 25 L 0 25 C" ""} }; + if (!secondPage.isEmpty()) + entries.insert(QStringLiteral("Second.xml"), secondPage); for (auto it = entries.cbegin(); it != entries.cend(); ++it) { QFile file(dir.filePath(it.key())); if (!file.open(QIODevice::WriteOnly) || file.write(it.value()) != it.value().size()) @@ -59,13 +65,23 @@ QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, } QProcess archive; archive.setWorkingDirectory(dir.path()); - archive.start(QStringLiteral("cmake"), {"-E", "tar", "cf", "fixture.ofd", "--format=zip", - "OFD.xml", "Document.xml", "Page.xml"}); + QStringList arguments = {"-E", "tar", "cf", "fixture.ofd", "--format=zip"}; + arguments.append(entries.keys()); + archive.start(QStringLiteral("cmake"), arguments); if (!archive.waitForFinished() || archive.exitCode() != 0) return {}; return dir.filePath("fixture.ofd"); } +const QByteArray explicitPageArea = "7 11 210 297"; +const QByteArray offsetSecondPage = "3 5 100 120"; + +QByteArray outlineNode(const QByteArray &title, const QByteArray &action) +{ + return "" + + action + ""; +} + } // namespace class TestOfdModel : public ::testing::Test @@ -88,6 +104,234 @@ class TestOfdModel : public ::testing::Test std::unique_ptr m_doc; }; +TEST(OfdApi, outlineKeepsNestedParentsAndCopiesNavigation) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea, R"xml( + + + + + + + + + + + + + + + + )xml", offsetSecondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const Outline outline = doc->outline(); + ASSERT_EQ(outline.size(), 1); + const Section &root = outline.first(); + EXPECT_EQ(root.title, QStringLiteral("目录")); + EXPECT_EQ(root.nIndex, -1); + EXPECT_FALSE(root.navigation.has_value()); + ASSERT_TRUE(root.expanded.has_value()); + EXPECT_FALSE(*root.expanded); + ASSERT_EQ(root.children.size(), 2); + EXPECT_EQ(root.children.last().title, QStringLiteral("末尾")); + const Section &parent = root.children.first(); + EXPECT_EQ(parent.nIndex, -1); + EXPECT_FALSE(parent.navigation.has_value()); + ASSERT_TRUE(parent.expanded.has_value()); + EXPECT_TRUE(*parent.expanded); + ASSERT_EQ(parent.children.size(), 1); + const Section &bookmark = parent.children.first(); + ASSERT_TRUE(bookmark.navigation.has_value()); + ASSERT_TRUE(bookmark.navigation->destination.has_value()); + const NavigationDestination &destination = *bookmark.navigation->destination; + EXPECT_EQ(destination.pageIndex, 1); + EXPECT_EQ(bookmark.nIndex, 1); + EXPECT_EQ(destination.mode, DestinationMode::XYZ); + ASSERT_TRUE(destination.left.has_value()); + ASSERT_TRUE(destination.top.has_value()); + EXPECT_DOUBLE_EQ(*destination.left, (13.0 - 3.0) * doc->xRes() / 25.4); + EXPECT_DOUBLE_EQ(*destination.top, (25.0 - 5.0) * doc->yRes() / 25.4); + ASSERT_TRUE(destination.zoom.has_value()); + EXPECT_DOUBLE_EQ(*destination.zoom, 0.0); + ASSERT_EQ(bookmark.children.size(), 1); + const Section &uri = bookmark.children.first(); + ASSERT_TRUE(uri.navigation.has_value()); + EXPECT_EQ(uri.nIndex, -1); + EXPECT_EQ(uri.navigation->uri, QUrl("https://example.invalid/base/child?q=1&x=2")); + const QVariantList warnings = doc->properties().value("Warnings").toList(); + EXPECT_FALSE(warnings.isEmpty()); + EXPECT_EQ(doc->outline().first().children.first().children.first().title, bookmark.title); + EXPECT_EQ(doc->properties().value("Warnings").toList(), warnings); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + EXPECT_FALSE(page->render(210, 297).isNull()); + page.reset(); + doc.reset(); + EXPECT_EQ(root.title, QStringLiteral("目录")); + EXPECT_EQ(uri.navigation->uri.host(), QStringLiteral("example.invalid")); +} + +TEST(OfdApi, outlineDestinationModesKeepPresenceAndTargetOrigin) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + QByteArray nodes; + const auto destNode = [](const QByteArray &mode, const QByteArray &fields) { + return outlineNode(mode, ""); + }; + nodes += destNode("XYZ", ""); + nodes += destNode("XYZ", "Left=\"0\" Top=\"0\" Zoom=\"0\""); + nodes += destNode("Fit", ""); + nodes += destNode("FitH", "Top=\"15\""); + nodes += destNode("FitV", "Left=\"13\""); + nodes += destNode("FitR", "Left=\"13\" Top=\"15\" Right=\"23\" Bottom=\"35\""); + const QString path = createOfdFixture(dir, {}, explicitPageArea, + "" + nodes + "", offsetSecondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const Outline outline = doc->outline(); + ASSERT_EQ(outline.size(), 6); + const DestinationMode modes[] = {DestinationMode::XYZ, DestinationMode::XYZ, DestinationMode::Fit, + DestinationMode::FitH, DestinationMode::FitV, DestinationMode::FitR}; + for (int i = 0; i < outline.size(); ++i) { + ASSERT_TRUE(outline[i].navigation.has_value()); + ASSERT_TRUE(outline[i].navigation->destination.has_value()); + EXPECT_EQ(outline[i].navigation->destination->mode, modes[i]); + EXPECT_EQ(outline[i].navigation->destination->pageIndex, 1); + EXPECT_TRUE(outline[i].navigation->isValid()); + } + const auto &absent = *outline[0].navigation->destination; + EXPECT_FALSE(absent.left.has_value()); + EXPECT_FALSE(absent.top.has_value()); + EXPECT_FALSE(absent.zoom.has_value()); + const auto &zero = *outline[1].navigation->destination; + ASSERT_TRUE(zero.left.has_value()); + ASSERT_TRUE(zero.top.has_value()); + ASSERT_TRUE(zero.zoom.has_value()); + EXPECT_DOUBLE_EQ(*zero.left, -3.0 * doc->xRes() / 25.4); + EXPECT_DOUBLE_EQ(*zero.top, -5.0 * doc->yRes() / 25.4); + EXPECT_DOUBLE_EQ(*zero.zoom, 0.0); + const auto &fitH = *outline[3].navigation->destination; + ASSERT_TRUE(fitH.top.has_value()); + EXPECT_DOUBLE_EQ(*fitH.top, 10.0 * doc->yRes() / 25.4); + EXPECT_FALSE(fitH.left.has_value()); + const auto &fitV = *outline[4].navigation->destination; + ASSERT_TRUE(fitV.left.has_value()); + EXPECT_DOUBLE_EQ(*fitV.left, 10.0 * doc->xRes() / 25.4); + EXPECT_FALSE(fitV.top.has_value()); + const auto &fitR = *outline[5].navigation->destination; + ASSERT_TRUE(fitR.right.has_value()); + ASSERT_TRUE(fitR.bottom.has_value()); + EXPECT_DOUBLE_EQ(*fitR.right, 20.0 * doc->xRes() / 25.4); + EXPECT_DOUBLE_EQ(*fitR.bottom, 30.0 * doc->yRes() / 25.4); +} + +TEST(OfdApi, outlineUnsafeAndInvalidActionsKeepNodesNonExecuting) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + QByteArray nodes; + const QList actions = { + "", + "", + "", + "", + "", + "", + "", + "", + "", "" + }; + for (const QByteArray &action : actions) + nodes += outlineNode("kept", action); + nodes += "" + "" + "" + ""; + const QString path = createOfdFixture(dir, {}, explicitPageArea, + "" + nodes + "", offsetSecondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const Outline outline = doc->outline(); + ASSERT_EQ(outline.size(), actions.size() + 1); + for (const Section §ion : outline) { + EXPECT_EQ(section.nIndex, -1); + EXPECT_FALSE(section.navigation.has_value()); + } + ASSERT_EQ(outline.last().children.size(), 1); + EXPECT_EQ(outline.last().children.first().title, QStringLiteral("child")); +} + +TEST(OfdApi, outlineGeometryQueriesRefreshWarningsAndKeepGoodPagesUsable) +{ + for (const QByteArray &secondPage : {QByteArray(""), QByteArray("broken XML")}) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea, + "" + outlineNode("target", "") + + "", secondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + const QVariantList before = doc->properties().value("Warnings").toList(); + EXPECT_TRUE(before.isEmpty()); + const Outline outline = doc->outline(); + ASSERT_EQ(outline.size(), 1); + if (secondPage.startsWith("")) { + ASSERT_TRUE(outline.first().navigation.has_value()); + ASSERT_TRUE(outline.first().navigation->destination->left.has_value()); + EXPECT_DOUBLE_EQ(*outline.first().navigation->destination->left, 10.0 * doc->xRes() / 25.4); + const QVariantList warnings = doc->properties().value("Warnings").toList(); + ASSERT_EQ(warnings.size(), 1); + EXPECT_EQ(warnings.first().toMap().value("Code").toUInt(), ROFD_WARNING_PAGE_AREA_FALLBACK); + EXPECT_EQ(warnings.first().toMap().value("Path").toString(), QStringLiteral("Second.xml")); + } else { + EXPECT_FALSE(outline.first().navigation.has_value()); + EXPECT_EQ(outline.first().nIndex, -1); + } + std::unique_ptr good(doc->page(0)); + ASSERT_NE(good, nullptr); + EXPECT_FALSE(good->render(210, 297).isNull()); + EXPECT_EQ(doc->outline().size(), 1); + } +} + +TEST(OfdApi, outlineConcurrentAndEmptyOrFailedSnapshotsStayStable) +{ + const QByteArray deepOutline = "" + QByteArray("").repeated(66) + + QByteArray("").repeated(66) + ""; + for (const QByteArray &extras : {QByteArray(), deepOutline, + QByteArray("")}) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea, extras); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::vector> queries; + for (int i = 0; i < 8; ++i) + queries.push_back(std::async(std::launch::async, [&doc] { return doc->outline(); })); + const int expected = extras.contains("Title=\"root\"") ? 1 : 0; + for (auto &query : queries) + EXPECT_EQ(query.get().size(), expected); + EXPECT_EQ(doc->outline().size(), expected); + std::unique_ptr good(doc->page(0)); + ASSERT_NE(good, nullptr); + EXPECT_FALSE(good->render(210, 297).isNull()); + } +} + TEST_F(TestOfdModel, loadDocument) { EXPECT_GT(m_doc->pageCount(), 0); From 60ece14bb65b95b14e53f447682a2c9eb944c2d3 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 10:15:02 +0800 Subject: [PATCH 14/19] feat(reader): expose full OFD catalogs and typed navigation --- reader/browser/SheetBrowser.cpp | 54 ++++++ reader/browser/SheetBrowser.h | 1 + reader/sidebar/CatalogOutlineModel.cpp | 73 ++++++++ reader/sidebar/CatalogOutlineModel.h | 22 +++ reader/sidebar/CatalogTreeView.cpp | 91 +++++----- reader/sidebar/CatalogTreeView.h | 3 + reader/uiframe/DocSheet.cpp | 7 +- reader/uiframe/DocSheet.h | 2 + tests/document/ut_catalogoutlinemodel.cpp | 136 ++++++++++++++ tests/ofd-model/CMakeLists.txt | 4 +- tests/ofd-model/navigation_smoke.cc | 210 ++++++++++++++++++++++ tests/ofd-model/run_navigation_smoke.py | 59 ++++++ 12 files changed, 610 insertions(+), 52 deletions(-) create mode 100644 reader/sidebar/CatalogOutlineModel.cpp create mode 100644 reader/sidebar/CatalogOutlineModel.h create mode 100644 tests/document/ut_catalogoutlinemodel.cpp create mode 100644 tests/ofd-model/navigation_smoke.cc create mode 100644 tests/ofd-model/run_navigation_smoke.py diff --git a/reader/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index bb764ba03..1ddcfc500 100644 --- a/reader/browser/SheetBrowser.cpp +++ b/reader/browser/SheetBrowser.cpp @@ -44,6 +44,9 @@ #include #include #include +#include + +#include DWIDGET_USE_NAMESPACE @@ -735,6 +738,57 @@ void SheetBrowser::jumpToOutline(const qreal &linkLeft, const qreal &linkTop, in qCDebug(appLog) << "SheetBrowser::jumpToOutline() - Jump to outline completed"; } +bool SheetBrowser::navigateTo(const NavigationTarget &target) +{ + if (!m_sheet || !target.isValid()) + return false; + + if (!target.destination) { + const QUrl uri = resolveNavigationUri(target.uri.toString(QUrl::FullyEncoded)); + if (uri.isEmpty()) + return false; + SecurityDialog dialog(uri.toString(QUrl::FullyEncoded), this); + if (dialog.exec() == DDialog::Accepted) + QDesktopServices::openUrl(uri); + return true; + } + + const NavigationDestination &destination = *target.destination; + if (destination.pageIndex >= m_items.size() || !m_items.at(destination.pageIndex)) + return false; + + const SheetOperation &operation = m_sheet->operation(); + QPointF currentPosition; + const int currentIndex = currentPage() - 1; + if (operation.scaleFactor > 0 && currentIndex >= 0 && currentIndex < m_items.size()) + currentPosition = m_items.at(currentIndex)->mapFromScene(mapToScene(QPoint(0, 0))) + / operation.scaleFactor; + + const auto view = navigationView(destination, m_sheet->renderer()->getPageSize(destination.pageIndex), + QSizeF(viewport()->size()), currentPosition, operation.scaleFactor, + m_sheet->maxScaleFactor(), int(operation.rotation) * 90, + operation.layoutMode == Dr::TwoPagesMode); + if (!view) + return false; + + { + QScopedValueRollback suppressPageChanges(m_bNeedNotifyCurPageChanged, false); + m_sheet->setScaleFactor(view->scale); + const qreal scale = m_sheet->operation().scaleFactor; + const QRectF scaledFocus(view->focusRect.topLeft() * scale, view->focusRect.size() * scale); + const QPointF scenePoint = m_items.at(destination.pageIndex)->mapRectToScene(scaledFocus).topLeft(); + if (!std::isfinite(scenePoint.x()) || !std::isfinite(scenePoint.y())) + return false; + // Bound before converting: document coordinates need not fit in an int. + horizontalScrollBar()->setValue(qRound(qBound(qreal(horizontalScrollBar()->minimum()), + scenePoint.x(), qreal(horizontalScrollBar()->maximum())))); + verticalScrollBar()->setValue(qRound(qBound(qreal(verticalScrollBar()->minimum()), + scenePoint.y(), qreal(verticalScrollBar()->maximum())))); + } + curpageChanged(destination.pageIndex + 1); + return true; +} + void SheetBrowser::jumpToHighLight(deepin_reader::Annotation *annotation, const int index) { qCDebug(appLog) << "SheetBrowser::jumpToHighLight() - Starting jump to high light"; diff --git a/reader/browser/SheetBrowser.h b/reader/browser/SheetBrowser.h index f8de05115..f50698443 100644 --- a/reader/browser/SheetBrowser.h +++ b/reader/browser/SheetBrowser.h @@ -203,6 +203,7 @@ class SheetBrowser : public Dtk::Widget::DGraphicsView * @param index 哪一页 */ void jumpToOutline(const qreal &left, const qreal &top, int page); + bool navigateTo(const deepin_reader::NavigationTarget &target); /** * @brief jumpToHighLight diff --git a/reader/sidebar/CatalogOutlineModel.cpp b/reader/sidebar/CatalogOutlineModel.cpp new file mode 100644 index 000000000..fced2cbc3 --- /dev/null +++ b/reader/sidebar/CatalogOutlineModel.cpp @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "CatalogOutlineModel.h" +#include +#include + +namespace deepin_reader { +void applyCatalogExpansion(QTreeView *view, const std::optional &saved) +{ + if (!view || !view->model()) + return; + const QSignalBlocker blocker(view); + QSet paths; + if (saved) + paths = QSet(saved->cbegin(), saved->cend()); + struct Pending { QModelIndex parent; QString path; }; + QVector pending{{QModelIndex(), QString()}}; + while (!pending.isEmpty()) { + const Pending current = pending.takeLast(); + for (int row = 0; row < view->model()->rowCount(current.parent); ++row) { + const QModelIndex index = view->model()->index(row, 0, current.parent); + const QString title = index.data().toString(); + const QString path = current.parent.isValid() ? current.path + QLatin1Char('/') + title : title; + const QVariant expanded = index.data(CatalogExpandedRole); + if (saved || expanded.isValid()) + view->setExpanded(index, saved ? paths.contains(path) : expanded.toBool()); + pending.append({index, path}); + } + } +} + +QList catalogRow(const Section §ion) +{ + int pageIndex = section.nIndex; + if (section.navigation) { + pageIndex = section.navigation->isValid() && section.navigation->destination + ? section.navigation->destination->pageIndex : -1; + } + auto *title = new QStandardItem(section.title); + auto *page = new QStandardItem(pageIndex >= 0 ? QString::number(qint64(pageIndex) + 1) : QString()); + const QList row{title, page}; + for (QStandardItem *item : row) { + item->setData(pageIndex, CatalogPageRole); + item->setData(section.offsetPointF.x(), CatalogLeftRole); + item->setData(section.offsetPointF.y(), CatalogTopRole); + if (section.navigation) + item->setData(QVariant::fromValue(*section.navigation), CatalogNavigationRole); + if (section.expanded) + item->setData(*section.expanded, CatalogExpandedRole); + } + title->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); + page->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + return row; +} + +void appendCatalogSections(QStandardItem *parent, const Outline &outline) +{ + if (!parent) + return; + struct Pending { QStandardItem *parent; const Section *section; }; + QVector pending; + for (auto it = outline.crbegin(); it != outline.crend(); ++it) + pending.append({parent, &*it}); + while (!pending.isEmpty()) { + const Pending current = pending.takeLast(); + const auto row = catalogRow(*current.section); + current.parent->appendRow(row); + const auto &children = current.section->children; + for (auto it = children.crbegin(); it != children.crend(); ++it) + pending.append({row.first(), &*it}); + } +} +} diff --git a/reader/sidebar/CatalogOutlineModel.h b/reader/sidebar/CatalogOutlineModel.h new file mode 100644 index 000000000..8987bd952 --- /dev/null +++ b/reader/sidebar/CatalogOutlineModel.h @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef CATALOGOUTLINEMODEL_H +#define CATALOGOUTLINEMODEL_H + +#include "Model.h" +#include +#include + +namespace deepin_reader { +enum CatalogRole { + CatalogPageRole = Qt::UserRole + 1, + CatalogLeftRole, + CatalogTopRole, + CatalogNavigationRole, + CatalogExpandedRole +}; +QList catalogRow(const Section §ion); +void appendCatalogSections(QStandardItem *parent, const Outline &outline); +void applyCatalogExpansion(QTreeView *view, const std::optional &saved = std::nullopt); +} +#endif diff --git a/reader/sidebar/CatalogTreeView.cpp b/reader/sidebar/CatalogTreeView.cpp index 9e8c5c6bd..71d4fe289 100644 --- a/reader/sidebar/CatalogTreeView.cpp +++ b/reader/sidebar/CatalogTreeView.cpp @@ -4,6 +4,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "CatalogTreeView.h" +#include "CatalogOutlineModel.h" #include "Application.h" #include "Utils.h" #include "DocSheet.h" @@ -19,6 +20,8 @@ #include #include #include +#include +#include class ActiveProxyStyle : public QProxyStyle { @@ -148,20 +151,7 @@ void CatalogTreeView::setRightControl(bool hasControl) void CatalogTreeView::parseCatalogData(const deepin_reader::Section &ol, QStandardItem *parentItem) { - qCDebug(appLog) << "Parsing catalog section:" << ol.title << "with" << ol.children.size() << "children"; - - foreach (auto s, ol.children) { // 2级显示 - if (s.nIndex >= 0) { - auto itemList = getItemList(s.title, s.nIndex, s.offsetPointF.x(), s.offsetPointF.y()); - parentItem->appendRow(itemList); - - foreach (auto s1, s.children) { // 3级显示 - auto itemList1 = getItemList(s1.title, s1.nIndex, s1.offsetPointF.x(), s1.offsetPointF.y()); - itemList.at(0)->appendRow(itemList1); - } - } - } - qCDebug(appLog) << "CatalogTreeView::parseCatalogData() - Completed"; + deepin_reader::appendCatalogSections(parentItem, ol.children); } QList CatalogTreeView::getItemList(const QString &title, const int &index, const qreal &realleft, const qreal &realtop) @@ -191,6 +181,9 @@ void CatalogTreeView::handleOpenSuccess() auto model = qobject_cast(this->model()); if (model) { + const QScopedValueRollback populating(m_populating, true); + const QSignalBlocker viewBlocker(this); + const QSignalBlocker selectionBlocker(selectionModel()); qCDebug(appLog) << "CatalogTreeView::handleOpenSuccess() - Clearing model"; model->clear(); @@ -201,13 +194,18 @@ void CatalogTreeView::handleOpenSuccess() m_index = m_sheet->currentIndex(); const deepin_reader::Outline &ol = m_sheet->outline(); - for (const deepin_reader::Section &s : ol) { //root - if (s.nIndex >= 0) { - auto itemList = getItemList(s.title, s.nIndex, s.offsetPointF.x(), s.offsetPointF.y()); - model->appendRow(itemList); - parseCatalogData(s, itemList.at(0)); - } + deepin_reader::appendCatalogSections(model->invisibleRootItem(), ol); + const QColor color = Dtk::Gui::DGuiApplicationHelper::instance()->applicationPalette().textTips().color(); + const auto items = model->findItems("*", Qt::MatchWildcard | Qt::MatchRecursive); + for (QStandardItem *item : items) { + QStandardItem *parent = item->parent() ? item->parent() : model->invisibleRootItem(); + if (QStandardItem *page = parent->child(item->row(), 1)) + page->setForeground(QBrush(color)); } + if (!m_pendingExpandedSections && m_sheet->hasRestoredViewState()) + m_pendingExpandedSections = m_sheet->operation().expandedSections; + deepin_reader::applyCatalogExpansion(this, m_pendingExpandedSections); + m_pendingExpandedSections.reset(); setIndex(m_index); } resizeCoulumnWidth(); @@ -220,6 +218,12 @@ QStringList CatalogTreeView::getExpandedSections() const auto model = qobject_cast(this->model()); if (!model) return result; + if (model->rowCount() == 0) { + if (m_pendingExpandedSections) + return *m_pendingExpandedSections; + if (m_sheet && m_sheet->hasRestoredViewState()) + return m_sheet->operation().expandedSections; + } // 递归遍历所有节点,收集展开状态的节点标题路径 const QList &itemList = model->findItems("*", Qt::MatchWildcard | Qt::MatchRecursive); @@ -246,39 +250,14 @@ QStringList CatalogTreeView::getExpandedSections() const void CatalogTreeView::restoreExpandedSections(const QStringList §ions) { - if (sections.isEmpty()) - return; - auto model = qobject_cast(this->model()); if (!model) return; - - qCDebug(appLog) << "CatalogTreeView::restoreExpandedSections() - Restoring" << sections.size() << "sections"; - - // 先折叠所有节点 - collapseAll(); - - // 遍历所有节点,匹配路径并展开 - const QList &itemList = model->findItems("*", Qt::MatchWildcard | Qt::MatchRecursive); - for (QStandardItem *item : itemList) { - QModelIndex idx = item->index(); - if (idx.column() != 0) - continue; - - // 构建当前节点的标题路径 - QStringList pathParts; - QStandardItem *cur = item; - while (cur) { - pathParts.prepend(cur->text()); - cur = cur->parent(); - } - QString path = pathParts.join("/"); - - if (sections.contains(path)) { - expand(idx); - qCDebug(appLog) << "CatalogTreeView::restoreExpandedSections() - Expanded:" << path; - } + if (model->rowCount() == 0) { + m_pendingExpandedSections = sections; + return; } + deepin_reader::applyCatalogExpansion(this, sections); } void CatalogTreeView::slotCollapsed(const QModelIndex &index) @@ -314,7 +293,8 @@ void CatalogTreeView::slotExpanded(const QModelIndex &index) void CatalogTreeView::currentChanged(const QModelIndex ¤t, const QModelIndex &previous) { Q_UNUSED(previous); - if (!rightnotifypagechanged) { + if (!m_populating && !rightnotifypagechanged + && !current.data(deepin_reader::CatalogNavigationRole).isValid()) { if (nullptr == m_sheet) { qCritical() << "Cannot navigate - document sheet is null"; @@ -342,6 +322,14 @@ void CatalogTreeView::onItemClicked(const QModelIndex ¤t) qCritical() << "Cannot navigate to clicked item - document sheet is null"; return; } + if (m_populating || !current.isValid()) + return; + const QVariant navigation = current.data(deepin_reader::CatalogNavigationRole); + if (navigation.isValid()) { + m_title = current.data(Qt::DisplayRole).toString(); + m_sheet->navigateTo(navigation.value()); + return; + } int nIndex = current.data(Qt::UserRole + 1).toInt(); double left = current.data(Qt::UserRole + 2).toDouble(); @@ -375,6 +363,9 @@ void CatalogTreeView::keyPressEvent(QKeyEvent *event) // qCDebug(appLog) << "CatalogTreeView::keyPressEvent() - Starting key press event"; rightnotifypagechanged = false; DTreeView::keyPressEvent(event); + if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) + && currentIndex().data(deepin_reader::CatalogNavigationRole).isValid()) + onItemClicked(currentIndex()); // qCDebug(appLog) << "CatalogTreeView::keyPressEvent() - Completed"; } diff --git a/reader/sidebar/CatalogTreeView.h b/reader/sidebar/CatalogTreeView.h index 7eacb9319..7b3c6e2fb 100644 --- a/reader/sidebar/CatalogTreeView.h +++ b/reader/sidebar/CatalogTreeView.h @@ -10,6 +10,7 @@ #include #include +#include DWIDGET_USE_NAMESPACE namespace deepin_reader { @@ -138,6 +139,8 @@ private slots: void onFontChanged(const QFont &font); private: + std::optional m_pendingExpandedSections; + bool m_populating = false; /** * @brief parseCatalogData * 解析文档目录数据 diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index a8f65d1ac..ab5162c06 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -90,7 +90,7 @@ DocSheet::DocSheet(const Dr::FileType &fileType, const QString &filePath, QWidg #endif #ifdef OFD_SUPPORT_ENABLED else if (Dr::OFD == fileType) - m_sidebar = new SheetSidebar(this, PREVIEW_THUMBNAIL | PREVIEW_BOOKMARK); + m_sidebar = new SheetSidebar(this, PREVIEW_THUMBNAIL | PREVIEW_CATALOG | PREVIEW_BOOKMARK); #endif else m_sidebar = new SheetSidebar(this); @@ -358,6 +358,11 @@ void DocSheet::jumpToOutline(const qreal &left, const qreal &top, int index) m_browser->jumpToOutline(left, top, index); } +bool DocSheet::navigateTo(const deepin_reader::NavigationTarget &target) +{ + return m_browser && m_browser->navigateTo(target); +} + void DocSheet::jumpToHighLight(deepin_reader::Annotation *annotation, const int index) { qCDebug(appLog) << "jumpToHighLight"; diff --git a/reader/uiframe/DocSheet.h b/reader/uiframe/DocSheet.h index 63a0c97a5..d9015e66c 100644 --- a/reader/uiframe/DocSheet.h +++ b/reader/uiframe/DocSheet.h @@ -233,6 +233,8 @@ class DocSheet : public Dtk::Widget::DSplitter * @return */ deepin_reader::Outline outline(); + bool navigateTo(const deepin_reader::NavigationTarget &target); + bool hasRestoredViewState() const { return m_restoredFromState; } /** * @brief jumpToOutline diff --git a/tests/document/ut_catalogoutlinemodel.cpp b/tests/document/ut_catalogoutlinemodel.cpp new file mode 100644 index 000000000..cdf529f34 --- /dev/null +++ b/tests/document/ut_catalogoutlinemodel.cpp @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "CatalogOutlineModel.h" +#include + +using namespace deepin_reader; + +TEST(CatalogOutlineModel, PreservesTargetlessParentsAndDeepChildren) +{ + Section leaf; + leaf.title = QStringLiteral("leaf"); + leaf.nIndex = 2; + Section root = leaf; + for (int i = 0; i < 8; ++i) { + Section parent; + parent.title = QStringLiteral("group %1").arg(i); + parent.children.append(root); + root = parent; + } + QStandardItemModel model; + appendCatalogSections(model.invisibleRootItem(), {root}); + ASSERT_EQ(model.rowCount(), 1); + QStandardItem *item = model.item(0); + EXPECT_EQ(model.item(0, 1)->text(), QString()); + EXPECT_EQ(item->data(CatalogPageRole).toInt(), -1); + for (int i = 0; i < 8; ++i) { + ASSERT_EQ(item->rowCount(), 1); + item = item->child(0); + } + EXPECT_EQ(item->text(), QStringLiteral("leaf")); + EXPECT_EQ(item->data(CatalogPageRole).toInt(), 2); +} + +TEST(CatalogOutlineModel, KeepsLegacyPageAndOffsetRolesInBothColumns) +{ + Section section; + section.title = QStringLiteral("chapter"); + section.nIndex = 3; + section.offsetPointF = QPointF(12, 34); + QStandardItemModel model; + appendCatalogSections(model.invisibleRootItem(), {section}); + ASSERT_EQ(model.rowCount(), 1); + ASSERT_EQ(model.columnCount(), 2); + EXPECT_EQ(model.item(0, 1)->text(), QStringLiteral("4")); + for (int c = 0; c < 2; ++c) { + EXPECT_EQ(model.item(0, c)->data(CatalogPageRole).toInt(), 3); + EXPECT_EQ(model.item(0, c)->data(CatalogLeftRole).toDouble(), 12); + EXPECT_EQ(model.item(0, c)->data(CatalogTopRole).toDouble(), 34); + } +} + +TEST(CatalogOutlineModel, PreservesSiblingOrderAndHandlesEmptyOutline) +{ + QStandardItemModel model; + appendCatalogSections(nullptr, {}); + appendCatalogSections(model.invisibleRootItem(), {}); + EXPECT_EQ(model.rowCount(), 0); + Section a, b; + a.title = QStringLiteral("A"); + b.title = QStringLiteral("B"); + appendCatalogSections(model.invisibleRootItem(), {a, b}); + ASSERT_EQ(model.rowCount(), 2); + EXPECT_EQ(model.item(0)->text(), QStringLiteral("A")); + EXPECT_EQ(model.item(1)->text(), QStringLiteral("B")); +} + +TEST(CatalogOutlineModel, TypedTargetsAndExpansionArePreservedInBothColumns) +{ + Section section; + section.title = QStringLiteral("typed"); + NavigationDestination destination; + destination.pageIndex = 2; + section.navigation = NavigationTarget{destination, {}}; + section.expanded = false; + QStandardItemModel model; + appendCatalogSections(model.invisibleRootItem(), {section}); + ASSERT_EQ(model.rowCount(), 1); + EXPECT_EQ(model.item(0, 1)->text(), QStringLiteral("3")); + for (int c = 0; c < 2; ++c) { + const auto *item = model.item(0, c); + EXPECT_EQ(item->data(CatalogPageRole).toInt(), 2); + const auto target = item->data(CatalogNavigationRole).value(); + ASSERT_TRUE(target.destination.has_value()); + EXPECT_EQ(target.destination->pageIndex, 2); + EXPECT_TRUE(item->data(CatalogExpandedRole).isValid()); + EXPECT_FALSE(item->data(CatalogExpandedRole).toBool()); + } +} + +TEST(CatalogOutlineModel, ExternalAndInvalidTargetsNeverInheritLegacyPage) +{ + Section external, invalid; + external.nIndex = 9; + external.navigation = NavigationTarget{{}, QUrl("https://example.invalid/")}; + invalid.nIndex = 8; + invalid.navigation = NavigationTarget{}; + QStandardItemModel model; + appendCatalogSections(model.invisibleRootItem(), {external, invalid}); + ASSERT_EQ(model.rowCount(), 2); + for (int i = 0; i < 2; ++i) { + EXPECT_EQ(model.item(i)->data(CatalogPageRole).toInt(), -1); + EXPECT_TRUE(model.item(i, 1)->text().isEmpty()); + EXPECT_TRUE(model.item(i)->data(CatalogNavigationRole).isValid()); + } +} + +TEST(CatalogOutlineModel, StoredExpansionIncludingEmptyListOverridesDefaultsWithoutSignals) +{ + Section leaf, child, root; + leaf.title = QStringLiteral("leaf"); + child.title = QStringLiteral("child"); + child.expanded = true; + child.children = {leaf}; + root.title = QStringLiteral("root"); + root.expanded = true; + root.children = {child}; + QStandardItemModel model; + appendCatalogSections(model.invisibleRootItem(), {root}); + QTreeView view; + view.setModel(&model); + int signalCount = 0; + QObject::connect(&view, &QTreeView::expanded, [&signalCount] { ++signalCount; }); + QObject::connect(&view, &QTreeView::collapsed, [&signalCount] { ++signalCount; }); + const QModelIndex rootIndex = model.index(0, 0); + const QModelIndex childIndex = model.index(0, 0, rootIndex); + applyCatalogExpansion(&view); + EXPECT_TRUE(view.isExpanded(rootIndex)); + EXPECT_TRUE(view.isExpanded(childIndex)); + applyCatalogExpansion(&view, QStringList{QStringLiteral("root")}); + EXPECT_TRUE(view.isExpanded(rootIndex)); + EXPECT_FALSE(view.isExpanded(childIndex)); + applyCatalogExpansion(&view, QStringList{}); + EXPECT_FALSE(view.isExpanded(rootIndex)); + EXPECT_FALSE(view.isExpanded(childIndex)); + EXPECT_EQ(signalCount, 0); +} diff --git a/tests/ofd-model/CMakeLists.txt b/tests/ofd-model/CMakeLists.txt index aacb7c800..ca5a8828f 100644 --- a/tests/ofd-model/CMakeLists.txt +++ b/tests/ofd-model/CMakeLists.txt @@ -25,6 +25,8 @@ get_filename_component(READER_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) add_executable(ofd-model-check main.cc ../document/ut_ofdmodel.cpp ../document/ut_navigation.cpp + ../document/ut_catalogoutlinemodel.cpp + ${READER_ROOT}/reader/sidebar/CatalogOutlineModel.cpp ${READER_ROOT}/reader/document/Navigation.cpp ${READER_ROOT}/reader/document/Navigation.h ${READER_ROOT}/reader/document/OfdModel.cpp @@ -34,7 +36,7 @@ target_compile_definitions(ofd-model-check PRIVATE OFD_SUPPORT_ENABLED UTSOURCEDIR="${READER_ROOT}/tests") target_include_directories(ofd-model-check PRIVATE ${ROFD_INCLUDE_DIR} - ${READER_ROOT}/reader ${READER_ROOT}/reader/document ${READER_ROOT}/reader/app + ${READER_ROOT}/reader ${READER_ROOT}/reader/document ${READER_ROOT}/reader/app ${READER_ROOT}/reader/sidebar ${READER_ROOT}/tests ${READER_ROOT}/3rdparty/deepin-pdfium/include) target_link_libraries(ofd-model-check PRIVATE Qt${QT_VERSION_MAJOR}::Widgets PkgConfig::CAIRO PkgConfig::DTKCORE diff --git a/tests/ofd-model/navigation_smoke.cc b/tests/ofd-model/navigation_smoke.cc new file mode 100644 index 000000000..36a94b4cb --- /dev/null +++ b/tests/ofd-model/navigation_smoke.cc @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Application.h" +#include "BrowserPage.h" +#include "CatalogTreeView.h" +#include "CatalogOutlineModel.h" +#include "DocSheet.h" +#include "Navigation.h" +#include "SecurityDialog.h" +#include "SheetBrowser.h" +#include "SheetRenderer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace deepin_reader; +static int checks = 0; +static void verify(bool condition, const char *description) +{ + if (!condition) + qFatal("FAIL: %s", description); + ++checks; +} +static void settle() +{ + QEventLoop loop; + QTimer::singleShot(250, &loop, &QEventLoop::quit); + loop.exec(); +} +class UrlSink : public QObject { + Q_OBJECT +public: + int calls = 0; +public slots: + void capture(const QUrl &) { ++calls; } +}; + +int main(int argc, char **argv) +{ + Application app(argc, argv); + QTimer::singleShot(60000, [] { qFatal("Smoke test timed out"); }); + QTemporaryDir fixture; + verify(fixture.isValid(), "fixture directory"); + QByteArray nested; + for (int i = 0; i < 8; ++i) + nested += ""; + nested += "" + "" + ""; + for (int i = 0; i < 8; ++i) + nested += ""; + nested += "" + ""; + const QMap entries{ + {"OFD.xml", "Document.xml"}, + {"Document.xml", "7 11 210 297" + "" + "" + nested + + ""}, + {"Page.xml", "7 11 210 297" + "" + "" + "" + "M 0 0 L 40 0 L 40 25 L 0 25 C" + ""}, + {"Second.xml", "3 5 100 120"} + }; + for (auto it = entries.cbegin(); it != entries.cend(); ++it) { + QFile file(fixture.filePath(it.key())); + verify(file.open(QIODevice::WriteOnly) && file.write(it.value()) == it.value().size(), "fixture file"); + } + QProcess zip; + zip.setWorkingDirectory(fixture.path()); + zip.start("cmake", QStringList{"-E", "tar", "cf", "smoke.ofd", "--format=zip"} + entries.keys()); + verify(zip.waitForFinished() && zip.exitCode() == 0, "fixture archive"); + UrlSink sink; + QDesktopServices::setUrlHandler("https", &sink, "capture"); + DocSheet sheet(Dr::OFD, fixture.filePath("smoke.ofd")); + sheet.resize(1100, 800); + sheet.show(); + verify(sheet.openFileExec(QString()), "open OFD in real DocSheet"); + settle(); + auto *browser = sheet.getSheetBrowser(); + auto *catalog = sheet.findChild(); + verify(browser && catalog, "OFD has browser and catalog widgets"); + catalog->handleOpenSuccess(); + verify(catalog->model()->rowCount() == 2, "root catalog rows"); + QModelIndex leaf; + for (int i = 0; i < 9; ++i) { + leaf = catalog->model()->index(0, 0, leaf); + verify(leaf.isValid(), "full catalog depth"); + if (i < 8) + verify(catalog->isExpanded(leaf), "document expansion defaults"); + } + verify(sheet.currentPage() == 1, "population does not navigate"); + catalog->setCurrentIndex(leaf); + verify(sheet.currentPage() == 1, "selection does not navigate"); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QCoreApplication::sendEvent(catalog, &enter); + verify(sheet.currentPage() == 2 && qAbs(sheet.operation().scaleFactor - 2) < .001, + "keyboard activation uses target page and zoom"); + catalog->restoreExpandedSections({}); + verify(catalog->getExpandedSections().isEmpty(), "all-collapsed saved state wins"); + + const QModelIndex website = catalog->model()->index(1, 1); + int dialogs = 0; + QTimer cancel; + QObject::connect(&cancel, &QTimer::timeout, [&] { + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (auto *dialog = qobject_cast(widget); dialog && dialog->isVisible()) { + ++dialogs; + dialog->reject(); + } + } + }); + cancel.start(10); + catalog->setCurrentIndex(website); + settle(); + verify(dialogs == 0 && sink.calls == 0, "URI selection has no effects"); + QCoreApplication::sendEvent(catalog, &enter); + verify(dialogs == 1 && sink.calls == 0, "URI keyboard activation confirms once and cancellation blocks opening"); + QMetaObject::invokeMethod(catalog, "onItemClicked", Qt::DirectConnection, Q_ARG(QModelIndex, website)); + verify(dialogs == 2 && sink.calls == 0, "URI click confirms once and cancellation blocks opening"); + + for (int rotation = 0; rotation < 4; ++rotation) { + if (rotation) + sheet.rotateRight(); + for (Dr::LayoutMode layout : {Dr::SinglePageMode, Dr::TwoPagesMode}) { + sheet.setLayoutMode(layout); + for (DestinationMode mode : {DestinationMode::XYZ, DestinationMode::Fit, DestinationMode::FitH, + DestinationMode::FitV, DestinationMode::FitR}) { + NavigationDestination destination; + destination.pageIndex = 1; + destination.mode = mode; + destination.left = 20; + destination.top = 30; + destination.right = 200; + destination.bottom = 250; + destination.zoom = 1.5; + NavigationTarget target; + target.destination = destination; + verify(sheet.navigateTo(target), "navigate mode/rotation/layout"); + verify(sheet.currentPage() == 2 && std::isfinite(sheet.operation().scaleFactor), "valid navigation state"); + BrowserPage *page = nullptr; + for (auto *item : browser->scene()->items()) + if (auto *candidate = dynamic_cast(item); candidate && candidate->itemIndex() == 1) + page = candidate; + verify(page, "target scene item"); + const auto view = navigationView(destination, sheet.renderer()->getPageSize(1), + QSizeF(browser->viewport()->size()), {}, sheet.operation().scaleFactor, + sheet.maxScaleFactor(), rotation * 90, layout == Dr::TwoPagesMode); + verify(view.has_value(), "navigation reference"); + const qreal scale = sheet.operation().scaleFactor; + const QPointF expected = page->mapRectToScene(QRectF(view->focusRect.topLeft() * scale, + view->focusRect.size() * scale)).topLeft(); + verify(browser->horizontalScrollBar()->value() == qRound(qBound(qreal(browser->horizontalScrollBar()->minimum()), expected.x(), qreal(browser->horizontalScrollBar()->maximum()))) + && browser->verticalScrollBar()->value() == qRound(qBound(qreal(browser->verticalScrollBar()->minimum()), expected.y(), qreal(browser->verticalScrollBar()->maximum()))), + "scroll aligns to transformed target"); + } + } + } + // Capture the old viewport position independently of navigationView. This + // exercises omitted-coordinate conversion through a rotated, scrolled page. + sheet.setLayoutMode(Dr::SinglePageMode); + sheet.setScaleFactor(2); + sheet.jumpToPage(2); + BrowserPage *sourcePage = nullptr; + BrowserPage *targetPage = nullptr; + for (auto *item : browser->scene()->items()) { + if (auto *page = dynamic_cast(item)) { + if (page->itemIndex() == 1) + sourcePage = page; + if (page->itemIndex() == 0) + targetPage = page; + } + } + verify(sourcePage && targetPage, "omitted-axis scene items"); + browser->horizontalScrollBar()->setValue(browser->horizontalScrollBar()->maximum() / 2); + const QPointF priorPosition = sourcePage->mapFromScene(browser->mapToScene(QPoint(0, 0))) / 2; + NavigationTarget partial; + partial.destination = NavigationDestination{}; + partial.destination->pageIndex = 0; + partial.destination->top = 40; + partial.destination->zoom = 0; + verify(sheet.navigateTo(partial), "rotated XYZ with omitted left"); + verify(sheet.operation().scaleFactor == 2, "zero zoom keeps the old scale"); + const QPointF expectedPartial = targetPage->mapToScene( + QPointF(qBound(qreal(0), priorPosition.x(), sheet.renderer()->getPageSize(0).width()), 40) * 2); + verify(browser->horizontalScrollBar()->value() == qRound(qBound(qreal(browser->horizontalScrollBar()->minimum()), expectedPartial.x(), qreal(browser->horizontalScrollBar()->maximum()))) + && browser->verticalScrollBar()->value() == qRound(qBound(qreal(browser->verticalScrollBar()->minimum()), expectedPartial.y(), qreal(browser->verticalScrollBar()->maximum()))), + "omitted axis retains captured current-page position"); + const auto oldScale = sheet.operation().scaleFactor; + NavigationTarget invalid; + invalid.destination = NavigationDestination{}; + invalid.destination->pageIndex = 999; + verify(!sheet.navigateTo(invalid) && sheet.operation().scaleFactor == oldScale, + "out-of-range target has no effects"); + verify(sink.calls == 0, "no network navigation attempted"); + QDesktopServices::unsetUrlHandler("https"); + qInfo("PASS: %d real-widget navigation checks", checks); + return 0; +} +#include "navigation_smoke.moc" diff --git a/tests/ofd-model/run_navigation_smoke.py b/tests/ofd-model/run_navigation_smoke.py new file mode 100644 index 000000000..e92bb3f08 --- /dev/null +++ b/tests/ofd-model/run_navigation_smoke.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# SPDX-License-Identifier: GPL-3.0-or-later +"""Run real-widget checks using an already-built Unix Makefiles reader target. + +Only the small smoke entry point is compiled. Reuse the application's objects +and libraries, replacing main, instead of building the monolithic unit tests. +""" + +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build", type=Path, help="completed reader CMake build directory") + args = parser.parse_args() + reader = args.build.resolve() / "reader" + target = reader / "CMakeFiles/deepin-reader.dir" + flags = {} + for line in (target / "flags.make").read_text().splitlines(): + key, separator, value = line.partition(" = ") + if separator: + flags[key] = shlex.split(value) + if "-DOFD_SUPPORT_ENABLED" not in flags["CXX_DEFINES"]: + parser.error("reader must have OFD support enabled") + link = shlex.split((target / "link.txt").read_text()) + main_object = "CMakeFiles/deepin-reader.dir/main.cpp.o" + if link.count(main_object) != 1 or link.count("-o") != 1: + parser.error("unsupported reader link command; use the Unix Makefiles generator") + moc = json.loads((reader / "CMakeFiles/deepin-reader_autogen.dir/AutogenInfo.json").read_text())["QT_MOC_EXECUTABLE"] + source = Path(__file__).resolve().with_name("navigation_smoke.cc") + with tempfile.TemporaryDirectory(prefix="navigation-smoke-", dir=args.build.resolve()) as directory: + work = Path(directory) + obj = work / "navigation_smoke.o" + binary = work / "navigation-smoke" + subprocess.run([moc, str(source), "-o", str(work / "navigation_smoke.moc")], check=True) + subprocess.run([link[0], *flags["CXX_DEFINES"], *flags["CXX_INCLUDES"], + *flags["CXX_FLAGS"], "-I", str(work), "-c", str(source), "-o", str(obj)], + cwd=reader, check=True) + link[link.index(main_object)] = str(obj) + link[link.index("-o") + 1] = str(binary) + link = [arg for arg in link if not arg.startswith("-Wl,--dependency-file=")] + subprocess.run(link, cwd=reader, check=True) + environment = dict(os.environ, QT_QPA_PLATFORM="offscreen", QT_LOGGING_RULES="*.debug=false") + for name in ("XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "TMPDIR"): + path = work / name.lower() + path.mkdir() + environment[name] = str(path) + subprocess.run([str(binary)], cwd=reader, env=environment, check=True, timeout=90) + + +if __name__ == "__main__": + main() From 06808284a57409b268eb73aec7ff8528c37def67 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 10:30:44 +0800 Subject: [PATCH 15/19] feat(ofd): connect page links to typed navigation --- CMakeLists.txt | 23 ++- reader/browser/SheetBrowser.cpp | 3 + reader/document/OfdModel.cpp | 97 +++++++++++ reader/document/OfdModel.h | 4 + tests/document/ut_ofdmodel.cpp | 241 +++++++++++++++++++++++++++- tests/ofd-model/navigation_smoke.cc | 49 ++++++ 6 files changed, 410 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 16b7cd959..dc1056bcb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,12 @@ if (OFD_SUPPORT) rofd_metadata_t *metadata = 0; rofd_warning_list_t *warnings = 0; rofd_outline_t *outline = 0; + rofd_link_list_t *links = 0; + rofd_outline_node_t node = {0}; + rofd_action_t action = {0}; rofd_destination_t destination = {0}; + rofd_rect_t region = {0}; + size_t count = 0; int32_t w = 0, h = 0; rofd_pixel_rect_init(&viewport, sizeof(viewport)); rofd_renderer_get_pixel_canvas_size(0, 0, 0, &w, &h, 0); @@ -119,19 +124,29 @@ if (OFD_SUPPORT) rofd_metadata_free(metadata); rofd_document_get_warnings(0, &warnings, 0); rofd_warning_list_free(warnings); - (void)&rofd_document_get_outline; - (void)&rofd_outline_get_action_destination; - (void)&rofd_outline_free; rofd_document_get_outline(0, &outline, 0); + node.struct_size = sizeof(node); + action.struct_size = sizeof(action); destination.struct_size = sizeof(destination); + rofd_outline_get_count(outline, &count, 0); + rofd_outline_get_node(outline, 0, &node, 0); + rofd_outline_get_action(outline, 0, 0, &action, 0); rofd_outline_get_action_destination(outline, 0, 0, &destination, 0); rofd_outline_free(outline); + rofd_page_get_links(0, &links, 0); + rofd_link_list_get_count(links, &count, 0); + rofd_link_list_get_region_count(links, 0, &count, 0); + rofd_link_list_get_region(links, 0, 0, ®ion, 0); + rofd_link_list_get_action_count(links, 0, &count, 0); + rofd_link_list_get_action(links, 0, 0, &action, 0); + rofd_link_list_get_action_destination(links, 0, 0, &destination, 0); + rofd_link_list_free(links); return 0; } ]=] ROFD_READER_APIS_AVAILABLE) cmake_pop_check_state() if (NOT ROFD_READER_APIS_AVAILABLE) - message(FATAL_ERROR "rofd headers/library lack the required region, metadata, warning or outline APIs. Install matching rofd >= 0.4.0 headers and library, and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") + message(FATAL_ERROR "rofd headers/library lack the required region, metadata, warning, outline or page-link APIs. Install matching rofd >= 0.4.0 headers and library, and set ROFD_INCLUDE_DIR/ROFD_FFI_LIBRARY accordingly.") endif() message(STATUS ">>> OFD support enabled (rofd_ffi: ${ROFD_FFI_LIBRARY})") add_compile_definitions(OFD_SUPPORT_ENABLED) diff --git a/reader/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index 1ddcfc500..5a36dc9c5 100644 --- a/reader/browser/SheetBrowser.cpp +++ b/reader/browser/SheetBrowser.cpp @@ -2267,6 +2267,9 @@ bool SheetBrowser::jump2Link(QPointF point) Link link = m_sheet->renderer()->getLinkAtPoint(page->itemIndex(), point); + if (link.navigation) + return navigateTo(*link.navigation); + if (link.page > 0 && link.page <= allPages()) { qCDebug(appLog) << "SheetBrowser::jump2Link() - Link page is greater than 0 and less than or equal to all pages"; jump2PagePos(m_items.at(link.page - 1), link.left, link.top); diff --git a/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp index 4dfa76736..08627b1ed 100644 --- a/reader/document/OfdModel.cpp +++ b/reader/document/OfdModel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace deepin_reader { @@ -596,6 +597,102 @@ QSizeF OfdPage::sizeF() const return m_sizePixel; } +Link OfdPage::getLinkAtPoint(const QPointF &point) +{ + if (!m_document || !m_page || !std::isfinite(point.x()) || !std::isfinite(point.y())) + return {}; + QMutexLocker lock(&m_linksMutex); + const bool loading = !m_linksLoaded; + if (loading) { + // The completed cache is Qt-owned and never changed, including empty + // and failed snapshots. Mouse movement must not retry failed parsing. + m_linksLoaded = true; + m_links = [this]() -> QList { + rofd_link_list_t *raw = nullptr; + rofd_error_t *error = nullptr; + const rofd_status_t status = rofd_page_get_links(m_page, &raw, &error); + const std::unique_ptr snapshot(raw, rofd_link_list_free); + if (status != ROFD_STATUS_OK || !snapshot) { + logSemanticError("Link loading", m_pageIndex, status, error); + return {}; + } + rofd_error_free(error); + size_t count = 0; + if (rofd_link_list_get_count(raw, &count, nullptr) != ROFD_STATUS_OK + || count > static_cast(std::numeric_limits::max())) { + qCWarning(appLog) << "Invalid OFD link count, page:" << m_pageIndex; + return {}; + } + QList links; + for (size_t i = 0; i < count; ++i) { + size_t actionCount = 0; + if (rofd_link_list_get_action_count(raw, i, &actionCount, nullptr) != ROFD_STATUS_OK) + continue; + Link link; + for (size_t actionIndex = 0; actionIndex < actionCount; ++actionIndex) { + rofd_action_t action = {}; + action.struct_size = sizeof(action); + if (rofd_link_list_get_action(raw, i, actionIndex, &action, nullptr) != ROFD_STATUS_OK) + continue; + rofd_destination_t destination = {}; + destination.struct_size = sizeof(destination); + const rofd_destination_t *target = nullptr; + if (action.event == ROFD_ACTION_EVENT_CLICK && action.kind == ROFD_ACTION_GOTO + && rofd_link_list_get_action_destination(raw, i, actionIndex, &destination, nullptr) == ROFD_STATUS_OK) { + target = &destination; + } + link.navigation = m_document->navigationTarget(action, target); + if (link.navigation) + break; + } + if (!link.navigation) + continue; + size_t regionCount = 0; + if (rofd_link_list_get_region_count(raw, i, ®ionCount, nullptr) != ROFD_STATUS_OK) + continue; + // addRect uses the same winding for each positive rectangle: + // overlapping areas stay clickable, but separated gaps do not. + link.boundary.setFillRule(Qt::WindingFill); + for (size_t regionIndex = 0; regionIndex < regionCount; ++regionIndex) { + rofd_rect_t region = {}; + if (rofd_link_list_get_region(raw, i, regionIndex, ®ion, nullptr) != ROFD_STATUS_OK) + continue; + const QRectF rect = toPixels(region); + if (std::isfinite(rect.x()) && std::isfinite(rect.y()) + && std::isfinite(rect.width()) && rect.width() > 0 + && std::isfinite(rect.height()) && rect.height() > 0 + && std::isfinite(rect.right()) && std::isfinite(rect.bottom())) { + link.boundary.addRect(rect); + } + } + if (link.boundary.isEmpty()) + continue; + if (link.navigation->destination) { + const auto &destination = *link.navigation->destination; + link.page = destination.pageIndex + 1; + link.left = destination.left.value_or(0); + link.top = destination.top.value_or(0); + } else { + link.urlOrFileName = link.navigation->uri.toString(QUrl::FullyEncoded); + } + links.append(std::move(link)); + } + return links; + }(); + } + Link result; + for (const Link &link : std::as_const(m_links)) { + if (link.boundary.contains(point)) { + result = link; + break; + } + } + lock.unlock(); + if (loading) + m_document->warningDetails(); + return result; +} + QImage OfdPage::render(int width, int height, const QRect &slice) const { if (nullptr == m_document || nullptr == m_page) { diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h index 227b79304..ff58f233c 100644 --- a/reader/document/OfdModel.h +++ b/reader/document/OfdModel.h @@ -84,6 +84,7 @@ class OfdPage : public Page QString text(const QRectF &rect) const override; QVector search(const QString &text, bool matchCase, bool wholeWords) const override; QList words() override; + Link getLinkAtPoint(const QPointF &point) override; private: rofd_rect_t toMillimetres(const QRectF &rect) const; @@ -94,6 +95,9 @@ class OfdPage : public Page int m_pageIndex = -1; rofd_rect_t m_pageRectMm = {0.0, 0.0, 0.0, 0.0}; QSizeF m_sizePixel; + QMutex m_linksMutex; + bool m_linksLoaded = false; + QList m_links; }; } // namespace deepin_reader diff --git a/tests/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp index 2b57eaa71..22a43095e 100644 --- a/tests/document/ut_ofdmodel.cpp +++ b/tests/document/ut_ofdmodel.cpp @@ -41,7 +41,8 @@ bool hasOfdFile() QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, const QByteArray &pageArea = QByteArray(), const QByteArray &documentExtras = QByteArray(), - const QByteArray &secondPage = QByteArray()) + const QByteArray &secondPage = QByteArray(), + const QByteArray &extraObjects = QByteArray()) { QMap entries = { {"OFD.xml", "" + info @@ -51,7 +52,7 @@ QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, + (secondPage.isEmpty() ? QByteArray() : QByteArray("")) + "" + documentExtras + ""}, - {"Page.xml", "" + pageArea + "" + pageArea + "" + extraObjects + "" "M 0 0 L 40 0 L 40 25 L 0 25 C" ""} @@ -82,6 +83,23 @@ QByteArray outlineNode(const QByteArray &title, const QByteArray &action) + action + ""; } +QByteArray linkAction(const QByteArray &action, const QByteArray ®ion = QByteArray(), + const QByteArray &event = "CLICK") +{ + return "" + region + action + ""; +} + +QPointF linkPoint(const OfdDocument &doc, qreal xMm, qreal yMm) +{ + return QPointF((xMm - 7) * doc.xRes() / 25.4, (yMm - 11) * doc.yRes() / 25.4); +} + +const QByteArray separatedLinkRegions = R"xml( + + + + )xml"; + } // namespace class TestOfdModel : public ::testing::Test @@ -311,7 +329,9 @@ TEST(OfdApi, outlineConcurrentAndEmptyOrFailedSnapshotsStayStable) const QByteArray deepOutline = "" + QByteArray("").repeated(66) + QByteArray("").repeated(66) + ""; for (const QByteArray &extras : {QByteArray(), deepOutline, - QByteArray("")}) { + QByteArray(""), + QByteArray("") + outlineNode("root", "") + + ""}) { QTemporaryDir dir; ASSERT_TRUE(dir.isValid()); const QString path = createOfdFixture(dir, {}, explicitPageArea, extras); @@ -332,6 +352,221 @@ TEST(OfdApi, outlineConcurrentAndEmptyOrFailedSnapshotsStayStable) } } +TEST(OfdApi, linksKeepSeparateRegionsAndChooseFirstSupportedOverlap) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QByteArray actions = "" + + linkAction("", {}, "PO") + + linkAction("", separatedLinkRegions) + + linkAction("", separatedLinkRegions) + + linkAction("", separatedLinkRegions) + ""; + const QString path = createOfdFixture(dir, {}, explicitPageArea + actions, + "" + "", offsetSecondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const Link first = page->getLinkAtPoint(linkPoint(*doc, 11, 21)); + EXPECT_TRUE(first.isValid()); // The inherited Page stub cannot provide a link. + ASSERT_TRUE(first.navigation.has_value()); + ASSERT_TRUE(first.navigation->destination.has_value()); + const auto &destination = *first.navigation->destination; + EXPECT_EQ(destination.pageIndex, 1); + EXPECT_EQ(first.page, 2); + ASSERT_TRUE(destination.left.has_value()); + ASSERT_TRUE(destination.top.has_value()); + EXPECT_DOUBLE_EQ(*destination.left, 10.0 * doc->xRes() / 25.4); + EXPECT_DOUBLE_EQ(*destination.top, 20.0 * doc->yRes() / 25.4); + ASSERT_TRUE(destination.zoom.has_value()); + EXPECT_DOUBLE_EQ(*destination.zoom, 0.0); + for (const QPointF point : {linkPoint(*doc, 15, 25), linkPoint(*doc, 45, 45)}) { + const Link hit = page->getLinkAtPoint(point); + EXPECT_TRUE(hit.isValid()); + EXPECT_EQ(hit.page, 2); + EXPECT_TRUE(hit.boundary.contains(point)); + } + EXPECT_FALSE(page->getLinkAtPoint(linkPoint(*doc, 30, 35)).isValid()); + EXPECT_FALSE(first.boundary.contains(linkPoint(*doc, 30, 35))); + page.reset(); + doc.reset(); + EXPECT_EQ(first.navigation->destination->pageIndex, 1); + EXPECT_FALSE(first.boundary.isEmpty()); +} + +TEST(OfdApi, linksUseAlreadyTransformedRegionsAndBoundaryFallback) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QByteArray explicitRegion = "" + ""; + const auto object = [](const QByteArray &id, const QByteArray &boundary, const QByteArray &actions) { + return "M 0 0 L 1 0 L 1 1 C" + "" + actions + ""; + }; + const QByteArray objects = object("10", "50 60 20 10", + linkAction("", explicitRegion)) + + object("11", "100 110 20 10", linkAction("")); + const QString path = createOfdFixture(dir, {}, explicitPageArea, {}, {}, objects); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + for (const auto &sample : {std::make_pair(QPointF(55, 65), QString("https://explicit.invalid/")), + std::make_pair(QPointF(105, 115), QString("https://fallback.invalid/"))}) { + const Link hit = page->getLinkAtPoint(linkPoint(*doc, sample.first.x(), sample.first.y())); + ASSERT_TRUE(hit.navigation.has_value()); + EXPECT_EQ(hit.urlOrFileName, sample.second); + EXPECT_EQ(hit.navigation->uri, QUrl(sample.second)); + EXPECT_NEAR(hit.boundary.boundingRect().width(), 20 * doc->xRes() / 25.4, 1e-6); + EXPECT_NEAR(hit.boundary.boundingRect().height(), 10 * doc->yRes() / 25.4, 1e-6); + } + EXPECT_FALSE(page->getLinkAtPoint(linkPoint(*doc, 80, 90)).isValid()); + EXPECT_FALSE(page->getLinkAtPoint(linkPoint(*doc, 1000, 1100)).isValid()); +} + +TEST(OfdApi, linksResolveSafeBaseAndSkipAllUnsupportedActions) +{ + const QList unsupported = { + linkAction("", {}, "PO"), + linkAction("", {}, "DO"), + linkAction("", {}, "CUSTOM"), + linkAction(""), linkAction(""), + linkAction(""), + linkAction(""), + linkAction(""), + linkAction(""), + linkAction(""), + linkAction("") + }; + QByteArray allUnsupported; + for (const QByteArray &action : unsupported) + allUnsupported += action; + for (bool hasSupported : {false, true}) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QByteArray supported = hasSupported + ? linkAction("") + + linkAction("") : QByteArray(); + const QString path = createOfdFixture(dir, {}, explicitPageArea + "" + + allUnsupported + supported + ""); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const Link hit = page->getLinkAtPoint(linkPoint(*doc, 15, 25)); + EXPECT_EQ(hit.isValid(), hasSupported); + if (hasSupported) { + ASSERT_TRUE(hit.navigation.has_value()); + EXPECT_EQ(hit.navigation->uri, QUrl("https://example.invalid/base/child?q=1&x=2")); + EXPECT_EQ(hit.urlOrFileName, QStringLiteral("https://example.invalid/base/child?q=1&x=2")); + EXPECT_EQ(hit.page, -1); + } + } +} + +TEST(OfdApi, linksRemainIndependentOfFailedOutlines) +{ + const QByteArray deepOutline = "" + QByteArray("").repeated(66) + + QByteArray("").repeated(66) + ""; + for (bool outlineFirst : {false, true}) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea + "" + + linkAction("") + "", deepOutline + + "", + offsetSecondPage); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + if (outlineFirst) + EXPECT_TRUE(doc->outline().isEmpty()); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const Link hit = page->getLinkAtPoint(linkPoint(*doc, 15, 25)); + ASSERT_TRUE(hit.navigation.has_value()); + ASSERT_TRUE(hit.navigation->destination.has_value()); + EXPECT_EQ(hit.navigation->destination->pageIndex, 1); + EXPECT_TRUE(doc->outline().isEmpty()); + EXPECT_TRUE(page->getLinkAtPoint(linkPoint(*doc, 15, 25)).isValid()); + } +} + +TEST(OfdApi, linksRefreshLazyWarningsWithoutInventingInvalidRegions) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea + "" + + linkAction("", "") + + linkAction("", separatedLinkRegions) + + "", {}, ""); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + EXPECT_TRUE(doc->properties().value("Warnings").toList().isEmpty()); + const Link hit = page->getLinkAtPoint(linkPoint(*doc, 15, 25)); + ASSERT_TRUE(hit.navigation.has_value()); + ASSERT_TRUE(hit.navigation->destination.has_value()); + EXPECT_DOUBLE_EQ(*hit.navigation->destination->left, 10.0 * doc->xRes() / 25.4); + const QVariantList warnings = doc->properties().value("Warnings").toList(); + ASSERT_EQ(warnings.size(), 2); + QList codes; + for (const QVariant &warning : warnings) + codes.append(warning.toMap().value("Code").toUInt()); + EXPECT_TRUE(codes.contains(ROFD_WARNING_NAVIGATION_INVALID)); + EXPECT_TRUE(codes.contains(ROFD_WARNING_PAGE_AREA_FALLBACK)); + EXPECT_FALSE(page->getLinkAtPoint(linkPoint(*doc, 100, 100)).isValid()); + EXPECT_EQ(doc->properties().value("Warnings").toList(), warnings); + EXPECT_FALSE(page->render(210, 297).isNull()); +} + +TEST(OfdApi, linksConcurrentEmptyAndFailedSnapshotsStayStable) +{ + // Deferred action XML is allowed to load the page but exceeds rofd's + // 100000-node navigation budget when a link snapshot is requested. + const QByteArray tooManyNodes = "" + QByteArray("").repeated(100001) + ""; + const QByteArray validGoto = "" + linkAction("") + + ""; + for (const QByteArray &actions : {QByteArray(), validGoto, tooManyNodes}) { + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = createOfdFixture(dir, {}, explicitPageArea + actions); + ASSERT_FALSE(path.isEmpty()); + Document::Error error; + std::unique_ptr doc(OfdDocument::loadDocument(path, error)); + ASSERT_NE(doc, nullptr); + std::unique_ptr page(doc->page(0)); + ASSERT_NE(page, nullptr); + const QPointF point = linkPoint(*doc, 15, 25); + std::vector> queries; + for (int i = 0; i < 8; ++i) + queries.push_back(std::async(std::launch::async, [&page, point] { return page->getLinkAtPoint(point); })); + for (auto &query : queries) + EXPECT_EQ(query.get().isValid(), actions == validGoto); + EXPECT_EQ(page->getLinkAtPoint(point).isValid(), actions == validGoto); + EXPECT_FALSE(page->render(210, 297).isNull()); + } +} + +TEST(OfdApi, linksMissingOwnersAreInert) +{ + OfdPage page(nullptr, nullptr, -1); + EXPECT_FALSE(page.getLinkAtPoint(QPointF(1, 1)).isValid()); + EXPECT_FALSE(page.getLinkAtPoint(QPointF(1, 1)).navigation.has_value()); +} + TEST_F(TestOfdModel, loadDocument) { EXPECT_GT(m_doc->pageCount(), 0); diff --git a/tests/ofd-model/navigation_smoke.cc b/tests/ofd-model/navigation_smoke.cc index 36a94b4cb..79535118e 100644 --- a/tests/ofd-model/navigation_smoke.cc +++ b/tests/ofd-model/navigation_smoke.cc @@ -69,6 +69,10 @@ int main(int argc, char **argv) "" "" "M 0 0 L 40 0 L 40 25 L 0 25 C" + "" + "" + "" + "M 0 0 L 40 0 L 40 25 L 0 25 C" ""}, {"Second.xml", "3 5 100 120"} }; @@ -202,6 +206,51 @@ int main(int argc, char **argv) invalid.destination->pageIndex = 999; verify(!sheet.navigateTo(invalid) && sheet.operation().scaleFactor == oldScale, "out-of-range target has no effects"); + + while (sheet.operation().rotation != Dr::RotateBy0) + sheet.rotateRight(); + NavigationTarget firstPage; + firstPage.destination = NavigationDestination{}; + firstPage.destination->pageIndex = 0; + firstPage.destination->left = 0; + firstPage.destination->top = 0; + firstPage.destination->zoom = 1; + verify(sheet.navigateTo(firstPage), "prepare page link interaction"); + const auto hitPoint = [&](qreal xMillimetres) { + // Source physical origin is (7,11); fixture hit is at y=35 mm. + const QPointF local((xMillimetres - 7) * targetPage->boundingRect().width() / 210, + 24 * targetPage->boundingRect().height() / 297); + browser->centerOn(targetPage->mapToScene(local)); + return browser->mapFromScene(targetPage->mapToScene(local)); + }; + const auto clickLink = [&](const QPoint &point) { + QMouseEvent press(QEvent::MouseButtonPress, QPointF(point), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(point), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(browser->viewport(), &press); + QCoreApplication::sendEvent(browser->viewport(), &release); + }; + QPoint hit = hitPoint(25); + QMouseEvent hover(QEvent::MouseMove, QPointF(hit), Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(browser->viewport(), &hover); + verify(browser->cursor().shape() == Qt::PointingHandCursor, "page link hover cursor"); + NavigationDestination fit; + fit.pageIndex = 1; + fit.mode = DestinationMode::Fit; + const auto expectedFit = navigationView(fit, sheet.renderer()->getPageSize(1), + QSizeF(browser->viewport()->size()), {}, sheet.operation().scaleFactor, + sheet.maxScaleFactor(), 0, false); + verify(expectedFit.has_value(), "page-link fit reference"); + clickLink(hit); + verify(sheet.currentPage() == 2, "page link reaches typed destination"); + verify(qAbs(sheet.operation().scaleFactor - expectedFit->scale) < .001, + "page link preserves Fit destination mode"); + verify(sheet.navigateTo(firstPage), "return to URI page link"); + hit = hitPoint(85); + const Link uriLink = sheet.renderer()->getLinkAtPoint(0, QPointF(78 * targetPage->boundingRect().width() / 210, + 24 * targetPage->boundingRect().height() / 297)); + verify(uriLink.urlOrFileName == "https://example.invalid/page", "page link hover URL"); + clickLink(hit); + verify(dialogs == 3 && sink.calls == 0, "URI page link confirms once and cancellation blocks opening"); verify(sink.calls == 0, "no network navigation attempted"); QDesktopServices::unsetUrlHandler("https"); qInfo("PASS: %d real-widget navigation checks", checks); From 458dbee730feaacfa19f41a092f7201a36e8d4c6 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 10:31:29 +0800 Subject: [PATCH 16/19] docs(ofd): record navigation integration verification --- .../plans/2026-09-11-ofd-navigation.md | 93 +++++++++++++------ .../specs/2026-09-11-ofd-navigation-design.md | 7 +- tests/ofd-model/README.md | 35 ++++++- 3 files changed, 102 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/plans/2026-09-11-ofd-navigation.md b/docs/superpowers/plans/2026-09-11-ofd-navigation.md index 046566ccd..09770f1b0 100644 --- a/docs/superpowers/plans/2026-09-11-ofd-navigation.md +++ b/docs/superpowers/plans/2026-09-11-ofd-navigation.md @@ -33,11 +33,11 @@ ctest --test-dir build/ofd-model --output-on-failure `tests/document/ut_navigation.cpp`; modify `reader/document/Model.h` and `tests/ofd-model/CMakeLists.txt`. -- [ ] Add tests first for missing/zero fields, all five modes, invalid rectangles, +- [x] Add tests first for missing/zero fields, all five modes, invalid rectangles, scale limits, 90/180/270-degree rotation, double-page viewport allocation, allowed URI resolution, rejected file/relative/unknown URI, and legacy defaults. -- [ ] Run the focused target and capture the missing-behavior failure before implementation. -- [ ] Implement this shared contract (namespace `deepin_reader`): +- [x] Run the focused target and capture the missing-behavior failure before implementation. +- [x] Implement this shared contract (namespace `deepin_reader`): ```cpp enum class DestinationMode { XYZ, Fit, FitH, FitV, FitR }; @@ -72,31 +72,31 @@ std::optional navigationView( XYZ defaults use currentPosition; zoom absent/zero preserves currentScale. URI resolution accepts only absolute HTTP/HTTPS with host and nonempty mailto, uses QUrl strict parsing and explicit Base, and never performs I/O. -- [ ] Verify all tests, including `Link legacy; legacy.page = 1; EXPECT_TRUE(legacy.isValid());`. -- [ ] Review the exact diff for spec compliance, then code quality, before consuming the contract. +- [x] Verify all tests, including `Link legacy; legacy.page = 1; EXPECT_TRUE(legacy.isValid());`. +- [x] Review the exact diff for spec compliance, then code quality, before consuming the contract. ### Task 2: OFD outlines and destinations **Files:** Modify `reader/document/OfdModel.h`, `OfdModel.cpp`, `tests/document/ut_ofdmodel.cpp`, `CMakeLists.txt`, `debian/control`. -- [ ] Add controlled two-page OFD fixtures with page origins `(7,11)` and `(3,5)`, +- [x] Add controlled two-page OFD fixtures with page origins `(7,11)` and `(3,5)`, nested titles four levels deep, a non-clickable parent, named and explicit destinations, zero/omitted fields, invalid targets and non-CLICK actions. First assertion against the current stub is `ASSERT_EQ(doc->outline().size(), 1);`. -- [ ] Run and observe the empty-outline failure. -- [ ] Implement `Outline outline() const override` with a mutex-protected cache, +- [x] Run and observe the empty-outline failure. +- [x] Implement `Outline outline() const override` with a mutex-protected cache, including empty/failed results. Convert preorder nodes in reverse index order into the tree after validating parent-before-child indices; retain invalid-target nodes without a page number. Copy all strings before releasing the RAII snapshot. A document helper maps rofd actions/destinations to Task 1's values and lazily caches target-page physical rectangles. Only CLICK Goto or allowed URI actions become targets; preserve optional coordinates and use target-page origins. -- [ ] Probe outline/destination functions at configure time and require +- [x] Probe outline/destination functions at configure time and require `librofd-ffi-dev (>= 0.4.0)`; refresh warnings after lazy navigation queries. -- [ ] Verify geometry with `EXPECT_NEAR(*target.destination->left, (13-3)*dpi/25.4, 1e-6);`, +- [x] Verify geometry with `EXPECT_NEAR(*target.destination->left, (13-3)*dpi/25.4, 1e-6);`, unresolved page targets remain absent, and repeated outline calls are stable. -- [ ] Run spec review then quality review; checkpoint the scoped backend change. +- [x] Run spec review then quality review; checkpoint the scoped backend change. ### Task 3: Complete catalog and view execution @@ -105,58 +105,97 @@ std::optional navigationView( `reader/uiframe/DocSheet.h/.cpp`, `reader/browser/SheetBrowser.h/.cpp`, `tests/ofd-model/CMakeLists.txt`. -- [ ] Test model construction first: a targetless root with a four-level child +- [x] Test model construction first: a targetless root with a four-level child must remain present, have a blank page column, retain navigation/expansion roles, and expose the leaf. Preserve legacy page and offset roles. -- [ ] Observe a failing model test, then implement reusable iterative tree-to-item +- [x] Observe a failing model test, then implement reusable iterative tree-to-item population. Bind both columns to the same target. CatalogTreeView uses it, expands OFD defaults, and does not activate targets while populating or syncing. -- [ ] Add `bool navigateTo(const deepin_reader::NavigationTarget &)` to SheetBrowser +- [x] Add `bool navigateTo(const deepin_reader::NavigationTarget &)` to SheetBrowser and a forwarding DocSheet method. Validate page count before changing state. Compute currentPosition by mapping viewport origin to the current page and dividing by current scale. Apply Task 1's view, call setScaleFactor, and map focusRect at the actual resulting scale through the target item's scene transform before setting scrollbars and notifying the current page. -- [ ] For a typed URI, revalidate, display the final URL in SecurityDialog, and +- [x] For a typed URI, revalidate, display the final URL in SecurityDialog, and call QDesktopServices only after Accepted. Share this path with Task 4. -- [ ] Enable PREVIEW_CATALOG for OFD. Typed outline actions execute on explicit +- [x] Enable PREVIEW_CATALOG for OFD. Typed outline actions execute on explicit click or keyboard activation exactly once, not currentChanged; legacy paths remain unchanged. Stored expansion state, including all-collapsed state, wins over defaults when the catalog is opened lazily. - [ ] Verify model/calculator tests and reader compilation; exercise actual outline activation, rotation and cancellation in a controlled app window. -- [ ] Spec review then quality review; checkpoint directory/navigation integration. +- [x] Spec review then quality review; checkpoint directory/navigation integration. ### Task 4: Page link adapter and shared activation **Files:** Modify `reader/document/OfdModel.h/.cpp`, `tests/document/ut_ofdmodel.cpp`, `reader/browser/SheetBrowser.cpp`, `CMakeLists.txt`. -- [ ] Add links to controlled fixtures with separated regions, overlaps, +- [x] Add links to controlled fixtures with separated regions, overlaps, transformed bounds, CLICK/PO/DO events, URI/Base and GotoA actions. First assertion against the stub is `EXPECT_TRUE(page->getLinkAtPoint(hit).isValid());`. -- [ ] Run and observe that missing-link failure. -- [ ] Implement a per-page immutable Qt-owned link cache guarded during init. +- [x] Run and observe that missing-link failure. +- [x] Implement a per-page immutable Qt-owned link cache guarded during init. Preserve source order, use the first supported hit, and union independent rectangles as paths without filling the gaps. Copy borrowed data before freeing the snapshot. Empty/failed lists are cached. Reuse Task 2 conversion and refresh warnings after rofd_page_get_links. No adapter action executes external code. -- [ ] `SheetBrowser::jump2Link` calls navigateTo when `link.navigation` exists; +- [x] `SheetBrowser::jump2Link` calls navigateTo when `link.navigation` exists; otherwise preserves the old PDF/XPS path. Keep resolved URI text for hover tips. -- [ ] Extend CMake symbol checks and tests for gaps, deterministic overlap, +- [x] Extend CMake symbol checks and tests for gaps, deterministic overlap, unsafe targets, failure isolation from outlines and repeated queries. -- [ ] Run spec review then quality review; checkpoint page-link integration. +- [x] Run spec review then quality review; checkpoint page-link integration. ### Task 5: Integrated validation and delivery **Files:** Update `tests/ofd-model/README.md` and this checklist with actual results. -- [ ] Run `git diff --check`, all focused tests, and inspect linked rofd SONAME. -- [ ] Build only `deepin-reader -j1`, reusing the existing PDFium build where possible. +- [x] Run `git diff --check`, all focused tests, and inspect linked rofd SONAME. +- [x] Build only `deepin-reader -j1`, reusing the existing PDFium build where possible. - [ ] Verify real window catalog depth/activation, page link hover/click, rotation, scale changes and external-link cancellation. Do not visit test URLs. -- [ ] Check legacy PDF/XPS targets keep their prior path and no new automatic actions +- [x] Check legacy PDF/XPS targets keep their prior path and no new automatic actions occur on opening a document, restoring state or selecting a catalog item. -- [ ] Review the entire diff after task reviews, report exact evidence and any +- [x] Review the entire diff after task reviews, report exact evidence and any unverified conditions. Keep outline/navigation and page links separable in history; do not push or merge to main without the user's request. + +## Execution evidence (2026-09-11) + +- Paired rofd snapshot: `35cb164`, version 0.4.0; the source repository was not + modified. The original reader worktree remains on `ofd_support`. +- Task 1: 22 navigation/legacy tests passed after the missing-behavior RED run. + Task 2: five new outline tests first failed on the empty inherited outline. + Task 3: six catalog-model tests passed after their RED runs. +- Task 4: six link tests first failed on the inherited empty link. The additional + missing-owner test reproduced a crash before the guard was added. The GUI + regression also failed with the backend connected but the old browser route: + a real mouse click changed pages but lost the destination's Fit scale. +- Fresh focused test run: **59 tests passed**, seven suites, no skips. +- Fresh `deepin-reader -j1` build succeeded with `BUILD_TESTS=OFF` and the existing + PDFium shared library. No aggregate test build or OOM occurred. +- Reproducible real-widget checks: **247 assertions passed** through + `python3 tests/ofd-model/run_navigation_smoke.py build/reader-local`. + Includes deep catalogs, inert selection, explicit Enter/click activation, + all five modes at four rotations and two layouts, omitted coordinates, + page-link hover and actual mouse press/release, Fit semantics, and URI cancel. + External test URLs were intercepted and never opened. +- `ldd` resolves `librofd_ffi.so.0` to the paired sibling `rofd/` snapshot and + PDFium to the original reader's existing shared build. A negative configure + check with the old rofd library correctly failed on missing navigation APIs. +- `git diff --check` passes. Spec and quality reviews passed for all four + implementation tasks and the integrated change, with no critical or important + findings. Direct fitting-ratio overflow/underflow coverage remains an optional + test enhancement; the bounded implementation was reviewed. +- Scoped implementation commits: `f5be344a` (typed targets/calculations), + `db340c99` (outline adapter), `d4336b10` (catalog/navigation UI and widget runner), + `b2431562` (page links and shared activation). + +### Validation limit + +The real-widget checks use Qt's **offscreen** platform. Visible desktop-window +acceptance remains unchecked above: the native UI automation runtime was not +available. The aggregate test executable was deliberately not built, following +the user's reader-only build constraint. No main-branch merge or remote push +has been performed. diff --git a/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md b/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md index 1ff769215..722199d94 100644 --- a/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md +++ b/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md @@ -12,6 +12,9 @@ - `9e4a169`:目录、动作与目标,本次接入。 - `2331199`:页面链接映射,本次接入。 +实施时补充:rofd 随后推进到 `35cb164`,发布版本为 0.4.0。因此本次实现使用 +配套的 0.4.0 头文件和共享库,并将 Debian 最低依赖更新为 0.4.0。 + 不增加附件提取、附件执行、自动动作、脚本、注释编辑或文件写回。 不重构 PDF/XPS 后端,也不把 rofd 的矩形链接范围描述为精确路径命中。 @@ -116,8 +119,8 @@ URI 先使用显式 Base 解析,再校验最终地址。首轮只允许有效 更新本地构建使用的配套 rofd 头文件和共享库;CMake 增加目录、目标与链接 符号检查,避免旧库带着相同版本号通过配置后才在链接阶段失败。 -不在源码提交中加入生成的共享库。rofd 当前仍标为 0.3.0,不虚构软件包版本; -发行版依赖下限在上游正式抬升版本后再更新。 +不在源码提交中加入生成的共享库。设计时 rofd 仍标为 0.3.0;实施时上游已正式 +发布 0.4.0,发行版依赖下限相应更新为 0.4.0。 实现分两项审查与交付: diff --git a/tests/ofd-model/README.md b/tests/ofd-model/README.md index 0de5e7cff..c60b514ce 100644 --- a/tests/ofd-model/README.md +++ b/tests/ofd-model/README.md @@ -1,7 +1,7 @@ # Focused OFD adapter checks -This independent CMake project runs `tests/document/ut_ofdmodel.cpp` against the -real reader adapter and a shared rofd library. It does not link PDFium or the +This independent CMake project runs the OFD adapter, navigation calculator and +catalog-model tests against a shared rofd library. It does not link PDFium or the monolithic `test-deepin-reader` executable. The `.cc` entry point is intentionally outside the main test project's `.cpp`/`.h` source glob. @@ -13,8 +13,8 @@ cmake --build build/ofd-model -j1 ctest --test-dir build/ofd-model --output-on-failure ``` -The library must provide the region, metadata and warning APIs added in rofd -commits `56da925` and `6db9bac`; the initial 0.3.0 release does not include them. +Use matching rofd **0.4.0 or newer** headers and library, including region, +metadata, warnings, outline/destination and page-link APIs. Its runtime SONAME (`librofd_ffi.so.0`) must resolve to the same library. Qt Widgets, DTK Core, Cairo, Google Test and CMake are required. Fixtures are created in temporary directories using `cmake -E tar --format=zip`. @@ -23,3 +23,30 @@ Coverage includes full and tiled rendering, nonzero physical page origins, small tiles on canvases larger than 4 GiB, pre-allocation raster limits, real-invoice pixel comparisons, search/selection, metadata/DocID, missing or invalid dates, and fresh warning snapshots after lazy page and annotation loads. +Navigation checks cover target-page physical origins, optional coordinates, +five destination modes, rotation/two-page fitting, safe URI resolution, deep +catalogs, expansion restoration, independent link regions and cached failures. + +## Real-widget smoke checks + +After building **only the reader application**, the following optional check +reuses its objects and link libraries with a small test entry point: + +```sh +cmake --build build/reader-local --target deepin-reader -j1 +python3 tests/ofd-model/run_navigation_smoke.py build/reader-local +``` + +The runner requires a completed, up-to-date **Unix Makefiles** CMake build with +OFD enabled. Use the shared PDFium/rofd configuration to keep linking small; this +does not build the aggregate `test-deepin-reader` target. It compiles only +`navigation_smoke.cc`, discovers moc from CMake, and isolates temporary files and +XDG application state. The source uses `.cc` to stay out of the aggregate test +source glob. + +It exercises real DocSheet, CatalogTreeView, SheetBrowser and SecurityDialog +objects with the Qt **offscreen** platform: deep catalog/explicit activation, +selection without navigation, all-collapsed restoration, five modes at four +rotations and both layouts, omitted coordinates, page-link hover/activation, +and cancellation of external links. Test URLs are intercepted and never opened. +These are in-process widget checks, not a visible desktop-window acceptance test. From f83541f0d2506f2317f264982198fd8ae1aa16b7 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 11:28:45 +0800 Subject: [PATCH 17/19] fix(reader): preserve explicit navigation during state restoration --- reader/browser/SheetBrowser.cpp | 4 + reader/uiframe/DocSheet.cpp | 16 ++++ reader/uiframe/DocSheet.h | 3 + tests/ofd-model/navigation_smoke.cc | 123 +++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/reader/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index 5a36dc9c5..fd533784e 100644 --- a/reader/browser/SheetBrowser.cpp +++ b/reader/browser/SheetBrowser.cpp @@ -771,6 +771,10 @@ bool SheetBrowser::navigateTo(const NavigationTarget &target) if (!view) return false; + // 目标校验成功后,主动导航取代旧的恢复任务;否则页码信号会被守卫 + // 忽略,布局稳定后还会跳回保存的锚点。目录和页面链接共用此路径。 + m_sheet->cancelRestoreGuard(); + { QScopedValueRollback suppressPageChanges(m_bNeedNotifyCurPageChanged, false); m_sheet->setScaleFactor(view->scale); diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index ab5162c06..9a9ce3029 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -1945,6 +1945,22 @@ void DocSheet::onBrowserDeformed() m_restoreSettleTimer->start(kRestoreSettleMs); } +void DocSheet::cancelRestoreGuard() +{ + if (!m_restoreGuardActive) + return; + + m_restoreSettleTimer->stop(); + m_restoreGuardActive = false; + m_restoreNotifyTip = false; + dismissRestoreTip(); + + // 守卫期间可见页可能已变化,但操作记录仍停留在恢复锚点。 + // 同页导航不会再发 sigPageChanged,解除守卫时先同步当前可见页。 + if (m_browser) + onBrowserPageChanged(m_browser->currentPage()); +} + void DocSheet::onLayoutSettled() { if (!m_restoreGuardActive) diff --git a/reader/uiframe/DocSheet.h b/reader/uiframe/DocSheet.h index d9015e66c..c6b9f4f0f 100644 --- a/reader/uiframe/DocSheet.h +++ b/reader/uiframe/DocSheet.h @@ -694,6 +694,9 @@ class DocSheet : public Dtk::Widget::DSplitter */ void beginRestoreGuard(bool notifyTip); + /** 有效的用户导航优先于尚未完成的阅读位置恢复。 */ + void cancelRestoreGuard(); + /** * @brief 获取当前滚动位置(0.0~1.0) */ diff --git a/tests/ofd-model/navigation_smoke.cc b/tests/ofd-model/navigation_smoke.cc index 79535118e..15cb65f94 100644 --- a/tests/ofd-model/navigation_smoke.cc +++ b/tests/ofd-model/navigation_smoke.cc @@ -9,6 +9,15 @@ #include "SecurityDialog.h" #include "SheetBrowser.h" #include "SheetRenderer.h" +#include "ThumbnailDelegate.h" +#include "SideBarImageViewModel.h" +#include "EyeProtectionManager.h" +#include "NightFilter.h" +#include +#include +#include +#include +#include #include #include #include @@ -28,10 +37,10 @@ static void verify(bool condition, const char *description) qFatal("FAIL: %s", description); ++checks; } -static void settle() +static void settle(int milliseconds = 250) { QEventLoop loop; - QTimer::singleShot(250, &loop, &QEventLoop::quit); + QTimer::singleShot(milliseconds, &loop, &QEventLoop::quit); loop.exec(); } class UrlSink : public QObject { @@ -42,6 +51,65 @@ public slots: void capture(const QUrl &) { ++calls; } }; +static void verifyThumbnailAppearance() +{ + auto *eye = EyeProtectionManager::instance(); + auto *theme = Dtk::Gui::DGuiApplicationHelper::instance(); + const auto oldMode = eye->mode(); + const auto oldPalette = theme->paletteType(); + const auto restore = qScopeGuard([=] { eye->setMode(oldMode); theme->setPaletteType(oldPalette); }); + QListView view; + view.setProperty("adaptScale", 1.0); + ThumbnailDelegate delegate(&view); + QStandardItemModel model(1, 1); + view.setModel(&model); + const QModelIndex index = model.index(0, 0); + model.setData(index, QSize(210, 297), IMAGE_PAGE_SIZE); + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 240, 300); + for (auto appearance : {Dtk::Gui::DGuiApplicationHelper::LightType, + Dtk::Gui::DGuiApplicationHelper::DarkType}) { + theme->setPaletteType(appearance); + verify(theme->themeType() == appearance, "thumbnail theme fixture takes effect"); + for (auto mode : {EyeProtectionManager::Off, EyeProtectionManager::Classic, + EyeProtectionManager::Green, EyeProtectionManager::Night}) { + eye->setMode(mode); + for (const QColor &color : {QColor(Qt::white), QColor(90, 140, 200)}) { + QPixmap source(32, 48); + source.setDevicePixelRatio(2); + source.fill(color); + model.setData(index, source, IMAGE_PIXMAP); + QImage expected(1, 1, QImage::Format_ARGB32_Premultiplied); + expected.fill(color); + if (mode == EyeProtectionManager::Night) + expected = NightFilter::applyPage(expected, {}); + { + QPainter painter(&expected); + if (mode == EyeProtectionManager::Night) { + QColor overlay = eye->pageBackgroundColor(); + overlay.setAlpha(60); + painter.fillRect(expected.rect(), overlay); + } else if (mode != EyeProtectionManager::Off) { + painter.setCompositionMode(QPainter::CompositionMode_Multiply); + painter.fillRect(expected.rect(), eye->pageBackgroundColor()); + } + } + for (int rotation : {0, 90}) { + model.setData(index, rotation, IMAGE_ROTATE); + QImage canvas(240, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + { + QPainter painter(&canvas); + static_cast(delegate).paint(&painter, option, index); + } + verify(canvas.pixelColor(120, 150) == expected.pixelColor(0, 0), + "thumbnail paint follows eye mode independently of system theme"); + } + } + } + } +} + int main(int argc, char **argv) { Application app(argc, argv); @@ -106,10 +174,41 @@ int main(int argc, char **argv) verify(sheet.currentPage() == 1, "population does not navigate"); catalog->setCurrentIndex(leaf); verify(sheet.currentPage() == 1, "selection does not navigate"); + // Opening/restoring a tab must not swallow an explicit catalog activation. + sheet.beginRestoreGuard(true); QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); QCoreApplication::sendEvent(catalog, &enter); verify(sheet.currentPage() == 2 && qAbs(sheet.operation().scaleFactor - 2) < .001, "keyboard activation uses target page and zoom"); + settle(kRestoreSettleMs + 100); + verify(sheet.currentPage() == 2 && browser->currentPage() == 2 && !sheet.needsRestoreTip(), + "explicit catalog navigation supersedes pending restore and its notification"); + sheet.saveCurrentViewState(); + verify(sheet.operation().scrollPosition > 0, "nonzero saved position for restore checks"); + sheet.beginRestoreGuard(true); + browser->setCurrentPage(1); + verify(browser->currentPage() == 1 && sheet.currentPage() == 2, + "restore guard freezes the saved page during a layout page change"); + NavigationTarget alreadyVisible; + alreadyVisible.destination = NavigationDestination{}; + alreadyVisible.destination->pageIndex = 0; + alreadyVisible.destination->zoom = sheet.operation().scaleFactor; + verify(sheet.navigateTo(alreadyVisible), "navigate to a page already visible during restoration"); + verify(sheet.currentPage() == 1 && browser->currentPage() == 1, + "explicit navigation synchronizes the saved page even without a browser page change"); + sheet.jumpToPage(2); + sheet.saveCurrentViewState(); + sheet.beginRestoreGuard(true); + NavigationTarget invalidDuringRestore; + invalidDuringRestore.destination = NavigationDestination{}; + invalidDuringRestore.destination->pageIndex = 999; + verify(!sheet.navigateTo(invalidDuringRestore), "invalid navigation rejected during restore"); + browser->setCurrentPage(1); + verify(sheet.currentPage() == 2, "invalid target leaves restore guard active"); + settle(kRestoreSettleMs + 100); + verify(browser->currentPage() == 2 && sheet.needsRestoreTip(), + "normal saved-page restoration and its notification remain intact"); + sheet.dismissRestoreTip(); catalog->restoreExpandedSections({}); verify(catalog->getExpandedSections().isEmpty(), "all-collapsed saved state wins"); @@ -128,8 +227,12 @@ int main(int argc, char **argv) catalog->setCurrentIndex(website); settle(); verify(dialogs == 0 && sink.calls == 0, "URI selection has no effects"); + sheet.beginRestoreGuard(true); QCoreApplication::sendEvent(catalog, &enter); verify(dialogs == 1 && sink.calls == 0, "URI keyboard activation confirms once and cancellation blocks opening"); + settle(kRestoreSettleMs + 100); + verify(sheet.needsRestoreTip(), "external link cancellation does not cancel page restoration"); + sheet.dismissRestoreTip(); QMetaObject::invokeMethod(catalog, "onItemClicked", Qt::DirectConnection, Q_ARG(QModelIndex, website)); verify(dialogs == 2 && sink.calls == 0, "URI click confirms once and cancellation blocks opening"); @@ -240,10 +343,15 @@ int main(int argc, char **argv) QSizeF(browser->viewport()->size()), {}, sheet.operation().scaleFactor, sheet.maxScaleFactor(), 0, false); verify(expectedFit.has_value(), "page-link fit reference"); + sheet.saveCurrentViewState(); + sheet.beginRestoreGuard(true); clickLink(hit); verify(sheet.currentPage() == 2, "page link reaches typed destination"); verify(qAbs(sheet.operation().scaleFactor - expectedFit->scale) < .001, "page link preserves Fit destination mode"); + settle(kRestoreSettleMs + 100); + verify(sheet.currentPage() == 2 && browser->currentPage() == 2 && !sheet.needsRestoreTip(), + "page-link navigation is not overwritten by a pending restore"); verify(sheet.navigateTo(firstPage), "return to URI page link"); hit = hitPoint(85); const Link uriLink = sheet.renderer()->getLinkAtPoint(0, QPointF(78 * targetPage->boundingRect().width() / 210, @@ -252,8 +360,17 @@ int main(int argc, char **argv) clickLink(hit); verify(dialogs == 3 && sink.calls == 0, "URI page link confirms once and cancellation blocks opening"); verify(sink.calls == 0, "no network navigation attempted"); + sheet.jumpToPage(2); + sheet.saveCurrentViewState(); + verify(sheet.operation().scrollPosition > 0, "tab-return restore has a saved position"); + sheet.restoreSavedViewState(); + verify(sheet.navigateTo(firstPage), "explicit navigation immediately after tab return"); + settle(kRestoreSettleMs + 100); + verify(sheet.currentPage() == 1 && browser->currentPage() == 1 && !sheet.needsRestoreTip(), + "tab-return restoration cannot overwrite explicit navigation"); QDesktopServices::unsetUrlHandler("https"); - qInfo("PASS: %d real-widget navigation checks", checks); + verifyThumbnailAppearance(); + qInfo("PASS: %d real-widget navigation/appearance checks", checks); return 0; } #include "navigation_smoke.moc" From e9c8a77560b290e48475841684b159710fa69923 Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 11:28:45 +0800 Subject: [PATCH 18/19] test(sidebar): detect Qt version in focused appearance checks --- tests/sidebar-appearance/CMakeLists.txt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/sidebar-appearance/CMakeLists.txt b/tests/sidebar-appearance/CMakeLists.txt index a40a50e6f..18cad65dd 100644 --- a/tests/sidebar-appearance/CMakeLists.txt +++ b/tests/sidebar-appearance/CMakeLists.txt @@ -3,9 +3,14 @@ project(reader-sidebar-appearance-tests LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) find_package(PkgConfig REQUIRED) -pkg_check_modules(DTK REQUIRED IMPORTED_TARGET dtk6widget) +if (QT_VERSION_MAJOR EQUAL 6) + pkg_check_modules(DTK REQUIRED IMPORTED_TARGET dtk6widget) +else() + pkg_check_modules(DTK REQUIRED IMPORTED_TARGET dtkwidget) +endif() set(READER_DIR "${CMAKE_CURRENT_LIST_DIR}/../../reader") add_executable(test-sidebar-appearance @@ -17,7 +22,7 @@ add_executable(test-sidebar-appearance target_include_directories(test-sidebar-appearance PRIVATE ${READER_DIR} ${READER_DIR}/app ${READER_DIR}/sidebar ${READER_DIR}/eyeprotection ${READER_DIR}/browser) -target_link_libraries(test-sidebar-appearance PRIVATE Qt6::Widgets PkgConfig::DTK) +target_link_libraries(test-sidebar-appearance PRIVATE Qt${QT_VERSION_MAJOR}::Widgets PkgConfig::DTK) enable_testing() add_test(NAME sidebar-appearance COMMAND test-sidebar-appearance) From d25b13b017d4811bee1178e70d022061db6ab73e Mon Sep 17 00:00:00 2001 From: Hualet Wang Date: Fri, 11 Sep 2026 11:28:45 +0800 Subject: [PATCH 19/19] docs(ofd): document post-rebase compatibility checks --- tests/ofd-model/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/ofd-model/README.md b/tests/ofd-model/README.md index c60b514ce..d4ff94e35 100644 --- a/tests/ofd-model/README.md +++ b/tests/ofd-model/README.md @@ -49,4 +49,39 @@ objects with the Qt **offscreen** platform: deep catalog/explicit activation, selection without navigation, all-collapsed restoration, five modes at four rotations and both layouts, omitted coordinates, page-link hover/activation, and cancellation of external links. Test URLs are intercepted and never opened. +It also covers navigation during the mainline reading-state restore guard: +catalog and page-link activation, timer expiry, tab return, already-visible +destinations, and preservation of normal restoration for invalid/external targets. +Actual thumbnail painting is checked in light/dark themes and all four eye modes, +including rotated, high-DPI source pixmaps. These checks compare content pixels; +they do not verify border styling or desktop repaint timing. These are in-process widget checks, not a visible desktop-window acceptance test. + +## Focused thumbnail filter checks + +The smaller sidebar project checks the production thumbnail filter/cache without +linking the reader, PDFium or rofd: + +```sh +cmake -S tests/sidebar-appearance -B build/sidebar-appearance +cmake --build build/sidebar-appearance -j1 +ctest --test-dir build/sidebar-appearance --output-on-failure +``` + +It compares pixels with the mainline `NightFilter`, including transparent input, +device-pixel ratio, cache invalidation and filter-before-rotation behavior. +Thumbnail image-object masks are not yet supplied: thumbnails use the mainline +filter's empty-mask fallback, not the main page's photo-preservation path. + +## Rebuilding after the master rebase + +Master `9acd6e5a` includes the new `DPdfPage::imageObjectRects(int, int)` API. +When reusing a locally built shared PDFium wrapper, update that wrapper as well +as its header; an older shared library will fail to link despite a clean rebase. +The existing underlying PDFium static library can be reused if its sources and +build configuration have not changed. Keep `BUILD_TESTS=OFF` and build only the +`deepin-reader` target with `-j1`; the focused checks above remain independent. + +Validation after this rebase on Qt 6/rofd 0.4.0: reader-only build, 59 model tests, +41 filter/cache checks and 294 real-widget checks passed. The qmake path and +visible desktop-window acceptance were not part of this validation.