diff --git a/CMakeLists.txt b/CMakeLists.txt index fd7fe65bf..dc1056bcb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,105 @@ else() unset(XPS_DEPS_CFLAGS_OTHER) endif() +# 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, 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}) + endif() + if (EXISTS "${CMAKE_SOURCE_DIR}/../rofd") + 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 ${ROFD_LIB_SUFFIXES} + ) + pkg_check_modules(OFD_CAIRO QUIET cairo) + + if (ROFD_INCLUDE_DIR AND ROFD_FFI_LIBRARY AND OFD_CAIRO_FOUND) + # 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) + 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; + 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); + 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); + rofd_document_get_warnings(0, &warnings, 0); + rofd_warning_list_free(warnings); + 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, 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) + set(OFD_SUPPORT_RESOLVED ON) + else() + message(WARNING ">>> OFD support disabled: install librofd-ffi-dev, or pass -DROFD_ROOT=") + 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/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/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/debian/control b/debian/control index 786e1d7c8..9b5da795e 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 (>= 0.4.0), libglib2.0-dev, libdjvulibre-dev, libtiff-dev, 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/plans/2026-09-11-ofd-navigation.md b/docs/superpowers/plans/2026-09-11-ofd-navigation.md new file mode 100644 index 000000000..09770f1b0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-ofd-navigation.md @@ -0,0 +1,201 @@ +# 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`. + +- [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. +- [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 }; +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. +- [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`. + +- [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);`. +- [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. +- [x] Probe outline/destination functions at configure time and require + `librofd-ffi-dev (>= 0.4.0)`; refresh warnings after lazy navigation queries. +- [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. +- [x] 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`. + +- [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. +- [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. +- [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. +- [x] For a typed URI, revalidate, display the final URL in SecurityDialog, and + call QDesktopServices only after Accepted. Share this path with Task 4. +- [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. +- [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`. + +- [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());`. +- [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. +- [x] `SheetBrowser::jump2Link` calls navigateTo when `link.navigation` exists; + otherwise preserves the old PDF/XPS path. Keep resolved URI text for hover tips. +- [x] Extend CMake symbol checks and tests for gaps, deterministic overlap, + unsafe targets, failure isolation from outlines and repeated queries. +- [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. + +- [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. +- [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. +- [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-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. 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..722199d94 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-ofd-navigation-design.md @@ -0,0 +1,149 @@ +# OFD 目录与页面链接接入设计 + +## 范围与基线 + +在现有 OFD 渲染、语义文本、元数据和警告支持上,接入 rofd 的目录树、 +跳转目标和页面链接,复用 reader 的侧栏、页面交互及安全确认界面。 + +本次核对的 rofd main 为 `2331199`,包含: + +- `56da925`:像素区域渲染,reader 已接入。 +- `6db9bac`:元数据和警告,reader 已接入。 +- `9e4a169`:目录、动作与目标,本次接入。 +- `2331199`:页面链接映射,本次接入。 + +实施时补充:rofd 随后推进到 `35cb164`,发布版本为 0.4.0。因此本次实现使用 +配套的 0.4.0 头文件和共享库,并将 Debian 最低依赖更新为 0.4.0。 + +不增加附件提取、附件执行、自动动作、脚本、注释编辑或文件写回。 +不重构 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;实施时上游已正式 +发布 0.4.0,发行版依赖下限相应更新为 0.4.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 做真实窗口的目录/链接交互验证。 +界面验证与后台测试分别报告,不把适配层测试通过当成用户交互已验证。 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/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..01dcde1cd 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"; @@ -58,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 f34d8c3b1..0ecf7ba3f 100644 --- a/reader/app/Global.h +++ b/reader/app/Global.h @@ -42,10 +42,14 @@ 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); +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/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/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index d61023445..fd533784e 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,61 @@ 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; + + // 目标校验成功后,主动导航取代旧的恢复任务;否则页码信号会被守卫 + // 忽略,布局稳定后还会跳回保存的锚点。目录和页面链接共用此路径。 + m_sheet->cancelRestoreGuard(); + + { + 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"; @@ -1915,13 +1973,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; } @@ -2218,6 +2271,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/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/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/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/reader/document/OfdModel.cpp b/reader/document/OfdModel.cpp new file mode 100644 index 000000000..08627b1ed --- /dev/null +++ b/reader/document/OfdModel.cpp @@ -0,0 +1,896 @@ +// 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 + +#include +#include +#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; + + 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(); + } + + loadMetadata(); + warningDetails(); + 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); + warningDetails(); + 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; +} + +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; + // 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 +{ + 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("; ")); + } +} + +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 + || (!slice.isNull() && !slice.isValid())) { + 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; + // 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"); + rofd_error_free(rofdError); + return QImage(); + } + + 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:" << target.size(); + return QImage(); + } + image.fill(Qt::white); + + cairo_surface_t *surface = cairo_image_surface_create_for_data(image.bits(), + CAIRO_FORMAT_ARGB32, + image.width(), + image.height(), + 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; + 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); + } + warningDetails(); + + 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_surface_flush(surface); + 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_error_free(rofdError); + return image; +} + +OfdPage::OfdPage(const OfdDocument *document, rofd_page_t *pageHandle, int pageIndex) + : m_document(document) + , m_page(pageHandle) + , m_pageIndex(pageIndex) +{ + 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; + } +} + +OfdPage::~OfdPage() +{ + rofd_page_free(m_page); +} + +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) { + return QImage(); + } + return m_document->renderPage(m_page, width, height, slice); +} + +QString OfdPage::text(const QRectF &rect) const +{ + 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 +{ + 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 + +#endif // OFD_SUPPORT_ENABLED diff --git a/reader/document/OfdModel.h b/reader/document/OfdModel.h new file mode 100644 index 000000000..ff58f233c --- /dev/null +++ b/reader/document/OfdModel.h @@ -0,0 +1,107 @@ +// 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 + +#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; + Outline outline() const override; + Properties properties() const override; + QString fileIdentifier() 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: + 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; + rofd_renderer_t *m_renderer = nullptr; + 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; +}; + +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; + QList words() override; + Link getLinkAtPoint(const QPointF &point) 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; + QMutex m_linksMutex; + bool m_linksLoaded = false; + QList m_links; +}; + +} // namespace deepin_reader + +#endif // OFD_SUPPORT_ENABLED + +#endif // OFDMODEL_H diff --git a/reader/document/document.pri b/reader/document/document.pri index 9b2041461..acd8aff52 100644 --- a/reader/document/document.pri +++ b/reader/document/document.pri @@ -11,6 +11,13 @@ xps_support { SOURCES += $$PWD/XpsTextExtractor.cpp } +# OFD支持文件(条件包含,需要系统安装的 librofd-ffi-dev,见 debian/control) +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/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 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/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/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/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/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index 6a4cdf584..9a9ce3029 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_CATALOG | PREVIEW_BOOKMARK); #endif else m_sidebar = new SheetSidebar(this); @@ -354,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"; @@ -867,6 +876,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 +900,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 ""; } @@ -1479,6 +1497,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()) { @@ -1917,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 63a0c97a5..c6b9f4f0f 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 @@ -692,6 +694,9 @@ class DocSheet : public Dtk::Widget::DSplitter */ void beginRestoreGuard(bool notifyTip); + /** 有效的用户导航优先于尚未完成的阅读位置恢复。 */ + void cancelRestoreGuard(); + /** * @brief 获取当前滚动位置(0.0~1.0) */ 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/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/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/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/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)); +} 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/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/document/ut_ofdmodel.cpp b/tests/document/ut_ofdmodel.cpp new file mode 100644 index 000000000..22a43095e --- /dev/null +++ b/tests/document/ut_ofdmodel.cpp @@ -0,0 +1,861 @@ +// 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 + +#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(); +} + +QString createOfdFixture(const QTemporaryDir &dir, const QByteArray &info, + const QByteArray &pageArea = QByteArray(), + const QByteArray &documentExtras = QByteArray(), + const QByteArray &secondPage = QByteArray(), + const QByteArray &extraObjects = QByteArray()) +{ + QMap entries = { + {"OFD.xml", "" + info + + "Document.xml"}, + {"Document.xml", "7 11 210 297" + "" + + (secondPage.isEmpty() ? QByteArray() : QByteArray("")) + + "" + + documentExtras + ""}, + {"Page.xml", "" + pageArea + "" + extraObjects + "" + "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()) + return {}; + } + QProcess archive; + archive.setWorkingDirectory(dir.path()); + 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 + ""; +} + +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 +{ +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(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(""), + QByteArray("") + outlineNode("root", "") + + ""}) { + 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(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); +} + +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, 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(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" + "one二one").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(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)); + 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; + 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 000000000..e5e355228 Binary files /dev/null and b/tests/files/normal.ofd differ diff --git a/tests/ofd-model/CMakeLists.txt b/tests/ofd-model/CMakeLists.txt new file mode 100644 index 000000000..ca5a8828f --- /dev/null +++ b/tests/ofd-model/CMakeLists.txt @@ -0,0 +1,48 @@ +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 + ../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 + ${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}/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 + 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..d4ff94e35 --- /dev/null +++ b/tests/ofd-model/README.md @@ -0,0 +1,87 @@ +# Focused OFD adapter checks + +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. + +```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 +``` + +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`. + +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. +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. 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(); +} diff --git a/tests/ofd-model/navigation_smoke.cc b/tests/ofd-model/navigation_smoke.cc new file mode 100644 index 000000000..15cb65f94 --- /dev/null +++ b/tests/ofd-model/navigation_smoke.cc @@ -0,0 +1,376 @@ +// 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 "ThumbnailDelegate.h" +#include "SideBarImageViewModel.h" +#include "EyeProtectionManager.h" +#include "NightFilter.h" +#include +#include +#include +#include +#include +#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(int milliseconds = 250) +{ + QEventLoop loop; + QTimer::singleShot(milliseconds, &loop, &QEventLoop::quit); + loop.exec(); +} +class UrlSink : public QObject { + Q_OBJECT +public: + int calls = 0; +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); + 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" + "" + "" + "" + "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"); + // 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"); + + 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"); + 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"); + + 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"); + + 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"); + 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, + 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"); + 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"); + verifyThumbnailAppearance(); + qInfo("PASS: %d real-widget navigation/appearance 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() diff --git a/tests/sidebar-appearance/CMakeLists.txt b/tests/sidebar-appearance/CMakeLists.txt new file mode 100644 index 000000000..18cad65dd --- /dev/null +++ b/tests/sidebar-appearance/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.16) +project(reader-sidebar-appearance-tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +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) +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 + 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 Qt${QT_VERSION_MAJOR}::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); +} 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