From 87b44d9a1a9d5ca6eb81fc2a378327e99833df9b Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:38:02 -0700 Subject: [PATCH 01/43] Complete G00 repository governance and provenance (#108) --- .github/workflows/LinuxInstall.yml | 2 +- .github/workflows/ci.yml | 5 + .github/workflows/sync-milestones.yml | 23 - .../io.github.mberrys.Loop-pdf.appdata.xml | 286 --- README.md | 22 +- architecture/proof-lanes.yaml | 36 + changes/chore-g00-governance.evidence.yaml | 18 + changes/chore-g00-governance.md | 8 + ...uct-surface-pruning-implementation-plan.md | 6 +- docs/ARCHITECTURE_PROOF_DISPOSITIONS.md | 26 + docs/GOVERNANCE_SOURCE_REGISTER.md | 259 +++ docs/LEGACY_ISSUE_PROVENANCE.md | 18 +- docs/LOOP_WORKSPACES.md | 6 +- docs/REPOSITORY_GOVERNANCE.md | 116 + docs/REPO_MAP.md | 2 +- docs/VERSIONING.md | 7 +- .../generated/preflight-coverage-backlog.json | 24 +- docs/github-milestones/README.md | 70 +- docs/github-milestones/manifest.json | 99 - docs/governance/proof-obligations.json | 247 ++ docs/governance/source-register.json | 1997 +++++++++++++++++ docs/preflight-check-catalog-overlay.json | 48 +- docs/product-surface.json | 32 +- scripts/agent/check-change.py | 6 + scripts/ci/check_governance_registers.py | 234 ++ scripts/ci/check_loop_identity.py | 48 + scripts/ci/test_check_governance_registers.py | 66 + scripts/ci/test_check_loop_identity.py | 22 + scripts/github/sync_milestones.py | 235 +- scripts/github/test_sync_milestones.py | 133 +- 30 files changed, 3223 insertions(+), 878 deletions(-) delete mode 100644 .github/workflows/sync-milestones.yml create mode 100644 changes/chore-g00-governance.evidence.yaml create mode 100644 changes/chore-g00-governance.md create mode 100644 docs/ARCHITECTURE_PROOF_DISPOSITIONS.md create mode 100644 docs/GOVERNANCE_SOURCE_REGISTER.md create mode 100644 docs/REPOSITORY_GOVERNANCE.md delete mode 100644 docs/github-milestones/manifest.json create mode 100644 docs/governance/proof-obligations.json create mode 100644 docs/governance/source-register.json create mode 100644 scripts/ci/check_governance_registers.py create mode 100644 scripts/ci/test_check_governance_registers.py diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 19beb1838..72fbc769e 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -248,7 +248,7 @@ jobs: version=$(cat "loop/build/version.txt") echo "Version: $version" echo "appimagefilename=Loop-pdf-$version-x86_64.AppImage" >> $GITHUB_ENV - echo "appimage_update_info=gh-releases-zsync|mberrys|Loop-pdf|latest|Loop-pdf-*-x86_64.AppImage.zsync" >> $GITHUB_ENV + echo "appimage_update_info=gh-releases-zsync|studio-berry|loop|latest|Loop-pdf-*-x86_64.AppImage.zsync" >> $GITHUB_ENV echo "AppImage file name: Loop-pdf-$version-x86_64.AppImage" echo "package_name=Loop-pdf-$version-x86_64.AppImage" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1421d7e33..743698a93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,11 @@ jobs: run: python3 scripts/ci/test_check_loop_identity.py - name: Verify active Loop identity run: python3 scripts/ci/check_loop_identity.py + - name: Verify repository governance and retired milestone writes + run: | + python3 -m unittest scripts.ci.test_check_governance_registers scripts.github.test_sync_milestones -q + python3 scripts/ci/check_governance_registers.py + python3 scripts/github/sync_milestones.py - name: Verify tracked source integrity run: python3 scripts/ci/check_source_integrity.py - name: Verify authoritative product-surface contract diff --git a/.github/workflows/sync-milestones.yml b/.github/workflows/sync-milestones.yml deleted file mode 100644 index 5775d00e8..000000000 --- a/.github/workflows/sync-milestones.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Sync GitHub milestones - -on: - workflow_dispatch: - -permissions: - contents: read - issues: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Verify supply-chain policy - run: python3 scripts/ci/check_supply_chain_pins.py - - - name: Sync milestone descriptions - env: - GH_TOKEN: ${{ github.token }} - run: python scripts/github/sync_milestones.py --apply diff --git a/Desktop/io.github.mberrys.Loop-pdf.appdata.xml b/Desktop/io.github.mberrys.Loop-pdf.appdata.xml index e8746b312..6ad68b115 100644 --- a/Desktop/io.github.mberrys.Loop-pdf.appdata.xml +++ b/Desktop/io.github.mberrys.Loop-pdf.appdata.xml @@ -21,292 +21,6 @@ https://github.com/studio-berry/loop io.github.mberrys.Loop-pdf.desktop - - - - - Issue #389: Adding hyperlink to _internal_ object in PDF - Issue #388: Update Windows color management system - Issue #385: PDFTextLayoutGenerator::isContentKindSuppressed(ContentKind kind) is missing ContentKind::Form - Issue #384: In the "Optimize Images" dialog, the info on the final image resolution and final DPI does not update - Issue #383: UX improvements for Loop PageMaster tool (v1.5.3.1) (ex. DocPage Organizer) - Issue #382: Startup Settings - Issue #381: Separated apps for double-click viewer in Linux - Issue #380: Ability to run app with custom settings directory - executable parameter with path - Issue #379: Advanced Find - Wildcard Mode - Issue #378: Advanced Find - Should start searching if Enter key is pressed - Issue #376: Deleting a note jumps to Outline - Issue #375: Not enough maximum compiled page cache - Issue #373: Ctrl/Shift keyboard selection for Outline - Issue #372: Option to not color images - Issue #370: Extracting pages within a range - Issue #369: Keeping redact box on - Issue #368: Side-to-side scrolling - Issue #357: Bulk delete/add/edit of page labels - Issue #356: Compatibility issues - font problems - Issue #354: Color blend mode for highlights - Issue #352: Icon size of the sidebar - Issue #349: Add inherit zoom to bookmark zoom options - Issue #338: Editor toolbox higher than editor window - Issue #334: Impossible to set French language - Issue #326: Checkboxes don't render in Loop - Issue #324: Menu text not rendered with correct color - Issue #321: Select text in Viewer - Issue #291: Support for editing XMP metadata or exporting to PDF/UA format - Issue #282: Editor outline view always zooms to around 50% - Issue #256: Loop cannot show some specific fonts correctly - Issue #253: Undo/redo doesn't work in "edit page content" mode - Issue #250: Snapping - Issue #242: Full screen - Issue #234: Setting font, font size and area of text annotations - Issue #230: Garbled characters when opening PDF files with Loop - Issue #225: Loop cannot open PDF files with embedded files - Issue #224: Option to remove invisible text - Issue #194: Change page size - Issue #160: Color | Custom (green/black) does not work - Issue #136: Smooth scrolling of document with mouse middle wheel - flywheel - Issue #92: Add image compression to PDF DocPage Organizer - Issue #18: Performance optimization - OutputPreview Renderer - - - - - - Windows installer fix - - - - - This release was withdrawn. - - Issue #341: Embed AppImage update information - Issue #339: Traditional Chinese translation file - Issue #333: ijg-libjpeg obsoleted in vcpkg - Issue #329: How to remove "External links" - Issue #269: Nested bookmarks lose children when moving - Issue #179: Adding a JPG or PNG signature image crashes Loop - Issue #146: Incorrect fonts displayed on Linux - Issue #144: Drag and drop text note annotations - - - - - - - Issue #313: Remove history UX improvement - Issue #310: Docpage organiser uses PDF title rather than filename - Issue #306: Unable to delete the recent files history. - Issue #303: Add Dockerfile for Simplified Loop Setup with Qt 6.9.1 and All Dependencies - Issue #301: Russian Language file - Issue #296: Redaction annotation support in PdfTool - Issue #295: Request: Make page layout choice persist between files - Issue #279: qt_add_translations uses options only in qt 6.9 - Issue #284: Scaling dimensions [Enhancements, Features] - Issue #281: install API headers for reuse by other applications ? - Issue #251: Let user lock direction of lines/polylines when pressing keybinding (e.g. shift) - - - - - - - Issue #271: Change of the license to MIT - Issue #257: Force light mode (or dark mode) - Issue #255: Page Master invert selection does not work - Issue #254: Unit Settings default window size too small - Issue #248: Update version and year in About page - Issue #247: Option to hide cross when adding annotations - - - - - - - Issue #244: Unite document after page removal loses outline - Issue #236: Highlighting - Allow color selection and intensity - Issue #229: Can't add picture to PDF - Issue #228: Reverse pages order - Issue #222: UnitTests fails several tests - Issue #207: Zoom to Cursor (Zoom Anchoring) - Issue #206: Name of the executable / command - Issue #205: Editor cannot create white or transparent annotations - Issue #202: Certain raster content not rendered - Issue #200: Icons under dark theme on Arch/KDE broken - Issue #185: Latest git fails to build on Linux - Issue #165: Inline Text latency issues on Flatpak/AppImage - Issue #161: Trust certificates like in Acrobat - - - - - - - Issue #190: PageMaster crash after removing pages - Issue #184: Form field font size too large - Issue #183: Option to ignore PDF security settings - Issue #182: Diff command-line arguments ignored - Issue #181: Dark mode feature - Issue #180: Options windows larger than desktop - Issue #174: Limited Linux support improvements - Issue #173: Errors loading Echoplex.pdf - Issue #172: Missing access keys in menus - Issue #168: Stamp disappears when merging files - Issue #166: Adding images to PDF - Issue #164: Taskbar icon not shown on Linux Mint - Issue #163: Invalid PDF rendering issues - Issue #161: Trust certificates like in Acrobat - Issue #159: Separate AppImage offerings - Issue #123: Alternative rendering backend (Blend2D) - Issue #42: Real text editing improvements - Issue #26: Add pictures in PDF files - - - - - - - Issue #142: Cannot reach the top - Issue #141: Save button in toolbar - Issue #140: New bookmarks created empty? - Issue #139: Changing page layout resets the current page - Issue #137: Redaction - Cannot convert '#000000' to color value - Issue #134: Add search bar for actions - Issue #133: Rectangle selector when choosing the page of the PDF - Issue #129: Cannot compile with lcms 2.16 - Issue #128: Create list of markup annotations - Issue #126: Remove <QtCore> include from main headers - Issue #119: Improve search bar 2 (the revenge) - Issue #118: Adding CMake options for minimal builds - Issue #116: Improve search bar (remember searched text, allow scroll and zoom) - Issue #115: Redesign of sidebar widget, new icons - Issue #113: Color | Custom (green/black) partially works in scanned PDF - Issue #112: Smooth scrolling - Issue #104: pdf_3d branch and license - Issue #102: PDF engine implementation details - Issue #99: Icons for dark mode - Issue #55: System certificates store not found - Issue #54: Bookmarks for pages - - - - - - - Issue #110: Viewer Lite arbitrary code execution when using Send by Email - Issue #109: Text, font and page parts missing - Issue #108: Add accessibility options in PDF reader - Issue #107: Create document with converted bitonic images - Issue #106: Hardware acceleration is not supported warning - Issue #103: Bookmark sidebar greyed out if a bookmark is not already present - Issue #100: Highlight rectangle - Issue #97: DocPage Organizer drag and drop, context menu and responsive layout - Issue #94: New directory structure - Issue #83: Default font for unrecognized fonts - Issue #76: Outline editing - Issue #58: PDF document with rotated Type 3 fonts - - - - - - - Issue #65: Text-to-speech doesn't work on Linux - Issue #72: Cannot select text - Issue #82: Hot reload function - Issue #80: Optimizer is slow on large PDF files - Issue #77: Document read error - Issue #78: Error decompressing stream - - - - - - - Issue #75: Redesign of sidebar widget - Issue #74: Highlighting text opens the sidebar every time - Issue #73: Minor GUI issues - Issue #71: Pages are mirrored - Issue #69: Dashed line not rendered as expected - Issue #68: Add alternative vcpkg file with dependency on Qt - Issue #59: Fix character font encoding for Mac Roman and Unicode tables - Issue #50: Some types of PDF don't open as expected - - - - - - - Issue #49: Document read error - Issue #46: Program does not launch or freezes on Windows - Issue #48: Page not displayed correctly - Issue #45: Blank page in DocPage Organizer - Issue #43: Save question - Issue #40: Sanitization of documents - - - - - - - Issue #39: Code-signed installation - - - - - - - Issue #34: Reset option - Issue #35: Window state is not remembered for viewers - - - - - - - Issue #21: Table selection tool - Issue #22: Solve compilation warnings - Issue #24: Text selection and table selection algorithm improvement - Issue #25: Move to Qt 6.4, CMake and vcpkg - Issue #28: Add multiple PDF files at once for merging - Issue #33: ToUnicode for simple fonts not used - Issue #32: New stable release - - - - - - - Issue #17: Public key security handler - Issue #19: Remove dependency on Microsoft Visual Studio Redistributable Packages - - - - - - - Issue #10: Performance optimization - Issue #14: Incorrect text drawing for vertical writing systems - Issue #15: MSI installer - Static XFA support (read-only, simple XFA only) - Electronically and digitally sign documents - - - - - - - DocDiff application for comparing similar PDF documents - Linux support - New icon set - - - - - - Initial beta release for non-production testing - - - Edit content of PDF documentshttps://jakubmelka.github.io/assets/flatpak/profi-editor.png Browse PDF files easily and efficientlyhttps://jakubmelka.github.io/assets/flatpak/profi-default.png diff --git a/README.md b/README.md index 38ed9131c..90461192e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ -# Loop-PDF +# Loop A desktop PDF toolkit for editing, inspecting, validating, and automating PDF workflows. -[Download](https://github.com/mberrys/Loop-pdf/releases) · [Platform support](docs/PLATFORM_SUPPORT.md) · [Project guide](docs/REPO_MAP.md) · [Build from source](#build-from-source) +Current source version: `0.2.1-alpha`. Topic branches start from `dev`; +`unstable` qualifies changes and `stable` is the default and release branch. +See the [identity and governance decision](docs/REPOSITORY_GOVERNANCE.md). -> **New install?** Get Loop-PDF from the official [GitHub releases](https://github.com/mberrys/Loop-pdf/releases). Windows and Linux are supported for V1; macOS source builds are best-effort only. +[Download](https://github.com/studio-berry/loop/releases) · [Platform support](docs/PLATFORM_SUPPORT.md) · [Project guide](docs/REPO_MAP.md) · [Build from source](#build-from-source) + +> **New install?** Get Loop from the official [GitHub releases](https://github.com/studio-berry/loop/releases). Windows and Linux are supported for V1; macOS source builds are best-effort only. ## What it does @@ -31,19 +35,19 @@ A desktop PDF toolkit for editing, inspecting, validating, and automating PDF wo Use Loop’s own release artifacts, not upstream packages. -- **Windows (x64):** download the MSI or portable ZIP from [GitHub releases](https://github.com/mberrys/Loop-pdf/releases). **V1 installers are unsigned** — Windows SmartScreen will show an “unrecognized app” warning on first install. Choose **More info** → **Run anyway** to proceed. Verify the download against `SHA256SUMS.txt` on the release page before installing. -- **Linux (x64):** download the AppImage from [GitHub releases](https://github.com/mberrys/Loop-pdf/releases), `chmod +x` it, and run. Verify the download against `SHA256SUMS.txt` on the release page. Flatpak packaging exists (`LinuxFlatpak.yml`) but isn't yet attached to releases. `ci.yml` also builds a `.deb` on every push to `master`, but **it does not work** — verified by installing it on a clean Ubuntu 22.04 container: `dpkg -i` succeeds, but the binary fails to launch (`libQt6Gui.so.6: cannot open shared object file`) because, unlike the AppImage, it doesn't bundle the Qt runtime, and it also has a glibc version mismatch against 22.04. Do not install the `.deb` until it's fixed. +- **Windows (x64):** download the MSI or portable ZIP from [GitHub releases](https://github.com/studio-berry/loop/releases). **V1 installers are unsigned** — Windows SmartScreen will show an “unrecognized app” warning on first install. Choose **More info** → **Run anyway** to proceed. Verify the download against `SHA256SUMS.txt` on the release page before installing. +- **Linux (x64):** download the AppImage from [GitHub releases](https://github.com/studio-berry/loop/releases), `chmod +x` it, and run. Verify the download against `SHA256SUMS.txt` on the release page. Flatpak packaging remains a separate CI artifact; release availability is determined by the release page. - **macOS:** not supported for V1. There is no official package, notarization, or macOS CI coverage. See [platform support](docs/PLATFORM_SUPPORT.md) for supported configurations, package layouts, and current validation notes. ## Build from source -Loop-PDF requires a C++20 compiler, Qt 6.11.1 or newer, and vcpkg. Windows and Linux are the supported development targets. +Loop requires a C++20 compiler, Qt 6.11.1 or newer, and vcpkg. Windows and Linux are the supported development targets. ```bash -git clone https://github.com/mberrys/Loop-pdf.git -cd Loop-pdf +git clone https://github.com/studio-berry/loop.git +cd loop git clone https://github.com/Microsoft/vcpkg.git ./vcpkg/bootstrap-vcpkg.sh -disableMetrics @@ -72,6 +76,6 @@ Contributions, testing, feedback, and bug reports are welcome. Please read [AGEN ## License and acknowledgements -Loop-PDF is based on [PDF4QT](https://github.com/JakubMelka/PDF4QT) and is currently distributed under the MIT License. **The license is subject to change at any time.** It includes third-party components with their own license obligations, including Qt, FreeType, OpenJPEG, OpenSSL, Little CMS, zlib, libjpeg, and Blend2D. Review the [packaging and licensing guide](docs/PACKAGING_LICENSING.md) before distributing a build. +Loop is based on [PDF4QT](https://github.com/JakubMelka/PDF4QT) and is currently distributed under the MIT License. **The license is subject to change at any time.** It includes third-party components with their own license obligations, including Qt, FreeType, OpenJPEG, OpenSSL, Little CMS, zlib, libjpeg, and Blend2D. Review the [packaging and licensing guide](docs/PACKAGING_LICENSING.md) before distributing a build. Copyright © 2026 Michael Berry. Portions are copyright © 2019 The FreeType Project. diff --git a/architecture/proof-lanes.yaml b/architecture/proof-lanes.yaml index a9ef9f108..da7cf7a40 100644 --- a/architecture/proof-lanes.yaml +++ b/architecture/proof-lanes.yaml @@ -430,6 +430,42 @@ subsystems: ref: .github/workflows/WindowsInstall.yml contains: "qualify_windows:" executes: binding + - id: repository-governance + summary: Product identity, source qualification, proof ownership and retired milestone writes. + paths: + - README.md + - docs/REPOSITORY_GOVERNANCE.md + - docs/GOVERNANCE_SOURCE_REGISTER.md + - docs/ARCHITECTURE_PROOF_DISPOSITIONS.md + - docs/governance/** + - docs/github-milestones/** + - scripts/github/** + - scripts/ci/check_loop_identity.py + - scripts/ci/test_check_loop_identity.py + - scripts/ci/check_governance_registers.py + - scripts/ci/test_check_governance_registers.py + - Desktop/io.github.mberrys.Loop-pdf.appdata.xml + required: + - kind: unit + bind: script + id: scripts/ci/test_check_governance_registers.py + executes: binding + - kind: unit + bind: script + id: scripts/ci/test_check_loop_identity.py + executes: binding + - kind: unit + bind: script + id: scripts/github/test_sync_milestones.py + executes: binding + - kind: architecture + bind: script + id: scripts/ci/check_governance_registers.py + executes: binding + - kind: architecture + bind: script + id: scripts/ci/check_loop_identity.py + executes: binding - id: agent-policy summary: Agent policy, architecture contracts, and the contract checker. paths: diff --git a/changes/chore-g00-governance.evidence.yaml b/changes/chore-g00-governance.evidence.yaml new file mode 100644 index 000000000..7737d10ff --- /dev/null +++ b/changes/chore-g00-governance.evidence.yaml @@ -0,0 +1,18 @@ +format_version: 1 +kind: evidence +claims: + - id: governance-records-and-write-retirement + evidence: + - unit:scripts/ci/test_check_governance_registers.py + - unit:scripts/ci/test_check_loop_identity.py + - unit:scripts/github/test_sync_milestones.py + - architecture:scripts/ci/check_governance_registers.py + - architecture:scripts/ci/check_loop_identity.py + - architecture:scripts/generate-architecture-catalogs.py + - id: agent-policy-tooling + evidence: + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py +unresolved: + - Pending architecture measurements and supplemental guards retain their declared or deferred disposition in docs/ARCHITECTURE_PROOF_DISPOSITIONS.md. diff --git a/changes/chore-g00-governance.md b/changes/chore-g00-governance.md new file mode 100644 index 000000000..876085da6 --- /dev/null +++ b/changes/chore-g00-governance.md @@ -0,0 +1,8 @@ +Category: internal +Audience: contributors and repository maintainers +Breaking-Change: no +Summary: Reconcile Loop identity and issue provenance, retire the obsolete milestone writer, and freeze source and architecture proof ownership for G00. + +Retain persisted package and application identifiers. Correct canonical links and +the AppImage update destination. Preserve historical sources and pending proof +limitations in checked, source-qualified registers. diff --git a/docs/ADR-005-product-surface-pruning-implementation-plan.md b/docs/ADR-005-product-surface-pruning-implementation-plan.md index d65bd5337..b6ffb4f96 100644 --- a/docs/ADR-005-product-surface-pruning-implementation-plan.md +++ b/docs/ADR-005-product-surface-pruning-implementation-plan.md @@ -5,7 +5,7 @@ **Status:** The Issue #191 product-surface contract is implemented. The checked-in manifest, source/package verifier, and profile-specific CI checks now describe the current post-Session-05 tree; Editor workspace integration remains planned for -#193 / 0.0.1B. +legacy #193 / 0.0.1B. ## 0.2B transitional surface contract @@ -97,7 +97,7 @@ The implementation must follow the ADR-005 order: profile-aware manifest and explicit `source_status` fields. - [ ] Resolve the redaction decision against GitHub #66 before changing the Editor plugin or the supported CLI surface. -- [x] Preserve Compare as `OPEN` with owner `m.berry` and follow-up #193 while +- [x] Preserve Compare as `OPEN` with owner `m.berry` and follow-up legacy #193 while recording the already-deleted `LoopDiff` as absent. - [ ] Decide whether developer-menu visibility is a build flag, a release setting, or both. The existing `m_allowDeveloperMode` setting is a useful @@ -274,7 +274,7 @@ build is available, validate the installed tree in this order: - Phase 1: Implemented — slim release flag is explicit and developer tools remain outside the release profile - Phase 2: Implemented — desktop/AppX/Flatpak/WiX sources and staged artifact checks derive from the manifest - Phase 3: Partial — release-only Developer menu gating is implemented; full Loop information architecture remains follow-up work -- Phase 4: Boundary recorded; Editor workspace wiring remains deferred to #193 +- Phase 4: Boundary recorded; Editor workspace wiring remains deferred to legacy #193 - Phase 5: Deferred until level-3 removals are explicitly approved **Overall:** Issue #191 implementation is complete on diff --git a/docs/ARCHITECTURE_PROOF_DISPOSITIONS.md b/docs/ARCHITECTURE_PROOF_DISPOSITIONS.md new file mode 100644 index 000000000..89fc3db06 --- /dev/null +++ b/docs/ARCHITECTURE_PROOF_DISPOSITIONS.md @@ -0,0 +1,26 @@ +# Architecture proof ownership and dispositions + +Reviewed source: `9fea0095221911fa81dcfa816a49d090995bddbd`. Read-back date: 2026-10-10. + +Generated from the JSON records under [governance](governance/). Regenerate with `python scripts/ci/check_governance_registers.py --write`. + +These records identify source, code, tests, owners, and decisions. A closed issue or merged PR supplies tracking provenance. Runtime proof, independent proof, and release admission are not revalidated by this inventory. + +No new metric enforcement is admitted. Fixed budgets and sealed outputs are unchanged. A supplemental guard that requires an exported API or schema remains subject to an architect checkpoint. + +| Obligation | Current declaration and disposition | Owner and tracker | Existing evidence | Closure criterion and limits | +| --- | --- | --- | --- | --- | +| `deterministic-core` | enforced; deferred | Core parser/writer and security maintainers; [#96](https://github.com/studio-berry/loop/issues/96), [#136](https://github.com/studio-berry/loop/issues/136) | [LoopLibCore/sources/pdfdocumentbuilder.cpp](../LoopLibCore/sources/pdfdocumentbuilder.cpp), [architecture/invariants.yaml](../architecture/invariants.yaml) | Classify clock/RNG call sites and separately admit parser/writer guards under #96/#136. Preserve legitimate document/signing timestamps until that decision. QtNetwork isolation is enforced; the clock/RNG classification TODO remains deferred. | +| `d1-canonical-json-bytes` | declared; deferred | Governed-execution maintainers; [#5](https://github.com/studio-berry/loop/issues/5), [#33](https://github.com/studio-berry/loop/issues/33) | [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp), [UnitTests/testdata/canonical-json/operation-plan-envelope.json](../UnitTests/testdata/canonical-json/operation-plan-envelope.json), [docs/CANONICAL_JSON.md](../docs/CANONICAL_JSON.md) | Admit a scoped include/call-site guard with deliberate noncanonical digest fixtures before closing the supplemental TODO. Golden vectors exist; no complete static call-site guard is admitted. | +| `d2-preview-non-publishing` | declared; deferred | Governed-execution maintainers; [#5](https://github.com/studio-berry/loop/issues/5), [#35](https://github.com/studio-berry/loop/issues/35) | [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp), [LoopLibCore/sources/pdfgovernedexecution.cpp](../LoopLibCore/sources/pdfgovernedexecution.cpp) | Define the publication boundary and reject a deliberate preview-to-publisher call in a binding CI guard before changing this declaration. Behavioral preview negatives exist; the supplemental call-graph guard is deferred. | +| `d3-destination-not-in-plan-digest` | declared; deferred | Governed-execution maintainers; [#5](https://github.com/studio-berry/loop/issues/5), [#37](https://github.com/studio-berry/loop/issues/37) | [UnitTests/testdata/canonical-json/operation-plan-envelope.json](../UnitTests/testdata/canonical-json/operation-plan-envelope.json), [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp) | Bind an envelope-key guard and reject a destination-bearing plan fixture before admitting static enforcement. Destination-binding behavior is tested; the supplemental envelope guard is deferred. | +| `d4-publication-vs-durable-completion` | declared; deferred | Governed-execution and provenance maintainers; [#5](https://github.com/studio-berry/loop/issues/5), [#39](https://github.com/studio-berry/loop/issues/39) | [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp), [LoopLibCore/sources/pdfoperationhistory.cpp](../LoopLibCore/sources/pdfoperationhistory.cpp) | Keep publication and durable completion distinct. A proposed state schema requires an architect checkpoint, migration fixtures and failure proof. Publish-without-sign-off behavior exists; no new exported state schema is admitted. | +| `d5-cross-surface-identity-equality` | declared; fulfilled | Governed-execution adapter and CI maintainers; [#40](https://github.com/studio-berry/loop/issues/40) | [scripts/ci/check_governed_parity.py](../scripts/ci/check_governed_parity.py), [UnitTests/tst_productoperatorloop.cpp](../UnitTests/tst_productoperatorloop.cpp), [UnitTests/testdata/governed-parity/editor-receipt.json](../UnitTests/testdata/governed-parity/editor-receipt.json), [.github/workflows/ci.yml](../.github/workflows/ci.yml) | Retain named repair, Action List, Editor and PageMaster fixtures and governedPublicationParityAcrossSurfaces. Re-run binding behavior on the candidate. The named-fixture TODO has binding evidence. The architecture row remains declared and current runtime proof is not asserted. | +| `search-latency` | declared; deferred | Document search and benchmark maintainers; [#136](https://github.com/studio-berry/loop/issues/136), [#185](https://github.com/studio-berry/loop/issues/185) | [UnitTests/tst_documentsearchtest.cpp](../UnitTests/tst_documentsearchtest.cpp), [UnitTests/tst_benchmarkidentitytest.cpp](../UnitTests/tst_benchmarkidentitytest.cpp) | Measure ms/page over a pinned corpus, toolchain, platform and reference machine. Bind the producer/comparator and show a value over 150 fails. The 150 ms/page cap is declared; a committed timing baseline is unavailable. | +| `bleed-fixup-latency` | declared; deferred | Bleed-fixup and benchmark maintainers; [#136](https://github.com/studio-berry/loop/issues/136), [#51](https://github.com/studio-berry/loop/issues/51) | [UnitTests/tst_bleedfixuptest.cpp](../UnitTests/tst_bleedfixuptest.cpp) | Publish per-page elapsed time with exact fixture/platform identity and prove a value over 150 ms/page fails. The 150 ms/page cap has no admitted timing producer or baseline. | +| `resident-memory` | declared; deferred | Core resource-envelope and benchmark maintainers; [#96](https://github.com/studio-berry/loop/issues/96), [#136](https://github.com/studio-berry/loop/issues/136) | [UnitTests/tst_processingbudgettest.cpp](../UnitTests/tst_processingbudgettest.cpp), [docs/RESOURCE_ENVELOPE_QUALIFICATION.md](../docs/RESOURCE_ENVELOPE_QUALIFICATION.md) | Measure widgets-free process RSS on pinned inputs and platforms. Bind the comparator and prove a value over 512 MiB fails. Processing budgets are not an RSS measurement; the 512 MiB cap remains declared. | +| `parser-differential-agreement` | declared; deferred | Independent-oracle and preflight qualification maintainers; [#95](https://github.com/studio-berry/loop/issues/95) | [UnitTests/tst_standardoracletest.cpp](../UnitTests/tst_standardoracletest.cpp), [UnitTests/tst_conversionoracletest.cpp](../UnitTests/tst_conversionoracletest.cpp) | Publish independent-oracle agreement with tool versions, corpus identity and exact SHA. Reject agreement below 1 and missing measurements. The oracle tests exist; a machine-readable agreement producer/comparator is not admitted. | +| `packaging-size` | declared; deferred | Linux and Windows packaging maintainers; [#94](https://github.com/studio-berry/loop/issues/94), [#136](https://github.com/studio-berry/loop/issues/136) | [.github/workflows/LinuxInstall.yml](../.github/workflows/LinuxInstall.yml), [.github/workflows/WindowsInstall.yml](../.github/workflows/WindowsInstall.yml), [scripts/ci/verify_release_assets.py](../scripts/ci/verify_release_assets.py) | Pin paired Linux/Windows package byte baselines and compare matching tool/platform identities. Show growth over 2 percent and missing artifacts fail. No admitted package-byte baseline exists; current packaging-size enforcement remains unavailable. | +| `sealed-rendering-channel-delta` | declared; deferred | Fixture custodians and independent print qualification maintainers; [#51](https://github.com/studio-berry/loop/issues/51), [#95](https://github.com/studio-berry/loop/issues/95) | [architecture/fixtures.yaml](../architecture/fixtures.yaml), [UnitTests/testdata/fixture-classes/sealed-eval/mixed-surface-holdout.yaml](../UnitTests/testdata/fixture-classes/sealed-eval/mixed-surface-holdout.yaml) | Obtain authorized held-out raised-text, mixed-paint and texture measurements. Bind the comparator and show channel delta above 2 fails. Sealed outputs cannot be retuned by this change. Held-out measurement remains unavailable. | +| `canonical-json-digest-stability` | declared; deferred | Governed-execution and qualification maintainers; [#5](https://github.com/studio-berry/loop/issues/5), [#136](https://github.com/studio-berry/loop/issues/136) | [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp), [UnitTests/testdata/canonical-json/operation-plan-envelope.json](../UnitTests/testdata/canonical-json/operation-plan-envelope.json) | Publish the digest-vector set count with identities and bind its comparator. A count below 1 or absent measurement must fail. Golden digest behavior exists; the metric artifact/count producer remains deferred. | +| `compare-review-delta-classification` | declared; deferred | Quick Compare and qualification maintainers; [#4](https://github.com/studio-berry/loop/issues/4), [#136](https://github.com/studio-berry/loop/issues/136) | [UnitTests/tst_productoperatorloop.cpp](../UnitTests/tst_productoperatorloop.cpp), [architecture/quality-budgets.yaml](../architecture/quality-budgets.yaml) | Publish golden comparison-vector counts with exact pair identity. Bind the comparator and prove zero or absent vectors fail. The subsequently merged Compare metric is included. Classification behavior is not its missing count artifact. | diff --git a/docs/GOVERNANCE_SOURCE_REGISTER.md b/docs/GOVERNANCE_SOURCE_REGISTER.md new file mode 100644 index 000000000..a27ef25a2 --- /dev/null +++ b/docs/GOVERNANCE_SOURCE_REGISTER.md @@ -0,0 +1,259 @@ +# Cross-module source and disposition register + +Reviewed source: `9fea0095221911fa81dcfa816a49d090995bddbd`. Read-back date: 2026-10-10. + +Generated from the JSON records under [governance](governance/). Regenerate with `python scripts/ci/check_governance_registers.py --write`. + +These records identify source, code, tests, owners, and decisions. A closed issue or merged PR supplies tracking provenance. Runtime proof, independent proof, and release admission are not revalidated by this inventory. + +## Named roadmap sources + +| Source | Availability and disposition | Owner and next action | +| --- | --- | --- | +| [Loop](https://app.notion.com/p/3bb9cb079ddb80c4a15feaa98f963f4c) | Readable planning or research source; not Loop runtime or release proof. | Repository maintainers keep G00 and the publication index aligned with GitHub. | +| [Issues](https://app.notion.com/p/3c89cb079ddb8026b704e72ac9bec3ec) | Deleted database; schema readable; legacy item bodies unavailable. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Sessions](https://app.notion.com/p/3c89cb079ddb8076b0b0e5ca8fa195a9) | Deleted database; schema readable; legacy item bodies unavailable. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Orchestration](https://app.notion.com/p/3c09cb079ddb80cb9a31ee5dd083739d) | Deleted page; historical text snapshot readable; not current authority. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Roadmap](https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f) | Deleted page; historical text snapshot readable; not current authority. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Loop — Architecture & Execution Plan v2 — 2026-09-05](https://app.notion.com/p/3d39cb079ddb815796fdf41944edb452) | Deleted page; historical text snapshot readable; not current authority. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Loop — Product Vision & Strategic Positioning](https://app.notion.com/p/3b49cb079ddb81ab9208c3c55a6e26a9) | Deleted page; historical text snapshot readable; not current authority. | Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements. | +| [Medic — Product Architecture & End-State Design](https://app.notion.com/p/3c79cb079ddb8150ba69c833b1531048) | Readable planning or research source; not Loop runtime or release proof. | Medic owner reconciles evidence under #47 before admitting host behavior. | +| [Medic V5 - Acceptance Experiment Execution Plan](https://app.notion.com/p/3d99cb079ddb81ce955fccd0968fecfe) | Readable planning or research source; not Loop runtime or release proof. | Medic owner reconciles evidence under #47 before admitting host behavior. | +| [V6-REV — adversarial review of the V6 architecture](https://app.notion.com/p/3db9cb079ddb81ecbd76ccdf7d227086) | Readable planning or research source; not Loop runtime or release proof. | Medic owner reconciles evidence under #47 before admitting host behavior. | +| [Jev research epic — findings and provisional architecture decision (2026-09-23)](https://app.notion.com/p/3e59cb079ddb818b86ced3a34485d451) | Readable planning or research source; not Loop runtime or release proof. | L08 owner uses #61-#67 and #261 for task-specific qualification. | +| [R4 — Jev-like empirical qualification and acceptance ledger (2026-09-23)](https://app.notion.com/p/3e59cb079ddb81c5b712e2a71e21c7f4) | Readable planning or research source; not Loop runtime or release proof. | L08 owner uses #61-#67 and #261 for task-specific qualification. | + +## Module intake + +| Module and source identities | Owner and disposition | Current issues | Evidence | Next action and limits | +| --- | --- | --- | --- | --- | +| L01: `legacy#618`, `legacy#619` | LoopLibCore preflight and qualification; partially-present | [#2](https://github.com/studio-berry/loop/issues/2), [#15](https://github.com/studio-berry/loop/issues/15), [#16](https://github.com/studio-berry/loop/issues/16), [#17](https://github.com/studio-berry/loop/issues/17), [#18](https://github.com/studio-berry/loop/issues/18), [#19](https://github.com/studio-berry/loop/issues/19), [#20](https://github.com/studio-berry/loop/issues/20), [#21](https://github.com/studio-berry/loop/issues/21), [#113](https://github.com/studio-berry/loop/issues/113), [#114](https://github.com/studio-berry/loop/issues/114), [#115](https://github.com/studio-berry/loop/issues/115), [#116](https://github.com/studio-berry/loop/issues/116), [#117](https://github.com/studio-berry/loop/issues/117), [#118](https://github.com/studio-berry/loop/issues/118), [#119](https://github.com/studio-berry/loop/issues/119), [#120](https://github.com/studio-berry/loop/issues/120) | [docs/EVIDENCE_CORE_RESET_INVENTORY.md](../docs/EVIDENCE_CORE_RESET_INVENTORY.md), [LoopLibCore/sources/preflightengine.cpp](../LoopLibCore/sources/preflightengine.cpp), [UnitTests/tst_preflightverdicttest.cpp](../UnitTests/tst_preflightverdicttest.cpp) | Reuse #15's inventory and the existing #16-#21 and #113-#120 outcomes. Revalidate accepted claims on a chosen candidate. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. The reviewed P3 color-inventory-probe-depth and thin-parts-raster-budget deferrals remain in #15's inventory. No legacy status is current exact-SHA proof. | +| L02: `legacy#584` | LoopLibInteraction; partially-present | [#3](https://github.com/studio-berry/loop/issues/3), [#22](https://github.com/studio-berry/loop/issues/22), [#23](https://github.com/studio-berry/loop/issues/23), [#24](https://github.com/studio-berry/loop/issues/24), [#25](https://github.com/studio-berry/loop/issues/25), [#26](https://github.com/studio-berry/loop/issues/26), [#103](https://github.com/studio-berry/loop/issues/103), [#104](https://github.com/studio-berry/loop/issues/104), [#206](https://github.com/studio-berry/loop/issues/206) | [LoopLibInteraction/sources/documentfacade.cpp](../LoopLibInteraction/sources/documentfacade.cpp), [UnitTests/tst_documentfacadetest.cpp](../UnitTests/tst_documentfacadetest.cpp) | Keep interaction changes under the existing module children, including #206. No replacement facade is scheduled. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L03: `legacy#477`, `legacy#586`, `legacy#193`, `legacy#195`, `legacy#66`, `legacy#30`, `legacy#41`, `legacy#126` | LoopEditor and LoopLibQuick; partially-present | [#4](https://github.com/studio-berry/loop/issues/4), [#27](https://github.com/studio-berry/loop/issues/27), [#28](https://github.com/studio-berry/loop/issues/28), [#29](https://github.com/studio-berry/loop/issues/29), [#30](https://github.com/studio-berry/loop/issues/30), [#31](https://github.com/studio-berry/loop/issues/31), [#32](https://github.com/studio-berry/loop/issues/32), [#121](https://github.com/studio-berry/loop/issues/121), [#122](https://github.com/studio-berry/loop/issues/122), [#123](https://github.com/studio-berry/loop/issues/123), [#124](https://github.com/studio-berry/loop/issues/124), [#125](https://github.com/studio-berry/loop/issues/125), [#126](https://github.com/studio-berry/loop/issues/126), [#205](https://github.com/studio-berry/loop/issues/205), [#231](https://github.com/studio-berry/loop/issues/231) | [docs/LOOP_WORKSPACES.md](../docs/LOOP_WORKSPACES.md), [LoopEditor/editorhost.cpp](../LoopEditor/editorhost.cpp), [UnitTests/tst_productoperatorloop.cpp](../UnitTests/tst_productoperatorloop.cpp) | Reuse delivered workspace work and keep residual command and absorbed-GUI coverage under #231. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L04: `legacy#587`, `legacy#588`, `legacy#589` | LoopLibCore governed execution; partially-present | [#5](https://github.com/studio-berry/loop/issues/5), [#33](https://github.com/studio-berry/loop/issues/33), [#34](https://github.com/studio-berry/loop/issues/34), [#35](https://github.com/studio-berry/loop/issues/35), [#36](https://github.com/studio-berry/loop/issues/36), [#37](https://github.com/studio-berry/loop/issues/37), [#38](https://github.com/studio-berry/loop/issues/38), [#39](https://github.com/studio-berry/loop/issues/39), [#40](https://github.com/studio-berry/loop/issues/40) | [LoopLibCore/sources/pdfgovernedexecution.cpp](../LoopLibCore/sources/pdfgovernedexecution.cpp), [UnitTests/tst_governedexecutiontest.cpp](../UnitTests/tst_governedexecutiontest.cpp), [docs/CORRECTION_COVERAGE_MATRIX.md](../docs/CORRECTION_COVERAGE_MATRIX.md) | Reuse #33-#40 and #221 provenance; retain the PDFFunction resource-contract deferral. New exported contracts require an architect checkpoint. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L05: `legacy#238`, `legacy#619` | PdfTool and worker supervision; partially-present | [#6](https://github.com/studio-berry/loop/issues/6), [#41](https://github.com/studio-berry/loop/issues/41), [#42](https://github.com/studio-berry/loop/issues/42), [#43](https://github.com/studio-berry/loop/issues/43), [#44](https://github.com/studio-berry/loop/issues/44), [#45](https://github.com/studio-berry/loop/issues/45), [#46](https://github.com/studio-berry/loop/issues/46) | [PdfTool/pdfworkerclient.cpp](../PdfTool/pdfworkerclient.cpp), [UnitTests/tst_pdfworkerisolation.cpp](../UnitTests/tst_pdfworkerisolation.cpp), [LoopLibCore/sources/pdfpagemasterexport.cpp](../LoopLibCore/sources/pdfpagemasterexport.cpp) | Audit consumer coverage and complete existing #41-#46 parity and worker gates. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L06: `legacy#47`, `notion:3c79cb079ddb8150ba69c833b1531048`, `notion:3d99cb079ddb81ce955fccd0968fecfe`, `notion:3db9cb079ddb81ecbd76ccdf7d227086` | Medic worker and Loop add-bleed adapter; partially-present | [#7](https://github.com/studio-berry/loop/issues/7), [#47](https://github.com/studio-berry/loop/issues/47), [#48](https://github.com/studio-berry/loop/issues/48), [#49](https://github.com/studio-berry/loop/issues/49), [#50](https://github.com/studio-berry/loop/issues/50), [#51](https://github.com/studio-berry/loop/issues/51), [#52](https://github.com/studio-berry/loop/issues/52), [#53](https://github.com/studio-berry/loop/issues/53), [#54](https://github.com/studio-berry/loop/issues/54) | [LoopLibCore/sources/pdfbleedfixup.cpp](../LoopLibCore/sources/pdfbleedfixup.cpp), [UnitTests/tst_bleedfixuptest.cpp](../UnitTests/tst_bleedfixuptest.cpp), [docs/EVIDENCE_CORE_RESET_INVENTORY.md](../docs/EVIDENCE_CORE_RESET_INVENTORY.md) | Use #47's host/evidence reconciliation and #48-#54. V5 candidate measurements and the V6 deadlock review do not admit released composite behavior. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L07: `legacy#34`, `legacy#128`, `legacy#404`, `legacy#405`, `legacy#406`, `legacy#407`, `legacy#408`, `legacy#409`, `legacy#410`, `legacy#411`, `legacy#412`, `legacy#413`, `legacy#414`, `legacy#415`, `legacy#416`, `legacy#591`, `legacy#592`, `legacy#593` | Proposed Job module; gap | [#8](https://github.com/studio-berry/loop/issues/8), [#55](https://github.com/studio-berry/loop/issues/55), [#56](https://github.com/studio-berry/loop/issues/56), [#57](https://github.com/studio-berry/loop/issues/57), [#58](https://github.com/studio-berry/loop/issues/58), [#59](https://github.com/studio-berry/loop/issues/59), [#60](https://github.com/studio-berry/loop/issues/60) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [docs/generated/architecture-catalog.json](../docs/generated/architecture-catalog.json), [LoopLibCore/sources/pdfoperationhistorystore.cpp](../LoopLibCore/sources/pdfoperationhistorystore.cpp) | Use #55-#60 for the accepted Job contract and qualification. The existing operation history is not a Job store. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L08: `legacy#584`, `legacy#590`, `legacy#371`, `legacy#686`, `notion:3e59cb079ddb818b86ced3a34485d451`, `notion:3e59cb079ddb81c5b712e2a71e21c7f4` | Proposed Decision module; gap | [#9](https://github.com/studio-berry/loop/issues/9), [#61](https://github.com/studio-berry/loop/issues/61), [#62](https://github.com/studio-berry/loop/issues/62), [#63](https://github.com/studio-berry/loop/issues/63), [#64](https://github.com/studio-berry/loop/issues/64), [#65](https://github.com/studio-berry/loop/issues/65), [#66](https://github.com/studio-berry/loop/issues/66), [#67](https://github.com/studio-berry/loop/issues/67), [#261](https://github.com/studio-berry/loop/issues/261) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [docs/generated/architecture-catalog.json](../docs/generated/architecture-catalog.json) | Use #61-#67 and #261. Keep no-provider/manual parity and human qualification gates; do not import Jev model results as Loop production proof. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L09: `legacy#418`, `legacy#419`, `legacy#420`, `legacy#421`, `legacy#422`, `legacy#423`, `legacy#424`, `legacy#425`, `legacy#426`, `legacy#427`, `legacy#428`, `legacy#429`, `legacy#594` | Proposed Intake module; gap | [#10](https://github.com/studio-berry/loop/issues/10), [#68](https://github.com/studio-berry/loop/issues/68), [#69](https://github.com/studio-berry/loop/issues/69), [#70](https://github.com/studio-berry/loop/issues/70), [#71](https://github.com/studio-berry/loop/issues/71), [#72](https://github.com/studio-berry/loop/issues/72), [#73](https://github.com/studio-berry/loop/issues/73) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [LoopLibCore/sources/pdfocrpagegate.cpp](../LoopLibCore/sources/pdfocrpagegate.cpp), [UnitTests/tst_ocrpagegatetest.cpp](../UnitTests/tst_ocrpagegatetest.cpp) | Use #68-#73. Existing page-gated OCR is a reusable input, not proof of reviewed JobSpec extraction. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L10: `legacy#431`, `legacy#432`, `legacy#433`, `legacy#434`, `legacy#435`, `legacy#436`, `legacy#437`, `legacy#438`, `legacy#439`, `legacy#440`, `legacy#441` | Proposed Outcome module; gap | [#11](https://github.com/studio-berry/loop/issues/11), [#74](https://github.com/studio-berry/loop/issues/74), [#75](https://github.com/studio-berry/loop/issues/75), [#76](https://github.com/studio-berry/loop/issues/76), [#77](https://github.com/studio-berry/loop/issues/77), [#78](https://github.com/studio-berry/loop/issues/78), [#79](https://github.com/studio-berry/loop/issues/79) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [LoopLibCore/sources/pdfoperationhistory.cpp](../LoopLibCore/sources/pdfoperationhistory.cpp) | Use #74-#79. Artifact operation history does not imply human-confirmed press observations. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L11: `legacy#443`, `legacy#444`, `legacy#445`, `legacy#446`, `legacy#447`, `legacy#448`, `legacy#449`, `legacy#450`, `legacy#451`, `legacy#452`, `legacy#453`, `legacy#595` | Proposed Workflow module; gap | [#12](https://github.com/studio-berry/loop/issues/12), [#80](https://github.com/studio-berry/loop/issues/80), [#81](https://github.com/studio-berry/loop/issues/81), [#82](https://github.com/studio-berry/loop/issues/82), [#83](https://github.com/studio-berry/loop/issues/83), [#84](https://github.com/studio-berry/loop/issues/84), [#85](https://github.com/studio-berry/loop/issues/85) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [docs/generated/architecture-catalog.json](../docs/generated/architecture-catalog.json) | Use #80-#85. Confirmed outcomes, counterexamples and shadow proof precede promotion. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L12: `legacy#455`, `legacy#456`, `legacy#457`, `legacy#458`, `legacy#459`, `legacy#460`, `legacy#461`, `legacy#462`, `legacy#463`, `legacy#464`, `legacy#465`, `legacy#466`, `legacy#596`, `legacy#597` | Proposed Automation module; gap | [#13](https://github.com/studio-berry/loop/issues/13), [#86](https://github.com/studio-berry/loop/issues/86), [#87](https://github.com/studio-berry/loop/issues/87), [#88](https://github.com/studio-berry/loop/issues/88), [#89](https://github.com/studio-berry/loop/issues/89), [#90](https://github.com/studio-berry/loop/issues/90), [#91](https://github.com/studio-berry/loop/issues/91), [#92](https://github.com/studio-berry/loop/issues/92) | [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md), [LoopLibCore/sources/pdfgovernedexecution.cpp](../LoopLibCore/sources/pdfgovernedexecution.cpp) | Use #86-#92. Existing publication authority does not grant trigger authority or autonomous workflow admission. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L13: `studio-berry/loop#109`, `studio-berry/loop#127`, `legacy#587`, `legacy#588`, `legacy#589` | LoopLibCore correction catalog; partially-present | [#109](https://github.com/studio-berry/loop/issues/109), [#127](https://github.com/studio-berry/loop/issues/127), [#128](https://github.com/studio-berry/loop/issues/128), [#129](https://github.com/studio-berry/loop/issues/129), [#130](https://github.com/studio-berry/loop/issues/130), [#131](https://github.com/studio-berry/loop/issues/131), [#132](https://github.com/studio-berry/loop/issues/132), [#133](https://github.com/studio-berry/loop/issues/133), [#134](https://github.com/studio-berry/loop/issues/134), [#165](https://github.com/studio-berry/loop/issues/165), [#166](https://github.com/studio-berry/loop/issues/166), [#167](https://github.com/studio-berry/loop/issues/167), [#168](https://github.com/studio-berry/loop/issues/168), [#169](https://github.com/studio-berry/loop/issues/169), [#170](https://github.com/studio-berry/loop/issues/170), [#171](https://github.com/studio-berry/loop/issues/171), [#172](https://github.com/studio-berry/loop/issues/172), [#173](https://github.com/studio-berry/loop/issues/173), [#174](https://github.com/studio-berry/loop/issues/174), [#175](https://github.com/studio-berry/loop/issues/175), [#176](https://github.com/studio-berry/loop/issues/176), [#177](https://github.com/studio-berry/loop/issues/177), [#178](https://github.com/studio-berry/loop/issues/178) | [docs/CORRECTION_COVERAGE_MATRIX.md](../docs/CORRECTION_COVERAGE_MATRIX.md), [UnitTests/tst_repairoperationtest.cpp](../UnitTests/tst_repairoperationtest.cpp), [docs/generated/correction-operation-catalog.json](../docs/generated/correction-operation-catalog.json) | Use #127-#134 and their #165-#178 children. Research #177 precedes conditional implementation #178; safe declines remain part of admission. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| L14: `legacy#54`, `legacy#55`, `legacy#56`, `legacy#57`, `studio-berry/loop#179` | LoopLibInteraction, LoopLibQuick and benchmark maintainers; partially-present | [#179](https://github.com/studio-berry/loop/issues/179), [#180](https://github.com/studio-berry/loop/issues/180), [#181](https://github.com/studio-berry/loop/issues/181), [#182](https://github.com/studio-berry/loop/issues/182), [#183](https://github.com/studio-berry/loop/issues/183), [#184](https://github.com/studio-berry/loop/issues/184), [#185](https://github.com/studio-berry/loop/issues/185), [#186](https://github.com/studio-berry/loop/issues/186), [#187](https://github.com/studio-berry/loop/issues/187) | [UnitTests/tst_benchmarkidentitytest.cpp](../UnitTests/tst_benchmarkidentitytest.cpp), [LoopLibCore/sources/pdfdocumentsearch.cpp](../LoopLibCore/sources/pdfdocumentsearch.cpp), [architecture/quality-budgets.yaml](../architecture/quality-budgets.yaml) | Use #180-#187 and #136. Preserve benchmark and target-machine identity before admitting a performance result. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| R00: `legacy#468`, `legacy#469`, `legacy#470`, `legacy#471`, `legacy#472`, `legacy#473`, `legacy#474`, `legacy#475`, `legacy#476`, `legacy#477`, `legacy#478`, `legacy#479`, `legacy#598` | Release and qualification maintainers; partially-present | [#14](https://github.com/studio-berry/loop/issues/14), [#93](https://github.com/studio-berry/loop/issues/93), [#94](https://github.com/studio-berry/loop/issues/94), [#95](https://github.com/studio-berry/loop/issues/95), [#96](https://github.com/studio-berry/loop/issues/96), [#97](https://github.com/studio-berry/loop/issues/97), [#98](https://github.com/studio-berry/loop/issues/98), [#135](https://github.com/studio-berry/loop/issues/135), [#136](https://github.com/studio-berry/loop/issues/136), [#137](https://github.com/studio-berry/loop/issues/137), [#138](https://github.com/studio-berry/loop/issues/138), [#139](https://github.com/studio-berry/loop/issues/139) | [docs/CORE_QUALIFICATION.md](../docs/CORE_QUALIFICATION.md), [docs/RESOURCE_ENVELOPE_QUALIFICATION.md](../docs/RESOURCE_ENVELOPE_QUALIFICATION.md), [docs/NATIVE_ACCESSIBILITY_QUALIFICATION.md](../docs/NATIVE_ACCESSIBILITY_QUALIFICATION.md) | Use #93-#98 and #135-#139. #93 selects version, SHA, modules and deviations before a candidate-specific parent is instantiated. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof. | +| X00: `legacy#58`, `legacy#654`, `legacy#599`, `legacy#600`, `legacy#601`, `legacy#602`, `legacy#603`, `legacy#604`, `legacy#605`, `legacy#606`, `legacy#607`, `legacy#608`, `legacy#609`, `legacy#610` | Expansion decision owners; deferred | [#110](https://github.com/studio-berry/loop/issues/110), [#140](https://github.com/studio-berry/loop/issues/140), [#141](https://github.com/studio-berry/loop/issues/141), [#142](https://github.com/studio-berry/loop/issues/142), [#143](https://github.com/studio-berry/loop/issues/143), [#144](https://github.com/studio-berry/loop/issues/144), [#145](https://github.com/studio-berry/loop/issues/145), [#146](https://github.com/studio-berry/loop/issues/146), [#147](https://github.com/studio-berry/loop/issues/147), [#148](https://github.com/studio-berry/loop/issues/148) | [docs/generated/preflight-coverage-backlog.json](../docs/generated/preflight-coverage-backlog.json), [docs/EVIDENCE_CORE_RESET_INVENTORY.md](../docs/EVIDENCE_CORE_RESET_INVENTORY.md), [docs/ROADMAP_0.5.0-0.8.0.md](../docs/ROADMAP_0.5.0-0.8.0.md) | Reuse #140-#148 and #141's value-case decisions. Keep ink, imposition, PDF/VT, marks, editing, collaboration and conditional extensions decision-first. The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable. The reviewed P3 color-inventory-probe-depth and thin-parts-raster-budget deferrals remain in #15's inventory. No legacy status is current exact-SHA proof. | + +## Special historical dispositions + +| Source | Owner and disposition | Next action and limits | +| --- | --- | --- | +| `legacy#61` | Repository provenance maintainers; partially-present; [#209](https://github.com/studio-berry/loop/issues/209) | Recover the implemented claim's source and verify it before migrating behavior. The original issue body and exact implementation identity remain unavailable; an old implemented status proves nothing on the review SHA. | +| `legacy#123` | Repository provenance maintainers; obsolete; [#209](https://github.com/studio-berry/loop/issues/209) | Retain obsolete migration history as provenance. Schedule no replacement implementation. The roadmap classifies this migration history as obsolete; original issue text is unavailable. | + +## Product follow-up reconciliation + +| Product record | Retired reference | Current owner | +| --- | --- | --- | +| `dimensions-plugin` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `object-inspector-plugin` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `output-preview-plugin` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `soft-proofing-plugin` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `redact-plugin` | legacy #66 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `editor-plugin` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-preflight-plugin` | legacy #195 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `action-list-plugin` | legacy #30 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `ocr-plugin` | legacy #41 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-document` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-preflight` | legacy #195 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-production-preview` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-pages-production` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-inspect` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-fix` | legacy #126 | [#231](https://github.com/studio-berry/loop/issues/231) | +| `loop-compare` | legacy #193 | [#231](https://github.com/studio-berry/loop/issues/231) | + +## Live tracker snapshot + +| Qualified issue | Title | State at review | Milestone | +| --- | --- | --- | --- | +| [studio-berry/loop#2](https://github.com/studio-berry/loop/issues/2) | L01 — Evidence Core module gate | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#3](https://github.com/studio-berry/loop/issues/3) | L02 — Interaction Contract module gate | OPEN | L02 - Interaction Contract | +| [studio-berry/loop#4](https://github.com/studio-berry/loop/issues/4) | L03 — Quick Workspace module gate | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#5](https://github.com/studio-berry/loop/issues/5) | L04 — Governed Operation Engine module gate | OPEN | L04 - Governed Operation Engine | +| [studio-berry/loop#6](https://github.com/studio-berry/loop/issues/6) | L05 — Production Orchestrator module gate | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#7](https://github.com/studio-berry/loop/issues/7) | L06 — Medic Specialist module gate | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#8](https://github.com/studio-berry/loop/issues/8) | L07 — Job Spine module gate | OPEN | L07 - Job Spine | +| [studio-berry/loop#9](https://github.com/studio-berry/loop/issues/9) | L08 — Bounded Decision Worker module gate | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#10](https://github.com/studio-berry/loop/issues/10) | L09 — Intake module gate | OPEN | L09 - Intake | +| [studio-berry/loop#11](https://github.com/studio-berry/loop/issues/11) | L10 — Confirmed Outcomes module gate | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#12](https://github.com/studio-berry/loop/issues/12) | L11 — Workflow Memory module gate | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#13](https://github.com/studio-berry/loop/issues/13) | L12 — Automation Runtime module gate | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#14](https://github.com/studio-berry/loop/issues/14) | R00 — Cross-module release qualification gate | OPEN | R00 - Release Qualification | +| [studio-berry/loop#15](https://github.com/studio-berry/loop/issues/15) | L01-01 — Pin reset truth and evidence obligations | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#16](https://github.com/studio-berry/loop/issues/16) | L01-02 — Define complete inspection receipt | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#17](https://github.com/studio-berry/loop/issues/17) | L01-03 — Fence scheduled and worker results | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#18](https://github.com/studio-berry/loop/issues/18) | L01-04 — Prove independent standards and rendering claims | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#19](https://github.com/studio-berry/loop/issues/19) | L01-05 — Qualify hostile and production resource envelopes | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#20](https://github.com/studio-berry/loop/issues/20) | L01-06 — Audit untrusted PDF process isolation | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#21](https://github.com/studio-berry/loop/issues/21) | L01-07 — Admit exact-SHA Core qualification | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#22](https://github.com/studio-berry/loop/issues/22) | L02-01 — Define typed DocumentFacade state | CLOSED | L02 - Interaction Contract | +| [studio-berry/loop#23](https://github.com/studio-berry/loop/issues/23) | L02-02 — Navigate stable findings to source region | CLOSED | L02 - Interaction Contract | +| [studio-berry/loop#24](https://github.com/studio-berry/loop/issues/24) | L02-03 — Model cancel, retry, and reopen transitions | CLOSED | L02 - Interaction Contract | +| [studio-berry/loop#25](https://github.com/studio-berry/loop/issues/25) | L02-04 — Expose accessible operator semantics | CLOSED | L02 - Interaction Contract | +| [studio-berry/loop#26](https://github.com/studio-berry/loop/issues/26) | L02-05 — Prove adapter parity over one receipt | CLOSED | L02 - Interaction Contract | +| [studio-berry/loop#27](https://github.com/studio-berry/loop/issues/27) | L03-01 — Build one-document Quick operator shell | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#28](https://github.com/studio-berry/loop/issues/28) | L03-02 — Separate Production Preview from fast canvas | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#29](https://github.com/studio-berry/loop/issues/29) | L03-03 — Add Compare and review workspace | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#30](https://github.com/studio-berry/loop/issues/30) | L03-04 — Present plan and approval journey | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#31](https://github.com/studio-berry/loop/issues/31) | L03-05 — Qualify native accessibility and Widgets-free graph | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#32](https://github.com/studio-berry/loop/issues/32) | L03-06 — Run clean-machine operator acceptance | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#33](https://github.com/studio-berry/loop/issues/33) | L04-01 — Reconcile registry and canonical plan contract | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#34](https://github.com/studio-berry/loop/issues/34) | L04-02 — Enforce operation-owned save and impact policy | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#35](https://github.com/studio-berry/loop/issues/35) | L04-03 — Compute isolated nonpublishing preview | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#36](https://github.com/studio-berry/loop/issues/36) | L04-04 — Bind exact-plan approval and revocation | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#37](https://github.com/studio-berry/loop/issues/37) | L04-05 — Centralize approved Core execution | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#38](https://github.com/studio-berry/loop/issues/38) | L04-06 — Revalidate exact published bytes | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#39](https://github.com/studio-berry/loop/issues/39) | L04-07 — Converge provenance and sign-off | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#40](https://github.com/studio-berry/loop/issues/40) | L04-08 — Implement forward rollback and cross-surface parity | CLOSED | L04 - Governed Operation Engine | +| [studio-berry/loop#41](https://github.com/studio-berry/loop/issues/41) | L05-01 — Define PdfTool supervisor-worker protocol | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#42](https://github.com/studio-berry/loop/issues/42) | L05-02 — Survive worker crash, timeout, and breach | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#43](https://github.com/studio-berry/loop/issues/43) | L05-03 — Preserve production stage order | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#44](https://github.com/studio-berry/loop/issues/44) | L05-04 — Audit scheduler consumers and queue pressure | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#45](https://github.com/studio-berry/loop/issues/45) | L05-05 — Prove CLI and Action List policy parity | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#46](https://github.com/studio-berry/loop/issues/46) | L05-06 — Qualify restartable batch publication | OPEN | L05 - Production Orchestrator | +| [studio-berry/loop#47](https://github.com/studio-berry/loop/issues/47) | L06-01 — Pin Medic host boundary and research evidence | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#48](https://github.com/studio-berry/loop/issues/48) | L06-02 — Version capability handshake and no-worker D0 | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#49](https://github.com/studio-berry/loop/issues/49) | L06-03 — Recover authored exterior before synthesis | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#50](https://github.com/studio-berry/loop/issues/50) | L06-04 — Evaluate deterministic and specialist candidates | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#51](https://github.com/studio-berry/loop/issues/51) | L06-05 — Validate every candidate on print axes | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#52](https://github.com/studio-berry/loop/issues/52) | L06-06 — Run held-out released-composite comparison | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#53](https://github.com/studio-berry/loop/issues/53) | L06-07 — Connect Medic preview to add-bleed gateway | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#54](https://github.com/studio-berry/loop/issues/54) | L06-08 — Shadow, canary, and rollback decision | OPEN | L06 - Medic Specialist | +| [studio-berry/loop#55](https://github.com/studio-berry/loop/issues/55) | L07-01 — Define minimal Job identity and field provenance | OPEN | L07 - Job Spine | +| [studio-berry/loop#56](https://github.com/studio-berry/loop/issues/56) | L07-02 — Persist encrypted local job lifecycle | OPEN | L07 - Job Spine | +| [studio-berry/loop#57](https://github.com/studio-berry/loop/issues/57) | L07-03 — Resolve job-aware profiles and plans | OPEN | L07 - Job Spine | +| [studio-berry/loop#58](https://github.com/studio-berry/loop/issues/58) | L07-04 — Project queue and status over existing scheduler | OPEN | L07 - Job Spine | +| [studio-berry/loop#59](https://github.com/studio-berry/loop/issues/59) | L07-05 — Package job artifacts for client handoff | OPEN | L07 - Job Spine | +| [studio-berry/loop#60](https://github.com/studio-berry/loop/issues/60) | L07-06 — Qualify full job lifecycle and recovery | OPEN | L07 - Job Spine | +| [studio-berry/loop#61](https://github.com/studio-berry/loop/issues/61) | L08-01 — Define indexed decision state and one-request question contract | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#62](https://github.com/studio-berry/loop/issues/62) | L08-02 — Build deterministic no-provider policy over the same action space | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#63](https://github.com/studio-berry/loop/issues/63) | L08-03 — Select and pin providers behind the one-request port | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#64](https://github.com/studio-berry/loop/issues/64) | L08-04 — Contain private state, hostile documents, and model output | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#65](https://github.com/studio-berry/loop/issues/65) | L08-05 — Calibrate task and stratum abstention | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#66](https://github.com/studio-berry/loop/issues/66) | L08-06 — Consume observations once through Medic routing and plan-assist adapters | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#67](https://github.com/studio-berry/loop/issues/67) | L08-07 — Qualify two clients, decision receipts, and the inspector | OPEN | L08 - Bounded Decision Worker | +| [studio-berry/loop#68](https://github.com/studio-berry/loop/issues/68) | L09-01 — Define source-attributed Intake contract | OPEN | L09 - Intake | +| [studio-berry/loop#69](https://github.com/studio-berry/loop/issues/69) | L09-02 — Page-gate OCR and sidecar lifecycle | OPEN | L09 - Intake | +| [studio-berry/loop#70](https://github.com/studio-berry/loop/issues/70) | L09-03 — Build deterministic spec-field parsers | OPEN | L09 - Intake | +| [studio-berry/loop#71](https://github.com/studio-berry/loop/issues/71) | L09-04 — Quarantine watch-folder imports | OPEN | L09 - Intake | +| [studio-berry/loop#72](https://github.com/studio-berry/loop/issues/72) | L09-05 — Review and confirm JobSpec candidates | OPEN | L09 - Intake | +| [studio-berry/loop#73](https://github.com/studio-berry/loop/issues/73) | L09-06 — Qualify intake under adversarial client data | OPEN | L09 - Intake | +| [studio-berry/loop#74](https://github.com/studio-berry/loop/issues/74) | L10-01 — Version production event import schema | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#75](https://github.com/studio-berry/loop/issues/75) | L10-02 — Implement idempotent file-based importers | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#76](https://github.com/studio-berry/loop/issues/76) | L10-03 — Match events to exact Loop outputs | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#77](https://github.com/studio-berry/loop/issues/77) | L10-04 — Add operator confirmation and rejection | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#78](https://github.com/studio-berry/loop/issues/78) | L10-05 — Build append-only confirmed-result ledger | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#79](https://github.com/studio-berry/loop/issues/79) | L10-06 — Qualify observe-mode history | OPEN | L10 - Confirmed Outcomes | +| [studio-berry/loop#80](https://github.com/studio-berry/loop/issues/80) | L11-01 — Mine confirmed repeat-job patterns | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#81](https://github.com/studio-berry/loop/issues/81) | L11-02 — Produce recommendation evidence cards | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#82](https://github.com/studio-berry/loop/issues/82) | L11-03 — Evaluate optional ranking versus transparent rules | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#83](https://github.com/studio-berry/loop/issues/83) | L11-04 — Replay candidates in shadow mode | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#84](https://github.com/studio-berry/loop/issues/84) | L11-05 — Version edits and promotion request | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#85](https://github.com/studio-berry/loop/issues/85) | L11-06 — Qualify support and no-recommendation behavior | OPEN | L11 - Workflow Memory | +| [studio-berry/loop#86](https://github.com/studio-berry/loop/issues/86) | L12-01 — Define immutable promotion and authorization | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#87](https://github.com/studio-berry/loop/issues/87) | L12-02 — Implement bounded trigger adapters | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#88](https://github.com/studio-berry/loop/issues/88) | L12-03 — Enforce budgets, canaries, and kill switch | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#89](https://github.com/studio-berry/loop/issues/89) | L12-04 — Recover idempotently after crash | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#90](https://github.com/studio-berry/loop/issues/90) | L12-05 — Use Core gateway and revalidation for every run | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#91](https://github.com/studio-berry/loop/issues/91) | L12-06 — Project client-visible status from receipts | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#92](https://github.com/studio-berry/loop/issues/92) | L12-07 — Qualify bounded canary and rollback | OPEN | L12 - Automation Runtime | +| [studio-berry/loop#93](https://github.com/studio-berry/loop/issues/93) | R00-01 — Select exact release candidate and module set | OPEN | R00 - Release Qualification | +| [studio-berry/loop#94](https://github.com/studio-berry/loop/issues/94) | R00-02 — Build and inspect Linux/Windows artifacts | OPEN | R00 - Release Qualification | +| [studio-berry/loop#95](https://github.com/studio-berry/loop/issues/95) | R00-03 — Prove independent standards and print acceptance | OPEN | R00 - Release Qualification | +| [studio-berry/loop#96](https://github.com/studio-berry/loop/issues/96) | R00-04 — Prove security, privacy, and resource envelope | OPEN | R00 - Release Qualification | +| [studio-berry/loop#97](https://github.com/studio-berry/loop/issues/97) | R00-05 — Close licensing, migration, and rollback dossier | OPEN | R00 - Release Qualification | +| [studio-berry/loop#98](https://github.com/studio-berry/loop/issues/98) | R00-06 — Make release admission decision | OPEN | R00 - Release Qualification | +| [studio-berry/loop#103](https://github.com/studio-berry/loop/issues/103) | Select and Hand toolbar buttons are checkable placeholders with no behavior | CLOSED | L14 - Interaction Performance | +| [studio-berry/loop#104](https://github.com/studio-berry/loop/issues/104) | Completed drags are discarded: EditorHost::onDragCompleted drops the DragSession | CLOSED | L14 - Interaction Performance | +| [studio-berry/loop#108](https://github.com/studio-berry/loop/issues/108) | G00 — Reset repository governance and provenance | OPEN | G00 - Repository Governance | +| [studio-berry/loop#109](https://github.com/studio-berry/loop/issues/109) | L13 — Correction Catalog Breadth module gate | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#110](https://github.com/studio-berry/loop/issues/110) | X00 — Expansion candidate register | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#111](https://github.com/studio-berry/loop/issues/111) | G00-01 — Re-home legacy issue provenance after the repository rename | CLOSED | G00 - Repository Governance | +| [studio-berry/loop#112](https://github.com/studio-berry/loop/issues/112) | G00-02 — Reconcile product identity, version, and branch references | OPEN | G00 - Repository Governance | +| [studio-berry/loop#113](https://github.com/studio-berry/loop/issues/113) | L01-08 — Classify ICC-based color by its painted space | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#114](https://github.com/studio-berry/loop/issues/114) | L01-09 — Audit glyph coverage in embedded fonts | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#115](https://github.com/studio-berry/loop/issues/115) | L01-10 — Evaluate optional-content membership for hidden content | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#116](https://github.com/studio-berry/loop/issues/116) | L01-11 — Stop isolated over-limit ink regions passing ink coverage | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#117](https://github.com/studio-berry/loop/issues/117) | L01-12 — Broaden invisible-content detection | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#118](https://github.com/studio-berry/loop/issues/118) | L01-13 — Make off-page and obscured-content geometry clip-aware | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#119](https://github.com/studio-berry/loop/issues/119) | L01-14 — Judge white overprint and transparency interaction on the authoritative renderer | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#120](https://github.com/studio-berry/loop/issues/120) | L01-15 — Close the sparse-mark bleed raster false pass | CLOSED | L01 - Evidence Core | +| [studio-berry/loop#121](https://github.com/studio-berry/loop/issues/121) | L03-07 — Ship operator documentation, first-run onboarding, and a sample corpus | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#122](https://github.com/studio-berry/loop/issues/122) | L03-08 — Open encrypted and password-protected documents in Quick | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#123](https://github.com/studio-berry/loop/issues/123) | L03-09 — Print and export from the Quick workspace | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#124](https://github.com/studio-berry/loop/issues/124) | L03-10 — Present undo and redo as governed revision history | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#125](https://github.com/studio-berry/loop/issues/125) | L03-11 — Show annotations, proof comments, and signature status in review | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#126](https://github.com/studio-berry/loop/issues/126) | L03-12 — Localize the Quick shell | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#127](https://github.com/studio-berry/loop/issues/127) | L13-01 — Define fixup admission contract and check-to-fix matrix | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#128](https://github.com/studio-berry/loop/issues/128) | L13-02 — Embed or outline fonts | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#129](https://github.com/studio-berry/loop/issues/129) | L13-03 — Map spot colors, normalize rich black, assign output intent, and limit ink | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#130](https://github.com/studio-berry/loop/issues/130) | L13-04 — Correct hairlines and white overprint | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#131](https://github.com/studio-berry/loop/issues/131) | L13-05 — Remove hidden, invisible, off-page, and active content | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#132](https://github.com/studio-berry/loop/issues/132) | L13-06 — Normalize page boxes, scale to trim, and rotation | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#133](https://github.com/studio-berry/loop/issues/133) | L13-07 — Flatten transparency with fidelity proof | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#134](https://github.com/studio-berry/loop/issues/134) | L13-08 — Qualify correction breadth across Editor, PdfTool, and Action List | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#135](https://github.com/studio-berry/loop/issues/135) | R00-07 — Make the macOS go/no-go decision | OPEN | R00 - Release Qualification | +| [studio-berry/loop#136](https://github.com/studio-berry/loop/issues/136) | R00-08 — Gate performance and scale on benchmark identities | OPEN | R00 - Release Qualification | +| [studio-berry/loop#137](https://github.com/studio-berry/loop/issues/137) | R00-09 — Decide installer signing, update channel, and SmartScreen posture | OPEN | R00 - Release Qualification | +| [studio-berry/loop#138](https://github.com/studio-berry/loop/issues/138) | R00-10 — Freeze public contracts and prove schema compatibility | OPEN | R00 - Release Qualification | +| [studio-berry/loop#139](https://github.com/studio-berry/loop/issues/139) | R00-11 — Decide opt-in crash and diagnostics reporting | OPEN | R00 - Release Qualification | +| [studio-berry/loop#140](https://github.com/studio-berry/loop/issues/140) | X00-01 — Decide the conditional Extensions module | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#141](https://github.com/studio-berry/loop/issues/141) | X00-02 — Re-file parked legacy expansion candidates with value cases | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#142](https://github.com/studio-berry/loop/issues/142) | X00-03 — Per-named-colorant ink limits for DeviceN and spot separations | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#143](https://github.com/studio-berry/loop/issues/143) | X00-04 — Barcode, slug, Braille, and dieline geometry validation | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#144](https://github.com/studio-berry/loop/issues/144) | X00-05 — Standards currency: PDF/X-6, PDF 2.0 output intents, and GWG 2022 check sets | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#145](https://github.com/studio-berry/loop/issues/145) | X00-06 — Verifiable receipts and content credentials for published artifacts | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#146](https://github.com/studio-berry/loop/issues/146) | X00-07 — XJDF/JMF job-ticket and device-outcome interchange | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#147](https://github.com/studio-berry/loop/issues/147) | X00-08 — Bounded local automation interface for agents and integrations | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#148](https://github.com/studio-berry/loop/issues/148) | X00-09 — PDF/UA accessibility preflight | OPEN | X00 - Expansion Candidates | +| [studio-berry/loop#165](https://github.com/studio-berry/loop/issues/165) | L13-03a — Qualify job-scoped spot-color mapping | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#166](https://github.com/studio-berry/loop/issues/166) | L13-03b — Normalize rich black using profile recipes | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#167](https://github.com/studio-berry/loop/issues/167) | L13-03c — Assign output intent with standards-safe validation | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#168](https://github.com/studio-berry/loop/issues/168) | L13-03d — Limit total ink via qualified ICC/DeviceLink path | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#169](https://github.com/studio-berry/loop/issues/169) | L13-04a — Correct thin strokes to the production minimum | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#170](https://github.com/studio-berry/loop/issues/170) | L13-04b — Convert unsafe white overprint to knockout | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#171](https://github.com/studio-berry/loop/issues/171) | L13-05a — Flatten optional content to the print configuration | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#172](https://github.com/studio-berry/loop/issues/172) | L13-05b — Remove exactly identified invisible and off-page objects | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#173](https://github.com/studio-berry/loop/issues/173) | L13-05c — Remove JavaScript and forbidden PDF actions safely | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#174](https://github.com/studio-berry/loop/issues/174) | L13-06a — Derive and validate TrimBox/BleedBox from trusted geometry | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#175](https://github.com/studio-berry/loop/issues/175) | L13-06b — Scale to trim with bounded geometry tolerance | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#176](https://github.com/studio-berry/loop/issues/176) | L13-06c — Normalize page rotation against rendered orientation | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#177](https://github.com/studio-berry/loop/issues/177) | L13-07a — Decide transparency flattener and fidelity budget (research gate) | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#178](https://github.com/studio-berry/loop/issues/178) | L13-07b — Implement admitted transparency flattening behind Core gateway | OPEN | L13 - Correction Catalog Breadth | +| [studio-berry/loop#179](https://github.com/studio-berry/loop/issues/179) | L14 — Interaction Performance module gate | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#180](https://github.com/studio-berry/loop/issues/180) | L14-01 — Decide stale-revision vs stale-resolution presentation rule | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#181](https://github.com/studio-berry/loop/issues/181) | L14-02 — Implement sub-rectangle page-surface rendering | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#182](https://github.com/studio-berry/loop/issues/182) | L14-03 — Refine zoom and scroll progressively from low to full resolution | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#183](https://github.com/studio-berry/loop/issues/183) | L14-04 — Prefetch adjacent pages and prioritize viewing over background work | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#184](https://github.com/studio-berry/loop/issues/184) | L14-05 — Enforce a GUI-thread stall budget | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#185](https://github.com/studio-berry/loop/issues/185) | L14-06 — Gate present-lane frame budgets on a pinned reference machine | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#186](https://github.com/studio-berry/loop/issues/186) | L14-07 — Measure cold start and open-to-first-view on installed artifacts | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#187](https://github.com/studio-berry/loop/issues/187) | L14-08 — Qualify long-session interaction feel | OPEN | L14 - Interaction Performance | +| [studio-berry/loop#205](https://github.com/studio-berry/loop/issues/205) | Ship a default recipe for the page-box drag move | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#206](https://github.com/studio-berry/loop/issues/206) | Decide drag-commit semantics for findings, guides and text | OPEN | L02 - Interaction Contract | +| [studio-berry/loop#207](https://github.com/studio-berry/loop/issues/207) | Reconcile or retire the legacy milestone sync after the reset | OPEN | G00 - Repository Governance | +| [studio-berry/loop#209](https://github.com/studio-berry/loop/issues/209) | Freeze the cross-module source and disposition register | OPEN | G00 - Repository Governance | +| [studio-berry/loop#210](https://github.com/studio-berry/loop/issues/210) | Assign owners and record decisions for pending architecture proof harnesses | OPEN | G00 - Repository Governance | +| [studio-berry/loop#231](https://github.com/studio-berry/loop/issues/231) | L03-13 — Restore Quick GUI coverage for the retired Widgets surfaces | OPEN | L03 - Quick Workspace | +| [studio-berry/loop#261](https://github.com/studio-berry/loop/issues/261) | L08-08 — Gate free-text slots behind a single-key text helper | OPEN | L08 - Bounded Decision Worker | + +## Existing pull-request provenance + +| PR | Title and status | Merge or head SHA | +| --- | --- | --- | +| [#149](https://github.com/studio-berry/loop/pull/149) | fix(catalog): separate live and legacy issue records (#111); MERGED | `e26d83a078ce8d141f4f67d08f6a2ef7f42bae3f` | +| [#155](https://github.com/studio-berry/loop/pull/155) | [0.3.0 first half] L01 Evidence Core train — unstable → stable; MERGED | `a492bf683d99a2fc4025f3f1f230695bc73286d0` | +| [#202](https://github.com/studio-berry/loop/pull/202) | L03-03: Add the Compare and review workspace (#29); MERGED | `e1a14184d4bf38d25ecdf17427def35eefe19bf1` | +| [#208](https://github.com/studio-berry/loop/pull/208) | L04-01: reconcile the operation registry and canonical plan contract (#33); MERGED | `f2e494d9607f86ed1738371727753d0dda40c86a` | +| [#221](https://github.com/studio-berry/loop/pull/221) | L04-02–08 — Governed execution, revalidation, provenance, and rollback; MERGED | `d217db55cca5c516807185801d4f9da6c7e7f8d0` | +| [#244](https://github.com/studio-berry/loop/pull/244) | Qualify native accessibility against final packages (#31); MERGED | `9fea0095221911fa81dcfa816a49d090995bddbd` | diff --git a/docs/LEGACY_ISSUE_PROVENANCE.md b/docs/LEGACY_ISSUE_PROVENANCE.md index 3ebf57fcb..e76ab8e8d 100644 --- a/docs/LEGACY_ISSUE_PROVENANCE.md +++ b/docs/LEGACY_ISSUE_PROVENANCE.md @@ -26,8 +26,9 @@ Consequences: ## Where legacy content is recoverable -The retired repository itself cannot be recovered from GitHub here. The specifications -and dispositions survive in Notion: +The retired repository itself cannot be recovered from GitHub here. The recovery sources are identified in Notion, but both ledger databases +return deleted markers at the 2026-10-10 read-back. The Issues archive query +returns no rows. Individual legacy bodies remain unavailable: - the *Loop Issues* ledger and the *Sessions* ledger, linked from the master roadmap (§2) — the source of record for legacy titles, bodies, and status; @@ -97,3 +98,16 @@ The legacy URL rewrite is complete: `docs/GOVERNED_EXECUTION.md` and `docs/adr/adr-011-architecture-contracts-d1-d5.md` were the last files holding a legacy link, and no `docs/` file links a legacy number now. The reset-issue links in `docs/CORE_QUALIFICATION.md` are live and stay as they are. + +## Current recovery and tracker register + +The [cross-module source register](GOVERNANCE_SOURCE_REGISTER.md) pins the current +review SHA, source availability, owners, and live tracker identities. It records +the deleted ledgers and preserves readable deleted-page snapshots as historical +evidence. Their status cannot establish implementation or release acceptance. + +Product follow-up numbers in `docs/product-surface.json` now point to the existing +Quick coverage issue #231. The source register preserves every former number +as legacy provenance. The pre-reset workspace documents qualify legacy #193. +Old changelog fragments and Git commit subjects retain their original numbers +under the historical-reference rule above; no history is rewritten. diff --git a/docs/LOOP_WORKSPACES.md b/docs/LOOP_WORKSPACES.md index 7c6b9bddc..77a2684b6 100644 --- a/docs/LOOP_WORKSPACES.md +++ b/docs/LOOP_WORKSPACES.md @@ -3,7 +3,7 @@ This document is the workspace companion to the authoritative product-surface contract in issue #191. It describes where an operator finds a capability; it does not add a second application shell or move UI code. Editor workspace wiring -is deferred to #193 and remains outside the pre-0.1.1 GUI scope. +is deferred to legacy #193 and remains outside the pre-0.1.1 GUI scope. ## Product surfaces @@ -37,7 +37,7 @@ receive a separate Loop desktop entry, AppX application, or product identity. | Fix | Deterministic, bounded corrective operations with preview, approval, output, and revalidation | Core repair operations and `PdfTool repair` | Silent mutation, GUI-only business logic, or implicit approval | | Compare | Proposed PDF comparison and production-proof evidence | Core `PDFDiff` contract if the product boundary is approved | An automatic replacement of the retired comparison product | -The shell issue (#193) may model these as stateful workspaces, but switching +The shell issue (legacy #193) may model these as stateful workspaces, but switching workspace must preserve the open document and preflight revision. A workspace is not a new executable and must not own a duplicate Core semantic path. @@ -76,7 +76,7 @@ rollback behavior unchanged. Compare is **OPEN**, not implicitly absorbed. The Core `PDFDiff` contract is retained while the standalone comparison executable is absent from both profiles -because its source was already deleted. The owner is `m.berry`; #193 is the follow-up for the shell +because its source was already deleted. The owner is `m.berry`; legacy #193 is the follow-up for the shell boundary and #197 is the release exit gate. No new UI replacement or product commitment is authorized by this document. diff --git a/docs/REPOSITORY_GOVERNANCE.md b/docs/REPOSITORY_GOVERNANCE.md new file mode 100644 index 000000000..2a7bcdbf1 --- /dev/null +++ b/docs/REPOSITORY_GOVERNANCE.md @@ -0,0 +1,116 @@ +# Repository governance decision + +This record implements [G00 #108](https://github.com/studio-berry/loop/issues/108). +The reviewed integration source is `9fea0095221911fa81dcfa816a49d090995bddbd`. +GitHub issues, native children, pull requests, milestones, default branch, and +roadmap sources were read on 2026-10-10. This PR's check-change report identifies +the implementation head. The review base identifies the source inventory, +not a runtime or release acceptance claim. + +## Product identity + +[#112](https://github.com/studio-berry/loop/issues/112) retains the existing product +and persistence identities. Repository links use the current full name. + +| Item | Decision | Authority | +| --- | --- | --- | +| Product and display name | Loop | Root CMake constants and `pdfapplicationidentity.cpp` | +| Repository | `studio-berry/loop` | GitHub repository metadata and `agent-policy.json` | +| Source version | `0.2.1-alpha` | `LOOP_VERSION=0.2.1`, `LOOP_VERSION_PRERELEASE=alpha` | +| Windows numeric version | `0.2.1.0` | `LOOP_WINDOWS_VERSION` | +| Default and release branch | `stable` | Live GitHub default and branch policy | +| Integration and topic source | `dev` | Branch policy | +| Qualification branch | `unstable` | Branch policy | +| Organization and domain | `Loop`, `io.github.mberrys` | Application identity | +| Flatpak, desktop, AppStream package ID | `io.github.mberrys.Loop-pdf` | CMake, Flatpak and Desktop manifests | +| MSIX identity and publisher | `mberrys.Loop-pdf`, `CN=F0582764-7439-4F99-A76D-473EDF10E031` | `AppxManifest.xml.in` | +| MSI UpgradeCode | `26336d8a-b2e7-44fc-9a73-68aa99900c7a` | `WixInstaller/Product.wxs.in` | +| Application/settings identity | `LoopEditor`, `PdfTool`, existing per-tool names | `pdfapplicationidentity.cpp`, `pdfsettings.cpp` | +| AppImage and Windows asset filenames | Existing `Loop-pdf-*` and `mberrys.Loop-pdf_*` filenames | Paired producer, release collector and asset verifier | +| AppImage update destination | `studio-berry/loop` releases | `LinuxInstall.yml` | + +The `Loop-pdf` tokens in package IDs and asset filenames are compatibility +identities. They are retained deliberately. No settings path, keystore path, +MSI upgrade identity, MSIX publisher, or Flatpak identity changes. +Existing upgrade and uninstall behavior therefore needs no new migration. +This change supplies source checks, not a packaging dry run or installation proof. + +README links and its build instructions use the current repository. +The stale `master` and broken-DEB narrative is removed because current CI has no +DEB lane. AppStream's inherited PDF4QT release history is removed from current +Loop metadata. It remains recoverable from the pinned Git history; it did not +describe Loop's `0.2.1-alpha` source version. Release publication remains owned +by [R00 #14](https://github.com/studio-berry/loop/issues/14). + +## Legacy provenance + +[Legacy issue provenance](LEGACY_ISSUE_PROVENANCE.md) owns the distinction between +the retired repository and the reset sequence. Legacy commit subjects and old +changelog fragments remain immutable history. A bare number in a pre-reset record +means a retired issue, even when the reset sequence now contains that number. + +The machine-read product follow-ups now point to +[#231](https://github.com/studio-berry/loop/issues/231), the existing Quick +coverage owner. This mapping does not claim that those GUI commands are delivered. +The preflight overlay's live titles, states, and milestones are refreshed. +Its backlog dispositions and reviewed P3 deferrals remain independent of issue +closure. + +The archived Issues and Sessions databases return deleted markers. The Issues +archive view returns no rows. Deleted roadmap, orchestration, architecture, and +vision pages still expose retrievable historical text, but lack current authority. +Legacy issue bodies and their original repository remain unavailable. +This recorded source limitation satisfies the loss-disposition branch of #111; +recovering the ledger remains with the provenance maintainers under that record. + +## Milestone decision + +[#207](https://github.com/studio-berry/loop/issues/207) retires the pre-reset +release-milestone synchronizer. It cannot write to GitHub, even with `--apply`. +Its dispatch workflow and manifest are removed. Historical descriptions remain +under [github-milestones](github-milestones/README.md) with explicit retired status. + +At review, GitHub has L01-L14 milestones 1-14, R00 15, G00 16, and X00 17. +L01 is closed; the other milestones are open. Those states are observations, +not module admission decisions. No milestone is created, closed, or rewritten. +Removal of the workflow reaches the default branch through the normal promotion +sequence after this PR merges into `dev`. + +## Source and proof ownership + +The [source register](GOVERNANCE_SOURCE_REGISTER.md) and its +[JSON source](governance/source-register.json) freeze the named intake for +L01-L14, R00, and X00. They reuse #15, #47, #141, and existing module children. +Unavailable issue bodies stay unknown. No new implementation issue was needed. + +The [proof table](ARCHITECTURE_PROOF_DISPOSITIONS.md) and its +[JSON source](governance/proof-obligations.json) cover the clock/RNG TODO, +D1-D5, and all eight pending quality metrics. They retain declared limits. +D5 has binding behavioral fixtures and a CI comparator; its architecture +declaration is still `declared`. No metric baseline, fixed cap, sealed expected +output, public schema, or product API is changed. + +The registers implement #209 and #210 as ownership and disposition records. +Their acceptance does not complete the separately owned proof or product issues. + +## Verification and limits + +Regenerate the two tables with +`python scripts/ci/check_governance_registers.py --write`. +Check local coverage with the default command. `--verify-github` compares the +frozen tracker snapshot with live titles, states, milestones, and PR identities. + +The focused negative tests reject a missing module, missing obligation, invented +enforcement, wrong issue identity, retired repository link, and unapproved package +identity change. The retirement test refuses writes before any external command. +Both CI and check-change run the governance checks. + +Required proof includes `python scripts/generate-architecture-catalogs.py --check --verify-github`, +the focused Python tests, and `python scripts/agent/check-change.py --base origin/dev`. +Pending measurements, independent oracle results, and release admission remain +with their named owners. Review of this PR is the maintainer decision checkpoint. + +Quality review: the change reuses existing product identities, tracker owners, +catalogs, and behavioral tests. It removes the obsolete milestone writer and +keeps source availability, implementation, measured proof, and release admission +separate without adding product abstractions. diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 89e630ab1..72942ce35 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -31,7 +31,7 @@ workflow audit is tracked in legacy issue #232. ## Versioning Loop uses [Semantic Versioning 2.0](https://semver.org/). The current product -version is **0.2.0-alpha**. Policy: [`version-policy.json`](version-policy.json) +version is **0.2.1-alpha**. Policy: [`version-policy.json`](version-policy.json) and [`VERSIONING.md`](VERSIONING.md). Former 0.0.3–0.0.6 gates are 0.1.1–0.1.4. ## Upstream tracking policy diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 02047975e..ed092f338 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -21,9 +21,12 @@ The current line is **0.2.1-alpha**. Display strings, PdfTool's envelope `project(VERSION)` stays `0.2.1` because it only accepts numeric components; the `-alpha` label lives in `LOOP_VERSION_PRERELEASE`. -## Milestone remap +## Historical milestone remap -Earlier 0.0.x planning numbers map onto the 0.1 line as follows: +The retired repository used this historical release remap. Current capability +milestones are L01-L14, R00, G00, and X00, as recorded in +[the governance decision](REPOSITORY_GOVERNANCE.md). The table below does not +name current GitHub milestone gates. | Former | Current | |--------|---------| diff --git a/docs/generated/preflight-coverage-backlog.json b/docs/generated/preflight-coverage-backlog.json index d2b9426c2..2d8e3b6b0 100644 --- a/docs/generated/preflight-coverage-backlog.json +++ b/docs/generated/preflight-coverage-backlog.json @@ -19,81 +19,81 @@ "milestone": "L01 - Evidence Core", "number": 113, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-08 \u2014 Classify ICC-based color by its painted space" }, "#114": { "milestone": "L01 - Evidence Core", "number": 114, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-09 \u2014 Audit glyph coverage in embedded fonts" }, "#115": { "milestone": "L01 - Evidence Core", "number": 115, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-10 \u2014 Evaluate optional-content membership for hidden content" }, "#116": { "milestone": "L01 - Evidence Core", "number": 116, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-11 \u2014 Stop isolated over-limit ink regions passing ink coverage" }, "#117": { "milestone": "L01 - Evidence Core", "number": 117, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-12 \u2014 Broaden invisible-content detection" }, "#118": { "milestone": "L01 - Evidence Core", "number": 118, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-13 \u2014 Make off-page and obscured-content geometry clip-aware" }, "#119": { "milestone": "L01 - Evidence Core", "number": 119, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-14 \u2014 Judge white overprint and transparency interaction on the authoritative renderer" }, "#120": { "milestone": "L01 - Evidence Core", "number": 120, "repository": "studio-berry/loop", - "state": "OPEN", + "state": "CLOSED", "title": "L01-15 \u2014 Close the sparse-mark bleed raster false pass" }, "#141": { - "milestone": null, + "milestone": "X00 - Expansion Candidates", "number": 141, "repository": "studio-berry/loop", "state": "OPEN", "title": "X00-02 \u2014 Re-file parked legacy expansion candidates with value cases" }, "#142": { - "milestone": null, + "milestone": "X00 - Expansion Candidates", "number": 142, "repository": "studio-berry/loop", "state": "OPEN", "title": "X00-03 \u2014 Per-named-colorant ink limits for DeviceN and spot separations" }, "#143": { - "milestone": null, + "milestone": "X00 - Expansion Candidates", "number": 143, "repository": "studio-berry/loop", "state": "OPEN", "title": "X00-04 \u2014 Barcode, slug, Braille, and dieline geometry validation" }, "#144": { - "milestone": null, + "milestone": "X00 - Expansion Candidates", "number": 144, "repository": "studio-berry/loop", "state": "OPEN", diff --git a/docs/github-milestones/README.md b/docs/github-milestones/README.md index be5d9a3af..822501165 100644 --- a/docs/github-milestones/README.md +++ b/docs/github-milestones/README.md @@ -1,61 +1,15 @@ -# GitHub milestones +# Retired release milestones -Canonical milestone text for [studio-berry/loop](https://github.com/studio-berry/loop) is maintained here and aligned with the Notion Loop [Roadmap](https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f). +The version-named Markdown files in this directory preserve pre-reset planning. +Their issue numbers, milestone numbers, completion states, and proposed versions +belong to the retired repository. They do not describe current Loop acceptance. -## Sequence +[The approved master roadmap](https://app.notion.com/p/3bb9cb079ddb80c4a15feaa98f963f4c) +replaced release buckets with L01-L14 capability milestones and R00, G00, and X00. +GitHub is the current milestone inventory. -| Milestone | GitHub # | Status | Former alias | -|-----------|----------|--------|--------------| -| 0.0.1 | 1 | Historical | — | -| 0.0.2 | 2 | Historical (recovery baseline merged to `stable`) | — | -| 0.1.0 | 5 | Shipped as `0.1.0-alpha` | — | -| 0.1.1 | 4 | Living | 0.0.3 | -| 0.2.0 | 8 | Living | 0.0.4 (supersedes retired `0.1.2` title) | -| 0.2.1 | 17 | Living | — | -| 0.3.0 | 9 | Living | 0.0.5 (supersedes retired `0.1.3` title) | -| 0.4.0 | 10 | Living | 0.0.6 (supersedes retired `0.1.4` title) | -| 0.5.0 | 12 (live title; description and assignments need sync) | Planned (proposed) | — | -| 0.6.0 | pending creation during sync | Planned (proposed) | 0.7.0 (retired 2026-09-06 consolidation title) | -| 0.7.0 | 13 | Planned (proposed) | 0.8.0 (former title; also consolidates retired `0.9.0`) | -| 0.8.0 | 14 | Planned (proposed) | 0.10.0 (retired 2026-09-06 consolidation title) | - -The living release train is **0.1.1 → 0.2.0 → 0.2.1 → 0.3.0 → 0.4.0**, continuing into the amended -planned train **0.5.0 → 0.6.0 → 0.7.0 → 0.8.0**. Retired `0.1.2`–`0.1.4` GitHub milestone titles are closed by the sync script. - -## Consolidation amendment (2026-09-06) - -The previously proposed 0.5.0–0.10.0 train was consolidated into four releases per the -canonical Notion Roadmap amendment of the same date: **0.6.0 absorbs the former 0.7.0** -(production outcome reconciliation) and **0.7.0 absorbs the former 0.9.0** (workflow -promotion and verified repeat automation); the former **0.10.0 renumbers to 0.8.0**. -Ceremony sessions (S00 reconcile openers, vertical integrations, qualification lanes) were -folded into their owning sessions; nothing was dropped. Scope decomposition: -[`docs/ROADMAP_0.5.0-0.8.0.md`](../ROADMAP_0.5.0-0.8.0.md). Each planned milestone -activates only on its predecessor's release acceptance. - -## Sync - -Apply descriptions to GitHub with a token that has `issues: write` on the repository: - -```bash -python scripts/github/sync_milestones.py --apply -``` - -Dry run (default): - -```bash -python scripts/github/sync_milestones.py -``` - -The script matches milestones by title, creates missing canonical milestones, updates description plus optional open/closed state from [`manifest.json`](manifest.json), and closes retired titles listed under `retire` (currently `0.1.2`, `0.1.3`, `0.1.4`, `0.9.0`, `0.10.0`). - -## 2026-09-23 live GitHub topology reconciliation (required before issue bulk moves) - -The GitHub milestone titled **0.5.0** currently has numeric id **#12** but retains the **0.6.0** description and 0.6.0-labelled issue assignments. GitHub milestone #11 is not an assignable 0.5.0 target; the manifest's earlier #11/#12 mapping was stale. The repository sync script matches by **title** and updates **description/state**; it does **not** retitle existing milestones or migrate issue assignments. - -1. Dry-run `python scripts/github/sync_milestones.py` using this reviewed manifest and inspect the live milestone list. It should update 0.4.0 and 0.5.0 descriptions and create a distinct 0.6.0 milestone; check for unrelated state changes before `--apply`. -2. After the corrected 0.6.0 milestone exists, move the existing 0.6.0 issue cohort currently on #12 into its newly created numeric milestone; only then move the 17 active 0.5.0 issues currently on #10 into #12. -3. Verify title, description, due date, open/closed issue counts, and active milestone membership for both 0.4.0, 0.5.0 and 0.6.0. Historical closed 0.4.0 issues remain historical references; #53/#159/#33/#268 are not live release dependencies. -4. The canonical 0.4.0 release theme is **Evidence-Grounded Correction Assistance** (#584/#590/#371/#686). Medic is a separately qualified, cross-milestone sidecar and cannot block 0.4.0. - -Do not rely on manifest `github_number` to perform remote writes: `sync_milestones.py` resolves titles and GitHub's actual numbers. Do not move issues to a milestone merely because its former manifest numeric id matches. +The [G00 decision](../REPOSITORY_GOVERNANCE.md) retires this directory as an input +to remote writes. The dispatch workflow and active manifest have been removed. +The compatibility command `python scripts/github/sync_milestones.py` reports +retirement and plans no changes. `--apply` exits with code 2 before contacting +GitHub. Historical descriptions stay available for source reconciliation. diff --git a/docs/github-milestones/manifest.json b/docs/github-milestones/manifest.json deleted file mode 100644 index aa6b7a89b..000000000 --- a/docs/github-milestones/manifest.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "source": "https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f", - "github_repo": "studio-berry/loop", - "milestones": [ - { - "title": "0.0.1", - "github_number": 1, - "description_file": "0.0.1.md", - "state": "open" - }, - { - "title": "0.0.2", - "github_number": 2, - "description_file": "0.0.2.md", - "state": "open" - }, - { - "title": "0.1.0", - "github_number": 5, - "description_file": "0.1.0.md", - "state": "closed" - }, - { - "title": "0.1.1", - "github_number": 4, - "description_file": "0.1.1.md", - "state": "open" - }, - { - "title": "0.2.0", - "github_number": 8, - "description_file": "0.2.0.md", - "state": "open" - }, - { - "title": "0.2.1", - "github_number": 17, - "description_file": "0.2.1.md", - "state": "open" - }, - { - "title": "0.3.0", - "github_number": 9, - "description_file": "0.3.0.md", - "state": "open" - }, - { - "title": "0.4.0", - "github_number": 10, - "description_file": "0.4.0.md", - "state": "open" - }, - { - "title": "0.5.0", - "github_number": 12, - "description_file": "0.5.0.md", - "state": "open" - }, - { - "title": "0.6.0", - "description_file": "0.6.0.md", - "state": "open" - }, - { - "title": "0.7.0", - "github_number": 13, - "description_file": "0.7.0.md", - "state": "open" - }, - { - "title": "0.8.0", - "github_number": 14, - "description_file": "0.8.0.md", - "state": "open" - } - ], - "retire": [ - { - "title": "0.1.2", - "description": "Retired milestone title. Living work moved to **0.2.0** per the canonical Notion Loop Roadmap." - }, - { - "title": "0.1.3", - "description": "Retired milestone title. Living work moved to **0.3.0** per the canonical Notion Loop Roadmap." - }, - { - "title": "0.1.4", - "description": "Retired milestone title. Living work moved to **0.4.0** per the canonical Notion Loop Roadmap." - }, - { - "title": "0.9.0", - "description": "Retired milestone title. Consolidated into **0.7.0** (workflow memory, recommendations & verified repeat automation) per the 2026-09-06 roadmap amendment; see docs/ROADMAP_0.5.0-0.8.0.md." - }, - { - "title": "0.10.0", - "description": "Retired milestone title. Renumbered to **0.8.0** (platform hardening, extension ecosystem & 1.0 readiness) per the 2026-09-06 roadmap amendment; see docs/ROADMAP_0.5.0-0.8.0.md." - } - ] -} diff --git a/docs/governance/proof-obligations.json b/docs/governance/proof-obligations.json new file mode 100644 index 000000000..382d526ec --- /dev/null +++ b/docs/governance/proof-obligations.json @@ -0,0 +1,247 @@ +{ + "format_version": 1, + "repository": "studio-berry/loop", + "reviewed_sha": "9fea0095221911fa81dcfa816a49d090995bddbd", + "reviewed_on": "2026-10-10", + "obligations": [ + { + "id": "deterministic-core", + "owner": "Core parser/writer and security maintainers", + "issues": [ + 96, + 136 + ], + "evidence": [ + "LoopLibCore/sources/pdfdocumentbuilder.cpp", + "architecture/invariants.yaml" + ], + "next_action": "Classify clock/RNG call sites and separately admit parser/writer guards under #96/#136. Preserve legitimate document/signing timestamps until that decision.", + "limitation": "QtNetwork isolation is enforced; the clock/RNG classification TODO remains deferred.", + "disposition": "deferred", + "enforcement": "enforced", + "measured_baseline": null + }, + { + "id": "d1-canonical-json-bytes", + "owner": "Governed-execution maintainers", + "issues": [ + 5, + 33 + ], + "evidence": [ + "UnitTests/tst_governedexecutiontest.cpp", + "UnitTests/testdata/canonical-json/operation-plan-envelope.json", + "docs/CANONICAL_JSON.md" + ], + "next_action": "Admit a scoped include/call-site guard with deliberate noncanonical digest fixtures before closing the supplemental TODO.", + "limitation": "Golden vectors exist; no complete static call-site guard is admitted.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "d2-preview-non-publishing", + "owner": "Governed-execution maintainers", + "issues": [ + 5, + 35 + ], + "evidence": [ + "UnitTests/tst_governedexecutiontest.cpp", + "LoopLibCore/sources/pdfgovernedexecution.cpp" + ], + "next_action": "Define the publication boundary and reject a deliberate preview-to-publisher call in a binding CI guard before changing this declaration.", + "limitation": "Behavioral preview negatives exist; the supplemental call-graph guard is deferred.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "d3-destination-not-in-plan-digest", + "owner": "Governed-execution maintainers", + "issues": [ + 5, + 37 + ], + "evidence": [ + "UnitTests/testdata/canonical-json/operation-plan-envelope.json", + "UnitTests/tst_governedexecutiontest.cpp" + ], + "next_action": "Bind an envelope-key guard and reject a destination-bearing plan fixture before admitting static enforcement.", + "limitation": "Destination-binding behavior is tested; the supplemental envelope guard is deferred.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "d4-publication-vs-durable-completion", + "owner": "Governed-execution and provenance maintainers", + "issues": [ + 5, + 39 + ], + "evidence": [ + "UnitTests/tst_governedexecutiontest.cpp", + "LoopLibCore/sources/pdfoperationhistory.cpp" + ], + "next_action": "Keep publication and durable completion distinct. A proposed state schema requires an architect checkpoint, migration fixtures and failure proof.", + "limitation": "Publish-without-sign-off behavior exists; no new exported state schema is admitted.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "d5-cross-surface-identity-equality", + "owner": "Governed-execution adapter and CI maintainers", + "issues": [ + 40 + ], + "evidence": [ + "scripts/ci/check_governed_parity.py", + "UnitTests/tst_productoperatorloop.cpp", + "UnitTests/testdata/governed-parity/editor-receipt.json", + ".github/workflows/ci.yml" + ], + "next_action": "Retain named repair, Action List, Editor and PageMaster fixtures and governedPublicationParityAcrossSurfaces. Re-run binding behavior on the candidate.", + "limitation": "The named-fixture TODO has binding evidence. The architecture row remains declared and current runtime proof is not asserted.", + "disposition": "fulfilled", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "search-latency", + "owner": "Document search and benchmark maintainers", + "issues": [ + 136, + 185 + ], + "evidence": [ + "UnitTests/tst_documentsearchtest.cpp", + "UnitTests/tst_benchmarkidentitytest.cpp" + ], + "next_action": "Measure ms/page over a pinned corpus, toolchain, platform and reference machine. Bind the producer/comparator and show a value over 150 fails.", + "limitation": "The 150 ms/page cap is declared; a committed timing baseline is unavailable.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "bleed-fixup-latency", + "owner": "Bleed-fixup and benchmark maintainers", + "issues": [ + 136, + 51 + ], + "evidence": [ + "UnitTests/tst_bleedfixuptest.cpp" + ], + "next_action": "Publish per-page elapsed time with exact fixture/platform identity and prove a value over 150 ms/page fails.", + "limitation": "The 150 ms/page cap has no admitted timing producer or baseline.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "resident-memory", + "owner": "Core resource-envelope and benchmark maintainers", + "issues": [ + 96, + 136 + ], + "evidence": [ + "UnitTests/tst_processingbudgettest.cpp", + "docs/RESOURCE_ENVELOPE_QUALIFICATION.md" + ], + "next_action": "Measure widgets-free process RSS on pinned inputs and platforms. Bind the comparator and prove a value over 512 MiB fails.", + "limitation": "Processing budgets are not an RSS measurement; the 512 MiB cap remains declared.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "parser-differential-agreement", + "owner": "Independent-oracle and preflight qualification maintainers", + "issues": [ + 95 + ], + "evidence": [ + "UnitTests/tst_standardoracletest.cpp", + "UnitTests/tst_conversionoracletest.cpp" + ], + "next_action": "Publish independent-oracle agreement with tool versions, corpus identity and exact SHA. Reject agreement below 1 and missing measurements.", + "limitation": "The oracle tests exist; a machine-readable agreement producer/comparator is not admitted.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "packaging-size", + "owner": "Linux and Windows packaging maintainers", + "issues": [ + 94, + 136 + ], + "evidence": [ + ".github/workflows/LinuxInstall.yml", + ".github/workflows/WindowsInstall.yml", + "scripts/ci/verify_release_assets.py" + ], + "next_action": "Pin paired Linux/Windows package byte baselines and compare matching tool/platform identities. Show growth over 2 percent and missing artifacts fail.", + "limitation": "No admitted package-byte baseline exists; current packaging-size enforcement remains unavailable.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "sealed-rendering-channel-delta", + "owner": "Fixture custodians and independent print qualification maintainers", + "issues": [ + 51, + 95 + ], + "evidence": [ + "architecture/fixtures.yaml", + "UnitTests/testdata/fixture-classes/sealed-eval/mixed-surface-holdout.yaml" + ], + "next_action": "Obtain authorized held-out raised-text, mixed-paint and texture measurements. Bind the comparator and show channel delta above 2 fails.", + "limitation": "Sealed outputs cannot be retuned by this change. Held-out measurement remains unavailable.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "canonical-json-digest-stability", + "owner": "Governed-execution and qualification maintainers", + "issues": [ + 5, + 136 + ], + "evidence": [ + "UnitTests/tst_governedexecutiontest.cpp", + "UnitTests/testdata/canonical-json/operation-plan-envelope.json" + ], + "next_action": "Publish the digest-vector set count with identities and bind its comparator. A count below 1 or absent measurement must fail.", + "limitation": "Golden digest behavior exists; the metric artifact/count producer remains deferred.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + }, + { + "id": "compare-review-delta-classification", + "owner": "Quick Compare and qualification maintainers", + "issues": [ + 4, + 136 + ], + "evidence": [ + "UnitTests/tst_productoperatorloop.cpp", + "architecture/quality-budgets.yaml" + ], + "next_action": "Publish golden comparison-vector counts with exact pair identity. Bind the comparator and prove zero or absent vectors fail.", + "limitation": "The subsequently merged Compare metric is included. Classification behavior is not its missing count artifact.", + "disposition": "deferred", + "enforcement": "declared", + "measured_baseline": null + } + ] +} diff --git a/docs/governance/source-register.json b/docs/governance/source-register.json new file mode 100644 index 000000000..2e6c98b0a --- /dev/null +++ b/docs/governance/source-register.json @@ -0,0 +1,1997 @@ +{ + "format_version": 1, + "repository": "studio-berry/loop", + "reviewed_sha": "9fea0095221911fa81dcfa816a49d090995bddbd", + "reviewed_on": "2026-10-10", + "roadmap": "https://app.notion.com/p/3bb9cb079ddb80c4a15feaa98f963f4c", + "sources": [ + { + "id": "3bb9cb079ddb80c4a15feaa98f963f4c", + "title": "Loop", + "url": "https://app.notion.com/p/3bb9cb079ddb80c4a15feaa98f963f4c", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "Repository maintainers keep G00 and the publication index aligned with GitHub." + }, + { + "id": "3c89cb079ddb8026b704e72ac9bec3ec", + "title": "Issues", + "url": "https://app.notion.com/p/3c89cb079ddb8026b704e72ac9bec3ec", + "availability": "Deleted database; schema readable; legacy item bodies unavailable.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "3c89cb079ddb8076b0b0e5ca8fa195a9", + "title": "Sessions", + "url": "https://app.notion.com/p/3c89cb079ddb8076b0b0e5ca8fa195a9", + "availability": "Deleted database; schema readable; legacy item bodies unavailable.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "3c09cb079ddb80cb9a31ee5dd083739d", + "title": "Orchestration", + "url": "https://app.notion.com/p/3c09cb079ddb80cb9a31ee5dd083739d", + "availability": "Deleted page; historical text snapshot readable; not current authority.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "38f9cb079ddb804a96dbe26b8d86e84f", + "title": "Roadmap", + "url": "https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f", + "availability": "Deleted page; historical text snapshot readable; not current authority.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "3d39cb079ddb815796fdf41944edb452", + "title": "Loop — Architecture & Execution Plan v2 — 2026-09-05", + "url": "https://app.notion.com/p/3d39cb079ddb815796fdf41944edb452", + "availability": "Deleted page; historical text snapshot readable; not current authority.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "3b49cb079ddb81ab9208c3c55a6e26a9", + "title": "Loop — Product Vision & Strategic Positioning", + "url": "https://app.notion.com/p/3b49cb079ddb81ab9208c3c55a6e26a9", + "availability": "Deleted page; historical text snapshot readable; not current authority.", + "next_action": "Provenance maintainers retain the source identity under #111 and seek recovery without inventing missing requirements." + }, + { + "id": "3c79cb079ddb8150ba69c833b1531048", + "title": "Medic — Product Architecture & End-State Design", + "url": "https://app.notion.com/p/3c79cb079ddb8150ba69c833b1531048", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "Medic owner reconciles evidence under #47 before admitting host behavior." + }, + { + "id": "3d99cb079ddb81ce955fccd0968fecfe", + "title": "Medic V5 - Acceptance Experiment Execution Plan", + "url": "https://app.notion.com/p/3d99cb079ddb81ce955fccd0968fecfe", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "Medic owner reconciles evidence under #47 before admitting host behavior." + }, + { + "id": "3db9cb079ddb81ecbd76ccdf7d227086", + "title": "V6-REV — adversarial review of the V6 architecture", + "url": "https://app.notion.com/p/3db9cb079ddb81ecbd76ccdf7d227086", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "Medic owner reconciles evidence under #47 before admitting host behavior." + }, + { + "id": "3e59cb079ddb818b86ced3a34485d451", + "title": "Jev research epic — findings and provisional architecture decision (2026-09-23)", + "url": "https://app.notion.com/p/3e59cb079ddb818b86ced3a34485d451", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "L08 owner uses #61-#67 and #261 for task-specific qualification." + }, + { + "id": "3e59cb079ddb81c5b712e2a71e21c7f4", + "title": "R4 — Jev-like empirical qualification and acceptance ledger (2026-09-23)", + "url": "https://app.notion.com/p/3e59cb079ddb81c5b712e2a71e21c7f4", + "availability": "Readable planning or research source; not Loop runtime or release proof.", + "next_action": "L08 owner uses #61-#67 and #261 for task-specific qualification." + } + ], + "modules": [ + { + "id": "L01", + "owner": "LoopLibCore preflight and qualification", + "disposition": "partially-present", + "inputs": [ + "legacy#618", + "legacy#619" + ], + "parent": 2, + "evidence": [ + "docs/EVIDENCE_CORE_RESET_INVENTORY.md", + "LoopLibCore/sources/preflightengine.cpp", + "UnitTests/tst_preflightverdicttest.cpp" + ], + "next_action": "Reuse #15's inventory and the existing #16-#21 and #113-#120 outcomes. Revalidate accepted claims on a chosen candidate.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "The reviewed P3 color-inventory-probe-depth and thin-parts-raster-budget deferrals remain in #15's inventory. No legacy status is current exact-SHA proof.", + "issues": [ + 2, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120 + ] + }, + { + "id": "L02", + "owner": "LoopLibInteraction", + "disposition": "partially-present", + "inputs": [ + "legacy#584" + ], + "parent": 3, + "evidence": [ + "LoopLibInteraction/sources/documentfacade.cpp", + "UnitTests/tst_documentfacadetest.cpp" + ], + "next_action": "Keep interaction changes under the existing module children, including #206. No replacement facade is scheduled.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 3, + 22, + 23, + 24, + 25, + 26, + 103, + 104, + 206 + ] + }, + { + "id": "L03", + "owner": "LoopEditor and LoopLibQuick", + "disposition": "partially-present", + "inputs": [ + "legacy#477", + "legacy#586", + "legacy#193", + "legacy#195", + "legacy#66", + "legacy#30", + "legacy#41", + "legacy#126" + ], + "parent": 4, + "evidence": [ + "docs/LOOP_WORKSPACES.md", + "LoopEditor/editorhost.cpp", + "UnitTests/tst_productoperatorloop.cpp" + ], + "next_action": "Reuse delivered workspace work and keep residual command and absorbed-GUI coverage under #231.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 4, + 27, + 28, + 29, + 30, + 31, + 32, + 121, + 122, + 123, + 124, + 125, + 126, + 205, + 231 + ] + }, + { + "id": "L04", + "owner": "LoopLibCore governed execution", + "disposition": "partially-present", + "inputs": [ + "legacy#587", + "legacy#588", + "legacy#589" + ], + "parent": 5, + "evidence": [ + "LoopLibCore/sources/pdfgovernedexecution.cpp", + "UnitTests/tst_governedexecutiontest.cpp", + "docs/CORRECTION_COVERAGE_MATRIX.md" + ], + "next_action": "Reuse #33-#40 and #221 provenance; retain the PDFFunction resource-contract deferral. New exported contracts require an architect checkpoint.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 5, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ] + }, + { + "id": "L05", + "owner": "PdfTool and worker supervision", + "disposition": "partially-present", + "inputs": [ + "legacy#238", + "legacy#619" + ], + "parent": 6, + "evidence": [ + "PdfTool/pdfworkerclient.cpp", + "UnitTests/tst_pdfworkerisolation.cpp", + "LoopLibCore/sources/pdfpagemasterexport.cpp" + ], + "next_action": "Audit consumer coverage and complete existing #41-#46 parity and worker gates.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 6, + 41, + 42, + 43, + 44, + 45, + 46 + ] + }, + { + "id": "L06", + "owner": "Medic worker and Loop add-bleed adapter", + "disposition": "partially-present", + "inputs": [ + "legacy#47", + "notion:3c79cb079ddb8150ba69c833b1531048", + "notion:3d99cb079ddb81ce955fccd0968fecfe", + "notion:3db9cb079ddb81ecbd76ccdf7d227086" + ], + "parent": 7, + "evidence": [ + "LoopLibCore/sources/pdfbleedfixup.cpp", + "UnitTests/tst_bleedfixuptest.cpp", + "docs/EVIDENCE_CORE_RESET_INVENTORY.md" + ], + "next_action": "Use #47's host/evidence reconciliation and #48-#54. V5 candidate measurements and the V6 deadlock review do not admit released composite behavior.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 7, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54 + ] + }, + { + "id": "L07", + "owner": "Proposed Job module", + "disposition": "gap", + "inputs": [ + "legacy#34", + "legacy#128", + "legacy#404", + "legacy#405", + "legacy#406", + "legacy#407", + "legacy#408", + "legacy#409", + "legacy#410", + "legacy#411", + "legacy#412", + "legacy#413", + "legacy#414", + "legacy#415", + "legacy#416", + "legacy#591", + "legacy#592", + "legacy#593" + ], + "parent": 8, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "docs/generated/architecture-catalog.json", + "LoopLibCore/sources/pdfoperationhistorystore.cpp" + ], + "next_action": "Use #55-#60 for the accepted Job contract and qualification. The existing operation history is not a Job store.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 8, + 55, + 56, + 57, + 58, + 59, + 60 + ] + }, + { + "id": "L08", + "owner": "Proposed Decision module", + "disposition": "gap", + "inputs": [ + "legacy#584", + "legacy#590", + "legacy#371", + "legacy#686", + "notion:3e59cb079ddb818b86ced3a34485d451", + "notion:3e59cb079ddb81c5b712e2a71e21c7f4" + ], + "parent": 9, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "docs/generated/architecture-catalog.json" + ], + "next_action": "Use #61-#67 and #261. Keep no-provider/manual parity and human qualification gates; do not import Jev model results as Loop production proof.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 9, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 261 + ] + }, + { + "id": "L09", + "owner": "Proposed Intake module", + "disposition": "gap", + "inputs": [ + "legacy#418", + "legacy#419", + "legacy#420", + "legacy#421", + "legacy#422", + "legacy#423", + "legacy#424", + "legacy#425", + "legacy#426", + "legacy#427", + "legacy#428", + "legacy#429", + "legacy#594" + ], + "parent": 10, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "LoopLibCore/sources/pdfocrpagegate.cpp", + "UnitTests/tst_ocrpagegatetest.cpp" + ], + "next_action": "Use #68-#73. Existing page-gated OCR is a reusable input, not proof of reviewed JobSpec extraction.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 10, + 68, + 69, + 70, + 71, + 72, + 73 + ] + }, + { + "id": "L10", + "owner": "Proposed Outcome module", + "disposition": "gap", + "inputs": [ + "legacy#431", + "legacy#432", + "legacy#433", + "legacy#434", + "legacy#435", + "legacy#436", + "legacy#437", + "legacy#438", + "legacy#439", + "legacy#440", + "legacy#441" + ], + "parent": 11, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "LoopLibCore/sources/pdfoperationhistory.cpp" + ], + "next_action": "Use #74-#79. Artifact operation history does not imply human-confirmed press observations.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 11, + 74, + 75, + 76, + 77, + 78, + 79 + ] + }, + { + "id": "L11", + "owner": "Proposed Workflow module", + "disposition": "gap", + "inputs": [ + "legacy#443", + "legacy#444", + "legacy#445", + "legacy#446", + "legacy#447", + "legacy#448", + "legacy#449", + "legacy#450", + "legacy#451", + "legacy#452", + "legacy#453", + "legacy#595" + ], + "parent": 12, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "docs/generated/architecture-catalog.json" + ], + "next_action": "Use #80-#85. Confirmed outcomes, counterexamples and shadow proof precede promotion.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 12, + 80, + 81, + 82, + 83, + 84, + 85 + ] + }, + { + "id": "L12", + "owner": "Proposed Automation module", + "disposition": "gap", + "inputs": [ + "legacy#455", + "legacy#456", + "legacy#457", + "legacy#458", + "legacy#459", + "legacy#460", + "legacy#461", + "legacy#462", + "legacy#463", + "legacy#464", + "legacy#465", + "legacy#466", + "legacy#596", + "legacy#597" + ], + "parent": 13, + "evidence": [ + "docs/ROADMAP_0.5.0-0.8.0.md", + "LoopLibCore/sources/pdfgovernedexecution.cpp" + ], + "next_action": "Use #86-#92. Existing publication authority does not grant trigger authority or autonomous workflow admission.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 13, + 86, + 87, + 88, + 89, + 90, + 91, + 92 + ] + }, + { + "id": "L13", + "owner": "LoopLibCore correction catalog", + "disposition": "partially-present", + "inputs": [ + "studio-berry/loop#109", + "studio-berry/loop#127", + "legacy#587", + "legacy#588", + "legacy#589" + ], + "parent": 109, + "evidence": [ + "docs/CORRECTION_COVERAGE_MATRIX.md", + "UnitTests/tst_repairoperationtest.cpp", + "docs/generated/correction-operation-catalog.json" + ], + "next_action": "Use #127-#134 and their #165-#178 children. Research #177 precedes conditional implementation #178; safe declines remain part of admission.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 109, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178 + ] + }, + { + "id": "L14", + "owner": "LoopLibInteraction, LoopLibQuick and benchmark maintainers", + "disposition": "partially-present", + "inputs": [ + "legacy#54", + "legacy#55", + "legacy#56", + "legacy#57", + "studio-berry/loop#179" + ], + "parent": 179, + "evidence": [ + "UnitTests/tst_benchmarkidentitytest.cpp", + "LoopLibCore/sources/pdfdocumentsearch.cpp", + "architecture/quality-budgets.yaml" + ], + "next_action": "Use #180-#187 and #136. Preserve benchmark and target-machine identity before admitting a performance result.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187 + ] + }, + { + "id": "R00", + "owner": "Release and qualification maintainers", + "disposition": "partially-present", + "inputs": [ + "legacy#468", + "legacy#469", + "legacy#470", + "legacy#471", + "legacy#472", + "legacy#473", + "legacy#474", + "legacy#475", + "legacy#476", + "legacy#477", + "legacy#478", + "legacy#479", + "legacy#598" + ], + "parent": 14, + "evidence": [ + "docs/CORE_QUALIFICATION.md", + "docs/RESOURCE_ENVELOPE_QUALIFICATION.md", + "docs/NATIVE_ACCESSIBILITY_QUALIFICATION.md" + ], + "next_action": "Use #93-#98 and #135-#139. #93 selects version, SHA, modules and deviations before a candidate-specific parent is instantiated.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "Code and test references show reuse or planned boundaries. This inventory does not execute native, independent, or installed-product proof.", + "issues": [ + 14, + 93, + 94, + 95, + 96, + 97, + 98, + 135, + 136, + 137, + 138, + 139 + ] + }, + { + "id": "X00", + "owner": "Expansion decision owners", + "disposition": "deferred", + "inputs": [ + "legacy#58", + "legacy#654", + "legacy#599", + "legacy#600", + "legacy#601", + "legacy#602", + "legacy#603", + "legacy#604", + "legacy#605", + "legacy#606", + "legacy#607", + "legacy#608", + "legacy#609", + "legacy#610" + ], + "parent": 110, + "evidence": [ + "docs/generated/preflight-coverage-backlog.json", + "docs/EVIDENCE_CORE_RESET_INVENTORY.md", + "docs/ROADMAP_0.5.0-0.8.0.md" + ], + "next_action": "Reuse #140-#148 and #141's value-case decisions. Keep ink, imposition, PDF/VT, marks, editing, collaboration and conditional extensions decision-first.", + "source_availability": "The roadmap and current reset issues are recoverable. Individual legacy issue bodies are unavailable because the original repository and ledger rows are not recoverable.", + "limitation": "The reviewed P3 color-inventory-probe-depth and thin-parts-raster-budget deferrals remain in #15's inventory. No legacy status is current exact-SHA proof.", + "issues": [ + 110, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148 + ] + } + ], + "special_dispositions": [ + { + "id": "legacy#61", + "owner": "Repository provenance maintainers", + "disposition": "partially-present", + "issues": [ + 209 + ], + "evidence": [ + "docs/LEGACY_ISSUE_PROVENANCE.md" + ], + "next_action": "Recover the implemented claim's source and verify it before migrating behavior.", + "limitation": "The original issue body and exact implementation identity remain unavailable; an old implemented status proves nothing on the review SHA." + }, + { + "id": "legacy#123", + "owner": "Repository provenance maintainers", + "disposition": "obsolete", + "issues": [ + 209 + ], + "evidence": [ + "docs/LEGACY_ISSUE_PROVENANCE.md" + ], + "next_action": "Retain obsolete migration history as provenance. Schedule no replacement implementation.", + "limitation": "The roadmap classifies this migration history as obsolete; original issue text is unavailable." + } + ], + "product_follow_ups": { + "dimensions-plugin": { + "legacy": 193, + "issue": 231 + }, + "object-inspector-plugin": { + "legacy": 193, + "issue": 231 + }, + "output-preview-plugin": { + "legacy": 193, + "issue": 231 + }, + "soft-proofing-plugin": { + "legacy": 193, + "issue": 231 + }, + "redact-plugin": { + "legacy": 66, + "issue": 231 + }, + "editor-plugin": { + "legacy": 193, + "issue": 231 + }, + "loop-preflight-plugin": { + "legacy": 195, + "issue": 231 + }, + "action-list-plugin": { + "legacy": 30, + "issue": 231 + }, + "ocr-plugin": { + "legacy": 41, + "issue": 231 + }, + "loop-document": { + "legacy": 193, + "issue": 231 + }, + "loop-preflight": { + "legacy": 195, + "issue": 231 + }, + "loop-production-preview": { + "legacy": 193, + "issue": 231 + }, + "loop-pages-production": { + "legacy": 193, + "issue": 231 + }, + "loop-inspect": { + "legacy": 193, + "issue": 231 + }, + "loop-fix": { + "legacy": 126, + "issue": 231 + }, + "loop-compare": { + "legacy": 193, + "issue": 231 + } + }, + "issues": { + "2": { + "repository": "studio-berry/loop", + "number": 2, + "title": "L01 — Evidence Core module gate", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "3": { + "repository": "studio-berry/loop", + "number": 3, + "title": "L02 — Interaction Contract module gate", + "state": "OPEN", + "milestone": "L02 - Interaction Contract" + }, + "4": { + "repository": "studio-berry/loop", + "number": 4, + "title": "L03 — Quick Workspace module gate", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "5": { + "repository": "studio-berry/loop", + "number": 5, + "title": "L04 — Governed Operation Engine module gate", + "state": "OPEN", + "milestone": "L04 - Governed Operation Engine" + }, + "6": { + "repository": "studio-berry/loop", + "number": 6, + "title": "L05 — Production Orchestrator module gate", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "7": { + "repository": "studio-berry/loop", + "number": 7, + "title": "L06 — Medic Specialist module gate", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "8": { + "repository": "studio-berry/loop", + "number": 8, + "title": "L07 — Job Spine module gate", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "9": { + "repository": "studio-berry/loop", + "number": 9, + "title": "L08 — Bounded Decision Worker module gate", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "10": { + "repository": "studio-berry/loop", + "number": 10, + "title": "L09 — Intake module gate", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "11": { + "repository": "studio-berry/loop", + "number": 11, + "title": "L10 — Confirmed Outcomes module gate", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "12": { + "repository": "studio-berry/loop", + "number": 12, + "title": "L11 — Workflow Memory module gate", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "13": { + "repository": "studio-berry/loop", + "number": 13, + "title": "L12 — Automation Runtime module gate", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "14": { + "repository": "studio-berry/loop", + "number": 14, + "title": "R00 — Cross-module release qualification gate", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "15": { + "repository": "studio-berry/loop", + "number": 15, + "title": "L01-01 — Pin reset truth and evidence obligations", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "16": { + "repository": "studio-berry/loop", + "number": 16, + "title": "L01-02 — Define complete inspection receipt", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "17": { + "repository": "studio-berry/loop", + "number": 17, + "title": "L01-03 — Fence scheduled and worker results", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "18": { + "repository": "studio-berry/loop", + "number": 18, + "title": "L01-04 — Prove independent standards and rendering claims", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "19": { + "repository": "studio-berry/loop", + "number": 19, + "title": "L01-05 — Qualify hostile and production resource envelopes", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "20": { + "repository": "studio-berry/loop", + "number": 20, + "title": "L01-06 — Audit untrusted PDF process isolation", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "21": { + "repository": "studio-berry/loop", + "number": 21, + "title": "L01-07 — Admit exact-SHA Core qualification", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "22": { + "repository": "studio-berry/loop", + "number": 22, + "title": "L02-01 — Define typed DocumentFacade state", + "state": "CLOSED", + "milestone": "L02 - Interaction Contract" + }, + "23": { + "repository": "studio-berry/loop", + "number": 23, + "title": "L02-02 — Navigate stable findings to source region", + "state": "CLOSED", + "milestone": "L02 - Interaction Contract" + }, + "24": { + "repository": "studio-berry/loop", + "number": 24, + "title": "L02-03 — Model cancel, retry, and reopen transitions", + "state": "CLOSED", + "milestone": "L02 - Interaction Contract" + }, + "25": { + "repository": "studio-berry/loop", + "number": 25, + "title": "L02-04 — Expose accessible operator semantics", + "state": "CLOSED", + "milestone": "L02 - Interaction Contract" + }, + "26": { + "repository": "studio-berry/loop", + "number": 26, + "title": "L02-05 — Prove adapter parity over one receipt", + "state": "CLOSED", + "milestone": "L02 - Interaction Contract" + }, + "27": { + "repository": "studio-berry/loop", + "number": 27, + "title": "L03-01 — Build one-document Quick operator shell", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "28": { + "repository": "studio-berry/loop", + "number": 28, + "title": "L03-02 — Separate Production Preview from fast canvas", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "29": { + "repository": "studio-berry/loop", + "number": 29, + "title": "L03-03 — Add Compare and review workspace", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "30": { + "repository": "studio-berry/loop", + "number": 30, + "title": "L03-04 — Present plan and approval journey", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "31": { + "repository": "studio-berry/loop", + "number": 31, + "title": "L03-05 — Qualify native accessibility and Widgets-free graph", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "32": { + "repository": "studio-berry/loop", + "number": 32, + "title": "L03-06 — Run clean-machine operator acceptance", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "33": { + "repository": "studio-berry/loop", + "number": 33, + "title": "L04-01 — Reconcile registry and canonical plan contract", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "34": { + "repository": "studio-berry/loop", + "number": 34, + "title": "L04-02 — Enforce operation-owned save and impact policy", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "35": { + "repository": "studio-berry/loop", + "number": 35, + "title": "L04-03 — Compute isolated nonpublishing preview", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "36": { + "repository": "studio-berry/loop", + "number": 36, + "title": "L04-04 — Bind exact-plan approval and revocation", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "37": { + "repository": "studio-berry/loop", + "number": 37, + "title": "L04-05 — Centralize approved Core execution", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "38": { + "repository": "studio-berry/loop", + "number": 38, + "title": "L04-06 — Revalidate exact published bytes", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "39": { + "repository": "studio-berry/loop", + "number": 39, + "title": "L04-07 — Converge provenance and sign-off", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "40": { + "repository": "studio-berry/loop", + "number": 40, + "title": "L04-08 — Implement forward rollback and cross-surface parity", + "state": "CLOSED", + "milestone": "L04 - Governed Operation Engine" + }, + "41": { + "repository": "studio-berry/loop", + "number": 41, + "title": "L05-01 — Define PdfTool supervisor-worker protocol", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "42": { + "repository": "studio-berry/loop", + "number": 42, + "title": "L05-02 — Survive worker crash, timeout, and breach", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "43": { + "repository": "studio-berry/loop", + "number": 43, + "title": "L05-03 — Preserve production stage order", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "44": { + "repository": "studio-berry/loop", + "number": 44, + "title": "L05-04 — Audit scheduler consumers and queue pressure", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "45": { + "repository": "studio-berry/loop", + "number": 45, + "title": "L05-05 — Prove CLI and Action List policy parity", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "46": { + "repository": "studio-berry/loop", + "number": 46, + "title": "L05-06 — Qualify restartable batch publication", + "state": "OPEN", + "milestone": "L05 - Production Orchestrator" + }, + "47": { + "repository": "studio-berry/loop", + "number": 47, + "title": "L06-01 — Pin Medic host boundary and research evidence", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "48": { + "repository": "studio-berry/loop", + "number": 48, + "title": "L06-02 — Version capability handshake and no-worker D0", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "49": { + "repository": "studio-berry/loop", + "number": 49, + "title": "L06-03 — Recover authored exterior before synthesis", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "50": { + "repository": "studio-berry/loop", + "number": 50, + "title": "L06-04 — Evaluate deterministic and specialist candidates", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "51": { + "repository": "studio-berry/loop", + "number": 51, + "title": "L06-05 — Validate every candidate on print axes", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "52": { + "repository": "studio-berry/loop", + "number": 52, + "title": "L06-06 — Run held-out released-composite comparison", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "53": { + "repository": "studio-berry/loop", + "number": 53, + "title": "L06-07 — Connect Medic preview to add-bleed gateway", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "54": { + "repository": "studio-berry/loop", + "number": 54, + "title": "L06-08 — Shadow, canary, and rollback decision", + "state": "OPEN", + "milestone": "L06 - Medic Specialist" + }, + "55": { + "repository": "studio-berry/loop", + "number": 55, + "title": "L07-01 — Define minimal Job identity and field provenance", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "56": { + "repository": "studio-berry/loop", + "number": 56, + "title": "L07-02 — Persist encrypted local job lifecycle", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "57": { + "repository": "studio-berry/loop", + "number": 57, + "title": "L07-03 — Resolve job-aware profiles and plans", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "58": { + "repository": "studio-berry/loop", + "number": 58, + "title": "L07-04 — Project queue and status over existing scheduler", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "59": { + "repository": "studio-berry/loop", + "number": 59, + "title": "L07-05 — Package job artifacts for client handoff", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "60": { + "repository": "studio-berry/loop", + "number": 60, + "title": "L07-06 — Qualify full job lifecycle and recovery", + "state": "OPEN", + "milestone": "L07 - Job Spine" + }, + "61": { + "repository": "studio-berry/loop", + "number": 61, + "title": "L08-01 — Define indexed decision state and one-request question contract", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "62": { + "repository": "studio-berry/loop", + "number": 62, + "title": "L08-02 — Build deterministic no-provider policy over the same action space", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "63": { + "repository": "studio-berry/loop", + "number": 63, + "title": "L08-03 — Select and pin providers behind the one-request port", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "64": { + "repository": "studio-berry/loop", + "number": 64, + "title": "L08-04 — Contain private state, hostile documents, and model output", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "65": { + "repository": "studio-berry/loop", + "number": 65, + "title": "L08-05 — Calibrate task and stratum abstention", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "66": { + "repository": "studio-berry/loop", + "number": 66, + "title": "L08-06 — Consume observations once through Medic routing and plan-assist adapters", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "67": { + "repository": "studio-berry/loop", + "number": 67, + "title": "L08-07 — Qualify two clients, decision receipts, and the inspector", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + }, + "68": { + "repository": "studio-berry/loop", + "number": 68, + "title": "L09-01 — Define source-attributed Intake contract", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "69": { + "repository": "studio-berry/loop", + "number": 69, + "title": "L09-02 — Page-gate OCR and sidecar lifecycle", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "70": { + "repository": "studio-berry/loop", + "number": 70, + "title": "L09-03 — Build deterministic spec-field parsers", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "71": { + "repository": "studio-berry/loop", + "number": 71, + "title": "L09-04 — Quarantine watch-folder imports", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "72": { + "repository": "studio-berry/loop", + "number": 72, + "title": "L09-05 — Review and confirm JobSpec candidates", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "73": { + "repository": "studio-berry/loop", + "number": 73, + "title": "L09-06 — Qualify intake under adversarial client data", + "state": "OPEN", + "milestone": "L09 - Intake" + }, + "74": { + "repository": "studio-berry/loop", + "number": 74, + "title": "L10-01 — Version production event import schema", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "75": { + "repository": "studio-berry/loop", + "number": 75, + "title": "L10-02 — Implement idempotent file-based importers", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "76": { + "repository": "studio-berry/loop", + "number": 76, + "title": "L10-03 — Match events to exact Loop outputs", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "77": { + "repository": "studio-berry/loop", + "number": 77, + "title": "L10-04 — Add operator confirmation and rejection", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "78": { + "repository": "studio-berry/loop", + "number": 78, + "title": "L10-05 — Build append-only confirmed-result ledger", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "79": { + "repository": "studio-berry/loop", + "number": 79, + "title": "L10-06 — Qualify observe-mode history", + "state": "OPEN", + "milestone": "L10 - Confirmed Outcomes" + }, + "80": { + "repository": "studio-berry/loop", + "number": 80, + "title": "L11-01 — Mine confirmed repeat-job patterns", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "81": { + "repository": "studio-berry/loop", + "number": 81, + "title": "L11-02 — Produce recommendation evidence cards", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "82": { + "repository": "studio-berry/loop", + "number": 82, + "title": "L11-03 — Evaluate optional ranking versus transparent rules", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "83": { + "repository": "studio-berry/loop", + "number": 83, + "title": "L11-04 — Replay candidates in shadow mode", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "84": { + "repository": "studio-berry/loop", + "number": 84, + "title": "L11-05 — Version edits and promotion request", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "85": { + "repository": "studio-berry/loop", + "number": 85, + "title": "L11-06 — Qualify support and no-recommendation behavior", + "state": "OPEN", + "milestone": "L11 - Workflow Memory" + }, + "86": { + "repository": "studio-berry/loop", + "number": 86, + "title": "L12-01 — Define immutable promotion and authorization", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "87": { + "repository": "studio-berry/loop", + "number": 87, + "title": "L12-02 — Implement bounded trigger adapters", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "88": { + "repository": "studio-berry/loop", + "number": 88, + "title": "L12-03 — Enforce budgets, canaries, and kill switch", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "89": { + "repository": "studio-berry/loop", + "number": 89, + "title": "L12-04 — Recover idempotently after crash", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "90": { + "repository": "studio-berry/loop", + "number": 90, + "title": "L12-05 — Use Core gateway and revalidation for every run", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "91": { + "repository": "studio-berry/loop", + "number": 91, + "title": "L12-06 — Project client-visible status from receipts", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "92": { + "repository": "studio-berry/loop", + "number": 92, + "title": "L12-07 — Qualify bounded canary and rollback", + "state": "OPEN", + "milestone": "L12 - Automation Runtime" + }, + "93": { + "repository": "studio-berry/loop", + "number": 93, + "title": "R00-01 — Select exact release candidate and module set", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "94": { + "repository": "studio-berry/loop", + "number": 94, + "title": "R00-02 — Build and inspect Linux/Windows artifacts", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "95": { + "repository": "studio-berry/loop", + "number": 95, + "title": "R00-03 — Prove independent standards and print acceptance", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "96": { + "repository": "studio-berry/loop", + "number": 96, + "title": "R00-04 — Prove security, privacy, and resource envelope", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "97": { + "repository": "studio-berry/loop", + "number": 97, + "title": "R00-05 — Close licensing, migration, and rollback dossier", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "98": { + "repository": "studio-berry/loop", + "number": 98, + "title": "R00-06 — Make release admission decision", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "103": { + "repository": "studio-berry/loop", + "number": 103, + "title": "Select and Hand toolbar buttons are checkable placeholders with no behavior", + "state": "CLOSED", + "milestone": "L14 - Interaction Performance" + }, + "104": { + "repository": "studio-berry/loop", + "number": 104, + "title": "Completed drags are discarded: EditorHost::onDragCompleted drops the DragSession", + "state": "CLOSED", + "milestone": "L14 - Interaction Performance" + }, + "108": { + "repository": "studio-berry/loop", + "number": 108, + "title": "G00 — Reset repository governance and provenance", + "state": "OPEN", + "milestone": "G00 - Repository Governance" + }, + "109": { + "repository": "studio-berry/loop", + "number": 109, + "title": "L13 — Correction Catalog Breadth module gate", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "110": { + "repository": "studio-berry/loop", + "number": 110, + "title": "X00 — Expansion candidate register", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "111": { + "repository": "studio-berry/loop", + "number": 111, + "title": "G00-01 — Re-home legacy issue provenance after the repository rename", + "state": "CLOSED", + "milestone": "G00 - Repository Governance" + }, + "112": { + "repository": "studio-berry/loop", + "number": 112, + "title": "G00-02 — Reconcile product identity, version, and branch references", + "state": "OPEN", + "milestone": "G00 - Repository Governance" + }, + "113": { + "repository": "studio-berry/loop", + "number": 113, + "title": "L01-08 — Classify ICC-based color by its painted space", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "114": { + "repository": "studio-berry/loop", + "number": 114, + "title": "L01-09 — Audit glyph coverage in embedded fonts", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "115": { + "repository": "studio-berry/loop", + "number": 115, + "title": "L01-10 — Evaluate optional-content membership for hidden content", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "116": { + "repository": "studio-berry/loop", + "number": 116, + "title": "L01-11 — Stop isolated over-limit ink regions passing ink coverage", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "117": { + "repository": "studio-berry/loop", + "number": 117, + "title": "L01-12 — Broaden invisible-content detection", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "118": { + "repository": "studio-berry/loop", + "number": 118, + "title": "L01-13 — Make off-page and obscured-content geometry clip-aware", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "119": { + "repository": "studio-berry/loop", + "number": 119, + "title": "L01-14 — Judge white overprint and transparency interaction on the authoritative renderer", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "120": { + "repository": "studio-berry/loop", + "number": 120, + "title": "L01-15 — Close the sparse-mark bleed raster false pass", + "state": "CLOSED", + "milestone": "L01 - Evidence Core" + }, + "121": { + "repository": "studio-berry/loop", + "number": 121, + "title": "L03-07 — Ship operator documentation, first-run onboarding, and a sample corpus", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "122": { + "repository": "studio-berry/loop", + "number": 122, + "title": "L03-08 — Open encrypted and password-protected documents in Quick", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "123": { + "repository": "studio-berry/loop", + "number": 123, + "title": "L03-09 — Print and export from the Quick workspace", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "124": { + "repository": "studio-berry/loop", + "number": 124, + "title": "L03-10 — Present undo and redo as governed revision history", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "125": { + "repository": "studio-berry/loop", + "number": 125, + "title": "L03-11 — Show annotations, proof comments, and signature status in review", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "126": { + "repository": "studio-berry/loop", + "number": 126, + "title": "L03-12 — Localize the Quick shell", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "127": { + "repository": "studio-berry/loop", + "number": 127, + "title": "L13-01 — Define fixup admission contract and check-to-fix matrix", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "128": { + "repository": "studio-berry/loop", + "number": 128, + "title": "L13-02 — Embed or outline fonts", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "129": { + "repository": "studio-berry/loop", + "number": 129, + "title": "L13-03 — Map spot colors, normalize rich black, assign output intent, and limit ink", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "130": { + "repository": "studio-berry/loop", + "number": 130, + "title": "L13-04 — Correct hairlines and white overprint", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "131": { + "repository": "studio-berry/loop", + "number": 131, + "title": "L13-05 — Remove hidden, invisible, off-page, and active content", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "132": { + "repository": "studio-berry/loop", + "number": 132, + "title": "L13-06 — Normalize page boxes, scale to trim, and rotation", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "133": { + "repository": "studio-berry/loop", + "number": 133, + "title": "L13-07 — Flatten transparency with fidelity proof", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "134": { + "repository": "studio-berry/loop", + "number": 134, + "title": "L13-08 — Qualify correction breadth across Editor, PdfTool, and Action List", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "135": { + "repository": "studio-berry/loop", + "number": 135, + "title": "R00-07 — Make the macOS go/no-go decision", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "136": { + "repository": "studio-berry/loop", + "number": 136, + "title": "R00-08 — Gate performance and scale on benchmark identities", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "137": { + "repository": "studio-berry/loop", + "number": 137, + "title": "R00-09 — Decide installer signing, update channel, and SmartScreen posture", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "138": { + "repository": "studio-berry/loop", + "number": 138, + "title": "R00-10 — Freeze public contracts and prove schema compatibility", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "139": { + "repository": "studio-berry/loop", + "number": 139, + "title": "R00-11 — Decide opt-in crash and diagnostics reporting", + "state": "OPEN", + "milestone": "R00 - Release Qualification" + }, + "140": { + "repository": "studio-berry/loop", + "number": 140, + "title": "X00-01 — Decide the conditional Extensions module", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "141": { + "repository": "studio-berry/loop", + "number": 141, + "title": "X00-02 — Re-file parked legacy expansion candidates with value cases", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "142": { + "repository": "studio-berry/loop", + "number": 142, + "title": "X00-03 — Per-named-colorant ink limits for DeviceN and spot separations", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "143": { + "repository": "studio-berry/loop", + "number": 143, + "title": "X00-04 — Barcode, slug, Braille, and dieline geometry validation", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "144": { + "repository": "studio-berry/loop", + "number": 144, + "title": "X00-05 — Standards currency: PDF/X-6, PDF 2.0 output intents, and GWG 2022 check sets", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "145": { + "repository": "studio-berry/loop", + "number": 145, + "title": "X00-06 — Verifiable receipts and content credentials for published artifacts", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "146": { + "repository": "studio-berry/loop", + "number": 146, + "title": "X00-07 — XJDF/JMF job-ticket and device-outcome interchange", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "147": { + "repository": "studio-berry/loop", + "number": 147, + "title": "X00-08 — Bounded local automation interface for agents and integrations", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "148": { + "repository": "studio-berry/loop", + "number": 148, + "title": "X00-09 — PDF/UA accessibility preflight", + "state": "OPEN", + "milestone": "X00 - Expansion Candidates" + }, + "165": { + "repository": "studio-berry/loop", + "number": 165, + "title": "L13-03a — Qualify job-scoped spot-color mapping", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "166": { + "repository": "studio-berry/loop", + "number": 166, + "title": "L13-03b — Normalize rich black using profile recipes", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "167": { + "repository": "studio-berry/loop", + "number": 167, + "title": "L13-03c — Assign output intent with standards-safe validation", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "168": { + "repository": "studio-berry/loop", + "number": 168, + "title": "L13-03d — Limit total ink via qualified ICC/DeviceLink path", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "169": { + "repository": "studio-berry/loop", + "number": 169, + "title": "L13-04a — Correct thin strokes to the production minimum", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "170": { + "repository": "studio-berry/loop", + "number": 170, + "title": "L13-04b — Convert unsafe white overprint to knockout", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "171": { + "repository": "studio-berry/loop", + "number": 171, + "title": "L13-05a — Flatten optional content to the print configuration", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "172": { + "repository": "studio-berry/loop", + "number": 172, + "title": "L13-05b — Remove exactly identified invisible and off-page objects", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "173": { + "repository": "studio-berry/loop", + "number": 173, + "title": "L13-05c — Remove JavaScript and forbidden PDF actions safely", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "174": { + "repository": "studio-berry/loop", + "number": 174, + "title": "L13-06a — Derive and validate TrimBox/BleedBox from trusted geometry", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "175": { + "repository": "studio-berry/loop", + "number": 175, + "title": "L13-06b — Scale to trim with bounded geometry tolerance", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "176": { + "repository": "studio-berry/loop", + "number": 176, + "title": "L13-06c — Normalize page rotation against rendered orientation", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "177": { + "repository": "studio-berry/loop", + "number": 177, + "title": "L13-07a — Decide transparency flattener and fidelity budget (research gate)", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "178": { + "repository": "studio-berry/loop", + "number": 178, + "title": "L13-07b — Implement admitted transparency flattening behind Core gateway", + "state": "OPEN", + "milestone": "L13 - Correction Catalog Breadth" + }, + "179": { + "repository": "studio-berry/loop", + "number": 179, + "title": "L14 — Interaction Performance module gate", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "180": { + "repository": "studio-berry/loop", + "number": 180, + "title": "L14-01 — Decide stale-revision vs stale-resolution presentation rule", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "181": { + "repository": "studio-berry/loop", + "number": 181, + "title": "L14-02 — Implement sub-rectangle page-surface rendering", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "182": { + "repository": "studio-berry/loop", + "number": 182, + "title": "L14-03 — Refine zoom and scroll progressively from low to full resolution", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "183": { + "repository": "studio-berry/loop", + "number": 183, + "title": "L14-04 — Prefetch adjacent pages and prioritize viewing over background work", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "184": { + "repository": "studio-berry/loop", + "number": 184, + "title": "L14-05 — Enforce a GUI-thread stall budget", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "185": { + "repository": "studio-berry/loop", + "number": 185, + "title": "L14-06 — Gate present-lane frame budgets on a pinned reference machine", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "186": { + "repository": "studio-berry/loop", + "number": 186, + "title": "L14-07 — Measure cold start and open-to-first-view on installed artifacts", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "187": { + "repository": "studio-berry/loop", + "number": 187, + "title": "L14-08 — Qualify long-session interaction feel", + "state": "OPEN", + "milestone": "L14 - Interaction Performance" + }, + "205": { + "repository": "studio-berry/loop", + "number": 205, + "title": "Ship a default recipe for the page-box drag move", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "206": { + "repository": "studio-berry/loop", + "number": 206, + "title": "Decide drag-commit semantics for findings, guides and text", + "state": "OPEN", + "milestone": "L02 - Interaction Contract" + }, + "207": { + "repository": "studio-berry/loop", + "number": 207, + "title": "Reconcile or retire the legacy milestone sync after the reset", + "state": "OPEN", + "milestone": "G00 - Repository Governance" + }, + "209": { + "repository": "studio-berry/loop", + "number": 209, + "title": "Freeze the cross-module source and disposition register", + "state": "OPEN", + "milestone": "G00 - Repository Governance" + }, + "210": { + "repository": "studio-berry/loop", + "number": 210, + "title": "Assign owners and record decisions for pending architecture proof harnesses", + "state": "OPEN", + "milestone": "G00 - Repository Governance" + }, + "231": { + "repository": "studio-berry/loop", + "number": 231, + "title": "L03-13 — Restore Quick GUI coverage for the retired Widgets surfaces", + "state": "OPEN", + "milestone": "L03 - Quick Workspace" + }, + "261": { + "repository": "studio-berry/loop", + "number": 261, + "title": "L08-08 — Gate free-text slots behind a single-key text helper", + "state": "OPEN", + "milestone": "L08 - Bounded Decision Worker" + } + }, + "pull_requests": [ + { + "number": 149, + "title": "fix(catalog): separate live and legacy issue records (#111)", + "state": "MERGED", + "sha": "e26d83a078ce8d141f4f67d08f6a2ef7f42bae3f" + }, + { + "number": 155, + "title": "[0.3.0 first half] L01 Evidence Core train — unstable → stable", + "state": "MERGED", + "sha": "a492bf683d99a2fc4025f3f1f230695bc73286d0" + }, + { + "number": 202, + "title": "L03-03: Add the Compare and review workspace (#29)", + "state": "MERGED", + "sha": "e1a14184d4bf38d25ecdf17427def35eefe19bf1" + }, + { + "number": 208, + "title": "L04-01: reconcile the operation registry and canonical plan contract (#33)", + "state": "MERGED", + "sha": "f2e494d9607f86ed1738371727753d0dda40c86a" + }, + { + "number": 221, + "title": "L04-02–08 — Governed execution, revalidation, provenance, and rollback", + "state": "MERGED", + "sha": "d217db55cca5c516807185801d4f9da6c7e7f8d0" + }, + { + "number": 244, + "title": "Qualify native accessibility against final packages (#31)", + "state": "MERGED", + "sha": "9fea0095221911fa81dcfa816a49d090995bddbd" + } + ] +} diff --git a/docs/preflight-check-catalog-overlay.json b/docs/preflight-check-catalog-overlay.json index 1da97c551..fbdc97ea8 100644 --- a/docs/preflight-check-catalog-overlay.json +++ b/docs/preflight-check-catalog-overlay.json @@ -68,86 +68,86 @@ }, "#113": { "number": 113, - "title": "L01-08 \u2014 Classify ICC-based color by its painted space", - "state": "OPEN", + "title": "L01-08 — Classify ICC-based color by its painted space", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#114": { "number": 114, - "title": "L01-09 \u2014 Audit glyph coverage in embedded fonts", - "state": "OPEN", + "title": "L01-09 — Audit glyph coverage in embedded fonts", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#115": { "number": 115, - "title": "L01-10 \u2014 Evaluate optional-content membership for hidden content", - "state": "OPEN", + "title": "L01-10 — Evaluate optional-content membership for hidden content", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#116": { "number": 116, - "title": "L01-11 \u2014 Stop isolated over-limit ink regions passing ink coverage", - "state": "OPEN", + "title": "L01-11 — Stop isolated over-limit ink regions passing ink coverage", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#117": { "number": 117, - "title": "L01-12 \u2014 Broaden invisible-content detection", - "state": "OPEN", + "title": "L01-12 — Broaden invisible-content detection", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#118": { "number": 118, - "title": "L01-13 \u2014 Make off-page and obscured-content geometry clip-aware", - "state": "OPEN", + "title": "L01-13 — Make off-page and obscured-content geometry clip-aware", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#119": { "number": 119, - "title": "L01-14 \u2014 Judge white overprint and transparency interaction on the authoritative renderer", - "state": "OPEN", + "title": "L01-14 — Judge white overprint and transparency interaction on the authoritative renderer", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#120": { "number": 120, - "title": "L01-15 \u2014 Close the sparse-mark bleed raster false pass", - "state": "OPEN", + "title": "L01-15 — Close the sparse-mark bleed raster false pass", + "state": "CLOSED", "milestone": "L01 - Evidence Core", "repository": "studio-berry/loop" }, "#141": { "number": 141, - "title": "X00-02 \u2014 Re-file parked legacy expansion candidates with value cases", + "title": "X00-02 — Re-file parked legacy expansion candidates with value cases", "state": "OPEN", - "milestone": null, + "milestone": "X00 - Expansion Candidates", "repository": "studio-berry/loop" }, "#142": { "number": 142, - "title": "X00-03 \u2014 Per-named-colorant ink limits for DeviceN and spot separations", + "title": "X00-03 — Per-named-colorant ink limits for DeviceN and spot separations", "state": "OPEN", - "milestone": null, + "milestone": "X00 - Expansion Candidates", "repository": "studio-berry/loop" }, "#143": { "number": 143, - "title": "X00-04 \u2014 Barcode, slug, Braille, and dieline geometry validation", + "title": "X00-04 — Barcode, slug, Braille, and dieline geometry validation", "state": "OPEN", - "milestone": null, + "milestone": "X00 - Expansion Candidates", "repository": "studio-berry/loop" }, "#144": { "number": 144, - "title": "X00-05 \u2014 Standards currency: PDF/X-6, PDF 2.0 output intents, and GWG 2022 check sets", + "title": "X00-05 — Standards currency: PDF/X-6, PDF 2.0 output intents, and GWG 2022 check sets", "state": "OPEN", - "milestone": null, + "milestone": "X00 - Expansion Candidates", "repository": "studio-berry/loop" } }, diff --git a/docs/product-surface.json b/docs/product-surface.json index f0b5aa291..ddc020aec 100644 --- a/docs/product-surface.json +++ b/docs/product-surface.json @@ -223,7 +223,7 @@ "replacement_surface": "loop-pages-production", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "object-inspector-plugin", @@ -238,7 +238,7 @@ "replacement_surface": "loop-inspect", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "output-preview-plugin", @@ -253,7 +253,7 @@ "replacement_surface": "loop-production-preview", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "soft-proofing-plugin", @@ -268,7 +268,7 @@ "replacement_surface": "loop-production-preview", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "redact-plugin", @@ -283,7 +283,7 @@ "replacement_surface": "loop-fix", "dependencies": ["PdfTool redact"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 66 + "follow_up_issue": 231 }, { "id": "signature-plugin", @@ -313,7 +313,7 @@ "replacement_surface": "loop-fix", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "loop-preflight-plugin", @@ -328,7 +328,7 @@ "replacement_surface": "loop-preflight", "dependencies": ["LoopLibCore", "PdfTool preflight"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 195 + "follow_up_issue": 231 }, { "id": "action-list-plugin", @@ -343,7 +343,7 @@ "replacement_surface": "loop-fix", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 30 + "follow_up_issue": 231 }, { "id": "scanner-plugin", @@ -388,7 +388,7 @@ "replacement_surface": "loop-cli", "dependencies": ["PdfTool ocr"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": 41 + "follow_up_issue": 231 }, { "id": "loop-document", @@ -403,7 +403,7 @@ "replacement_surface": "loop-editor", "dependencies": ["LoopEditor", "LoopLibQuick"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "loop-preflight", @@ -418,7 +418,7 @@ "replacement_surface": "loop-editor", "dependencies": ["PdfTool preflight"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 195 + "follow_up_issue": 231 }, { "id": "loop-production-preview", @@ -433,7 +433,7 @@ "replacement_surface": "loop-production-preview", "dependencies": ["LoopLibInteraction", "PreviewStateModel"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "loop-pages-production", @@ -448,7 +448,7 @@ "replacement_surface": "loop-pagemaster", "dependencies": ["PDFPageMasterExport", "ADR-003", "ADR-004"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "loop-inspect", @@ -463,7 +463,7 @@ "replacement_surface": "loop-inspect", "dependencies": ["LoopLibInteraction", "InspectorModel"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 193 + "follow_up_issue": 231 }, { "id": "loop-fix", @@ -478,7 +478,7 @@ "replacement_surface": "loop-fix", "dependencies": ["LoopLibCore", "LoopLibInteraction", "PdfTool repair"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 126 + "follow_up_issue": 231 }, { "id": "loop-compare", @@ -493,7 +493,7 @@ "replacement_surface": "loop-diff", "dependencies": ["PDFDiff"], "validation_evidence": "docs/LOOP_WORKSPACES.md", - "follow_up_issue": 193 + "follow_up_issue": 231 } ], "packaging": { diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index d8ccbaa63..4d89571c9 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -410,6 +410,12 @@ def main() -> int: add_result(evidence, "source_integrity", [python, "scripts/ci/check_source_integrity.py"], ROOT, args.dry_run) add_result(evidence, "architecture_catalog", [python, "scripts/generate-architecture-catalogs.py", "--check"], ROOT, args.dry_run) add_result(evidence, "policy_adapters", [python, "scripts/agent/generate-adapters.py"], ROOT, args.dry_run) + add_result(evidence, "loop_identity", [python, "scripts/ci/check_loop_identity.py"], ROOT, args.dry_run) + add_result(evidence, "governance_registers", [python, "scripts/ci/check_governance_registers.py"], ROOT, args.dry_run) + add_result(evidence, "governance_negative_cases", + [python, "-m", "unittest", "scripts.ci.test_check_governance_registers", + "scripts.ci.test_check_loop_identity", "scripts.github.test_sync_milestones", "-q"], + ROOT, args.dry_run) # Whole-tree contract guards: they audit invariants of the tree, not just the # diff, so they run on every change like source_integrity does. add_result( diff --git a/scripts/ci/check_governance_registers.py b/scripts/ci/check_governance_registers.py new file mode 100644 index 000000000..ae74c4947 --- /dev/null +++ b/scripts/ci/check_governance_registers.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Validate G00 ownership records and regenerate their reference tables.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from scripts.agent.yaml_subset import load_yaml + +REGISTER_DIR = "docs/governance" +MODULE_IDS = {f"L{number:02d}" for number in range(1, 15)} | {"R00", "X00"} +DISPOSITIONS = {"partially-present", "gap", "duplicate", "obsolete", "deferred"} +SOURCE_TABLE = "docs/GOVERNANCE_SOURCE_REGISTER.md" +PROOF_TABLE = "docs/ARCHITECTURE_PROOF_DISPOSITIONS.md" + + +def read_registers(root: Path) -> tuple[dict, dict]: + return tuple(json.loads((root / REGISTER_DIR / name).read_text(encoding="utf-8")) + for name in ("source-register.json", "proof-obligations.json")) + + +def pending_obligations(root: Path) -> dict[str, str]: + invariants = load_yaml((root / "architecture/invariants.yaml").read_text(encoding="utf-8")) + budgets = load_yaml((root / "architecture/quality-budgets.yaml").read_text(encoding="utf-8")) + return { + **{row["id"]: row["enforcement"] for row in invariants["checks"] if row.get("todo")}, + **{row["id"]: row["enforcement"] for row in budgets["metrics"] + if row["enforcement"] == "declared"}, + } + + +def validate_data(root: Path, sources: dict, proofs: dict) -> list[str]: + errors = [] + for label, document in (("sources", sources), ("proofs", proofs)): + if document.get("repository") != "studio-berry/loop": + errors.append(f"{label}: repository must be studio-berry/loop") + if not re.fullmatch(r"[0-9a-f]{40}", str(document.get("reviewed_sha", ""))): + errors.append(f"{label}: reviewed_sha must be a full SHA") + if not document.get("reviewed_on"): + errors.append(f"{label}: review date is absent") + if sources.get("reviewed_sha") != proofs.get("reviewed_sha"): + errors.append("registers have different review SHAs") + modules = sources.get("modules", []) + module_ids = [row["id"] for row in modules] + if set(module_ids) != MODULE_IDS or len(module_ids) != len(MODULE_IDS): + errors.append("source register must cover L01-L14, R00 and X00 exactly once") + expected = pending_obligations(root) + obligations = proofs.get("obligations", []) + ids = [row["id"] for row in obligations] + if set(ids) != set(expected) or len(ids) != len(expected): + errors.append("proof register must cover every pending invariant and quality metric exactly once") + for row in modules + obligations + sources.get("special_dispositions", []): + for field in ("owner", "disposition", "evidence", "next_action", "limitation", "issues"): + if not row.get(field): + errors.append(f"{row['id']}: missing {field}") + for path in row.get("evidence", []): + target = (root / path).resolve() + if not target.is_relative_to(root.resolve()) or not target.is_file(): + errors.append(f"{row['id']}: evidence does not resolve: {path}") + for number in row.get("issues", []): + if str(number) not in sources.get("issues", {}): + errors.append(f"{row['id']}: issue #{number} has no qualified snapshot") + for row in modules: + if row["disposition"] not in DISPOSITIONS: + errors.append(f"{row['id']}: invalid source disposition") + if not row.get("inputs") or not row.get("source_availability"): + errors.append(f"{row['id']}: source identity or availability is absent") + for source in row.get("inputs", []): + if not re.fullmatch(r"legacy#[0-9]+|notion:[0-9a-f]{32}|studio-berry/loop#[0-9]+", source): + errors.append(f"{row['id']}: unqualified input {source}") + for row in obligations: + if row.get("enforcement") != expected.get(row["id"]): + errors.append(f"{row['id']}: enforcement disagrees with the architecture contract") + if row.get("disposition") not in {"deferred", "fulfilled"}: + errors.append(f"{row['id']}: pending proof needs a fulfillment or explicit deferral") + if row.get("measured_baseline") is not None: + errors.append(f"{row['id']}: this inventory does not admit new measurement baselines") + for number, issue in sources.get("issues", {}).items(): + if issue.get("repository") != sources.get("repository") or str(issue.get("number")) != number: + errors.append(f"issue #{number}: repository/number identity disagrees") + if not issue.get("title") or issue.get("state") not in {"OPEN", "CLOSED"}: + errors.append(f"issue #{number}: title/state snapshot is absent") + for retired in (".github/workflows/sync-milestones.yml", "docs/github-milestones/manifest.json"): + if (root / retired).exists(): + errors.append(f"{retired}: retired milestone write input was restored") + surface = json.loads((root / "docs/product-surface.json").read_text(encoding="utf-8")) + actual = {row["id"]: row["follow_up_issue"] for row in surface["surfaces"] + if row["follow_up_issue"] is not None} + planned = {identifier: row["issue"] for identifier, row in sources["product_follow_ups"].items()} + if actual != planned: + errors.append("product follow-up identities disagree with the reviewed source register") + for number in planned.values(): + if str(number) not in sources["issues"]: + errors.append(f"product follow-up #{number} has no qualified issue snapshot") + return errors + + +def tracker_findings(snapshot: dict, live: dict[int, dict]) -> list[str]: + errors = [] + for number, expected in snapshot.items(): + current = live.get(int(number)) + if current is None: + errors.append(f"issue #{number}: not an issue in studio-berry/loop") + continue + milestone = (current.get("milestone") or {}).get("title") + if any(current[field] != expected[field] for field in ("title", "state")) or milestone != expected["milestone"]: + errors.append(f"issue #{number}: live title/state/milestone disagrees with the frozen snapshot") + return errors + + +def github_findings(sources: dict) -> list[str]: + command = ["gh", "issue", "list", "--repo", sources["repository"], "--state", "all", + "--limit", "1000", "--json", "number,title,state,milestone"] + result = subprocess.run(command, check=True, capture_output=True, text=True, encoding="utf-8") + live = {row["number"]: row for row in json.loads(result.stdout)} + errors = tracker_findings(sources["issues"], live) + for expected in sources.get("pull_requests", []): + result = subprocess.run( + ["gh", "pr", "view", str(expected["number"]), "--repo", sources["repository"], + "--json", "number,title,state,mergeCommit,headRefOid"], + check=True, capture_output=True, text=True, encoding="utf-8") + current = json.loads(result.stdout) + sha = (current.get("mergeCommit") or {}).get("oid") or current.get("headRefOid") + if current["title"] != expected["title"] or current["state"] != expected["state"] or sha != expected["sha"]: + errors.append(f"PR #{expected['number']}: live identity disagrees with the frozen snapshot") + return errors + + +def issue_links(numbers: list[int]) -> str: + return ", ".join(f"[#{number}](https://github.com/studio-berry/loop/issues/{number})" + for number in numbers) + + +def evidence_links(paths: list[str]) -> str: + return ", ".join(f"[{path}](../{path})" for path in paths) + + +def tables(sources: dict, proofs: dict) -> dict[str, str]: + introduction = ( + f"Reviewed source: `{sources['reviewed_sha']}`. Read-back date: {sources['reviewed_on']}.\n\n" + "Generated from the JSON records under [governance](governance/). " + "Regenerate with `python scripts/ci/check_governance_registers.py --write`.\n\n" + "These records identify source, code, tests, owners, and decisions. " + "A closed issue or merged PR supplies tracking provenance. Runtime proof, independent proof, " + "and release admission are not revalidated by this inventory.\n\n") + source_lines = ["# Cross-module source and disposition register\n\n", introduction, + "## Named roadmap sources\n\n", + "| Source | Availability and disposition | Owner and next action |\n", + "| --- | --- | --- |\n"] + for row in sources["sources"]: + source_lines.append(f"| [{row['title']}]({row['url']}) | {row['availability']} | {row['next_action']} |\n") + source_lines.extend(["\n## Module intake\n\n", + "| Module and source identities | Owner and disposition | Current issues | Evidence | Next action and limits |\n", + "| --- | --- | --- | --- | --- |\n"]) + for row in sources["modules"]: + identities = ", ".join(f"`{identity}`" for identity in row["inputs"]) + source_lines.append( + f"| {row['id']}: {identities} | {row['owner']}; {row['disposition']} | " + f"{issue_links(row['issues'])} | {evidence_links(row['evidence'])} | " + f"{row['next_action']} {row['source_availability']} {row['limitation']} |\n") + source_lines.extend(["\n## Special historical dispositions\n\n", + "| Source | Owner and disposition | Next action and limits |\n", + "| --- | --- | --- |\n"]) + for row in sources["special_dispositions"]: + source_lines.append(f"| `{row['id']}` | {row['owner']}; {row['disposition']}; " + f"{issue_links(row['issues'])} | {row['next_action']} {row['limitation']} |\n") + source_lines.extend(["\n## Product follow-up reconciliation\n\n", + "| Product record | Retired reference | Current owner |\n", + "| --- | --- | --- |\n"]) + for identifier, row in sources["product_follow_ups"].items(): + source_lines.append(f"| `{identifier}` | legacy #{row['legacy']} | {issue_links([row['issue']])} |\n") + source_lines.extend(["\n## Live tracker snapshot\n\n", + "| Qualified issue | Title | State at review | Milestone |\n", + "| --- | --- | --- | --- |\n"]) + for number, row in sorted(sources["issues"].items(), key=lambda item: int(item[0])): + source_lines.append( + f"| [studio-berry/loop#{number}](https://github.com/studio-berry/loop/issues/{number}) | " + f"{row['title']} | {row['state']} | {row['milestone'] or 'None'} |\n") + source_lines.extend(["\n## Existing pull-request provenance\n\n", + "| PR | Title and status | Merge or head SHA |\n", "| --- | --- | --- |\n"]) + for row in sources["pull_requests"]: + source_lines.append(f"| [#{row['number']}](https://github.com/studio-berry/loop/pull/{row['number']}) | " + f"{row['title']}; {row['state']} | `{row['sha']}` |\n") + proof_lines = ["# Architecture proof ownership and dispositions\n\n", introduction, + "No new metric enforcement is admitted. Fixed budgets and sealed outputs are unchanged. " + "A supplemental guard that requires an exported API or schema remains subject to an architect checkpoint.\n\n", + "| Obligation | Current declaration and disposition | Owner and tracker | Existing evidence | Closure criterion and limits |\n", + "| --- | --- | --- | --- | --- |\n"] + for row in proofs["obligations"]: + proof_lines.append( + f"| `{row['id']}` | {row['enforcement']}; {row['disposition']} | {row['owner']}; " + f"{issue_links(row['issues'])} | {evidence_links(row['evidence'])} | " + f"{row['next_action']} {row['limitation']} |\n") + return {SOURCE_TABLE: "".join(source_lines), PROOF_TABLE: "".join(proof_lines)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the reference tables") + parser.add_argument("--verify-github", action="store_true", help="read back frozen issue and PR identities") + args = parser.parse_args() + try: + sources, proofs = read_registers(ROOT) + errors = validate_data(ROOT, sources, proofs) + if errors: + raise ValueError("\n".join(errors)) + if args.verify_github: + errors.extend(github_findings(sources)) + if errors: + raise ValueError("\n".join(errors)) + for relative, content in tables(sources, proofs).items(): + path = ROOT / relative + if args.write: + path.write_text(content, encoding="utf-8", newline="\n") + elif not path.is_file() or path.read_text(encoding="utf-8") != content: + errors.append(f"{relative}: stale; run --write") + if errors: + raise ValueError("\n".join(errors)) + except (OSError, ValueError, subprocess.CalledProcessError) as exc: + print(f"governance registers FAILED: {exc}", file=sys.stderr) + return 1 + print(f"governance registers passed: {len(sources['modules'])} modules, {len(proofs['obligations'])} proof obligations") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/check_loop_identity.py b/scripts/ci/check_loop_identity.py index 7261aecc6..37e0e65b6 100644 --- a/scripts/ci/check_loop_identity.py +++ b/scripts/ci/check_loop_identity.py @@ -3,9 +3,11 @@ from __future__ import annotations +import json import re import subprocess import sys +import xml.etree.ElementTree as ET from pathlib import Path from typing import Iterable, Mapping @@ -114,8 +116,46 @@ def find_legacy_token_findings(root: Path = ROOT) -> list[str]: return legacy_token_findings(files) +def repository_reference_findings(files: Mapping[str, str]) -> list[str]: + retired = re.compile(r"https://github\.com/(?:mberrys/(?:Loop-pdf|loop2?)|studio-berry/(?:loop2|Loop-pdf))(?:[/#?\s]|$)", re.IGNORECASE) + findings = [] + for path, content in sorted(files.items()): + for number, line in enumerate(content.splitlines(), 1): + if retired.search(line) or "gh-releases-zsync|mberrys|Loop-pdf|" in line: + findings.append(f"{path}:{number}: retired repository reference") + return findings + + +def persisted_identity_findings(files: Mapping[str, str]) -> list[str]: + findings = [] + checks = { + "WixInstaller/Product.wxs.in": (".//{*}Product", "UpgradeCode", "26336d8a-b2e7-44fc-9a73-68aa99900c7a"), + "AppxManifest.xml.in": (".//{*}Identity", "Name", "mberrys.Loop-pdf"), + } + for path, (element_path, attribute, expected) in checks.items(): + element = ET.fromstring(files[path]).find(element_path) + if element is None or element.get(attribute) != expected: + findings.append(f"{path}: persisted identity changed; migration proof required") + identity = ET.fromstring(files["AppxManifest.xml.in"]).find(".//{*}Identity") + if identity is None or identity.get("Publisher") != "CN=F0582764-7439-4F99-A76D-473EDF10E031": + findings.append("AppxManifest.xml.in: publisher changed; migration proof required") + if json.loads(files["Flatpak/io.github.mberrys.Loop-pdf.json"])["app-id"] != "io.github.mberrys.Loop-pdf": + findings.append("Flatpak: persisted app-id changed; migration proof required") + return findings + + def contract_findings(root: Path = ROOT) -> list[str]: findings = find_legacy_token_findings(root) + active = ["README.md", "docs/REPO_MAP.md", "docs/VERSIONING.md", "docs/PLATFORM_SUPPORT.md"] + active.extend(str(path.relative_to(root)) for pattern in + (".github/workflows/*.yml", "Desktop/*.xml", "Flatpak/*.json") + for path in root.glob(pattern)) + findings.extend(repository_reference_findings( + {path: (root / path).read_text(encoding="utf-8") for path in active})) + identities = ("WixInstaller/Product.wxs.in", "AppxManifest.xml.in", + "Flatpak/io.github.mberrys.Loop-pdf.json") + findings.extend(persisted_identity_findings( + {path: (root / path).read_text(encoding="utf-8") for path in identities})) def require(path_name: str, snippet: str, description: str) -> None: path = root / path_name @@ -142,6 +182,14 @@ def require(path_name: str, snippet: str, description: str) -> None: "legacy settings migration", ) + policy = json.loads((root / "docs/version-policy.json").read_text(encoding="utf-8")) + display_version = policy["current"] + ("-" + policy["prerelease"] if policy["prerelease"] else "") + require("README.md", f"Current source version: `{display_version}`", "current source version") + require("docs/REPO_MAP.md", f"**{display_version}**", "current source version") + require("Desktop/io.github.mberrys.Loop-pdf.appdata.xml", "Loop", "Loop display name") + require("Desktop/io.github.mberrys.Loop-pdf.appdata.xml", + "io.github.mberrys.Loop-pdf", "preserved AppStream identity") + for path_name, surface in ENTRYPOINT_SURFACES.items(): require( path_name, diff --git a/scripts/ci/test_check_governance_registers.py b/scripts/ci/test_check_governance_registers.py new file mode 100644 index 000000000..704d40626 --- /dev/null +++ b/scripts/ci/test_check_governance_registers.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Negative cases for source qualification and complete proof ownership.""" + +from __future__ import annotations + +import copy +import unittest + +from scripts.ci import check_governance_registers as checker + + +class GovernanceRegisterTests(unittest.TestCase): + def setUp(self) -> None: + self.sources, self.proofs = checker.read_registers(checker.ROOT) + + def test_registers_cover_current_contracts(self) -> None: + self.assertEqual(checker.validate_data(checker.ROOT, self.sources, self.proofs), []) + + def test_missing_module_fails(self) -> None: + self.sources["modules"].pop() + self.assertTrue(any("cover L01-L14" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_missing_pending_metric_requires_a_disposition(self) -> None: + self.proofs["obligations"].pop() + self.assertTrue(any("every pending" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_declared_metric_cannot_be_promoted_by_the_register(self) -> None: + self.proofs["obligations"][-1]["enforcement"] = "enforced" + self.assertTrue(any("enforcement disagrees" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_inventory_cannot_invent_a_measurement_baseline(self) -> None: + self.proofs["obligations"][-1]["measured_baseline"] = 1 + self.assertTrue(any("does not admit" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_legacy_product_follow_up_cannot_replace_the_reviewed_owner(self) -> None: + row = next(iter(self.sources["product_follow_ups"].values())) + row["issue"] = row["legacy"] + self.assertTrue(any("follow-up identities disagree" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_missing_evidence_is_rejected(self) -> None: + self.proofs["obligations"][0]["evidence"] = ["unavailable-measurement.json"] + self.assertTrue(any("does not resolve" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_wrong_repository_issue_identity_is_rejected(self) -> None: + issue = next(iter(self.sources["issues"].values())) + issue["repository"] = "legacy" + self.assertTrue(any("identity disagrees" in error for error in + checker.validate_data(checker.ROOT, self.sources, self.proofs))) + + def test_number_collision_fails_live_read_back(self) -> None: + snapshot = {"47": {"number": 47, "title": "L06-01", "state": "OPEN", "milestone": None}} + live = {47: {"number": 47, "title": "Legacy bleed calibration", "state": "OPEN", "milestone": None}} + self.assertTrue(checker.tracker_findings(copy.deepcopy(snapshot), live)) + + def test_missing_live_issue_cannot_be_a_provenance_match(self) -> None: + self.assertTrue(checker.tracker_findings({"47": {}}, {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_check_loop_identity.py b/scripts/ci/test_check_loop_identity.py index 101514326..1f9f45a19 100644 --- a/scripts/ci/test_check_loop_identity.py +++ b/scripts/ci/test_check_loop_identity.py @@ -17,6 +17,28 @@ class LoopIdentityContractTest(unittest.TestCase): def test_repository_contract_passes(self) -> None: self.assertEqual(check_loop_identity.contract_findings(), []) + def test_retired_repository_link_is_rejected(self) -> None: + findings = check_loop_identity.repository_reference_findings( + {"README.md": "https://github.com/mberrys/Loop-pdf/releases\n"}) + self.assertEqual(len(findings), 1) + + def test_reset_repository_redirect_is_rejected_in_active_metadata(self) -> None: + self.assertTrue(check_loop_identity.repository_reference_findings( + {"workflow.yml": "https://github.com/studio-berry/loop2"})) + + def test_appimage_update_destination_is_checked(self) -> None: + self.assertTrue(check_loop_identity.repository_reference_findings( + {"LinuxInstall.yml": "gh-releases-zsync|mberrys|Loop-pdf|latest|"})) + + def test_upgrade_identity_change_requires_migration_proof(self) -> None: + paths = ("WixInstaller/Product.wxs.in", "AppxManifest.xml.in", + "Flatpak/io.github.mberrys.Loop-pdf.json") + files = {path: (ROOT / path).read_text(encoding="utf-8") for path in paths} + files[paths[0]] = files[paths[0]].replace( + "26336d8a-b2e7-44fc-9a73-68aa99900c7a", "00000000-0000-0000-0000-000000000000") + self.assertTrue(any("migration proof required" in finding + for finding in check_loop_identity.persisted_identity_findings(files))) + def test_unallowlisted_legacy_token_is_reported(self) -> None: legacy_token = "lo" + "upe" findings = check_loop_identity.legacy_token_findings( diff --git a/scripts/github/sync_milestones.py b/scripts/github/sync_milestones.py index b444b840c..543b8f29a 100644 --- a/scripts/github/sync_milestones.py +++ b/scripts/github/sync_milestones.py @@ -1,239 +1,20 @@ #!/usr/bin/env python3 -"""Sync GitHub milestone descriptions from docs/github-milestones/.""" +"""Report retirement of the pre-reset release-milestone synchronizer.""" from __future__ import annotations import argparse -import json -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -ROOT = Path(__file__).resolve().parents[2] -MILESTONES_DIR = ROOT / "docs" / "github-milestones" -MANIFEST_PATH = MILESTONES_DIR / "manifest.json" -REPO = "studio-berry/loop" - - -@dataclass(frozen=True) -class MilestoneSpec: - title: str - description: str - state: str - - -@dataclass(frozen=True) -class RetireSpec: - title: str - description: str - - -@dataclass(frozen=True) -class RemoteMilestone: - number: int - title: str - description: str - state: str - - -def load_manifest() -> tuple[list[MilestoneSpec], list[RetireSpec]]: - manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) - specs: list[MilestoneSpec] = [] - for entry in manifest["milestones"]: - description_path = MILESTONES_DIR / entry["description_file"] - specs.append( - MilestoneSpec( - title=entry["title"], - description=description_path.read_text(encoding="utf-8").strip(), - state=entry.get("state", "open"), - ) - ) - retire = [ - RetireSpec(title=entry["title"], description=entry["description"].strip()) - for entry in manifest.get("retire", []) - ] - return specs, retire - - -def gh_api( - method: str, - path: str, - payload: dict | None = None, - *, - paginate: bool = False, -) -> object: - command = ["gh", "api", "--method", method, path] - if paginate: - command.append("--paginate") - if payload is not None: - command.extend(["--input", "-"]) - try: - completed = subprocess.run( - command, - input=None if payload is None else json.dumps(payload), - text=True, - capture_output=True, - check=True, - ) - except subprocess.CalledProcessError as exc: - raise RuntimeError(exc.stderr.strip() or exc.stdout.strip()) from exc - if not completed.stdout.strip(): - return {} - return json.loads(completed.stdout) - - -def remote_from_payload(item: dict) -> RemoteMilestone: - return RemoteMilestone( - number=item["number"], - title=item["title"], - description=item.get("description") or "", - state=item["state"], - ) - - -def list_remote_milestones() -> list[RemoteMilestone]: - raw = gh_api( - "GET", - f"repos/{REPO}/milestones?state=all&per_page=100", - paginate=True, - ) - if not isinstance(raw, list): - raise RuntimeError("unexpected milestone list response") - return [remote_from_payload(item) for item in raw] - - -def plan_updates( - specs: list[MilestoneSpec], remote: list[RemoteMilestone] -) -> tuple[list[MilestoneSpec], list[tuple[MilestoneSpec, RemoteMilestone]]]: - by_title = {item.title: item for item in remote} - creates: list[MilestoneSpec] = [] - updates: list[tuple[MilestoneSpec, RemoteMilestone]] = [] - for spec in specs: - current = by_title.get(spec.title) - if current is None: - creates.append(spec) - continue - if ( - current.description.strip() == spec.description - and current.state == spec.state - ): - continue - updates.append((spec, current)) - return creates, updates - - -def plan_retirements( - retire_specs: list[RetireSpec], remote: list[RemoteMilestone] -) -> list[tuple[RetireSpec, RemoteMilestone]]: - by_title = {item.title: item for item in remote} - planned: list[tuple[RetireSpec, RemoteMilestone]] = [] - for spec in retire_specs: - current = by_title.get(spec.title) - if current is None: - continue - if current.state == "closed" and current.description.strip() == spec.description: - continue - planned.append((spec, current)) - return planned - - -def create_milestone(spec: MilestoneSpec) -> RemoteMilestone: - payload = gh_api( - "POST", - f"repos/{REPO}/milestones", - { - "title": spec.title, - "description": spec.description, - "state": spec.state, - }, - ) - if not isinstance(payload, dict): - raise RuntimeError(f"unexpected create response for {spec.title!r}") - return remote_from_payload(payload) - - -def apply_update(spec: MilestoneSpec, current: RemoteMilestone) -> None: - gh_api( - "PATCH", - f"repos/{REPO}/milestones/{current.number}", - { - "description": spec.description, - "state": spec.state, - }, - ) - - -def apply_retirement(spec: RetireSpec, current: RemoteMilestone) -> None: - gh_api( - "PATCH", - f"repos/{REPO}/milestones/{current.number}", - { - "description": spec.description, - "state": "closed", - }, - ) - - -def main() -> int: +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--apply", - action="store_true", - help="write updates to GitHub (default is dry run)", - ) - args = parser.parse_args() - - specs, retire_specs = load_manifest() - remote = list_remote_milestones() - creates, updates = plan_updates(specs, remote) - retirements = plan_retirements(retire_specs, remote) - - if not creates and not updates and not retirements: - print("All milestone descriptions already match manifest.") - return 0 - - for spec in creates: - print(f"{spec.title}: create milestone ({spec.state})") - - for spec, current in updates: - print(f"{spec.title}: milestone #{current.number}") - if current.description.strip() != spec.description: - print(" description: update") - if current.state != spec.state: - print(f" state: {current.state} -> {spec.state}") - - for spec, current in retirements: - print(f"{spec.title}: retire milestone #{current.number}") - if current.description.strip() != spec.description: - print(" description: update") - if current.state != "closed": - print(f" state: {current.state} -> closed") - - if not args.apply: - action_count = len(creates) + len(updates) + len(retirements) - print(f"Dry run only. Re-run with --apply to update {action_count} milestone(s).") - return 0 - - for spec in creates: - created = create_milestone(spec) - print(f"Created {spec.title} (#{created.number}).") - - for spec, current in updates: - apply_update(spec, current) - print(f"Updated {spec.title} (#{current.number}).") - - for spec, current in retirements: - apply_retirement(spec, current) - print(f"Retired {spec.title} (#{current.number}).") - + parser.add_argument("--apply", action="store_true", help="retired; writes are refused") + args = parser.parse_args(argv) + if args.apply: + parser.error("milestone synchronization is retired; manage L01-L14, R00, G00 and X00 through reviewed issues") + print("Milestone synchronization is retired. No GitHub changes are planned.") return 0 if __name__ == "__main__": - try: - raise SystemExit(main()) - except RuntimeError as exc: - print(f"error: {exc}", file=sys.stderr) - raise SystemExit(1) from exc + raise SystemExit(main()) diff --git a/scripts/github/test_sync_milestones.py b/scripts/github/test_sync_milestones.py index 3cc9f820f..1ba19b9a8 100644 --- a/scripts/github/test_sync_milestones.py +++ b/scripts/github/test_sync_milestones.py @@ -1,125 +1,34 @@ #!/usr/bin/env python3 -"""Tests for scripts/github/sync_milestones.py.""" +"""Prove the retired milestone command cannot contact or mutate GitHub.""" from __future__ import annotations -import json -import tempfile +import contextlib +import io +import subprocess import unittest -from pathlib import Path from unittest import mock -from scripts.github import sync_milestones as module +from scripts.github import sync_milestones class SyncMilestonesTests(unittest.TestCase): - def test_plan_updates_detects_description_and_state_drift(self) -> None: - specs = [ - module.MilestoneSpec("0.1.0", "shipped", "closed"), - module.MilestoneSpec("0.1.1", "living", "open"), - ] - remote = [ - module.RemoteMilestone(5, "0.1.0", "old", "open"), - module.RemoteMilestone(4, "0.1.1", "living", "open"), - ] - - creates, updates = module.plan_updates(specs, remote) - - self.assertEqual(creates, []) - self.assertEqual(len(updates), 1) - self.assertEqual(updates[0][0].title, "0.1.0") - self.assertEqual(updates[0][1].number, 5) - - def test_plan_updates_creates_missing_milestones(self) -> None: - specs = [ - module.MilestoneSpec("0.2.0", "operator experience", "open"), - module.MilestoneSpec("0.1.1", "living", "open"), - ] - remote = [module.RemoteMilestone(4, "0.1.1", "living", "open")] - - creates, updates = module.plan_updates(specs, remote) - - self.assertEqual(len(creates), 1) - self.assertEqual(creates[0].title, "0.2.0") - self.assertEqual(updates, []) - - def test_plan_updates_skips_matching_milestones(self) -> None: - specs = [module.MilestoneSpec("0.4.0", "agent alpha", "open")] - remote = [module.RemoteMilestone(7, "0.4.0", "agent alpha", "open")] - - creates, updates = module.plan_updates(specs, remote) - - self.assertEqual(creates, []) - self.assertEqual(updates, []) - - def test_plan_retirements_closes_superseded_titles(self) -> None: - retire = [module.RetireSpec("0.1.2", "retired")] - remote = [module.RemoteMilestone(3, "0.1.2", "old text", "open")] - - retirements = module.plan_retirements(retire, remote) - - self.assertEqual(len(retirements), 1) - self.assertEqual(retirements[0][0].title, "0.1.2") - - def test_plan_retirements_skips_already_retired(self) -> None: - retire = [module.RetireSpec("0.1.2", "retired")] - remote = [module.RemoteMilestone(3, "0.1.2", "retired", "closed")] - - self.assertEqual(module.plan_retirements(retire, remote), []) - - def test_load_manifest_reads_manifest_and_markdown(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - milestones_dir = root / "docs" / "github-milestones" - milestones_dir.mkdir(parents=True) - (milestones_dir / "manifest.json").write_text( - json.dumps( - { - "milestones": [ - { - "title": "0.2.0", - "description_file": "0.2.0.md", - "state": "open", - } - ], - "retire": [ - { - "title": "0.1.2", - "description": "retired", - } - ], - } - ), - encoding="utf-8", - ) - (milestones_dir / "0.2.0.md").write_text("operator experience", encoding="utf-8") - - with mock.patch.object(module, "MILESTONES_DIR", milestones_dir), mock.patch.object( - module, "MANIFEST_PATH", milestones_dir / "manifest.json" - ): - specs, retire = module.load_manifest() - - self.assertEqual(len(specs), 1) - self.assertEqual(specs[0].title, "0.2.0") - self.assertEqual(specs[0].description, "operator experience") - self.assertEqual(len(retire), 1) - self.assertEqual(retire[0].title, "0.1.2") - - @mock.patch.object(module, "gh_api") - def test_list_remote_milestones_requests_all_pages(self, gh_api: mock.Mock) -> None: - gh_api.return_value = [ - {"number": 1, "title": "0.1.0", "description": "old", "state": "closed"}, - {"number": 101, "title": "0.2.0", "description": "new", "state": "open"}, - ] - - milestones = module.list_remote_milestones() - - gh_api.assert_called_once_with( - "GET", - "repos/studio-berry/loop/milestones?state=all&per_page=100", - paginate=True, - ) - self.assertEqual([item.number for item in milestones], [1, 101]) + @mock.patch.object(subprocess, "run", side_effect=AssertionError("external command")) + def test_dry_run_is_offline_and_plans_no_writes(self, run: mock.Mock) -> None: + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(sync_milestones.main([]), 0) + self.assertIn("No GitHub changes are planned", output.getvalue()) + run.assert_not_called() + + @mock.patch.object(subprocess, "run", side_effect=AssertionError("external command")) + def test_apply_is_refused_before_any_external_command(self, run: mock.Mock) -> None: + error = io.StringIO() + with contextlib.redirect_stderr(error), self.assertRaises(SystemExit) as raised: + sync_milestones.main(["--apply"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("synchronization is retired", error.getvalue()) + run.assert_not_called() if __name__ == "__main__": From ca59c30f14eb1a3d7ec59abbfd0fd13d166d9b6a Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:41:23 -0700 Subject: [PATCH 02/43] Bind G00 proof lanes and record focused verification --- changes/chore-g00-governance.evidence.yaml | 7 +++++++ docs/REPOSITORY_GOVERNANCE.md | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/changes/chore-g00-governance.evidence.yaml b/changes/chore-g00-governance.evidence.yaml index 7737d10ff..e1bbf7b78 100644 --- a/changes/chore-g00-governance.evidence.yaml +++ b/changes/chore-g00-governance.evidence.yaml @@ -14,5 +14,12 @@ claims: - unit:scripts/agent/test_architecture_contracts.py - architecture:scripts/agent/check-architecture.py - architecture:scripts/agent/generate-adapters.py + - id: plugin-and-packaging-proof-lanes + evidence: + - unit:agent-policy:plugins + - packaging:linux-build + - packaging:windows-build + - security:codeql unresolved: + - Hosted Linux/Windows packaging and CodeQL checks are pending on the PR head; workflow references identify the required lanes, not a passing run. - Pending architecture measurements and supplemental guards retain their declared or deferred disposition in docs/ARCHITECTURE_PROOF_DISPOSITIONS.md. diff --git a/docs/REPOSITORY_GOVERNANCE.md b/docs/REPOSITORY_GOVERNANCE.md index 2a7bcdbf1..3f4b9c890 100644 --- a/docs/REPOSITORY_GOVERNANCE.md +++ b/docs/REPOSITORY_GOVERNANCE.md @@ -110,6 +110,23 @@ the focused Python tests, and `python scripts/agent/check-change.py --base origi Pending measurements, independent oracle results, and release admission remain with their named owners. Review of this PR is the maintainer decision checkpoint. +The focused suite ran 118 Python cases: 117 passed, and the optional PdfTool +inventory case skipped because PdfTool is not built in this worktree. +The catalog, live tracker read-back, version, product-reference, supply-chain, +and Phase 5 evidence checks passed. The before-audit found eight stale preflight +states, four absent expansion milestones, sixteen colliding product follow-ups, +retired README/update links, and the active obsolete milestone writer. +The after-audit resolves those findings. + +The mapped `UnitTestsPluginAbi` test passed in the existing +`C:/\.dev/build/loop-milestone-1-gaps` build. Its build plan reports +`ninja: no work to do`. The plugin implementation, header, and ABI test are +byte-identical between that build's source checkout at +`73ca7268f2dc1345b36568eea85d029ac0fcc232` and this PR. +This is focused ABI evidence for unchanged native inputs, not a complete build +or installed-package qualification of the PR head. Check-change uses that +existing build for its mapped lane. Hosted packaging and CodeQL remain pending. + Quality review: the change reuses existing product identities, tracker owners, catalogs, and behavioral tests. It removes the obsolete milestone writer and keeps source availability, implementation, measured proof, and release admission From 78f9c0bce909bca7f95cc5b6c701043e61332087 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:42:38 -0700 Subject: [PATCH 03/43] Correct the existing build path in the G00 proof record --- docs/REPOSITORY_GOVERNANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/REPOSITORY_GOVERNANCE.md b/docs/REPOSITORY_GOVERNANCE.md index 3f4b9c890..d812c56dc 100644 --- a/docs/REPOSITORY_GOVERNANCE.md +++ b/docs/REPOSITORY_GOVERNANCE.md @@ -119,7 +119,7 @@ retired README/update links, and the active obsolete milestone writer. The after-audit resolves those findings. The mapped `UnitTestsPluginAbi` test passed in the existing -`C:/\.dev/build/loop-milestone-1-gaps` build. Its build plan reports +`C:/.dev/build/loop-milestone-1-gaps` build. Its build plan reports `ninja: no work to do`. The plugin implementation, header, and ABI test are byte-identical between that build's source checkout at `73ca7268f2dc1345b36568eea85d029ac0fcc232` and this PR. From 095927b085a19460efe3200c1a57e9fe0dd3bf94 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:33:33 -0700 Subject: [PATCH 04/43] Add authoritative Quick print-to-PDF and document exports --- LoopEditor/CMakeLists.txt | 2 + LoopEditor/editorhost.cpp | 204 +++++++++++++++++ LoopEditor/editorhost.h | 22 ++ LoopEditor/qml/Main.qml | 107 +++++++++ LoopEditor/qml/MenuModel.qml | 2 + LoopEditor/quickoutput.cpp | 205 ++++++++++++++++++ LoopEditor/quickoutput.h | 44 ++++ .../sources/actionlistrunsubmitter.cpp | 4 + .../sources/actionlistrunsubmitter.h | 1 + UnitTests/phase4-tests.cmake | 10 + UnitTests/tst_editorhosttest.cpp | 187 ++++++++++++++++ agent-policy.json | 2 + ...issue-123-quick-print-export.evidence.yaml | 18 ++ changes/feat-issue-123-quick-print-export.md | 4 + docs/LOOP_SHELL_CONTRACT.md | 2 +- docs/QUICK_PDF_PARITY.md | 6 +- docs/QUICK_PRINT_EXPORT.md | 49 +++++ docs/loop-shell-actions.json | 75 ++++++- scripts/verify-command-catalog.py | 2 + .../CMakeLists.txt | 2 + .../qml/Main.qml | 107 +++++++++ .../qml/MenuModel.qml | 2 + 22 files changed, 1050 insertions(+), 7 deletions(-) create mode 100644 LoopEditor/quickoutput.cpp create mode 100644 LoopEditor/quickoutput.h create mode 100644 changes/feat-issue-123-quick-print-export.evidence.yaml create mode 100644 changes/feat-issue-123-quick-print-export.md create mode 100644 docs/QUICK_PRINT_EXPORT.md diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index c1a4d85d9..b160446e3 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -31,6 +31,8 @@ qt_add_executable(LoopEditor documentviewsession.cpp documentviewsession.h editorhost.cpp + quickoutput.cpp + quickoutput.h editorhost.h focusrestoration.cpp focusrestoration.h diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index f335a51fe..0da2988cf 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -416,6 +416,7 @@ EditorHost::EditorHost(QObject* parent) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishDocumentOutput(snapshot); refreshCanvasTrace(); }); } @@ -426,6 +427,8 @@ EditorHost::~EditorHost() cancelPreflight(); cancelActionList(); QObject::disconnect(&m_session->scheduler(), nullptr, this, nullptr); + if (outputBusy()) + m_session->scheduler().cancel(m_outputJobId); unbindCanvas(); // The guard registration is global process state owned by the thread that @@ -3348,6 +3351,161 @@ void EditorHost::reopenDocument() } } +QString EditorHost::outputRevision() const +{ + return m_session->facade().currentRevision().toString(); +} + +bool EditorHost::exportDocumentFileUrl(const QUrl& url, const QString& format, int dpi, + const QString& revision, bool fidelityAcknowledged) +{ + if (!url.isValid() || !url.isLocalFile()) + return false; + return invokeCommand(format == QLatin1String("print-pdf") ? QStringLiteral("actionPrint") : QStringLiteral("actionExportDocument"), + { { QStringLiteral("path"), url.toLocalFile() }, { QStringLiteral("format"), format }, { QStringLiteral("dpi"), dpi }, { QStringLiteral("revision"), revision }, { QStringLiteral("fidelityAcknowledged"), fidelityAcknowledged } }) != pdfinteraction::InvalidCommandInvocation; +} + +void EditorHost::beginDocumentOutput(pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) +{ + const auto refuse = [this, invocation](const QString& reason) + { + m_outputRecord = { { QStringLiteral("error"), reason }, { QStringLiteral("printProof"), false } }; + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, reason); + announceDocumentState(reason); + bumpPresentation(); + Q_EMIT documentOutputFinished(); + }; + if (!hasDocument() || outputBusy() || m_session->facade().operation().pending || + parameters.value(QStringLiteral("revision")).toString() != outputRevision()) + { + refuse(QStringLiteral("output/stale-or-busy-document")); + return; + } + loopeditor::OutputRequest request; + const QString format = parameters.value(QStringLiteral("format")).toString(); + if (format == QLatin1String("pdf")) + request.format = loopeditor::OutputFormat::PublishedPdf; + else if (format == QLatin1String("print-pdf")) + request.format = loopeditor::OutputFormat::PrintPdf; + else if (format == QLatin1String("png")) + request.format = loopeditor::OutputFormat::Png; + else if (format == QLatin1String("tiff")) + request.format = loopeditor::OutputFormat::Tiff; + else + { + refuse(QStringLiteral("output/invalid-format")); + return; + } + const bool printing = request.format == loopeditor::OutputFormat::PrintPdf; + request.dpi = parameters.value(QStringLiteral("dpi"), 300).toInt(); + if ((printing && (!m_documentModel.canPrint() || (!m_documentModel.canHighResolutionPrint() && request.dpi > 150))) || + (!printing && !m_documentModel.canCopy())) + { + refuse(QStringLiteral("output/permission-restricted")); + return; + } + request.fidelityAcknowledged = parameters.value(QStringLiteral("fidelityAcknowledged")).toBool(); + if (!pageFidelityIsExact() && !request.fidelityAcknowledged) + { + refuse(QStringLiteral("output/fidelity-acknowledgment-required")); + return; + } + request.revision = outputRevision(); + request.document = m_session->context().getDocumentPointer(); + request.sourcePath = m_session->facade().source().path; + request.page = currentPage(); + if (m_publicationRevision == request.revision) + { + request.publishedBytes = m_publishedOutputBytes; + request.publicationReceipt = m_publicationReceipt; + } + if (request.format == loopeditor::OutputFormat::PublishedPdf && request.publishedBytes.isEmpty() && + m_session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)) + { + refuse(QStringLiteral("output/unpublished-revision")); + return; + } + const QString destination = parameters.value(QStringLiteral("path")).toString(); + const QFileInfo target(destination), source(request.sourcePath); + if (destination.isEmpty() || target.isDir() || + target.absoluteFilePath() == source.absoluteFilePath() || + (target.exists() && target.canonicalFilePath() == source.canonicalFilePath())) + { + refuse(QStringLiteral("output/invalid-or-source-destination")); + return; + } + m_outputRevision = request.revision; + m_outputDestination = destination; + m_outputInvocation = invocation; + m_outputCancelled = false; + m_outputJobId = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_outputRecord = { { QStringLiteral("status"), QStringLiteral("preparing") } }; + m_outputResult = std::make_shared(); + pdf::PDFJobSpec spec; + spec.jobId = m_outputJobId; + spec.kind = pdf::PDFJobKind::Export; + spec.priority = pdf::PDFJobPriority::Operator; + spec.documentKey = m_session->revisionSource()->documentKey(); + spec.documentRevision = m_outputRevision; + spec.operationId = QStringLiteral("quick-output.%1").arg(format); + const auto result = m_outputResult; + m_session->scheduler().submit(spec, [request, result](pdf::PDFJobContext& job) + { loopeditor::prepareOutput(request, *result, job); }); + refreshFeatureAvailability(); + bumpPresentation(); +} + +void EditorHost::finishDocumentOutput(const pdf::PDFJobSnapshot& snapshot) +{ + if (snapshot.jobId != m_outputJobId || !m_outputResult) + return; + QString error = m_outputResult->error; + if (m_outputCancelled || snapshot.status != pdf::PDFJobStatus::Succeeded || !hasDocument() || + snapshot.kind != pdf::PDFJobKind::Export || snapshot.documentKey != m_session->revisionSource()->documentKey() || + snapshot.documentRevision != outputRevision() || snapshot.documentRevision != m_outputRevision || + !m_session->catalog().isPending(m_outputInvocation)) + { + error = QStringLiteral("output/cancelled-stale-or-failed"); + } + if (error.isEmpty()) + { + QFile staged(m_outputResult->stagedPath); + if (!staged.open(QIODevice::ReadOnly)) + error = QStringLiteral("output/staging-read-failed"); + else + { + const QString expectedDigest = snapshot.outputArtifact.sha256; + const auto written = pdf::PDFSafeFileWriter::writeDevice(m_outputDestination, [&staged, expectedDigest](QIODevice* output) + { + QCryptographicHash digest(QCryptographicHash::Sha256); + while (!staged.atEnd()) + { + const QByteArray chunk = staged.read(65536); + if (chunk.isEmpty() || output->write(chunk) != chunk.size()) return false; + digest.addData(chunk); + } + return staged.error() == QFile::NoError && QString::fromLatin1(digest.result().toHex()) == expectedDigest; }, pdf::PDFSafeFileWriter::OverwritePolicy::Overwrite); + if (!written) + error = QStringLiteral("output/commit-failed"); + } + } + const bool ok = error.isEmpty(); + m_outputRecord = m_outputResult->record.toVariantMap(); + m_outputRecord.insert(QStringLiteral("status"), ok ? QStringLiteral("saved") : QStringLiteral("failed")); + m_outputRecord.insert(QStringLiteral("error"), error); + m_outputRecord.insert(QStringLiteral("printProof"), false); + m_session->catalog().finishInvocation(m_outputInvocation, ok ? pdfinteraction::CommandTerminalState::Completed : snapshot.status == pdf::PDFJobStatus::Cancelled ? pdfinteraction::CommandTerminalState::Cancelled + : pdfinteraction::CommandTerminalState::Failed, + error); + m_outputJobId.clear(); + m_outputResult.reset(); + m_outputInvocation = pdfinteraction::InvalidCommandInvocation; + refreshFeatureAvailability(); + announceDocumentState(ok ? tr("Output saved. Artifact %1. This is not print proof.").arg(snapshot.outputArtifact.sha256.left(12)) : error); + bumpPresentation(); + Q_EMIT documentOutputFinished(); +} + void EditorHost::cancelPendingOperation() { if (m_session->facade().cancelPendingOperation()) @@ -3553,6 +3711,32 @@ void EditorHost::registerShellHandlers() void EditorHost::registerFeatureHandlers() { + for (const QString& id : { QStringLiteral("actionPrint"), QStringLiteral("actionExportDocument") }) + { + pdfinteraction::CommandCatalog::Handler output; + output.invoke = [this, id](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + if (parameters.isEmpty()) + { + Q_EMIT documentOutputRequested(id == QLatin1String("actionPrint")); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + return; + } + QVariantMap request = parameters; + if (id == QLatin1String("actionPrint")) + request.insert(QStringLiteral("format"), QStringLiteral("print-pdf")); + beginDocumentOutput(invocation, request); + }; + output.cancel = [this](pdfinteraction::CommandInvocationId invocation) + { + if (invocation == m_outputInvocation) + { + m_outputCancelled = true; + m_session->scheduler().cancel(m_outputJobId); + } + }; + m_session->catalog().setHandler(id, std::move(output)); + } auto bind = [this](const QString& id, std::function action) { pdfinteraction::CommandCatalog::Handler handler; @@ -3616,6 +3800,8 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); availability.insert(QStringLiteral("actionFullscreenMode"), true); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); + availability.insert(QStringLiteral("actionPrint"), ready && !outputBusy() && m_documentModel.canPrint()); + availability.insert(QStringLiteral("actionExportDocument"), ready && !outputBusy() && m_documentModel.canCopy()); m_session->catalog().setEnabledBatch(availability); } @@ -3713,6 +3899,12 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { + if (outputBusy()) + m_session->scheduler().cancel(m_outputJobId); + m_publishedOutputBytes.clear(); + m_publicationReceipt = {}; + m_publicationRevision.clear(); + m_outputRecord.clear(); cancelPreflight(); cancelActionList(); if (m_findingNavigator) @@ -3961,6 +4153,10 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) m_actionListController.acceptExecution(snapshot.jobId, snapshot.documentRevision, outcome->executionResult)) { m_session->context().setDocument(outcome->candidate); + m_session->scheduler().setCurrentRevision(m_session->revisionSource()->documentKey(), m_session->facade().currentRevision().toString()); + m_publishedOutputBytes = outcome->publishedBytes; + m_publicationReceipt = outcome->executionResult.governed.value(QStringLiteral("receipt")).toObject(); + m_publicationRevision = m_session->facade().currentRevision().toString(); m_preflight.markProfileStale(); syncRevisionModels(); announceDocumentState(tr("Action List applied to the open document.")); @@ -4116,6 +4312,14 @@ void EditorHost::syncRevisionModels() const QString documentKey = m_session->revisionSource()->documentKey(); const QString documentRevision = m_session->facade().currentRevision().toString(); m_preflight.setCurrentRevision(documentKey, documentRevision); + if (outputBusy() && documentRevision != m_outputRevision) + m_session->scheduler().cancel(m_outputJobId); + if (documentRevision != m_publicationRevision) + { + m_publishedOutputBytes.clear(); + m_publicationReceipt = {}; + m_publicationRevision.clear(); + } m_actionListController.setCurrentRevision(documentKey, documentRevision); m_inspector.setCurrentRevision(documentKey, documentRevision); m_preview.setCurrentRevision(documentKey, documentRevision); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 9cef1bf44..4a6a0a10a 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -47,6 +47,7 @@ #include "focusrestoration.h" #include "documentviewsession.h" #include "quickdocumentmodel.h" +#include "quickoutput.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -86,6 +87,8 @@ class EditorHost final : public QObject Q_PROPERTY(QString documentState READ documentState NOTIFY presentationChanged) Q_PROPERTY(bool hasDocument READ hasDocument NOTIFY presentationChanged) + Q_PROPERTY(bool outputBusy READ outputBusy NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap outputRecord READ outputRecord NOTIFY presentationChanged) Q_PROPERTY(bool correctionsAllowed READ correctionsAllowed NOTIFY presentationChanged) Q_PROPERTY(QString displayTitle READ displayTitle NOTIFY presentationChanged) Q_PROPERTY(QString typedError READ typedError NOTIFY presentationChanged) @@ -193,6 +196,11 @@ class EditorHost final : public QObject QString documentState() const; bool hasDocument() const; + bool outputBusy() const { return !m_outputJobId.isEmpty(); } + QVariantMap outputRecord() const { return m_outputRecord; } + Q_INVOKABLE QString outputRevision() const; + Q_INVOKABLE bool exportDocumentFileUrl(const QUrl& url, const QString& format, int dpi, + const QString& revision, bool fidelityAcknowledged); bool correctionsAllowed() const; QString displayTitle() const; QString typedError() const; @@ -485,6 +493,8 @@ class EditorHost final : public QObject void preflightProfileDraftChanged(); void actionListRecipesChanged(); void preflightReportExportRequested(); + void documentOutputRequested(bool printing); + void documentOutputFinished(); void preflightProfileImportRequested(); void preflightProfileExportRequested(); void preflightProfileSaveRequested(); @@ -518,6 +528,8 @@ class EditorHost final : public QObject void finishPreflightJob(const pdf::PDFJobSnapshot& snapshot); void finishActionListJob(const pdf::PDFJobSnapshot& snapshot); void finishRollbackJob(const pdf::PDFJobSnapshot& snapshot); + void beginDocumentOutput(pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters); + void finishDocumentOutput(const pdf::PDFJobSnapshot& snapshot); bool submitActionListJob(pdfinteraction::ActionListRunPhase phase, pdfinteraction::ActionListController::State controllerState); void reloadActionListRecipes(); @@ -565,6 +577,16 @@ class EditorHost final : public QObject QPointer m_canvas; QHash m_activeAsyncJobs; + QString m_outputJobId; + QString m_outputRevision; + QString m_outputDestination; + bool m_outputCancelled = false; + pdfinteraction::CommandInvocationId m_outputInvocation = pdfinteraction::InvalidCommandInvocation; + std::shared_ptr m_outputResult; + QVariantMap m_outputRecord; + QByteArray m_publishedOutputBytes; + QJsonObject m_publicationReceipt; + QString m_publicationRevision; struct PreflightWorkerOutcome; QHash> m_preflightOutcomes; QHash> m_actionListOutcomes; diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b815..641507d20 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -144,6 +144,113 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + Dialog { + id: outputDialog + objectName: "documentOutputDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: printing ? qsTr("Print to PDF") : qsTr("Export document") + standardButtons: Dialog.Ok | Dialog.Cancel + property bool printing: false + property string revision: "" + Accessible.name: title + contentItem: ColumnLayout { + Label { + text: host ? host.previewFidelitySummary : "" + wrapMode: Text.WordWrap + Layout.preferredWidth: 440 + } + Label { + text: outputDialog.printing ? qsTr("Print every page to a PDF file using the authoritative renderer.") : + qsTr("PDF copies the exact document bytes. PNG and TIFF export the current page.") + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + ComboBox { + id: outputFormat + visible: !outputDialog.printing + model: [qsTr("PDF"), qsTr("PNG"), qsTr("TIFF")] + Accessible.name: qsTr("Export format") + Layout.fillWidth: true + } + SpinBox { + id: outputResolution + from: 72 + to: 600 + value: 300 + editable: true + visible: outputDialog.printing || outputFormat.currentIndex !== 0 + Accessible.name: qsTr("Resolution in dots per inch") + } + CheckBox { + id: outputAcknowledgment + text: qsTr("I acknowledge fidelity warnings. This output is not print proof.") + Accessible.name: text + Layout.fillWidth: true + } + } + onOpened: printing ? outputResolution.forceActiveFocus() : outputFormat.forceActiveFocus() + onAccepted: outputFileDialog.open() + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FileDialog { + id: outputFileDialog + title: outputDialog.title + fileMode: FileDialog.SaveFile + readonly property string format: outputDialog.printing ? "print-pdf" : ["pdf", "png", "tiff"][outputFormat.currentIndex] + nameFilters: format === "png" ? [qsTr("PNG files (*.png)")] : + format === "tiff" ? [qsTr("TIFF files (*.tiff *.tif)")] : [qsTr("PDF files (*.pdf)")] + onAccepted: { + if (host) host.exportDocumentFileUrl(selectedFile, format, outputResolution.value, outputDialog.revision, outputAcknowledgment.checked) + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + Connections { + target: host + function onDocumentOutputRequested(printing) { + if (host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + outputDialog.printing = printing + outputDialog.revision = host.outputRevision() + outputAcknowledgment.checked = false + outputResolution.value = printing && !host.documentModel.canHighResolutionPrint ? 150 : 300 + outputDialog.open() + } + function onDocumentOutputFinished() { outputResultDialog.open() } + } + + Dialog { + id: outputResultDialog + objectName: "documentOutputResultDialog" + parent: Overlay.overlay + anchors.centerIn: parent + title: qsTr("Output result") + standardButtons: Dialog.Close + Accessible.name: title + contentItem: ColumnLayout { + Label { + Layout.preferredWidth: 500 + wrapMode: Text.WrapAnywhere + text: { + const record = host ? host.outputRecord : ({}) + if (record.error) return record.error + const artifact = record.artifact || ({}) + const receipt = record.publicationReceipt || ({}) + return qsTr("Saved artifact: %1\nRevision: %2\n%3\n%4\nThis output is not print proof.") + .arg(artifact.sha256 || "").arg(record.revision || "") + .arg(record.renderer || "") + .arg(receipt.plan_digest ? qsTr("Correction receipt plan: %1").arg(receipt.plan_digest) : + record.dpi ? qsTr("%1 dpi — %2").arg(record.dpi).arg(record.colorHandling) : qsTr("Original source bytes")) + } + Accessible.name: text + } + } + onClosed: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: saveAsDialog title: qsTr("Save PDF As") diff --git a/LoopEditor/qml/MenuModel.qml b/LoopEditor/qml/MenuModel.qml index 34069d28d..90456a6c2 100644 --- a/LoopEditor/qml/MenuModel.qml +++ b/LoopEditor/qml/MenuModel.qml @@ -78,6 +78,8 @@ QtObject { } function labelForEntry(entry) { + if (entry && entry.id === "actionPrint") return qsTr("Print to PDF…") + if (entry && entry.id === "actionExportDocument") return qsTr("Export document…") if (!entry || !entry.labelKey) { return entry && entry.id ? entry.id : "" } diff --git a/LoopEditor/quickoutput.cpp b/LoopEditor/quickoutput.cpp new file mode 100644 index 000000000..0e0a0e789 --- /dev/null +++ b/LoopEditor/quickoutput.cpp @@ -0,0 +1,205 @@ +// MIT License +#include "quickoutput.h" +#include "pdfartifactidentity.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsession.h" +#include "pdfoptionalcontent.h" +#include "pdfpage.h" +#include "pdftransparencyrenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace loopeditor +{ +void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job) +{ + if (!request.document || request.revision.isEmpty() || request.dpi < 72 || request.dpi > 600) + { + result.error = QStringLiteral("output/invalid-request"); + return; + } + result.staging = std::make_shared(); + result.stagedPath = result.staging->filePath(QStringLiteral("output")); + if (!result.staging->isValid()) + { + result.error = QStringLiteral("output/staging-unavailable"); + return; + } + const auto hash = [](const QByteArray& bytes) + { return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); }; + result.record = QJsonObject{ + { QStringLiteral("revision"), request.revision }, + { QStringLiteral("printProof"), false }, + { QStringLiteral("fidelityAcknowledged"), request.fidelityAcknowledged } + }; + if (job.isCancellationRequested()) + return; + + if (request.format == OutputFormat::PublishedPdf) + { + QByteArray bytes = request.publishedBytes; + if (bytes.isEmpty()) + { + QFile source(request.sourcePath); + if (!source.open(QIODevice::ReadOnly)) + { + result.error = QStringLiteral("output/source-unavailable"); + return; + } + bytes = source.readAll(); + if (source.error() != QFile::NoError || hash(bytes) != QString::fromLatin1(request.document->getSourceDataHash().toHex())) + { + result.error = QStringLiteral("output/source-changed"); + return; + } + } + else if (request.publicationReceipt.value(QStringLiteral("status")).toString() != QLatin1String("published") || + request.publicationReceipt.value(QStringLiteral("published_sha256")).toString() != hash(bytes)) + { + result.error = QStringLiteral("output/publication-identity-mismatch"); + return; + } + QFile output(result.stagedPath); + if (!output.open(QIODevice::WriteOnly) || output.write(bytes) != bytes.size() || !output.flush()) + { + result.error = QStringLiteral("output/staging-write-failed"); + return; + } + result.record.insert(QStringLiteral("publicationReceipt"), request.publicationReceipt); + result.record.insert(QStringLiteral("renderer"), QStringLiteral("none: exact PDF byte copy")); + } + else + { + const auto* catalog = request.document->getCatalog(); + const bool printing = request.format == OutputFormat::PrintPdf; + const int first = printing ? 0 : request.page; + const int last = printing ? int(catalog->getPageCount()) - 1 : request.page; + if (first < 0 || last >= int(catalog->getPageCount()) || first > last) + { + result.error = QStringLiteral("output/invalid-page"); + return; + } + pdf::PDFDocumentContext context(request.document); + auto* session = context.getSession(); + pdf::PDFOptionalContentActivity optionalContent(request.document.data(), printing ? pdf::OCUsage::Print : pdf::OCUsage::Export, nullptr); + pdf::PDFInkMapper inks(nullptr, request.document.data()); + inks.createSpotColors(true); + pdf::PDFTransparencyRendererSettings settings; + settings.renderPolicy = pdf::PDFRenderPolicy::forOutputPreview(); + settings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + settings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SmoothImageTransformation, true); + pdf::PDFRenderDiagnostics diagnostics; + std::unique_ptr writer; + QPainter painter; + if (printing) + { + writer = std::make_unique(result.stagedPath); + writer->setResolution(request.dpi); + writer->setTitle(QStringLiteral("Loop print-to-PDF")); + writer->setCreator(QStringLiteral("Loop / PDFTransparencyRenderer")); + } + for (int index = first; index <= last; ++index) + { + if (job.isCancellationRequested()) + return; + const auto* page = catalog->getPage(size_t(index)); + if (!page->getAnnotations().empty()) + { + diagnostics.record(pdf::PDFRenderFidelity::Unsupported, QStringLiteral("Annotation appearances are not included in this page-content output.")); + } + const QSizeF points = page->getRotatedMediaBox().size(); + if (!points.isValid() || !std::isfinite(points.width()) || !std::isfinite(points.height()) || + points.width() * request.dpi / 72.0 > 20000 || points.height() * request.dpi / 72.0 > 20000) + { + result.error = QStringLiteral("output/invalid-page-size"); + return; + } + const QSize pixels(qCeil(points.width() * request.dpi / 72.0), qCeil(points.height() * request.dpi / 72.0)); + job.processingBudget().chargeRenderPixels(uint64_t(pixels.width()) * uint64_t(pixels.height()), QStringLiteral("Quick output")); + const QTransform matrix = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, QRectF(QPointF(), QSizeF(pixels))); + pdf::PDFTransparencyRenderer renderer(page, request.document.data(), session->getFontCache(), session->getCMS(), + &optionalContent, &inks, settings, matrix); + renderer.setOperationControl(job.operationControl()); + renderer.beginPaint(pixels); + renderer.processContents(); + renderer.endPaint(); + diagnostics.merge(renderer.getRenderDiagnostics()); + if (!diagnostics.isExact() && !request.fidelityAcknowledged) + { + result.error = QStringLiteral("output/fidelity-acknowledgment-required"); + return; + } + QImage image = renderer.toImage(false, true, pdf::PDFRGB{ 1.0f, 1.0f, 1.0f }); + if (image.isNull()) + { + result.error = QStringLiteral("output/render-failed"); + return; + } + image.setDotsPerMeterX(qRound(request.dpi / 0.0254)); + image.setDotsPerMeterY(qRound(request.dpi / 0.0254)); + if (printing) + { + writer->setPageSize(QPageSize(points, QPageSize::Point)); + writer->setPageMargins(QMarginsF(), QPageLayout::Point); + if ((index == first && !painter.begin(writer.get())) || (index != first && !writer->newPage())) + { + result.error = QStringLiteral("output/pdf-write-failed"); + return; + } + painter.drawImage(QRect(0, 0, writer->width(), writer->height()), image); + } + else + { + QImageWriter imageWriter(result.stagedPath, request.format == OutputFormat::Png ? QByteArray("png") : QByteArray("tiff")); + imageWriter.setText(QStringLiteral("Renderer"), QStringLiteral("Loop Core PDFTransparencyRenderer")); + imageWriter.setText(QStringLiteral("Color handling"), QStringLiteral("Core CMS; RGB composite; spot separation simulation; white paper")); + if (!imageWriter.write(image)) + { + result.error = QStringLiteral("output/image-encoder-unavailable-or-failed"); + return; + } + } + job.reportProgress((index - first + 1) * 90 / (last - first + 1)); + } + if (printing && !painter.end()) + { + result.error = QStringLiteral("output/pdf-write-failed"); + return; + } + writer.reset(); + result.record.insert(QStringLiteral("renderer"), QStringLiteral("Loop Core PDFTransparencyRenderer")); + result.record.insert(QStringLiteral("dpi"), request.dpi); + result.record.insert(QStringLiteral("firstPage"), first + 1); + result.record.insert(QStringLiteral("lastPage"), last + 1); + result.record.insert(QStringLiteral("colorHandling"), QStringLiteral("Core CMS; RGB composite; spot separation simulation; white paper")); + result.record.insert(QStringLiteral("fidelity"), diagnostics.isExact() ? QStringLiteral("exact-supported") : QStringLiteral("limited")); + result.record.insert(QStringLiteral("limitations"), QJsonArray::fromStringList(diagnostics.reasons)); + } + QFile file(result.stagedPath); + if (!file.open(QIODevice::ReadOnly)) + { + result.error = QStringLiteral("output/staging-read-failed"); + return; + } + QCryptographicHash digest(QCryptographicHash::Sha256); + if (!digest.addData(&file)) + { + result.error = QStringLiteral("output/staging-read-failed"); + return; + } + pdf::PDFArtifactIdentity artifact; + artifact.sha256 = QString::fromLatin1(digest.result().toHex()); + artifact.size = file.size(); + artifact.mediaType = request.format == OutputFormat::Png ? QStringLiteral("image/png") : request.format == OutputFormat::Tiff ? QStringLiteral("image/tiff") + : QStringLiteral("application/pdf"); + result.record.insert(QStringLiteral("artifact"), artifact.toJson()); + job.setOutputArtifact(artifact); +} +} diff --git a/LoopEditor/quickoutput.h b/LoopEditor/quickoutput.h new file mode 100644 index 000000000..225baad1d --- /dev/null +++ b/LoopEditor/quickoutput.h @@ -0,0 +1,44 @@ +// MIT License +#ifndef QUICKOUTPUT_H +#define QUICKOUTPUT_H + +#include "pdfdocument.h" +#include "pdfjobscheduler.h" +#include +#include +#include + +namespace loopeditor +{ +enum class OutputFormat +{ + PublishedPdf, + PrintPdf, + Png, + Tiff +}; + +struct OutputRequest +{ + OutputFormat format = OutputFormat::PublishedPdf; + pdf::PDFDocumentPointer document; + QString sourcePath; + QByteArray publishedBytes; + QJsonObject publicationReceipt; + QString revision; + int page = 0; + int dpi = 300; + bool fidelityAcknowledged = false; +}; + +struct OutputResult +{ + std::shared_ptr staging; + QString stagedPath; + QString error; + QJsonObject record; +}; + +void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job); +} +#endif diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp index 82d971185..f43beed1b 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -277,6 +277,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, mutation.operationControl = context.operationControl(); pdf::PDFGovernedMutationReceipt receipt; const pdf::PDFOperationResult governedResult = pdf::executeGovernedMutation(mutation, &receipt); + if (governedResult && receipt.isPublished()) + { + outcome->publishedBytes = candidateData; + } outcome->executionResult.governed = QJsonObject{ { QStringLiteral("approval"), approval.toJson() }, { QStringLiteral("revalidation"), receipt.revalidation.toJson() }, diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h index eb9618399..844efcb6f 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.h +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -53,6 +53,7 @@ struct ActionListWorkerOutcome QVector validationSteps; pdf::PDFActionListExecutionResult executionResult; pdf::PDFDocumentPointer candidate; + QByteArray publishedBytes; }; using ActionListRunWorker = std::function; diff --git a/UnitTests/phase4-tests.cmake b/UnitTests/phase4-tests.cmake index cb963a85a..3ab2d0945 100644 --- a/UnitTests/phase4-tests.cmake +++ b/UnitTests/phase4-tests.cmake @@ -69,6 +69,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) tst_editorhosttest.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/app.qrc ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -103,6 +105,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsProductOperatorLoop tst_productoperatorloop.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -307,6 +311,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsShellKeyboard tst_shellkeyboardtest.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -334,6 +340,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) ${CMAKE_SOURCE_DIR}/LoopEditor/operatorhelp.h ${CMAKE_SOURCE_DIR}/LoopEditor/app.qrc ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -362,6 +370,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsShellInspectorDispatch tst_shellinspectordispatch.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 3bd15b0ea..f37344165 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,10 @@ #include "pdfblockingthreadguard.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentwriter.h" +#include "pdfdocumentreader.h" +#include "quickoutput.h" +#include +#include #include "pdfsecurityhandler.h" #include "pdfoperationhistorystore.h" #include "pdfsettings.h" @@ -269,6 +274,11 @@ class EditorHostTest : public QObject Q_OBJECT private slots: + void outputBytesAndFences(); + void outputPublishedPacket(); + void outputRenderFormats_data(); + void outputRenderFormats(); + void outputOverprintWarning(); void encryptedOpen_data(); void encryptedOpen(); void passwordFailureIsTypedAndRetryable(); @@ -1840,6 +1850,13 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QVERIFY2(host.fixLifecycleSummary().contains(QStringLiteral("published")), qPrintable(host.fixLifecycleSummary())); + const QString exportPath = directory.filePath(QStringLiteral("accepted-correction.pdf")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(exportPath), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY2(host.outputRecord().value(QStringLiteral("status")).toString() == QLatin1String("saved"), qPrintable(host.outputRecord().value(QStringLiteral("error")).toString())); + QCOMPARE(QString::fromLatin1(fileDigest(exportPath)), publishedSha256); + QCOMPARE(host.outputRecord().value(QStringLiteral("publicationReceipt")).toMap().value(QStringLiteral("published_sha256")).toString(), publishedSha256); + // The as-received input is never the publication target. QCOMPARE(fileDigest(documentPath), sourceDigest); } @@ -2392,6 +2409,176 @@ void EditorHostTest::executeApprovedActionListPlanRefusesAnUnreviewedPlan() QVERIFY(host.executeApprovedActionListPlan()); } +void EditorHostTest::outputBytesAndFences() +{ + QTemporaryDir directory; + const QString source = directory.filePath(QStringLiteral("source.pdf")); + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 72, 144)); + const auto document = builder.build(); + QVERIFY(pdf::PDFDocumentWriter(nullptr).write(source, &document, true)); + QFile input(source); + QVERIFY(input.open(QIODevice::ReadOnly)); + const auto bytes = input.readAll(); + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(source)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + const QString destination = directory.filePath(QStringLiteral("export.pdf")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("pdf"), 300, QStringLiteral("stale"), true)); + QVERIFY(!QFile::exists(destination)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/stale-or-busy-document")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QCOMPARE(host.outputRecord().value(QStringLiteral("status")).toString(), QStringLiteral("saved")); + QFile exported(destination); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); + exported.close(); + QCOMPARE(host.outputRecord().value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString(), + QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex())); + QVERIFY(!host.outputRecord().value(QStringLiteral("printProof")).toBool()); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(source), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/invalid-or-source-destination")); + + const QVariantMap parameters{ { QStringLiteral("path"), destination }, { QStringLiteral("format"), QStringLiteral("print-pdf") }, { QStringLiteral("dpi"), 72 }, { QStringLiteral("revision"), host.outputRevision() }, { QStringLiteral("fidelityAcknowledged"), true } }; + const auto invocation = host.invokeCommand(QStringLiteral("actionPrint"), parameters); + QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); + QVERIFY(host.cancelCommand(invocation)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); + exported.close(); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("print-pdf"), 72, host.outputRevision(), true)); + host.invokeCommand(QStringLiteral("actionClose")); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); +} + +void EditorHostTest::outputPublishedPacket() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 72, 144)); + loopeditor::OutputRequest request; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); + request.revision = QStringLiteral("accepted-revision"); + request.publishedBytes = QByteArray("%PDF-1.7\nexact published packet\n%%EOF"); + const QString digest = QString::fromLatin1(QCryptographicHash::hash(request.publishedBytes, QCryptographicHash::Sha256).toHex()); + request.publicationReceipt = { { QStringLiteral("status"), QStringLiteral("published") }, + { QStringLiteral("published_sha256"), digest }, + { QStringLiteral("plan_digest"), QStringLiteral("reviewed-plan") } }; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::OutputResult result; + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.isEmpty(), qPrintable(result.error)); + QFile exported(result.stagedPath); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), request.publishedBytes); + QCOMPARE(result.record.value(QStringLiteral("publicationReceipt")).toObject(), request.publicationReceipt); + request.publicationReceipt.insert(QStringLiteral("published_sha256"), QString(64, QLatin1Char('0'))); + loopeditor::OutputResult refused; + loopeditor::prepareOutput(request, refused, job); + QCOMPARE(refused.error, QStringLiteral("output/publication-identity-mismatch")); + QVERIFY(!QFile::exists(refused.stagedPath)); +} + +void EditorHostTest::outputRenderFormats_data() +{ + QTest::addColumn("format"); + QTest::newRow("print-to-PDF") << QStringLiteral("print-pdf"); + QTest::newRow("PNG") << QStringLiteral("png"); + QTest::newRow("TIFF") << QStringLiteral("tiff"); +} + +void EditorHostTest::outputRenderFormats() +{ + QFETCH(QString, format); + if (format == QLatin1String("tiff") && !QImageWriter::supportedImageFormats().contains(QByteArray("tiff"))) + QSKIP("Qt TIFF encoder is unavailable; the product refuses TIFF output explicitly."); + QTemporaryDir directory; + const QString source = directory.filePath(QStringLiteral("source.pdf")); + pdf::PDFDocumentBuilder builder; + const auto firstPage = builder.appendPage(QRectF(0, 0, 72, 144)); + builder.appendPage(QRectF(0, 0, 144, 72)); + pdf::PDFPageContentStreamBuilder contents(&builder); + auto* painter = contents.begin(firstPage); + QVERIFY(painter); + painter->fillRect(QRectF(0, 0, 36, 144), Qt::black); + contents.end(painter); + const auto document = builder.build(); + QVERIFY(pdf::PDFDocumentWriter(nullptr).write(source, &document, true)); + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(source)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + const QString destination = directory.filePath(QStringLiteral("output")); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + host.goToPage(0); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), format, 72, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 20000); + const auto record = host.outputRecord(); + QVERIFY2(record.value(QStringLiteral("status")).toString() == QLatin1String("saved"), qPrintable(record.value(QStringLiteral("error")).toString())); + QCOMPARE(record.value(QStringLiteral("dpi")).toInt(), 72); + QCOMPARE(record.value(QStringLiteral("renderer")).toString(), QStringLiteral("Loop Core PDFTransparencyRenderer")); + QVERIFY(!record.value(QStringLiteral("colorHandling")).toString().isEmpty()); + QVERIFY(!record.value(QStringLiteral("printProof")).toBool()); + if (format == QLatin1String("print-pdf")) + { + pdf::PDFDocumentReader reader(nullptr, {}, true, false); + const auto printed = reader.readFromFile(destination); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + QCOMPARE(printed.getCatalog()->getPageCount(), size_t(2)); + QCOMPARE(printed.getCatalog()->getPage(0)->getMediaBox().size(), QSizeF(72, 144)); + QCOMPARE(printed.getCatalog()->getPage(1)->getMediaBox().size(), QSizeF(144, 72)); + loopeditor::OutputRequest request; + request.format = loopeditor::OutputFormat::Png; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(printed)); + request.revision = QStringLiteral("printed-fixture"); + request.dpi = 72; + request.fidelityAcknowledged = true; + loopeditor::OutputResult result; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.isEmpty(), qPrintable(result.error)); + const auto image = QImageReader(result.stagedPath).read(); + QCOMPARE(image.pixelColor(18, 72), QColor(Qt::black)); + QCOMPARE(image.pixelColor(54, 72), QColor(Qt::white)); + } + else + { + QImageReader reader(destination); + const auto image = reader.read(); + QCOMPARE(image.size(), QSize(72, 144)); + QCOMPARE(image.pixelColor(18, 72), QColor(Qt::black)); + QCOMPARE(image.pixelColor(54, 72), QColor(Qt::white)); + QVERIFY(qAbs(image.dotsPerMeterX() - qRound(72 / 0.0254)) <= 1); + } + QFile artifact(destination); + QVERIFY(artifact.open(QIODevice::ReadOnly)); + qInfo().noquote() << "quick-output-fixture" << format << "source_sha256=" + << QString::fromLatin1(host.sessionForTest()->context().getDocumentPointer()->getSourceDataHash().toHex()) + << "artifact_sha256=" << record.value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString(); +} + +void EditorHostTest::outputOverprintWarning() +{ + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(preflightFixturesDir() + QStringLiteral("/overprint-cmyk-mode0-on.pdf"))); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + host.goToPage(0); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_VERIFY_WITH_TIMEOUT(!host.pageFidelityIsExact(), 10000); + QTemporaryDir directory; + const QString destination = directory.filePath(QStringLiteral("output.png")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("png"), 72, host.outputRevision(), false)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/fidelity-acknowledgment-required")); + QVERIFY(!QFile::exists(destination)); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("png"), 72, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 20000); + QCOMPARE(host.outputRecord().value(QStringLiteral("status")).toString(), QStringLiteral("saved")); + QVERIFY(host.outputRecord().value(QStringLiteral("fidelityAcknowledged")).toBool()); + QVERIFY(!host.outputRecord().value(QStringLiteral("printProof")).toBool()); +} + QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" diff --git a/agent-policy.json b/agent-policy.json index 67c60b349..6a9501330 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -218,6 +218,8 @@ "LoopLibQuick/**", "LoopEditor/editorhost.cpp", "LoopEditor/editorhost.h", + "LoopEditor/quickoutput.cpp", + "LoopEditor/quickoutput.h", "LoopEditor/quickdocumentmodel.cpp", "LoopEditor/quickdocumentmodel.h", "UnitTests/tst_quickdocumentmodeltest.cpp", diff --git a/changes/feat-issue-123-quick-print-export.evidence.yaml b/changes/feat-issue-123-quick-print-export.evidence.yaml new file mode 100644 index 000000000..fcfefd87e --- /dev/null +++ b/changes/feat-issue-123-quick-print-export.evidence.yaml @@ -0,0 +1,18 @@ +format_version: 1 +kind: evidence +claims: + - id: quick-print-to-pdf-and-export + evidence: + - unit:agent-policy:quick + - unit:agent-policy:interaction + - unit:agent-policy:build_policy + - unit:agent-policy:schemas_contracts + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py + - unit:UnitTests/tst_editorhosttest.cpp + - integration:tools/ProductQuickAccessibilitySmoke/main.cpp + - architecture:docs/QUICK_PRINT_EXPORT.md +unresolved: + - clean-machine-windows-print-to-file + - clean-machine-linux-print-to-file diff --git a/changes/feat-issue-123-quick-print-export.md b/changes/feat-issue-123-quick-print-export.md new file mode 100644 index 000000000..36f4dee99 --- /dev/null +++ b/changes/feat-issue-123-quick-print-export.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators +Breaking-Change: no +Summary: Add authoritative print-to-PDF and PDF/PNG/TIFF export from Quick, with revision fencing, fidelity acknowledgment, artifact identity, and the correction publication receipt. diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index c575b3553..67135a3d6 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -43,7 +43,7 @@ workspace rail (7 IDs) | workspace stack | Document: pages rail | PDF canvas | c status: document state · production state · preflight state · page/zoom ``` -Manifest-driven menus group the 108 catalog actions by shell menu and +Manifest-driven menus group the 109 catalog actions by shell menu and workspace `target`, with `ADVANCED` actions gated behind the release-profile developer diagnostics flag. Compare is enabled: it presents the before/after artifact identities, technical finding delta, preserved attributes and diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md index e227e92f2..1d302f688 100644 --- a/docs/QUICK_PDF_PARITY.md +++ b/docs/QUICK_PDF_PARITY.md @@ -29,10 +29,14 @@ slice is intentionally model-driven: The following remain deliberately declared or policy-excluded until their typed bridge and revision-fenced tests land: annotation/form overlays and -editing, attachments and metadata editing, print/export, undo/redo, encryption +editing, attachments and metadata editing, native printer spooling, undo/redo, encryption creation or changes, sanitization, optimization, signature verification, OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. +Print-to-PDF and PDF/PNG/TIFF exports use the revision-fenced Core output bridge +described in [Quick print and export](QUICK_PRINT_EXPORT.md). Corrected PDF +export copies the exact publication bytes and retains their Core receipt. + Search currently runs through the Core model on the host thread. It is a functional read-only bridge, but its next hardening step is to submit the same snapshot computation through `PDFJobScheduler` and admit the value on the diff --git a/docs/QUICK_PRINT_EXPORT.md b/docs/QUICK_PRINT_EXPORT.md new file mode 100644 index 000000000..b1cec1fa2 --- /dev/null +++ b/docs/QUICK_PRINT_EXPORT.md @@ -0,0 +1,49 @@ +# Quick print and export + +Issue [#123](https://github.com/studio-berry/loop/issues/123) adds File → Export +document and Document → Print to PDF. The approved scope for this PR is +print-to-PDF and PDF/PNG/TIFF exports. Native printer spooling remains outside +this slice; the product continues to exclude Qt PrintSupport and Widgets. + +PDF export copies bytes without rendering or reserializing. For an accepted +correction, the Editor retains the exact bytes passed through Core's governed +publication gateway, together with its existing mutation receipt. Export checks +the receipt's published digest and binds the packet to the accepted revision. +An unmodified input is copied only if its on-disk digest still matches the +loaded document. An unpublished modified revision is refused. + +Print to PDF renders every page through Core's `PDFTransparencyRenderer` and +`PDFRenderPolicy::forOutputPreview()`, then paints those new renders into a +QtGui PDF writer. PNG and TIFF render the current page through the same +compositor. These operations never read canvas pixels. They use Print or Export +optional-content usage respectively, preserve rotated media-page dimensions, +and offer 72–600 dpi. Restricted low-resolution printing is capped at 150 dpi. +The output is a color-managed RGB composite with separation simulation and +white paper; it is not a separations file. Annotation appearances are an +explicit fidelity limitation and require acknowledgment. + +The dialog shows the current page's fidelity summary. An active warning, or a +limitation discovered during output rendering, requires explicit acknowledgment. +The result records renderer, resolution, color handling, page range, artifact +SHA-256, document revision, and any correction receipt. Output never certifies +print safety. A missing TIFF encoder is an explicit failure. + +The scheduler prepares output in a private temporary directory. The owner +checks request identity, cancellation, lifecycle, and revision before Core's +atomic writer commits the selected destination. The writer verifies the staged +digest while copying. Cancelled, stale, failed, or refused requests preserve +an existing destination, and exporting over the input is refused. + +`UnitTestsEditorHost` covers exact input and publication-byte copying, receipt +identity mismatch, stale requests, cancellation/replacement, output geometry, +raster dimensions and resolution, and overprint acknowledgment. Its existing +governed correction journey also exports the accepted artifact and verifies its +digest against the Core sign-off and mutation receipt. + +Clean-machine print-to-file qualification on Windows and Linux is still a +separate acceptance lane. Record exact source SHA, fixture and artifact identity, +platform/toolchain, and hosted run before claiming that lane complete. + +The final diff review retains Core's existing renderer, safe writer, receipt, +and command catalog. It introduces no renderer, publication authority, stored +receipt schema, dependency, or protected build-contract change. diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index f18322e8b..bdc21bcff 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -2,7 +2,7 @@ "schema_version": 1, "issue": 193, "source_ui": "LoopEditor Quick shell (Widgets pdfeditormainwindow.ui deleted Issue 17)", - "expected_action_count": 108, + "expected_action_count": 109, "actions": [ { "id": "actionAbout", @@ -1041,10 +1041,36 @@ "shortcut": { "standard_key": "Print" }, - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "dpi", + "type": "integer", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + }, + { + "name": "fidelityAcknowledged", + "type": "boolean", + "required": false + } + ], + "capability": "document.read", + "cancellable": true, + "availability": "implemented" }, "menu_group": "File" }, @@ -1541,6 +1567,45 @@ "availability": "implemented" }, "menu_group": "View" + }, + { + "id": "actionExportDocument", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionExportDocument.label", + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "dpi", + "type": "integer", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + }, + { + "name": "fidelityAcknowledged", + "type": "boolean", + "required": false + } + ], + "capability": "document.read", + "cancellable": true, + "availability": "implemented" + }, + "menu_group": "File" } ] } diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index e31cb6144..be0d98a97 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -69,6 +69,8 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionPrint", + "actionExportDocument", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt b/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt index c15ed93d6..37c83118c 100644 --- a/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt +++ b/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt @@ -3,6 +3,8 @@ qt_policy(SET QTP0001 NEW) qt_add_executable(ProductQuickAccessibilitySmoke main.cpp ../../LoopEditor/editorhost.cpp + ../../LoopEditor/quickoutput.cpp + ../../LoopEditor/quickoutput.h ../../LoopEditor/editorhost.h ../../LoopEditor/focusrestoration.cpp ../../LoopEditor/focusrestoration.h diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 25b34b815..641507d20 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -144,6 +144,113 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + Dialog { + id: outputDialog + objectName: "documentOutputDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: printing ? qsTr("Print to PDF") : qsTr("Export document") + standardButtons: Dialog.Ok | Dialog.Cancel + property bool printing: false + property string revision: "" + Accessible.name: title + contentItem: ColumnLayout { + Label { + text: host ? host.previewFidelitySummary : "" + wrapMode: Text.WordWrap + Layout.preferredWidth: 440 + } + Label { + text: outputDialog.printing ? qsTr("Print every page to a PDF file using the authoritative renderer.") : + qsTr("PDF copies the exact document bytes. PNG and TIFF export the current page.") + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + ComboBox { + id: outputFormat + visible: !outputDialog.printing + model: [qsTr("PDF"), qsTr("PNG"), qsTr("TIFF")] + Accessible.name: qsTr("Export format") + Layout.fillWidth: true + } + SpinBox { + id: outputResolution + from: 72 + to: 600 + value: 300 + editable: true + visible: outputDialog.printing || outputFormat.currentIndex !== 0 + Accessible.name: qsTr("Resolution in dots per inch") + } + CheckBox { + id: outputAcknowledgment + text: qsTr("I acknowledge fidelity warnings. This output is not print proof.") + Accessible.name: text + Layout.fillWidth: true + } + } + onOpened: printing ? outputResolution.forceActiveFocus() : outputFormat.forceActiveFocus() + onAccepted: outputFileDialog.open() + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FileDialog { + id: outputFileDialog + title: outputDialog.title + fileMode: FileDialog.SaveFile + readonly property string format: outputDialog.printing ? "print-pdf" : ["pdf", "png", "tiff"][outputFormat.currentIndex] + nameFilters: format === "png" ? [qsTr("PNG files (*.png)")] : + format === "tiff" ? [qsTr("TIFF files (*.tiff *.tif)")] : [qsTr("PDF files (*.pdf)")] + onAccepted: { + if (host) host.exportDocumentFileUrl(selectedFile, format, outputResolution.value, outputDialog.revision, outputAcknowledgment.checked) + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + Connections { + target: host + function onDocumentOutputRequested(printing) { + if (host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + outputDialog.printing = printing + outputDialog.revision = host.outputRevision() + outputAcknowledgment.checked = false + outputResolution.value = printing && !host.documentModel.canHighResolutionPrint ? 150 : 300 + outputDialog.open() + } + function onDocumentOutputFinished() { outputResultDialog.open() } + } + + Dialog { + id: outputResultDialog + objectName: "documentOutputResultDialog" + parent: Overlay.overlay + anchors.centerIn: parent + title: qsTr("Output result") + standardButtons: Dialog.Close + Accessible.name: title + contentItem: ColumnLayout { + Label { + Layout.preferredWidth: 500 + wrapMode: Text.WrapAnywhere + text: { + const record = host ? host.outputRecord : ({}) + if (record.error) return record.error + const artifact = record.artifact || ({}) + const receipt = record.publicationReceipt || ({}) + return qsTr("Saved artifact: %1\nRevision: %2\n%3\n%4\nThis output is not print proof.") + .arg(artifact.sha256 || "").arg(record.revision || "") + .arg(record.renderer || "") + .arg(receipt.plan_digest ? qsTr("Correction receipt plan: %1").arg(receipt.plan_digest) : + record.dpi ? qsTr("%1 dpi — %2").arg(record.dpi).arg(record.colorHandling) : qsTr("Original source bytes")) + } + Accessible.name: text + } + } + onClosed: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: saveAsDialog title: qsTr("Save PDF As") diff --git a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml index 34069d28d..90456a6c2 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml @@ -78,6 +78,8 @@ QtObject { } function labelForEntry(entry) { + if (entry && entry.id === "actionPrint") return qsTr("Print to PDF…") + if (entry && entry.id === "actionExportDocument") return qsTr("Export document…") if (!entry || !entry.labelKey) { return entry && entry.id ? entry.id : "" } From 09bce707b3059065ad4886ee69235e6110e26589 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:35:25 -0700 Subject: [PATCH 05/43] Refuse incomplete renders before output publication --- LoopEditor/quickoutput.cpp | 10 ++++++++++ UnitTests/tst_editorhosttest.cpp | 30 ++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/LoopEditor/quickoutput.cpp b/LoopEditor/quickoutput.cpp index 0e0a0e789..e22ec7601 100644 --- a/LoopEditor/quickoutput.cpp +++ b/LoopEditor/quickoutput.cpp @@ -130,6 +130,16 @@ void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJ renderer.beginPaint(pixels); renderer.processContents(); renderer.endPaint(); + for (const auto& error : renderer.getRenderErrors()) + { + if (error.type == pdf::RenderErrorType::Error) + { + result.error = QStringLiteral("output/render-failed: %1").arg(error.message); + return; + } + if (error.type != pdf::RenderErrorType::Information) + diagnostics.record(pdf::PDFRenderFidelity::Unsupported, error.message); + } diagnostics.merge(renderer.getRenderDiagnostics()); if (!diagnostics.isExact() && !request.fidelityAcknowledged) { diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index f37344165..6f5469966 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -279,6 +279,7 @@ private slots: void outputRenderFormats_data(); void outputRenderFormats(); void outputOverprintWarning(); + void outputRenderFailure(); void encryptedOpen_data(); void encryptedOpen(); void passwordFailureIsTypedAndRetryable(); @@ -1826,10 +1827,6 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QTRY_VERIFY_WITH_TIMEOUT(host.fixLifecycleStateName() != QStringLiteral("executing"), 120000); QCOMPARE(host.fixLifecycleStateName(), QStringLiteral("succeeded")); - // Completion presents the revalidated result and the artifact Core signed, distinct - // from the as-received input and bound to the plan the operator approved. The run - // presents the published bytes; materializing them as a file is the export slice, so - // this asserts the presentation rather than a filesystem write. const QVariantMap recheck = host.fixRecheck(); QVERIFY(recheck.value(QStringLiteral("available")).toBool()); const QString verdictState = recheck.value(QStringLiteral("verdictState")).toString(); @@ -2554,6 +2551,8 @@ void EditorHostTest::outputRenderFormats() } QFile artifact(destination); QVERIFY(artifact.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(artifact.readAll(), QCryptographicHash::Sha256).toHex()), + record.value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString()); qInfo().noquote() << "quick-output-fixture" << format << "source_sha256=" << QString::fromLatin1(host.sessionForTest()->context().getDocumentPointer()->getSourceDataHash().toHex()) << "artifact_sha256=" << record.value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString(); @@ -2579,6 +2578,29 @@ void EditorHostTest::outputOverprintWarning() QVERIFY(!host.outputRecord().value(QStringLiteral("printProof")).toBool()); } +void EditorHostTest::outputRenderFailure() +{ + pdf::PDFDocumentBuilder builder; + const auto page = builder.appendPage(QRectF(0, 0, 72, 144)); + const auto stream = builder.addObject(pdf::PDFObject::createStream( + std::make_shared(pdf::PDFDictionary(), QByteArray("unknownOperator")))); + pdf::PDFDictionary dictionary; + dictionary.addEntry(QByteArray("Contents"), pdf::PDFObject::createReference(stream)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(dictionary)))); + loopeditor::OutputRequest request; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); + request.format = loopeditor::OutputFormat::Png; + request.revision = QStringLiteral("broken-content-fixture"); + request.dpi = 72; + request.fidelityAcknowledged = true; + loopeditor::OutputResult result; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.startsWith(QLatin1String("output/render-failed")), qPrintable(result.error)); + QVERIFY(!QFile::exists(result.stagedPath)); + QVERIFY(!result.record.contains(QStringLiteral("artifact"))); +} + QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" From 10d246fbc3089d465f49bf305bd525426fbcdcf1 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:47:38 -0700 Subject: [PATCH 06/43] Label export resolution and reflect restricted print permissions --- LoopEditor/qml/Main.qml | 6 +++++- docs/QUICK_PRINT_EXPORT.md | 2 +- tools/ProductQuickAccessibilitySmoke/qml/Main.qml | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 641507d20..d86c4b2c5 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -174,10 +174,14 @@ ApplicationWindow { Accessible.name: qsTr("Export format") Layout.fillWidth: true } + Label { + text: qsTr("Resolution (dpi)") + visible: outputResolution.visible + } SpinBox { id: outputResolution from: 72 - to: 600 + to: outputDialog.printing && host && !host.documentModel.canHighResolutionPrint ? 150 : 600 value: 300 editable: true visible: outputDialog.printing || outputFormat.currentIndex !== 0 diff --git a/docs/QUICK_PRINT_EXPORT.md b/docs/QUICK_PRINT_EXPORT.md index b1cec1fa2..4db809d26 100644 --- a/docs/QUICK_PRINT_EXPORT.md +++ b/docs/QUICK_PRINT_EXPORT.md @@ -1,7 +1,7 @@ # Quick print and export Issue [#123](https://github.com/studio-berry/loop/issues/123) adds File → Export -document and Document → Print to PDF. The approved scope for this PR is +document and File → Print to PDF. The approved scope for this PR is print-to-PDF and PDF/PNG/TIFF exports. Native printer spooling remains outside this slice; the product continues to exclude Qt PrintSupport and Widgets. diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 641507d20..d86c4b2c5 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -174,10 +174,14 @@ ApplicationWindow { Accessible.name: qsTr("Export format") Layout.fillWidth: true } + Label { + text: qsTr("Resolution (dpi)") + visible: outputResolution.visible + } SpinBox { id: outputResolution from: 72 - to: 600 + to: outputDialog.printing && host && !host.documentModel.canHighResolutionPrint ? 150 : 600 value: 300 editable: true visible: outputDialog.printing || outputFormat.currentIndex !== 0 From 932e8418392567862eb2fbcbb639b022c66e77ce Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:57:52 -0700 Subject: [PATCH 07/43] Read Core render diagnostics through a private output adapter --- LoopEditor/quickoutput.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/LoopEditor/quickoutput.cpp b/LoopEditor/quickoutput.cpp index e22ec7601..b3526aa46 100644 --- a/LoopEditor/quickoutput.cpp +++ b/LoopEditor/quickoutput.cpp @@ -18,6 +18,16 @@ namespace loopeditor { +namespace +{ +class OutputRenderer final : public pdf::PDFTransparencyRenderer +{ +public: + using pdf::PDFPageContentProcessor::getRenderErrors; + using pdf::PDFTransparencyRenderer::PDFTransparencyRenderer; +}; +} + void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job) { if (!request.document || request.revision.isEmpty() || request.dpi < 72 || request.dpi > 600) @@ -124,8 +134,8 @@ void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJ const QSize pixels(qCeil(points.width() * request.dpi / 72.0), qCeil(points.height() * request.dpi / 72.0)); job.processingBudget().chargeRenderPixels(uint64_t(pixels.width()) * uint64_t(pixels.height()), QStringLiteral("Quick output")); const QTransform matrix = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, QRectF(QPointF(), QSizeF(pixels))); - pdf::PDFTransparencyRenderer renderer(page, request.document.data(), session->getFontCache(), session->getCMS(), - &optionalContent, &inks, settings, matrix); + OutputRenderer renderer(page, request.document.data(), session->getFontCache(), session->getCMS(), + &optionalContent, &inks, settings, matrix); renderer.setOperationControl(job.operationControl()); renderer.beginPaint(pixels); renderer.processContents(); From b9dccb3aa620820b6739542be2066f3d065314dd Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:59:42 -0700 Subject: [PATCH 08/43] Update command catalog expectations and render-error fixture types --- UnitTests/tst_documentfacadetest.cpp | 4 ++-- UnitTests/tst_editorhosttest.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index ed124cc98..82e31628c 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -404,7 +404,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() // library that lost its resource would present an empty command set as a // working one, so this also pins the resource wiring. QVERIFY2(catalog.isLoaded(), qPrintable(catalog.loadError())); - QCOMPARE(catalog.descriptors().size(), 108); + QCOMPARE(catalog.descriptors().size(), 109); const pdfinteraction::CommandDescriptor* open = catalog.descriptor(pdfinteraction::DocumentFacade::OpenCommandId); @@ -436,7 +436,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 26); + QCOMPARE(implemented, 28); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 6f5469966..ab0842b93 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -2585,7 +2585,7 @@ void EditorHostTest::outputRenderFailure() const auto stream = builder.addObject(pdf::PDFObject::createStream( std::make_shared(pdf::PDFDictionary(), QByteArray("unknownOperator")))); pdf::PDFDictionary dictionary; - dictionary.addEntry(QByteArray("Contents"), pdf::PDFObject::createReference(stream)); + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(stream)); builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(dictionary)))); loopeditor::OutputRequest request; request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); From 7d63d17640b6f2e0311d3190a556256afb30b657 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:50:25 -0700 Subject: [PATCH 09/43] Present Quick undo and redo as governed revision history --- LoopEditor/editorhost.cpp | 230 ++++++++++++------ LoopEditor/editorhost.h | 24 +- LoopEditor/qml/ActionListPane.qml | 59 ++++- LoopEditor/qml/Main.qml | 30 +++ .../sources/pdfoperationhistorystore.cpp | 13 +- .../sources/pdfoperationhistorystore.h | 3 +- .../sources/actionlistrunsubmitter.cpp | 125 +++++++++- .../sources/actionlistrunsubmitter.h | 6 +- UnitTests/tst_documentfacadetest.cpp | 2 +- UnitTests/tst_editorhosttest.cpp | 147 ++++++++++- UnitTests/tst_operationhistorytest.cpp | 71 ++++++ ...t-issue-124-governed-history.evidence.yaml | 15 ++ changes/feat-issue-124-governed-history.md | 4 + docs/QUICK_PDF_PARITY.md | 4 +- docs/QUICK_REVISION_HISTORY.md | 42 ++++ docs/loop-shell-actions.json | 38 ++- scripts/verify-command-catalog.py | 2 + .../qml/ActionListPane.qml | 59 ++++- .../qml/Main.qml | 30 +++ 19 files changed, 795 insertions(+), 109 deletions(-) create mode 100644 changes/feat-issue-124-governed-history.evidence.yaml create mode 100644 changes/feat-issue-124-governed-history.md create mode 100644 docs/QUICK_REVISION_HISTORY.md diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index f335a51fe..4fa24de1e 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -638,31 +638,6 @@ QVariantMap fixStateVisualMap(const pdfquick::tokens::LoopStateVisual& visual) return result; } -/// Recorded rollback points for a document's history sidecar, or nothing when there is no -/// history or it cannot be read. Read-only: the shell never rewrites history. -QList rollbackPointsForDocument(const QString& documentPath) -{ - if (documentPath.isEmpty()) - { - return {}; - } - - const QString historyDirectory = - QFileInfo(documentPath).absoluteFilePath() + QStringLiteral(".loop-history"); - const QString historyPath = QDir(historyDirectory).filePath(QStringLiteral("history.sqlite3")); - if (!QFileInfo::exists(historyPath)) - { - return {}; - } - - pdf::PDFOperationHistoryStore history(historyPath); - QString error; - if (!history.open(&error)) - { - return {}; - } - return history.rollbackPoints(&error); -} } // namespace QString EditorHost::fixCurrentPlanDigest() const @@ -1027,8 +1002,11 @@ QVariantMap EditorHost::fixSignOff() const bool EditorHost::fixRollbackAvailable() const { - return m_session->facade().permissions().allowsCorrection() && !m_fixRollbackPoints.isEmpty() && - documentShellStatus() != QLatin1String("MODIFIED"); + return m_historyError.isEmpty() && m_session->facade().permissions().allowsCorrection() && !m_fixRollbackPoints.isEmpty() && + m_rollbackOutcomes.isEmpty() && m_actionListOutcomes.isEmpty() && + m_session->facade().outputState() != pdfinteraction::DocumentOutputState::Pending && + (documentShellStatus() != QLatin1String("MODIFIED") || + m_publishedHistoryRevision == m_session->facade().currentRevision().toString()); } QVariantList EditorHost::fixRollbackPoints() const @@ -1036,13 +1014,45 @@ QVariantList EditorHost::fixRollbackPoints() const return m_fixRollbackPoints; } +QString EditorHost::historyDocumentPath() const +{ + return m_publishedHistoryRevision == m_session->facade().currentRevision().toString() + ? m_publishedHistoryPath + : m_session->facade().source().path; +} + +QString EditorHost::historyArtifactDigest() const +{ + const auto document = m_session->context().getDocumentPointer(); + return document ? QString::fromLatin1(document->getSourceDataHash().toHex()) : QString(); +} + +QString EditorHost::revisionHistorySummary() const +{ + if (!m_historyError.isEmpty()) + return tr("Revision history is unavailable: %1").arg(m_historyError); + return tr("%1 recorded events. Undo and Redo publish forward rollback artifacts; approvals and receipts are retained.") + .arg(m_revisionHistory.size()); +} + +bool EditorHost::historyUndoAvailable() const +{ + return fixRollbackAvailable() && !m_historyUndoId.isEmpty(); +} + +bool EditorHost::historyRedoAvailable() const +{ + return fixRollbackAvailable() && !m_historyRedoId.isEmpty(); +} + QString EditorHost::fixRollbackSummary() const { if (!hasDocument()) { return tr("Open a document to return to a recorded revision."); } - if (documentShellStatus() == QLatin1String("MODIFIED")) + if (documentShellStatus() == QLatin1String("MODIFIED") && + m_publishedHistoryRevision != m_session->facade().currentRevision().toString()) { return tr("Save the document before returning to a recorded revision."); } @@ -2441,9 +2451,9 @@ bool EditorHost::saveActionListRecipe() bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, pdfinteraction::ActionListController::State controllerState) { - if (!m_session->facade().permissions().allowsCorrection()) + if (!m_rollbackOutcomes.isEmpty() || !m_session->facade().permissions().allowsCorrection()) { - announceDocumentState(tr("Document permissions restrict corrections. No plan or output was created.")); + announceDocumentState(tr("A rollback is in progress or document permissions restrict corrections.")); return false; } if (!hasDocument() || m_selectedActionListRecipeId.isEmpty() || !m_session->revisionSource()) @@ -2542,7 +2552,8 @@ bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, outcome, preflightProfilePath, preflightProfile, - preflightProfileBindings)); + preflightProfileBindings, + historyDocumentPath())); if (submittedId != jobId) { m_actionListOutcomes.remove(jobId); @@ -2826,44 +2837,72 @@ void EditorHost::refreshFixRollbackPoints() void EditorHost::refreshFixRollbackPointsFromHistory() { m_fixRollbackPoints.clear(); - + m_revisionHistory.clear(); + m_historyUndoId.clear(); + m_historyRedoId.clear(); + m_historyError.clear(); if (!hasDocument()) + return; + const QString databasePath = QDir(historyDocumentPath() + QStringLiteral(".loop-history")).filePath(QStringLiteral("history.sqlite3")); + if (!QFileInfo::exists(databasePath)) + return; + pdf::PDFOperationHistoryStore history(databasePath); + if (!history.open(&m_historyError)) + return; + const auto verification = history.verify(); + if (!verification.verified) { - m_fixRollbackSummary = fixRollbackSummary(); + m_historyError = verification.errorMessage; return; } - - const QList points = documentRollbackPoints(); - for (const pdf::PDFRollbackPoint& point : points) - { - // Core returns to a revision only when it is the accepted output of a recorded - // execution, so an evicted artifact or the as-received input (kept as a protected - // retention point rather than a selectable revision) is never offered as one. - if (point.artifactEvicted || point.isOriginalInput) - { + const auto points = history.rollbackPoints(&m_historyError); + const auto events = history.events(&m_historyError); + QHash parents; + QStringList redo; + const QString currentDigest = historyArtifactDigest(); + for (const auto& event : events) + { + QVariantMap row = event.toJson().toVariantMap(); + row.insert(QStringLiteral("current"), event.output && event.output->sha256 == currentDigest); + row.insert(QStringLiteral("receiptText"), QString::fromUtf8(QJsonDocument(event.resultSummary.value(QStringLiteral("governed")).toObject()).toJson(QJsonDocument::Compact))); + m_revisionHistory.append(row); + if (!event.output) continue; + const auto execution = history.execution(event.executionId, &m_historyError); + if (!execution) + continue; + const QString source = execution->input.sha256; + const QString target = event.output->sha256; + if (event.status == pdf::PDFOperationHistoryStatus::Accepted && source != target) + { + parents.insert(target, source); + redo.clear(); + } + else if (event.status == pdf::PDFOperationHistoryStatus::RolledBack) + { + if (parents.value(source) == target) + redo.append(source); + else if (!redo.isEmpty() && redo.last() == target) + redo.removeLast(); + else + redo.clear(); } - QVariantMap entry; - entry.insert(QStringLiteral("rollbackId"), point.rollbackId); - entry.insert(QStringLiteral("documentRevisionDigest"), point.documentRevisionDigest); - entry.insert(QStringLiteral("createdUtc"), point.createdAtUtc.toString(Qt::ISODateWithMs)); - entry.insert(QStringLiteral("operationId"), point.operationId); - entry.insert(QStringLiteral("artifactBytes"), point.artifactBytes); - entry.insert(QStringLiteral("isOriginalInput"), point.isOriginalInput); - entry.insert(QStringLiteral("approvedOutput"), point.approvedOutput); - entry.insert(QStringLiteral("planSummary"), point.planSummary.left(160)); - m_fixRollbackPoints.append(entry); } - m_fixRollbackSummary = fixRollbackSummary(); -} - -QList EditorHost::documentRollbackPoints() const -{ - if (!hasDocument() || !m_session->facade().source().isValid()) + for (const auto& point : points) { - return {}; + const auto accepted = std::find_if(events.cbegin(), events.cend(), [&point](const auto& event) + { return event.entryId == point.auditEventId && event.status == pdf::PDFOperationHistoryStatus::Accepted; }); + if (point.artifactEvicted || (!point.isOriginalInput && accepted == events.cend())) + continue; + QVariantMap entry = point.toJson().toVariantMap(); + entry.insert(QStringLiteral("canReturn"), !point.isOriginalInput || point.documentRevisionDigest == parents.value(currentDigest)); + m_fixRollbackPoints.append(entry); + if (point.documentRevisionDigest == parents.value(currentDigest)) + m_historyUndoId = point.rollbackId; + if (!redo.isEmpty() && point.documentRevisionDigest == redo.last()) + m_historyRedoId = point.rollbackId; } - return rollbackPointsForDocument(m_session->facade().source().path); + refreshFeatureAvailability(); } bool EditorHost::requestFixRollback(const QString& rollbackId) @@ -2873,7 +2912,7 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) return false; } - const QString documentPath = m_session->facade().source().path; + const QString documentPath = historyDocumentPath(); if (documentPath.isEmpty()) { return false; @@ -2911,11 +2950,23 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QString currentDigest = QString::fromLatin1(QCryptographicHash::hash(currentBytes, QCryptographicHash::Sha256).toHex()); + if (currentDigest != historyArtifactDigest()) + { + announceDocumentState(tr("The source artifact changed; reopen it before navigating history.")); + return false; + } QUuid targetExecutionId; const QList events = history.events(&error); const auto event = std::find_if(events.cbegin(), events.cend(), - [&point](const pdf::PDFOperationHistoryEvent& candidate) - { return candidate.entryId == point->auditEventId; }); + [&point, &history, ¤tDigest](const pdf::PDFOperationHistoryEvent& candidate) + { + if (!point->isOriginalInput) + return candidate.entryId == point->auditEventId; + const auto execution = history.execution(candidate.executionId); + return execution && candidate.status == pdf::PDFOperationHistoryStatus::Accepted && + candidate.output && candidate.output->sha256 == currentDigest && + execution->input.sha256 == point->documentRevisionDigest; + }); if (event == events.cend()) { announceDocumentState(tr("The recorded revision has no audit event and cannot be returned to.")); @@ -2958,8 +3009,8 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QFileInfo documentInfo(documentPath); const QString destination = documentInfo.absoluteDir().filePath(QStringLiteral("%1-rollback-%2.pdf") - .arg(documentInfo.completeBaseName(), - point->documentRevisionDigest.left(8))); + .arg(documentInfo.completeBaseName().left(32), + QUuid::createUuid().toString(QUuid::WithoutBraces))); // One rollback at a time: a second request while the first is still restoring would // race the same history and publish two revisions from one document state. @@ -2974,7 +3025,6 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) // scheduled exactly like the Action List run. The worker builds its own stores: Qt SQL // connections are thread-affine, so the GUI-thread history opened above must never be // touched off-thread. - const QString historyPath = QDir(historyDirectory).filePath(QStringLiteral("history.sqlite3")); const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); pdf::PDFJobSpec spec; @@ -2995,20 +3045,24 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) const QString submittedId = m_session->scheduler().submit( spec, - [request, historyPath, historyDirectory, destination, outcome](pdf::PDFJobContext& context) + [request, historyDirectory, destination, outcome](pdf::PDFJobContext& context) { if (context.isCancellationRequested()) { return; } - pdf::PDFOperationHistoryStore workerHistory(historyPath); QString openError; + const QString sourcePath = historyDirectory.left(historyDirectory.size() - QStringLiteral(".loop-history").size()); + if (!pdfinteraction::retainHistoryForArtifact(sourcePath, destination, &openError)) + throw std::runtime_error(openError.toStdString()); + const QString outputHistoryDirectory = destination + QStringLiteral(".loop-history"); + pdf::PDFOperationHistoryStore workerHistory(QDir(outputHistoryDirectory).filePath(QStringLiteral("history.sqlite3"))); if (!workerHistory.open(&openError)) { throw std::runtime_error( QStringLiteral("Recorded revisions are unavailable: %1").arg(openError).toStdString()); } - pdf::PDFArtifactStore artifacts(historyDirectory); + pdf::PDFArtifactStore artifacts(outputHistoryDirectory); const pdf::PDFOperationResult rollback = workerHistory.rollbackTo(request, artifacts, destination); if (!rollback) { @@ -3033,7 +3087,8 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) return false; } - // The rollback is scheduled, not complete: finishRollbackJob announces the outcome. + refreshFeatureAvailability(); + bumpPresentation(); return true; } @@ -3598,6 +3653,30 @@ void EditorHost::registerFeatureHandlers() bumpPresentation(); }; m_session->catalog().setHandler(MoveSelectionCommandId, std::move(move)); + for (const QString& command : { QStringLiteral("actionUndo"), QStringLiteral("actionRedo") }) + { + pdfinteraction::CommandCatalog::Handler navigation; + navigation.invoke = [this, command](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString rollbackId = command == QLatin1String("actionUndo") ? m_historyUndoId : m_historyRedoId; + bool accepted = command == QLatin1String("actionUndo") ? historyUndoAvailable() : historyRedoAvailable(); + if (accepted && parameters.isEmpty()) + { + setWorkspace(LoopWorkspace::Fix); + Q_EMIT historyNavigationRequested(command, rollbackId, m_session->facade().currentRevision().toString()); + } + else if (accepted) + { + accepted = parameters.value(QStringLiteral("rollbackId")).toString() == rollbackId && + parameters.value(QStringLiteral("revision")).toString() == m_session->facade().currentRevision().toString() && + requestFixRollback(rollbackId); + } + m_session->catalog().finishInvocation(invocation, accepted ? pdfinteraction::CommandTerminalState::Completed : pdfinteraction::CommandTerminalState::Failed, + accepted ? QString() : QStringLiteral("history/stale-or-unavailable")); + bumpPresentation(); + }; + m_session->catalog().setHandler(command, std::move(navigation)); + } refreshFeatureAvailability(); } @@ -3615,6 +3694,8 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionFindNext"), hasSearchResults); availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); availability.insert(QStringLiteral("actionFullscreenMode"), true); + availability.insert(QStringLiteral("actionUndo"), historyUndoAvailable()); + availability.insert(QStringLiteral("actionRedo"), historyRedoAvailable()); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); m_session->catalog().setEnabledBatch(availability); } @@ -3713,6 +3794,8 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { + m_publishedHistoryPath.clear(); + m_publishedHistoryRevision.clear(); cancelPreflight(); cancelActionList(); if (m_findingNavigator) @@ -3961,6 +4044,11 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) m_actionListController.acceptExecution(snapshot.jobId, snapshot.documentRevision, outcome->executionResult)) { m_session->context().setDocument(outcome->candidate); + m_session->scheduler().setCurrentRevision(m_session->revisionSource()->documentKey(), + m_session->facade().currentRevision().toString()); + m_publishedHistoryPath = outcome->publishedPath; + m_publishedHistoryRevision = outcome->publishedPath.isEmpty() ? QString() + : m_session->facade().currentRevision().toString(); m_preflight.markProfileStale(); syncRevisionModels(); announceDocumentState(tr("Action List applied to the open document.")); @@ -4021,7 +4109,9 @@ void EditorHost::finishRollbackJob(const pdf::PDFJobSnapshot& snapshot) } const QString message = outcome->errorMessage.isEmpty() ? snapshot.errorMessage : outcome->errorMessage; + refreshFeatureAvailability(); announceDocumentState(tr("The document was not rolled back: %1").arg(message)); + bumpPresentation(); } void EditorHost::refreshCanvasTrace() diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 9cef1bf44..f24cfc56c 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -137,6 +137,11 @@ class EditorHost final : public QObject Q_PROPERTY(bool pageFidelityIsExact READ pageFidelityIsExact NOTIFY presentationChanged) Q_PROPERTY(QString pageFidelityReason READ pageFidelityReason NOTIFY presentationChanged) Q_PROPERTY(bool pageFidelityIsAuthoritative READ pageFidelityIsAuthoritative NOTIFY presentationChanged) + Q_PROPERTY(QString governedArtifactPath READ governedArtifactPath NOTIFY presentationChanged) + Q_PROPERTY(QVariantList revisionHistory READ revisionHistory NOTIFY presentationChanged) + Q_PROPERTY(QString revisionHistorySummary READ revisionHistorySummary NOTIFY presentationChanged) + Q_PROPERTY(bool historyUndoAvailable READ historyUndoAvailable NOTIFY presentationChanged) + Q_PROPERTY(bool historyRedoAvailable READ historyRedoAvailable NOTIFY presentationChanged) Q_PROPERTY(bool searchPanelVisible READ searchPanelVisible NOTIFY presentationChanged) Q_PROPERTY(bool fullscreenRequested READ fullscreenRequested NOTIFY presentationChanged) Q_PROPERTY(int workspaceRequest READ workspaceRequest NOTIFY presentationChanged) @@ -297,6 +302,13 @@ class EditorHost final : public QObject /// Recorded rollback points for the open document (read-only). QVariantList fixRollbackPoints() const; + QString governedArtifactPath() const { return historyDocumentPath(); } + Q_INVOKABLE void openGovernedArtifact() { openFileUrl(QUrl::fromLocalFile(historyDocumentPath())); } + QVariantList revisionHistory() const { return m_revisionHistory; } + QString revisionHistorySummary() const; + bool historyUndoAvailable() const; + bool historyRedoAvailable() const; + /// Why the rollback affordance is enabled or unavailable, in the operator's words. QString fixRollbackSummary() const; @@ -473,6 +485,7 @@ class EditorHost final : public QObject const DocumentViewSession* sessionForTest() const noexcept { return m_session.get(); } signals: + void historyNavigationRequested(const QString& commandId, const QString& rollbackId, const QString& revision); void passwordRequestChanged(); /// A completed drag was refused rather than committed (#206). Both values are the /// target's stable names so a surface can explain the refusal. @@ -547,8 +560,9 @@ class EditorHost final : public QObject void clearFixReview(); pdf::PDFActionListExecutionResult fixRunResult() const; QVariantMap fixPreviewFromResult() const; - QList documentRollbackPoints() const; void refreshFixRollbackPointsFromHistory(); + QString historyDocumentPath() const; + QString historyArtifactDigest() const; std::unique_ptr m_session; std::unique_ptr m_findingNavigator; @@ -581,6 +595,13 @@ class EditorHost final : public QObject QString retentionError; }; QHash> m_rollbackOutcomes; + QVariantList m_revisionHistory; + QString m_historyError; + QString m_historyUndoId; + QString m_historyRedoId; + QString m_publishedHistoryPath; + QString m_publishedHistoryRevision; + struct PreflightProfileChoice { QString id; @@ -634,7 +655,6 @@ class EditorHost final : public QObject QString m_fixPlannedDocumentRevision; QString m_fixPlannedPlanDigest; QVariantList m_fixRollbackPoints; - QString m_fixRollbackSummary; }; #endif // EDITORHOST_H diff --git a/LoopEditor/qml/ActionListPane.qml b/LoopEditor/qml/ActionListPane.qml index 051fe1d03..dff4fe9d0 100644 --- a/LoopEditor/qml/ActionListPane.qml +++ b/LoopEditor/qml/ActionListPane.qml @@ -290,7 +290,7 @@ Pane { GroupBox { objectName: "fixRollbackGroup" Layout.fillWidth: true - title: qsTr("Recorded revisions") + title: qsTr("Revision history") Accessible.name: qsTr("Rollback to a recorded revision") ColumnLayout { @@ -305,6 +305,61 @@ Pane { Accessible.name: qsTr("Rollback availability") } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.host ? root.host.revisionHistorySummary : "" + Accessible.name: qsTr("Governed revision history summary") + } + RowLayout { + Button { + objectName: "historyUndoButton" + text: qsTr("Undo") + enabled: root.host && root.host.historyUndoAvailable + onClicked: root.host.invokeCommand("actionUndo") + Accessible.name: qsTr("Undo by publishing a forward rollback") + } + Button { + objectName: "historyRedoButton" + text: qsTr("Redo") + enabled: root.host && root.host.historyRedoAvailable + onClicked: root.host.invokeCommand("actionRedo") + Accessible.name: qsTr("Redo by publishing a forward rollback") + } + Button { + text: qsTr("Open published artifact") + enabled: root.host && root.host.hasDocument + onClicked: root.host.openGovernedArtifact() + Accessible.description: root.host ? root.host.governedArtifactPath : "" + } + } + Repeater { + model: root.host ? root.host.revisionHistory : [] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Event %1 · %2 · revision %3%4 · approval %5 by %6") + .arg(modelData.sequence).arg(modelData.status) + .arg(modelData.output ? modelData.output.sha256 : modelData.documentRevisionDigest) + .arg(modelData.current ? qsTr(" (current)") : "") + .arg(modelData.approval.decision || qsTr("none")) + .arg(modelData.approval.actorId || qsTr("none")) + Accessible.name: text + } + TextArea { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + visible: modelData.receiptText !== "{}" + text: modelData.receiptText + Accessible.name: qsTr("Revision approval and publication receipt") + } + } + } Repeater { model: root.host ? root.host.fixRollbackPoints : [] @@ -325,7 +380,7 @@ Pane { Button { text: qsTr("Return to this revision") - enabled: root.host !== null && root.host.fixRollbackAvailable + enabled: root.host !== null && root.host.fixRollbackAvailable && modelData.canReturn onClicked: { root.pendingRollbackId = modelData.rollbackId rollbackConfirm.open() diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b815..543daa6e1 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -131,6 +131,36 @@ ApplicationWindow { saveAsDialog: saveAsDialog } + Dialog { + id: historyConfirm + objectName: "historyNavigationConfirmDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: commandId === "actionUndo" ? qsTr("Undo published correction") : qsTr("Redo published correction") + standardButtons: Dialog.Ok | Dialog.Cancel + property string commandId: "" + property string rollbackId: "" + property string revision: "" + Label { + width: 360 + wrapMode: Text.Wrap + text: qsTr("Publish a new rollback artifact after revalidation? Existing revisions, approvals and receipts are retained.") + Accessible.name: text + } + onAccepted: if (window.host) window.host.invokeCommand(commandId, {rollbackId: rollbackId, revision: revision}) + } + + Connections { + target: window.host + function onHistoryNavigationRequested(commandId, rollbackId, revision) { + historyConfirm.commandId = commandId + historyConfirm.rollbackId = rollbackId + historyConfirm.revision = revision + historyConfirm.open() + } + } + FileDialog { id: openDialog title: qsTr("Open PDF") diff --git a/LoopLibCore/sources/pdfoperationhistorystore.cpp b/LoopLibCore/sources/pdfoperationhistorystore.cpp index 1150bd741..8248e78f8 100644 --- a/LoopLibCore/sources/pdfoperationhistorystore.cpp +++ b/LoopLibCore/sources/pdfoperationhistorystore.cpp @@ -1138,8 +1138,17 @@ PDFOperationResult PDFOperationHistoryStore::resolveRollbackTarget(const PDFRoll query.prepare(QStringLiteral("SELECT a.sha256, a.size_bytes, a.media_type, a.logical_name, a.storage_token FROM history_events h JOIN artifacts a ON a.sha256 = h.output_sha256 JOIN rollback_points p ON p.audit_event_id = h.entry_id WHERE h.execution_id = ? AND h.status = 'accepted' AND h.output_sha256 = ? AND p.artifact_evicted = 0 ORDER BY h.sequence DESC LIMIT 1")); query.addBindValue(request.targetExecutionId.toString(QUuid::WithoutBraces)); query.addBindValue(request.targetArtifactSha256.toLower()); - if (!query.exec() || !query.next()) - return PDFOperationResult(QStringLiteral("Rollback target is not an accepted immutable artifact.")); + if (!query.exec()) + return PDFOperationResult(queryError(query)); + if (!query.next()) + { + query.prepare(QStringLiteral("SELECT a.sha256, a.size_bytes, a.media_type, a.logical_name, a.storage_token FROM artifacts a JOIN rollback_points p ON p.document_revision_digest = a.sha256 JOIN executions e ON e.source_sha256 = a.sha256 JOIN history_events h ON h.execution_id = e.execution_id WHERE e.execution_id = ? AND h.status = 'accepted' AND h.output_sha256 = ? AND a.sha256 = ? AND p.is_original_input = 1 AND p.artifact_evicted = 0 AND a.artifact_evicted = 0 LIMIT 1")); + query.addBindValue(request.targetExecutionId.toString(QUuid::WithoutBraces)); + query.addBindValue(request.currentArtifactSha256.toLower()); + query.addBindValue(request.targetArtifactSha256.toLower()); + if (!query.exec() || !query.next()) + return PDFOperationResult(QStringLiteral("Rollback target is neither an accepted immutable artifact nor the retained original input of the accepted revision being undone.")); + } targetArtifact->sha256 = query.value(0).toString(); targetArtifact->size = query.value(1).toLongLong(); targetArtifact->mediaType = query.value(2).toString(); diff --git a/LoopLibCore/sources/pdfoperationhistorystore.h b/LoopLibCore/sources/pdfoperationhistorystore.h index 5f4c1e756..b4e96b844 100644 --- a/LoopLibCore/sources/pdfoperationhistorystore.h +++ b/LoopLibCore/sources/pdfoperationhistorystore.h @@ -156,7 +156,8 @@ class LOOPLIBCORESHARED_EXPORT PDFOperationHistoryStore const QString& destinationPath, qint64* sequence = nullptr); - /// Resolves a rollback target only when it is the output of an accepted event. + /// Resolves an accepted output, or its retained original input when the + /// request binds that input to the accepted execution being undone. PDFOperationResult resolveRollbackTarget(const PDFRollbackRequest& request, PDFArtifactIdentity* targetArtifact) const; diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp index 82d971185..10218eec7 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -25,12 +25,18 @@ #include "pdfdocumentwriter.h" #include "pdfgovernedexecution.h" +#include "pdfartifactstore.h" +#include "pdfoperationhistorystore.h" #include "preflightengine.h" #include "preflightprofileresolver.h" #include #include #include +#include +#include +#include +#include #include #include @@ -76,6 +82,66 @@ QVector validationSteps(const pdf::PDFActionList& namespace pdfinteraction { +bool retainHistoryForArtifact(const QString& sourcePath, const QString& destinationPath, QString* error) +{ + const QString sourceRoot = QFileInfo(sourcePath).absoluteFilePath() + QStringLiteral(".loop-history"); + const QString destinationRoot = QFileInfo(destinationPath).absoluteFilePath() + QStringLiteral(".loop-history"); + const QString databasePath = QDir(sourceRoot).filePath(QStringLiteral("history.sqlite3")); + if (!QFileInfo::exists(databasePath)) + return true; + if (QFileInfo::exists(destinationRoot)) + { + *error = QStringLiteral("The new revision history destination already exists."); + return false; + } + pdf::PDFOperationHistoryStore source(databasePath); + if (!source.open(error)) + return false; + const auto verification = source.verify(); + source.close(); + if (!verification.verified) + { + *error = verification.errorMessage; + return false; + } + if (QFileInfo(databasePath + QStringLiteral("-wal")).size() > 0) + { + *error = QStringLiteral("History is in use; retry when its writer has finished."); + return false; + } + QTemporaryDir staging(QFileInfo(destinationPath).absoluteDir().filePath(QStringLiteral(".loop-history-XXXXXX"))); + if (!staging.isValid()) + { + *error = QStringLiteral("Could not stage the retained history."); + return false; + } + QDirIterator files(sourceRoot, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot, QDirIterator::Subdirectories); + while (files.hasNext()) + { + const QString file = files.next(); + const QString relative = QDir(sourceRoot).relativeFilePath(file); + if (relative.endsWith(QStringLiteral("-wal")) || relative.endsWith(QStringLiteral("-shm"))) + continue; + const QString target = QDir(staging.path()).filePath(relative); + if (files.fileInfo().isSymLink() || !QDir().mkpath(QFileInfo(target).absolutePath()) || !QFile::copy(file, target)) + { + *error = QStringLiteral("Could not retain history artifact %1.").arg(relative); + return false; + } + } + pdf::PDFOperationHistoryStore snapshot(QDir(staging.path()).filePath(QStringLiteral("history.sqlite3"))); + if (!snapshot.open(error)) + return false; + const auto copied = snapshot.verify(); + snapshot.close(); + if (!copied.verified || !QDir().rename(staging.path(), destinationRoot)) + { + *error = QStringLiteral("The retained history snapshot could not be verified or published."); + return false; + } + return true; +} + ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, pdf::PDFActionList actionList, pdf::PDFDocumentPointer document, @@ -83,9 +149,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, std::shared_ptr outcome, QString preflightProfilePath, QJsonObject preflightProfile, - QJsonObject preflightProfileBindings) + QJsonObject preflightProfileBindings, + QString publicationSourcePath) { - return [phase, actionList = std::move(actionList), document = std::move(document), bindings = std::move(bindings), outcome = std::move(outcome), preflightProfilePath = std::move(preflightProfilePath), preflightProfile = std::move(preflightProfile), preflightProfileBindings = std::move(preflightProfileBindings)](pdf::PDFJobContext& context) + return [phase, actionList = std::move(actionList), document = std::move(document), bindings = std::move(bindings), outcome = std::move(outcome), preflightProfilePath = std::move(preflightProfilePath), preflightProfile = std::move(preflightProfile), preflightProfileBindings = std::move(preflightProfileBindings), publicationSourcePath = std::move(publicationSourcePath)](pdf::PDFJobContext& context) { if (!outcome || !document) { @@ -177,12 +244,9 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, const QString stagedCandidatePath = stagingDirectory.isValid() ? stagingDirectory.filePath(QStringLiteral("editor-candidate.pdf")) : QString(); QTemporaryDir publicationDirectory; - const QString publicationPath = - publicationDirectory.isValid() ? publicationDirectory.filePath(QStringLiteral("editor-publication.pdf")) : QString(); - // The trusted source path is not known at this layer and the - // publication target is a fresh temporary artifact, so this - // validation cannot refuse today; it keeps the publication - // boundary on the same save contract as the CLI write paths. + const QString publicationPath = publicationSourcePath.isEmpty() + ? publicationDirectory.filePath(QStringLiteral("published.pdf")) + : QFileInfo(publicationSourcePath).absoluteDir().filePath(QStringLiteral("%1-revision-%2.pdf").arg(QFileInfo(publicationSourcePath).completeBaseName().left(32), QUuid::createUuid().toString(QUuid::WithoutBraces))); pdf::PDFSaveRequest publicationRequest; publicationRequest.outputPath = publicationPath; publicationRequest.required = outcome->executionResult.savePolicy; @@ -251,9 +315,6 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, approval.approval.evidenceSha256 = approval.planDigest; approval.approval.decisionReference = QStringLiteral("editor-confirmation:%1").arg(approval.planDigest); approval.approval.decidedUtc = QDateTime::currentDateTimeUtc(); - // The Editor worker has no operation-history store in scope, so - // revocation cannot be resolved here; #37 centralizes execution - // where the chain becomes available. pdf::PDFApprovalAuthorizationContext authorizationContext; authorizationContext.evaluatedUtc = QDateTime::currentDateTimeUtc(); authorizationContext.expectedProfileDigest = expectedProfileDigest; @@ -275,8 +336,50 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, mutation.signOffActor = QStringLiteral("Editor"); mutation.signOffPolicy = QStringLiteral("desktop-postflight"); mutation.operationControl = context.operationControl(); + std::unique_ptr history; + if (!publicationSourcePath.isEmpty()) + { + QFile sourceFile(publicationSourcePath); + if (!sourceFile.open(QIODevice::ReadOnly)) + throw std::runtime_error("The governed source artifact cannot be read."); + const QByteArray sourceBytes = sourceFile.readAll(); + if (QString::fromLatin1(QCryptographicHash::hash(sourceBytes, QCryptographicHash::Sha256).toHex()) != approval.sourceSha256) + throw std::runtime_error("The governed source artifact changed before publication."); + QString historyError; + if (!retainHistoryForArtifact(publicationSourcePath, publicationPath, &historyError)) + throw std::runtime_error(historyError.toStdString()); + const QString historyRoot = publicationPath + QStringLiteral(".loop-history"); + pdf::PDFArtifactStore artifacts(historyRoot); + const auto input = artifacts.importBytes(sourceBytes, { QStringLiteral("application/pdf"), QStringLiteral("source.pdf") }); + const auto output = artifacts.importBytes(candidateData, { QStringLiteral("application/pdf"), QStringLiteral("published.pdf") }); + if (!input.success || !output.success) + throw std::runtime_error((input.success ? output.errorMessage : input.errorMessage).toStdString()); + history = std::make_unique(QDir(historyRoot).filePath(QStringLiteral("history.sqlite3"))); + if (!history->open(&historyError)) + throw std::runtime_error(historyError.toStdString()); + const auto registeredInput = history->rollbackPoints().isEmpty() ? history->registerOriginalInput(input.artifact) + : history->registerArtifact(input.artifact); + if (!registeredInput || !history->registerArtifact(output.artifact)) + throw std::runtime_error("The governed history artifacts could not be registered."); + mutation.history = history.get(); + mutation.authorization.history = history.get(); + mutation.operationId = QStringLiteral("action-list.%1").arg(actionList.id); + mutation.inputArtifact = input.artifact; + mutation.outputArtifact = output.artifact; + mutation.parameters = QJsonObject{ { QStringLiteral("planDigest"), approval.planDigest } }; + mutation.resultSummary = [approval](const pdf::PDFGovernedMutationReceipt& receipt) + { + return QJsonObject{ { QStringLiteral("governed"), QJsonObject{ + { QStringLiteral("approval"), approval.toJson() }, + { QStringLiteral("revalidation"), receipt.revalidation.toJson() }, + { QStringLiteral("sign_off"), receipt.signOff.toJson() }, + { QStringLiteral("receipt"), receipt.toJson() } } } }; + }; + } pdf::PDFGovernedMutationReceipt receipt; const pdf::PDFOperationResult governedResult = pdf::executeGovernedMutation(mutation, &receipt); + if (receipt.isPublished() && !publicationSourcePath.isEmpty()) + outcome->publishedPath = publicationPath; outcome->executionResult.governed = QJsonObject{ { QStringLiteral("approval"), approval.toJson() }, { QStringLiteral("revalidation"), receipt.revalidation.toJson() }, diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h index eb9618399..70d1f9b3e 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.h +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -53,6 +53,7 @@ struct ActionListWorkerOutcome QVector validationSteps; pdf::PDFActionListExecutionResult executionResult; pdf::PDFDocumentPointer candidate; + QString publishedPath; }; using ActionListRunWorker = std::function; @@ -64,7 +65,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, std::shared_ptr outcome, QString preflightProfilePath = {}, QJsonObject preflightProfile = {}, - QJsonObject preflightProfileBindings = {}); + QJsonObject preflightProfileBindings = {}, + QString publicationSourcePath = {}); + +bool retainHistoryForArtifact(const QString& sourcePath, const QString& destinationPath, QString* error); } // namespace pdfinteraction diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index ed124cc98..2e2d9220b 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -436,7 +436,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 26); + QCOMPARE(implemented, 28); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 3bd15b0ea..5de07ac37 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -1840,8 +1840,140 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QVERIFY2(host.fixLifecycleSummary().contains(QStringLiteral("published")), qPrintable(host.fixLifecycleSummary())); - // The as-received input is never the publication target. QCOMPARE(fileDigest(documentPath), sourceDigest); + const QString publishedPath = host.governedArtifactPath(); + QVERIFY(publishedPath != documentPath); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + const QVariantList recorded = host.revisionHistory(); + QVERIFY(!recorded.isEmpty()); + QVERIFY(std::any_of(recorded.cbegin(), recorded.cend(), [&approvedDigest](const QVariant& value) + { return value.toMap().value(QStringLiteral("receiptText")).toString().contains(approvedDigest); })); + const QString beforeRevision = host.sessionForTest()->facade().currentRevision().toString(); + host.invokeCommand(QStringLiteral("actionZoom_In")); + host.setWorkspace(EditorHost::LoopWorkspace::Inspect); + QVERIFY(host.sessionForTest()->interaction()); + host.sessionForTest()->interaction()->selectTarget(makeFindingTarget(QStringLiteral("view-only-selection"), QRectF(0, 0, 20, 20))); + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), beforeRevision); + QCOMPARE(host.revisionHistory(), recorded); + QSignalSpy confirmation(&host, &EditorHost::historyNavigationRequested); + host.invokeCommand(QStringLiteral("actionUndo")); + QCOMPARE(confirmation.count(), 1); + const QList undoIntent = confirmation.takeFirst(); + const QVariantMap undoParameters{ { QStringLiteral("rollbackId"), undoIntent[1] }, + { QStringLiteral("revision"), undoIntent[2] } }; + host.invokeCommand(QStringLiteral("actionUndo"), QVariantMap{ { QStringLiteral("rollbackId"), undoIntent[1] }, + { QStringLiteral("revision"), QStringLiteral("stale") } }); + QCOMPARE(host.governedArtifactPath(), publishedPath); + bool historyJobFinished = false; + QObject::connect(&host.sessionForTest()->scheduler(), &pdf::PDFJobScheduler::jobFinished, &host, + [&historyJobFinished](const pdf::PDFJobSnapshot& snapshot) + { if (snapshot.operationId.startsWith(QStringLiteral("rollback."))) historyJobFinished = true; }); + host.invokeCommand(QStringLiteral("actionUndo"), undoParameters); + QTRY_VERIFY_WITH_TIMEOUT(historyJobFinished, 15000); + QCOMPARE(host.governedArtifactPath(), publishedPath); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QCOMPARE(host.revisionHistory(), recorded); + const QString historyProfilePath = directory.filePath(QStringLiteral("history-profile.json")); + QFile historyProfile(historyProfilePath); + QVERIFY(historyProfile.open(QIODevice::WriteOnly)); + historyProfile.write(QJsonDocument(QJsonObject{ + { QStringLiteral("schema_version"), 1 }, { QStringLiteral("id"), QStringLiteral("history-fixture") }, { QStringLiteral("version"), QStringLiteral("1.0.0") }, { QStringLiteral("name"), QStringLiteral("History fixture") }, { QStringLiteral("checks"), QJsonArray{ QJsonObject{ { QStringLiteral("id"), QStringLiteral("embedded-fonts") }, { QStringLiteral("severity"), QStringLiteral("error") } } } } }) + .toJson()); + historyProfile.close(); + QVERIFY(host.importPreflightProfileFileUrl(QUrl::fromLocalFile(historyProfilePath))); + historyJobFinished = false; + host.invokeCommand(QStringLiteral("actionUndo"), undoParameters); + QTRY_VERIFY_WITH_TIMEOUT(historyJobFinished, 15000); + QTRY_VERIFY_WITH_TIMEOUT(host.sessionForTest()->facade().source().path != documentPath && host.hasDocument(), 15000); + const QString undoPath = host.sessionForTest()->facade().source().path; + QCOMPARE(fileDigest(undoPath), sourceDigest); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + QVERIFY(!host.historyUndoAvailable()); + QVERIFY(host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(host.historyRedoAvailable()); + host.invokeCommand(QStringLiteral("actionRedo")); + QCOMPARE(confirmation.count(), 1); + const QList redoIntent = confirmation.takeFirst(); + host.invokeCommand(QStringLiteral("actionRedo"), QVariantMap{ { QStringLiteral("rollbackId"), redoIntent[1] }, + { QStringLiteral("revision"), redoIntent[2] } }); + QTRY_VERIFY_WITH_TIMEOUT(host.sessionForTest()->facade().source().path != undoPath && host.hasDocument(), 60000); + const QString redoPath = host.sessionForTest()->facade().source().path; + QCOMPARE(QString::fromLatin1(fileDigest(redoPath)), publishedSha256); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(host.historyUndoAvailable()); + QVERIFY(!host.historyRedoAvailable()); + pdf::PDFOperationHistoryStore persisted(redoPath + QStringLiteral(".loop-history/history.sqlite3")); + QVERIFY(persisted.open()); + QVERIFY(persisted.verify().verified); + const auto reopenedEvents = persisted.events(); + QVERIFY(std::any_of(reopenedEvents.cbegin(), reopenedEvents.cend(), [&approvedDigest](const auto& event) + { return event.status == pdf::PDFOperationHistoryStatus::Accepted && + event.resultSummary.value(QStringLiteral("governed")).toObject().value(QStringLiteral("receipt")).toObject().value(QStringLiteral("plan_digest")).toString() == approvedDigest; })); + persisted.close(); + int correctionNumber = 0; + const auto publishAnotherCorrection = [&]() + { + QFile nextRecipe(recipePath); + if (!nextRecipe.open(QIODevice::WriteOnly)) + return false; + nextRecipe.write(QJsonDocument(QJsonObject{ + { QStringLiteral("schema"), QStringLiteral("loop-action-list/2") }, + { QStringLiteral("id"), QStringLiteral("history-next-%1").arg(++correctionNumber) }, + { QStringLiteral("name"), QStringLiteral("Next bleed correction") }, + { QStringLiteral("steps"), QJsonArray{ QJsonObject{ + { QStringLiteral("id"), QStringLiteral("bleed") }, { QStringLiteral("operation"), QStringLiteral("add-bleed") }, { QStringLiteral("params"), QJsonObject{ { QStringLiteral("bleed_mm"), 5 + correctionNumber }, { QStringLiteral("mode"), QStringLiteral("mirror") }, { QStringLiteral("force"), true } } } } } } }) + .toJson()); + nextRecipe.close(); + if (!host.importActionListRecipe(QUrl::fromLocalFile(recipePath)) || !host.validateActionListRecipe() || + !QTest::qWaitFor([&] + { return host.actionList()->property("validationReady").toBool(); }, 15000) || + !host.planActionList() || !QTest::qWaitFor([&] + { return host.fixLifecycleStateName() == QStringLiteral("preview-ready"); }, 15000) || + !host.approveActionListPlan() || !host.executeApprovedActionListPlan()) + return false; + return QTest::qWaitFor([&] + { return host.fixLifecycleStateName() != QStringLiteral("executing"); }, 30000) && + host.fixLifecycleStateName() == QStringLiteral("succeeded"); + }; + const auto navigateHistory = [&](const QString& command, const QByteArray& expected) + { + const QString previousPath = host.sessionForTest()->facade().source().path; + confirmation.clear(); + host.invokeCommand(command); + if (confirmation.count() != 1) + return false; + const auto intent = confirmation.takeFirst(); + host.invokeCommand(command, QVariantMap{ { QStringLiteral("rollbackId"), intent[1] }, { QStringLiteral("revision"), intent[2] } }); + return QTest::qWaitFor([&] + { return host.hasDocument() && host.sessionForTest()->facade().source().path != previousPath; }, 15000) && + fileDigest(host.governedArtifactPath()) == expected; + }; + QVERIFY2(publishAnotherCorrection(), qPrintable(host.fixLifecycleSummary() + QStringLiteral(" ") + host.actionList()->property("operatorSummary").toString())); + const QByteArray secondDigest = fileDigest(host.governedArtifactPath()); + QVERIFY(secondDigest != publishedSha256.toLatin1()); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), publishedSha256.toLatin1())); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), sourceDigest)); + QVERIFY(navigateHistory(QStringLiteral("actionRedo"), publishedSha256.toLatin1())); + QVERIFY(host.historyRedoAvailable()); + host.reopenDocument(); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(navigateHistory(QStringLiteral("actionRedo"), secondDigest)); + QVERIFY(!host.historyRedoAvailable()); + QVERIFY(navigateHistory(QStringLiteral("actionUndo"), publishedSha256.toLatin1())); + QVERIFY(host.historyRedoAvailable()); + QVERIFY2(publishAnotherCorrection(), qPrintable(host.fixLifecycleSummary() + QStringLiteral(" ") + host.actionList()->property("operatorSummary").toString())); + QVERIFY(!host.historyRedoAvailable()); + QCOMPARE(QString::fromLatin1(fileDigest(publishedPath)), publishedSha256); + qInfo().noquote() << "editor-history-fixture" << "source=" << sourceDigest + << "published=" << publishedSha256 << "undo=" << fileDigest(undoPath) + << "redo=" << fileDigest(redoPath) << "events=" << reopenedEvents.size(); } void EditorHostTest::translatePageBoxRefusesAMoveThatIntroducesABlockingFinding() @@ -1954,15 +2086,15 @@ void EditorHostTest::fixRollbackReturnsToARecordedRevision() QVERIFY(host.fixRollbackAvailable()); QVERIFY(!host.fixRollbackSummary().trimmed().isEmpty()); - // Only revisions Core can actually return to are offered: the as-received input stays a - // protected retention point and is never presented as a selectable revision. for (const QVariant& point : points) { - QCOMPARE(point.toMap().value(QStringLiteral("isOriginalInput")).toBool(), false); QVERIFY(!point.toMap().value(QStringLiteral("rollbackId")).toString().isEmpty()); QVERIFY(!point.toMap().value(QStringLiteral("documentRevisionDigest")).toString().isEmpty()); } - const QString rollbackId = points.first().toMap().value(QStringLiteral("rollbackId")).toString(); + const auto acceptedPoint = std::find_if(points.cbegin(), points.cend(), [](const QVariant& point) + { return !point.toMap().value(QStringLiteral("isOriginalInput")).toBool(); }); + QVERIFY(acceptedPoint != points.cend()); + const QString rollbackId = acceptedPoint->toMap().value(QStringLiteral("rollbackId")).toString(); // An unknown revision is refused before anything is written, and the refusal writes no // revision. (A rollback without a validated profile is refused the same way; Core's @@ -2009,9 +2141,8 @@ void EditorHostTest::fixRollbackReturnsToARecordedRevision() QVERIFY(QFile::exists(sourcePath)); // Core recorded the rollback as a new event and left the existing history intact. - const QString historyPath = - QDir(QFileInfo(repairedPath).absoluteFilePath() + QStringLiteral(".loop-history")) - .filePath(QStringLiteral("history.sqlite3")); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument() && host.sessionForTest()->facade().source().path != repairedPath, 15000); + const QString historyPath = host.sessionForTest()->facade().source().path + QStringLiteral(".loop-history/history.sqlite3"); pdf::PDFOperationHistoryStore history(historyPath); QString historyError; QVERIFY2(history.open(&historyError), qPrintable(historyError)); diff --git a/UnitTests/tst_operationhistorytest.cpp b/UnitTests/tst_operationhistorytest.cpp index 30cef0032..c59b85162 100644 --- a/UnitTests/tst_operationhistorytest.cpp +++ b/UnitTests/tst_operationhistorytest.cpp @@ -104,6 +104,7 @@ private slots: void rollbackPointsRetentionAndAtomicity(); void rollbackRevalidatesAndRecordsGovernedReceipt(); void rollbackRefusesWithoutProfileOrOnCompromisedChain(); + void rollbackToOriginalRequiresTheAcceptedExecutionBeingUndone(); void externalPayloadTamperingCompromisesChain(); void provenanceKindsRoundTripAndMiddleDeletionCompromisesChain(); void schemaVersionPersistsAcrossReopen(); @@ -1583,5 +1584,75 @@ void OperationHistoryTest::unreadableOrCompromisedApprovalHistoryRefusesPublicat QVERIFY(!QFile::exists(outputPath)); } +void OperationHistoryTest::rollbackToOriginalRequiresTheAcceptedExecutionBeingUndone() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + pdf::PDFArtifactStore artifacts(directory.path()); + const QByteArray originalBytes = minimalPdfBytes(200, 200); + const QByteArray correctedBytes = minimalPdfBytes(220, 220); + const auto original = artifacts.importBytes(originalBytes, { QStringLiteral("application/pdf"), QStringLiteral("original.pdf") }); + const auto corrected = artifacts.importBytes(correctedBytes, { QStringLiteral("application/pdf"), QStringLiteral("corrected.pdf") }); + QVERIFY(original.success && corrected.success); + const QString databasePath = directory.filePath(QStringLiteral("history.sqlite3")); + pdf::PDFOperationHistoryStore history(databasePath); + QVERIFY(history.open()); + QVERIFY(history.registerOriginalInput(original.artifact)); + QVERIFY(history.registerArtifact(corrected.artifact)); + pdf::PDFOperationHistoryExecution execution; + execution.input = original.artifact; + execution.operationId = QStringLiteral("test.correction"); + QUuid executionId; + QVERIFY(history.beginExecution(execution, &executionId)); + pdf::PDFOperationHistoryEvent accepted; + accepted.executionId = executionId; + accepted.status = pdf::PDFOperationHistoryStatus::Accepted; + accepted.output = corrected.artifact; + accepted.approval.kind = pdf::PDFApprovalKind::Human; + accepted.approval.actorId = QStringLiteral("original-approver"); + accepted.approval.decision = QStringLiteral("approve"); + accepted.approval.decidedUtc = QDateTime::currentDateTimeUtc(); + accepted.resultSummary = QJsonObject{ { QStringLiteral("receipt"), QStringLiteral("retained-original-receipt") } }; + QVERIFY(history.appendEvent(accepted)); + const auto before = history.events(); + pdf::PDFRollbackRequest request; + request.currentArtifactSha256 = corrected.artifact.sha256; + request.targetArtifactSha256 = original.artifact.sha256; + request.targetExecutionId = QUuid::createUuid(); + approveRollback(request); + const QString destination = directory.filePath(QStringLiteral("undo.pdf")); + QVERIFY(!history.rollbackTo(request, artifacts, destination)); + QVERIFY(!QFileInfo::exists(destination)); + request.targetExecutionId = executionId; + request.currentArtifactSha256 = original.artifact.sha256; + QVERIFY(!history.rollbackTo(request, artifacts, destination)); + request.currentArtifactSha256 = corrected.artifact.sha256; + QVERIFY(history.rollbackTo(request, artifacts, destination)); + QFile output(destination); + QVERIFY(output.open(QIODevice::ReadOnly)); + QCOMPARE(output.readAll(), originalBytes); + const auto after = history.events(); + QCOMPARE(after.first().toJson(), before.first().toJson()); + QCOMPARE(after.last().status, pdf::PDFOperationHistoryStatus::RolledBack); + QVERIFY(artifacts.verify(original.artifact)); + QVERIFY(artifacts.verify(corrected.artifact)); + qInfo().noquote() << "history-original-fixture" << "source=" << corrected.artifact.sha256 + << "rollback=" << original.artifact.sha256 << "accepted-event=" << before.first().entryId + << "rollback-event=" << after.last().entryId; + history.close(); + pdf::PDFOperationHistoryStore reopened(databasePath); + QVERIFY(reopened.open()); + QVERIFY(reopened.verify().verified); + QCOMPARE(reopened.events().first().toJson(), before.first().toJson()); + QFile corrupt(artifacts.pathFor(original.artifact)); + QVERIFY(QFile::setPermissions(corrupt.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + QVERIFY(corrupt.open(QIODevice::Append)); + corrupt.write("tampered"); + corrupt.close(); + QVERIFY(!reopened.rollbackTo(request, artifacts, directory.filePath(QStringLiteral("refused.pdf")))); + QVERIFY(!QFileInfo::exists(directory.filePath(QStringLiteral("refused.pdf")))); + QCOMPARE(reopened.events().first().toJson(), before.first().toJson()); +} + QTEST_MAIN(OperationHistoryTest) #include "tst_operationhistorytest.moc" \ No newline at end of file diff --git a/changes/feat-issue-124-governed-history.evidence.yaml b/changes/feat-issue-124-governed-history.evidence.yaml new file mode 100644 index 000000000..fffbd38f7 --- /dev/null +++ b/changes/feat-issue-124-governed-history.evidence.yaml @@ -0,0 +1,15 @@ +format_version: 1 +kind: evidence +claims: + - id: governed-revision-history + evidence: + - unit:agent-policy:core + - unit:agent-policy:interaction + - unit:agent-policy:quick + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - unit:UnitTests/tst_operationhistorytest.cpp + - unit:UnitTests/tst_editorhosttest.cpp + - integration:tools/ProductQuickAccessibilitySmoke/main.cpp + - architecture:docs/QUICK_REVISION_HISTORY.md +unresolved: [] diff --git a/changes/feat-issue-124-governed-history.md b/changes/feat-issue-124-governed-history.md new file mode 100644 index 000000000..541ec5b50 --- /dev/null +++ b/changes/feat-issue-124-governed-history.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators +Breaking-Change: no +Summary: Present Quick Undo and Redo as forward rollback artifacts with retained revision identities, approvals, and receipts. diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md index e227e92f2..6ca9f1c33 100644 --- a/docs/QUICK_PDF_PARITY.md +++ b/docs/QUICK_PDF_PARITY.md @@ -29,7 +29,7 @@ slice is intentionally model-driven: The following remain deliberately declared or policy-excluded until their typed bridge and revision-fenced tests land: annotation/form overlays and -editing, attachments and metadata editing, print/export, undo/redo, encryption +editing, attachments and metadata editing, print/export, encryption creation or changes, sanitization, optimization, signature verification, OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. @@ -37,3 +37,5 @@ Search currently runs through the Core model on the host thread. It is a functional read-only bridge, but its next hardening step is to submit the same snapshot computation through `PDFJobScheduler` and admit the value on the owner thread, matching the renderer and preflight paths. + +Quick Undo and Redo follow [governed revision history](QUICK_REVISION_HISTORY.md). diff --git a/docs/QUICK_REVISION_HISTORY.md b/docs/QUICK_REVISION_HISTORY.md new file mode 100644 index 000000000..d267c4d77 --- /dev/null +++ b/docs/QUICK_REVISION_HISTORY.md @@ -0,0 +1,42 @@ +# Quick governed revision history + +Issue [#124](https://github.com/studio-berry/loop/issues/124) implements Edit → Undo +and Redo as forward rollback through Core. Each confirmation is bound to the +shown document revision and target identity. Viewport, workspace, and selection +changes never enter the document history. + +The Fix workspace shows canonical event identities, artifact digests, approval +actors and decisions, and governed publication receipt/sign-off details. It +checks Core's history chain before enabling navigation. Undo of an accepted +correction targets that execution's input. Redo follows recorded rollback +transitions; a new correction clears the redo branch. These choices are rebuilt +from the retained chain after reopen, rather than from a transient byte stack. + +Editor corrections now retain their exact published artifact as a unique sibling +file and append Core's execution and receipt to its existing `.loop-history` +sidecar format. A subsequent publication or rollback carries a verified snapshot +of the preceding chain and immutable artifact store. The source file and its +approved events stay intact. Open published artifact opens the retained output; +ordinary Save remains the document facade's existing save behavior. + +The human-approved Core contract extension permits a retained original input as +a rollback target only when its digest is the input of the accepted execution +being undone and the request's current digest identifies that accepted output. +It does not synthesize an acceptance event for the original input. Existing +request types and persisted schemas are unchanged. Core still verifies target +bytes, requires approval, revalidates, and appends a new rolled-back receipt. + +History snapshots require the previous SQLite writer to have closed/checkpointed +and refuse a live WAL. They copy existing metadata and retained artifacts, +verify the resulting chain, and publish the sidecar before the new artifact. +A failed or cancelled request can retain a failed-history sidecar; it never +removes an approved revision or receipt. A rollback already committed by Core +remains retained when the owner completion is cancelled or stale; it is not opened. + +`UnitTestsOperationHistory` covers first-correction rollback, incorrect execution +and current identities, tampered original bytes, unchanged approved events, and +reopen. `UnitTestsEditorHost` covers publish → Undo → reopen → Redo → reopen, +artifact/receipt identity, stale confirmations, and presentation-only state. +The mapped Core, Interaction, Quick, and product Quick smoke lanes provide the +remaining regression proof. No package or native accessibility qualification +is asserted by the software Quick smoke. diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index f18322e8b..69cdc39f2 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -1083,16 +1083,27 @@ { "id": "actionRedo", "disposition": "KEEP", - "target": "Document", + "target": "Fix", "command": { "label_key": "command.actionRedo.label", "shortcut": { "standard_key": "Redo" }, - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "rollbackId", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, @@ -1497,16 +1508,27 @@ { "id": "actionUndo", "disposition": "KEEP", - "target": "Document", + "target": "Fix", "command": { "label_key": "command.actionUndo.label", "shortcut": { "standard_key": "Undo" }, - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "rollbackId", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index e31cb6144..0cb35a4ff 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -69,6 +69,8 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionUndo", + "actionRedo", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml b/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml index 051fe1d03..dff4fe9d0 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/ActionListPane.qml @@ -290,7 +290,7 @@ Pane { GroupBox { objectName: "fixRollbackGroup" Layout.fillWidth: true - title: qsTr("Recorded revisions") + title: qsTr("Revision history") Accessible.name: qsTr("Rollback to a recorded revision") ColumnLayout { @@ -305,6 +305,61 @@ Pane { Accessible.name: qsTr("Rollback availability") } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.host ? root.host.revisionHistorySummary : "" + Accessible.name: qsTr("Governed revision history summary") + } + RowLayout { + Button { + objectName: "historyUndoButton" + text: qsTr("Undo") + enabled: root.host && root.host.historyUndoAvailable + onClicked: root.host.invokeCommand("actionUndo") + Accessible.name: qsTr("Undo by publishing a forward rollback") + } + Button { + objectName: "historyRedoButton" + text: qsTr("Redo") + enabled: root.host && root.host.historyRedoAvailable + onClicked: root.host.invokeCommand("actionRedo") + Accessible.name: qsTr("Redo by publishing a forward rollback") + } + Button { + text: qsTr("Open published artifact") + enabled: root.host && root.host.hasDocument + onClicked: root.host.openGovernedArtifact() + Accessible.description: root.host ? root.host.governedArtifactPath : "" + } + } + Repeater { + model: root.host ? root.host.revisionHistory : [] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Event %1 · %2 · revision %3%4 · approval %5 by %6") + .arg(modelData.sequence).arg(modelData.status) + .arg(modelData.output ? modelData.output.sha256 : modelData.documentRevisionDigest) + .arg(modelData.current ? qsTr(" (current)") : "") + .arg(modelData.approval.decision || qsTr("none")) + .arg(modelData.approval.actorId || qsTr("none")) + Accessible.name: text + } + TextArea { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + visible: modelData.receiptText !== "{}" + text: modelData.receiptText + Accessible.name: qsTr("Revision approval and publication receipt") + } + } + } Repeater { model: root.host ? root.host.fixRollbackPoints : [] @@ -325,7 +380,7 @@ Pane { Button { text: qsTr("Return to this revision") - enabled: root.host !== null && root.host.fixRollbackAvailable + enabled: root.host !== null && root.host.fixRollbackAvailable && modelData.canReturn onClicked: { root.pendingRollbackId = modelData.rollbackId rollbackConfirm.open() diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 25b34b815..543daa6e1 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -131,6 +131,36 @@ ApplicationWindow { saveAsDialog: saveAsDialog } + Dialog { + id: historyConfirm + objectName: "historyNavigationConfirmDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: commandId === "actionUndo" ? qsTr("Undo published correction") : qsTr("Redo published correction") + standardButtons: Dialog.Ok | Dialog.Cancel + property string commandId: "" + property string rollbackId: "" + property string revision: "" + Label { + width: 360 + wrapMode: Text.Wrap + text: qsTr("Publish a new rollback artifact after revalidation? Existing revisions, approvals and receipts are retained.") + Accessible.name: text + } + onAccepted: if (window.host) window.host.invokeCommand(commandId, {rollbackId: rollbackId, revision: revision}) + } + + Connections { + target: window.host + function onHistoryNavigationRequested(commandId, rollbackId, revision) { + historyConfirm.commandId = commandId + historyConfirm.rollbackId = rollbackId + historyConfirm.revision = revision + historyConfirm.open() + } + } + FileDialog { id: openDialog title: qsTr("Open PDF") From 091de7db83d17eb7894501576cc1fe20b5ea0fe7 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:13:17 -0700 Subject: [PATCH 10/43] feat(commands): fail the catalog check on unbound KEEP/ABSORB handlers (#231) The descriptor availability flag is the handler gate: CommandCatalog refuses a handler for a declared id and invoke() answers command/not-implemented, so a KEEP or ABSORB action that is still declared is a menu entry that does nothing. scripts/verify-command-catalog.py now requires every KEEP/ABSORB action to be implemented or listed in docs/shell-handler-coverage.json, the baseline that records today's 70 gaps and may only shrink. --record-ratchet rewrites it. Negative-case tests prove each rule can fail; ci.yml and check-change.py report the check as whole-tree evidence. --- .github/workflows/ci.yml | 4 + changes/gh-231-quick-coverage.md | 6 ++ docs/LOOP_SHELL_CONTRACT.md | 18 +++- docs/shell-handler-coverage.json | 78 +++++++++++++++++ scripts/agent/check-change.py | 7 ++ scripts/ci/test_command_catalog.py | 70 +++++++++++++++- scripts/verify-command-catalog.py | 129 ++++++++++++++++++++++++++++- 7 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 changes/gh-231-quick-coverage.md create mode 100644 docs/shell-handler-coverage.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 743698a93..4132559b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,10 @@ jobs: run: | python3 scripts/ci/test_correction_operation_catalog.py python3 scripts/generate-architecture-catalogs.py --check + - name: Verify command catalog handler coverage (#231) + run: | + python3 scripts/ci/test_command_catalog.py + python3 scripts/verify-command-catalog.py - name: Verify preflight check catalog and coverage backlog (#165) run: python3 scripts/ci/test_preflight_check_catalog.py - name: Verify preflight corpus coverage map (#165) diff --git a/changes/gh-231-quick-coverage.md b/changes/gh-231-quick-coverage.md new file mode 100644 index 000000000..c54ce14be --- /dev/null +++ b/changes/gh-231-quick-coverage.md @@ -0,0 +1,6 @@ +# Restore Quick GUI coverage: handler-coverage contract gate (epic #231) + +Category: added +Audience: developers +Breaking-Change: no +Summary: `docs/shell-handler-coverage.json` records the KEEP and ABSORB commands the Quick shell has not bound a handler for yet (70 at b08e2183). `scripts/verify-command-catalog.py` now fails when a KEEP or ABSORB action is unbound without a ratchet entry, when a recorded gap stays after its handler lands, or when a baseline entry is not a KEEP or ABSORB command, so the count cannot regress and the list can only shrink; `--record-ratchet` rewrites the baseline from the contract. Negative-case tests in `scripts/ci/test_command_catalog.py` prove each rule can fail, the check runs as its own step in `ci.yml`'s source_integrity job, `scripts/agent/check-change.py` reports the check and its cases as whole-tree evidence, and `docs/LOOP_SHELL_CONTRACT.md` documents the gate. diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index c575b3553..54af8a381 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -197,9 +197,16 @@ so menus, shortcuts, and routing are complete, and invoking it returns the typed `implemented` requires a real capability and a registered handler; invoking an ID that is not in the contract is reported as a routing error rather than ignored. -`scripts/verify-command-catalog.py` checks the block, and checks shortcut parity -against `PDFActionManager::initActions` so the catalog cannot become a second -command truth wearing the first one's ID set. +A KEEP or ABSORB command, though, may not sit `declared` unrecorded: both +dispositions reach the menus, and a menu entry that answers `not-implemented` is +a gap (epic #231). `docs/shell-handler-coverage.json` records each remaining +unbound KEEP or ABSORB command, and the list may only shrink. Binding a handler +removes its entry with the descriptor flipped to `implemented`. ADVANCED +commands are release-profile gated and owe no handler to the release shell. + +`scripts/verify-command-catalog.py` checks the block, the handler-coverage +ratchet, and shortcut parity against `PDFActionManager::initActions` so the +catalog cannot become a second command truth wearing the first one's ID set. The Quick shell policy is self-authoritative; the retired migration form is not a runtime or documentation dependency. @@ -241,6 +248,11 @@ The command descriptors have their own check: python scripts/verify-command-catalog.py ``` +It also enforces the handler-coverage ratchet: every KEEP or ABSORB action is +`implemented` or listed in `docs/shell-handler-coverage.json`, the recorded gaps +have not moved in either direction, and the first run of the check records the +current gap set (`--record-ratchet` rewrites the baseline). + `UnitTestsDocumentFacade` covers the runtime side — catalog loading, invocation and cancellation, and the document lifecycle — with no `QWidget` and no QML engine. diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json new file mode 100644 index 000000000..0ca39c964 --- /dev/null +++ b/docs/shell-handler-coverage.json @@ -0,0 +1,78 @@ +{ + "schema_version": 1, + "issue": 231, + "policy": "docs/loop-shell-actions.json", + "rationale": "KEEP and ABSORB commands the Quick shell has not bound a handler for yet. The descriptor availability flag is the gate: CommandCatalog::setHandler refuses a declared id, so an unbound KEEP or ABSORB action is a menu entry that does nothing. This list may only shrink. Binding a handler removes its id here with the descriptor flipped to implemented; a new KEEP or ABSORB action that arrives unbound fails scripts/verify-command-catalog.py until it is either bound or deliberately recorded here. An empty list is the epic #231 contract gate.", + "unbound": [ + "actionAutomaticDocumentRefresh", + "actionBleedFixup", + "actionBookmarkAutoGenerate", + "actionBookmarkExport", + "actionBookmarkImport", + "actionBookmarkPage", + "actionClearRecentFileHistory", + "actionColorBitonal", + "actionColorCustom", + "actionColorGrayscale", + "actionColorHighContrast", + "actionColorInvert", + "actionCopyText", + "actionCreateBitonalDocument", + "actionCreateEllipse", + "actionCreateFreehandCurve", + "actionCreateHyperlink", + "actionCreateHyperlinkToThisPDFFit", + "actionCreateHyperlinkToThisPDFFitB", + "actionCreateHyperlinkToThisPDFFitBH", + "actionCreateHyperlinkToThisPDFFitBV", + "actionCreateHyperlinkToThisPDFFitH", + "actionCreateHyperlinkToThisPDFFitR", + "actionCreateHyperlinkToThisPDFFitV", + "actionCreateHyperlinkToThisPDFXYZ", + "actionCreateHyperlinkToThisPDFXYZInheritZoom", + "actionCreatePolygon", + "actionCreatePolyline", + "actionCreateRectangle", + "actionCreateStraightLine", + "actionCreateTextHighlight", + "actionCreateTextSquiggly", + "actionCreateTextStrikeout", + "actionCreateTextUnderline", + "actionDeselectText", + "actionEncryption", + "actionExtractImage", + "actionFirstPageOnRightSide", + "actionGoToNextLine", + "actionGoToPreviousLine", + "actionGotoNextBookmark", + "actionGotoPreviousBookmark", + "actionInlineText", + "actionInsertPageNumbers", + "actionOptimize", + "actionOptimizeImages", + "actionPageGeometry", + "actionPrint", + "actionRedo", + "actionRemoveExternalLinks", + "actionRenderOptionAntialiasing", + "actionRenderOptionDisplayAnnotations", + "actionRenderOptionIgnoreOptionalContentSettings", + "actionRenderOptionSmoothPictures", + "actionRenderOptionTextAntialiasing", + "actionRender_to_Images", + "actionSanitize", + "actionScreenshot", + "actionSelectTable", + "actionSelectText", + "actionSelectTextAll", + "actionSend_by_E_Mail", + "actionStickyNoteComment", + "actionStickyNoteHelp", + "actionStickyNoteInsert", + "actionStickyNoteKey", + "actionStickyNoteNewParagraph", + "actionStickyNoteNote", + "actionStickyNoteParagraph", + "actionUndo" + ] +} diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index 4d89571c9..ddf6a46a9 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -416,6 +416,13 @@ def main() -> int: [python, "-m", "unittest", "scripts.ci.test_check_governance_registers", "scripts.ci.test_check_loop_identity", "scripts.github.test_sync_milestones", "-q"], ROOT, args.dry_run) + # The command catalog's handler-coverage ratchet (epic #231): the check is + # whole-tree, so it runs on every change like source_integrity does. + add_result(evidence, "command_catalog", + [python, "scripts/verify-command-catalog.py"], ROOT, args.dry_run) + add_result(evidence, "command_catalog_cases", + [python, "-m", "unittest", "scripts.ci.test_command_catalog", "-q"], + ROOT, args.dry_run) # Whole-tree contract guards: they audit invariants of the tree, not just the # diff, so they run on every change like source_integrity does. add_result( diff --git a/scripts/ci/test_command_catalog.py b/scripts/ci/test_command_catalog.py index 51ec9ecec..13c6fcf62 100644 --- a/scripts/ci/test_command_catalog.py +++ b/scripts/ci/test_command_catalog.py @@ -128,21 +128,35 @@ def __init__( policy: dict | None = None, main_window: str = MAIN_WINDOW, controller: str = CONTROLLER, + ratchet_unbound: list[str] | None = None, ): self.root = Path(stack.enterContext(tempfile.TemporaryDirectory())) self.policy_path = self.root / "loop-shell-actions.json" + self.ratchet_path = self.root / "shell-handler-coverage.json" self.main_window_path = self.root / "pdfeditormainwindow.cpp" self.controller_path = self.root / "pdfprogramcontroller.cpp" - self.write(policy if policy is not None else copy.deepcopy(POLICY)) + effective_policy = policy if policy is not None else copy.deepcopy(POLICY) + self.write(effective_policy) + if ratchet_unbound is None: + ratchet_unbound = verifier.unbound_actions(effective_policy) + self.write_ratchet(ratchet_unbound) self.main_window_path.write_text(main_window, encoding="utf-8") self.controller_path.write_text(controller, encoding="utf-8") def write(self, policy: dict) -> None: self.policy_path.write_text(json.dumps(policy), encoding="utf-8") + def write_ratchet(self, unbound: list[str]) -> None: + self.ratchet_path.write_text( + json.dumps({"schema_version": 1, "unbound": unbound}), encoding="utf-8" + ) + def run(self) -> list[str]: return verifier.validate_catalog( - self.policy_path, self.main_window_path, self.controller_path + self.policy_path, + self.main_window_path, + self.controller_path, + self.ratchet_path, ) @@ -211,6 +225,58 @@ def test_unexpected_implemented_command_is_rejected(self): errors = self.mutate("actionAbout", availability="implemented", capability="none") self.assertTrue(any("without a registered handler" in error for error in errors), errors) + def test_unrecorded_unbound_command_is_rejected(self): + errors = CatalogFixture(self, ratchet_unbound=[]).run() + self.assertTrue( + any( + "actionBleedFixup" in error and "ratchet baseline" in error + for error in errors + ), + errors, + ) + + def test_baseline_entry_with_a_handler_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionBleedFixup", "actionOpen"] + ).run() + self.assertTrue( + any( + "actionOpen" in error and "bound handler" in error + for error in errors + ), + errors, + ) + + def test_baseline_entry_for_unknown_command_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionBleedFixup", "actionGhost"] + ).run() + self.assertTrue(any("actionGhost" in error for error in errors), errors) + + def test_baseline_entry_for_advanced_command_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionAbout", "actionBleedFixup"] + ).run() + self.assertTrue( + any( + "actionAbout" in error and "KEEP" in error for error in errors + ), + errors, + ) + + def test_recording_writes_the_current_gap_set(self): + fixture = CatalogFixture(self, ratchet_unbound=[]) + self.assertTrue(fixture.run(), "an empty baseline must fail while a gap exists") + unbound = verifier.record_ratchet(fixture.policy_path, fixture.ratchet_path) + self.assertEqual(unbound, ["actionBleedFixup"]) + self.assertEqual(fixture.run(), []) + + def test_unsorted_or_duplicated_baseline_is_reported(self): + fixture = CatalogFixture(self, ratchet_unbound=["actionBleedFixup"]) + fixture.write_ratchet(["actionBleedFixup", "actionBleedFixup"]) + with self.assertRaises(verifier.ContractError): + fixture.run() + def test_downgrading_an_implemented_command_is_rejected(self): errors = self.mutate("actionOpen", availability="declared", parameters=[]) self.assertTrue(any("no 'implemented' availability" in error for error in errors), errors) diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index e31cb6144..25115a0a8 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -12,6 +12,15 @@ disagreed with those assignments would be a second command truth wearing the first one's ID set, so both files are parsed and compared here. +The descriptor `availability` flag is the handler gate. CommandCatalog refuses +to register a handler for an id the contract marks declared, and invoke() +rejects such an id as `command/not-implemented`, so a KEEP or ABSORB command +whose availability is still `declared` is a menu entry that does nothing. Every +KEEP or ABSORB action must therefore be `implemented` or recorded in +docs/shell-handler-coverage.json, the ratchet baseline that may only shrink: +binding a handler removes its id there, and a new KEEP or ABSORB action cannot +arrive unbound (epic #231). + See docs/LOOP_SHELL_CONTRACT.md and architecture invariant I22. """ @@ -26,9 +35,15 @@ ROOT = Path(__file__).resolve().parents[1] POLICY_PATH = ROOT / "docs" / "loop-shell-actions.json" +RATCHET_PATH = ROOT / "docs" / "shell-handler-coverage.json" MAIN_WINDOW_PATH = ROOT / "LoopLibGui" / "pdfeditormainwindow.cpp" CONTROLLER_PATH = ROOT / "LoopLibGui" / "pdfprogramcontroller.cpp" +# The dispositions the handler-coverage ratchet covers. ADVANCED commands are +# release-profile gated and HIDE / STOP-SHIPPING commands never reach a menu, +# so none of them owes the Quick shell a handler. +RATCHET_DISPOSITIONS = frozenset({"KEEP", "ABSORB"}) + # Commands that have a handler in LoopLibInteraction (DocumentFacade for # document lifecycle, ViewportCommandBridge for page/zoom/rotate). A command # may only leave this set by being implemented somewhere else, never by being @@ -349,10 +364,102 @@ def check_implemented_set(policy: dict) -> list[str]: return errors +def unbound_actions(policy: dict) -> list[str]: + """KEEP and ABSORB actions whose descriptor has no bound handler yet.""" + return sorted( + action["id"] + for action in policy["actions"] + if action.get("disposition") in RATCHET_DISPOSITIONS + and action.get("command", {}).get("availability") != "implemented" + ) + + +def load_ratchet(path: Path) -> list[str]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ContractError(f"cannot read {path.name}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ContractError(f"{path.name} is not valid JSON: {exc}") from exc + + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + raise ContractError(f"{path.name} schema_version must be 1") + unbound = payload.get("unbound") + if not isinstance(unbound, list) or any( + not isinstance(action_id, str) or not action_id for action_id in unbound + ): + raise ContractError(f"{path.name} must carry a string 'unbound' array") + if unbound != sorted(set(unbound)): + raise ContractError(f"{path.name} 'unbound' must be sorted and unique") + return unbound + + +def check_handler_ratchet(policy: dict, baseline: list[str]) -> list[str]: + """Every KEEP/ABSORB action is implemented, or is a recorded gap. + + The baseline is the frozen record of gaps; it is exact, not a floor. A new + KEEP or ABSORB action that arrives without a handler fails here, and so does + a baseline entry kept after its handler landed, so the count of unbound + commands can only move down with a visible edit. + """ + errors: list[str] = [] + awaiting = set(unbound_actions(policy)) + recorded = set(baseline) + + for action_id in sorted(awaiting - recorded): + errors.append( + f"{action_id}: KEEP/ABSORB command has no bound handler and is " + "missing from the ratchet baseline (docs/shell-handler-coverage.json)" + ) + + actions_by_id = { + action["id"]: action + for action in policy["actions"] + if isinstance(action, dict) and isinstance(action.get("id"), str) + } + for action_id in sorted(recorded - awaiting): + action = actions_by_id.get(action_id) + if action is None: + errors.append( + f"{action_id}: ratchet baseline names a command the catalog " + "does not carry; remove it" + ) + elif action.get("disposition") not in RATCHET_DISPOSITIONS: + errors.append( + f"{action_id}: ratchet baseline names a " + f"{action.get('disposition')} command; the ratchet covers KEEP " + "and ABSORB only; remove it" + ) + else: + errors.append( + f"{action_id}: ratchet baseline entry now has a bound handler; " + "remove it from the ratchet" + ) + return errors + + +def record_ratchet( + policy_path: Path = POLICY_PATH, ratchet_path: Path = RATCHET_PATH +) -> list[str]: + """Rewrites the ratchet baseline from the current contract.""" + policy = load_policy(policy_path) + unbound = unbound_actions(policy) + + payload: dict = {"schema_version": 1} + if ratchet_path.is_file(): + payload = json.loads(ratchet_path.read_text(encoding="utf-8")) + payload["unbound"] = unbound + ratchet_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + return unbound + + def validate_catalog( policy_path: Path = POLICY_PATH, main_window_path: Path = MAIN_WINDOW_PATH, controller_path: Path = CONTROLLER_PATH, + ratchet_path: Path = RATCHET_PATH, ) -> list[str]: """Returns every rule violation. A malformed input raises instead.""" policy = load_policy(policy_path) @@ -373,6 +480,7 @@ def validate_catalog( errors.extend(check_command_block(action_id, action["command"])) errors.extend(check_implemented_set(policy)) + errors.extend(check_handler_ratchet(policy, load_ratchet(ratchet_path))) if main_window_path.is_file() and controller_path.is_file(): enum_to_id = action_ids_by_enum(read_source(main_window_path), main_window_path) @@ -395,6 +503,11 @@ def verify() -> str: policy = load_policy(POLICY_PATH) implemented = len(IMPLEMENTED_COMMANDS | SHELL_IMPLEMENTED_COMMANDS) + unbound = unbound_actions(policy) + awaiting_note = ( + f"{len(unbound)} KEEP/ABSORB commands still await a handler " + f"(ratchet baseline {RATCHET_PATH.name})." + ) if MAIN_WINDOW_PATH.is_file() and CONTROLLER_PATH.is_file(): shortcuts = widget_shortcuts(read_source(CONTROLLER_PATH), CONTROLLER_PATH) shortcut_note = ( @@ -405,13 +518,27 @@ def verify() -> str: return ( f"Command catalog verified: {len(policy['actions'])} descriptors " f"({implemented} implemented, {len(policy['actions']) - implemented} declared); " + f"{awaiting_note} " f"{shortcut_note}" ) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.parse_args(argv) + parser.add_argument( + "--record-ratchet", + action="store_true", + help="rewrite docs/shell-handler-coverage.json from the current contract", + ) + args = parser.parse_args(argv) + + if args.record_ratchet: + unbound = record_ratchet() + print( + f"Handler ratchet recorded: {len(unbound)} KEEP/ABSORB commands " + f"await a handler in {RATCHET_PATH.name}." + ) + return 0 try: print(verify()) From 604d92aed5b92e09bb0ece5486533df3fdfb7de7 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:23:48 -0700 Subject: [PATCH 11/43] changes: add the PR1 evidence manifest for the handler-coverage ratchet (#231) --- changes/gh-231-quick-coverage.evidence.yaml | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changes/gh-231-quick-coverage.evidence.yaml diff --git a/changes/gh-231-quick-coverage.evidence.yaml b/changes/gh-231-quick-coverage.evidence.yaml new file mode 100644 index 000000000..59a26a886 --- /dev/null +++ b/changes/gh-231-quick-coverage.evidence.yaml @@ -0,0 +1,24 @@ +format_version: 1 +kind: evidence +claims: + - id: handler-coverage-ratchet + evidence: + - unit:scripts/ci/test_command_catalog.py + - unit:agent-policy:interaction + - architecture:scripts/verify-command-catalog.py + - id: agent-policy-contracts + evidence: + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py + - architecture:scripts/generate-architecture-catalogs.py + - id: packaging-workflows + evidence: + - packaging:linux-build + - packaging:windows-build + - security:codeql + - id: quick-and-interaction-modules + evidence: + - unit:agent-policy:quick + - unit:agent-policy:interaction +unresolved: [] From 1ccce441a1df39036b8246f19a13f5b5bd9639aa Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:27:01 -0700 Subject: [PATCH 12/43] docs(product-surface): point the signature and scanner plugin rows at the live epic (#231) The G00 provenance repair re-homed the absorbed plugin rows; these two ADVANCED rows were left null while their replacement surfaces (signing in loop-fix, the scanner document workflow) are still pending, so no live issue owned them. --- docs/product-surface.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-surface.json b/docs/product-surface.json index ddc020aec..cabe678ea 100644 --- a/docs/product-surface.json +++ b/docs/product-surface.json @@ -298,7 +298,7 @@ "replacement_surface": "loop-fix", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": null + "follow_up_issue": 231 }, { "id": "editor-plugin", @@ -358,7 +358,7 @@ "replacement_surface": "loop-document", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": null + "follow_up_issue": 231 }, { "id": "audiobook-plugin", From 3d24b6f8839917a0c01a8327827c716a8922edbd Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:30:08 -0700 Subject: [PATCH 13/43] feat(quick): measure and select/copy/capture on the Quick canvas (#238, #232) Text and Measure join the closed InteractionTool vocabulary as their own InteractionKinds: a text selection is a typed, revision-fenced TextSelection resolved through Core's text layout in reading order, and a measurement is a typed revision-fenced Measurement in production units that never becomes a DragSession. The nine actionSelect*/CopyText/GoTo*Line/Screenshot/ExtractImage commands are bound; screenshot and image extraction write through the atomic writer and refuse to overwrite; a page without a text layer reports text/no-text-layer instead of copying an empty string. --- LoopEditor/documentviewsession.cpp | 16 + LoopEditor/documentviewsession.h | 5 + LoopEditor/editorhost.cpp | 270 +++++++++- LoopEditor/editorhost.h | 29 ++ LoopEditor/qml/Main.qml | 32 ++ LoopEditor/qml/ShellToolBar.qml | 32 ++ LoopLibInteraction/CMakeLists.txt | 6 + LoopLibInteraction/sources/canvassnapshot.cpp | 49 ++ LoopLibInteraction/sources/canvassnapshot.h | 7 + .../sources/interactioncontroller.cpp | 435 ++++++++++++++++ .../sources/interactioncontroller.h | 97 ++++ .../sources/interactionstate.cpp | 130 +++++ LoopLibInteraction/sources/interactionstate.h | 153 +++++- LoopLibInteraction/sources/overlaybuilder.cpp | 74 ++- LoopLibInteraction/sources/overlayframe.cpp | 2 + LoopLibInteraction/sources/overlayframe.h | 12 +- LoopLibInteraction/sources/pageunitsource.h | 52 ++ .../sources/pdfdocumentimageextractor.cpp | 152 ++++++ .../sources/pdfdocumentimageextractor.h | 74 +++ .../pdfdocumenttextselectionsource.cpp | 364 +++++++++++++ .../sources/pdfdocumenttextselectionsource.h | 84 +++ .../sources/textselectionsource.h | 88 ++++ .../sources/viewportcontroller.cpp | 28 + .../sources/viewportcontroller.h | 9 +- UnitTests/tst_documentviewsessiontest.cpp | 327 ++++++++++++ UnitTests/tst_interactioncontrollertest.cpp | 492 +++++++++++++++++- UnitTests/tst_overlayframetest.cpp | 71 +++ UnitTests/tst_quickaccessibilitytest.cpp | 28 + UnitTests/tst_quickcanvastest.cpp | 252 +++++++++ docs/INTERACTION_CONTRACT.md | 35 +- docs/LOOP_SHELL_CONTRACT.md | 2 +- docs/loop-shell-actions.json | 36 +- docs/shell-handler-coverage.json | 9 - scripts/verify-command-catalog.py | 9 + .../qml/Main.qml | 32 ++ .../qml/ShellToolBar.qml | 32 ++ 36 files changed, 3484 insertions(+), 41 deletions(-) create mode 100644 LoopLibInteraction/sources/pageunitsource.h create mode 100644 LoopLibInteraction/sources/pdfdocumentimageextractor.cpp create mode 100644 LoopLibInteraction/sources/pdfdocumentimageextractor.h create mode 100644 LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp create mode 100644 LoopLibInteraction/sources/pdfdocumenttextselectionsource.h create mode 100644 LoopLibInteraction/sources/textselectionsource.h diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 7ad871c89..3a8f0a012 100644 --- a/LoopEditor/documentviewsession.cpp +++ b/LoopEditor/documentviewsession.cpp @@ -5,6 +5,8 @@ #include "interactioncontroller.h" #include "overlaybuilder.h" #include "pagesurfacecoordinator.h" +#include "pdfdocumentimageextractor.h" +#include "pdfdocumenttextselectionsource.h" #include "pdfdocumentsession.h" #include "pdfpagecachebudget.h" @@ -104,6 +106,16 @@ DocumentViewSession::DocumentViewSession(QObject* parent) : *m_hitTest, *m_overlays, this); + m_textSelectionSource = std::make_unique(&m_context); + m_interaction->setTextSelectionSource(m_textSelectionSource.get()); + m_imageExtractor = std::make_unique(&m_context); + + // The revision moving under a measurement or a text selection is the + // conflict path: both are dropped rather than carried onto geometry the + // operator never saw. The session owns the stack, so it owns this wiring. + connect(&m_context, &pdf::PDFDocumentContext::revisionChanged, this, + [this](const pdf::PDFRevisionIdentity&, const pdf::PDFRevisionIdentity&) + { m_interaction->handleRevisionChanged(); }); m_viewport.setPageLayout(pdfinteraction::PageLayout::SinglePage); applyInitialViewportScreenMetrics(m_viewport); @@ -205,6 +217,9 @@ void DocumentViewSession::prepareDocumentView() m_interaction->invalidate(); m_geometry = std::make_unique(&m_context); m_viewport.setGeometrySource(m_geometry.get()); + // The geometry source answers the page's /UserUnit scale too, so the + // measure tool reads the same page facts the viewport laid out. + m_interaction->setPageUnitSource(m_geometry.get()); m_viewport.invalidateLayout(); m_surfaces->setDocumentKey(m_revisionSource->documentKey()); @@ -223,6 +238,7 @@ void DocumentViewSession::prepareDocumentView() void DocumentViewSession::clearDocumentView() { m_interaction->invalidate(); + m_interaction->setPageUnitSource(nullptr); m_viewport.setGeometrySource(nullptr); m_geometry.reset(); m_surfaces->invalidate(m_facade->currentRevision()); diff --git a/LoopEditor/documentviewsession.h b/LoopEditor/documentviewsession.h index 9de04b912..5de1d1114 100644 --- a/LoopEditor/documentviewsession.h +++ b/LoopEditor/documentviewsession.h @@ -31,6 +31,8 @@ class OverlayBuilder; class PageSurfaceCoordinator; class PDFDocumentContextSource; class PDFDocumentPageGeometrySource; +class PDFDocumentTextSelectionSource; +class PDFDocumentImageExtractor; } /// Owns the document-bound view machinery used by EditorHost. @@ -65,6 +67,7 @@ class DocumentViewSession final : public QObject pdfinteraction::HitTestDispatcher* hitTest() const noexcept { return m_hitTest.get(); } pdfinteraction::OverlayBuilder* overlays() const noexcept { return m_overlays.get(); } pdfinteraction::InteractionController* interaction() const noexcept { return m_interaction.get(); } + pdfinteraction::PDFDocumentImageExtractor* imageExtractor() const noexcept { return m_imageExtractor.get(); } pdfinteraction::ViewportCommandBridge& commandBridge() noexcept { return m_commandBridge; } pdfinteraction::PageBoxHitTestSource& pageBoxSource() noexcept { return m_pageBoxSource; } @@ -131,6 +134,8 @@ class DocumentViewSession final : public QObject std::unique_ptr m_hitTest; std::unique_ptr m_overlays; std::unique_ptr m_interaction; + std::unique_ptr m_textSelectionSource; + std::unique_ptr m_imageExtractor; pdfinteraction::ViewportCommandBridge m_commandBridge; pdfinteraction::PageBoxHitTestSource m_pageBoxSource; qsizetype m_cacheLimit = DefaultCacheLimit; // total authority (partitioned via PDFPageCacheBudget) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index f335a51fe..e07b1762d 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -43,6 +43,7 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdocumentimageextractor.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -546,6 +548,157 @@ bool EditorHost::setActiveTool(const QString& toolId) return true; } +bool EditorHost::copySelectedText() +{ + pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (!interaction) + { + return false; + } + + // The refusal is typed and named, and the clipboard is left untouched: an + // empty string is never copied as success. + const pdfinteraction::TextCopyResult result = interaction->selectedText(); + if (!result.copied) + { + announceDocumentState(tr("Copy refused: %1").arg(result.refusalReason)); + return false; + } + + QGuiApplication::clipboard()->setText(result.text); + announceDocumentState(tr("Copied %n character(s).", "", result.text.size())); + return true; +} + +QImage EditorHost::composeVisibleCanvasImage() const +{ + pdfinteraction::PageSurfaceCoordinator* surfaces = m_session->surfaces(); + if (!surfaces || !hasDocument()) + { + return QImage(); + } + + const pdfinteraction::CanvasSnapshot& snapshot = surfaces->snapshot(); + if (snapshot.isEmpty() || !snapshot.token.revision.isValid() || + snapshot.token.revision != m_session->facade().currentRevision()) + { + return QImage(); + } + + return pdfinteraction::composeCanvasImage(snapshot, + m_session->viewport().viewportRect(), + m_session->viewport().devicePixelRatio()); +} + +bool EditorHost::captureCanvasFileUrl(const QUrl& url) +{ + const QString path = url.toLocalFile(); + if (path.isEmpty()) + { + announceDocumentState(tr("Choose a local file to save the screenshot to.")); + return false; + } + + const QImage image = composeVisibleCanvasImage(); + if (image.isNull()) + { + announceDocumentState(tr("There is nothing on the canvas to capture.")); + return false; + } + + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG")) + { + announceDocumentState(tr("The screenshot could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(path, png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write the screenshot: %1").arg(result.getErrorMessage())); + return false; + } + + announceDocumentState(tr("Screenshot saved.")); + return true; +} + +bool EditorHost::extractCurrentPageImagesFileUrl(const QUrl& directory) +{ + if (!hasDocument() || !m_session->imageExtractor()) + { + announceDocumentState(tr("Open a document before extracting images.")); + return false; + } + + const QString directoryPath = directory.toLocalFile(); + if (directoryPath.isEmpty() || !QFileInfo(directoryPath).isDir()) + { + announceDocumentState(tr("Choose an existing directory to extract the images into.")); + return false; + } + + QString typedError; + const QList images = m_session->imageExtractor()->extractPage(currentPage(), &typedError); + if (!typedError.isEmpty()) + { + announceDocumentState(tr("Could not extract images: %1").arg(typedError)); + return false; + } + if (images.isEmpty()) + { + announceDocumentState(tr("This page has no images to extract.")); + return false; + } + + // Guard every planned output up front: extraction must not silently clobber + // an existing file, and a run must not stop halfway through the set. + QStringList plannedOutputs; + plannedOutputs.reserve(images.size()); + for (int index = 0; index < images.size(); ++index) + { + plannedOutputs << QDir(directoryPath).filePath(QStringLiteral("page-%1-image-%2.png").arg(currentPage() + 1).arg(index + 1)); + } + + if (!pdf::PDFSafeFileWriter::findOutputConflicts(plannedOutputs, true).isEmpty()) + { + announceDocumentState(tr("Refusing to overwrite an existing file in the destination directory.")); + return false; + } + + for (int index = 0; index < images.size(); ++index) + { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!images[index].image.save(&buffer, "PNG")) + { + announceDocumentState(tr("An extracted image could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(plannedOutputs[index], png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write %1: %2") + .arg(QFileInfo(plannedOutputs[index]).fileName(), result.getErrorMessage())); + return false; + } + } + + announceDocumentState(tr("Extracted %n image(s).", "", images.size())); + return true; +} + QObject* EditorHost::preflight() { return &m_preflight; @@ -3525,6 +3678,25 @@ void EditorHost::connectInteraction() &pdfinteraction::InteractionController::dragCompleted, this, &EditorHost::onDragCompleted); + + // Measurements and text selections are presentation state the accessible + // summary reports, so every change re-reads the summary. + connect(m_session->interaction(), + &pdfinteraction::InteractionController::measurementCompleted, + this, + [this](const pdfinteraction::Measurement& measurement) + { + announceDocumentState(tr("Measured %1.").arg(pdfinteraction::measurementReadout(measurement))); + bumpPresentation(); + }); + connect(m_session->interaction(), + &pdfinteraction::InteractionController::measurementsCleared, + this, + &EditorHost::bumpPresentation); + connect(m_session->interaction(), + &pdfinteraction::InteractionController::textSelectionChanged, + this, + &EditorHost::bumpPresentation); } void EditorHost::connectSurfaces() @@ -3587,6 +3759,58 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionProperties"), [this] { setWorkspace(LoopWorkspace::Inspect); }); + // ---- Select, copy and capture (issue #232) ------------------------------------- + // A selection command finishes Failed with a typed code when it applied + // nothing, so "the menu did nothing" is reported rather than implied. + auto bindSelectionCommand = [this](const QString& id, std::function action) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, action = std::move(action)](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool applied = action(); + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : QStringLiteral("text/selection-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindSelectionCommand(QStringLiteral("actionSelectText"), [this] + { return setActiveTool(QStringLiteral("text")); }); + bindSelectionCommand(QStringLiteral("actionSelectTextAll"), [this] + { return m_session->interaction()->selectAllText(); }); + bindSelectionCommand(QStringLiteral("actionDeselectText"), [this] + { return m_session->interaction()->deselectText(); }); + bindSelectionCommand(QStringLiteral("actionSelectTable"), [this] + { return m_session->interaction()->selectTableUnderPointer(); }); + bindSelectionCommand(QStringLiteral("actionGoToNextLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(1); }); + bindSelectionCommand(QStringLiteral("actionGoToPreviousLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(-1); }); + + // Copy reports the controller's typed refusal, so a page with no text layer + // can never be copied as an empty success. + pdfinteraction::CommandCatalog::Handler copy; + copy.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool copied = copySelectedText(); + m_session->catalog().finishInvocation(invocation, + copied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + copied ? QString() : QStringLiteral("text/copy-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionCopyText"), std::move(copy)); + + // Screenshot and image extraction need a destination, so the commands ask + // the shell for one and write nothing themselves. + bind(QStringLiteral("actionScreenshot"), [this] + { Q_EMIT screenshotRequested(); }); + bind(QStringLiteral("actionExtractImage"), [this] + { Q_EMIT imageExtractionRequested(); }); + pdfinteraction::CommandCatalog::Handler move; move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { @@ -3616,6 +3840,20 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); availability.insert(QStringLiteral("actionFullscreenMode"), true); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); + + // Select, copy and capture (issue #232). Every one of these is enabled with + // a document: Copy deliberately stays enabled without a selection so its + // typed refusal is reachable and visible, rather than the command silently + // disappearing from the menu. + for (const QString& id : { QStringLiteral("actionSelectText"), QStringLiteral("actionSelectTextAll"), + QStringLiteral("actionDeselectText"), QStringLiteral("actionSelectTable"), + QStringLiteral("actionCopyText"), QStringLiteral("actionGoToNextLine"), + QStringLiteral("actionGoToPreviousLine"), QStringLiteral("actionScreenshot"), + QStringLiteral("actionExtractImage") }) + { + availability.insert(id, ready); + } + m_session->catalog().setEnabledBatch(availability); } @@ -4181,8 +4419,36 @@ void EditorHost::updateCanvasAccessibilitySummary() const int pageNumber = currentPage() + 1; const int pages = pageCount(); const int zoomPercent = qRound(zoom() * 100.0); - m_canvas->setAccessibleDocumentSummary( - tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent)); + QString summary = tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent); + + // Measurements and text selections are readouts, so the screen reader gets + // them from the same summary the canvas already exposes. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const pdfinteraction::InteractionState& state = interaction->state(); + const std::optional& selection = state.textSelection(); + if (selection.has_value()) + { + if (!selection->available) + { + summary += QLatin1Char(' ') + tr("Text selection unavailable: %1").arg(selection->unavailableReason); + } + else if (!selection->text.isEmpty()) + { + summary += QLatin1Char(' ') + tr("Selected %n character(s).", "", selection->text.size()); + } + } + + if (!state.measurements().isEmpty()) + { + summary += QLatin1Char(' ') + tr("Measurements: %1. Latest: %2.") + .arg(state.measurements().size()) + .arg(pdfinteraction::measurementReadout(state.measurements().last())); + } + } + + m_canvas->setAccessibleDocumentSummary(summary); } void EditorHost::onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request) diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 9cef1bf44..492803a24 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -53,6 +53,7 @@ #include "pdfoperationhistory.h" #include +#include #include #include #include @@ -440,6 +441,21 @@ class EditorHost final : public QObject /// in-flight drag with ToolChanged (#141 AC3). Q_INVOKABLE bool setActiveTool(const QString& toolId); + // ---- Select, copy and capture (issue #232) ------------------------------------- + /// Copies the canvas text selection to the clipboard. The clipboard is only + /// written when there is text to copy; a page with no text layer reports + /// that state and an empty string is never copied as success. + Q_INVOKABLE bool copySelectedText(); + + /// Writes the visible canvas region to `url` as a PNG through the atomic + /// writer, which refuses to overwrite an existing file. + Q_INVOKABLE bool captureCanvasFileUrl(const QUrl& url); + + /// Extracts the current page's images into `directory` through the atomic + /// writer. Refuses up front when any target file already exists, so a run + /// cannot half-overwrite the destination. + Q_INVOKABLE bool extractCurrentPageImagesFileUrl(const QUrl& directory); + Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; Q_INVOKABLE quint64 invokeCommand(const QString& commandId, const QVariantMap& parameters = {}); @@ -489,6 +505,15 @@ class EditorHost final : public QObject void preflightProfileExportRequested(); void preflightProfileSaveRequested(); + /// The Screenshot command needs a destination, so the shell asks for one + /// and answers with captureCanvasFileUrl(). The command itself writes + /// nothing. + void screenshotRequested(); + + /// The Extract Image command needs a destination directory, so the shell + /// asks for one and answers with extractCurrentPageImagesFileUrl(). + void imageExtractionRequested(); + private: void connectFacade(); void connectViewport(); @@ -538,6 +563,10 @@ class EditorHost final : public QObject void applyEmptyCanvasInspectorSelection(); void setInspectionMode(QString mode); + /// The visible canvas region as one image, or a null image when no admitted + /// tile intersects it or the snapshot is stale. + QImage composeVisibleCanvasImage() const; + /// Operator review of the planned correction (#586). `fixPlanIsCurrent()` is the only /// authority on whether the reviewed plan still describes the open revision. QString fixCurrentPlanDigest() const; diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b815..5c25e1cd7 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -158,6 +158,32 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: screenshotDialog + title: qsTr("Save Screenshot") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + if (host) { + host.captureCanvasFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FolderDialog { + id: extractImagesDialog + title: qsTr("Extract Images Into Folder") + onAccepted: { + if (host) { + host.extractCurrentPageImagesFileUrl(selectedFolder) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: preflightReportDialog title: qsTr("Export Preflight Report") @@ -228,6 +254,12 @@ ApplicationWindow { profileSaveVersionField.text = host ? host.preflightProfileDraftVersion : "" preflightProfileSaveDialog.open() } + function onScreenshotRequested() { + screenshotDialog.open() + } + function onImageExtractionRequested() { + extractImagesDialog.open() + } } TextField { diff --git a/LoopEditor/qml/ShellToolBar.qml b/LoopEditor/qml/ShellToolBar.qml index bfad8d5ae..b8ca48281 100644 --- a/LoopEditor/qml/ShellToolBar.qml +++ b/LoopEditor/qml/ShellToolBar.qml @@ -157,6 +157,38 @@ ToolBar { value: !!root.host && root.host.activeTool === "hand" } } + ToolButton { + id: textToolButton + objectName: "textToolButton" + text: qsTr("Text") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("text") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Text selection tool") + ButtonGroup.group: toolGroup + Binding { + target: textToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "text" + } + } + ToolButton { + id: measureToolButton + objectName: "measureToolButton" + text: qsTr("Measure") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("measure") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Measure tool") + ButtonGroup.group: toolGroup + Binding { + target: measureToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "measure" + } + } ToolSeparator {} ToolButton { objectName: "zoomInButton" diff --git a/LoopLibInteraction/CMakeLists.txt b/LoopLibInteraction/CMakeLists.txt index c73069f67..0aa27c2b2 100644 --- a/LoopLibInteraction/CMakeLists.txt +++ b/LoopLibInteraction/CMakeLists.txt @@ -71,6 +71,12 @@ add_library(LoopLibInteraction STATIC sources/interactiontarget.h sources/interactionstate.cpp sources/interactionstate.h + sources/textselectionsource.h + sources/pdfdocumenttextselectionsource.cpp + sources/pdfdocumenttextselectionsource.h + sources/pdfdocumentimageextractor.cpp + sources/pdfdocumentimageextractor.h + sources/pageunitsource.h sources/hittestsource.cpp sources/hittestsource.h sources/pagespatialindex.cpp diff --git a/LoopLibInteraction/sources/canvassnapshot.cpp b/LoopLibInteraction/sources/canvassnapshot.cpp index aca5c5f13..8caf10626 100644 --- a/LoopLibInteraction/sources/canvassnapshot.cpp +++ b/LoopLibInteraction/sources/canvassnapshot.cpp @@ -22,6 +22,8 @@ #include "canvassnapshot.h" +#include + namespace pdfinteraction { @@ -38,4 +40,51 @@ const CanvasTile* CanvasSnapshot::tileForPage(int pageIndex) const return nullptr; } +QImage composeCanvasImage(const CanvasSnapshot& snapshot, QRect viewportRect, qreal devicePixelRatio) +{ + const qreal ratio = devicePixelRatio > 0.0 ? devicePixelRatio : 1.0; + + QImage image(viewportRect.size() * ratio, QImage::Format_ARGB32_Premultiplied); + if (image.isNull()) + { + return image; + } + + // A capture of the visible region shows paper where a page is and the + // canvas background where one is not; the tiles carry the paper, so the + // canvas is filled with white rather than left undefined. + image.fill(Qt::white); + image.setDevicePixelRatio(ratio); + + QPainter painter(&image); + bool anyTile = false; + for (const CanvasTile& tile : snapshot.tiles) + { + if (!tile.pixels || tile.pixels->image.isNull() || tile.placedRect.isEmpty()) + { + continue; + } + + if (!QRectF(tile.placedRect).intersects(QRectF(viewportRect))) + { + continue; + } + + // The tile image is drawn into its placed rectangle in device pixels. + // A tile rendered for another resolution is scaled here exactly as the + // scene graph scales it, so the capture shows what the canvas shows. + const QRectF target(tile.placedRect.topLeft() * ratio, tile.placedRect.size() * ratio); + painter.drawImage(target, tile.pixels->image); + anyTile = true; + } + painter.end(); + + if (!anyTile) + { + return QImage(); + } + + return image; +} + } // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/canvassnapshot.h b/LoopLibInteraction/sources/canvassnapshot.h index 084657789..f9fb78af5 100644 --- a/LoopLibInteraction/sources/canvassnapshot.h +++ b/LoopLibInteraction/sources/canvassnapshot.h @@ -25,6 +25,7 @@ #include "pagesurfacekey.h" +#include #include #include @@ -59,6 +60,12 @@ struct CanvasSnapshot bool isEmpty() const { return tiles.isEmpty(); } }; +/// Composes the admitted tiles that intersect `viewportRect` into one image, in +/// device pixels, over a white page background. Pure: it reads the snapshot and +/// writes nothing, so the screenshot path and its test drive identical code. +/// Returns a null image when no tile intersects the region. +QImage composeCanvasImage(const CanvasSnapshot& snapshot, QRect viewportRect, qreal devicePixelRatio); + } // namespace pdfinteraction #endif // CANVASSNAPSHOT_H diff --git a/LoopLibInteraction/sources/interactioncontroller.cpp b/LoopLibInteraction/sources/interactioncontroller.cpp index b8908edbc..5961a6ca0 100644 --- a/LoopLibInteraction/sources/interactioncontroller.cpp +++ b/LoopLibInteraction/sources/interactioncontroller.cpp @@ -24,6 +24,8 @@ #include +#include + namespace pdfinteraction { @@ -93,6 +95,22 @@ void InteractionController::setTraceRecorder(InteractionTraceRecorder* recorder) m_trace = recorder; } +void InteractionController::setTextSelectionSource(ITextSelectionSource* source) +{ + m_textSelectionSource = source; +} + +void InteractionController::setPageUnitSource(IPageUnitSource* source) +{ + m_pageUnitSource = source; +} + +bool InteractionController::fenceIsCurrent() const +{ + const RevisionFencedToken fence = token(); + return fence.revision.isValid() && (!m_revisions || m_revisions->isCurrent(fence.revision)); +} + RevisionFencedToken InteractionController::token() const { RevisionFencedToken token; @@ -162,7 +180,17 @@ void InteractionController::cancelActive(InteractionCancelReason reason) return; } + // A cancelled text-selection gesture drops the draft selection with it: an + // escape or a tool change must not leave half a selection the operator + // never finished. A completed selection is dropped by deselectText() or by + // a revision change, not by the gesture that made it. + const bool wasTextSelect = m_state.isActive(InteractionKind::TextSelect); m_state.cancel(reason); + if (wasTextSelect && m_state.clearTextSelection()) + { + Q_EMIT textSelectionChanged(); + } + Q_EMIT interactionCancelled(reason); } @@ -251,6 +279,21 @@ void InteractionController::handlePointerPress(const PointerIntent& intent) return; } + // Text and Measure own the left button the same way Hand does: they are + // tools, so what the press means is decided by the tool, not by the target + // under the pointer. + if (m_activeTool == InteractionTool::Text) + { + beginTextSelection(intent); + return; + } + + if (m_activeTool == InteractionTool::Measure) + { + beginMeasurement(intent); + return; + } + const bool selectionDidChange = m_state.setSelected(target); if (selectionDidChange) { @@ -292,6 +335,39 @@ void InteractionController::handlePointerMove(const PointerIntent& intent) return; } + if (m_state.isActive(InteractionKind::TextSelect)) + { + // A text selection stays on the page it began on, exactly as a drag + // does; resolving the page under the pointer would retarget the + // gesture the moment it crossed a page boundary. + const std::optional pagePoint = m_viewport->viewportToPagePoint(intent.positionPx, m_textGesturePage); + m_state.setPointerPosition(intent.positionPx, m_textGesturePage, pagePoint); + if (pagePoint.has_value()) + { + applyTextRange(m_textGesturePage, m_textAnchorPagePoint, *pagePoint); + } + + publishOverlay(); + return; + } + + if (m_state.isActive(InteractionKind::Measure)) + { + const std::optional pagePoint = m_viewport->viewportToPagePoint(intent.positionPx, m_measurePageIndex); + m_state.setPointerPosition(intent.positionPx, m_measurePageIndex, pagePoint); + if (pagePoint.has_value() && m_state.measurementDraft().has_value()) + { + const Measurement draft = measureBetween(m_measurePageIndex, + m_state.measurementDraft()->startPagePoint, + *pagePoint, + m_measurePointToMM); + m_state.setMeasurementDraft(draft, token()); + } + + publishOverlay(); + return; + } + if (m_state.drag().has_value()) { const int dragPage = m_state.drag()->target.pageIndex; @@ -343,6 +419,51 @@ void InteractionController::handlePointerRelease(const PointerIntent& intent) return; } + if (m_state.isActive(InteractionKind::TextSelect)) + { + // Below the hysteresis threshold a press and release is a click: it + // clears the selection instead of leaving a zero-length one behind. + const QPoint travel = intent.positionPx - m_textAnchorPx; + const bool travelled = std::hypot(qreal(travel.x()), qreal(travel.y())) > qreal(m_state.dragThresholdPx()); + + m_state.cancel(InteractionCancelReason::Explicit); + if (travelled) + { + Q_EMIT textSelectionChanged(); + } + else if (m_state.clearTextSelection()) + { + Q_EMIT textSelectionChanged(); + } + + publishOverlay(); + return; + } + + if (m_state.isActive(InteractionKind::Measure)) + { + const QPoint travel = intent.positionPx - m_measureAnchorPx; + const bool travelled = std::hypot(qreal(travel.x()), qreal(travel.y())) > qreal(m_state.dragThresholdPx()); + + if (!travelled) + { + m_state.cancel(InteractionCancelReason::Explicit); + publishOverlay(); + return; + } + + const std::optional completed = m_state.completeMeasurement(token()); + if (completed.has_value()) + { + // Presentation state only. Nothing here is routed to a command: + // a measurement is not a document edit. + Q_EMIT measurementCompleted(*completed); + } + + publishOverlay(); + return; + } + if (!m_state.drag().has_value()) { return; @@ -419,6 +540,15 @@ void InteractionController::handleKey(const KeyIntent& intent) { cancelActive(InteractionCancelReason::Escape); publishOverlay(); + return; + } + + // With the measure tool active and nothing in progress, Escape is + // the keyboard path that drops the completed measurements, so the + // tool is operable without a mouse. + if (m_activeTool == InteractionTool::Measure && clearMeasurements()) + { + publishOverlay(); } return; @@ -513,6 +643,311 @@ void InteractionController::selectTarget(const InteractionTarget& target) publishOverlay(); } +void InteractionController::beginTextSelection(const PointerIntent& intent) +{ + int pageIndex = -1; + QPointF pagePoint; + { + const StageTimer hitTimer(this, TraceStage::HitTest); + hitTestAt(intent.positionPx, &pageIndex, &pagePoint); + } + + m_state.setPointerPosition(intent.positionPx, pageIndex, pageIndex >= 0 ? std::optional(pagePoint) : std::nullopt); + + if (pageIndex < 0 || !m_textSelectionSource) + { + return; + } + + m_textGesturePage = pageIndex; + m_textAnchorPx = intent.positionPx; + m_textAnchorPagePoint = pagePoint; + + m_state.begin(InteractionKind::TextSelect, token()); + applyTextRange(pageIndex, pagePoint, pagePoint); + publishOverlay(); +} + +void InteractionController::beginMeasurement(const PointerIntent& intent) +{ + int pageIndex = -1; + QPointF pagePoint; + { + const StageTimer hitTimer(this, TraceStage::HitTest); + hitTestAt(intent.positionPx, &pageIndex, &pagePoint); + } + + m_state.setPointerPosition(intent.positionPx, pageIndex, pageIndex >= 0 ? std::optional(pagePoint) : std::nullopt); + + qreal pointToMM = 0.0; + if (pageIndex < 0 || !m_pageUnitSource || !m_pageUnitSource->pagePointToMM(pageIndex, &pointToMM)) + { + // No resolvable page scale is no measurement. A guessed 1.0 would be a + // wrong number presented as a fact. + return; + } + + m_measurePageIndex = pageIndex; + m_measureAnchorPx = intent.positionPx; + m_measurePointToMM = pointToMM; + + if (!m_state.begin(InteractionKind::Measure, token())) + { + return; + } + + m_state.setMeasurementDraft(measureBetween(pageIndex, pagePoint, pagePoint, pointToMM), token()); + publishOverlay(); +} + +void InteractionController::applyTextRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) +{ + if (!m_textSelectionSource) + { + return; + } + + const TextSelectionResult result = m_textSelectionSource->selectRange(pageIndex, startPagePoint, endPagePoint); + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + // A drag in progress is overlay-only: the change is announced once, when + // the gesture completes, not on every pointer delta. + m_state.setTextSelection(selection, token()); +} + +bool InteractionController::storeTextSelection(const TextSelection& selection) +{ + if (!m_state.setTextSelection(selection, token())) + { + return false; + } + + Q_EMIT textSelectionChanged(); + publishOverlay(); + return true; +} + +TextCopyResult InteractionController::selectedText() const +{ + TextCopyResult result; + + if (!m_textSelectionSource) + { + result.refusalCode = QStringLiteral("text/unavailable"); + result.refusalReason = tr("Text selection is unavailable."); + return result; + } + + const std::optional& selection = m_state.textSelection(); + if (!selection.has_value() || !selection->isValid()) + { + result.refusalCode = QStringLiteral("text/nothing-selected"); + result.refusalReason = tr("Nothing is selected."); + return result; + } + + if (m_revisions && !m_revisions->isCurrent(selection->fence.revision)) + { + result.refusalCode = QStringLiteral("text/stale-selection"); + result.refusalReason = tr("The document changed after this selection was made."); + return result; + } + + if (!selection->available) + { + result.refusalCode = QStringLiteral("text/no-text-layer"); + result.refusalReason = selection->unavailableReason.isEmpty() + ? tr("This page has no text layer.") + : selection->unavailableReason; + return result; + } + + if (selection->text.isEmpty()) + { + result.refusalCode = QStringLiteral("text/nothing-selected"); + result.refusalReason = tr("The selection contains no text."); + return result; + } + + result.copied = true; + result.text = selection->text; + return result; +} + +bool InteractionController::selectAllText() +{ + const int pageIndex = m_viewport->currentPage(); + if (pageIndex < 0 || !m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectPage(pageIndex); + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + return storeTextSelection(selection); +} + +bool InteractionController::selectTableUnderPointer() +{ + if (!m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + QPointF pagePoint; + const int pageIndex = m_viewport->pageUnderPoint(m_state.pointerPx(), &pagePoint); + if (pageIndex < 0) + { + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectBlockAt(pageIndex, pagePoint); + + TextSelection selection; + selection.kind = TextSelectionKind::Block; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + return storeTextSelection(selection); +} + +bool InteractionController::stepTextSelectionByLine(int direction) +{ + if (direction == 0 || !m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + const std::optional& current = m_state.textSelection(); + const bool haveSteppableSelection = current.has_value() && current->isValid() && current->available && current->pageIndex >= 0; + + const int pageIndex = haveSteppableSelection ? current->pageIndex : m_viewport->currentPage(); + if (pageIndex < 0) + { + return false; + } + + const int count = m_textSelectionSource->lineCount(pageIndex); + if (count <= 0) + { + return false; + } + + int target = -1; + if (haveSteppableSelection && current->pageIndex == pageIndex) + { + target = direction > 0 + ? (current->lastLine >= 0 ? current->lastLine + 1 : 0) + : (current->firstLine >= 0 ? current->firstLine - 1 : count - 1); + } + else + { + target = direction > 0 ? 0 : count - 1; + } + + if (target < 0 || target >= count) + { + // The selection is already at the end of the page's text; the caller + // reports that rather than wrapping onto another page. + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectLine(pageIndex, target); + if (!result.available) + { + return false; + } + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = true; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine >= 0 ? result.firstLine : target; + selection.lastLine = result.lastLine >= 0 ? result.lastLine : target; + + return storeTextSelection(selection); +} + +bool InteractionController::deselectText() +{ + if (!m_state.clearTextSelection()) + { + return false; + } + + Q_EMIT textSelectionChanged(); + publishOverlay(); + return true; +} + +bool InteractionController::clearMeasurements() +{ + if (!m_state.clearMeasurements()) + { + return false; + } + + Q_EMIT measurementsCleared(); + return true; +} + +void InteractionController::handleRevisionChanged() +{ + const bool wasActive = m_state.isActive() || m_state.drag().has_value(); + if (wasActive) + { + cancelActive(InteractionCancelReason::RevisionChanged); + } + + const bool hadSelection = m_state.textSelection().has_value(); + const bool hadMeasurements = m_state.measurementDraft().has_value() || !m_state.measurements().isEmpty(); + if (!m_state.clearRevisionBoundState()) + { + if (wasActive) + { + publishOverlay(); + } + return; + } + + if (hadSelection) + { + Q_EMIT textSelectionChanged(); + } + if (hadMeasurements) + { + Q_EMIT measurementsCleared(); + } + + publishOverlay(); +} + void InteractionController::invalidate() { cancelActive(InteractionCancelReason::DocumentClosed); diff --git a/LoopLibInteraction/sources/interactioncontroller.h b/LoopLibInteraction/sources/interactioncontroller.h index 2a1cb7c25..d9e315432 100644 --- a/LoopLibInteraction/sources/interactioncontroller.h +++ b/LoopLibInteraction/sources/interactioncontroller.h @@ -31,6 +31,8 @@ #include "interactiontrace.h" #include "overlaybuilder.h" #include "overlayframe.h" +#include "pageunitsource.h" +#include "textselectionsource.h" #include "viewportcontroller.h" #include @@ -39,6 +41,17 @@ namespace pdfinteraction { +/// The outcome of a Copy request. `text` is non-empty exactly when `copied` is +/// true; a refusal always names its reason, so "nothing was copied" is never +/// presented as success with an empty clipboard payload. +struct TextCopyResult +{ + bool copied = false; + QString text; + QString refusalCode; + QString refusalReason; +}; + /// Turns host input into transient state, viewport motion and overlay frames. /// /// This class is where the P4-S4 exit condition is enforced. Everything a @@ -112,6 +125,48 @@ class InteractionController final : public QObject void setActiveTool(InteractionTool tool); InteractionTool activeTool() const noexcept { return m_activeTool; } + /// Text extraction for the Text tool and the selection commands (issue + /// #232). Observed, not owned; may be nullptr, which makes every selection + /// request answer "unavailable" rather than inventing text. + void setTextSelectionSource(ITextSelectionSource* source); + ITextSelectionSource* textSelectionSource() const noexcept { return m_textSelectionSource; } + + /// The page's user-space scale for measurement (issue #238). Observed, not + /// owned; when absent the measure tool refuses to begin rather than measure + /// at a guessed scale. + void setPageUnitSource(IPageUnitSource* source); + IPageUnitSource* pageUnitSource() const noexcept { return m_pageUnitSource; } + + /// The selected text, or the typed reason there is none. The host puts the + /// text on the clipboard; the refusal is never an empty string copied as + /// success. + TextCopyResult selectedText() const; + + /// Selects the current page's whole text layer, in reading order. + bool selectAllText(); + + /// Selects the text block under the pointer as one typed selection. What a + /// table is to the text layer is a block, so this is how a table is + /// selected. + bool selectTableUnderPointer(); + + /// Steps the text selection one line in reading order. With no selection, + /// selects the first line (direction > 0) or the last (direction < 0). + bool stepTextSelectionByLine(int direction); + + /// Drops the text selection. Returns true when there was one. + bool deselectText(); + + /// Drops every measurement. Returns true when there was something to drop. + bool clearMeasurements(); + + /// The document revision moved under the interaction state. Cancels the + /// active gesture with RevisionChanged and drops what was derived from the + /// previous revision -- measurements and the text selection -- because + /// carrying either onto new geometry is presenting a fact about a document + /// the operator is no longer looking at. + void handleRevisionChanged(); + void handlePointer(const PointerIntent& intent); void handleWheel(const WheelIntent& intent); void handleKey(const KeyIntent& intent); @@ -157,6 +212,17 @@ class InteractionController final : public QObject /// hysteresis threshold. The owner turns it into one command invocation. void dragCompleted(pdfinteraction::DragSession session); + /// Exactly one per completed measurement, and never for a click below the + /// hysteresis threshold. Presentation state only: nothing here is routed to + /// a command. + void measurementCompleted(pdfinteraction::Measurement measurement); + + /// Every measurement was dropped, by the operator or by a revision change. + void measurementsCleared(); + + /// The text selection was replaced, stepped or cleared. + void textSelectionChanged(); + void interactionCancelled(pdfinteraction::InteractionCancelReason reason); private: @@ -191,6 +257,23 @@ class InteractionController final : public QObject void handlePointerMove(const PointerIntent& intent); void handlePointerRelease(const PointerIntent& intent); + /// A left press in the Text tool: begins the gesture and the draft range. + void beginTextSelection(const PointerIntent& intent); + + /// A left press in the Measure tool: begins the gesture and the draft + /// measurement. Does nothing when the page has no resolvable scale. + void beginMeasurement(const PointerIntent& intent); + + /// Recomputes the draft text selection from the source and stores it + /// without announcing a change: a drag in progress is overlay-only. + void applyTextRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint); + + /// Stores a text selection for the current fence and announces it. + bool storeTextSelection(const TextSelection& selection); + + /// True when the fence still names the open document revision. + bool fenceIsCurrent() const; + InteractionTarget hitTestAt(QPoint viewportPx, int* pageIndex, QPointF* pagePoint) const; void cancelActive(InteractionCancelReason reason); @@ -201,6 +284,8 @@ class InteractionController final : public QObject HitTestDispatcher* m_hitTest = nullptr; OverlayBuilder* m_overlays = nullptr; InteractionTraceRecorder* m_trace = nullptr; + ITextSelectionSource* m_textSelectionSource = nullptr; + IPageUnitSource* m_pageUnitSource = nullptr; InteractionState m_state; OverlayFrame m_overlay; @@ -219,6 +304,18 @@ class InteractionController final : public QObject /// Pointer position at the last pan step, so a pan scrolls by the delta /// rather than by the absolute position. QPoint m_panAnchorPx; + + /// The text gesture's page and anchor: a selection stays on the page it + /// began on, exactly as a drag does. + int m_textGesturePage = -1; + QPoint m_textAnchorPx; + QPointF m_textAnchorPagePoint; + + /// The measure gesture's page, anchor and scale, read once at the press + /// from the page's own /UserUnit. + int m_measurePageIndex = -1; + QPoint m_measureAnchorPx; + qreal m_measurePointToMM = 0.0; }; } // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/interactionstate.cpp b/LoopLibInteraction/sources/interactionstate.cpp index df94f8e3b..ee6b30d57 100644 --- a/LoopLibInteraction/sources/interactionstate.cpp +++ b/LoopLibInteraction/sources/interactionstate.cpp @@ -43,6 +43,10 @@ const char* getInteractionKindName(InteractionKind kind) return "marquee"; case InteractionKind::Pan: return "pan"; + case InteractionKind::TextSelect: + return "text-select"; + case InteractionKind::Measure: + return "measure"; case InteractionKind::ToolGesture: return "tool-gesture"; } @@ -58,6 +62,10 @@ const char* getInteractionToolName(InteractionTool tool) return "select"; case InteractionTool::Hand: return "hand"; + case InteractionTool::Text: + return "text"; + case InteractionTool::Measure: + return "measure"; } return "unknown"; @@ -73,10 +81,57 @@ std::optional interactionToolFromName(const QString& name) { return InteractionTool::Hand; } + if (name == QLatin1String("text")) + { + return InteractionTool::Text; + } + if (name == QLatin1String("measure")) + { + return InteractionTool::Measure; + } return std::nullopt; } +const char* getTextSelectionKindName(TextSelectionKind kind) +{ + switch (kind) + { + case TextSelectionKind::Text: + return "text"; + case TextSelectionKind::Block: + return "block"; + } + + return "unknown"; +} + +Measurement measureBetween(int pageIndex, QPointF startPagePoint, QPointF endPagePoint, qreal pointToMM) +{ + Measurement measurement; + measurement.pageIndex = pageIndex; + measurement.startPagePoint = startPagePoint; + measurement.endPagePoint = endPagePoint; + measurement.pointToMM = pointToMM; + + const QPointF delta = endPagePoint - startPagePoint; + const qreal scale = pointToMM > 0.0 ? pointToMM : 1.0; + + measurement.distanceMM = std::hypot(delta.x(), delta.y()) * scale; + measurement.widthMM = qAbs(delta.x()) * scale; + measurement.heightMM = qAbs(delta.y()) * scale; + measurement.perimeterMM = 2.0 * (measurement.widthMM + measurement.heightMM); + measurement.areaMM2 = measurement.widthMM * measurement.heightMM; + measurement.angleDegrees = qRadiansToDegrees(std::atan2(delta.y(), delta.x())); + + return measurement; +} + +QString measurementReadout(const Measurement& measurement) +{ + return QStringLiteral("%1 mm").arg(QString::number(measurement.distanceMM, 'f', 1)); +} + const char* getInteractionCancelReasonName(InteractionCancelReason reason) { switch (reason) @@ -266,6 +321,79 @@ void InteractionState::cancel(InteractionCancelReason reason) m_lastCancelReason = reason; } +bool InteractionState::setMeasurementDraft(const Measurement& draft, const RevisionFencedToken& token) +{ + if (!isCurrent(token) || draft.pageIndex < 0 || !std::isfinite(draft.distanceMM)) + { + return false; + } + + m_measurementDraft = draft; + m_measurementDraft->fence = token; + return true; +} + +std::optional InteractionState::completeMeasurement(const RevisionFencedToken& token) +{ + if (!m_measurementDraft.has_value()) + { + return std::nullopt; + } + + if (!isCurrent(token)) + { + // A measurement that lost its revision is dropped, not carried onto new + // geometry. + cancel(InteractionCancelReason::RevisionChanged); + return std::nullopt; + } + + Measurement measurement = *m_measurementDraft; + measurement.fence = token; + clearTransient(); + + m_measurements.push_back(measurement); + return measurement; +} + +bool InteractionState::setTextSelection(const TextSelection& selection, const RevisionFencedToken& token) +{ + if (!token.isValid() || selection.pageIndex < 0) + { + return false; + } + + m_textSelection = selection; + m_textSelection->fence = token; + return true; +} + +bool InteractionState::clearTextSelection() +{ + if (!m_textSelection.has_value()) + { + return false; + } + + m_textSelection.reset(); + return true; +} + +bool InteractionState::clearMeasurements() +{ + const bool hadSomething = m_measurementDraft.has_value() || !m_measurements.isEmpty(); + m_measurementDraft.reset(); + m_measurements.clear(); + return hadSomething; +} + +bool InteractionState::clearRevisionBoundState() +{ + const bool hadSelection = clearTextSelection(); + const bool hadMeasurements = clearMeasurements(); + return hadSelection || hadMeasurements; +} + bool InteractionState::clearHover() { if (!m_hovered.isValid()) @@ -290,6 +418,7 @@ void InteractionState::reset(InteractionCancelReason reason) m_lastCancelReason = reason; m_hovered = InteractionTarget(); m_selected = InteractionTarget(); + clearRevisionBoundState(); m_pointerPagePoint.reset(); m_pointerPageIndex = -1; } @@ -304,6 +433,7 @@ void InteractionState::clearTransient() m_kind = InteractionKind::None; m_token = RevisionFencedToken(); m_drag.reset(); + m_measurementDraft.reset(); m_pointerCapture = false; } diff --git a/LoopLibInteraction/sources/interactionstate.h b/LoopLibInteraction/sources/interactionstate.h index c22733901..8d5fc3942 100644 --- a/LoopLibInteraction/sources/interactionstate.h +++ b/LoopLibInteraction/sources/interactionstate.h @@ -47,6 +47,16 @@ enum class InteractionKind Drag, Marquee, Pan, + + /// A text selection being dragged out (issue #232). It is its own kind + /// rather than a ToolGesture because what it produces is a typed + /// TextSelection, not a command. + TextSelect, + + /// A measurement being dragged out (issue #238). It never becomes a + /// DragSession: a measurement is presentation state, not a document edit. + Measure, + ToolGesture }; @@ -66,7 +76,17 @@ enum class InteractionTool /// The left button is a viewport grip: a left drag pans and never selects, /// retargets or starts a transform. The configured pan button still pans /// in every tool. - Hand + Hand, + + /// The left button drags out a text selection on the page under the pointer + /// (issue #232). What it produces is the typed TextSelection state; it never + /// selects a target, never starts a transform and never reaches the document. + Text, + + /// The left button drags out a measurement in production units (issue #238). + /// A measurement is presentation state fenced to the revision it was taken + /// from; it is never a document mutation and never a DragSession. + Measure }; const char* getInteractionToolName(InteractionTool tool); @@ -136,6 +156,91 @@ struct DragSession RevisionFencedToken fence; }; +/// One measurement on a page, in production units (issue #238). +/// +/// A measurement is derived presentation state: it names page-space points and +/// the millimetres they span after the page's /UserUnit scale, and no method +/// anywhere turns it into a document edit. The rectangle fields are the +/// axis-aligned region the two points span, so a region drag reads as a +/// distance, a width x height, a perimeter and an area at once. +struct Measurement +{ + int pageIndex = -1; + QPointF startPagePoint; + QPointF endPagePoint; + + /// Millimetres per page-space point this measurement was taken with, + /// including the page's /UserUnit. Carried so a readout can be explained + /// and re-derived without another document lookup. + qreal pointToMM = 1.0; + + qreal distanceMM = 0.0; + qreal widthMM = 0.0; + qreal heightMM = 0.0; + qreal perimeterMM = 0.0; + qreal areaMM2 = 0.0; + qreal angleDegrees = 0.0; + + /// The fence the measurement was taken against. A measurement whose fence no + /// longer matches is dropped rather than carried onto new geometry. + RevisionFencedToken fence; + + bool isValid() const { return pageIndex >= 0 && fence.isValid(); } + bool operator==(const Measurement& other) const = default; +}; + +/// Derives a measurement from two page-space points and the page's +/// millimetres-per-point scale. Pure; the caller supplies the scale it read +/// from the page's own /UserUnit. +Measurement measureBetween(int pageIndex, QPointF startPagePoint, QPointF endPagePoint, qreal pointToMM); + +/// The operator-facing readout of a measurement's distance, e.g. "42.3 mm". +/// The layer names the unit so no host invents a second one. +QString measurementReadout(const Measurement& measurement); + +/// What produced a text selection. +enum class TextSelectionKind +{ + /// A text range: a drag between two page points, or a whole page. + Text, + + /// One whole text block, which is what a table or a paragraph is to the + /// text layer (issue #232). + Block +}; + +const char* getTextSelectionKindName(TextSelectionKind kind); + +/// A typed text selection (issue #232). +/// +/// Bound to the revision it was taken from through `fence`; a revision change +/// clears it rather than carrying text onto geometry the operator never saw. +/// `available == false` is a real state -- a page with no text layer -- and it +/// is never an empty string presented as success. +struct TextSelection +{ + TextSelectionKind kind = TextSelectionKind::Text; + int pageIndex = -1; + + /// False when the page has no text layer. `unavailableReason` then says so. + bool available = false; + QString unavailableReason; + + QString text; + QList geometry; + + /// Reading-order line range of the selection, or -1 when the selection does + /// not address lines. Stepping by line moves this range. + int firstLine = -1; + int lastLine = -1; + + RevisionFencedToken fence; + + bool isValid() const { return pageIndex >= 0 && fence.isValid(); } + bool isEmpty() const { return text.isEmpty() && geometry.isEmpty(); } + bool operator==(const TextSelection& other) const = default; +}; + /// A cheap, per-event, revision-fenced snapshot of what the pointer is doing. /// /// The class exists to keep direct manipulation off the document. Hover, @@ -181,6 +286,17 @@ class InteractionState final const InteractionTarget& selected() const noexcept { return m_selected; } const std::optional& drag() const noexcept { return m_drag; } + /// The measurement being dragged out, if any. Never a DragSession. + const std::optional& measurementDraft() const noexcept { return m_measurementDraft; } + + /// Completed measurements, in the order they were taken. Cleared by + /// clearMeasurements() and by a revision change, never carried onto new + /// geometry. + const QList& measurements() const noexcept { return m_measurements; } + + /// The typed text selection, or nothing when no page has one. + const std::optional& textSelection() const noexcept { return m_textSelection; } + /// The target that owns pointer capture, if any. A captured pointer keeps /// delivering to the drag even once it leaves the target's bounds. bool hasPointerCapture() const noexcept { return m_pointerCapture; } @@ -221,6 +337,32 @@ class InteractionState final /// session is what a caller turns into exactly one command invocation. std::optional completeDrag(const RevisionFencedToken& token); + /// Sets the in-progress measurement. Refuses a stale token or an invalid + /// measurement, so a measurement can never be steered onto a revision it + /// was not taken against. + bool setMeasurementDraft(const Measurement& draft, const RevisionFencedToken& token); + + /// Ends the in-progress measurement, stores it, and returns it. Nothing is + /// returned for a stale token or when there is no draft; a stale draft is + /// dropped rather than completed. + std::optional completeMeasurement(const RevisionFencedToken& token); + + /// Stores a text selection. Refuses a stale token or a selection without a + /// page, so a selection always names the page it came from. + bool setTextSelection(const TextSelection& selection, const RevisionFencedToken& token); + + /// Drops the text selection. Returns true when there was one. + bool clearTextSelection(); + + /// Drops every measurement, draft and completed. Returns true when there + /// was something to drop. + bool clearMeasurements(); + + /// Drops the state derived from the document revision: the text selection + /// and every measurement. Returns true when something was dropped. A + /// revision change calls this instead of carrying a derived fact forward. + bool clearRevisionBoundState(); + /// Ends the active interaction. Idempotent; always leaves no preview behind. void cancel(InteractionCancelReason reason); @@ -228,7 +370,8 @@ class InteractionState final /// when something was cleared. bool clearHover(); - /// Drops everything, including the selection. For a document replacement. + /// Drops everything, including the selection, the text selection and every + /// measurement. For a document replacement. void reset(InteractionCancelReason reason); /// Whether `token` still addresses the active interaction. @@ -248,6 +391,9 @@ class InteractionState final InteractionTarget m_hovered; InteractionTarget m_selected; std::optional m_drag; + std::optional m_measurementDraft; + QList m_measurements; + std::optional m_textSelection; bool m_pointerCapture = false; int m_dragThresholdPx = DefaultDragThresholdPx; @@ -258,5 +404,8 @@ class InteractionState final Q_DECLARE_METATYPE(pdfinteraction::DragSession) Q_DECLARE_METATYPE(pdfinteraction::InteractionKind) Q_DECLARE_METATYPE(pdfinteraction::InteractionCancelReason) +Q_DECLARE_METATYPE(pdfinteraction::Measurement) +Q_DECLARE_METATYPE(pdfinteraction::TextSelection) +Q_DECLARE_METATYPE(pdfinteraction::TextSelectionKind) #endif // INTERACTIONSTATE_H diff --git a/LoopLibInteraction/sources/overlaybuilder.cpp b/LoopLibInteraction/sources/overlaybuilder.cpp index 3178f8384..6e4233942 100644 --- a/LoopLibInteraction/sources/overlaybuilder.cpp +++ b/LoopLibInteraction/sources/overlaybuilder.cpp @@ -170,7 +170,14 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision // object that already has one, and a host keys retained nodes on the id. The // target keeps its own id so a click routed back from a primitive still names // the real object. - const auto emitPrimitive = [&](const InteractionTarget& target, const QString& id, QRectF pageBounds, OverlayLayer layer, OverlayPrimitiveKind kind, OverlaySeverity severity) -> bool + const auto emitPrimitive = [&](const InteractionTarget& target, + const QString& id, + QRectF pageBounds, + OverlayLayer layer, + OverlayPrimitiveKind kind, + OverlaySeverity severity, + const QString& label = QString(), + const QPolygonF& pagePolygon = QPolygonF()) -> bool { if (frame.primitives.size() >= m_bounds.maxPrimitives) { @@ -191,6 +198,8 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision primitive.severity = severity; primitive.pageIndex = target.pageIndex; primitive.pageBounds = pageBounds; + primitive.pagePolygon = pagePolygon; + primitive.label = label; primitive.target = target; primitive.sequence = sequence++; primitive.hovered = state.hovered().isValid() && state.hovered().id == target.id && state.hovered().pageIndex == target.pageIndex; @@ -240,6 +249,48 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision emitPrimitive(guide, guide.id, guide.pageBounds, layer, OverlayPrimitiveKind::Rectangle, OverlaySeverity::None); } + // Measurements paint in the Guides band the z-order table already assigns + // them. They are presentation state: a measurement has no command and no + // mutation behind it, and a revision change has already dropped it by the + // time this frame is rebuilt. + if (!suppressExtraGraphics) + { + const auto emitMeasurement = [&](const Measurement& measurement, const QString& id) + { + if (!isVisiblePage(measurement.pageIndex)) + { + return; + } + + InteractionTarget target; + target.kind = InteractionTargetKind::Guide; + target.pageIndex = measurement.pageIndex; + target.id = id; + target.pageBounds = QRectF(measurement.startPagePoint, measurement.endPagePoint).normalized(); + + QPolygonF line; + line << measurement.startPagePoint << measurement.endPagePoint; + + emitPrimitive(target, + id, + target.pageBounds, + OverlayLayer::Guides, + OverlayPrimitiveKind::Measurement, + OverlaySeverity::None, + measurementReadout(measurement), + line); + }; + + if (state.measurementDraft().has_value()) + { + emitMeasurement(*state.measurementDraft(), QStringLiteral("measurement#draft")); + } + for (int index = 0; index < state.measurements().size(); ++index) + { + emitMeasurement(state.measurements().at(index), QStringLiteral("measurement#%1").arg(index)); + } + } + for (const InteractionTarget& finding : m_findings) { if (suppressExtraGraphics || !isVisiblePage(finding.pageIndex) || m_hiddenFindingIds.contains(finding.id)) @@ -287,6 +338,27 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision emitPrimitive(selected, selected.id + QStringLiteral("#selection"), selected.pageBounds, OverlayLayer::Selection, OverlayPrimitiveKind::Rectangle, OverlaySeverity::None); } + // A text selection paints in the Selection band, one rectangle per line + // span, so the operator sees which lines are selected. It carries no + // target: there is no text target kind, and clicking selected text is a + // new selection, not a routed command. + const std::optional& textSelection = state.textSelection(); + if (!suppressExtraGraphics && textSelection.has_value() && textSelection->available && isVisiblePage(textSelection->pageIndex)) + { + for (int index = 0; index < textSelection->geometry.size(); ++index) + { + InteractionTarget target; + target.pageIndex = textSelection->pageIndex; + + emitPrimitive(target, + QStringLiteral("text-selection#%1").arg(index), + textSelection->geometry.at(index), + OverlayLayer::Selection, + OverlayPrimitiveKind::Rectangle, + OverlaySeverity::None); + } + } + for (const InteractionTarget& handle : m_handles) { if (suppressExtraGraphics || !isVisiblePage(handle.pageIndex)) diff --git a/LoopLibInteraction/sources/overlayframe.cpp b/LoopLibInteraction/sources/overlayframe.cpp index 45dd6505c..64354695d 100644 --- a/LoopLibInteraction/sources/overlayframe.cpp +++ b/LoopLibInteraction/sources/overlayframe.cpp @@ -62,6 +62,8 @@ const char* getOverlayPrimitiveKindName(OverlayPrimitiveKind kind) return "marker"; case OverlayPrimitiveKind::Handle: return "handle"; + case OverlayPrimitiveKind::Measurement: + return "measurement"; } return "unknown"; diff --git a/LoopLibInteraction/sources/overlayframe.h b/LoopLibInteraction/sources/overlayframe.h index 15d1df16e..2d18695a2 100644 --- a/LoopLibInteraction/sources/overlayframe.h +++ b/LoopLibInteraction/sources/overlayframe.h @@ -67,7 +67,12 @@ enum class OverlayPrimitiveKind Rectangle, Polyline, Marker, - Handle + Handle, + + /// A measurement: a dimension line between two page-space points, with the + /// readout in `label`. It paints in the Guides band the z-order table + /// already assigns to measurements. + Measurement }; const char* getOverlayPrimitiveKindName(OverlayPrimitiveKind kind); @@ -104,6 +109,11 @@ struct OverlayPrimitive QRectF pageBounds; QPolygonF pagePolygon; + /// The readout of a measurement primitive, e.g. "42.3 mm". Empty for every + /// other kind; a host draws it near the line and a screen reader announces + /// it without deriving either. + QString label; + /// The thing this primitive represents, so a host can route a click back /// without a second lookup table. InteractionTarget target; diff --git a/LoopLibInteraction/sources/pageunitsource.h b/LoopLibInteraction/sources/pageunitsource.h new file mode 100644 index 000000000..c0d8a1bb6 --- /dev/null +++ b/LoopLibInteraction/sources/pageunitsource.h @@ -0,0 +1,52 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PAGEUNITSOURCE_H +#define PAGEUNITSOURCE_H + +#include "interactionglobal.h" + +#include + +namespace pdfinteraction +{ + +/// The scale of a page's user space. +/// +/// A measurement is only a production measurement once the page's own /UserUnit +/// is applied: without it a 100-unit line on a /UserUnit 2 page reads half its +/// real length. The interaction layer asks for the scale here instead of +/// reading a document, and a page whose scale cannot be resolved is refused +/// rather than measured at a guessed 1.0. +class IPageUnitSource +{ +public: + virtual ~IPageUnitSource() = default; + + /// Millimetres one page-space point spans, including the page's /UserUnit. + /// Returns false when the page cannot be resolved. + virtual bool pagePointToMM(int pageIndex, qreal* factor) const = 0; +}; + +} // namespace pdfinteraction + +#endif // PAGEUNITSOURCE_H diff --git a/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp b/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp new file mode 100644 index 000000000..b884dff04 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp @@ -0,0 +1,152 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdocumentimageextractor.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfpage.h" +#include "pdfpagecontentprocessor.h" + +namespace pdfinteraction +{ + +namespace +{ + +/// Walks one page's content stream and keeps every painted image, in paint +/// order. The walk is Core's PDFPageContentProcessor -- the same one PdfTool's +/// fetch-images drives -- so both surfaces extract the same images the same way. +class PageImageProcessor final : public pdf::PDFPageContentProcessor +{ + using BaseClass = pdf::PDFPageContentProcessor; + +public: + PageImageProcessor(const pdf::PDFPage* page, + const pdf::PDFDocument* document, + const pdf::PDFFontCache* fontCache, + const pdf::PDFCMS* cms, + const pdf::PDFOptionalContentActivity* optionalContentActivity, + const pdf::PDFMeshQualitySettings& meshQualitySettings, + int pageIndex, + QList* images) : + BaseClass(page, document, fontCache, cms, optionalContentActivity, QTransform(), meshQualitySettings), + m_pageIndex(pageIndex), + m_images(images) + { + } + +protected: + bool isContentSuppressedByOC(pdf::PDFObjectReference ocgOrOcmd) override + { + Q_UNUSED(ocgOrOcmd); + return false; + } + + bool isContentKindSuppressed(ContentKind kind) const override + { + switch (kind) + { + case ContentKind::Shapes: + case ContentKind::Text: + case ContentKind::Shading: + return true; + + case ContentKind::Tiling: + case ContentKind::Images: + return false; // Tiling can have images + + default: + { + Q_ASSERT(false); + break; + } + } + + return false; + } + + void performImagePainting(const QImage& image) override + { + PageImage pageImage; + pageImage.pageIndex = m_pageIndex; + pageImage.order = m_images->size(); + pageImage.image = image; + m_images->push_back(pageImage); + } + +private: + int m_pageIndex = -1; + QList* m_images = nullptr; +}; + +} // namespace + +PDFDocumentImageExtractor::PDFDocumentImageExtractor(pdf::PDFDocumentContext* context) : + m_context(context) +{ +} + +QList PDFDocumentImageExtractor::extractPage(int pageIndex, QString* typedError) const +{ + const auto refuse = [typedError](const QString& reason) + { + if (typedError) + { + *typedError = reason; + } + return QList(); + }; + + pdf::PDFDocumentContext* context = m_context; + if (!context) + { + return refuse(tr("No document is open.")); + } + + pdf::PDFDocumentSession* session = context->getSession(); + const pdf::PDFDocument* document = context->getDocument(); + if (!session || !document) + { + return refuse(tr("No document is open.")); + } + + const pdf::PDFCatalog* catalog = document->getCatalog(); + const bool pageInRange = catalog && pageIndex >= 0 && + static_cast(pageIndex) < catalog->getPageCount(); + const pdf::PDFPage* page = pageInRange ? catalog->getPage(static_cast(pageIndex)) : nullptr; + if (!page) + { + return refuse(tr("The page does not exist.")); + } + + const pdf::PDFMeshQualitySettings meshQuality; + QList images; + PageImageProcessor processor(page, document, session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), meshQuality, pageIndex, &images); + processor.processContents(); + + return images; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/pdfdocumentimageextractor.h b/LoopLibInteraction/sources/pdfdocumentimageextractor.h new file mode 100644 index 000000000..7711f0024 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumentimageextractor.h @@ -0,0 +1,74 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFDOCUMENTIMAGEEXTRACTOR_H +#define PDFDOCUMENTIMAGEEXTRACTOR_H + +#include "interactionglobal.h" + +#include "pdfdocumentcontext.h" + +#include +#include +#include +#include + +namespace pdfinteraction +{ + +/// One image a page's content stream paints. +struct PageImage +{ + int pageIndex = -1; + + /// Paint order within the page, from zero. The same ordinal PdfTool's + /// fetch-images reports, so the two surfaces name an image identically. + int order = 0; + + QImage image; +}; + +/// Extracts the images a page paints, in paint order. +/// +/// The extraction is Core's: the same PDFPageContentProcessor walk PdfTool's +/// `fetch-images` uses, so a Quick extraction and a CLI extraction of the same +/// page produce the same images in the same order. The context is observed +/// through a QPointer, so a destroyed context degrades to "no document" rather +/// than a dangling read. +class PDFDocumentImageExtractor final +{ + Q_DECLARE_TR_FUNCTIONS(pdfinteraction::PDFDocumentImageExtractor) + +public: + explicit PDFDocumentImageExtractor(pdf::PDFDocumentContext* context); + + /// Images `pageIndex` paints, in paint order. Returns an empty list with + /// `typedError` set when the page cannot be read. + QList extractPage(int pageIndex, QString* typedError) const; + +private: + QPointer m_context; +}; + +} // namespace pdfinteraction + +#endif // PDFDOCUMENTIMAGEEXTRACTOR_H diff --git a/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp new file mode 100644 index 000000000..ab9b3c2e1 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp @@ -0,0 +1,364 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdocumenttextselectionsource.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfexception.h" +#include "pdfpage.h" +#include "pdfprocessingbudget.h" +#include "pdfrenderer.h" +#include "pdftextlayoutgenerator.h" + +namespace pdfinteraction +{ + +namespace +{ + +/// A selection's own colour is presentation state the host owns; Core is asked +/// for geometry and text, never for a palette. +const QColor SelectionScratchColor = QColor(0, 0, 0, 0); + +/// Page-space rectangles of one selection item, one per line span. A selection +/// item stays inside one block -- PDFTextLayout::createTextSelection adds one +/// item per block -- so the line walk cannot cross a block boundary. +void appendItemGeometry(const pdf::PDFTextLayout& layout, + const pdf::PDFTextSelectionColoredItem& item, + QList* geometry) +{ + const pdf::PDFTextBlocks& blocks = layout.getTextBlocks(); + if (item.start.blockIndex >= blocks.size()) + { + return; + } + + const pdf::PDFTextLines& lines = blocks[item.start.blockIndex].getLines(); + const size_t firstLine = item.start.lineIndex; + const size_t lastLine = item.end.blockIndex == item.start.blockIndex ? item.end.lineIndex : firstLine; + + for (size_t lineIndex = firstLine; lineIndex <= lastLine && lineIndex < lines.size(); ++lineIndex) + { + const pdf::TextCharacters& characters = lines[lineIndex].getCharacters(); + if (characters.empty()) + { + continue; + } + + const size_t firstCharacter = lineIndex == firstLine ? item.start.characterIndex : 0; + const size_t lastCharacter = + item.end.blockIndex == item.start.blockIndex && lineIndex == lastLine + ? qMin(item.end.characterIndex, characters.size() - 1) + : characters.size() - 1; + + QRectF lineBounds; + bool any = false; + for (size_t characterIndex = firstCharacter; characterIndex <= lastCharacter && characterIndex < characters.size(); ++characterIndex) + { + const QRectF bounds = characters[characterIndex].boundingBox.boundingRect(); + if (bounds.isEmpty()) + { + continue; + } + + lineBounds = any ? lineBounds.united(bounds) : bounds; + any = true; + } + + if (any) + { + geometry->push_back(lineBounds); + } + } +} + +} // namespace + +PDFDocumentTextSelectionSource::PDFDocumentTextSelectionSource(pdf::PDFDocumentContext* context) : + m_context(context) +{ +} + +void PDFDocumentTextSelectionSource::dropLayoutCacheIfStale() const +{ + const pdf::PDFRevisionIdentity revision = m_context ? m_context->getRevision() : pdf::PDFRevisionIdentity(); + if (revision != m_layoutRevision) + { + m_layouts.clear(); + m_layoutRevision = revision; + } +} + +pdf::PDFTextLayout* PDFDocumentTextSelectionSource::mutableTextLayout(int pageIndex, QString* unavailableReason) const +{ + const auto unavailable = [unavailableReason](const QString& reason) -> pdf::PDFTextLayout* + { + if (unavailableReason) + { + *unavailableReason = reason; + } + return nullptr; + }; + + pdf::PDFDocumentContext* context = m_context; + if (!context) + { + return unavailable(tr("No document is open.")); + } + + pdf::PDFDocumentSession* session = context->getSession(); + const pdf::PDFDocument* document = context->getDocument(); + if (!session || !document) + { + return unavailable(tr("No document is open.")); + } + + dropLayoutCacheIfStale(); + + const auto cached = m_layouts.find(pageIndex); + if (cached != m_layouts.end()) + { + return &cached.value(); + } + + const pdf::PDFCatalog* catalog = document->getCatalog(); + const pdf::PDFPage* page = catalog && pageIndex >= 0 ? catalog->getPage(static_cast(pageIndex)) : nullptr; + if (!page) + { + return unavailable(tr("The page does not exist.")); + } + + const pdf::PDFRenderer::Features features = pdf::PDFRenderer::IgnoreOptionalContent; + const pdf::PDFMeshQualitySettings meshQuality; + pdf::PDFProcessingBudget budget(session->getProcessingLimits()); + try + { + pdf::PDFTextLayoutGenerator generator(features, page, document, + session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), QTransform(), meshQuality, + &budget); + generator.processContents(); + m_layouts.insert(pageIndex, generator.createTextLayout()); + } + catch (const pdf::PDFBudgetExceededException& exception) + { + return unavailable(exception.getMessage()); + } + + pdf::PDFTextLayout& layout = m_layouts[pageIndex]; + if (layout.getTextBlocks().empty()) + { + return unavailable(tr("This page has no text layer.")); + } + + return &layout; +} + +int PDFDocumentTextSelectionSource::lineOrdinal(const pdf::PDFTextLayout& layout, size_t blockIndex, size_t lineIndex) const +{ + int ordinal = 0; + const pdf::PDFTextBlocks& blocks = layout.getTextBlocks(); + for (size_t block = 0; block < blocks.size(); ++block) + { + const pdf::PDFTextLines& lines = blocks[block].getLines(); + for (size_t line = 0; line < lines.size(); ++line, ++ordinal) + { + if (block == blockIndex && line == lineIndex) + { + return ordinal; + } + } + } + + return -1; +} + +TextSelectionResult PDFDocumentTextSelectionSource::resultFrom(const pdf::PDFTextLayout& layout, + const pdf::PDFTextSelection& selection, + int pageIndex) const +{ + TextSelectionResult result; + result.available = true; + result.text = layout.getTextFromSelection(selection, pageIndex); + + for (auto item = selection.begin(pageIndex); item != selection.end(pageIndex); ++item) + { + if (item->start.pageIndex != pageIndex) + { + continue; + } + + appendItemGeometry(layout, *item, &result.geometry); + + const int firstOrdinal = lineOrdinal(layout, item->start.blockIndex, item->start.lineIndex); + const int lastOrdinal = lineOrdinal(layout, item->end.blockIndex, item->end.lineIndex); + if (firstOrdinal >= 0) + { + result.firstLine = result.firstLine < 0 ? firstOrdinal : qMin(result.firstLine, firstOrdinal); + } + if (lastOrdinal >= 0) + { + result.lastLine = qMax(result.lastLine, lastOrdinal); + } + } + + return result; +} + +bool PDFDocumentTextSelectionSource::hasTextLayer(int pageIndex) const +{ + return mutableTextLayout(pageIndex, nullptr) != nullptr; +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + const pdf::PDFTextSelection selection = + layout->createTextSelection(pageIndex, startPagePoint, endPagePoint, SelectionScratchColor, false); + return resultFrom(*layout, selection, pageIndex); +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectPage(int pageIndex) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + // Every block, in the layout's own reading order, as one selection. A + // range selection over the media box would instead depend on where the box + // corners fall relative to the text, which is not the same question. + pdf::PDFTextSelection selection; + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + const pdf::PDFTextSelection blockSelection = layout->selectBlock(blockIndex, pageIndex, SelectionScratchColor); + pdf::PDFTextSelectionItems items; + for (auto item = blockSelection.begin(pageIndex); item != blockSelection.end(pageIndex); ++item) + { + items.push_back(pdf::PDFTextSelectionItem(item->start, item->end)); + } + selection.addItems(items, SelectionScratchColor); + } + selection.build(); + + return resultFrom(*layout, selection, pageIndex); +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectBlockAt(int pageIndex, QPointF pagePoint) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + if (!blocks[blockIndex].getBoundingBox().contains(pagePoint)) + { + continue; + } + + // The first block containing the point wins; a block may be a table or + // a paragraph, and the text layer is what tells them apart. + const pdf::PDFTextSelection selection = layout->selectBlock(blockIndex, pageIndex, SelectionScratchColor); + return resultFrom(*layout, selection, pageIndex); + } + + // No block under the point: an available page with nothing selected. + TextSelectionResult result; + result.available = true; + return result; +} + +int PDFDocumentTextSelectionSource::lineCount(int pageIndex) const +{ + QString unavailableReason; + const pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + return -1; + } + + int count = 0; + for (const pdf::PDFTextBlock& block : layout->getTextBlocks()) + { + count += static_cast(block.getLines().size()); + } + return count; +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectLine(int pageIndex, int lineIndex) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + int ordinal = 0; + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + const pdf::PDFTextLines& lines = blocks[blockIndex].getLines(); + for (size_t line = 0; line < lines.size(); ++line, ++ordinal) + { + if (ordinal != lineIndex) + { + continue; + } + + const pdf::PDFTextSelection selection = layout->selectLineInBlock(blockIndex, line, pageIndex, SelectionScratchColor); + TextSelectionResult result = resultFrom(*layout, selection, pageIndex); + result.firstLine = lineIndex; + result.lastLine = lineIndex; + return result; + } + } + + TextSelectionResult result; + result.available = true; + return result; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h new file mode 100644 index 000000000..8810e23d3 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h @@ -0,0 +1,84 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFDOCUMENTTEXTSELECTIONSOURCE_H +#define PDFDOCUMENTTEXTSELECTIONSOURCE_H + +#include "interactionglobal.h" +#include "textselectionsource.h" + +#include "pdfdocumentcontext.h" +#include "pdftextlayout.h" + +#include +#include + +namespace pdfinteraction +{ + +/// Answers text selection questions from the document the context holds. +/// +/// Extraction is Core's: the same PDFTextLayoutGenerator and text-flow +/// machinery pdf::searchDocumentText uses, so a copied selection is the text +/// layer the document search already agreed on, including the docstrum reading +/// order. Layouts are cached per page and dropped when the context's revision +/// moves, because a layout describes one revision and nothing else. +/// +/// The context is observed through a QPointer, so a destroyed context degrades +/// to "no document" rather than a dangling read -- the same rule +/// PDFDocumentContextSource and PDFDocumentPageGeometrySource follow. +class PDFDocumentTextSelectionSource final : public ITextSelectionSource +{ + Q_DECLARE_TR_FUNCTIONS(pdfinteraction::PDFDocumentTextSelectionSource) + +public: + explicit PDFDocumentTextSelectionSource(pdf::PDFDocumentContext* context); + + bool hasTextLayer(int pageIndex) const override; + TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override; + TextSelectionResult selectPage(int pageIndex) override; + TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override; + int lineCount(int pageIndex) const override; + TextSelectionResult selectLine(int pageIndex, int lineIndex) override; + +private: + /// The page's layout, built on first use and cached for the current + /// revision. Returns nullptr with `unavailableReason` set when the page has + /// no text layer or the document cannot be read. + pdf::PDFTextLayout* mutableTextLayout(int pageIndex, QString* unavailableReason) const; + + /// One result from one selection, with page-space geometry per line span. + TextSelectionResult resultFrom(const pdf::PDFTextLayout& layout, const pdf::PDFTextSelection& selection, int pageIndex) const; + + /// Reading-order ordinal of (blockIndex, lineIndex), or -1. + int lineOrdinal(const pdf::PDFTextLayout& layout, size_t blockIndex, size_t lineIndex) const; + + void dropLayoutCacheIfStale() const; + + QPointer m_context; + mutable pdf::PDFRevisionIdentity m_layoutRevision; + mutable QHash m_layouts; +}; + +} // namespace pdfinteraction + +#endif // PDFDOCUMENTTEXTSELECTIONSOURCE_H diff --git a/LoopLibInteraction/sources/textselectionsource.h b/LoopLibInteraction/sources/textselectionsource.h new file mode 100644 index 000000000..d98092b3d --- /dev/null +++ b/LoopLibInteraction/sources/textselectionsource.h @@ -0,0 +1,88 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef TEXTSELECTIONSOURCE_H +#define TEXTSELECTIONSOURCE_H + +#include "interactionglobal.h" + +#include +#include +#include +#include + +namespace pdfinteraction +{ + +/// What one selection request produced. `available == false` means the page has +/// no text layer to select from; it is a reported state, never an empty string +/// presented as success. +struct TextSelectionResult +{ + bool available = false; + QString unavailableReason; + QString text; + + /// Page-space rectangles of the selected characters, one per line span. + QList geometry; + + /// Reading-order line range of the result, or -1 when it does not address + /// lines. + int firstLine = -1; + int lastLine = -1; +}; + +/// Text extraction for the interaction layer (issue #232). +/// +/// The seam exists because a text selection needs the document's text layer and +/// the interaction layer holds no document. An implementation answers in page +/// space and in Core's own reading order; the interaction layer re-implements +/// no extraction and invents no order of its own. +class ITextSelectionSource +{ +public: + virtual ~ITextSelectionSource() = default; + + /// Whether the page has a text layer at all. + virtual bool hasTextLayer(int pageIndex) const = 0; + + /// Text between two page-space points, in reading order. + virtual TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) = 0; + + /// The page's whole text layer, in reading order. + virtual TextSelectionResult selectPage(int pageIndex) = 0; + + /// The one text block containing a page-space point -- a table or a + /// paragraph to the text layer. + virtual TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) = 0; + + /// Lines the page's text layer has in reading order, or -1 when the page + /// has no text layer. + virtual int lineCount(int pageIndex) const = 0; + + /// One line by reading-order ordinal. + virtual TextSelectionResult selectLine(int pageIndex, int lineIndex) = 0; +}; + +} // namespace pdfinteraction + +#endif // TEXTSELECTIONSOURCE_H diff --git a/LoopLibInteraction/sources/viewportcontroller.cpp b/LoopLibInteraction/sources/viewportcontroller.cpp index 523cf4daa..c951244ef 100644 --- a/LoopLibInteraction/sources/viewportcontroller.cpp +++ b/LoopLibInteraction/sources/viewportcontroller.cpp @@ -28,6 +28,7 @@ #include #include +#include namespace pdfinteraction { @@ -126,6 +127,33 @@ QTransform PDFDocumentPageGeometrySource::pagePointToDeviceMatrix(int pageIndex, return pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, extraRotation); } +bool PDFDocumentPageGeometrySource::pagePointToMM(int pageIndex, qreal* factor) const +{ + const pdf::PDFDocument* document = this->document(); + if (!document || !factor || pageIndex < 0 || pageIndex >= pageCount()) + { + return false; + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + return false; + } + + // /UserUnit is a multiplier of 1/72 inch (ISO 32000-2). A missing or + // nonsensical value is refused rather than coerced to 1.0, because a + // measurement at a guessed scale is a wrong number presented as a fact. + const pdf::PDFReal userUnit = page->getUserUnit(); + if (!(userUnit > 0.0) || !std::isfinite(userUnit)) + { + return false; + } + + *factor = pdf::PDF_POINT_TO_MM * userUnit; + return true; +} + ViewportController::ViewportController(QObject* parent) : QObject(parent) { diff --git a/LoopLibInteraction/sources/viewportcontroller.h b/LoopLibInteraction/sources/viewportcontroller.h index e30c26116..f11d0db5a 100644 --- a/LoopLibInteraction/sources/viewportcontroller.h +++ b/LoopLibInteraction/sources/viewportcontroller.h @@ -25,6 +25,7 @@ #include "interactionglobal.h" +#include "pageunitsource.h" #include "pdfdocumentcontext.h" #include "pdfpage.h" @@ -89,7 +90,11 @@ class IPageGeometrySource /// The context is observed through a QPointer, so a destroyed context degrades to /// an empty document rather than a dangling read -- the same rule /// PDFDocumentContextSource follows. -class PDFDocumentPageGeometrySource final : public IPageGeometrySource +/// +/// It also answers the page's user-space scale (IPageUnitSource), because that +/// is the same page-facts question: the viewport's caller reads the page's +/// /UserUnit for measurement without a second document-backed adapter. +class PDFDocumentPageGeometrySource final : public IPageGeometrySource, public IPageUnitSource { public: explicit PDFDocumentPageGeometrySource(pdf::PDFDocumentContext* context); @@ -98,6 +103,8 @@ class PDFDocumentPageGeometrySource final : public IPageGeometrySource QSizeF pageSizeMM(int pageIndex, pdf::PageRotation extraRotation) const override; QTransform pagePointToDeviceMatrix(int pageIndex, const QRectF& deviceRect, pdf::PageRotation extraRotation) const override; + bool pagePointToMM(int pageIndex, qreal* factor) const override; + private: const pdf::PDFDocument* document() const; diff --git a/UnitTests/tst_documentviewsessiontest.cpp b/UnitTests/tst_documentviewsessiontest.cpp index 6b0c427e9..f15d3c343 100644 --- a/UnitTests/tst_documentviewsessiontest.cpp +++ b/UnitTests/tst_documentviewsessiontest.cpp @@ -7,11 +7,155 @@ #include "pdfdocumentsession.h" #include "pdfpagecachebudget.h" +#include "interactioncontroller.h" #include "overlaybuilder.h" +#include "pdfdocumentimageextractor.h" +#include "pdfexception.h" #include "pdfrenderer.h" #include +namespace +{ + +/// A page whose /UserUnit is 2 and whose own /Rotate is 90. Both are the +/// fixture facts issue #238 asks a measurement to respect; the page is built by +/// hand so the test needs no golden file. +pdf::PDFDocument makeRotatedUserUnitDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 400, 200)); + builder.setPageRotation(page, pdf::PageRotation::Rotate90); + builder.setPageUserUnit(page, 2.0); + return builder.build(); +} + +/// A page carrying one 2x2 RGB image XObject and no text at all: the fixture +/// for "a page with no text layer" and for image extraction. +pdf::PDFDocument makeImageOnlyDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 200, 200)); + + QByteArray imageData; + for (int pixel = 0; pixel < 4; ++pixel) + { + imageData.append('\xff'); + imageData.append('\0'); + imageData.append('\0'); + } + + pdf::PDFDictionary imageDictionary; + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("XObject")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Image")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Width"), pdf::PDFObject::createInteger(2)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Height"), pdf::PDFObject::createInteger(2)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("ColorSpace"), pdf::PDFObject::createName("DeviceRGB")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("BitsPerComponent"), pdf::PDFObject::createInteger(8)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), pdf::PDFObject::createInteger(imageData.size())); + pdf::PDFStream imageStream(std::move(imageDictionary), std::move(imageData)); + const pdf::PDFObjectReference imageReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(imageStream))); + + const QByteArray content = "q 40 0 0 40 80 80 cm /Im0 Do Q"; + + pdf::PDFDictionary contentDictionary; + contentDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), + pdf::PDFObject::createInteger(content.size())); + pdf::PDFStream contentStream(std::move(contentDictionary), QByteArray(content)); + const pdf::PDFObjectReference contentReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(contentStream))); + + pdf::PDFDictionary xObjects; + xObjects.addEntry(pdf::PDFInplaceOrMemoryString("Im0"), pdf::PDFObject::createReference(imageReference)); + pdf::PDFDictionary resources; + resources.addEntry(pdf::PDFInplaceOrMemoryString("XObject"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(xObjects)))); + + pdf::PDFDictionary pageUpdate; + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Resources"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(resources)))); + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(contentReference)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(pageUpdate)))); + + return builder.build(); +} + +/// A two-column text page with no painter and no GUI: four text-showing +/// operators in a hand-built content stream. The columns are far apart, so a +/// row-major order would interleave them and a reading order would not. +pdf::PDFDocument makeTwoColumnTextDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 400, 300)); + + pdf::PDFDictionary fontDictionary; + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("Font")); + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Type1")); + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("BaseFont"), pdf::PDFObject::createName("Helvetica")); + const pdf::PDFObjectReference font = + builder.addObject(pdf::PDFObject::createDictionary(std::make_shared(std::move(fontDictionary)))); + + QByteArray content; + const auto appendLine = [&content](const char* text, int x, int y) + { + content += "BT /F1 12 Tf 1 0 0 1 " + QByteArray::number(x) + " " + QByteArray::number(y) + " Tm (" + text + ") Tj ET\n"; + }; + appendLine("LEFT ONE", 40, 240); + appendLine("LEFT TWO", 40, 220); + appendLine("RIGHT ONE", 240, 240); + appendLine("RIGHT TWO", 240, 220); + + pdf::PDFDictionary contentDictionary; + contentDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), + pdf::PDFObject::createInteger(content.size())); + pdf::PDFStream contentStream(std::move(contentDictionary), std::move(content)); + const pdf::PDFObjectReference contentReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(contentStream))); + + pdf::PDFDictionary fonts; + fonts.addEntry(pdf::PDFInplaceOrMemoryString("F1"), pdf::PDFObject::createReference(font)); + pdf::PDFDictionary resources; + resources.addEntry(pdf::PDFInplaceOrMemoryString("Font"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(fonts)))); + + pdf::PDFDictionary pageUpdate; + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Resources"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(resources)))); + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(contentReference)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(pageUpdate)))); + + return builder.build(); +} + +pdfinteraction::PointerIntent makePointer(pdfinteraction::PointerAction action, + QPoint positionPx, + quint64 sequence, + Qt::MouseButton button = Qt::NoButton, + Qt::MouseButtons buttons = Qt::NoButton) +{ + pdfinteraction::PointerIntent intent; + intent.stamp.sequence = sequence; + intent.stamp.monotonicNs = qint64(sequence) * 1000000; + intent.action = action; + intent.positionPx = positionPx; + intent.button = button; + intent.buttons = buttons; + return intent; +} + +/// A viewport large enough for one page at the injected density, so a test +/// never depends on the host monitor. +void configureViewport(pdfinteraction::ViewportController& viewport) +{ + viewport.setPixelPerMM(4.0); + viewport.setDevicePixelRatio(1.0); + viewport.setViewportSizePx(QSize(1000, 1000)); + viewport.setZoom(1.0); +} + +} // namespace + class DocumentViewSessionTest final : public QObject { Q_OBJECT @@ -20,6 +164,11 @@ private slots: void defaultCacheLimitPartitions(); void cacheLimitNormalizesAndReappliesAfterDocumentReplacement(); void denyExtraGraphicsSyncsFromRenderFeatures(); + void measurementRespectsUserUnitAndPageRotation(); + void copiedTextFollowsReadingOrderOnAMultiColumnPage(); + void selectionOnAPageWithNoTextLayerReportsThatState(); + void revisionChangeDropsMeasurementsAndTextSelection(); + void imageExtractionReportsThePagesImages(); }; void DocumentViewSessionTest::defaultCacheLimitPartitions() @@ -83,6 +232,184 @@ void DocumentViewSessionTest::denyExtraGraphicsSyncsFromRenderFeatures() QVERIFY(!session.overlays()->denyExtraGraphics()); } +void DocumentViewSessionTest::measurementRespectsUserUnitAndPageRotation() +{ + pdf::PDFDocument document = makeRotatedUserUnitDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + interaction->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPointF startPagePoint(50.0, 50.0); + const QPointF endPagePoint(150.0, 150.0); + + const QPoint fromPx = session.viewport().pagePointToViewportMatrix(0).map(startPagePoint).toPoint(); + const QPoint toPx = session.viewport().pagePointToViewportMatrix(0).map(endPagePoint).toPoint(); + + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromPx, 1, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toPx, 2, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toPx, 3, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 1); + const pdfinteraction::Measurement measurement = interaction->state().measurements().constFirst(); + + // The reference is the retired dimensions plugin's own formula -- the + // page-space length times /UserUnit, converted to millimetres -- within the + // tolerance the pointer round trip through integer pixels costs. + const qreal referenceMM = std::hypot(100.0, 100.0) * pdf::PDF_POINT_TO_MM * 2.0; + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= 1.0); + QCOMPARE(measurement.pointToMM, pdf::PDF_POINT_TO_MM * 2.0); + + // The same page-space segment under the opposite viewer rotation measures + // the same: a page-space segment is rotation invariant, and the controller + // resolves the points through the viewport's own transforms. + session.viewport().setRotation(pdf::PageRotation::Rotate270); + const QPoint fromRotated = session.viewport().pagePointToViewportMatrix(0).map(startPagePoint).toPoint(); + const QPoint toRotated = session.viewport().pagePointToViewportMatrix(0).map(endPagePoint).toPoint(); + + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromRotated, 4, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toRotated, 5, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toRotated, 6, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 2); + const pdfinteraction::Measurement rotated = interaction->state().measurements().constLast(); + QVERIFY(qAbs(rotated.distanceMM - measurement.distanceMM) <= 1.0); +} + +void DocumentViewSessionTest::copiedTextFollowsReadingOrderOnAMultiColumnPage() +{ + pdf::PDFDocument document = makeTwoColumnTextDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + QVERIFY(interaction->selectAllText()); + + const std::optional& selection = interaction->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QVERIFY(selection->isValid()); + QVERIFY(!selection->geometry.isEmpty()); + + // Column-major reading order, as Core's text layer orders the blocks: every + // line of the left column first, then the right column's. A row-major order + // would interleave them ("LEFT ONE", "RIGHT ONE", "LEFT TWO", ...). Each + // line is its own block in this fixture, and the whole-page selection joins + // the blocks' texts with a newline. + QCOMPARE(selection->text, QStringLiteral("LEFT ONE\nLEFT TWO\nRIGHT ONE\nRIGHT TWO")); + + const pdfinteraction::TextCopyResult copy = interaction->selectedText(); + QVERIFY(copy.copied); + QCOMPARE(copy.text, selection->text); + + // Stepping by line walks the same order: a whole-page selection already + // ends on the last line, so stepping forward is refused, and the first line + // is reachable by deselecting and stepping forward again. + QVERIFY(!interaction->stepTextSelectionByLine(1)); + QVERIFY(interaction->deselectText()); + QVERIFY(interaction->stepTextSelectionByLine(1)); + QCOMPARE(interaction->state().textSelection()->text, QStringLiteral("LEFT ONE")); + QVERIFY(interaction->stepTextSelectionByLine(1)); + QCOMPARE(interaction->state().textSelection()->text, QStringLiteral("LEFT TWO")); +} + +void DocumentViewSessionTest::selectionOnAPageWithNoTextLayerReportsThatState() +{ + pdf::PDFDocument document = makeImageOnlyDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + // The attempt stores a typed selection that says why there is nothing. + QVERIFY(interaction->selectAllText()); + + const std::optional& selection = interaction->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + QVERIFY(selection->text.isEmpty()); + + // And Copy reports that state instead of copying an empty string. + const pdfinteraction::TextCopyResult copy = interaction->selectedText(); + QVERIFY(!copy.copied); + QCOMPARE(copy.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(copy.text.isEmpty()); +} + +void DocumentViewSessionTest::revisionChangeDropsMeasurementsAndTextSelection() +{ + pdf::PDFDocument first = makeRotatedUserUnitDocument(); + pdf::PDFDocument second = makeTwoColumnTextDocument(); + + DocumentViewSession session; + session.context().setDocument(&first); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + interaction->setActiveTool(pdfinteraction::InteractionTool::Measure); + const QPoint fromPx = session.viewport().pagePointToViewportMatrix(0).map(QPointF(50.0, 50.0)).toPoint(); + const QPoint toPx = session.viewport().pagePointToViewportMatrix(0).map(QPointF(150.0, 150.0)).toPoint(); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromPx, 1, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toPx, 2, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toPx, 3, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 1); + + // A revision change, delivered the way the shell delivers one: the context + // replaces the document and the session's own wiring drops both derived + // facts. + session.context().setDocument(&second); + + QVERIFY(interaction->state().measurements().isEmpty()); + QVERIFY(!interaction->state().measurementDraft().has_value()); + QVERIFY(!interaction->state().textSelection().has_value()); +} + +void DocumentViewSessionTest::imageExtractionReportsThePagesImages() +{ + pdf::PDFDocument document = makeImageOnlyDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + + pdfinteraction::PDFDocumentImageExtractor* extractor = session.imageExtractor(); + QVERIFY(extractor != nullptr); + + QString typedError; + const QList images = extractor->extractPage(0, &typedError); + + QVERIFY(typedError.isEmpty()); + QCOMPARE(images.size(), 1); + QCOMPARE(images.constFirst().pageIndex, 0); + QCOMPARE(images.constFirst().order, 0); + QCOMPARE(images.constFirst().image.size(), QSize(2, 2)); + QVERIFY(!images.constFirst().image.isNull()); + + // A page index that does not exist is a typed refusal, not an empty success. + const QList missing = extractor->extractPage(7, &typedError); + QVERIFY(missing.isEmpty()); + QVERIFY(!typedError.isEmpty()); +} + QTEST_GUILESS_MAIN(DocumentViewSessionTest) #include "tst_documentviewsessiontest.moc" diff --git a/UnitTests/tst_interactioncontrollertest.cpp b/UnitTests/tst_interactioncontrollertest.cpp index edef4dcbf..869d13c86 100644 --- a/UnitTests/tst_interactioncontrollertest.cpp +++ b/UnitTests/tst_interactioncontrollertest.cpp @@ -36,6 +36,7 @@ #include #include +#include #include #include "hittestsource.h" @@ -124,6 +125,130 @@ class FakeRevisionSource final : public pdfinteraction::IDocumentRevisionSource pdf::PDFRevisionIdentity revision = makeRevision(QStringLiteral("doc-1")); }; +/// The page's own /UserUnit scale, injected so a measurement test does not need +/// a PDF. The default is the un-scaled point-to-millimetre factor. +class FakePageUnitSource final : public pdfinteraction::IPageUnitSource +{ +public: + bool pagePointToMM(int pageIndex, qreal* factor) const override + { + if (!factor || pageIndex < 0 || pageIndex >= pageCount) + { + return false; + } + + *factor = pointToMM; + return true; + } + + int pageCount = 3; + qreal pointToMM = pdf::PDF_POINT_TO_MM; +}; + +/// Scripted text extraction, so the controller's selection, stepping and copy +/// behavior is provable without a document. The Core-backed source is exercised +/// separately against a real document in UnitTestsDocumentViewSession. +class ScriptedTextSelectionSource final : public pdfinteraction::ITextSelectionSource +{ +public: + bool hasTextLayer(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available; + } + + pdfinteraction::TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override + { + Q_UNUSED(pageIndex); + lastRangeStart = startPagePoint; + lastRangeEnd = endPagePoint; + + pdfinteraction::TextSelectionResult result = baseResult(); + if (!result.available) + { + return result; + } + + result.text = rangeText; + result.geometry = { QRectF(startPagePoint, endPagePoint).normalized() }; + result.firstLine = 0; + result.lastLine = 1; + return result; + } + + pdfinteraction::TextSelectionResult selectPage(int pageIndex) override + { + Q_UNUSED(pageIndex); + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available) + { + result.text = pageText; + result.geometry = { QRectF(0.0, 0.0, 50.0, 50.0) }; + result.firstLine = 0; + result.lastLine = lineTexts.size() - 1; + } + return result; + } + + pdfinteraction::TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override + { + Q_UNUSED(pageIndex); + lastBlockPoint = pagePoint; + + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available) + { + result.text = blockText; + result.geometry = { QRectF(10.0, 10.0, 20.0, 20.0) }; + result.firstLine = 0; + result.lastLine = 0; + } + return result; + } + + int lineCount(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available ? lineTexts.size() : -1; + } + + pdfinteraction::TextSelectionResult selectLine(int pageIndex, int lineIndex) override + { + Q_UNUSED(pageIndex); + + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available && lineIndex >= 0 && lineIndex < lineTexts.size()) + { + result.text = lineTexts.at(lineIndex); + result.geometry = { QRectF(0.0, qreal(lineIndex) * 10.0, 50.0, 8.0) }; + result.firstLine = lineIndex; + result.lastLine = lineIndex; + } + return result; + } + + bool available = true; + QString rangeText = QStringLiteral("selected text"); + QString pageText = QStringLiteral("whole page"); + QString blockText = QStringLiteral("block text"); + QStringList lineTexts = { QStringLiteral("line one"), QStringLiteral("line two") }; + QPointF lastRangeStart; + QPointF lastRangeEnd; + QPointF lastBlockPoint; + +private: + pdfinteraction::TextSelectionResult baseResult() const + { + pdfinteraction::TextSelectionResult result; + result.available = available; + if (!available) + { + result.unavailableReason = QStringLiteral("This page has no text layer."); + } + return result; + } +}; + /// Returns targets a test wrote down, so hit-test precedence is provable without /// a corpus. EvidenceHitTestSource is exercised separately against a real /// pdf::PDFEvidenceGraph. @@ -228,6 +353,19 @@ private Q_SLOTS: void traceReplayReproducesState(); void traceCarriesNoDocumentPayload(); + void measureToolMeasuresInProductionUnits(); + void measureToolReportsNoDragSessionOrTargetSelection(); + void measureToolClickWithoutTravelRecordsNothing(); + void measureToolRefusesWithoutAPageScale(); + void revisionChangeClearsMeasurementsAndTextSelection(); + void textToolStoresTypedSelectionFromTheSource(); + void textToolClickClearsTheSelection(); + void copyReturnsSelectedTextAndNeverAnEmptyString(); + void copyRefusesOnAPageWithNoTextLayer(); + void selectAllAndDeselectText(); + void steppingByLineWalksTheTextLayer(); + void tableSelectionUsesTheBlockUnderThePointer(); + private: void setViewport(); pdfinteraction::InteractionTarget objectTarget() const; @@ -239,6 +377,8 @@ private Q_SLOTS: std::unique_ptr m_dispatcher; std::unique_ptr m_overlays; std::unique_ptr m_controller; + std::unique_ptr m_units; + std::unique_ptr m_textSource; }; void InteractionControllerTest::init() @@ -262,11 +402,18 @@ void InteractionControllerTest::init() m_overlays = std::make_unique(*m_viewport); m_controller = std::make_unique(*m_revisions, *m_viewport, *m_dispatcher, *m_overlays); + + m_units = std::make_unique(); + m_textSource = std::make_unique(); + m_controller->setPageUnitSource(m_units.get()); + m_controller->setTextSelectionSource(m_textSource.get()); } void InteractionControllerTest::cleanup() { m_controller.reset(); + m_textSource.reset(); + m_units.reset(); m_overlays.reset(); m_dispatcher.reset(); m_targets.reset(); @@ -280,12 +427,16 @@ pdfinteraction::InteractionTarget InteractionControllerTest::objectTarget() cons return makeTarget(pdfinteraction::InteractionTargetKind::Finding, QStringLiteral("finding-a"), QRectF(20.0, 20.0, 20.0, 20.0)); } -/// Viewport pixel for a page-space point on page 0, through the same matrix the -/// page surfaces use. Computing it any other way would test the test's -/// arithmetic rather than the controller's. +/// A viewport pixel for a page-space point on page 0, through the same matrix +/// the page surfaces use. +static QPoint viewportPointFor(const pdfinteraction::ViewportController& viewport, int pageIndex, QPointF pagePoint) +{ + return viewport.pagePointToViewportMatrix(pageIndex).map(pagePoint).toPoint(); +} + static QPoint viewportPointFor(const pdfinteraction::ViewportController& viewport, QPointF pagePoint) { - return viewport.pagePointToViewportMatrix(0).map(pagePoint).toPoint(); + return viewportPointFor(viewport, 0, pagePoint); } void InteractionControllerTest::hoverChangeIsOverlayOnly() @@ -510,12 +661,16 @@ void InteractionControllerTest::toolVocabularyNamesRoundTrip() QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Select)), QStringLiteral("select")); QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Hand)), QStringLiteral("hand")); + QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Text)), QStringLiteral("text")); + QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Measure)), QStringLiteral("measure")); QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("select")).value_or(InteractionTool::Hand), InteractionTool::Select); QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("hand")).value_or(InteractionTool::Select), InteractionTool::Hand); + QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("text")).value_or(InteractionTool::Hand), InteractionTool::Text); + QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("measure")).value_or(InteractionTool::Hand), InteractionTool::Measure); // A name outside the vocabulary is refused, never coerced into a default. - QVERIFY(!pdfinteraction::interactionToolFromName(QStringLiteral("measure")).has_value()); + QVERIFY(!pdfinteraction::interactionToolFromName(QStringLiteral("lasso")).has_value()); QVERIFY(!pdfinteraction::interactionToolFromName(QString()).has_value()); } @@ -1180,6 +1335,333 @@ void InteractionControllerTest::traceCarriesNoDocumentPayload() QVERIFY(!traceJson.contains("text")); } +/// Page-space tolerance for a measurement that made a round trip through +/// integer viewport pixels. Half a page unit at two pixels per unit is a quarter +/// of a pixel, and the /UserUnit 2 fixture doubles it; 0.5 mm is the stated +/// tolerance the acceptance asks for. +constexpr qreal MeasurementToleranceMM = 0.5; + +void InteractionControllerTest::measureToolMeasuresInProductionUnits() +{ + // A /UserUnit 2 page: the same page-space points span twice the physical + // distance, and the readout must say so. + m_units->pointToMM = pdf::PDF_POINT_TO_MM * 2.0; + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + QVERIFY(m_controller->state().measurementDraft().has_value()); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QCOMPARE(measureSpy.size(), 1); + QCOMPARE(m_controller->state().measurements().size(), 1); + + const pdfinteraction::Measurement measurement = m_controller->state().measurements().constFirst(); + const qreal referenceMM = + std::hypot(endPagePoint.x() - startPagePoint.x(), endPagePoint.y() - startPagePoint.y()) * m_units->pointToMM; + + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= MeasurementToleranceMM); + QVERIFY(measurement.isValid()); + QCOMPARE(measurement.fence, m_controller->token()); + QCOMPARE(measurement.pointToMM, m_units->pointToMM); + QCOMPARE(pdfinteraction::measurementReadout(measurement), QStringLiteral("35.3 mm")); + + // The region the two points span is reported in the same units. + QVERIFY(qAbs(measurement.widthMM - 30.0 * m_units->pointToMM) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.heightMM - 40.0 * m_units->pointToMM) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.perimeterMM - 2.0 * (measurement.widthMM + measurement.heightMM)) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.areaMM2 - measurement.widthMM * measurement.heightMM) <= 1.0); +} + +void InteractionControllerTest::measureToolReportsNoDragSessionOrTargetSelection() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + QSignalSpy dragSpy(m_controller.get(), &pdfinteraction::InteractionController::dragCompleted); + QSignalSpy selectionSpy(m_controller.get(), &pdfinteraction::InteractionController::selectionChanged); + const quint64 generationBefore = m_viewport->requestGeneration(); + const pdf::PDFRevisionIdentity revisionBefore = m_revisions->currentRevision(); + + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + // A measurement is presentation state: no DragSession, no target selection, + // no document revision change and no page render demand. + QCOMPARE(dragSpy.size(), 0); + QCOMPARE(selectionSpy.size(), 0); + QVERIFY(!m_controller->state().drag().has_value()); + QVERIFY(!m_controller->state().selected().isValid()); + QCOMPARE(m_revisions->currentRevision(), revisionBefore); + QCOMPARE(m_viewport->requestGeneration(), generationBefore); +} + +void InteractionControllerTest::measureToolClickWithoutTravelRecordsNothing() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPoint point = viewportPointFor(*m_viewport, QPointF(30.0, 30.0)); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, point, 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, point, 2, Qt::LeftButton)); + + // Below the hysteresis threshold: no zero-length measurement is recorded. + QCOMPARE(measureSpy.size(), 0); + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); +} + +void InteractionControllerTest::measureToolRefusesWithoutAPageScale() +{ + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + // A controller with no unit source must refuse the gesture rather than + // measure at a guessed 1.0. + m_controller->setPageUnitSource(nullptr); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); + + // And a source that cannot resolve the page refuses the same way. + m_units->pageCount = 0; + m_controller->setPageUnitSource(m_units.get()); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 6, Qt::LeftButton)); + + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); +} + +void InteractionControllerTest::revisionChangeClearsMeasurementsAndTextSelection() +{ + // A completed measurement... + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(10.0, 10.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(40.0, 50.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(40.0, 50.0)), 3, Qt::LeftButton)); + + // ...and a text selection, both taken against the current revision. + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 6, Qt::LeftButton)); + + QCOMPARE(m_controller->state().measurements().size(), 1); + QVERIFY(m_controller->state().textSelection().has_value()); + + QSignalSpy clearedSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementsCleared); + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + // The document revision moved under both facts. + m_revisions->revision = makeRevision(QStringLiteral("doc-1"), 2); + m_controller->handleRevisionChanged(); + + // Neither is carried onto geometry the operator never saw. + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); + QVERIFY(!m_controller->state().textSelection().has_value()); + QCOMPARE(clearedSpy.size(), 1); + QCOMPARE(textSpy.size(), 1); + + const pdfinteraction::TextCopyResult refused = m_controller->selectedText(); + QVERIFY(!refused.copied); + QCOMPARE(refused.refusalCode, QStringLiteral("text/nothing-selected")); +} + +void InteractionControllerTest::textToolStoresTypedSelectionFromTheSource() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + QSignalSpy targetSpy(m_controller.get(), &pdfinteraction::InteractionController::selectionChanged); + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + const QPointF startPagePoint(20.0, 20.0); + const QPointF endPagePoint(60.0, 60.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + // Announced once, when the gesture completed, not on every pointer delta. + QCOMPARE(textSpy.size(), 1); + + // A text selection never selects a target: text is not an + // InteractionTargetKind. + QCOMPARE(targetSpy.size(), 0); + QVERIFY(!m_controller->state().selected().isValid()); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->isValid()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Text); + QCOMPARE(selection->pageIndex, 0); + QCOMPARE(selection->text, m_textSource->rangeText); + QCOMPARE(selection->firstLine, 0); + QCOMPARE(selection->lastLine, 1); + QVERIFY(!selection->geometry.isEmpty()); + QCOMPARE(selection->fence, m_controller->token()); + + // The source was asked in page space, through the viewport's own transform. + QVERIFY(qAbs(m_textSource->lastRangeStart.x() - startPagePoint.x()) <= 1.0); + QVERIFY(qAbs(m_textSource->lastRangeEnd.x() - endPagePoint.x()) <= 1.0); + QVERIFY(qAbs(m_textSource->lastRangeEnd.y() - endPagePoint.y()) <= 1.0); +} + +void InteractionControllerTest::textToolClickClearsTheSelection() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + const QPointF startPagePoint(20.0, 20.0); + const QPointF endPagePoint(60.0, 60.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QVERIFY(m_controller->state().textSelection().has_value()); + + // A press and release without travel is a click: it clears the selection + // rather than leaving a zero-length one behind. + const QPoint click = viewportPointFor(*m_viewport, QPointF(30.0, 30.0)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, click, 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, click, 5, Qt::LeftButton)); + + QVERIFY(!m_controller->state().textSelection().has_value()); +} + +void InteractionControllerTest::copyReturnsSelectedTextAndNeverAnEmptyString() +{ + // Nothing is selected: the refusal is typed and the text is empty. + const pdfinteraction::TextCopyResult nothing = m_controller->selectedText(); + QVERIFY(!nothing.copied); + QCOMPARE(nothing.refusalCode, QStringLiteral("text/nothing-selected")); + QVERIFY(nothing.text.isEmpty()); + QVERIFY(!nothing.refusalReason.isEmpty()); + + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 3, Qt::LeftButton)); + + const pdfinteraction::TextCopyResult copied = m_controller->selectedText(); + QVERIFY(copied.copied); + QCOMPARE(copied.text, m_textSource->rangeText); + QVERIFY(copied.refusalCode.isEmpty()); + + // A selection that carries no text is refused, never copied as an empty + // success. + m_textSource->rangeText.clear(); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 6, Qt::LeftButton)); + + const pdfinteraction::TextCopyResult empty = m_controller->selectedText(); + QVERIFY(!empty.copied); + QCOMPARE(empty.refusalCode, QStringLiteral("text/nothing-selected")); + QVERIFY(empty.text.isEmpty()); +} + +void InteractionControllerTest::copyRefusesOnAPageWithNoTextLayer() +{ + m_textSource->available = false; + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 3, Qt::LeftButton)); + + // The selection reports the state instead of pretending to be empty. + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + QVERIFY(selection->text.isEmpty()); + + const pdfinteraction::TextCopyResult result = m_controller->selectedText(); + QVERIFY(!result.copied); + QCOMPARE(result.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(result.text.isEmpty()); +} + +void InteractionControllerTest::selectAllAndDeselectText() +{ + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + QVERIFY(m_controller->selectAllText()); + QCOMPARE(textSpy.size(), 1); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Text); + QCOMPARE(selection->text, m_textSource->pageText); + QCOMPARE(selection->firstLine, 0); + QCOMPARE(selection->lastLine, 1); + + QVERIFY(m_controller->deselectText()); + QVERIFY(!m_controller->state().textSelection().has_value()); + QCOMPARE(textSpy.size(), 2); + + // Idempotent: nothing to drop is not a change. + QVERIFY(!m_controller->deselectText()); + QCOMPARE(textSpy.size(), 2); +} + +void InteractionControllerTest::steppingByLineWalksTheTextLayer() +{ + // With no selection, stepping forward selects the first line of the page's + // text layer in reading order. + QVERIFY(m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(0)); + QCOMPARE(m_controller->state().textSelection()->firstLine, 0); + QCOMPARE(m_controller->state().textSelection()->lastLine, 0); + + QVERIFY(m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(1)); + + // Past the last line: refused rather than wrapped onto another page. + QVERIFY(!m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(1)); + + QVERIFY(m_controller->stepTextSelectionByLine(-1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(0)); + QVERIFY(!m_controller->stepTextSelectionByLine(-1)); +} + +void InteractionControllerTest::tableSelectionUsesTheBlockUnderThePointer() +{ + // "Under the pointer" needs a pointer position over the page. + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(30.0, 30.0)), 1)); + + QVERIFY(m_controller->selectTableUnderPointer()); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Block); + QCOMPARE(selection->text, m_textSource->blockText); + QVERIFY(qAbs(m_textSource->lastBlockPoint.x() - 30.0) <= 1.0); + QVERIFY(qAbs(m_textSource->lastBlockPoint.y() - 30.0) <= 1.0); +} + QTEST_GUILESS_MAIN(InteractionControllerTest) #include "tst_interactioncontrollertest.moc" diff --git a/UnitTests/tst_overlayframetest.cpp b/UnitTests/tst_overlayframetest.cpp index d08bb198b..a0e80bb65 100644 --- a/UnitTests/tst_overlayframetest.cpp +++ b/UnitTests/tst_overlayframetest.cpp @@ -141,6 +141,8 @@ private Q_SLOTS: void highMarkerCountStaysBounded(); void invalidTokenYieldsAnEmptyFrame(); void denyExtraGraphicsSuppressesOverlays(); + void measurementPaintsInTheGuidesBandWithItsReadout(); + void textSelectionPaintsInTheSelectionBandWithoutATarget(); private: std::unique_ptr m_geometry; @@ -411,6 +413,75 @@ void OverlayFrameTest::invalidTokenYieldsAnEmptyFrame() QVERIFY(!frame.token.isValid()); } +void OverlayFrameTest::measurementPaintsInTheGuidesBandWithItsReadout() +{ + const pdfinteraction::RevisionFencedToken token = makeToken(); + + QVERIFY(m_state.begin(pdfinteraction::InteractionKind::Measure, token)); + + const pdfinteraction::Measurement draft = + pdfinteraction::measureBetween(0, QPointF(10.0, 10.0), QPointF(40.0, 50.0), pdf::PDF_POINT_TO_MM); + QVERIFY(m_state.setMeasurementDraft(draft, token)); + + pdfinteraction::OverlayFrame frame = m_overlays->build(m_state, token); + QVERIFY(frame.isOrdered()); + QCOMPARE(frame.primitives.size(), 1); + + const pdfinteraction::OverlayPrimitive* primitive = findPrimitive(frame, QStringLiteral("measurement#draft")); + QVERIFY(primitive); + QCOMPARE(primitive->layer, pdfinteraction::OverlayLayer::Guides); + QCOMPARE(primitive->kind, pdfinteraction::OverlayPrimitiveKind::Measurement); + QCOMPARE(primitive->label, pdfinteraction::measurementReadout(draft)); + QCOMPARE(primitive->pagePolygon.size(), 2); + QVERIFY(primitive->renderable); + + // Completing it moves the primitive to its stable completed id. + QVERIFY(m_state.completeMeasurement(token).has_value()); + + frame = m_overlays->build(m_state, token); + QVERIFY(findPrimitive(frame, QStringLiteral("measurement#0"))); + QVERIFY(!findPrimitive(frame, QStringLiteral("measurement#draft"))); + QCOMPARE(frame.primitives.size(), 1); +} + +void OverlayFrameTest::textSelectionPaintsInTheSelectionBandWithoutATarget() +{ + const pdfinteraction::RevisionFencedToken token = makeToken(); + + pdfinteraction::TextSelection selection; + selection.kind = pdfinteraction::TextSelectionKind::Text; + selection.pageIndex = 0; + selection.available = true; + selection.text = QStringLiteral("two lines"); + selection.geometry = { QRectF(10.0, 10.0, 40.0, 8.0), QRectF(10.0, 20.0, 40.0, 8.0) }; + QVERIFY(m_state.setTextSelection(selection, token)); + + const pdfinteraction::OverlayFrame frame = m_overlays->build(m_state, token); + QVERIFY(frame.isOrdered()); + QCOMPARE(frame.primitives.size(), 2); + + const pdfinteraction::OverlayPrimitive* first = findPrimitive(frame, QStringLiteral("text-selection#0")); + const pdfinteraction::OverlayPrimitive* second = findPrimitive(frame, QStringLiteral("text-selection#1")); + QVERIFY(first); + QVERIFY(second); + QCOMPARE(first->layer, pdfinteraction::OverlayLayer::Selection); + QCOMPARE(first->kind, pdfinteraction::OverlayPrimitiveKind::Rectangle); + QVERIFY(first->renderable); + + // There is no text target kind, so a selection rectangle is not a routed + // click target. + QVERIFY(!first->target.isValid()); + + // An unavailable selection -- a page with no text layer -- has no geometry + // to paint. + pdfinteraction::TextSelection unavailable; + unavailable.pageIndex = 0; + unavailable.available = false; + unavailable.unavailableReason = QStringLiteral("This page has no text layer."); + QVERIFY(m_state.setTextSelection(unavailable, token)); + QVERIFY(m_overlays->build(m_state, token).isEmpty()); +} + QTEST_GUILESS_MAIN(OverlayFrameTest) #include "tst_overlayframetest.moc" diff --git a/UnitTests/tst_quickaccessibilitytest.cpp b/UnitTests/tst_quickaccessibilitytest.cpp index 0ab0892c9..1ad85d7e4 100644 --- a/UnitTests/tst_quickaccessibilitytest.cpp +++ b/UnitTests/tst_quickaccessibilitytest.cpp @@ -14,6 +14,7 @@ class QuickAccessibilityTest : public QObject private Q_SLOTS: void canvasAccessibleHasNoChildren(); + void canvasAccessibleCarriesTheInteractionReadout(); void focusRestorationRestoresItem(); }; @@ -38,6 +39,33 @@ void QuickAccessibilityTest::canvasAccessibleHasNoChildren() QVERIFY(!iface->text(QAccessible::Description).isEmpty()); } +void QuickAccessibilityTest::canvasAccessibleCarriesTheInteractionReadout() +{ + pdfquick::installLoopCanvasAccessibility(); + + QQuickWindow window; + window.resize(320, 240); + window.show(); + + pdfquick::LoopCanvasItem item(window.contentItem()); + item.setWidth(320); + item.setHeight(240); + + // The host composes the readout into the same summary the canvas exposes: + // a measurement's production-unit value and a text selection's size are + // read out, not derived by the screen reader's client. + item.setAccessibleDocumentSummary( + QStringLiteral("Document canvas. Page 1 of 1. Zoom 100 percent. Selected 12 character(s). Measurements: 1. Latest: 42.3 mm.")); + + QAccessibleInterface* iface = QAccessible::queryAccessibleInterface(&item); + QVERIFY(iface); + QCOMPARE(iface->role(), QAccessible::Canvas); + + const QString description = iface->text(QAccessible::Description); + QVERIFY(description.contains(QStringLiteral("42.3 mm"))); + QVERIFY(description.contains(QStringLiteral("Selected 12 character(s)."))); +} + void QuickAccessibilityTest::focusRestorationRestoresItem() { FocusRestoration restoration; diff --git a/UnitTests/tst_quickcanvastest.cpp b/UnitTests/tst_quickcanvastest.cpp index 7ff10251a..d1315034b 100644 --- a/UnitTests/tst_quickcanvastest.cpp +++ b/UnitTests/tst_quickcanvastest.cpp @@ -51,6 +51,7 @@ #include #include +#include #include #include "canvaspalette.h" @@ -169,6 +170,89 @@ pdfinteraction::InteractionTarget makeTarget(const QString& id, QRectF bounds) return target; } +/// Scripted text extraction, so the canvas-side selection path is provable +/// without a document. The Core-backed source is exercised against a real +/// document in UnitTestsDocumentViewSession. +class ScriptedTextSelectionSource final : public pdfinteraction::ITextSelectionSource +{ +public: + bool hasTextLayer(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available; + } + + pdfinteraction::TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override + { + Q_UNUSED(pageIndex); + + pdfinteraction::TextSelectionResult result; + result.available = available; + if (!available) + { + result.unavailableReason = QStringLiteral("This page has no text layer."); + return result; + } + + result.text = QStringLiteral("canvas selection"); + result.geometry = { QRectF(startPagePoint, endPagePoint).normalized() }; + result.firstLine = 0; + result.lastLine = 0; + return result; + } + + pdfinteraction::TextSelectionResult selectPage(int pageIndex) override + { + Q_UNUSED(pageIndex); + return selectRange(0, QPointF(), QPointF()); + } + + pdfinteraction::TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override + { + Q_UNUSED(pagePoint); + return selectPage(pageIndex); + } + + int lineCount(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available ? 1 : -1; + } + + pdfinteraction::TextSelectionResult selectLine(int pageIndex, int lineIndex) override + { + Q_UNUSED(lineIndex); + return selectPage(pageIndex); + } + + bool available = true; +}; + +class FakePageUnitSource final : public pdfinteraction::IPageUnitSource +{ +public: + bool pagePointToMM(int pageIndex, qreal* factor) const override + { + if (!factor || pageIndex < 0 || pageIndex >= pageCount) + { + return false; + } + + *factor = pointToMM; + return true; + } + + int pageCount = 2; + qreal pointToMM = pdf::PDF_POINT_TO_MM; +}; + +/// A viewport pixel for a page-space point on page 0, through the same matrix +/// the page surfaces use. +QPointF viewportPointFor(const pdfinteraction::ViewportController& viewport, QPointF pagePoint) +{ + return viewport.pagePointToViewportMatrix(0).map(pagePoint); +} + } // namespace class QuickCanvasTest : public QObject @@ -192,6 +276,10 @@ private Q_SLOTS: void focusLossCancelsTheDrag(); void unboundItemIgnoresInput(); void toolSelectionRoundTrips(); + void textToolDragThroughTheCanvasStoresASelection(); + void measureToolDragThroughTheCanvasReportsProductionUnits(); + void textSelectionOnAPageWithNoTextLayerReportsThatState(); + void canvasCompositionPlacesTilesInDevicePixels(); void admittedTilesReachTheSceneGraph(); void revisionReplacementLeavesNoStaleTile(); @@ -250,6 +338,9 @@ private Q_SLOTS: std::unique_ptr m_source; std::unique_ptr m_item; + std::unique_ptr m_textSource; + std::unique_ptr m_units; + std::unique_ptr m_submitter; std::unique_ptr m_renderer; std::unique_ptr m_surfaces; @@ -278,6 +369,11 @@ void QuickCanvasTest::init() m_controller = std::make_unique(*m_revisions, *m_viewport, *m_hitTest, *m_overlays); + m_textSource = std::make_unique(); + m_units = std::make_unique(); + m_controller->setTextSelectionSource(m_textSource.get()); + m_controller->setPageUnitSource(m_units.get()); + m_item = std::make_unique(); m_item->setSize(QSizeF(400.0, 400.0)); } @@ -285,6 +381,8 @@ void QuickCanvasTest::init() void QuickCanvasTest::cleanup() { m_item.reset(); + m_units.reset(); + m_textSource.reset(); m_window.reset(); m_surfaces.reset(); m_renderer.reset(); @@ -633,6 +731,160 @@ void QuickCanvasTest::toolSelectionRoundTrips() QCOMPARE(m_item->activeTool(), QStringLiteral("hand")); } +// --------------------------------------------------------------------------- +// Issue #232 / #238: the canvas carries the text-selection and measurement +// tools the same way it carries select and hand -- events in, neutral intents +// out, and the typed state read back from the controller. +// --------------------------------------------------------------------------- + +void QuickCanvasTest::textToolDragThroughTheCanvasStoresASelection() +{ + bindItem(); + + QVERIFY(m_item->setActiveTool(QStringLiteral("text"))); + QCOMPARE(m_controller->activeTool(), pdfinteraction::InteractionTool::Text); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->text, QStringLiteral("canvas selection")); + QCOMPARE(selection->pageIndex, 0); + + // The overlay frame carries one rectangle per line span in the Selection + // band, and no page render was asked for. + const pdfinteraction::OverlayFrame& frame = m_controller->overlayFrame(); + QVERIFY(frame.isOrdered()); + int selectionPrimitives = 0; + for (const pdfinteraction::OverlayPrimitive& primitive : frame.primitives) + { + if (primitive.layer == pdfinteraction::OverlayLayer::Selection) + { + ++selectionPrimitives; + } + } + QCOMPARE(selectionPrimitives, selection->geometry.size()); + + // And Copy through the controller returns exactly what was selected. + const pdfinteraction::TextCopyResult copy = m_controller->selectedText(); + QVERIFY(copy.copied); + QCOMPARE(copy.text, QStringLiteral("canvas selection")); +} + +void QuickCanvasTest::measureToolDragThroughTheCanvasReportsProductionUnits() +{ + bindItem(); + + QVERIFY(m_item->setActiveTool(QStringLiteral("measure"))); + QCOMPARE(m_controller->activeTool(), pdfinteraction::InteractionTool::Measure); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + QCOMPARE(measureSpy.size(), 1); + QCOMPARE(m_controller->state().measurements().size(), 1); + + // The measurement is page-space geometry times the page's own scale, and + // the overlay carries the readout. + const pdfinteraction::Measurement measurement = m_controller->state().measurements().constFirst(); + const qreal referenceMM = std::hypot(40.0, 40.0) * m_units->pointToMM; + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= 0.5); + + const pdfinteraction::OverlayFrame& frame = m_controller->overlayFrame(); + const pdfinteraction::OverlayPrimitive* primitive = nullptr; + for (const pdfinteraction::OverlayPrimitive& candidate : frame.primitives) + { + if (candidate.kind == pdfinteraction::OverlayPrimitiveKind::Measurement) + { + primitive = &candidate; + } + } + QVERIFY(primitive); + QCOMPARE(primitive->label, pdfinteraction::measurementReadout(measurement)); + QCOMPARE(primitive->layer, pdfinteraction::OverlayLayer::Guides); + + // No DragSession, no command: a measurement is never a document edit. + QVERIFY(!m_controller->state().drag().has_value()); +} + +void QuickCanvasTest::textSelectionOnAPageWithNoTextLayerReportsThatState() +{ + bindItem(); + + m_textSource->available = false; + QVERIFY(m_item->setActiveTool(QStringLiteral("text"))); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + + // Copy refuses with the typed reason and never an empty success. + const pdfinteraction::TextCopyResult copy = m_controller->selectedText(); + QVERIFY(!copy.copied); + QCOMPARE(copy.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(copy.text.isEmpty()); + + // And there is nothing to paint for an unavailable selection. + for (const pdfinteraction::OverlayPrimitive& primitive : m_controller->overlayFrame().primitives) + { + QVERIFY(primitive.layer != pdfinteraction::OverlayLayer::Selection); + } +} + +void QuickCanvasTest::canvasCompositionPlacesTilesInDevicePixels() +{ + // The screenshot path composes the admitted tiles into one image. Pure + // function, no window and no scene graph: the same call the host makes. + pdfinteraction::CanvasSnapshot snapshot; + snapshot.token = pdfinteraction::RevisionFencedToken(); + + QImage red(20, 20, QImage::Format_ARGB32_Premultiplied); + red.fill(Qt::red); + QImage blue(20, 20, QImage::Format_ARGB32_Premultiplied); + blue.fill(Qt::blue); + + pdfinteraction::CanvasTile first; + first.key.pageIndex = 0; + first.pixels = pdfinteraction::makeSurfaceBuffer(red); + first.placedRect = QRect(0, 0, 20, 20); + snapshot.tiles.push_back(first); + + pdfinteraction::CanvasTile second; + second.key.pageIndex = 1; + second.pixels = pdfinteraction::makeSurfaceBuffer(blue); + second.placedRect = QRect(20, 0, 20, 20); + snapshot.tiles.push_back(second); + + const QImage composed = pdfinteraction::composeCanvasImage(snapshot, QRect(0, 0, 40, 20), 1.0); + QVERIFY(!composed.isNull()); + QCOMPARE(composed.size(), QSize(40, 20)); + QCOMPARE(composed.pixelColor(10, 10), QColor(Qt::red)); + QCOMPARE(composed.pixelColor(30, 10), QColor(Qt::blue)); + + // A region no tile intersects is a refusal, not a blank success. + QVERIFY(pdfinteraction::composeCanvasImage(snapshot, QRect(100, 100, 40, 20), 1.0).isNull()); +} + // --------------------------------------------------------------------------- // P4-S6: scene-graph lifecycle and canvas parity. // diff --git a/docs/INTERACTION_CONTRACT.md b/docs/INTERACTION_CONTRACT.md index 836bb5f40..1d95389fe 100644 --- a/docs/INTERACTION_CONTRACT.md +++ b/docs/INTERACTION_CONTRACT.md @@ -2,7 +2,8 @@ Status: P4-S4 (0.2.0 Phase 4). Types live in `LoopLibInteraction/sources/inputintent.h`, `interactiontarget.h`, `interactionstate.h`, `hittestsource.h`, `overlayframe.h`, -`overlaybuilder.h`, `interactiontrace.h`, and `interactioncontroller.h`. +`overlaybuilder.h`, `interactiontrace.h`, `textselectionsource.h`, `pageunitsource.h`, +and `interactioncontroller.h`. Architecture invariant **I24**; test targets `UnitTestsInteractionController` and `UnitTestsOverlayFrame`. Companion to [PAGE_SURFACE_CONTRACT.md](PAGE_SURFACE_CONTRACT.md), which owns page pixels. @@ -88,6 +89,8 @@ A tool is what a left-button gesture means. The vocabulary is closed and lives i | --- | --- | | `select` | Selects the target under the pointer; a second press on the selection begins a drag. The default, and the behavior every gesture had before the vocabulary existed. | | `hand` | Begins an `InteractionKind::Pan`. Never selects, retargets or starts a transform. | +| `text` | Begins an `InteractionKind::TextSelect` and drags out a text selection (issue #232). The result is the typed `TextSelection` state; it never selects a target and never reaches the document. | +| `measure` | Begins an `InteractionKind::Measure` and drags out a measurement in production units (issue #238). It never becomes a `DragSession` and never reaches the document. | A host parses a tool name into `InteractionTool` once, at its own boundary. A name outside the vocabulary is refused there — `interactionToolFromName()` returns nothing — so no @@ -99,6 +102,36 @@ Changing the tool calls `cancelActive(InteractionCancelReason::ToolChanged)` (is so a tool change mid-drag drops the gesture and commits nothing. Setting the tool that is already active is not a change and cancels nothing. +### Text selection and measurement + +Both are derived state, and both are fenced to the revision they were taken from. +`InteractionState` carries them beside the selection, and `InteractionController` drops them +when the fence moves: `handleRevisionChanged()` cancels the active gesture with +`RevisionChanged` and clears the measurements and the text selection, because carrying +either onto new geometry presents a fact about a document the operator is no longer looking +at. A measurement also never produces a `DragSession`: it has no command and no mutation +path behind it. + +Text extraction is Core's. `ITextSelectionSource` is the seam — the interaction layer asks +for text and re-implements no extraction — and `PDFDocumentTextSelectionSource` answers +from the same `PDFTextLayoutGenerator` and text-flow machinery `pdf::searchDocumentText` +uses, so a copied selection is the text layer the document search already agreed on, +including the docstrum reading order. A page with no text layer answers +`available == false` with a reason; Copy then refuses with a typed code and never copies an +empty string as success. + +A measurement's scale comes from `IPageUnitSource` — millimetres per page-space point, +including the page's `/UserUnit` — so a `/UserUnit 2` page measures twice what its raw user +space says. Rotation needs no correction of its own: the two points are page-space points, +resolved through `ViewportController`'s own transforms, so a measurement taken on a rotated +page spans the same page-space segment as the unrotated one. + +Overlays follow the same value-type rule as everything else. A measurement is an +`OverlayPrimitiveKind::Measurement` in the `Guides` band with its readout in `label`; a text +selection is one `Rectangle` per line span in the `Selection` band and carries no target, +because there is no text target kind and clicking selected text is a new selection rather +than a routed command. + ## Hit testing `IHitTestSource` answers for one domain, one page, one page-space point, in no particular diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index 54af8a381..52213fae3 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -38,7 +38,7 @@ The LoopEditor shell exposes these workspace IDs: The implemented composition is: ```text -toolbar: open · save/export · undo/redo · select/hand · zoom · preflight · preview +toolbar: open · save/export · undo/redo · select/hand/text/measure · zoom · preflight · preview workspace rail (7 IDs) | workspace stack | Document: pages rail | PDF canvas | contextual inspector dock status: document state · production state · preflight state · page/zoom ``` diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index f18322e8b..388e99749 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -247,9 +247,9 @@ "standard_key": "Copy" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, @@ -536,9 +536,9 @@ "standard_key": "Deselect" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -562,9 +562,9 @@ "command": { "label_key": "command.actionExtractImage.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -755,9 +755,9 @@ "standard_key": "MoveToNextLine" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "View" }, @@ -803,9 +803,9 @@ "standard_key": "MoveToPreviousLine" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "View" }, @@ -1316,9 +1316,9 @@ "command": { "label_key": "command.actionScreenshot.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1329,9 +1329,9 @@ "command": { "label_key": "command.actionSelectTable.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1342,9 +1342,9 @@ "command": { "label_key": "command.actionSelectText.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1358,9 +1358,9 @@ "standard_key": "SelectAll" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index 0ca39c964..4e6ef8475 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -16,7 +16,6 @@ "actionColorGrayscale", "actionColorHighContrast", "actionColorInvert", - "actionCopyText", "actionCreateBitonalDocument", "actionCreateEllipse", "actionCreateFreehandCurve", @@ -38,12 +37,8 @@ "actionCreateTextSquiggly", "actionCreateTextStrikeout", "actionCreateTextUnderline", - "actionDeselectText", "actionEncryption", - "actionExtractImage", "actionFirstPageOnRightSide", - "actionGoToNextLine", - "actionGoToPreviousLine", "actionGotoNextBookmark", "actionGotoPreviousBookmark", "actionInlineText", @@ -61,10 +56,6 @@ "actionRenderOptionTextAntialiasing", "actionRender_to_Images", "actionSanitize", - "actionScreenshot", - "actionSelectTable", - "actionSelectText", - "actionSelectTextAll", "actionSend_by_E_Mail", "actionStickyNoteComment", "actionStickyNoteHelp", diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index 25115a0a8..7347d8904 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -84,6 +84,15 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionSelectText", + "actionSelectTextAll", + "actionDeselectText", + "actionSelectTable", + "actionCopyText", + "actionGoToNextLine", + "actionGoToPreviousLine", + "actionScreenshot", + "actionExtractImage", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 25b34b815..5c25e1cd7 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -158,6 +158,32 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: screenshotDialog + title: qsTr("Save Screenshot") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + if (host) { + host.captureCanvasFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FolderDialog { + id: extractImagesDialog + title: qsTr("Extract Images Into Folder") + onAccepted: { + if (host) { + host.extractCurrentPageImagesFileUrl(selectedFolder) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: preflightReportDialog title: qsTr("Export Preflight Report") @@ -228,6 +254,12 @@ ApplicationWindow { profileSaveVersionField.text = host ? host.preflightProfileDraftVersion : "" preflightProfileSaveDialog.open() } + function onScreenshotRequested() { + screenshotDialog.open() + } + function onImageExtractionRequested() { + extractImagesDialog.open() + } } TextField { diff --git a/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml b/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml index bfad8d5ae..b8ca48281 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml @@ -157,6 +157,38 @@ ToolBar { value: !!root.host && root.host.activeTool === "hand" } } + ToolButton { + id: textToolButton + objectName: "textToolButton" + text: qsTr("Text") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("text") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Text selection tool") + ButtonGroup.group: toolGroup + Binding { + target: textToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "text" + } + } + ToolButton { + id: measureToolButton + objectName: "measureToolButton" + text: qsTr("Measure") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("measure") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Measure tool") + ButtonGroup.group: toolGroup + Binding { + target: measureToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "measure" + } + } ToolSeparator {} ToolButton { objectName: "zoomInButton" From bb79912d00ff16b7f66ebd671afad5d4239c3755 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:07 -0700 Subject: [PATCH 14/43] feat(quick): display colour modes and soft-proof separations (#235, #237) Five actionColor* display modes become presentation-only filters applied in CanvasNodeBuilder::syncTiles over admitted surfaces, converted with Core's own PDFColorConvertor, marked as simulations in the fidelity indicator and never reachable as production evidence. Soft proof renders through a local LittleCMS proofing CMS behind its own surface identity (withSoftProofMarker), shows the profile identity, and a missing or unreadable output intent is a visible state. Plates expose Core's separations with visibility and isolation state that never moves the revision, and the ink-coverage readout measures named channels at a page point. --- LoopEditor/documentviewsession.cpp | 5 + LoopEditor/documentviewsession.h | 7 + LoopEditor/editorhost.cpp | 681 +++++++++++++++++- LoopEditor/editorhost.h | 150 +++- LoopEditor/qml/ProductionPreviewPane.qml | 235 +++++- .../sources/pagesurfacecoordinator.cpp | 25 +- .../sources/pagesurfacecoordinator.h | 17 + LoopLibInteraction/sources/pagesurfacekey.cpp | 31 + LoopLibInteraction/sources/pagesurfacekey.h | 19 + .../sources/pagesurfacerenderer.cpp | 128 ++++ LoopLibQuick/CMakeLists.txt | 2 + LoopLibQuick/sources/canvasdisplaycolor.cpp | 143 ++++ LoopLibQuick/sources/canvasdisplaycolor.h | 93 +++ LoopLibQuick/sources/canvasnodebuilder.cpp | 61 +- LoopLibQuick/sources/canvasnodebuilder.h | 20 + LoopLibQuick/sources/loopcanvasitem.cpp | 72 ++ LoopLibQuick/sources/loopcanvasitem.h | 28 + LoopLibQuick/sources/loopcanvasitemscene.cpp | 9 +- LoopLibQuick/sources/loopstatevisual.cpp | 34 +- LoopLibQuick/sources/loopstatevisual.h | 18 +- UnitTests/tst_documentfacadetest.cpp | 4 +- UnitTests/tst_editorhosttest.cpp | 376 +++++++++- UnitTests/tst_loopstatevisualtest.cpp | 35 +- UnitTests/tst_quickcanvastest.cpp | 177 ++++- docs/loop-shell-actions.json | 20 +- docs/shell-handler-coverage.json | 14 +- scripts/verify-command-catalog.py | 5 + .../qml/ProductionPreviewPane.qml | 235 +++++- 28 files changed, 2542 insertions(+), 102 deletions(-) create mode 100644 LoopLibQuick/sources/canvasdisplaycolor.cpp create mode 100644 LoopLibQuick/sources/canvasdisplaycolor.h diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 3a8f0a012..c14cc95fa 100644 --- a/LoopEditor/documentviewsession.cpp +++ b/LoopEditor/documentviewsession.cpp @@ -253,6 +253,11 @@ void DocumentViewSession::setSurfaceRenderFeatures(pdf::PDFRenderer::Features fe m_surfaces->setRenderSettings(settings); } +void DocumentViewSession::setSoftProofProfile(const QString& proofProfileId) +{ + m_surfaces->setSoftProofProfile(proofProfileId); +} + void DocumentViewSession::setCacheLimit(qsizetype totalBytes) { const qsizetype normalized = pdf::PDFPageCacheBudget::total(totalBytes); diff --git a/LoopEditor/documentviewsession.h b/LoopEditor/documentviewsession.h index 5de1d1114..19f544b68 100644 --- a/LoopEditor/documentviewsession.h +++ b/LoopEditor/documentviewsession.h @@ -81,6 +81,13 @@ class DocumentViewSession final : public QObject void clearDocumentView(); void setSurfaceRenderFeatures(pdf::PDFRenderer::Features features); + /// The proofing profile identity in force for the interactive canvas (issue + /// #237). Empty disables proofing. The surfaces re-render as proofs through + /// Core's colour management; the identity joins the surface key, so a proof + /// can never be served from, or mistaken for, the fast or authoritative + /// render of the same page. + void setSoftProofProfile(const QString& proofProfileId); + quint64 passwordRequestId() const; bool answerPassword(quint64 requestId, QString password, bool accepted); diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index e07b1762d..27466fa71 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -54,6 +54,7 @@ #include "pdfsafefilewriter.h" #include "pdfblockingthreadguard.h" +#include "pdfcms.h" #include "pdfpage.h" #include "pdftransparencyrenderer.h" @@ -169,6 +170,15 @@ int rotationToDegrees(pdf::PageRotation rotation) return 0; } +/// Rasterization resolution of the ink-coverage point readout. It samples one +/// point, not a page: the value only has to be fine enough that the pixel under +/// the point is that point, and the job is charged for the pixels it renders. +constexpr int InkCoverageReadoutDpi = 150; + +/// Ceiling on the readout raster's longest edge. A huge page samples the same +/// point at a coarser resolution instead of rasterizing an unbounded image. +constexpr int InkCoverageMaxRasterPixels = 4096; + QString actionListStateToString(pdfinteraction::ActionListController::State state) { switch (state) @@ -418,6 +428,7 @@ EditorHost::EditorHost(QObject* parent) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishInkCoverageJob(snapshot); refreshCanvasTrace(); }); } @@ -1257,10 +1268,28 @@ QString EditorHost::previewFidelityStateName() const { return QStringLiteral("unavailable"); } + // A display simulation outranks the render underneath it: the operator is + // looking at a transformed page, so the indicator may not report the render + // path's own fidelity as if that were what is on screen (issue #235 display + // half, issue #237). return pdfquick::tokens::classifyPreviewFidelityState(hasDocument(), stale, pageFidelityIsAuthoritative(), - pageFidelityIsExact()); + pageFidelityIsExact(), + displaySimulationName()); +} + +QString EditorHost::displaySimulationName() const +{ + if (softProofActive()) + { + return QStringLiteral("soft-proof"); + } + if (m_displayColor.isActive()) + { + return QStringLiteral("display-filter"); + } + return QString(); } QVariantMap EditorHost::previewFidelityVisual() const @@ -1332,6 +1361,20 @@ QString EditorHost::previewFidelitySummary() const "authoritative overprint render before any print claim.") .arg(pageFidelityReason()); } + if (state == QLatin1String("display-filter")) + { + return tr("Display colour simulation (%1): a display colour mode is filtering the canvas, so " + "these pixels are a presentation of the page and not a production render. They are " + "never recorded as approval evidence.") + .arg(displayColorModeName()); + } + if (state == QLatin1String("soft-proof")) + { + return tr("Soft-proof simulation of the output intent through colour management (profile %1). " + "It is a simulation of print, not the print's evidence, and it is never recorded as " + "approval.") + .arg(m_softProofProfile.name.isEmpty() ? m_softProofProfile.id : m_softProofProfile.name); + } return tr("Fidelity exact, origin fast-canvas: this page reports no overprint-sensitive content. " "The interactive preview still does not certify print-safe output."); } @@ -1870,6 +1913,607 @@ bool EditorHost::ensureAuthoritativePreview() return true; } +QString EditorHost::displayColorModeName() const +{ + return pdfquick::getCanvasDisplayColorModeName(m_displayColor.mode); +} + +bool EditorHost::displayColorModeActive() const +{ + return m_displayColor.isActive(); +} + +QColor EditorHost::displayColorCustomForeground() const +{ + return m_displayColor.customForeground; +} + +QColor EditorHost::displayColorCustomBackground() const +{ + return m_displayColor.customBackground; +} + +int EditorHost::displayColorBitonalThreshold() const +{ + return m_displayColor.bitonalThreshold; +} + +bool EditorHost::setDisplayColorMode(const QString& modeName) +{ + const std::optional mode = + pdfquick::canvasDisplayColorModeFromName(modeName); + if (!mode) + { + return false; + } + + m_displayColor.mode = *mode; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +bool EditorHost::setDisplayColorCustomColors(const QColor& foreground, const QColor& background) +{ + if (!foreground.isValid() || !background.isValid()) + { + return false; + } + + m_displayColor.customForeground = foreground; + m_displayColor.customBackground = background; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +bool EditorHost::setDisplayColorBitonalThreshold(int threshold) +{ + if (threshold < 1 || threshold > 254) + { + return false; + } + + m_displayColor.bitonalThreshold = threshold; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +void EditorHost::applyDisplayColorToCanvas() +{ + if (m_canvas) + { + m_canvas->setDisplayColorMode(pdfquick::getCanvasDisplayColorModeName(m_displayColor.mode)); + m_canvas->setDisplayColorCustomForeground(m_displayColor.customForeground); + m_canvas->setDisplayColorCustomBackground(m_displayColor.customBackground); + m_canvas->setDisplayColorBitonalThreshold(m_displayColor.bitonalThreshold); + } +} + +bool EditorHost::softProofActive() const +{ + return m_softProofEnabled && !m_softProofProfile.id.isEmpty(); +} + +QString EditorHost::softProofStateName() const +{ + return m_softProofStateName; +} + +QString EditorHost::softProofProfileName() const +{ + return m_softProofProfile.name; +} + +QString EditorHost::softProofProfileId() const +{ + return m_softProofProfile.id; +} + +QString EditorHost::softProofProfileTypeName() const +{ + return m_softProofProfile.typeName; +} + +QString EditorHost::softProofSummary() const +{ + if (!hasDocument()) + { + return tr("No document is open, so there is no output intent to simulate."); + } + if (softProofActive()) + { + return tr("Soft proof of the output intent through colour management. The profile is %1. " + "This is a simulation of print, not the print's evidence: it is never recorded " + "as approval.") + .arg(m_softProofProfile.name.isEmpty() ? m_softProofProfile.id : m_softProofProfile.name); + } + if (m_softProofStateName == QLatin1String("profile-missing")) + { + return tr("Soft proof is unavailable: this document declares no output-intent profile. " + "Nothing is simulated and the canvas keeps the unproofed render."); + } + if (m_softProofStateName == QLatin1String("profile-invalid")) + { + return tr("Soft proof is unavailable: the document's output-intent profile could not be " + "read. Nothing is simulated and the canvas keeps the unproofed render."); + } + return tr("Soft proof is off. The canvas shows the unproofed render."); +} + +std::optional EditorHost::resolveOutputIntentProfile(bool* documentDeclaresOutputIntent) const +{ + if (documentDeclaresOutputIntent) + { + *documentDeclaresOutputIntent = false; + } + + if (!hasDocument()) + { + return std::nullopt; + } + + pdf::PDFDocumentSession* session = m_session->context().tryGetSession(); + const pdf::PDFDocument* document = m_session->context().getDocument(); + if (!session || !document) + { + return std::nullopt; + } + + if (documentDeclaresOutputIntent) + { + *documentDeclaresOutputIntent = !document->getCatalog()->getOutputIntents().empty(); + } + + // Core resolves the output intent: PDFCMSManager decodes the profile stream + // under the session's operation budget and refuses one it cannot open, so an + // unreadable profile is absent here rather than guessed at. A budget failure + // is the same kind of answer -- the profile could not be read -- and is + // reported as one instead of escaping a presentation call. + try + { + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document, session->getProcessingBudget()); + + for (const pdf::PDFColorProfileIdentifier& identifier : cmsManager.getCMYKProfiles()) + { + if (!identifier.isOutputIntentProfile) + { + continue; + } + + SoftProofProfile profile; + profile.id = identifier.id; + profile.name = identifier.name; + profile.typeName = QStringLiteral("output-intent"); + return profile; + } + } + catch (const pdf::PDFException&) + { + return std::nullopt; + } + return std::nullopt; +} + +bool EditorHost::setSoftProofEnabled(bool enabled) +{ + if (!enabled) + { + const bool wasActive = softProofActive(); + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("off"); + m_softProofProfile = SoftProofProfile(); + if (m_session) + { + m_session->setSoftProofProfile(QString()); + } + if (wasActive) + { + bumpPresentation(); + } + return wasActive; + } + + if (!hasDocument()) + { + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("profile-missing"); + bumpPresentation(); + return false; + } + + bool declaresOutputIntent = false; + const std::optional profile = resolveOutputIntentProfile(&declaresOutputIntent); + if (!profile) + { + m_softProofEnabled = false; + m_softProofStateName = declaresOutputIntent ? QStringLiteral("profile-invalid") + : QStringLiteral("profile-missing"); + bumpPresentation(); + return false; + } + + m_softProofProfile = *profile; + m_softProofEnabled = true; + m_softProofStateName = QStringLiteral("simulating"); + m_session->setSoftProofProfile(m_softProofProfile.id); + bumpPresentation(); + return true; +} + +bool EditorHost::toggleSoftProof() +{ + if (softProofActive()) + { + setSoftProofEnabled(false); + return false; + } + return setSoftProofEnabled(true); +} + +QVariantMap EditorHost::plateRow(const QString& id) const +{ + for (const QVariant& entry : m_plates) + { + const QVariantMap row = entry.toMap(); + if (row.value(QStringLiteral("id")).toString() == id) + { + return row; + } + } + return QVariantMap(); +} + +QVariantList EditorHost::productionPlates() const +{ + return m_plates; +} + +QString EditorHost::plateIsolationSummary() const +{ + if (m_plates.isEmpty()) + { + return tr("No separations are known for this document."); + } + if (!m_isolatedPlateId.isEmpty()) + { + const QVariantMap isolated = plateRow(m_isolatedPlateId); + return tr("Isolated plate: %1. The other plates are hidden.") + .arg(isolated.value(QStringLiteral("displayName")).toString()); + } + + QStringList hidden; + for (const QVariant& entry : m_plates) + { + const QVariantMap row = entry.toMap(); + if (!row.value(QStringLiteral("visible")).toBool()) + { + hidden.append(row.value(QStringLiteral("displayName")).toString()); + } + } + if (hidden.isEmpty()) + { + return tr("Every plate is shown."); + } + return tr("Hidden plates: %1.").arg(hidden.join(QStringLiteral(", "))); +} + +bool EditorHost::setPlateVisible(const QString& plateId, bool visible) +{ + if (plateRow(plateId).isEmpty()) + { + return false; + } + + m_plateVisible.insert(plateId, visible); + if (!visible && m_isolatedPlateId == plateId) + { + m_isolatedPlateId.clear(); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +bool EditorHost::isolatePlate(const QString& plateId) +{ + if (plateRow(plateId).isEmpty()) + { + return false; + } + + m_isolatedPlateId = plateId; + for (const QVariant& entry : m_plates) + { + const QString id = entry.toMap().value(QStringLiteral("id")).toString(); + m_plateVisible.insert(id, id == plateId); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +bool EditorHost::showAllPlates() +{ + m_isolatedPlateId.clear(); + for (const QVariant& entry : m_plates) + { + m_plateVisible.insert(entry.toMap().value(QStringLiteral("id")).toString(), true); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +void EditorHost::reloadProductionPlates() +{ + m_plates.clear(); + + if (!hasDocument()) + { + return; + } + + pdf::PDFDocumentSession* session = m_session->context().tryGetSession(); + const pdf::PDFDocument* document = m_session->context().getDocument(); + if (!session || !document) + { + return; + } + + // The press separations Core resolves for this document: the four process + // plates the preview simulates plus the document's own spot inks. This is + // the same PDFInkMapper recipe PdfTool's ink tools and the authoritative + // renderer use, so the list names the inks the render path actually knows. + try + { + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document, session->getProcessingBudget()); + pdf::PDFInkMapper inkMapper(&cmsManager, document); + inkMapper.createSpotColors(true); + + for (const pdf::PDFInkMapper::ColorInfo& info : inkMapper.getSeparations(4, true)) + { + const QString id = QString::fromLatin1(info.name); + QVariantMap row; + row.insert(QStringLiteral("id"), id); + row.insert(QStringLiteral("displayName"), info.textName.isEmpty() ? id : info.textName); + row.insert(QStringLiteral("kind"), info.isSpot ? QStringLiteral("spot") : QStringLiteral("process")); + row.insert(QStringLiteral("spotColorName"), info.isSpot ? id : QString()); + row.insert(QStringLiteral("color"), info.color.name()); + row.insert(QStringLiteral("visible"), m_plateVisible.value(id, true)); + row.insert(QStringLiteral("isolated"), m_isolatedPlateId == id); + m_plates.append(row); + } + } + catch (const pdf::PDFException&) + { + // A document whose inks could not be enumerated says so through the + // empty list and its summary rather than presenting an invented one. + m_plates.clear(); + } +} + +bool EditorHost::requestInkCoverageAtPoint(qreal pageX, qreal pageY) +{ + if (!hasDocument() || !m_session->revisionSource()) + { + return false; + } + + const int pageIndex = currentPage(); + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + if (!document || pageIndex < 0) + { + return false; + } + + const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); + pdf::PDFJobSpec spec; + spec.jobId = jobId; + spec.kind = pdf::PDFJobKind::Other; + spec.priority = pdf::PDFJobPriority::Operator; + spec.documentKey = m_session->revisionSource()->documentKey(); + spec.documentRevision = m_session->facade().currentRevision().toString(); + spec.operationId = QStringLiteral("ink-coverage.point"); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + auto outcome = std::make_shared(); + outcome->pageIndex = pageIndex; + m_inkCoverageOutcomes.insert(jobId, outcome); + + m_session->scheduler().submit(spec, [document, outcome, pageIndex, pageX, pageY](pdf::PDFJobContext& context) + { + if (context.isCancellationRequested()) + { + return; + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + outcome->reason = QStringLiteral("ink-coverage/no-such-page"); + return; + } + + // The readout is rendered through Core's separation-accurate path, so the + // coverage it reports is the coverage of the separated inks rather than + // of the display raster. + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document.data(), nullptr); + pdf::PDFInkMapper inkMapper(&cmsManager, document.data()); + inkMapper.createSpotColors(true); + + pdf::PDFFontCache fontCache(pdf::DEFAULT_FONT_CACHE_LIMIT, pdf::DEFAULT_REALIZED_FONT_CACHE_LIMIT); + pdf::PDFOptionalContentActivity optionalContent(document.data(), pdf::OCUsage::Print, nullptr); + pdf::PDFModifiedDocument modifiedDocument(document.data(), &optionalContent); + fontCache.setDocument(modifiedDocument); + fontCache.setCacheShrinkEnabled(nullptr, false); + + pdf::PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.renderPolicy = pdf::PDFRenderPolicy::forSeparationPreview(); + + // The readout raster is chosen for the point query, not for display: it + // only has to be fine enough that one pixel is a point sample, and the + // job is charged for every pixel it rasterizes. + const QRectF cropBox = page->getCropBox(); + if (!cropBox.isValid() || cropBox.width() <= 0.0 || cropBox.height() <= 0.0) + { + outcome->reason = QStringLiteral("ink-coverage/page-has-no-box"); + return; + } + + const qreal scale = static_cast(InkCoverageReadoutDpi) / 72.0; + QSize pixelSize(qRound(cropBox.width() * scale), qRound(cropBox.height() * scale)); + pixelSize = pixelSize.boundedTo(QSize(InkCoverageMaxRasterPixels, InkCoverageMaxRasterPixels)); + if (pixelSize.isEmpty()) + { + outcome->reason = QStringLiteral("ink-coverage/raster-too-small"); + return; + } + + context.processingBudget().chargeRenderPixels(static_cast(pixelSize.width()) * static_cast(pixelSize.height()), + QStringLiteral("ink coverage readout")); + + const QRectF deviceRect(QPointF(0.0, 0.0), QSizeF(pixelSize)); + const QTransform pagePointToDevice = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, pdf::PageRotation::None); + const QPointF devicePoint = pagePointToDevice.map(QPointF(pageX, pageY)); + const int pixelX = qFloor(devicePoint.x()); + const int pixelY = qFloor(devicePoint.y()); + if (pixelX < 0 || pixelY < 0 || pixelX >= pixelSize.width() || pixelY >= pixelSize.height()) + { + outcome->reason = QStringLiteral("ink-coverage/point-outside-page"); + return; + } + + pdf::PDFTransparencyRenderer renderer(page, + document.data(), + &fontCache, + cmsManager.getCurrentCMS().data(), + &optionalContent, + &inkMapper, + rendererSettings, + pagePointToDevice); + renderer.setOperationControl(context.operationControl()); + renderer.beginPaint(pixelSize); + renderer.processContents(); + const pdf::PDFFloatBitmap& separated = renderer.endPaint(); + + if (context.isCancellationRequested()) + { + return; + } + + const pdf::PDFConstColorBuffer channels = separated.getPixel(static_cast(pixelX), static_cast(pixelY)); + const pdf::PDFPixelFormat format = separated.getPixelFormat(); + + QVariantList channelRows; + const std::vector separations = inkMapper.getSeparations(4, true); + const int channelCount = format.getColorChannelCount(); + for (int channel = 0; channel < channelCount; ++channel) + { + QVariantMap row; + const size_t separationIndex = static_cast(channel); + if (separationIndex < separations.size()) + { + row.insert(QStringLiteral("name"), QString::fromLatin1(separations[separationIndex].name)); + row.insert(QStringLiteral("displayName"), separations[separationIndex].textName); + row.insert(QStringLiteral("isSpot"), separations[separationIndex].isSpot); + } + else + { + row.insert(QStringLiteral("name"), QStringLiteral("channel-%1").arg(channel)); + row.insert(QStringLiteral("displayName"), QStringLiteral("Channel %1").arg(channel)); + row.insert(QStringLiteral("isSpot"), false); + } + row.insert(QStringLiteral("coverage"), double(channels[static_cast(channel)])); + channelRows.append(row); + } + + outcome->ok = true; + outcome->readout.insert(QStringLiteral("available"), true); + outcome->readout.insert(QStringLiteral("pageIndex"), pageIndex); + outcome->readout.insert(QStringLiteral("pageX"), pageX); + outcome->readout.insert(QStringLiteral("pageY"), pageY); + outcome->readout.insert(QStringLiteral("totalInkCoverage"), double(separated.getPixelInkCoverage(static_cast(pixelX), static_cast(pixelY)))); + outcome->readout.insert(QStringLiteral("channelCount"), channelCount); + outcome->readout.insert(QStringLiteral("channels"), channelRows); + outcome->readout.insert(QStringLiteral("rasterSize"), pixelSize); + outcome->readout.insert(QStringLiteral("reason"), QString()); }); + + return true; +} + +QVariantMap EditorHost::inkCoverageReadout() const +{ + return m_inkCoverageReadout; +} + +qreal EditorHost::currentPageCentreX() const +{ + return currentPageCentre().x(); +} + +qreal EditorHost::currentPageCentreY() const +{ + return currentPageCentre().y(); +} + +QPointF EditorHost::currentPageCentre() const +{ + if (!hasDocument()) + { + return QPointF(); + } + + const pdf::PDFDocument* document = m_session->context().getDocument(); + const int pageIndex = currentPage(); + if (!document || pageIndex < 0 || pageIndex >= static_cast(document->getCatalog()->getPageCount())) + { + return QPointF(); + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + return QPointF(); + } + + const QRectF cropBox = page->getCropBox(); + return cropBox.isValid() ? cropBox.center() : QPointF(); +} + +void EditorHost::finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot) +{ + const auto outcome = m_inkCoverageOutcomes.take(snapshot.jobId); + if (!outcome) + { + return; + } + + if (!outcome->ok) + { + // A readout that could not be produced is a visible state, never a zero: + // "0% ink" and "not measured" are different facts. + QVariantMap readout; + readout.insert(QStringLiteral("available"), false); + readout.insert(QStringLiteral("pageIndex"), outcome->pageIndex); + readout.insert(QStringLiteral("reason"), outcome->reason.isEmpty() + ? QStringLiteral("ink-coverage/not-measured") + : outcome->reason); + readout.insert(QStringLiteral("channels"), QVariantList()); + m_inkCoverageReadout = readout; + } + else + { + m_inkCoverageReadout = outcome->readout; + } + bumpPresentation(); +} + void EditorHost::selectFinding(const QString& findingId) { if (!m_session->revisionSource() || findingId.isEmpty()) @@ -3811,6 +4455,24 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionExtractImage"), [this] { Q_EMIT imageExtractionRequested(); }); + // Display colour modes (#235 display half). Presentation-only: each filters + // the pixels the canvas already holds and asks for no render. Invoking the + // mode that is already in force returns the display to normal, which is the + // toggle a checkable action used to provide -- there is no sixth action for + // "normal", and a mode with no way back is a trap. + for (const pdfquick::CanvasDisplayColorMode mode : { pdfquick::CanvasDisplayColorMode::Invert, + pdfquick::CanvasDisplayColorMode::Grayscale, + pdfquick::CanvasDisplayColorMode::HighContrast, + pdfquick::CanvasDisplayColorMode::Bitonal, + pdfquick::CanvasDisplayColorMode::Custom }) + { + const QString actionId = pdfquick::getCanvasDisplayColorModeActionId(mode); + bind(actionId, [this, mode] + { setDisplayColorMode(m_displayColor.mode == mode + ? QStringLiteral("normal") + : pdfquick::getCanvasDisplayColorModeName(mode)); }); + } + pdfinteraction::CommandCatalog::Handler move; move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { @@ -3831,7 +4493,10 @@ void EditorHost::refreshFeatureAvailability() QHash availability; for (const QString& id : { QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), - QStringLiteral("actionFind"), QStringLiteral("actionProperties"), MoveSelectionCommandId }) + QStringLiteral("actionFind"), QStringLiteral("actionProperties"), MoveSelectionCommandId, + QStringLiteral("actionColorInvert"), QStringLiteral("actionColorGrayscale"), + QStringLiteral("actionColorHighContrast"), QStringLiteral("actionColorBitonal"), + QStringLiteral("actionColorCustom") }) { availability.insert(id, ready); } @@ -3900,6 +4565,7 @@ void EditorHost::bumpPresentation() { m_canvas->setHighContrast(highContrast()); } + applyDisplayColorToCanvas(); Q_EMIT presentationChanged(); } @@ -3918,6 +4584,7 @@ void EditorHost::onDocumentReady() syncDocumentLifecycle(); m_searchRow = -1; refreshHitTestSources(); + reloadProductionPlates(); m_documentBound = true; bindCanvas(); updateCanvasAccessibilitySummary(); @@ -3966,6 +4633,16 @@ void EditorHost::onDocumentGone() m_searchRow = -1; m_preview.clear(); m_production.clear(); + // Proofing and the plate list describe the document that just closed, so + // neither may outlive it: a soft proof of a document nobody has open would + // keep the canvas on a simulation with no revision behind it. + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("off"); + m_softProofProfile = SoftProofProfile(); + m_plates.clear(); + m_plateVisible.clear(); + m_isolatedPlateId.clear(); + m_inkCoverageReadout = QVariantMap(); m_session->hitTest()->clearSources(); m_documentBound = false; m_preflightCertificateStateName = QStringLiteral("not-certified"); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 492803a24..04ee5ddb1 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -48,6 +48,8 @@ #include "documentviewsession.h" #include "quickdocumentmodel.h" +#include "canvasdisplaycolor.h" + #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" #include "pdfoperationhistory.h" @@ -63,6 +65,7 @@ #include #include +#include namespace pdfinteraction { @@ -173,6 +176,29 @@ class EditorHost final : public QObject Q_PROPERTY(bool previewRequiresAuthoritative READ previewRequiresAuthoritative NOTIFY presentationChanged) Q_PROPERTY(QVariantMap compareReview READ compareReview NOTIFY presentationChanged) + // ---- Display colour modes (#235 display half) ----------------------------------- + // Presentation-only display modes. They filter the pixels the canvas already + // holds; they ask for no render, they join no surface identity, and the + // fidelity indicator reports them as a simulation. + Q_PROPERTY(QString displayColorModeName READ displayColorModeName NOTIFY presentationChanged) + Q_PROPERTY(bool displayColorModeActive READ displayColorModeActive NOTIFY presentationChanged) + Q_PROPERTY(QColor displayColorCustomForeground READ displayColorCustomForeground NOTIFY presentationChanged) + Q_PROPERTY(QColor displayColorCustomBackground READ displayColorCustomBackground NOTIFY presentationChanged) + Q_PROPERTY(int displayColorBitonalThreshold READ displayColorBitonalThreshold NOTIFY presentationChanged) + + // ---- Soft proof, plates and ink coverage (#237) --------------------------------- + Q_PROPERTY(bool softProofActive READ softProofActive NOTIFY presentationChanged) + Q_PROPERTY(QString softProofStateName READ softProofStateName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileName READ softProofProfileName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileId READ softProofProfileId NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileTypeName READ softProofProfileTypeName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofSummary READ softProofSummary NOTIFY presentationChanged) + Q_PROPERTY(QVariantList productionPlates READ productionPlates NOTIFY presentationChanged) + Q_PROPERTY(QString plateIsolationSummary READ plateIsolationSummary NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap inkCoverageReadout READ inkCoverageReadout NOTIFY presentationChanged) + Q_PROPERTY(qreal currentPageCentreX READ currentPageCentreX NOTIFY presentationChanged) + Q_PROPERTY(qreal currentPageCentreY READ currentPageCentreY NOTIFY presentationChanged) + public: enum LoopWorkspace { @@ -309,13 +335,15 @@ class EditorHost final : public QObject /// Render fidelity and origin of the interactive preview (#28), the same fact on the /// ordinary canvas and in the Production Preview. `previewFidelityStateName` is one of - /// `unavailable`, `stale`, `exact`, `approximate` or `authoritative`, derived by - /// `pdfquick::tokens::classifyPreviewFidelityState()` from the preview state and the - /// current page's render diagnostics; `previewFidelityOriginName` names the render path - /// (`none`, `fast-canvas`, `output-preview`). `previewRequiresAuthoritative` is true for - /// the overprint-sensitive fast render (`approximate`), whose pixels are not proof of - /// print-safe output. The interactive preview never certifies publication safety: only - /// `authoritative` names the output-preview origin, and no state reaches a pass. + /// `unavailable`, `stale`, `exact`, `approximate`, `authoritative`, `display-filter` or + /// `soft-proof`, derived by `pdfquick::tokens::classifyPreviewFidelityState()` from the + /// preview state, the display simulation in force and the current page's render + /// diagnostics; `previewFidelityOriginName` names the render path (`none`, `fast-canvas`, + /// `output-preview`, `display-filter`, `soft-proof`). `previewRequiresAuthoritative` is + /// true for the overprint-sensitive fast render (`approximate`), whose pixels are not + /// proof of print-safe output. The interactive preview never certifies publication + /// safety: only `authoritative` names the output-preview origin, and no state reaches a + /// pass. QString previewFidelityStateName() const; QVariantMap previewFidelityVisual() const; QColor previewFidelityColor() const; @@ -323,6 +351,62 @@ class EditorHost final : public QObject QString previewFidelitySummary() const; bool previewRequiresAuthoritative() const; + // ---- Display colour modes (#235 display half) ----------------------------------- + /// The display colour mode in force, by vocabulary name, and the settings the + /// custom mode reads. A display mode is a presentation filter: it changes what + /// the canvas shows, never what the document is, and it is reported as a + /// simulation by the fidelity indicator. + QString displayColorModeName() const; + bool displayColorModeActive() const; + QColor displayColorCustomForeground() const; + QColor displayColorCustomBackground() const; + int displayColorBitonalThreshold() const; + /// Parses the mode name once at this boundary. An unknown name is refused and + /// changes nothing. + Q_INVOKABLE bool setDisplayColorMode(const QString& modeName); + Q_INVOKABLE bool setDisplayColorCustomColors(const QColor& foreground, const QColor& background); + Q_INVOKABLE bool setDisplayColorBitonalThreshold(int threshold); + + // ---- Soft proof (#237) ----------------------------------------------------------- + /// True while the canvas is showing the proofing simulation. + bool softProofActive() const; + /// `off`, `simulating`, `profile-missing` or `profile-invalid`. A document + /// whose output-intent profile is absent or unreadable is a visible state: the + /// proof is refused with the reason, never silently rendered as sRGB. + QString softProofStateName() const; + QString softProofProfileName() const; + QString softProofProfileId() const; + QString softProofProfileTypeName() const; + QString softProofSummary() const; + /// Enables or disables the proofing simulation. Enabling resolves the + /// document's output-intent profile through Core's colour management; a + /// document without a usable one returns false and reports `profile-missing` + /// or `profile-invalid`. + Q_INVOKABLE bool setSoftProofEnabled(bool enabled); + Q_INVOKABLE bool toggleSoftProof(); + + // ---- Plates and ink coverage (#237) ---------------------------------------------- + /// The separations Core resolves for the open document: the process plates and + /// the document's spot plates, each with its visibility and whether it is the + /// isolated plate. Presentation state: hiding or isolating a plate changes what + /// the preview shows, never the document. + QVariantList productionPlates() const; + QString plateIsolationSummary() const; + Q_INVOKABLE bool setPlateVisible(const QString& plateId, bool visible); + /// Shows exactly one plate and hides the others. An unknown plate id is refused. + Q_INVOKABLE bool isolatePlate(const QString& plateId); + Q_INVOKABLE bool showAllPlates(); + /// Renders the current page through Core's separation-accurate path and reports + /// the ink coverage at one page-space point. Asynchronous: the readout lands in + /// inkCoverageReadout() when the job finishes. + Q_INVOKABLE bool requestInkCoverageAtPoint(qreal pageX, qreal pageY); + QVariantMap inkCoverageReadout() const; + /// The centre of the current page's crop box in page space (PDF user space, the + /// same space requestInkCoverageAtPoint takes). The host computes it so a + /// surface can offer a point readout without deriving document geometry. + qreal currentPageCentreX() const; + qreal currentPageCentreY() const; + /// Read-only composition of the Core comparison facts for the Compare workspace: /// the before/after artifact identities, the technical finding delta, the attributes /// the comparison preserved, and the risk it left unresolved. Every field is a @@ -566,6 +650,29 @@ class EditorHost final : public QObject /// The visible canvas region as one image, or a null image when no admitted /// tile intersects it or the snapshot is stale. QImage composeVisibleCanvasImage() const; + // ---- Display colour modes, soft proof and plates (#235 display half, #237) ------- + void applyDisplayColorToCanvas(); + /// The simulation the fidelity indicator must name, or empty when the display + /// shows the render path's own pixels: `soft-proof` or `display-filter`. + QString displaySimulationName() const; + struct SoftProofProfile + { + QString id; + QString name; + QString typeName; + }; + /// Resolves the document's output-intent profile through Core's colour + /// management. `documentDeclaresOutputIntent` distinguishes "the document + /// names no output intent" from "the one it names could not be read", which + /// are two different visible states. Nothing is resolved without a document. + std::optional resolveOutputIntentProfile(bool* documentDeclaresOutputIntent) const; + /// Caches the separations Core resolves for the open document, keyed by + /// colorant name, keeping the operator's visibility state for plates that + /// are still present. + void reloadProductionPlates(); + QVariantMap plateRow(const QString& id) const; + QPointF currentPageCentre() const; + void finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot); /// Operator review of the planned correction (#586). `fixPlanIsCurrent()` is the only /// authority on whether the reviewed plan still describes the open revision. @@ -610,6 +717,35 @@ class EditorHost final : public QObject QString retentionError; }; QHash> m_rollbackOutcomes; + + /// Display colour mode in force (issue #235 display half). Presentation state: + /// pushed to the canvas item, never to a render request. + pdfquick::CanvasDisplayColorSettings m_displayColor; + + /// Soft proof state (issue #237). The profile is the document's output-intent + /// profile Core resolved; `m_softProofStateName` is `off`, `simulating`, + /// `profile-missing` or `profile-invalid`. + bool m_softProofEnabled = false; + QString m_softProofStateName = QStringLiteral("off"); + SoftProofProfile m_softProofProfile; + + /// Separations Core resolved for the open document, and the operator's own + /// presentation state over them. Visibility is keyed by colorant name so a + /// plate that is still present keeps its state across a reload. + QVariantList m_plates; + QHash m_plateVisible; + QString m_isolatedPlateId; + + /// The last ink-coverage readout, and the worker outcomes still in flight. + QVariantMap m_inkCoverageReadout; + struct InkCoverageOutcome + { + bool ok = false; + QString reason; + int pageIndex = -1; + QVariantMap readout; + }; + QHash> m_inkCoverageOutcomes; struct PreflightProfileChoice { QString id; diff --git a/LoopEditor/qml/ProductionPreviewPane.qml b/LoopEditor/qml/ProductionPreviewPane.qml index 58e7fd2e4..9c9af0178 100644 --- a/LoopEditor/qml/ProductionPreviewPane.qml +++ b/LoopEditor/qml/ProductionPreviewPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts // Production Preview workspace (#586): the identity-bound before/after preview. @@ -138,39 +139,227 @@ Pane { Accessible.description: qsTr("Re-renders the current page only; the document stays open.") } } + + Label { + objectName: "productionPreviewSoftProofState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host + ? "" + : qsTr("Soft proof %1: %2") + .arg(root.host.softProofStateName) + .arg(root.host.softProofSummary()) + Accessible.name: qsTr("Soft proof state") + Accessible.description: text + } + + Label { + objectName: "productionPreviewSoftProofProfile" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: root.host !== null && root.host.softProofProfileId.length > 0 + text: root.host + ? qsTr("Output-intent profile %1 (identifier %2, type %3).") + .arg(root.host.softProofProfileName) + .arg(root.host.softProofProfileId) + .arg(root.host.softProofProfileTypeName) + : "" + Accessible.name: qsTr("Soft-proof profile identity") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewSoftProofToggle" + text: root.host && root.host.softProofActive + ? qsTr("Stop the soft proof") + : qsTr("Simulate the output intent (soft proof)") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.toggleSoftProof() + Accessible.name: qsTr("Toggle the soft-proof simulation of the output intent") + Accessible.description: qsTr("Renders the canvas through Core's colour management under proofing settings. A simulation of print, never recorded as approval.") + } + } } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Display colour") + Accessible.name: qsTr("Display colour modes") + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + ComboBox { + objectName: "productionPreviewDisplayColorMode" + Layout.fillWidth: true + enabled: root.host !== null + model: [ "normal", "invert", "grayscale", "high-contrast", "bitonal", "custom" ] + currentIndex: { + if (!root.host) + return 0 + const index = model.indexOf(root.host.displayColorModeName) + return index < 0 ? 0 : index + } + onActivated: if (root.host) root.host.setDisplayColorMode(model[currentIndex]) + Accessible.name: qsTr("Display colour mode") + Accessible.description: qsTr("Presentation only: filters the canvas pixels and asks for no render.") + } + + Label { + objectName: "productionPreviewDisplayColorState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host || !root.host.displayColorModeActive + ? qsTr("No display colour mode is active.") + : qsTr("Display colour simulation (%1): presentation only, never a production render.") + .arg(root.host.displayColorModeName) + Accessible.name: qsTr("Display colour state") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewCustomForeground" + text: qsTr("Custom foreground") + enabled: root.host !== null + onClicked: customForegroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's foreground colour") + } + + Button { + objectName: "productionPreviewCustomBackground" + text: qsTr("Custom background") + enabled: root.host !== null + onClicked: customBackgroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's background colour") + } + } + } + } + + ColorDialog { + id: customForegroundDialog + title: qsTr("Custom mode foreground") + selectedColor: root.host ? root.host.displayColorCustomForeground : "black" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(selectedColor, root.host.displayColorCustomBackground) + } + + ColorDialog { + id: customBackgroundDialog + title: qsTr("Custom mode background") + selectedColor: root.host ? root.host.displayColorCustomBackground : "white" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(root.host.displayColorCustomForeground, selectedColor) + } + GroupBox { Layout.fillWidth: true Layout.fillHeight: true title: qsTr("Plates and separations") Accessible.name: qsTr("Plates and separations") - ListView { - id: platesView - objectName: "productionPreviewPlatesView" + ColumnLayout { anchors.fill: parent - clip: true - focus: visible - activeFocusOnTab: true - model: root.production - - Accessible.role: Accessible.List - Accessible.name: qsTr("Plates") - Accessible.description: qsTr("Inks and plates for the current page.") - - delegate: ItemDelegate { - id: plateRow - width: platesView.width - text: plateRow.displayName - Accessible.role: Accessible.ListItem - Accessible.name: plateRow.displayName - Accessible.description: qsTr("Kind %1. Spot colour %2. Overprint %3. Prints %4.") - .arg(plateRow.kind) - .arg(plateRow.spotColorName) - .arg(plateRow.overprint ? qsTr("yes") : qsTr("no")) - .arg(plateRow.shouldPrint ? qsTr("yes") : qsTr("no")) + spacing: 4 + + Label { + objectName: "productionPreviewPlateIsolation" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.host ? root.host.plateIsolationSummary : "" + Accessible.name: qsTr("Plate isolation state") + Accessible.description: text + } + + ListView { + id: platesView + objectName: "productionPreviewPlatesView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.host ? root.host.productionPlates : [] + + Accessible.role: Accessible.List + Accessible.name: qsTr("Plates") + Accessible.description: qsTr("Inks and plates for the current page. Each plate can be shown alone or hidden.") + + delegate: ItemDelegate { + id: plateRow + width: platesView.width + text: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.role: Accessible.ListItem + Accessible.name: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.description: qsTr("Kind %1. Spot colour %2. Shown %3. Isolated %4.") + .arg(plateRow.modelData ? plateRow.modelData.kind : "") + .arg(plateRow.modelData ? plateRow.modelData.spotColorName : "") + .arg(plateRow.modelData && plateRow.modelData.visible ? qsTr("yes") : qsTr("no")) + .arg(plateRow.modelData && plateRow.modelData.isolated ? qsTr("yes") : qsTr("no")) + + CheckBox { + objectName: "productionPreviewPlateVisibleCheck" + anchors.verticalCenter: parent.verticalCenter + checked: plateRow.modelData ? plateRow.modelData.visible : false + onToggled: if (root.host && plateRow.modelData) root.host.setPlateVisible(plateRow.modelData.id, checked) + Accessible.name: qsTr("Show plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + + Button { + objectName: "productionPreviewPlateIsolate" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Isolate") + onClicked: if (root.host && plateRow.modelData) root.host.isolatePlate(plateRow.modelData.id) + Accessible.name: qsTr("Show only plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewShowAllPlates" + text: qsTr("Show all plates") + enabled: root.host !== null + onClicked: if (root.host) root.host.showAllPlates() + Accessible.name: qsTr("Show every plate") + } + + Button { + objectName: "productionPreviewInkCoverage" + text: qsTr("Measure ink at the page centre") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.requestInkCoverageAtPoint(root.host.currentPageCentreX, root.host.currentPageCentreY) + Accessible.name: qsTr("Measure ink coverage at the page centre") + Accessible.description: qsTr("Renders the current page through Core's separation-accurate path and reports the ink coverage at one point.") + } + } + + Label { + objectName: "productionPreviewInkCoverageReadout" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: { + if (!root.host || !root.host.inkCoverageReadout || !root.host.inkCoverageReadout.available) + return root.host && root.host.inkCoverageReadout && root.host.inkCoverageReadout.reason + ? qsTr("Ink coverage is not measured: %1.").arg(root.host.inkCoverageReadout.reason) + : qsTr("Ink coverage has not been measured at a point.") + return qsTr("Ink coverage at (%1, %2) pt: %3 total across %4 plate(s).") + .arg(Number(root.host.inkCoverageReadout.pageX).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.pageY).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.totalInkCoverage).toFixed(3)) + .arg(root.host.inkCoverageReadout.channelCount) + } + Accessible.name: qsTr("Ink coverage readout") + Accessible.description: text } } } diff --git a/LoopLibInteraction/sources/pagesurfacecoordinator.cpp b/LoopLibInteraction/sources/pagesurfacecoordinator.cpp index 336afa867..783eb9c47 100644 --- a/LoopLibInteraction/sources/pagesurfacecoordinator.cpp +++ b/LoopLibInteraction/sources/pagesurfacecoordinator.cpp @@ -245,9 +245,11 @@ std::optional PageSurfaceCoordinator::keyForPage(int pageIndex) const qreal devicePixelRatio = m_viewport->devicePixelRatio(); const QSize targetPixelSize(qRound(placedRect.width() * devicePixelRatio), qRound(placedRect.height() * devicePixelRatio)); - const QString colorOutputIdentity = m_authoritativePages.contains(pageIndex) - ? withAuthoritativeOverprintMarker(m_settings.colorOutputIdentity) - : m_settings.colorOutputIdentity; + const QString colorOutputIdentity = m_softProofProfile.isEmpty() + ? (m_authoritativePages.contains(pageIndex) + ? withAuthoritativeOverprintMarker(m_settings.colorOutputIdentity) + : m_settings.colorOutputIdentity) + : withSoftProofMarker(m_settings.colorOutputIdentity, m_softProofProfile); return makePageSurfaceKey(revision, pageIndex, m_viewport->rotation(), m_settings.features, colorOutputIdentity, m_viewport->zoom(), targetPixelSize, devicePixelRatio); } @@ -276,6 +278,23 @@ void PageSurfaceCoordinator::setPageAuthoritativeOverprint(int pageIndex, bool e requestSurfaces(); } +void PageSurfaceCoordinator::setSoftProofProfile(const QString& proofProfileId) +{ + if (m_softProofProfile == proofProfileId) + { + return; + } + + m_softProofProfile = proofProfileId; + + // Every page's key changed, so nothing cached under the previous output + // identity may stand in -- a proofed surface is a different identity from + // the fast render and from the authoritative one. rebuildSnapshot() first so + // the display never shows an unproofed surface while the proof is pending. + rebuildSnapshot(); + requestSurfaces(); +} + std::optional PageSurfaceCoordinator::diagnosticsForPage(int pageIndex) const { const std::optional wanted = keyForPage(pageIndex); diff --git a/LoopLibInteraction/sources/pagesurfacecoordinator.h b/LoopLibInteraction/sources/pagesurfacecoordinator.h index 0b9983bcb..0b8374b5e 100644 --- a/LoopLibInteraction/sources/pagesurfacecoordinator.h +++ b/LoopLibInteraction/sources/pagesurfacecoordinator.h @@ -151,6 +151,19 @@ class PageSurfaceCoordinator final : public QObject void setPageAuthoritativeOverprint(int pageIndex, bool enabled); bool isPageAuthoritativeOverprint(int pageIndex) const { return m_authoritativePages.contains(pageIndex); } + /// Requests the soft-proof simulation of the output intent for every page + /// (issue #237). The proofing profile identity joins the colour-managed + /// output identity (see withSoftProofMarker), so a proofed surface gets its + /// own cache slot and can neither be served from nor stand in for the fast + /// or authoritative render of the same page. Empty disables proofing. + /// + /// This is a render request, not a display filter: the pixels are produced + /// by Core's colour management under proofing settings, and the simulation + /// identity travels with the key. + void setSoftProofProfile(const QString& proofProfileId); + QString softProofProfile() const { return m_softProofProfile; } + bool isSoftProofing() const { return !m_softProofProfile.isEmpty(); } + /// Diagnostics for the surface currently admitted for \p pageIndex, if any. /// Reflects whichever render path actually produced that surface -- the /// standard path's cached-flag approximation, or the authoritative @@ -303,6 +316,10 @@ class PageSurfaceCoordinator final : public QObject std::shared_ptr m_resourceBudget; QSet m_authoritativePages; + /// The proofing profile identity in force, empty when no soft proof is + /// requested. It joins every page's colour-managed output identity. + QString m_softProofProfile; + std::shared_ptr m_relay; quint64 m_generation = 1; diff --git a/LoopLibInteraction/sources/pagesurfacekey.cpp b/LoopLibInteraction/sources/pagesurfacekey.cpp index 64c6e923c..d809afebb 100644 --- a/LoopLibInteraction/sources/pagesurfacekey.cpp +++ b/LoopLibInteraction/sources/pagesurfacekey.cpp @@ -49,6 +49,13 @@ constexpr double MinimumBucketedZoom = 1e-6; /// identity. const QString AuthoritativeOverprintMarker = QStringLiteral("|overprint-authoritative"); +/// Appended to a colorOutputIdentity by withSoftProofMarker, carrying the +/// proofing profile identifier after the separator. Like the overprint marker +/// it is only ever appended, and the profile identifier is a Core-issued +/// identifier with no '|' in it, so parsing back is exact rather than +/// heuristic. +const QString SoftProofMarkerPrefix = QStringLiteral("|soft-proof:"); + } // namespace int zoomBucketFor(qreal zoom) @@ -134,6 +141,30 @@ bool hasAuthoritativeOverprintMarker(const QString& colorOutputIdentity) return colorOutputIdentity.endsWith(AuthoritativeOverprintMarker); } +QString withSoftProofMarker(const QString& colorOutputIdentity, const QString& proofProfileId) +{ + if (proofProfileId.isEmpty()) + { + return colorOutputIdentity; + } + return colorOutputIdentity + SoftProofMarkerPrefix + proofProfileId; +} + +bool hasSoftProofMarker(const QString& colorOutputIdentity) +{ + return colorOutputIdentity.contains(SoftProofMarkerPrefix); +} + +QString softProofProfileId(const QString& colorOutputIdentity) +{ + const qsizetype marker = colorOutputIdentity.indexOf(SoftProofMarkerPrefix); + if (marker < 0) + { + return QString(); + } + return colorOutputIdentity.mid(marker + SoftProofMarkerPrefix.size()); +} + const char* getSurfaceTerminalStateName(SurfaceTerminalState state) { switch (state) diff --git a/LoopLibInteraction/sources/pagesurfacekey.h b/LoopLibInteraction/sources/pagesurfacekey.h index ea517f31b..fa4e05cae 100644 --- a/LoopLibInteraction/sources/pagesurfacekey.h +++ b/LoopLibInteraction/sources/pagesurfacekey.h @@ -144,6 +144,25 @@ QString withAuthoritativeOverprintMarker(const QString& colorOutputIdentity); /// True if \p colorOutputIdentity was produced by withAuthoritativeOverprintMarker. bool hasAuthoritativeOverprintMarker(const QString& colorOutputIdentity); +/// Joins the soft-proof marker onto a colorOutputIdentity (issue #237). A soft +/// proof simulates the output intent through Core's colour management, so its +/// pixels are a distinct identity with their own cache slot and their own +/// admission -- exactly as the authoritative overprint marker is -- and the +/// marker carries the proofing profile's identifier, so the renderer proofs +/// through the profile the caller resolved rather than through a default. +/// +/// A proofed surface can therefore never be served from, or stand in for, the +/// fast or authoritative render of the same page, and the simulation's identity +/// travels with the pixels instead of being remembered beside them. +QString withSoftProofMarker(const QString& colorOutputIdentity, const QString& proofProfileId); + +/// True if \p colorOutputIdentity was produced by withSoftProofMarker. +bool hasSoftProofMarker(const QString& colorOutputIdentity); + +/// The proofing profile identifier a soft-proof identity carries, or an empty +/// string when the identity is not a soft proof. +QString softProofProfileId(const QString& colorOutputIdentity); + /// The zoom quantizer used by makePageSurfaceKey, exposed so a test can pin it /// rather than re-derive it. int zoomBucketFor(qreal zoom); diff --git a/LoopLibInteraction/sources/pagesurfacerenderer.cpp b/LoopLibInteraction/sources/pagesurfacerenderer.cpp index f85cbec4d..e1301fdc4 100644 --- a/LoopLibInteraction/sources/pagesurfacerenderer.cpp +++ b/LoopLibInteraction/sources/pagesurfacerenderer.cpp @@ -25,6 +25,7 @@ #include "pdfdocument.h" #include "pdfdocumentsession.h" #include "pdfexception.h" +#include "pdfcms.h" #include "pdfpagecachebudget.h" #include "pdfpainter.h" #include "pdfprocessingbudget.h" @@ -172,6 +173,125 @@ PageSurfaceResult renderAuthoritativeOverprint(const PageSurfaceRequest& request return result; } +/// Renders one page as a soft proof of the output intent (issue #237): the page +/// goes through PDFTransparencyRenderer with the separation-accurate policy, and +/// every colour it converts passes through a proofing CMS built for the profile +/// the caller's marker named. Core's own PDFLittleCMS implements the proof +/// (cmsCreateProofingTransform with the proofing profile and intent), so the +/// simulation is Core's colour management rather than a second approximation +/// invented here. +/// +/// The CMS is built per render from a local PDFCMSManager, the construction +/// PdfTool's ink tools already use, and never replaces the document session's +/// own CMS: proofing is scoped to the surfaces that asked for it, so the +/// authoritative render of the same page and every production consumer keep +/// rendering unproofed. +/// +/// A missing or unusable profile is a typed terminal state, never a silent +/// fallback to sRGB: the render fails and the caller reports the reason. +PageSurfaceResult renderSoftProof(const PageSurfaceRequest& request, + const pdf::PDFPage* page, + const pdf::PDFDocument* document, + pdf::PDFDocumentSession* session, + pdf::PDFJobContext& jobContext, + QSize pixelSize, + pdf::PDFRenderer::Features features) +{ + if (jobContext.isCancellationRequested()) + { + return makeTerminal(request, SurfaceTerminalState::Cancelled, QStringLiteral("page-surface/cancelled")); + } + + const QString proofProfileId = softProofProfileId(request.key.colorOutputIdentity); + if (proofProfileId.isEmpty()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/soft-proof-profile-missing")); + } + + pdf::PDFCMSManager proofCms(nullptr); + proofCms.setDocument(document, session->getProcessingBudget()); + + pdf::PDFCMSSettings settings = proofCms.getDefaultSettings(); + settings.system = pdf::PDFCMSSettings::System::LittleCMS2; + settings.isConsiderOutputIntent = true; + settings.isSoftProofing = true; + settings.softProofingProfile = proofProfileId; + proofCms.setSettings(settings); + + const pdf::PDFCMSPointer proofingCms = proofCms.getCurrentCMS(); + if (proofingCms.isNull()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/soft-proof-profile-unavailable")); + } + + pdf::PDFInkMapper inkMapper(&proofCms, document); + inkMapper.createSpotColors(true); + + pdf::PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SmoothImageTransformation, + features.testFlag(pdf::PDFRenderer::SmoothImages)); + rendererSettings.renderPolicy = pdf::PDFRenderPolicy::forSeparationPreview(); + + const QRectF deviceRect(QPointF(0.0, 0.0), QSizeF(pixelSize)); + const QTransform pagePointToDevice = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, request.key.rotation); + + pdf::PDFTransparencyRenderer renderer(page, + document, + session->getFontCache(), + proofingCms.data(), + session->getOptionalContentActivity(), + &inkMapper, + rendererSettings, + pagePointToDevice); + renderer.setOperationControl(jobContext.operationControl()); + + renderer.beginPaint(pixelSize); + renderer.processContents(); + renderer.endPaint(); + + if (jobContext.isCancellationRequested()) + { + return makeTerminal(request, SurfaceTerminalState::Cancelled, QStringLiteral("page-surface/cancelled")); + } + + const QColor paperColor = proofingCms->getPaperColor(); + QImage image = renderer.toImage(false, + true, + pdf::PDFRGB{ static_cast(paperColor.redF()), + static_cast(paperColor.greenF()), + static_cast(paperColor.blueF()) }); + if (image.isNull()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/render-failed")); + } + + image = applySurfaceFeatures(std::move(image), page, pagePointToDevice, features, proofingCms.data()); + image.setDevicePixelRatio(request.key.devicePixelRatio1000 / 1000.0); + + PageSurfaceResult result; + result.key = request.key; + result.token = request.token; + result.state = SurfaceTerminalState::Complete; + result.pixels = makeSurfaceBuffer(std::move(image)); + result.pixelSize = pixelSize; + result.byteSize = result.pixels ? result.pixels->byteSize : 0; + result.diagnostics = renderer.getRenderDiagnostics(); + + // A soft proof is a simulation of the output intent. Recording that here + // keeps it out of any exact-render claim: the surface describes what the + // proof did, and no consumer can read it as the authoritative render. + result.diagnostics.record(pdf::PDFRenderFidelity::SupportedWithFallback, + QStringLiteral("soft-proof/simulation")); + + if (!result.pixels) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/empty-surface")); + } + + return result; +} + } // namespace PDFSessionPageSurfaceRenderer::PDFSessionPageSurfaceRenderer(pdf::PDFDocumentContext& context) : @@ -282,6 +402,14 @@ PageSurfaceResult PDFSessionPageSurfaceRenderer::render(const PageSurfaceRequest // exactly this work. jobContext.processingBudget().chargeRenderPixels(static_cast(pixelSize.width()) * static_cast(pixelSize.height()), QStringLiteral("page surface")); + if (hasSoftProofMarker(request.key.colorOutputIdentity)) + { + // The proofing simulation from issue #237: a separation-accurate + // render through Core's colour management under proofing settings, + // for the surfaces whose identity asked for it. + return renderSoftProof(request, page, document, session, jobContext, pixelSize, features); + } + if (hasAuthoritativeOverprintMarker(request.key.colorOutputIdentity)) { // The one-page authoritative escalation from issue #49: a full diff --git a/LoopLibQuick/CMakeLists.txt b/LoopLibQuick/CMakeLists.txt index 14e1cfc03..dc3bfd94a 100644 --- a/LoopLibQuick/CMakeLists.txt +++ b/LoopLibQuick/CMakeLists.txt @@ -44,6 +44,8 @@ qt_policy(SET QTP0001 NEW) qt_add_library(LoopLibQuick SHARED sources/loopquickglobal.h + sources/canvasdisplaycolor.cpp + sources/canvasdisplaycolor.h sources/canvaspalette.cpp sources/canvaspalette.h sources/canvasnodebuilder.cpp diff --git a/LoopLibQuick/sources/canvasdisplaycolor.cpp b/LoopLibQuick/sources/canvasdisplaycolor.cpp new file mode 100644 index 000000000..d05d1ce18 --- /dev/null +++ b/LoopLibQuick/sources/canvasdisplaycolor.cpp @@ -0,0 +1,143 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#include "canvasdisplaycolor.h" + +#include "pdfcolorconvertor.h" + +namespace pdfquick +{ + +namespace +{ + +pdf::PDFColorConvertor::Mode convertorMode(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Invert: + return pdf::PDFColorConvertor::Mode::InvertedColors; + case CanvasDisplayColorMode::Grayscale: + return pdf::PDFColorConvertor::Mode::Grayscale; + case CanvasDisplayColorMode::HighContrast: + return pdf::PDFColorConvertor::Mode::HighContrast; + case CanvasDisplayColorMode::Bitonal: + return pdf::PDFColorConvertor::Mode::Bitonal; + case CanvasDisplayColorMode::Custom: + return pdf::PDFColorConvertor::Mode::CustomColors; + case CanvasDisplayColorMode::Normal: + break; + } + return pdf::PDFColorConvertor::Mode::Normal; +} + +} // namespace + +QString getCanvasDisplayColorModeName(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Normal: + return QStringLiteral("normal"); + case CanvasDisplayColorMode::Invert: + return QStringLiteral("invert"); + case CanvasDisplayColorMode::Grayscale: + return QStringLiteral("grayscale"); + case CanvasDisplayColorMode::HighContrast: + return QStringLiteral("high-contrast"); + case CanvasDisplayColorMode::Bitonal: + return QStringLiteral("bitonal"); + case CanvasDisplayColorMode::Custom: + return QStringLiteral("custom"); + } + return QStringLiteral("normal"); +} + +std::optional canvasDisplayColorModeFromName(const QString& name) +{ + const QString normalized = name.trimmed().toLower(); + + if (normalized == QLatin1String("normal")) + { + return CanvasDisplayColorMode::Normal; + } + if (normalized == QLatin1String("invert")) + { + return CanvasDisplayColorMode::Invert; + } + if (normalized == QLatin1String("grayscale")) + { + return CanvasDisplayColorMode::Grayscale; + } + if (normalized == QLatin1String("high-contrast")) + { + return CanvasDisplayColorMode::HighContrast; + } + if (normalized == QLatin1String("bitonal")) + { + return CanvasDisplayColorMode::Bitonal; + } + if (normalized == QLatin1String("custom")) + { + return CanvasDisplayColorMode::Custom; + } + return std::nullopt; +} + +QString getCanvasDisplayColorModeActionId(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Normal: + return QString(); + case CanvasDisplayColorMode::Invert: + return QStringLiteral("actionColorInvert"); + case CanvasDisplayColorMode::Grayscale: + return QStringLiteral("actionColorGrayscale"); + case CanvasDisplayColorMode::HighContrast: + return QStringLiteral("actionColorHighContrast"); + case CanvasDisplayColorMode::Bitonal: + return QStringLiteral("actionColorBitonal"); + case CanvasDisplayColorMode::Custom: + return QStringLiteral("actionColorCustom"); + } + return QString(); +} + +QImage applyCanvasDisplayColorMode(const QImage& image, const CanvasDisplayColorSettings& settings) +{ + if (!settings.isActive() || image.isNull()) + { + return image; + } + + pdf::PDFColorConvertor convertor; + convertor.setMode(convertorMode(settings.mode)); + convertor.setBitonalThreshold(settings.bitonalThreshold); + convertor.setHighContrastBrightnessFactor(settings.highContrastSlopeFactor); + convertor.setForegroundColor(settings.customForeground); + convertor.setBackgroundColor(settings.customBackground); + return convertor.convert(image); +} + +} // namespace pdfquick diff --git a/LoopLibQuick/sources/canvasdisplaycolor.h b/LoopLibQuick/sources/canvasdisplaycolor.h new file mode 100644 index 000000000..b5156ca35 --- /dev/null +++ b/LoopLibQuick/sources/canvasdisplaycolor.h @@ -0,0 +1,93 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#ifndef CANVASDISPLAYCOLOR_H +#define CANVASDISPLAYCOLOR_H + +#include "loopquickglobal.h" + +#include +#include +#include + +#include + +namespace pdfquick +{ + +/// The five display colour modes of issue #235, plus the identity. +/// +/// A display colour mode is a presentation filter over pixels the render path +/// already produced. It is deliberately not a render request: the mode never +/// reaches a PageSurfaceKey, the page cache, `PDFRenderer::Features` or +/// `PDFRenderPolicy`, so no surface can be mistaken for the pixels a production +/// render would have produced, and switching modes re-renders nothing (issue +/// #235 display half; the production half -- bitonal document creation, bleed +/// fixup -- belongs to PR 3 and is not here). +/// +/// The transform itself is Core's own `pdf::PDFColorConvertor`, the same +/// converter `PDFRenderer::applyFeaturesToColorConvertor` configures for the +/// ColorAdjust_* render features, so "invert" means one thing in this repository +/// rather than two. +enum class CanvasDisplayColorMode +{ + Normal, + Invert, + Grayscale, + HighContrast, + Bitonal, + Custom +}; + +/// The vocabulary name of a mode, as `actionColor*` commands and QML use it. +LOOPLIBQUICK_EXPORT QString getCanvasDisplayColorModeName(CanvasDisplayColorMode mode); + +/// Parses a name once at a boundary. An unknown name returns nothing rather +/// than being coerced into a default. +LOOPLIBQUICK_EXPORT std::optional canvasDisplayColorModeFromName(const QString& name); + +/// The shell action id that selects a mode, for the handler the host binds. +LOOPLIBQUICK_EXPORT QString getCanvasDisplayColorModeActionId(CanvasDisplayColorMode mode); + +/// Everything the transform reads. The custom colours and the bitonal threshold +/// are settings, not modes, so they stay valid while the mode changes. +struct CanvasDisplayColorSettings +{ + CanvasDisplayColorMode mode = CanvasDisplayColorMode::Normal; + QColor customForeground = Qt::green; + QColor customBackground = Qt::black; + int bitonalThreshold = 128; + float highContrastSlopeFactor = 10.0F; + + bool isActive() const noexcept { return mode != CanvasDisplayColorMode::Normal; } + + bool operator==(const CanvasDisplayColorSettings&) const = default; +}; + +/// The display transform. Pure: the input image is not modified and a Normal +/// setting returns it unchanged. +LOOPLIBQUICK_EXPORT QImage applyCanvasDisplayColorMode(const QImage& image, const CanvasDisplayColorSettings& settings); + +} // namespace pdfquick + +#endif // CANVASDISPLAYCOLOR_H diff --git a/LoopLibQuick/sources/canvasnodebuilder.cpp b/LoopLibQuick/sources/canvasnodebuilder.cpp index 1ef05b417..74189ddbe 100644 --- a/LoopLibQuick/sources/canvasnodebuilder.cpp +++ b/LoopLibQuick/sources/canvasnodebuilder.cpp @@ -23,6 +23,8 @@ #include "canvasnodebuilder.h" +#include "canvasdisplaycolor.h" + #include #include #include @@ -244,6 +246,24 @@ void CanvasNodeBuilder::setPalette(const CanvasPalette& palette) } } +void CanvasNodeBuilder::setDisplayColorSettings(const CanvasDisplayColorSettings& settings) +{ + if (m_displayColor == settings) + { + return; + } + + m_displayColor = settings; + + // Every retained copy described the previous setting. Dropping the source + // identity (and not the node) makes the next sync re-convert and re-upload + // each tile, which is the whole cost of a mode change. + for (auto& tile : m_tiles) + { + tile.second.displaySource = nullptr; + } +} + void CanvasNodeBuilder::forget() { m_tiles.clear(); @@ -296,19 +316,38 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho entry.node->setFiltering(QSGTexture::Linear); } + // The display colour filter is applied here, on the render thread, over + // pixels that were already admitted. The copy is retained per tile so a + // mode that did not change costs no conversion and no upload. + pdfinteraction::SurfaceBufferPointer drawn = tile.pixels; + if (m_displayColor.isActive()) + { + if (entry.displaySource != tile.pixels.get() || entry.displaySettings != m_displayColor) + { + entry.displayPixels = pdfinteraction::makeSurfaceBuffer( + applyCanvasDisplayColorMode(tile.pixels->image, m_displayColor)); + entry.displaySource = tile.pixels.get(); + entry.displaySettings = m_displayColor; + } + if (entry.displayPixels) + { + drawn = entry.displayPixels; + } + } + // Pointer identity is the whole test. SurfaceBuffer is immutable and // shared, so the same pointer is necessarily the same pixels and the // upload is skipped -- which is what keeps a pan from re-uploading every // visible page. - if (entry.pixels != tile.pixels) + if (entry.pixels != drawn) { entry.resourceReservation.reset(); - const QQuickWindow::CreateTextureOptions options = tile.pixels->image.hasAlphaChannel() + const QQuickWindow::CreateTextureOptions options = drawn->image.hasAlphaChannel() ? QQuickWindow::TextureHasAlphaChannel : QQuickWindow::TextureIsOpaque; if (m_resourceBudget) { - const qsizetype bytes = tile.pixels->image.sizeInBytes(); + const qsizetype bytes = drawn->image.sizeInBytes(); if (bytes <= 0 || !m_resourceBudget->tryReserve(pdf::PDFResourcePool::GpuTextureCache, bytes, pdf::PDFResourcePriority::Visible, @@ -323,15 +362,15 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho continue; } entry.resourceReservation = std::make_shared(m_resourceBudget, - pdf::PDFResourcePool::GpuTextureCache, - bytes); + pdf::PDFResourcePool::GpuTextureCache, + bytes); } - entry.node->setTexture(m_window->createTextureFromImage(tile.pixels->image, options)); - entry.pixels = tile.pixels; + entry.node->setTexture(m_window->createTextureFromImage(drawn->image, options)); + entry.pixels = drawn; } else if (m_resourceBudget && !entry.resourceReservation) { - const qsizetype bytes = tile.pixels->image.sizeInBytes(); + const qsizetype bytes = drawn->image.sizeInBytes(); if (bytes <= 0 || !m_resourceBudget->tryReserve(pdf::PDFResourcePool::GpuTextureCache, bytes, pdf::PDFResourcePriority::Visible, @@ -346,8 +385,8 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho continue; } entry.resourceReservation = std::make_shared(m_resourceBudget, - pdf::PDFResourcePool::GpuTextureCache, - bytes); + pdf::PDFResourcePool::GpuTextureCache, + bytes); } entry.placedRect = tile.placedRect; @@ -368,7 +407,7 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho parent->appendChildNode(entry.node); retained.insert({ tile.key, entry }); - retainedBytes += qint64(tile.pixels->image.sizeInBytes()); + retainedBytes += qint64(drawn->image.sizeInBytes()); ++m_tileCount; if (!tile.exact) diff --git a/LoopLibQuick/sources/canvasnodebuilder.h b/LoopLibQuick/sources/canvasnodebuilder.h index ec8a220f4..ecb7a20f2 100644 --- a/LoopLibQuick/sources/canvasnodebuilder.h +++ b/LoopLibQuick/sources/canvasnodebuilder.h @@ -24,6 +24,7 @@ #ifndef CANVASNODEBUILDER_H #define CANVASNODEBUILDER_H +#include "canvasdisplaycolor.h" #include "canvaspalette.h" #include "loopquickglobal.h" @@ -92,6 +93,15 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder void setPalette(const CanvasPalette& palette); const CanvasPalette& palette() const noexcept { return m_palette; } + /// The display colour filter applied to every tile this builder presents + /// (issue #235 display half). It is presentation state, not render state: + /// the filter runs here, over pixels that were already admitted, and never + /// reaches a surface request or a PageSurfaceKey. Changing it re-converts + /// the retained tiles against the new setting; it asks nothing of a + /// coordinator. + void setDisplayColorSettings(const CanvasDisplayColorSettings& settings); + const CanvasDisplayColorSettings& displayColorSettings() const noexcept { return m_displayColor; } + /// Page pixels. `parent` is emptied of tiles that the snapshot no longer /// contains, and every surviving tile keeps its texture. /// @@ -160,6 +170,15 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder pdfinteraction::SurfaceBufferPointer pixels; std::shared_ptr resourceReservation; + /// The display-filtered copy of the tile for the settings it was built + /// with, when a display colour mode is active. `displaySource` is a key, + /// not an owner: `pixels` above keeps the source buffer alive for as + /// long as this entry exists, and the two are compared to decide whether + /// the copy still describes the frame. + pdfinteraction::SurfaceBufferPointer displayPixels; + const pdfinteraction::SurfaceBuffer* displaySource = nullptr; + CanvasDisplayColorSettings displaySettings; + QRect placedRect; bool exact = true; }; @@ -186,6 +205,7 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder QQuickWindow* m_window = nullptr; std::shared_ptr m_resourceBudget; CanvasPalette m_palette = CanvasPalette::standard(); + CanvasDisplayColorSettings m_displayColor; std::map m_tiles; QHash m_overlays; diff --git a/LoopLibQuick/sources/loopcanvasitem.cpp b/LoopLibQuick/sources/loopcanvasitem.cpp index 90e0c54f8..70ebed261 100644 --- a/LoopLibQuick/sources/loopcanvasitem.cpp +++ b/LoopLibQuick/sources/loopcanvasitem.cpp @@ -23,6 +23,7 @@ #include "loopcanvasitem.h" +#include "canvasdisplaycolor.h" #include "loopcanvasaccessible.h" #include "interactioncontroller.h" #include "pagesurfacecoordinator.h" @@ -259,6 +260,66 @@ void LoopCanvasItem::setHighContrast(bool highContrast) requestFrame(); } +QString LoopCanvasItem::displayColorMode() const +{ + return getCanvasDisplayColorModeName(m_displayColor.mode); +} + +bool LoopCanvasItem::setDisplayColorMode(const QString& modeName) +{ + const std::optional mode = canvasDisplayColorModeFromName(modeName); + if (!mode || m_displayColor.mode == *mode) + { + return mode.has_value(); + } + + m_displayColor.mode = *mode; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); + return true; +} + +void LoopCanvasItem::setDisplayColorCustomForeground(const QColor& color) +{ + if (!color.isValid() || m_displayColor.customForeground == color) + { + return; + } + + m_displayColor.customForeground = color; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + +void LoopCanvasItem::setDisplayColorCustomBackground(const QColor& color) +{ + if (!color.isValid() || m_displayColor.customBackground == color) + { + return; + } + + m_displayColor.customBackground = color; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + +void LoopCanvasItem::setDisplayColorBitonalThreshold(int threshold) +{ + const int clamped = qBound(1, threshold, 254); + if (m_displayColor.bitonalThreshold == clamped) + { + return; + } + + m_displayColor.bitonalThreshold = clamped; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + QString LoopCanvasItem::accessibleDocumentSummary() const { return m_accessibleDocumentSummary; @@ -290,6 +351,17 @@ void LoopCanvasItem::refreshPalette() m_overlaysDirty = true; } +void LoopCanvasItem::refreshDisplayColor() +{ + m_builder.setDisplayColorSettings(m_displayColor); + m_displayColorDirty = false; + + // The builder dropped the retained copies; the tiles must be synced again + // for the new mode to reach a texture. No surface is asked for: the filter + // reads pixels that are already admitted. + m_tilesDirty = true; +} + CanvasFrameStats LoopCanvasItem::frameStats() const { CanvasFrameStats stats; diff --git a/LoopLibQuick/sources/loopcanvasitem.h b/LoopLibQuick/sources/loopcanvasitem.h index 4eac2a96b..d27398d58 100644 --- a/LoopLibQuick/sources/loopcanvasitem.h +++ b/LoopLibQuick/sources/loopcanvasitem.h @@ -100,6 +100,13 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem Q_PROPERTY(QString activeTool READ activeTool NOTIFY activeToolChanged) Q_PROPERTY(bool traceOverlayVisible READ isTraceOverlayVisible WRITE setTraceOverlayVisible NOTIFY traceOverlayVisibleChanged) Q_PROPERTY(bool highContrast READ isHighContrast WRITE setHighContrast NOTIFY highContrastChanged) + /// Display colour modes (issue #235 display half). They filter the pixels + /// this item already holds; they are not render requests, so setting one + /// re-renders nothing and no surface identity depends on them. + Q_PROPERTY(QString displayColorMode READ displayColorMode WRITE setDisplayColorMode NOTIFY displayColorChanged) + Q_PROPERTY(QColor displayColorCustomForeground READ displayColorCustomForeground WRITE setDisplayColorCustomForeground NOTIFY displayColorChanged) + Q_PROPERTY(QColor displayColorCustomBackground READ displayColorCustomBackground WRITE setDisplayColorCustomBackground NOTIFY displayColorChanged) + Q_PROPERTY(int displayColorBitonalThreshold READ displayColorBitonalThreshold WRITE setDisplayColorBitonalThreshold NOTIFY displayColorChanged) Q_PROPERTY(QString accessibleDocumentSummary READ accessibleDocumentSummary WRITE setAccessibleDocumentSummary NOTIFY accessibleDocumentSummaryChanged) public: @@ -149,6 +156,18 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool isHighContrast() const noexcept { return m_highContrast; } void setHighContrast(bool highContrast); + QString displayColorMode() const; + /// Parses a display colour mode name once at this boundary and returns false + /// for a name outside the vocabulary, leaving the mode in place. + bool setDisplayColorMode(const QString& modeName); + bool isDisplayColorModeActive() const noexcept { return m_displayColor.isActive(); } + QColor displayColorCustomForeground() const noexcept { return m_displayColor.customForeground; } + void setDisplayColorCustomForeground(const QColor& color); + QColor displayColorCustomBackground() const noexcept { return m_displayColor.customBackground; } + void setDisplayColorCustomBackground(const QColor& color); + int displayColorBitonalThreshold() const noexcept { return m_displayColor.bitonalThreshold; } + void setDisplayColorBitonalThreshold(int threshold); + QString accessibleDocumentSummary() const; void setAccessibleDocumentSummary(const QString& summary); void notifyAccessibilityUpdate(); @@ -164,6 +183,7 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem void activeToolChanged(); void traceOverlayVisibleChanged(); void highContrastChanged(); + void displayColorChanged(); void accessibleDocumentSummaryChanged(); protected: @@ -211,6 +231,10 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem void publishViewportGeometry(); void refreshPalette(); + /// Pushes the display colour settings to the node builder and marks the + /// tiles for a re-sync. Called from prepareSceneGraph, on the render thread, + /// where the builder may be touched at all. + void refreshDisplayColor(); /// Reconnects the window-lifetime signals. Called from itemChange for every /// scene change, including the change to nullptr when the item is removed @@ -254,6 +278,7 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool m_traceOverlayVisible = false; bool m_highContrast = false; + CanvasDisplayColorSettings m_displayColor; QString m_accessibleDocumentSummary; /// Set when the snapshot or the frame changed and the scene graph has not @@ -281,6 +306,9 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool m_tilesDirty = true; bool m_overlaysDirty = true; bool m_paletteDirty = true; + /// Set on the GUI thread, acted on in prepareSceneGraph: the builder is + /// render-thread state, so a mode change only raises this flag there. + bool m_displayColorDirty = true; /// Written in updatePaintNode on the render thread, read on the GUI thread /// by frameStats(). Plain ints: the GUI thread is blocked while diff --git a/LoopLibQuick/sources/loopcanvasitemscene.cpp b/LoopLibQuick/sources/loopcanvasitemscene.cpp index a69aca768..abbfe3964 100644 --- a/LoopLibQuick/sources/loopcanvasitemscene.cpp +++ b/LoopLibQuick/sources/loopcanvasitemscene.cpp @@ -35,6 +35,11 @@ QSGNode* LoopCanvasItem::prepareSceneGraph(QSGNode* oldNode, QQuickWindow* hostW refreshPalette(); } + if (m_displayColorDirty) + { + refreshDisplayColor(); + } + if (m_builderResetPending.exchange(false)) { m_builder.forget(); @@ -139,8 +144,8 @@ int LoopCanvasItem::syncOverlays(QSGNode* root, qreal pixelScale) } void LoopCanvasItem::syncTraceHud(QSGNode* root, - QQuickWindow* hostWindow, - qreal devicePixelRatio) + QQuickWindow* hostWindow, + qreal devicePixelRatio) { const bool wantHud = m_traceOverlayVisible && m_recorder; QSGImageNode* hud = root->childCount() > HudChild diff --git a/LoopLibQuick/sources/loopstatevisual.cpp b/LoopLibQuick/sources/loopstatevisual.cpp index 7ca2a1955..b6bf5551c 100644 --- a/LoopLibQuick/sources/loopstatevisual.cpp +++ b/LoopLibQuick/sources/loopstatevisual.cpp @@ -267,12 +267,14 @@ LoopStateVisual resolveFixLifecycleStateVisual(const QString& stateName) QStringLiteral("No correction planned") }; } -QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact) +QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact, const QString& simulation) { - // The host owns these four facts; this orders them and names the result. An open + // The host owns these facts; this orders them and names the result. An open // document whose preview belongs to an earlier revision is stale before it is - // anything else, and an authoritative render is authoritative before it is judged - // on its (ascertained later) diagnostics. + // anything else; a simulation is a simulation before the render underneath it is + // judged, because the display transform is what the operator is looking at; and an + // authoritative render is authoritative before it is judged on its (ascertained + // later) diagnostics. if (!hasDocument) { return QStringLiteral("unavailable"); @@ -281,6 +283,10 @@ QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authorit { return QStringLiteral("stale"); } + if (!simulation.trimmed().isEmpty()) + { + return simulation.trimmed().toLower(); + } if (authoritative) { return QStringLiteral("authoritative"); @@ -305,6 +311,12 @@ QString previewFidelityOriginName(const QString& stateName) { return QStringLiteral("fast-canvas"); } + if (normalized == QLatin1String("display-filter") || normalized == QLatin1String("soft-proof")) + { + // The simulation names its own path: neither the fast canvas nor the + // output-preview render produced what is on screen. + return normalized; + } return QStringLiteral("none"); } @@ -333,6 +345,20 @@ LoopStateVisual resolvePreviewFidelityStateVisual(const QString& stateName) return { StateKind::Incomplete, ColorRole::StateIncomplete, StateIcon::Hatched, QStringLiteral("Preview is stale for the current revision") }; } + if (normalized == QLatin1String("display-filter")) + { + // A display colour mode is a presentation filter: the pixels are a simulation of + // the page, never a production render and never approval evidence. + return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::Slash, + QStringLiteral("Display colour simulation, not a production render") }; + } + if (normalized == QLatin1String("soft-proof")) + { + // Soft proof simulates the output intent through colour management. It is a + // simulation of print, not the print's evidence. + return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::HalfFilled, + QStringLiteral("Soft-proof simulation of the output intent") }; + } // "unavailable", an empty string and anything unrecognised: no preview to trust. return { StateKind::NotChecked, ColorRole::StateNotChecked, StateIcon::Outline, diff --git a/LoopLibQuick/sources/loopstatevisual.h b/LoopLibQuick/sources/loopstatevisual.h index 1a6b1972c..003538999 100644 --- a/LoopLibQuick/sources/loopstatevisual.h +++ b/LoopLibQuick/sources/loopstatevisual.h @@ -121,18 +121,26 @@ LOOPLIBQUICK_EXPORT LoopStateVisual resolvePreflightStateVisual(const QString& s LOOPLIBQUICK_EXPORT LoopStateVisual resolveFixLifecycleStateVisual(const QString& stateName); /// Render fidelity and origin of the interactive preview (#28). The state name is derived -/// from four facts the host already holds: whether a document is open, whether the preview -/// is stale for the open revision, whether the current page is showing the authoritative -/// output-preview render, and whether that page's render is diagnostic-exact. +/// from facts the host already holds: whether a document is open, whether the preview is +/// stale for the open revision, whether a display simulation is in force, whether the +/// current page is showing the authoritative output-preview render, and whether that page's +/// render is diagnostic-exact. /// /// `approximate` is the overprint-sensitive page on the fast canvas path: overprint is not /// simulated there, so those pixels are never proof of print-safe output. `authoritative` /// is the only state that names the output-preview origin. No preview state reaches a pass /// treatment: the interactive preview proves a page's render path, never publication safety. -LOOPLIBQUICK_EXPORT QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact); +/// +/// `simulation` is the presentation filter or the proofing simulation in force -- issue +/// #235's display colour modes (`display-filter`) or issue #237's soft proof +/// (`soft-proof`). It outranks `authoritative`: the pixels on screen have been transformed +/// for the display, so they are a simulation of the page whatever render produced them, and +/// no simulation may be read as the authoritative render or as approval evidence. +LOOPLIBQUICK_EXPORT QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact, const QString& simulation = {}); /// Which render path produced the preview's pixels, for a state name above: `none`, -/// `fast-canvas` or `output-preview`. Rendering only, no analysis. +/// `fast-canvas`, `output-preview`, `display-filter` or `soft-proof`. Rendering only, no +/// analysis. LOOPLIBQUICK_EXPORT QString previewFidelityOriginName(const QString& stateName); /// Canonical treatment for a preview fidelity state name. Each state takes a shape no other diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index ed124cc98..5526f0134 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -436,7 +436,9 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 26); + // The five display colour modes joined the implemented set in epic #231 PR1 + // (issue #235 display half), each with the `none` capability. + QCOMPARE(implemented, 31); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 3bd15b0ea..fe1f2dafd 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -307,6 +307,12 @@ private slots: void fixRollbackReturnsToARecordedRevision(); void previewFidelityNamesTheOriginAndSwitchesExplicitly(); void refusedRenderCannotBecomeAuthoritativeEvidence(); + void displayColorModesChangePresentationAndAreNeverProductionEvidence(); + void displayColorModeNamesAreParsedOnceAtTheBoundary(); + void softProofSimulatesThroughCoreColourManagementAndNeverBecomesEvidence(); + void softProofWithoutAUsableOutputIntentIsAVisibleState(); + void platesListSeparationsAndCarryVisibilityWithoutTouchingTheDocument(); + void inkCoverageReadoutAtAPointIsMeasuredOrExplicitlyUnavailable(); }; void EditorHostTest::encryptedOpen_data() @@ -2392,6 +2398,374 @@ void EditorHostTest::executeApprovedActionListPlanRefusesAnUnreviewedPlan() QVERIFY(host.executeApprovedActionListPlan()); } -QTEST_GUILESS_MAIN(EditorHostTest) +/// Issue #235, display half. A display colour mode changes what the canvas shows and +/// nothing else: it is marked a simulation by the fidelity indicator, and neither the +/// Compare workspace nor the production identity can see it. The failure case is a +/// display mode mistaken for a production render. +void EditorHostTest::displayColorModesChangePresentationAndAreNeverProductionEvidence() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 612, 792)); + const QString path = directory.filePath(QStringLiteral("display-modes.pdf")); + { + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + // Every display mode is a bound command with a document open. + const QStringList actions = { QStringLiteral("actionColorInvert"), QStringLiteral("actionColorGrayscale"), + QStringLiteral("actionColorHighContrast"), QStringLiteral("actionColorBitonal"), + QStringLiteral("actionColorCustom") }; + for (const QString& action : actions) + { + QVERIFY2(host.isCommandEnabled(action), qPrintable(action)); + } + + const QVariantMap compareBefore = host.compareReview(); + const QVariantMap identityBefore = host.previewIdentity(); + const QVariantMap signOffBefore = host.fixSignOff(); + const QString revisionBefore = host.sessionForTest()->facade().currentRevision().toString(); + + // The menu entry works, and what it produces is a simulation -- not the render + // path's fidelity, and never a pass. + QVERIFY(host.invokeCommand(QStringLiteral("actionColorInvert")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("invert")); + QVERIFY(host.displayColorModeActive()); + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("display-filter")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("display-filter")); + QVERIFY(host.previewFidelitySummary().contains(QStringLiteral("never recorded as approval evidence"))); + + const QVariantMap visual = host.previewFidelityVisual(); + QCOMPARE(visual.value(QStringLiteral("kind")).toString(), QStringLiteral("Warning")); + QVERIFY(visual.value(QStringLiteral("kind")).toString() != QStringLiteral("Passed")); + QVERIFY(visual.value(QStringLiteral("colorRole")).toString() != QStringLiteral("Success")); + QVERIFY(!visual.value(QStringLiteral("accessibleName")).toString().trimmed().isEmpty()); + + // The failure case: nothing in Compare or sign-off may mistake it for a production + // render. The comparison, the preview identity and the sign-off are the same facts + // they were before the mode existed. + QCOMPARE(host.compareReview(), compareBefore); + QCOMPARE(host.previewIdentity(), identityBefore); + QCOMPARE(host.fixSignOff(), signOffBefore); + QVERIFY(!host.fixPreview().value(QStringLiteral("available")).toBool()); + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), revisionBefore); + + // Invoking the mode already in force returns to normal: there is no sixth action, + // and a display mode with no way back is a trap. + QVERIFY(host.invokeCommand(QStringLiteral("actionColorInvert")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + QVERIFY(!host.displayColorModeActive()); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + // Each of the remaining modes is reachable and distinct. + for (const QString& action : { QStringLiteral("actionColorGrayscale"), QStringLiteral("actionColorHighContrast"), + QStringLiteral("actionColorBitonal"), QStringLiteral("actionColorCustom") }) + { + QVERIFY(host.invokeCommand(action) != 0); + QVERIFY(host.displayColorModeActive()); + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("display-filter")); + } + QCOMPARE(host.displayColorModeName(), QStringLiteral("custom")); + QVERIFY(host.invokeCommand(QStringLiteral("actionColorCustom")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); +} + +void EditorHostTest::displayColorModeNamesAreParsedOnceAtTheBoundary() +{ + EditorHost host; + + QVERIFY(!host.setDisplayColorMode(QStringLiteral("sepia"))); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + QVERIFY(!host.setDisplayColorMode(QString())); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + + QVERIFY(host.setDisplayColorMode(QStringLiteral("Grayscale"))); + QCOMPARE(host.displayColorModeName(), QStringLiteral("grayscale")); + QVERIFY(host.setDisplayColorMode(QStringLiteral("normal"))); + + // The custom mode's settings are validated at the boundary too: an invalid colour + // or an out-of-range threshold is refused rather than coerced. + QVERIFY(host.setDisplayColorCustomColors(QColor(10, 20, 30), QColor(200, 210, 220))); + QCOMPARE(host.displayColorCustomForeground(), QColor(10, 20, 30)); + QCOMPARE(host.displayColorCustomBackground(), QColor(200, 210, 220)); + QVERIFY(!host.setDisplayColorCustomColors(QColor(), QColor(200, 210, 220))); + QVERIFY(!host.setDisplayColorCustomColors(QColor(10, 20, 30), QColor())); + + QVERIFY(host.setDisplayColorBitonalThreshold(200)); + QCOMPARE(host.displayColorBitonalThreshold(), 200); + QVERIFY(!host.setDisplayColorBitonalThreshold(0)); + QVERIFY(!host.setDisplayColorBitonalThreshold(255)); + QCOMPARE(host.displayColorBitonalThreshold(), 200); +} + +/// Issue #237. The soft proof renders through Core's colour management under proofing +/// settings, shows the profile identity, and is marked a simulation. It is never +/// recorded as approval evidence, and it never replaces the document session's own CMS. +void EditorHostTest::softProofSimulatesThroughCoreColourManagementAndNeverBecomesEvidence() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-cmyk.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + // A page's fidelity is only reported once the shell has a layout and the + // page's surface has been requested. + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.currentPage(), 0, 30000); + + QCOMPARE(host.softProofStateName(), QStringLiteral("off")); + QVERIFY(!host.softProofActive()); + QVERIFY(!host.softProofSummary().trimmed().isEmpty()); + + QVERIFY(host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("simulating")); + QVERIFY(host.softProofActive()); + QVERIFY(!host.softProofProfileId().isEmpty()); + QCOMPARE(host.softProofProfileTypeName(), QStringLiteral("output-intent")); + // The identity shown is the profile's: its Core-issued identifier always, and + // its name when the profile carries one. + QVERIFY(host.softProofSummary().contains(host.softProofProfileId())); + QVERIFY(host.softProofSummary().contains(host.softProofProfileName().isEmpty() + ? host.softProofProfileId() + : host.softProofProfileName())); + + // The proof is its own render identity: the surfaces re-render as proofs, through + // Core's colour management, and the diagnostics carry the simulation's reason. + // Until that render is admitted the indicator honestly says `unavailable`, so the + // simulation state is asserted once the proof is on screen. + auto* surfaces = host.sessionForTest()->surfaces(); + QVERIFY(surfaces->isSoftProofing()); + QCOMPARE(surfaces->softProofProfile(), host.softProofProfileId()); + QTRY_VERIFY_WITH_TIMEOUT(surfaces->diagnosticsForPage(0).has_value() && surfaces->diagnosticsForPage(0)->reasons.contains(QStringLiteral("soft-proof/simulation")), + 30000); + QVERIFY(!host.pageFidelityIsExact()); + + // The indicator says simulation, and the origin names the proofing path rather + // than the fast canvas or the output-preview render. + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("soft-proof")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("soft-proof")); + const QVariantMap visual = host.previewFidelityVisual(); + QVERIFY(visual.value(QStringLiteral("kind")).toString() != QStringLiteral("Passed")); + QVERIFY(visual.value(QStringLiteral("colorRole")).toString() != QStringLiteral("Success")); + + // Never approval evidence: no correction preview, no sign-off, no comparison. + QVERIFY(!host.fixPreview().value(QStringLiteral("available")).toBool()); + QVERIFY(host.fixSignOff().value(QStringLiteral("planDigest")).toString().isEmpty()); + + // Turning it off restores the unproofed identity. + QVERIFY(!host.toggleSoftProof()); + QCOMPARE(host.softProofStateName(), QStringLiteral("off")); + QVERIFY(!host.softProofActive()); + QVERIFY(!surfaces->isSoftProofing()); + QVERIFY(host.softProofProfileId().isEmpty()); + QVERIFY(!host.previewFidelityStateName().contains(QStringLiteral("soft-proof"))); +} + +void EditorHostTest::softProofWithoutAUsableOutputIntentIsAVisibleState() +{ + // A document that names no output intent at all. + { + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-missing.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + QVERIFY(!host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("profile-missing")); + QVERIFY(!host.softProofActive()); + QVERIFY(host.softProofProfileId().isEmpty()); + QVERIFY(host.softProofSummary().contains(QStringLiteral("no output-intent profile"))); + + // Never a silent sRGB fallback: the surfaces keep the unproofed identity, and + // the indicator still describes the render path, not a simulation. + QVERIFY(!host.sessionForTest()->surfaces()->isSoftProofing()); + QVERIFY(host.previewFidelityStateName() != QStringLiteral("soft-proof")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("fast-canvas")); + } + + // A document that names one Core cannot read. + { + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-profile-invalid.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + QVERIFY(!host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("profile-invalid")); + QVERIFY(!host.softProofActive()); + QVERIFY(host.softProofSummary().contains(QStringLiteral("could not be read"))); + QVERIFY(!host.sessionForTest()->surfaces()->isSoftProofing()); + } +} + +/// Issue #237. The plates are Core's own separations for the document, and the +/// operator's visibility and isolation over them is presentation state: the document +/// revision does not move. +void EditorHostTest::platesListSeparationsAndCarryVisibilityWithoutTouchingTheDocument() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/color-inventory-spot.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + const QVariantList plates = host.productionPlates(); + QVERIFY(plates.size() >= 5); + + QSet ids; + bool sawProcess = false; + bool sawSpot = false; + for (const QVariant& entry : plates) + { + const QVariantMap row = entry.toMap(); + const QString id = row.value(QStringLiteral("id")).toString(); + QVERIFY(!id.isEmpty()); + QVERIFY(!row.value(QStringLiteral("displayName")).toString().isEmpty()); + QVERIFY(row.value(QStringLiteral("visible")).toBool()); + ids.insert(id); + if (row.value(QStringLiteral("kind")).toString() == QStringLiteral("spot")) + { + sawSpot = true; + QCOMPARE(row.value(QStringLiteral("spotColorName")).toString(), id); + } + else + { + sawProcess = true; + } + } + QVERIFY(sawProcess); + QVERIFY(sawSpot); + QVERIFY(ids.contains(QStringLiteral("Cyan"))); + QVERIFY(ids.contains(QStringLiteral("Black"))); + QVERIFY(ids.contains(QStringLiteral("PANTONE 185 C"))); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Every plate is shown"))); + + const QString revisionBefore = host.sessionForTest()->facade().currentRevision().toString(); + const QString spotId = QStringLiteral("PANTONE 185 C"); + + const auto rowFor = [&host](const QString& id) + { + for (const QVariant& entry : host.productionPlates()) + { + const QVariantMap row = entry.toMap(); + if (row.value(QStringLiteral("id")).toString() == id) + { + return row; + } + } + return QVariantMap(); + }; + + // Hiding a plate is presentation state, and it is visible in the summary. + QVERIFY(host.setPlateVisible(spotId, false)); + QVERIFY(!rowFor(spotId).value(QStringLiteral("visible")).toBool()); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Hidden plates"))); + QVERIFY(host.plateIsolationSummary().contains(spotId)); + + // Isolating one shows exactly one. + QVERIFY(host.isolatePlate(QStringLiteral("Cyan"))); + for (const QVariant& entry : host.productionPlates()) + { + const QVariantMap row = entry.toMap(); + QCOMPARE(row.value(QStringLiteral("visible")).toBool(), + row.value(QStringLiteral("id")).toString() == QStringLiteral("Cyan")); + QCOMPARE(row.value(QStringLiteral("isolated")).toBool(), + row.value(QStringLiteral("id")).toString() == QStringLiteral("Cyan")); + } + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Isolated plate: Cyan"))); + + QVERIFY(host.showAllPlates()); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Every plate is shown"))); + + // An unknown plate id is refused rather than inventing a plate. + QVERIFY(!host.setPlateVisible(QStringLiteral("No Such Ink"), false)); + QVERIFY(!host.isolatePlate(QStringLiteral("No Such Ink"))); + + // Presentation state: the document revision never moved. + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), revisionBefore); +} + +/// Issue #237. The readout is measured through Core's separation-accurate path, and a +/// readout that could not be produced is a visible reason -- never a zero, which would +/// read as "no ink here". +void EditorHostTest::inkCoverageReadoutAtAPointIsMeasuredOrExplicitlyUnavailable() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/color-cmyk.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + // A point on the current page is only meaningful once the shell has a layout. + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.currentPage(), 0, 30000); + + // Nothing has been measured yet. + QVERIFY(!host.inkCoverageReadout().value(QStringLiteral("available")).toBool()); + + // Inside the page: a real measurement with per-plate channels. The channel count + // is whatever the page's separated device space has -- three for this fixture -- + // so the assertion is on the shape of the answer, not on a number: named plates, + // coverage in [0, 1] each, and a total that is their sum. + QVERIFY(host.requestInkCoverageAtPoint(150.0, 150.0)); + QTRY_VERIFY_WITH_TIMEOUT(host.inkCoverageReadout().value(QStringLiteral("available")).toBool(), 30000); + const QVariantMap readout = host.inkCoverageReadout(); + QCOMPARE(readout.value(QStringLiteral("pageIndex")).toInt(), 0); + const int channelCount = readout.value(QStringLiteral("channelCount")).toInt(); + QVERIFY2(channelCount >= 1, + qPrintable(QStringLiteral("channelCount=%1").arg(channelCount))); + const QVariantList channels = readout.value(QStringLiteral("channels")).toList(); + QCOMPARE(channels.size(), channelCount); + + double channelSum = 0.0; + for (const QVariant& entry : channels) + { + const QVariantMap channel = entry.toMap(); + QVERIFY(!channel.value(QStringLiteral("name")).toString().isEmpty()); + QVERIFY(!channel.value(QStringLiteral("displayName")).toString().isEmpty()); + const double coverage = channel.value(QStringLiteral("coverage")).toDouble(); + QVERIFY(coverage >= 0.0 && coverage <= 1.0); + channelSum += coverage; + } + + const double total = readout.value(QStringLiteral("totalInkCoverage")).toDouble(); + QVERIFY(qAbs(total - channelSum) < 1e-6); + // The sampled point is inside the fixture's ink, so the readout is a measurement + // rather than a plausible zero. + QVERIFY(total > 0.0); + QVERIFY(readout.value(QStringLiteral("reason")).toString().isEmpty()); + + // Outside the page: a typed reason and available=false, not a zero readout. + QVERIFY(host.requestInkCoverageAtPoint(-100.0, -100.0)); + QTRY_COMPARE_WITH_TIMEOUT(host.inkCoverageReadout().value(QStringLiteral("reason")).toString(), + QStringLiteral("ink-coverage/point-outside-page"), 30000); + QVERIFY(!host.inkCoverageReadout().value(QStringLiteral("available")).toBool()); + QVERIFY(!host.inkCoverageReadout().contains(QStringLiteral("totalInkCoverage"))); + + // No document: the request is refused outright. + EditorHost empty; + QVERIFY(!empty.requestInkCoverageAtPoint(10.0, 10.0)); +} + +QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" diff --git a/UnitTests/tst_loopstatevisualtest.cpp b/UnitTests/tst_loopstatevisualtest.cpp index 007f797d2..fd460e1f0 100644 --- a/UnitTests/tst_loopstatevisualtest.cpp +++ b/UnitTests/tst_loopstatevisualtest.cpp @@ -241,6 +241,7 @@ private slots: void previewFidelityClassification_data(); void previewFidelityClassification(); + void previewFidelitySimulationOutranksTheRender(); void previewFidelityOrigin_data(); void previewFidelityOrigin(); void previewFidelityStatesStayDistinctWithoutColour(); @@ -838,6 +839,32 @@ void LoopStateVisualTest::previewFidelityClassification() QCOMPARE(classifyPreviewFidelityState(hasDocument, stale, authoritative, exact), expectedName); } +/// A display simulation is what the operator is looking at, so it outranks the +/// render path underneath it (issue #235 display half, issue #237). Only a stale +/// preview, which is not evidence for this revision at all, outranks it in turn. +void LoopStateVisualTest::previewFidelitySimulationOutranksTheRender() +{ + QCOMPARE(classifyPreviewFidelityState(true, false, true, true, QStringLiteral("soft-proof")), + QStringLiteral("soft-proof")); + QCOMPARE(classifyPreviewFidelityState(true, false, true, true, QStringLiteral("display-filter")), + QStringLiteral("display-filter")); + + // Case is the host's, but the classifier normalizes so the state name it returns + // is always the one the visual resolver and the origin table know. + QCOMPARE(classifyPreviewFidelityState(true, false, false, true, QStringLiteral(" Soft-Proof ")), + QStringLiteral("soft-proof")); + + // A preview that does not describe the open revision is not evidence first. + QCOMPARE(classifyPreviewFidelityState(true, true, true, true, QStringLiteral("soft-proof")), + QStringLiteral("stale")); + QCOMPARE(classifyPreviewFidelityState(false, false, true, true, QStringLiteral("soft-proof")), + QStringLiteral("unavailable")); + + // No simulation in force: the render path's own fidelity is the answer. + QCOMPARE(classifyPreviewFidelityState(true, false, false, true, QString()), + QStringLiteral("exact")); +} + void LoopStateVisualTest::previewFidelityOrigin_data() { QTest::addColumn("stateName"); @@ -849,6 +876,8 @@ void LoopStateVisualTest::previewFidelityOrigin_data() QTest::newRow("stale") << QStringLiteral("stale") << QStringLiteral("none"); QTest::newRow("unavailable") << QStringLiteral("unavailable") << QStringLiteral("none"); QTest::newRow("unrecognised") << QStringLiteral("garbage") << QStringLiteral("none"); + QTest::newRow("display filter") << QStringLiteral("display-filter") << QStringLiteral("display-filter"); + QTest::newRow("soft proof") << QStringLiteral("soft-proof") << QStringLiteral("soft-proof"); } void LoopStateVisualTest::previewFidelityOrigin() @@ -866,7 +895,8 @@ void LoopStateVisualTest::previewFidelityStatesStayDistinctWithoutColour() { const QStringList states = { QStringLiteral("unavailable"), QStringLiteral("stale"), QStringLiteral("exact"), QStringLiteral("approximate"), - QStringLiteral("authoritative") }; + QStringLiteral("authoritative"), QStringLiteral("display-filter"), + QStringLiteral("soft-proof") }; QSet
Windows installer fix
This release was withdrawn.
Initial beta release for non-production testing