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..4132559b7 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 @@ -121,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/.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/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index c1a4d85d9..5f232b2f4 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -26,12 +26,17 @@ qt_add_executable(LoopEditor main.cpp operatorhelp.cpp operatorhelp.h + operatorhelpstrings.h icon.rc app.qrc documentviewsession.cpp documentviewsession.h editorhost.cpp + quickoutput.cpp + quickoutput.h editorhost.h + editorsettings.cpp + editorsettings.h focusrestoration.cpp focusrestoration.h quickdocumentmodel.cpp @@ -45,6 +50,8 @@ qt_add_qml_module(LoopEditor QML_FILES qml/Main.qml qml/OperatorHelpDialog.qml + qml/SettingsDialog.qml + qml/DiagnosticsDialog.qml qml/Workspace.qml qml/DocumentPane.qml qml/CanvasPane.qml @@ -78,6 +85,9 @@ target_include_directories(LoopEditor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) qt_import_qml_plugins(LoopEditor) +# Explicit product builds must compile the catalogs before install consumes them. +add_dependencies(LoopEditor ${PROJECT_NAME}_lrelease) + # Packaging builds LoopEditor explicitly; ensure the Loop.Canvas plugin module is # built before install copies it into usr/lib/qml/Loop/Canvas. add_dependencies(LoopEditor LoopLibQuickplugin) diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 7ad871c89..c14cc95fa 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()); @@ -237,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 9de04b912..19f544b68 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; } @@ -78,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); @@ -131,6 +141,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..e28f9ef1c 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -33,8 +33,10 @@ #include "pagesurfacecoordinator.h" #include "actionlistcontroller.h" #include "actionlistrunsubmitter.h" +#include "directeditrequest.h" #include "repairparameterschema.h" #include "preflightcontroller.h" +#include "shellglossary.h" #include "preflightclirun.h" #include "preflightengine.h" #include "preflightprofileresolver.h" @@ -43,9 +45,14 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdirectedit.h" +#include "pdfdocumentimageextractor.h" +#include "pdfannotationauthoring.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" +#include "pdfcertificatestore.h" +#include "pdfform.h" #include "pdfoperationhistorystore.h" #include "pdfpreflightaudit.h" #include "pdfpreflightcertificate.h" @@ -53,13 +60,17 @@ #include "pdfsafefilewriter.h" #include "pdfblockingthreadguard.h" +#include "pdfcms.h" +#include "pdfdiagnostics.h" #include "pdfpage.h" +#include "pdfrenderer.h" #include "pdftransparencyrenderer.h" #include #include #include #include +#include #include #include #include @@ -77,7 +88,9 @@ #include #include +#include #include +#include #include #include #include @@ -90,6 +103,56 @@ const QString QuitCommandId = QStringLiteral("actionQuit"); const QString MoveSelectionCommandId = QStringLiteral("actionMoveSelection"); const QString TranslatePageBoxOperationId = QStringLiteral("translate-page-box"); +/// The destination type each `actionCreateHyperlinkToThisPDF*` command writes +/// (issue #233). `XYZInheritZoom` is the XYZ destination with a null zoom, +/// which asks the viewer to keep its own zoom. +struct HyperlinkDestinationVariant +{ + const char* command; + pdf::DestinationType type; + bool inheritZoom; +}; + +const HyperlinkDestinationVariant HyperlinkDestinationVariants[] = { + { "actionCreateHyperlinkToThisPDFFit", pdf::DestinationType::Fit, false }, + { "actionCreateHyperlinkToThisPDFFitB", pdf::DestinationType::FitB, false }, + { "actionCreateHyperlinkToThisPDFFitBH", pdf::DestinationType::FitBH, false }, + { "actionCreateHyperlinkToThisPDFFitBV", pdf::DestinationType::FitBV, false }, + { "actionCreateHyperlinkToThisPDFFitH", pdf::DestinationType::FitH, false }, + { "actionCreateHyperlinkToThisPDFFitR", pdf::DestinationType::FitR, false }, + { "actionCreateHyperlinkToThisPDFFitV", pdf::DestinationType::FitV, false }, + { "actionCreateHyperlinkToThisPDFXYZ", pdf::DestinationType::XYZ, false }, + { "actionCreateHyperlinkToThisPDFXYZInheritZoom", pdf::DestinationType::XYZ, true }, +}; + +/// Reads a page-space rectangle from four scalar parameters, or nothing when +/// the surface supplied none. A partial rectangle is refused: a corner the +/// surface never gave is not a corner the host may invent. +std::optional rectangleFromParameters(const QVariantMap& parameters, + const QString& leftKey, + const QString& bottomKey, + const QString& rightKey, + const QString& topKey, + QString& typedError) +{ + const bool any = parameters.contains(leftKey) || parameters.contains(bottomKey) || + parameters.contains(rightKey) || parameters.contains(topKey); + if (!any) + { + return std::nullopt; + } + + if (!parameters.contains(leftKey) || !parameters.contains(bottomKey) || + !parameters.contains(rightKey) || !parameters.contains(topKey)) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return std::nullopt; + } + + return QRectF(QPointF(parameters.value(leftKey).toDouble(), parameters.value(bottomKey).toDouble()), + QPointF(parameters.value(rightKey).toDouble(), parameters.value(topKey).toDouble())); +} + QString actionListBindingsHash(const QJsonObject& bindings) { return QString::fromLatin1(QCryptographicHash::hash(QJsonDocument(bindings).toJson(QJsonDocument::Compact), @@ -167,6 +230,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) @@ -271,6 +343,20 @@ QString shellMenuGroupForAction(const QString& id, const QString& target) return QStringLiteral("Document"); } +pdf::PDFRenderer::Features featuresForOptions(const EditorSettings::RenderOptions& options) +{ + pdf::PDFRenderer::Features features = pdf::PDFRenderer::getDefaultFeatures(); + features.setFlag(pdf::PDFRenderer::Antialiasing, options.antialiasing); + features.setFlag(pdf::PDFRenderer::TextAntialiasing, options.textAntialiasing); + features.setFlag(pdf::PDFRenderer::SmoothImages, options.smoothPictures); + features.setFlag(pdf::PDFRenderer::DisplayAnnotations, options.displayAnnotations); + features.setFlag(pdf::PDFRenderer::IgnoreOptionalContent, options.ignoreOptionalContentSettings); + features.setFlag(pdf::PDFRenderer::DisplayTimes, options.displayRenderTimes); + features.setFlag(pdf::PDFRenderer::DebugTextBlocks, options.showTextBlocks); + features.setFlag(pdf::PDFRenderer::DebugTextLines, options.showTextLines); + return features; +} + QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descriptor, bool enabled) { QVariantMap entry; @@ -280,7 +366,11 @@ QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descrip entry.insert(QStringLiteral("enabled"), enabled); entry.insert(QStringLiteral("target"), descriptor.target); entry.insert(QStringLiteral("disposition"), descriptor.disposition); - entry.insert(QStringLiteral("menuGroup"), shellMenuGroupForAction(descriptor.id, descriptor.target)); + // Menu placement is the manifest's routing policy (menu_group); the prefix + // table is only the fallback for a descriptor that arrives without one. + entry.insert(QStringLiteral("menuGroup"), + descriptor.menuGroup.isEmpty() ? shellMenuGroupForAction(descriptor.id, descriptor.target) + : descriptor.menuGroup); QVariantMap shortcut; shortcut.insert(QStringLiteral("standardKey"), descriptor.shortcut.standardKey); @@ -302,7 +392,7 @@ struct EditorHost::PreflightWorkerOutcome QJsonObject auditSummary; }; -EditorHost::EditorHost(QObject* parent) : +EditorHost::EditorHost(EditorSettings* settings, QObject* parent, QString recipesDirectory) : QObject(parent), m_session(std::make_unique(this)), m_findingNavigator(std::make_unique( @@ -313,8 +403,19 @@ EditorHost::EditorHost(QObject* parent) : pdfinteraction::FindingTargetingCapabilityRegistry::defaultRegistry(), this)), m_preflight(&m_session->scheduler(), this), - m_actionListController(&m_session->scheduler(), this) + m_actionListController(&m_session->scheduler(), this), + m_actionListCatalog(nullptr, std::move(recipesDirectory)) { + if (settings) + { + m_settings = settings; + } + else + { + m_ownedSettings = std::make_unique(nullptr, this); + m_settings = m_ownedSettings.get(); + } + // Registers this constructing thread -- the one QML dispatches pointer // and frame callbacks on -- as the thread blocking service adapters // (PreflightEngine::run, and future OCR/AI/file-I/O adapters) must @@ -344,6 +445,12 @@ EditorHost::EditorHost(QObject* parent) : registerShellHandlers(); registerFeatureHandlers(); + // One apply path for every option change, wherever it came from: the + // command handlers, the settings surface, or a factory reset. + connect(m_settings, &EditorSettings::optionsChanged, this, [this] + { applyRenderOptions(); }); + connect(m_settings, &EditorSettings::noticeChanged, this, &EditorHost::bumpPresentation); + m_preflightOverlayBridge.setFindingsModel(m_preflight.findingsModel()); m_preflightOverlayBridge.setOverlayBuilder(m_session->overlays()); m_preflightOverlayBridge.setInteractionController(m_session->interaction()); @@ -416,6 +523,9 @@ EditorHost::EditorHost(QObject* parent) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishInkCoverageJob(snapshot); + finishDocumentOutput(snapshot); + finishSignatureCheckJob(snapshot); refreshCanvasTrace(); }); } @@ -426,6 +536,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 @@ -546,6 +658,181 @@ 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; +} + +bool EditorHost::importBookmarksFileUrl(const QUrl& url, const QString& revision) +{ + if (!url.isValid() || !url.isLocalFile()) + { + return false; + } + + return invokeCommand(QStringLiteral("actionBookmarkImport"), + { { QStringLiteral("path"), url.toLocalFile() }, + { QStringLiteral("revision"), revision } }) != pdfinteraction::InvalidCommandInvocation; +} + +bool EditorHost::exportBookmarksFileUrl(const QUrl& url, const QString& revision) +{ + if (!url.isValid() || !url.isLocalFile()) + { + return false; + } + + return invokeCommand(QStringLiteral("actionBookmarkExport"), + { { QStringLiteral("path"), url.toLocalFile() }, + { QStringLiteral("revision"), revision } }) != pdfinteraction::InvalidCommandInvocation; +} + QObject* EditorHost::preflight() { return &m_preflight; @@ -638,31 +925,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 @@ -704,7 +966,8 @@ pdf::PDFActionListExecutionResult EditorHost::fixRunResult() const bool EditorHost::fixExecutionArmed() const { return m_session->facade().permissions().allowsCorrection() && - fixLifecycleStateName() == QLatin1String("approved") && fixPlanIsCurrent(); + fixLifecycleStateName() == QLatin1String("approved") && fixPlanIsCurrent() && + (!planSignatureInvalidationApplies() || planSignatureInvalidationAcknowledged()); } QString EditorHost::fixLifecycleStateName() const @@ -790,6 +1053,11 @@ QString EditorHost::fixLifecycleSummary() const } if (state == QLatin1String("approved")) { + if (planSignatureInvalidationApplies() && !planSignatureInvalidationAcknowledged()) + { + return tr("The approved plan invalidates document signatures. Acknowledge the signature " + "invalidation before executing."); + } return tr("The approved plan is bound to revision %1 and is ready to execute.") .arg(m_fixPlannedDocumentRevision.left(12)); } @@ -844,6 +1112,8 @@ QVariantMap EditorHost::fixPlanIdentity() const : (m_fixReviewDecision == FixReviewDecision::Rejected ? QStringLiteral("rejected") : QStringLiteral("none"))); identity.insert(QStringLiteral("executionArmed"), fixExecutionArmed()); + identity.insert(QStringLiteral("signatureInvalidationApplies"), planSignatureInvalidationApplies()); + identity.insert(QStringLiteral("signatureInvalidationAcknowledged"), planSignatureInvalidationAcknowledged()); return identity; } @@ -860,6 +1130,7 @@ QVariantMap EditorHost::fixPreview() const preview.insert(QStringLiteral("pageFidelityIsExact"), pageFidelityIsExact()); preview.insert(QStringLiteral("pageFidelityReason"), pageFidelityReason()); preview.insert(QStringLiteral("pageFidelityIsAuthoritative"), pageFidelityIsAuthoritative()); + preview.insert(QStringLiteral("signatureInvalidation"), signatureInvalidationMap()); QVariantList steps; QVariantList plannedSteps; @@ -1025,10 +1296,132 @@ QVariantMap EditorHost::fixSignOff() const return signOff; } +bool EditorHost::planSignatureInvalidationApplies() const +{ + if (!hasDocument()) + { + return false; + } + if (m_session->facade().signatureFacts().state == pdfinteraction::DocumentSignatureState::None) + { + return false; + } + // The plan's own merged save policy, the field Core serializes as + // `invalidates_signatures` and includes in the plan digest. + return fixRunResult().savePolicy.invalidatesSignatures; +} + +bool EditorHost::planSignatureInvalidationAcknowledged() const +{ + return !m_fixSignatureInvalidationAcknowledgedDigest.isEmpty() && + m_fixSignatureInvalidationAcknowledgedDigest == fixCurrentPlanDigest(); +} + +QString EditorHost::planSignatureInvalidationSummary() const +{ + if (!planSignatureInvalidationApplies()) + { + return QString(); + } + + const pdfinteraction::DocumentSignatureFacts facts = m_session->facade().signatureFacts(); + const QString rationale = fixRunResult().savePolicy.rationale; + return tr("This plan invalidates %1 document signature(s): %2 The published correction will " + "not carry a verifiable signature.") + .arg(facts.signatureCount) + .arg(rationale); +} + +QVariantMap EditorHost::signatureInvalidationMap() const +{ + const pdfinteraction::DocumentSignatureFacts facts = m_session->facade().signatureFacts(); + + QVariantMap invalidation; + invalidation.insert(QStringLiteral("applies"), planSignatureInvalidationApplies()); + invalidation.insert(QStringLiteral("acknowledged"), planSignatureInvalidationAcknowledged()); + invalidation.insert(QStringLiteral("signatureStateName"), signatureStateName()); + invalidation.insert(QStringLiteral("signatureCount"), facts.signatureCount); + invalidation.insert(QStringLiteral("summary"), planSignatureInvalidationSummary()); + return invalidation; +} + +bool EditorHost::acknowledgePlanSignatureInvalidation(const QString& planDigest) +{ + if (m_actionListController.state() != pdfinteraction::ActionListController::State::Planned || + !fixPlanIsCurrent() || planDigest.isEmpty() || + planDigest != m_fixPlannedPlanDigest || planDigest != fixCurrentPlanDigest()) + { + return false; + } + if (!planSignatureInvalidationApplies()) + { + return false; + } + + m_fixSignatureInvalidationAcknowledgedDigest = planDigest; + announceDocumentState(tr("Signature invalidation acknowledged for this plan.")); + bumpPresentation(); + return true; +} + +QString EditorHost::signatureStateName() const +{ + return QString::fromLatin1( + pdfinteraction::getDocumentSignatureStateName(m_session->facade().signatureFacts().state)); +} + +QVariantMap EditorHost::signatureStateVisual() const +{ + return fixStateVisualMap( + pdfquick::tokens::resolveSignatureStateVisual(signatureStateName())); +} + +QColor EditorHost::signatureStateColor() const +{ + const pdfquick::tokens::LoopStateVisual visual = + pdfquick::tokens::resolveSignatureStateVisual(signatureStateName()); + const pdfquick::tokens::LoopTheme theme = + highContrast() ? pdfquick::tokens::LoopTheme::HighContrast : pdfquick::tokens::LoopTheme::Dark; + return pdfquick::tokens::color(visual.colorRole, theme); +} + +QString EditorHost::signatureSummary() const +{ + const pdfinteraction::DocumentSignatureFacts facts = m_session->facade().signatureFacts(); + + switch (facts.state) + { + case pdfinteraction::DocumentSignatureState::None: + return hasDocument() ? tr("No signature is present in this document.") + : tr("Open a document to check its signatures."); + case pdfinteraction::DocumentSignatureState::Valid: + return tr("%1 signature(s) verified against the document's source bytes.") + .arg(facts.signatureCount); + case pdfinteraction::DocumentSignatureState::Invalid: + return tr("At least one signature does not match the document's source bytes. The " + "document was changed after it was signed, or the signature is damaged."); + case pdfinteraction::DocumentSignatureState::ModifiedAfterSigning: + return tr("The document carries bytes added after it was signed; the existing " + "signature(s) do not cover the whole file."); + case pdfinteraction::DocumentSignatureState::Unknown: + break; + } + + if (!facts.detail.isEmpty()) + { + return facts.detail; + } + return tr("The signature(s) could not be verified with the certificates available to this " + "machine."); +} + 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 +1429,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."); } @@ -1104,10 +1529,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 @@ -1179,6 +1622,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."); } @@ -1431,6 +1888,30 @@ bool EditorHost::navigateCompareDelta(int deltaIndex) return true; } +bool EditorHost::navigateToAnnotation(int row) +{ + if (!hasDocument() || !m_documentBound) + { + return false; + } + + QuickAnnotationModel& annotations = m_documentModel.annotationModel(); + if (row < 0 || row >= annotations.rows().size()) + { + return false; + } + + const QuickAnnotation& annotation = annotations.rows().at(row); + annotations.setSelectedRow(row); + refreshAnnotationOverlay(); + + const bool revealed = m_session->viewport().revealPageRegion(annotation.pageIndex, + annotation.bounds, 0.2); + updateCanvasAccessibilitySummary(); + bumpPresentation(); + return revealed; +} + QString EditorHost::productionStateName() const { if (!hasDocument()) @@ -1460,17 +1941,117 @@ bool EditorHost::allowDeveloperDiagnostics() const #endif } -void EditorHost::acknowledgeSearchPanel() +QString EditorHost::settingsNotice() { - if (!m_searchPanelVisible) - { - return; - } + return m_settings->notice(); +} + +QVariantMap EditorHost::diagnosticsBundle() const +{ + QVariantMap bundle; + bundle.insert(QStringLiteral("status"), m_diagnosticsBundle.status); + bundle.insert(QStringLiteral("directory"), m_diagnosticsBundle.directory); + bundle.insert(QStringLiteral("error"), m_diagnosticsBundle.error); + bundle.insert(QStringLiteral("fileCount"), m_diagnosticsBundle.fileCount); + return bundle; +} + +bool EditorHost::requestSettingsReset() +{ + m_settingsResetPending = true; + Q_EMIT settingsResetRequested(); + bumpPresentation(); + return true; +} + +bool EditorHost::confirmSettingsReset() +{ + if (!m_settingsResetPending) + { + return false; + } + m_settingsResetPending = false; + + // resetToFactory() reports its own failure through the store notice; the + // typed state is only replaced when every owned key was removed. + const bool reset = m_settings->resetToFactory(); + bumpPresentation(); + return reset; +} + +void EditorHost::cancelSettingsReset() +{ + if (!m_settingsResetPending) + { + return; + } + m_settingsResetPending = false; + bumpPresentation(); +} + +bool EditorHost::collectDiagnosticsFileUrl(const QUrl& parentDirectory) +{ + if (!parentDirectory.isValid() || !parentDirectory.isLocalFile() || parentDirectory.toLocalFile().isEmpty()) + { + m_diagnosticsBundle.status = QStringLiteral("failed"); + m_diagnosticsBundle.directory.clear(); + m_diagnosticsBundle.fileCount = 0; + m_diagnosticsBundle.error = tr("Choose a local folder for the diagnostics bundle."); + bumpPresentation(); + return false; + } + + pdf::PDFDiagnosticsOptions options; + options.applicationId = QStringLiteral("editor"); + options.outputDirectory = parentDirectory.toLocalFile(); + const pdf::PDFDiagnosticsResult result = pdf::PDFDiagnosticsCollector::collect(options); + + if (!result.success) + { + m_diagnosticsBundle.status = QStringLiteral("failed"); + m_diagnosticsBundle.directory.clear(); + m_diagnosticsBundle.fileCount = 0; + m_diagnosticsBundle.error = result.errorMessage.isEmpty() + ? tr("The diagnostics bundle could not be written.") + : result.errorMessage; + bumpPresentation(); + return false; + } + + m_diagnosticsBundle.status = QStringLiteral("ready"); + m_diagnosticsBundle.error.clear(); + m_diagnosticsBundle.directory = result.bundleDirectory; + m_diagnosticsBundle.fileCount = static_cast(result.files.size()); + bumpPresentation(); + return true; +} + +void EditorHost::applyRenderOptions() +{ + m_session->setSurfaceRenderFeatures(featuresForOptions(m_settings->renderOptions())); + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + bumpPresentation(); +} + +void EditorHost::acknowledgeSearchPanel() +{ + if (!m_searchPanelVisible) + { + return; + } m_searchPanelVisible = false; Q_EMIT presentationChanged(); } +QString EditorHost::localizedTerm(const QString& code) const +{ + return pdfinteraction::glossary::term(code); +} + QString EditorHost::preflightStateName() const { return preflightStateToString(m_preflight.state()); @@ -1662,6 +2243,13 @@ bool EditorHost::pageFidelityIsExact() const return true; } + if (m_settings->isOptionEnabled(EditorSettings::IgnoreOptionalContentOptionId)) + { + // The optional-content override deliberately deviates from the + // document's own view state; no render under it is an exact render. + return false; + } + const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); return !diagnostics.has_value() || diagnostics->isExact(); } @@ -1673,13 +2261,20 @@ QString EditorHost::pageFidelityReason() const return QString(); } + QStringList reasons; + if (m_settings->isOptionEnabled(EditorSettings::IgnoreOptionalContentOptionId)) + { + reasons.append(tr("The optional content override is enabled: this render shows all optional " + "content regardless of the document's settings.")); + } + const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); - if (!diagnostics.has_value() || diagnostics->reasons.isEmpty()) + if (diagnostics.has_value() && !diagnostics->reasons.isEmpty()) { - return QString(); + reasons.append(diagnostics->reasons); } - return diagnostics->reasons.join(QStringLiteral(" ")); + return reasons.join(QStringLiteral(" ")); } bool EditorHost::pageFidelityIsAuthoritative() const @@ -1717,6 +2312,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()) @@ -1930,15 +3126,15 @@ bool EditorHost::runPreflight() } const auto* security = document->getStorage().getSecurityHandler(); receipt.limitations.append(security && security->getMode() != pdf::EncryptionMode::None - ? QStringLiteral("Document is encrypted; authenticated document permissions apply.") - : QStringLiteral("Document is not encrypted.")); + ? tr("Document is encrypted; authenticated document permissions apply.") + : tr("Document is not encrypted.")); outcome->auditSummary.insert(QStringLiteral("inspection_receipt"), receipt.toJson()); if (context.isCancellationRequested()) return; context.reportProgress(95); - context.setResultSummary(QStringLiteral("Preflight completed.")); + context.setResultSummary(tr("Preflight completed.")); }); if (submittedId != jobId) { @@ -2441,9 +3637,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 +3738,10 @@ bool EditorHost::submitActionListJob(pdfinteraction::ActionListRunPhase phase, outcome, preflightProfilePath, preflightProfile, - preflightProfileBindings)); + preflightProfileBindings, + historyDocumentPath(), + phase == pdfinteraction::ActionListRunPhase::Execute && + planSignatureInvalidationAcknowledged())); if (submittedId != jobId) { m_actionListOutcomes.remove(jobId); @@ -2570,6 +3769,11 @@ bool EditorHost::runActionList() { if (!fixExecutionArmed()) { + if (planSignatureInvalidationApplies() && !planSignatureInvalidationAcknowledged()) + { + announceDocumentState(tr("The plan invalidates document signatures. Acknowledge the " + "signature invalidation before executing.")); + } return false; } return submitActionListJob(pdfinteraction::ActionListRunPhase::Execute, @@ -2585,6 +3789,7 @@ void EditorHost::clearFixReview() { m_fixReviewDecision = FixReviewDecision::None; m_fixReviewedPlanDigest.clear(); + m_fixSignatureInvalidationAcknowledgedDigest.clear(); } bool EditorHost::approveActionListPlan() @@ -2826,44 +4031,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 +4106,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,17 +4144,30 @@ 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.")); return false; } targetExecutionId = event->executionId; + history.close(); // P2: a rollback revalidates the restored revision through the #38 path, so the // Editor must supply the effective profile it already holds. Without a validated @@ -2958,8 +4204,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 +4220,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 +4240,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()); + tr("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 +4282,8 @@ bool EditorHost::requestFixRollback(const QString& rollbackId) return false; } - // The rollback is scheduled, not complete: finishRollbackJob announces the outcome. + refreshFeatureAvailability(); + bumpPresentation(); return true; } @@ -3136,7 +4386,7 @@ void EditorHost::reloadPreflightProfiles() if (parseError.error != QJsonParseError::NoError || !parsed.isObject()) { choice.name = QFileInfo(source).completeBaseName(); - choice.diagnostic = QStringLiteral("Profile JSON is invalid."); + choice.diagnostic = tr("Profile JSON is invalid."); profiles.append(choice); return; } @@ -3290,10 +4540,26 @@ bool EditorHost::commandPermissionAllowed(const QString& commandId) const return permissions.copy; } if (commandId == MoveSelectionCommandId || commandId == QLatin1String("actionPageGeometry") || - commandId == QLatin1String("actionInsertPageNumbers") || commandId == QLatin1String("actionStickyNoteInsert")) + commandId == QLatin1String("actionInsertPageNumbers")) { return permissions.allowsCorrection(); } + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + if (commandId == entry.first) + { + return permissions.modifyAnnotations; + } + } + if (commandId == QLatin1String("actionSetFormFieldValue")) + { + return m_documentModel.canFillForms(); + } + if (commandId == QLatin1String("actionAddAttachment") || commandId == QLatin1String("actionRemoveAttachment") || + commandId == QLatin1String("actionEditDocumentMetadata")) + { + return m_documentModel.canModify(); + } return true; } @@ -3348,6 +4614,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()) @@ -3471,6 +4892,7 @@ void EditorHost::connectFacade() { m_documentModel.setDocument(&m_session->context()); m_searchRow = -1; + refreshAnnotationOverlay(); bumpPresentation(); } }); @@ -3525,6 +4947,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() @@ -3553,6 +4994,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; @@ -3587,18 +5054,604 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionProperties"), [this] { setWorkspace(LoopWorkspace::Inspect); }); - pdfinteraction::CommandCatalog::Handler move; - move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + // ---- 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(); }); + + // 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)); }); + } + + // Shell settings, render options and diagnostics (#239). Every one of these + // is ADVANCED-disposition and developer-gated; binding a handler does not + // make it reachable in a release profile. + bind(QStringLiteral("actionOptions"), [this] + { Q_EMIT settingsPanelRequested(); }); + bind(QStringLiteral("actionResetToFactorySettings"), [this] + { requestSettingsReset(); }); + bind(QStringLiteral("actionCollectDiagnostics"), [this] + { Q_EMIT diagnosticsCollectionRequested(); }); + + auto bindOptionToggle = [this](const QString& commandId, const QString& optionId) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, optionId](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool enabled = !m_settings->isOptionEnabled(optionId); + const bool applied = m_settings->setOptionEnabled(optionId, enabled); + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : QStringLiteral("settings/persist-failed")); + bumpPresentation(); + }; + m_session->catalog().setHandler(commandId, std::move(handler)); + }; + + bindOptionToggle(QStringLiteral("actionRenderOptionAntialiasing"), EditorSettings::AntialiasingOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionTextAntialiasing"), EditorSettings::TextAntialiasingOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionSmoothPictures"), EditorSettings::SmoothPicturesOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionDisplayAnnotations"), EditorSettings::DisplayAnnotationsOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionIgnoreOptionalContentSettings"), EditorSettings::IgnoreOptionalContentOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionDisplayRenderTimes"), EditorSettings::DisplayRenderTimesOptionId); + bindOptionToggle(QStringLiteral("actionShow_Text_Blocks"), EditorSettings::ShowTextBlocksOptionId); + bindOptionToggle(QStringLiteral("actionShow_Text_Lines"), EditorSettings::ShowTextLinesOptionId); + + pdfinteraction::CommandCatalog::Handler move; + move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const bool routed = requestMoveSelection(parameters); + m_session->catalog().finishInvocation(invocation, + routed ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + routed ? QString() : QStringLiteral("move/rejected")); + 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)); + } + + // ---- Bookmarks and hyperlinks (issue #233, ADR-014) ------------------------------- + // Every edit is one typed direct-edit request on the document facade, which + // fences the revision and the document's modify permission before Core + // writes the outline or the link annotation. Nothing here and nothing in + // QML edits the outline model; it re-reads the revision the edit produced. + auto bindAuthoringEdit = [this](const QString& id, + std::function(const QVariantMap&, QString&)> build) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, build = std::move(build)](pdfinteraction::CommandInvocationId invocation, + const QVariantMap& parameters) + { + QString typedError; + const std::optional edit = build(parameters, typedError); + runAuthoringEdit(invocation, edit, typedError, parameters); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindAuthoringEdit(QStringLiteral("actionBookmarkPage"), + [this](const QVariantMap& parameters, QString& typedError) + { + const int page = currentPage(); + if (page < 0) + { + // The viewport has placed no page yet, so there is + // no page the operator is on. Refuse with that + // reason instead of blaming the document's page + // range, which is not what is wrong. + typedError = QStringLiteral("bookmark/no-current-page"); + return std::optional(); + } + + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + edit.text = parameters.value(QStringLiteral("title")).toString(); + edit.pageIndex = page; + return std::optional(edit); + }); + bindAuthoringEdit(QStringLiteral("actionBookmarkAutoGenerate"), + [](const QVariantMap&, QString&) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::GenerateBookmarks; + return std::optional(edit); + }); + bindAuthoringEdit(QStringLiteral("actionCreateHyperlink"), + [this](const QVariantMap& parameters, QString& typedError) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddUriLink; + edit.text = parameters.value(QStringLiteral("uri")).toString(); + if (edit.text.isEmpty()) + { + typedError = QStringLiteral("hyperlink/uri-required"); + return std::optional(); + } + if (!authoringLinkGeometry(parameters, &edit.pageIndex, &edit.rectangle, typedError)) + { + return std::optional(); + } + return std::optional(edit); + }); + + for (const HyperlinkDestinationVariant& variant : HyperlinkDestinationVariants) + { + bindAuthoringEdit(QString::fromLatin1(variant.command), + [this, variant](const QVariantMap& parameters, QString& typedError) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddDestinationLink; + edit.destinationType = variant.type; + if (!authoringLinkGeometry(parameters, &edit.pageIndex, &edit.rectangle, typedError)) + { + return std::optional(); + } + + edit.targetPageIndex = + parameters.value(QStringLiteral("targetPage"), currentPage()).toInt(); + const std::optional target = + rectangleFromParameters(parameters, + QStringLiteral("targetLeft"), + QStringLiteral("targetBottom"), + QStringLiteral("targetRight"), + QStringLiteral("targetTop"), + typedError); + if (!typedError.isEmpty()) + { + return std::optional(); + } + if (target.has_value()) + { + edit.targetRectangle = *target; + } + + // XYZ without an explicit zoom uses the zoom on + // screen; the inherit-zoom variant writes the PDF + // null zoom instead, which keeps the viewer's own. + edit.zoom = variant.inheritZoom + ? std::numeric_limits::quiet_NaN() + : parameters.value(QStringLiteral("zoom"), m_session->viewport().zoom()).toDouble(); + return std::optional(edit); + }); + } + + // Import and export carry a file, so the file is read or written here and + // only the outline replacement is a document edit. Without a path the + // command asks the shell for one and answers through + // importBookmarksFileUrl()/exportBookmarksFileUrl(). + pdfinteraction::CommandCatalog::Handler importBookmarks; + importBookmarks.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString path = parameters.value(QStringLiteral("path")).toString(); + if (path.isEmpty()) + { + Q_EMIT bookmarkImportRequested(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + return; + } + + QString typedError; + std::optional file; + QFile source(path); + if (!source.open(QIODevice::ReadOnly)) + { + typedError = QStringLiteral("bookmark/file-unreadable"); + } + else + { + file = pdf::PDFBookmarkFile::fromJson(source.readAll(), typedError); + } + + std::optional edit; + if (file.has_value()) + { + pdf::PDFAuthoringEdit replacement; + replacement.kind = pdf::PDFAuthoringEdit::Kind::ReplaceBookmarks; + replacement.bookmarks = file->bookmarks; + edit = replacement; + } + runAuthoringEdit(invocation, edit, typedError, parameters); + }; + m_session->catalog().setHandler(QStringLiteral("actionBookmarkImport"), std::move(importBookmarks)); + + pdfinteraction::CommandCatalog::Handler exportBookmarks; + exportBookmarks.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString path = parameters.value(QStringLiteral("path")).toString(); + if (path.isEmpty()) + { + Q_EMIT bookmarkExportRequested(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + return; + } + + QString typedError; + pdf::PDFBookmarkFile file; + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + const QString displayed = parameters.value(QStringLiteral("revision")).toString(); + std::optional> records; + if (!document) + { + typedError = QStringLiteral("bookmark/document-not-ready"); + } + else if (!displayed.isEmpty() && displayed != m_session->facade().currentRevision().toString()) + { + // The file would describe a revision the document has left. + typedError = QStringLiteral("bookmark/stale-revision"); + } + else + { + records = pdf::PDFAuthoringEditWriter::readOutline(document.data(), typedError); + } + + if (records.has_value()) + { + file.bookmarks = *records; + const pdf::PDFOperationResult written = pdf::PDFSafeFileWriter::writeData( + path, file.toJson(), pdf::PDFSafeFileWriter::OverwritePolicy::Overwrite); + if (!written) + { + typedError = QStringLiteral("bookmark/export-failed"); + } + } + + const bool exported = typedError.isEmpty() && records.has_value(); + if (exported) + { + announceDocumentState(tr("Exported %n bookmark(s).", "", file.count())); + } + else + { + announceDocumentState(tr("The bookmarks could not be exported: %1").arg(typedError)); + } + m_session->catalog().finishInvocation(invocation, + exported ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + exported ? QString() : typedError); + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionBookmarkExport"), std::move(exportBookmarks)); + + for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) + { + pdfinteraction::CommandCatalog::Handler navigation; + navigation.invoke = [this, id](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + QString typedError; + const bool moved = goToAdjacentBookmark(id == QLatin1String("actionGotoNextBookmark") ? 1 : -1, + typedError); + if (moved) + { + announceDocumentState(tr("Moved to bookmark on page %1.").arg(currentPage() + 1)); + } + else + { + announceDocumentState(tr("No bookmark to move to: %1").arg(typedError)); + } + m_session->catalog().finishInvocation(invocation, + moved ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + moved ? QString() : typedError); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(navigation)); + } + + + // ---- Annotation authoring (issue #234) --------------------------------- + // One handler per declared annotation command. The handler parses geometry + // at this boundary, submits the typed request to the facade's direct-edit + // gateway (ADR-014) and surfaces the gateway's typed refusal; it never + // touches the document itself. An accepted edit is a new revision, and the + // revision-changed path already refreshes the canvas and the annotation + // overlay, so no annotation state is cached here. + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + const pdf::AnnotationAuthoringSubtype subtype = entry.second; + pdfinteraction::CommandCatalog::Handler authoring; + authoring.invoke = [this, subtype](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + QString refusal; + const std::optional parsed = + pdfinteraction::directEditRequestFromParameters(subtype, parameters, &refusal); + if (!parsed) + { + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, refusal); + bumpPresentation(); + return; + } + + pdfinteraction::DirectEditRequest request = *parsed; + request.revision = m_session->facade().currentRevision(); + request.operatorIdentity = QStringLiteral("Editor"); + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); + if (!outcome.accepted) + { + // A menu command that changed nothing must say why, in the same + // announcement channel every other operator-visible outcome uses. + announceDocumentState(tr("The annotation was not created: %1").arg(outcome.typedError)); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, outcome.typedError); + } + else + { + announceDocumentState(tr("Annotation added.")); + // The annotation exists. A provenance failure is reported on the + // completed invocation instead of pretending the edit did not land. + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed, + outcome.historyAppended ? QString() : outcome.historyError); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(entry.first, std::move(authoring)); + } + + + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // Form values, attachments and metadata are mutations through this catalog + // into Core: each handler maps its declared scalars into the facade's typed + // direct-edit request, and the facade checks the revision fence and the + // document's permission before any writer runs. A refusal finishes Failed + // with the facade's own stable code, so a stale, unauthorized, XFA or + // unsupported edit is reported rather than silently dropped. + auto bindDocumentDirectEdit = [this](const QString& id, pdfinteraction::DirectEditKind kind) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const pdfinteraction::DirectEditOutcome result = submitDocumentDirectEdit(kind, parameters); + m_session->catalog().finishInvocation(invocation, + result.accepted ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + result.accepted ? QString() : result.typedError); + if (result.accepted) + { + announceDocumentState(tr("The document was edited. Save to publish the new revision.")); + // The document was replaced in place, so the surfaces the new + // revision needs are requested now rather than at the next + // viewport change. + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(result.typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindDocumentDirectEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DirectEditKind::SetFormFieldValue); + bindDocumentDirectEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DirectEditKind::AddAttachment); + bindDocumentDirectEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DirectEditKind::RemoveAttachment); + bindDocumentDirectEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DirectEditKind::EditMetadata); + + // Send by e-mail hands the current published revision to the mail client and + // writes nothing. An unsaved document is refused with a typed reason: the + // bytes on disk are then a different revision than the one on screen. + pdfinteraction::CommandCatalog::Handler sendByMail; + sendByMail.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + QString typedError; + const bool handed = handOffPublishedRevisionToMail(&typedError); + m_session->catalog().finishInvocation(invocation, + handed ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + handed ? QString() : typedError); + if (!handed) + { + announceDocumentState(tr("The document was not handed to the mail client: %1").arg(typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionSend_by_E_Mail"), std::move(sendByMail)); + + refreshFeatureAvailability(); +} + +pdfinteraction::DirectEditOutcome EditorHost::submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters) +{ + pdfinteraction::DirectEditOutcome outcome; + if (!hasDocument()) + { + outcome.typedError = QStringLiteral("document/not-ready"); + return outcome; + } + + pdfinteraction::DirectEditRequest request; + request.kind = kind; + request.scalars = parameters; + // The caller names the revision the edit was begun against. It is parsed at + // this boundary, and an absent or malformed identity stays invalid, so the + // gateway refuses it as a stale fence instead of guessing which revision + // the caller meant. + request.revision = pdf::PDFRevisionIdentity::fromString(parameters.value(QStringLiteral("revision")).toString()); + + if (kind == pdfinteraction::DirectEditKind::AddAttachment) + { + // The command carries a path; the host reads the file so the mutation + // gateway never touches the filesystem. + constexpr qint64 maximumAttachmentBytes = 64ll * 1024 * 1024; + const QString source = parameters.value(QStringLiteral("path")).toString(); + QFile file(source); + if (source.isEmpty() || !file.open(QIODevice::ReadOnly)) + { + outcome.typedError = QStringLiteral("attachment/source-unreadable"); + return outcome; + } + if (file.size() > maximumAttachmentBytes) + { + outcome.typedError = QStringLiteral("attachment/source-too-large"); + return outcome; + } + request.contents = file.readAll(); + } + + return m_session->facade().applyDirectEdit(request); +} + +bool EditorHost::handOffPublishedRevisionToMail(QString* typedError) +{ + typedError->clear(); + if (!hasDocument()) { - const bool routed = requestMoveSelection(parameters); - m_session->catalog().finishInvocation(invocation, - routed ? pdfinteraction::CommandTerminalState::Completed - : pdfinteraction::CommandTerminalState::Failed, - routed ? QString() : QStringLiteral("move/rejected")); + *typedError = QStringLiteral("mail/no-document"); + return false; + } + + const pdfinteraction::DocumentFacade& facade = m_session->facade(); + if (facade.facets().testFlag(pdfinteraction::DocumentFacet::Dirty)) + { + // The published bytes are a different revision than the one on screen. + // Handing them over as if they were current is exactly the stale case. + *typedError = QStringLiteral("mail/unsaved-changes"); + return false; + } + + const pdfinteraction::DocumentSource& source = facade.source(); + if (!source.isValid() || !QFileInfo::exists(source.path)) + { + *typedError = QStringLiteral("mail/no-published-file"); + return false; + } + + // The revision travels with the hand-off, so the receiving side can refuse a + // file that is not the revision the shell was showing. + Q_EMIT documentMailRequested(source.path, facade.currentRevision().toString()); + announceDocumentState(tr("The published document was handed to the mail client.")); + return true; +} + +bool EditorHost::extractAttachmentFileUrl(const QUrl& directory, const QString& name) +{ + const auto project = [this](const QString& status, const QString& fileName, bool autoOpenRefused, const QString& typedError) + { + QVariantMap projection; + projection.insert(QStringLiteral("status"), status); + projection.insert(QStringLiteral("fileName"), fileName); + projection.insert(QStringLiteral("autoOpenRefused"), autoOpenRefused); + projection.insert(QStringLiteral("typedError"), typedError); + m_attachmentExtraction = projection; bumpPresentation(); }; - m_session->catalog().setHandler(MoveSelectionCommandId, std::move(move)); - refreshFeatureAvailability(); + + if (!hasDocument() || !m_session->context().getDocument() || name.isEmpty()) + { + project(QStringLiteral("refused"), name, false, QStringLiteral("attachment/no-document")); + return false; + } + + const pdf::PDFAttachmentWriter::ExtractionOutcome outcome = + pdf::PDFAttachmentWriter::extractToDirectory(m_session->context().getDocument(), name, directory.toLocalFile()); + + if (!outcome.written) + { + project(QStringLiteral("refused"), name, false, outcome.typedError); + announceDocumentState(tr("The attachment could not be extracted: %1").arg(outcome.typedError)); + return false; + } + + const QString extractedName = QFileInfo(outcome.path).fileName(); + project(QStringLiteral("written"), extractedName, outcome.autoOpenRefused, QString()); + if (outcome.autoOpenRefused) + { + // The bytes are the document's; launching them is not. The shell must + // not auto-open an executable-type attachment. + announceDocumentState(tr("Extracted %1. This file type must not be opened automatically.").arg(extractedName)); + } + else + { + announceDocumentState(tr("Extracted %1.").arg(extractedName)); + } + return true; } void EditorHost::refreshFeatureAvailability() @@ -3607,7 +5660,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); } @@ -3615,10 +5671,288 @@ 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()); + + // 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); + } + + // The developer-diagnostics shell surfaces (#239): settings, render options + // and the diagnostics bundle. They follow the same flag the menu gate + // reads, so a release profile cannot reach them even by invocation. + const bool developerDiagnostics = allowDeveloperDiagnostics(); + for (const QString& id : { QStringLiteral("actionOptions"), + QStringLiteral("actionResetToFactorySettings"), + QStringLiteral("actionCollectDiagnostics"), + QStringLiteral("actionRenderOptionAntialiasing"), + QStringLiteral("actionRenderOptionTextAntialiasing"), + QStringLiteral("actionRenderOptionSmoothPictures"), + QStringLiteral("actionRenderOptionDisplayAnnotations"), + QStringLiteral("actionRenderOptionIgnoreOptionalContentSettings"), + QStringLiteral("actionRenderOptionDisplayRenderTimes"), + QStringLiteral("actionShow_Text_Blocks"), + QStringLiteral("actionShow_Text_Lines") }) + { + availability.insert(id, developerDiagnostics); + } + + availability.insert(QStringLiteral("actionPrint"), ready && !outputBusy() && m_documentModel.canPrint()); + availability.insert(QStringLiteral("actionExportDocument"), ready && !outputBusy() && m_documentModel.canCopy()); + + // Annotation authoring (issue #234): available with a document whose + // modify-annotations permission is granted, so a restricted document cannot + // offer an edit the gateway would refuse. The ids come from the one table + // the handlers were registered from. + const bool annotationAuthoring = ready && m_session->facade().permissions().modifyAnnotations; + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + availability.insert(entry.first, annotationAuthoring); + } + + // Direct authoring edits (ADR-014, issue #242). Each is enabled exactly when + // the open document grants the permission its family needs, so an + // unauthorized edit is unreachable rather than refused after the fact. + availability.insert(QStringLiteral("actionSetFormFieldValue"), ready && m_documentModel.canFillForms()); + availability.insert(QStringLiteral("actionAddAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionRemoveAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionEditDocumentMetadata"), ready && m_documentModel.canModify()); + // Send by e-mail stays enabled with a document even when it has unsaved + // changes, so its typed refusal is reachable and visible rather than the + // command silently disappearing from the menu. + availability.insert(QStringLiteral("actionSend_by_E_Mail"), ready && !outputBusy()); + + // Bookmarks and hyperlinks (#233). The edits are document mutations, so they + // follow the document's modify permission; navigation needs an outline to + // move through, and export follows the same copy permission the document + // export uses. + const bool canModify = ready && m_session->facade().permissions().modify; + for (const QString& id : { QStringLiteral("actionBookmarkPage"), + QStringLiteral("actionBookmarkAutoGenerate"), + QStringLiteral("actionBookmarkImport"), + QStringLiteral("actionCreateHyperlink"), + QStringLiteral("actionCreateHyperlinkToThisPDFFit"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitB"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBV"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitR"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitV"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZ"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZInheritZoom") }) + { + availability.insert(id, canModify); + } + availability.insert(QStringLiteral("actionBookmarkExport"), ready && m_documentModel.canCopy()); + for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) + { + availability.insert(id, ready && m_documentModel.hasOutline()); + } + m_session->catalog().setEnabledBatch(availability); } +void EditorHost::runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters) +{ + QString typedError = buildError; + pdfinteraction::DirectEditOutcome outcome; + if (edit.has_value()) + { + outcome = submitAuthoringEdit(*edit, parameters, typedError); + } + + const bool applied = edit.has_value() && outcome.accepted; + if (applied) + { + announceDocumentState(authoringEditAnnouncement(*edit, outcome)); + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(typedError)); + } + + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : typedError); + bumpPresentation(); + refreshFeatureAvailability(); +} + +pdfinteraction::DirectEditOutcome EditorHost::submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError) +{ + const pdf::PDFRevisionIdentity current = m_session->facade().currentRevision(); + const QString displayed = parameters.value(QStringLiteral("revision")).toString(); + if (!displayed.isEmpty() && displayed != current.toString()) + { + // The surface acted on a revision the document has left; the edit is + // refused rather than rebased onto one the operator never saw. + typedError = QStringLiteral("edit/stale-revision"); + return {}; + } + + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring = edit; + request.revision = current; + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); + typedError = outcome.typedError; + return outcome; +} + +bool EditorHost::authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const +{ + const std::optional explicitRectangle = rectangleFromParameters(parameters, + QStringLiteral("left"), + QStringLiteral("bottom"), + QStringLiteral("right"), + QStringLiteral("top"), + typedError); + if (!typedError.isEmpty()) + { + return false; + } + + const QVariant page = parameters.value(QStringLiteral("page")); + if (explicitRectangle.has_value() || page.isValid()) + { + if (!explicitRectangle.has_value() || !page.isValid()) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return false; + } + + *pageIndex = page.toInt(); + *rectangle = *explicitRectangle; + return true; + } + + // No explicit geometry: the current text selection is the shell's own + // "this region" gesture, already fenced to the revision it was taken from. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const std::optional& selection = interaction->state().textSelection(); + if (selection.has_value() && selection->isValid() && selection->available && !selection->geometry.isEmpty()) + { + QRectF bounds; + for (const QRectF& span : selection->geometry) + { + bounds = bounds.united(span); + } + + if (bounds.isValid() && !bounds.isEmpty()) + { + *pageIndex = selection->pageIndex; + *rectangle = bounds; + return true; + } + } + } + + typedError = QStringLiteral("hyperlink/link-rectangle-required"); + return false; +} + +bool EditorHost::goToAdjacentBookmark(int direction, QString& typedError) +{ + if (!hasDocument()) + { + typedError = QStringLiteral("bookmark/document-not-ready"); + return false; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + const QList pages = pdf::PDFAuthoringEditWriter::bookmarkPages(document.data()); + if (pages.isEmpty()) + { + typedError = QStringLiteral("bookmark/none"); + return false; + } + + // Outline order, not page order: the first bookmark after (or before) the + // current page, wrapping at the ends like the shell's historical bookmark + // cursor. The page the operator is already on is never its own destination. + const int current = currentPage(); + int target = -1; + if (direction > 0) + { + for (const int page : pages) + { + if (page > current) + { + target = page; + break; + } + } + if (target < 0) + { + target = pages.constFirst(); + } + } + else + { + for (auto it = pages.crbegin(); it != pages.crend(); ++it) + { + if (*it < current) + { + target = *it; + break; + } + } + if (target < 0) + { + target = pages.constLast(); + } + } + + goToPage(target); + return true; +} + +QString EditorHost::authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DirectEditOutcome& outcome) const +{ + switch (outcome.resolution) + { + case pdf::PDFAuthoringEditResult::Kind::BookmarkAdded: + return tr("Bookmark added for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarkRemoved: + return tr("Bookmark removed for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksGenerated: + return tr("Generated %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksReplaced: + return tr("Imported %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::LinkCreated: + return tr("Hyperlink created."); + + case pdf::PDFAuthoringEditResult::Kind::Refused: + break; + } + + return QString(); +} + void EditorHost::moveSearch(int direction) { const int count = m_documentModel.searchResults()->rowCount(); @@ -3662,6 +5996,7 @@ void EditorHost::bumpPresentation() { m_canvas->setHighContrast(highContrast()); } + applyDisplayColorToCanvas(); Q_EMIT presentationChanged(); } @@ -3673,6 +6008,9 @@ void EditorHost::bumpCommandEpoch() void EditorHost::onDocumentReady() { + // The persisted render options must be in force before the first surfaces + // are requested, so no page is rendered twice on open. + applyRenderOptions(); m_session->prepareDocumentView(); syncRevisionModels(); @@ -3680,10 +6018,13 @@ void EditorHost::onDocumentReady() syncDocumentLifecycle(); m_searchRow = -1; refreshHitTestSources(); + reloadProductionPlates(); m_documentBound = true; bindCanvas(); updateCanvasAccessibilitySummary(); applyEmptyCanvasInspectorSelection(); + refreshAnnotationOverlay(); + submitSignatureCheck(); announceDocumentState(tr("Document ready.")); } @@ -3713,8 +6054,23 @@ 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(); + m_publishedHistoryPath.clear(); + m_publishedHistoryRevision.clear(); cancelPreflight(); cancelActionList(); + if (!m_signatureCheckJobId.isEmpty()) + { + m_session->scheduler().cancel(m_signatureCheckJobId); + m_signatureCheckJobId.clear(); + } + m_signatureCheckOutcome.reset(); + m_fixSignatureInvalidationAcknowledgedDigest.clear(); if (m_findingNavigator) { m_findingNavigator->invalidate(); @@ -3725,9 +6081,20 @@ void EditorHost::onDocumentGone() m_actionListController.clear(); m_inspector.clearSelection(); m_documentModel.clear(); + refreshAnnotationOverlay(); 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"); @@ -3961,6 +6328,15 @@ 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_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 +6397,51 @@ 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::finishSignatureCheckJob(const pdf::PDFJobSnapshot& snapshot) +{ + if (snapshot.jobId.isEmpty() || snapshot.jobId != m_signatureCheckJobId || !m_signatureCheckOutcome) + { + return; + } + + const std::shared_ptr outcome = m_signatureCheckOutcome; + m_signatureCheckJobId.clear(); + m_signatureCheckOutcome.reset(); + + if (snapshot.kind != pdf::PDFJobKind::Other || + snapshot.operationId != QStringLiteral("document.verify-signatures")) + { + return; + } + + QString transitionError; + if (snapshot.status == pdf::PDFJobStatus::Succeeded && outcome->ok) + { + // The facade refuses a completion that belongs to another request, + // generation or revision; a refused completion presents nothing. + if (m_session->facade().completeSignatureCheck(outcome->token, outcome->results, transitionError)) + { + bumpPresentation(); + } + return; + } + + QString detail = outcome->detail; + if (detail.isEmpty()) + { + detail = snapshot.status == pdf::PDFJobStatus::Failed + ? tr("Signature verification failed for this document.") + : tr("Signature verification did not complete for this document."); + } + if (m_session->facade().failSignatureCheck(outcome->token, detail, transitionError)) + { + bumpPresentation(); + } } void EditorHost::refreshCanvasTrace() @@ -4116,6 +6536,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); @@ -4139,6 +6567,138 @@ void EditorHost::syncRevisionModels() } } +void EditorHost::refreshAnnotationOverlay() +{ + // Read-only review marks (issue #125). Every annotation row becomes one + // page-space rectangle; the selected row carries the selection treatment. + // The marks are not interaction targets: their kind stays None, so nothing + // hit-tests them and nothing routes a click through them. + const QuickAnnotationModel& annotations = m_documentModel.annotationModel(); + const QList& rows = annotations.rows(); + + QList marks; + marks.reserve(rows.size()); + for (const QuickAnnotation& row : rows) + { + pdfinteraction::InteractionTarget target; + target.kind = pdfinteraction::InteractionTargetKind::None; + target.pageIndex = row.pageIndex; + target.id = QStringLiteral("annotation:") + row.reference; + target.pageBounds = row.bounds; + marks.append(target); + } + + const int selectedRow = annotations.selectedRow(); + const QString selectedId = selectedRow >= 0 && selectedRow < rows.size() + ? QStringLiteral("annotation:") + rows.at(selectedRow).reference + : QString(); + + m_session->overlays()->setAnnotations(std::move(marks)); + m_session->overlays()->setSelectedAnnotationId(selectedId); + m_session->interaction()->refreshOverlay(); +} + +void EditorHost::submitSignatureCheck() +{ + if (!hasDocument() || !m_session->revisionSource()) + { + return; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + if (!document) + { + return; + } + + QString beginError; + const std::optional token = + m_session->facade().beginSignatureCheck(beginError); + if (!token.has_value()) + { + return; + } + + 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("document.verify-signatures"); + spec.checkId = QStringLiteral("signature-verification"); + spec.progressModel = QStringLiteral("signature-check-progress-v1"); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString documentPath = m_session->facade().source().path; + const QByteArray expectedSourceHash = m_session->context().getDocumentIdentity().sourceDataHash; + + auto outcome = std::make_shared(); + outcome->token = *token; + m_signatureCheckJobId = jobId; + m_signatureCheckOutcome = outcome; + + const QString submittedId = m_session->scheduler().submit( + spec, + [document, documentPath, expectedSourceHash, outcome](pdf::PDFJobContext& context) + { + if (context.isCancellationRequested()) + { + return; + } + + // Verification needs the exact source bytes a signature covers, and + // the loaded document retains only their digest. Re-reading the file + // is admitted only when the bytes still hash to the revision the + // document was loaded from; a changed file is a failed check, never + // a verdict about the open revision. + QFile file(documentPath); + if (!file.open(QIODevice::ReadOnly)) + { + outcome->ok = false; + outcome->detail = QStringLiteral("The source file could not be read for signature verification."); + return; + } + const QByteArray sourceData = file.readAll(); + if (QCryptographicHash::hash(sourceData, QCryptographicHash::Sha256) != expectedSourceHash) + { + outcome->ok = false; + outcome->detail = QStringLiteral("The file changed since the document was loaded; its signatures were not verified."); + return; + } + + try + { + pdf::PDFCertificateStore certificateStore; + pdf::PDFSignatureHandler::Parameters parameters; + parameters.store = &certificateStore; + parameters.dss = &document->getCatalog()->getDocumentSecurityStore(); + parameters.enableVerification = true; + parameters.useSystemCertificateStore = true; + const pdf::PDFForm form = + pdf::PDFForm::parse(document.get(), document->getCatalog()->getFormObject()); + outcome->results = pdf::PDFSignatureHandler::verifySignatures(form, sourceData, parameters); + outcome->ok = true; + } + catch (const std::exception&) + { + outcome->ok = false; + outcome->detail = QStringLiteral("Signature verification could not complete for this document."); + } + }); + if (submittedId != jobId) + { + m_signatureCheckJobId.clear(); + m_signatureCheckOutcome.reset(); + QString failError; + m_session->facade().failSignatureCheck(*token, + tr("Signature verification could not be scheduled."), + failError); + } +} + void EditorHost::syncProductionState() { if (!m_session->revisionSource() || !hasDocument()) @@ -4181,8 +6741,46 @@ 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())); + } + } + + // The outline is the one document structure the canvas can announce (issue + // #233): its count is read from the same Core projection bookmark + // navigation moves through. + const QList bookmarkPages = + pdf::PDFAuthoringEditWriter::bookmarkPages(m_session->context().getDocumentPointer().data()); + if (!bookmarkPages.isEmpty()) + { + summary += QLatin1Char(' ') + tr("%n bookmark(s).", "", bookmarkPages.size()); + } + + m_canvas->setAccessibleDocumentSummary(summary); } void EditorHost::onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request) @@ -4327,11 +6925,11 @@ void EditorHost::applyEmptyCanvasInspectorSelection() selection.title = tr("Document canvas"); selection.kind = pdfinteraction::InspectorModel::SelectionKind::EmptyCanvas; selection.properties = { - { QStringLiteral("document"), QStringLiteral("Document"), displayTitle() }, - { QStringLiteral("pages"), QStringLiteral("Pages"), QString::number(pageCount()) }, - { QStringLiteral("document-status"), QStringLiteral("Document status"), documentShellStatus() }, - { QStringLiteral("preflight"), QStringLiteral("Preflight"), preflightStateName() }, - { QStringLiteral("production"), QStringLiteral("Production"), productionStateName() }, + { QStringLiteral("document"), tr("Document"), displayTitle() }, + { QStringLiteral("pages"), tr("Pages"), QString::number(pageCount()) }, + { QStringLiteral("document-status"), tr("Document status"), documentShellStatus() }, + { QStringLiteral("preflight"), tr("Preflight"), preflightStateName() }, + { QStringLiteral("production"), tr("Production"), productionStateName() }, }; m_inspector.setSelection(selection); } @@ -4373,18 +6971,18 @@ void EditorHost::applyInspectorSelection(const pdfinteraction::InteractionTarget selection.title = tr("Image"); selection.kind = pdfinteraction::InspectorModel::SelectionKind::Image; selection.properties = { - { QStringLiteral("id"), QStringLiteral("Image"), target.id.mid(6) }, - { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, - { QStringLiteral("bounds"), QStringLiteral("Bounds"), + { QStringLiteral("id"), tr("Image"), target.id.mid(6) }, + { QStringLiteral("page"), tr("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("bounds"), tr("Bounds"), QStringLiteral("%1,%2 %3x%4") .arg(QString::number(target.pageBounds.x()), QString::number(target.pageBounds.y()), QString::number(target.pageBounds.width()), QString::number(target.pageBounds.height())) }, - { QStringLiteral("dpi"), QStringLiteral("Effective DPI"), tr("pending") }, - { QStringLiteral("colour-space"), QStringLiteral("Colour space"), tr("pending") }, - { QStringLiteral("compression"), QStringLiteral("Compression"), tr("pending") }, - { QStringLiteral("mask"), QStringLiteral("Mask"), tr("pending") }, + { QStringLiteral("dpi"), tr("Effective DPI"), tr("pending") }, + { QStringLiteral("colour-space"), tr("Colour space"), tr("pending") }, + { QStringLiteral("compression"), tr("Compression"), tr("pending") }, + { QStringLiteral("mask"), tr("Mask"), tr("pending") }, }; m_inspector.setSelection(selection); return; @@ -4399,10 +6997,10 @@ void EditorHost::applyInspectorSelection(const pdfinteraction::InteractionTarget selection.title = tr("Separation"); selection.kind = pdfinteraction::InspectorModel::SelectionKind::Separation; selection.properties = { - { QStringLiteral("name"), QStringLiteral("Ink"), target.id.mid(11) }, - { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, - { QStringLiteral("coverage"), QStringLiteral("Ink coverage"), tr("pending") }, - { QStringLiteral("kind"), QStringLiteral("Process / spot"), tr("pending") }, + { QStringLiteral("name"), tr("Ink"), target.id.mid(11) }, + { QStringLiteral("page"), tr("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("coverage"), tr("Ink coverage"), tr("pending") }, + { QStringLiteral("kind"), tr("Process / spot"), tr("pending") }, }; m_inspector.setSelection(selection); return; @@ -4420,13 +7018,13 @@ void EditorHost::applyInspectorSelection(const pdfinteraction::InteractionTarget : tr("Page %1").arg(target.pageIndex + 1); selection.kind = pdfinteraction::InspectorModel::SelectionKind::Page; selection.properties = { - { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, - { QStringLiteral("box"), QStringLiteral("Box"), target.id }, - { QStringLiteral("size"), QStringLiteral("Size"), + { QStringLiteral("page"), tr("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("box"), tr("Box"), target.id }, + { QStringLiteral("size"), tr("Size"), QStringLiteral("%1 x %2") .arg(QString::number(target.pageBounds.width()), QString::number(target.pageBounds.height())) }, - { QStringLiteral("rotation"), QStringLiteral("Rotation"), QStringLiteral("%1°").arg(rotationDegrees()) }, - { QStringLiteral("ocg"), QStringLiteral("Optional content"), m_documentModel.hasOptionalContent() ? tr("present") : tr("none") }, + { QStringLiteral("rotation"), tr("Rotation"), QStringLiteral("%1°").arg(rotationDegrees()) }, + { QStringLiteral("ocg"), tr("Optional content"), m_documentModel.hasOptionalContent() ? tr("present") : tr("none") }, }; m_inspector.setSelection(selection); return; diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 9cef1bf44..fb7c98bf3 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -46,13 +46,20 @@ #include "focusrestoration.h" #include "documentviewsession.h" +#include "editorsettings.h" #include "quickdocumentmodel.h" +#include "quickoutput.h" + +#include "canvasdisplaycolor.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" #include "pdfoperationhistory.h" +#include "pdfsignaturehandler.h" +#include "pdfauthoringedit.h" #include +#include #include #include #include @@ -62,6 +69,7 @@ #include #include +#include namespace pdfinteraction { @@ -86,6 +94,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) @@ -137,6 +147,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) @@ -145,6 +160,23 @@ class EditorHost final : public QObject Q_PROPERTY(QString productionStateName READ productionStateName NOTIFY presentationChanged) Q_PROPERTY(bool allowDeveloperDiagnostics READ allowDeveloperDiagnostics CONSTANT) + // ---- Shell settings and diagnostics (#239) ------------------------------------- + // The settings store is the one typed home of the persisted render options; + // QML renders its rows and toggles them through its typed invokables. The + // diagnostics bundle projection names the last collection outcome, never a + // second copy of the bundle's content. + Q_PROPERTY(QObject* settings READ settings CONSTANT) + Q_PROPERTY(QString settingsNotice READ settingsNotice NOTIFY presentationChanged) + Q_PROPERTY(bool settingsResetPending READ settingsResetPending NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap diagnosticsBundle READ diagnosticsBundle NOTIFY presentationChanged) + + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // The last attachment extraction's outcome: `status` is `idle`, `written` or + // `refused`; `fileName` is the extracted name alone (never a path); + // `autoOpenRefused` says the extracted file must not be auto-opened because + // its type is executable; `typedError` is the stable refusal code. + Q_PROPERTY(QVariantMap attachmentExtraction READ attachmentExtraction NOTIFY presentationChanged) + // ---- Workspace surfaces (#586) ------------------------------------------------- // Every property below is a read-only projection of what Core, PdfTool or the // shell's own run already decided. QML renders these; it derives no state and @@ -172,6 +204,37 @@ 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) + // ---- Signature review (#125) ---------------------------------------------------- + // The open document's signature status, reduced by Core's verification and + // admitted through the document facade. QML renders these values; it never + // analyses a signature itself. + Q_PROPERTY(QString signatureStateName READ signatureStateName NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap signatureStateVisual READ signatureStateVisual NOTIFY presentationChanged) + Q_PROPERTY(QColor signatureStateColor READ signatureStateColor NOTIFY presentationChanged) + Q_PROPERTY(QString signatureSummary READ signatureSummary NOTIFY presentationChanged) + public: enum LoopWorkspace { @@ -185,7 +248,10 @@ class EditorHost final : public QObject }; Q_ENUM(LoopWorkspace) - explicit EditorHost(QObject* parent = nullptr); + /// `settings` is the one typed settings store. When null, the host opens + /// the application's own user-scope store on first use; an injected store + /// stays owned by the caller and must outlive the host. + explicit EditorHost(EditorSettings* settings = nullptr, QObject* parent = nullptr, QString recipesDirectory = QString()); ~EditorHost() override; EditorHost(const EditorHost&) = delete; @@ -193,6 +259,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; @@ -263,6 +334,46 @@ class EditorHost final : public QObject /// Production / plate model for the Pages and Production Preview surfaces. QObject* production() { return &m_production; } + /// The one typed settings store (#239). QML renders its option rows and + /// calls its typed invokables; nothing else owns a settings copy. + QObject* settings() { return m_settings; } + + /// The settings store's visible notice: a persisted value that was rejected + /// and replaced, or the last refused settings operation. Empty when nothing + /// needs reporting. + QString settingsNotice(); + + /// True while a confirmed reset-to-factory is armed and awaiting the + /// operator's confirmation. + bool settingsResetPending() const noexcept { return m_settingsResetPending; } + + /// Outcome of the last diagnostics collection: `status` is `idle`, `ready` + /// or `failed`, with the bundle directory and file count on success and a + /// typed error on failure. + QVariantMap diagnosticsBundle() const; + + /// Arms the reset-to-factory confirmation and opens the settings surface. + /// The reset itself only runs through confirmSettingsReset(). + Q_INVOKABLE bool requestSettingsReset(); + Q_INVOKABLE bool confirmSettingsReset(); + Q_INVOKABLE void cancelSettingsReset(); + + /// Collects the ADR-006 diagnostics bundle under the operator-chosen parent + /// directory and projects the outcome. QML passes a folder URL; paths stay + /// in C++. + Q_INVOKABLE bool collectDiagnosticsFileUrl(const QUrl& parentDirectory); + + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + /// The last attachment extraction's outcome. See the Q_PROPERTY above. + QVariantMap attachmentExtraction() const { return m_attachmentExtraction; } + + /// Extracts one attachment into `directory` through the atomic writer, which + /// refuses to replace an existing file. The outcome is projected into + /// attachmentExtraction(); an extracted file whose type is executable is + /// reported with `autoOpenRefused`, and the shell must not hand it to the + /// operating system's opener. Reads the document; mutates nothing. + Q_INVOKABLE bool extractAttachmentFileUrl(const QUrl& directory, const QString& name); + /// Operator lifecycle of the governed-correction route (#586): /// `idle`, `planned`, `preview-ready`, `approved`, `executing`, `succeeded`, /// `stale`, `rejected`, `cancelled`, `failed`. It is a projection of the run the @@ -297,6 +408,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; @@ -308,13 +426,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; @@ -322,6 +442,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 @@ -335,6 +511,25 @@ class EditorHost final : public QObject /// `materialDeltas`. Navigation only: it plans, approves and executes nothing. Q_INVOKABLE bool navigateCompareDelta(int deltaIndex); + /// Read-only annotation review (#125): reveals the annotation's page region + /// and marks that annotation selected in the review list and the overlay. + /// Navigation only: it edits nothing and plans nothing. + Q_INVOKABLE bool navigateToAnnotation(int row); + + /// The open document's signature status, as `not-signed`, `valid`, + /// `invalid`, `unknown` or `modified-after-signing` (Core's verification, + /// reduced by the document facade). + QString signatureStateName() const; + QVariantMap signatureStateVisual() const; + QColor signatureStateColor() const; + QString signatureSummary() const; + + /// Records that the operator accepted the signature invalidation this plan + /// declares, bound to the exact plan digest that was displayed. Execution + /// stays unarmed while an invalidating plan over a signed document has no + /// acknowledgement for the current digest. + Q_INVOKABLE bool acknowledgePlanSignatureInvalidation(const QString& planDigest); + /// Overprint render fidelity for the currently displayed page (issue #49). /// True (and pageFidelityReason empty) when the page has no overprint /// content, or none is known yet. Separate from the document-wide @@ -349,6 +544,7 @@ class EditorHost final : public QObject /// approximate one. bool pageFidelityIsAuthoritative() const; + Q_INVOKABLE QString localizedTerm(const QString& code) const; Q_INVOKABLE void selectFinding(const QString& findingId); Q_INVOKABLE bool selectNextFinding(); Q_INVOKABLE bool selectPreviousFinding(); @@ -440,6 +636,33 @@ class EditorHost final : public QObject /// in-flight drag with ToolChanged (#141 AC3). Q_INVOKABLE bool setActiveTool(const QString& toolId); + // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- + /// Imports a bookmark interchange file and replaces the document outline + /// with its records. `revision` is the revision the operator saw when the + /// file dialog opened; an import that no longer matches it is refused with + /// a typed reason instead of landing on a revision the operator never saw. + Q_INVOKABLE bool importBookmarksFileUrl(const QUrl& url, const QString& revision); + + /// Exports the document outline to the bookmark interchange file. The + /// exported bytes describe `revision`; a stale `revision` is refused rather + /// than exported as if it described the current document. + Q_INVOKABLE bool exportBookmarksFileUrl(const QUrl& url, const QString& revision); + + // ---- 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 = {}); @@ -473,6 +696,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. @@ -485,10 +709,40 @@ class EditorHost final : public QObject void preflightProfileDraftChanged(); void actionListRecipesChanged(); void preflightReportExportRequested(); + void documentOutputRequested(bool printing); + void documentOutputFinished(); void preflightProfileImportRequested(); 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(); + + /// Send by e-mail hands the current published revision to the shell's mail + /// client. `path` is the published artifact and `revision` is the identity it + /// was published as, so the receiving side can refuse a mismatch. Emitted + /// only for an open document with no unsaved changes: an unsaved or stale + /// revision is refused instead, never mailed as if it were the file. + void documentMailRequested(const QString& path, const QString& revision); + + /// The shell-side surfaces (#239). The commands emit these; QML owns the + /// dialogs and opens them. + void settingsPanelRequested(); + void settingsResetRequested(); + void diagnosticsCollectionRequested(); + + /// The bookmark import/export commands need a file, so the shell asks for + /// one and answers with importBookmarksFileUrl()/exportBookmarksFileUrl(). + /// The commands themselves write nothing. + void bookmarkImportRequested(); + void bookmarkExportRequested(); + private: void connectFacade(); void connectViewport(); @@ -499,7 +753,45 @@ class EditorHost final : public QObject void registerFeatureHandlers(); void refreshFeatureAvailability(); bool commandPermissionAllowed(const QString& commandId) const; + /// Applies one document-family direct-edit command through the facade's + /// direct-edit gateway (ADR-014). The command's declared scalars become the + /// request's payload; for an attachment the host reads the source file, so + /// the gateway itself never touches the filesystem. The facade's typed + /// refusal is returned as it stands. + pdfinteraction::DirectEditOutcome submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters); + /// Hands the current published revision to the shell's mail client. Refuses + /// when there is no open document, when its source is not a file, or when it + /// has unsaved changes: the bytes on disk would then be a different revision + /// than the one on screen. Never writes and never opens anything itself. + bool handOffPublishedRevisionToMail(QString* typedError); bool requestMoveSelection(const QVariantMap& parameters); + + // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- + /// Runs one authoring command: submits the built edit through the facade + /// gateway and finishes the invocation with the outcome, announcing what + /// happened. `buildError` is the command's typed refusal when no edit could + /// be built. + void runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters); + /// The fence and the facade call behind every authoring command. `typedError` + /// is set on refusal. + pdfinteraction::DirectEditOutcome submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError); + /// The link rectangle and page a hyperlink command addresses: explicit + /// scalars when the surface supplied them, otherwise the current text + /// selection, which is the Quick shell's own "this region" gesture. + bool authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const; + /// Moves to the next (1) or previous (-1) bookmark page in outline order, + /// wrapping at the ends like the shell's historical bookmark cursor. + bool goToAdjacentBookmark(int direction, QString& typedError); + /// The operator-facing sentence for one applied edit. + QString authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DirectEditOutcome& outcome) const; void moveSearch(int direction); bool moveFindingSelection(int direction); void refreshHitTestSources(); @@ -518,6 +810,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(); @@ -529,6 +823,9 @@ class EditorHost final : public QObject void updatePreflightProfileWatch(); void syncRevisionModels(); void updateCanvasAccessibilitySummary(); + void refreshAnnotationOverlay(); + void submitSignatureCheck(); + void finishSignatureCheckJob(const pdf::PDFJobSnapshot& snapshot); void onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request); static QString dragRefusalMessage(pdfinteraction::InteractionTargetKind kind); void onDragCompleted(pdfinteraction::DragSession session); @@ -538,6 +835,36 @@ 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; + // ---- 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); + /// Pushes the typed render options into the shared render presentation + /// policy and re-requests the surfaces whose keys the change superseded. + void applyRenderOptions(); + /// 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; @@ -547,8 +874,18 @@ 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; + + /// Signature invalidation disclosure (#125). `applies` means the plan's + /// merged save policy invalidates signatures and the open document has some. + /// The acknowledgement is bound to the exact plan digest, so a replan or a + /// revision change cannot inherit it. + bool planSignatureInvalidationApplies() const; + bool planSignatureInvalidationAcknowledged() const; + QString planSignatureInvalidationSummary() const; + QVariantMap signatureInvalidationMap() const; std::unique_ptr m_session; std::unique_ptr m_findingNavigator; @@ -563,8 +900,39 @@ class EditorHost final : public QObject FocusRestoration m_focusRestoration; pdfinteraction::FindingListHitTestSource m_findingsHitTest; + /// The settings store: injected by the caller, or owned here when the host + /// was constructed without one. See the constructor's contract. + std::unique_ptr m_ownedSettings; + EditorSettings* m_settings = nullptr; + bool m_settingsResetPending = false; + + /// The last diagnostics collection's outcome. A projection of the + /// collector's own result, never a second copy of the bundle. + struct DiagnosticsBundleState + { + QString status = QStringLiteral("idle"); + QString directory; + QString error; + int fileCount = 0; + }; + DiagnosticsBundleState m_diagnosticsBundle; + + /// The last attachment extraction's outcome (ADR-014, issue #242). A + /// projection of the Core writer's own result: file name only, never a path. + QVariantMap m_attachmentExtraction; + 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; @@ -581,6 +949,42 @@ 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; + QVariantList m_revisionHistory; + QString m_historyError; + QString m_historyUndoId; + QString m_historyRedoId; + QString m_publishedHistoryPath; + QString m_publishedHistoryRevision; + struct PreflightProfileChoice { QString id; @@ -614,7 +1018,7 @@ class EditorHost final : public QObject int m_searchRow = -1; QString m_inspectionMode = QStringLiteral("page"); QString m_preflightCertificateStateName = QStringLiteral("not-certified"); - QString m_preflightCertificateSummary = QStringLiteral("No certified preflight is recorded for this document."); + QString m_preflightCertificateSummary = tr("No certified preflight is recorded for this document."); /// Review decision for the governed-correction route (#586). The digest records which /// plan the operator reviewed, so a replan or a revision change cannot inherit it. @@ -634,7 +1038,20 @@ class EditorHost final : public QObject QString m_fixPlannedDocumentRevision; QString m_fixPlannedPlanDigest; QVariantList m_fixRollbackPoints; - QString m_fixRollbackSummary; + QString m_fixSignatureInvalidationAcknowledgedDigest; + + /// One signature check at a time, fenced by the facade token it began with. + /// The outcome crosses threads only through the scheduler's jobFinished + /// dispatch, exactly like the preflight and rollback outcomes. + struct SignatureCheckOutcome + { + pdfinteraction::DocumentSignatureCheckToken token; + bool ok = false; + QString detail; + std::vector results; + }; + QString m_signatureCheckJobId; + std::shared_ptr m_signatureCheckOutcome; }; #endif // EDITORHOST_H diff --git a/LoopEditor/editorsettings.cpp b/LoopEditor/editorsettings.cpp new file mode 100644 index 000000000..f14e81e03 --- /dev/null +++ b/LoopEditor/editorsettings.cpp @@ -0,0 +1,336 @@ +// MIT License +// Copyright (c) 2026 Loop contributors + +#include "editorsettings.h" + +#include +#include + +#include +#include + +namespace +{ + +const QString RenderOptionsGroup = QStringLiteral("renderOptions"); + +/// Strict parse of one persisted value. QSettings writes booleans as +/// "true"/"false" (or a boolean in the native backend); anything else is an +/// invalid stored value, not a value to coerce. +bool parseStoredBool(const QVariant& stored, bool* parsed) +{ + if (stored.metaType().id() == QMetaType::Bool) + { + *parsed = true; + return stored.toBool(); + } + if (stored.metaType().id() == QMetaType::QString) + { + const QString text = stored.toString().trimmed(); + if (text.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0) + { + *parsed = true; + return true; + } + if (text.compare(QStringLiteral("false"), Qt::CaseInsensitive) == 0) + { + *parsed = true; + return false; + } + } + + *parsed = false; + return false; +} + +} // namespace + +EditorSettings::EditorSettings(QSettings* settings, QObject* parent) : + QObject(parent), + m_store(settings) +{ + m_options = { + { AntialiasingOptionId, + tr("Antialiasing"), + tr("Smooth the edges of lines, shapes and curves on the page."), + RenderOptionsGroup + QStringLiteral("/antialiasing"), + true, + true }, + { TextAntialiasingOptionId, + tr("Text antialiasing"), + tr("Smooth the rendering of text on the page."), + RenderOptionsGroup + QStringLiteral("/textAntialiasing"), + true, + true }, + { SmoothPicturesOptionId, + tr("Smooth pictures"), + tr("Scale images with smooth interpolation. Slower, but better image quality."), + RenderOptionsGroup + QStringLiteral("/smoothPictures"), + false, + false }, + { DisplayAnnotationsOptionId, + tr("Display annotations"), + tr("Draw the document's annotations over the page."), + RenderOptionsGroup + QStringLiteral("/displayAnnotations"), + true, + true }, + { IgnoreOptionalContentOptionId, + tr("Ignore optional content settings"), + tr("Show all optional content regardless of the document's own layer settings. The " + "preview no longer matches the document's view state; the fidelity indicator " + "reports the deviation."), + RenderOptionsGroup + QStringLiteral("/ignoreOptionalContentSettings"), + false, + false }, + { DisplayRenderTimesOptionId, + tr("Display render times"), + tr("Draw the page compile and draw time over the page."), + RenderOptionsGroup + QStringLiteral("/displayRenderTimes"), + false, + false }, + { ShowTextBlocksOptionId, + tr("Show text blocks"), + tr("Draw the text block layout the renderer parsed."), + RenderOptionsGroup + QStringLiteral("/showTextBlocks"), + false, + false }, + { ShowTextLinesOptionId, + tr("Show text lines"), + tr("Draw the text line layout the renderer parsed."), + RenderOptionsGroup + QStringLiteral("/showTextLines"), + false, + false }, + }; +} + +EditorSettings::~EditorSettings() = default; + +QSettings& EditorSettings::store() +{ + if (!m_store) + { + // The owned fallback: the same IniFormat/UserScope idiom Core uses for + // the diagnostics settings, so a host constructed without an explicit + // store still persists under the application identity. + m_ownedStore = std::make_unique(QSettings::IniFormat, + QSettings::UserScope, + QCoreApplication::organizationName(), + QCoreApplication::applicationName()); + m_store = m_ownedStore.get(); + } + return *m_store; +} + +void EditorSettings::load() +{ + if (m_loaded) + { + return; + } + m_loaded = true; + + QSettings& settings = store(); + for (Option& option : m_options) + { + if (!settings.contains(option.settingsKey)) + { + option.value = option.defaultValue; + continue; + } + + bool parsed = false; + const bool value = parseStoredBool(settings.value(option.settingsKey), &parsed); + if (parsed) + { + option.value = value; + continue; + } + + const QString stored = settings.value(option.settingsKey).toString(); + option.value = option.defaultValue; + m_loadNotices.append(tr("\"%1\" was stored as \"%2\", which is not a boolean; it was replaced by its " + "default (%3).") + .arg(option.settingsKey, + stored, + option.defaultValue ? tr("enabled") : tr("disabled"))); + if (!writeOptionValue(option, option.defaultValue)) + { + m_loadNotices.append(tr("\"%1\" could not be repaired in the settings store.").arg(option.settingsKey)); + } + } + + if (!m_loadNotices.isEmpty()) + { + Q_EMIT noticeChanged(); + } +} + +EditorSettings::Option* EditorSettings::findOption(const QString& id) +{ + const auto it = std::find_if(m_options.begin(), m_options.end(), [&id](const Option& option) + { return option.id == id; }); + return it == m_options.end() ? nullptr : &*it; +} + +bool EditorSettings::writeOptionValue(const Option& option, bool enabled) +{ + QSettings& settings = store(); + settings.setValue(option.settingsKey, enabled); + settings.sync(); + return settings.status() == QSettings::NoError; +} + +void EditorSettings::setOperationNotice(QString text) +{ + if (m_operationNotice == text) + { + return; + } + m_operationNotice = std::move(text); + Q_EMIT noticeChanged(); +} + +EditorSettings::RenderOptions EditorSettings::renderOptions() +{ + load(); + + RenderOptions options; + for (const Option& option : m_options) + { + if (option.id == AntialiasingOptionId) + { + options.antialiasing = option.value; + } + else if (option.id == TextAntialiasingOptionId) + { + options.textAntialiasing = option.value; + } + else if (option.id == SmoothPicturesOptionId) + { + options.smoothPictures = option.value; + } + else if (option.id == DisplayAnnotationsOptionId) + { + options.displayAnnotations = option.value; + } + else if (option.id == IgnoreOptionalContentOptionId) + { + options.ignoreOptionalContentSettings = option.value; + } + else if (option.id == DisplayRenderTimesOptionId) + { + options.displayRenderTimes = option.value; + } + else if (option.id == ShowTextBlocksOptionId) + { + options.showTextBlocks = option.value; + } + else if (option.id == ShowTextLinesOptionId) + { + options.showTextLines = option.value; + } + } + return options; +} + +bool EditorSettings::isOptionEnabled(const QString& id) +{ + load(); + + const Option* option = findOption(id); + return option && option->value; +} + +QVariantList EditorSettings::options() +{ + load(); + + QVariantList rows; + rows.reserve(static_cast(m_options.size())); + for (const Option& option : m_options) + { + QVariantMap row; + row.insert(QStringLiteral("id"), option.id); + row.insert(QStringLiteral("label"), option.label); + row.insert(QStringLiteral("description"), option.description); + row.insert(QStringLiteral("enabled"), option.value); + rows.append(row); + } + return rows; +} + +QString EditorSettings::notice() +{ + load(); + + QStringList notices = m_loadNotices; + if (!m_operationNotice.isEmpty()) + { + notices.append(m_operationNotice); + } + return notices.join(QLatin1Char('\n')); +} + +bool EditorSettings::hasNotice() +{ + return !notice().isEmpty(); +} + +bool EditorSettings::setOptionEnabled(const QString& id, bool enabled) +{ + load(); + + Option* option = findOption(id); + if (!option) + { + setOperationNotice(tr("Unknown setting \"%1\" was refused.").arg(id)); + return false; + } + + if (option->value == enabled) + { + return true; + } + + if (!writeOptionValue(*option, enabled)) + { + setOperationNotice(tr("\"%1\" could not be saved; the previous value is unchanged.").arg(option->label)); + return false; + } + + option->value = enabled; + setOperationNotice(QString()); + Q_EMIT optionsChanged(); + return true; +} + +bool EditorSettings::resetToFactory() +{ + load(); + + QSettings& settings = store(); + settings.remove(RenderOptionsGroup); + settings.sync(); + if (settings.status() != QSettings::NoError) + { + setOperationNotice(tr("The shell settings could not be reset; the stored values are unchanged.")); + return false; + } + + bool changed = false; + for (Option& option : m_options) + { + if (option.value != option.defaultValue) + { + option.value = option.defaultValue; + changed = true; + } + } + + setOperationNotice(tr("Shell settings were reset to factory defaults.")); + if (changed) + { + Q_EMIT optionsChanged(); + } + return true; +} diff --git a/LoopEditor/editorsettings.h b/LoopEditor/editorsettings.h new file mode 100644 index 000000000..42964e163 --- /dev/null +++ b/LoopEditor/editorsettings.h @@ -0,0 +1,121 @@ +// MIT License +// Copyright (c) 2026 Loop contributors + +#ifndef EDITORSETTINGS_H +#define EDITORSETTINGS_H + +#include +#include +#include +#include +#include + +#include +#include + +/// The one typed settings store for the Quick shell (#239). +/// +/// Owns the persisted render options and diagnostic-view toggles the shell +/// applies to the interactive render path, and the factory reset that returns +/// them to their defaults. Persisted values are parsed once, when the store is +/// first read, into typed state: a stored value that is not a boolean is +/// reported through notice() and replaced by its default, never silently +/// coerced. Every option is presentation state only - no option changes a +/// document. +class EditorSettings final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QVariantList options READ options NOTIFY optionsChanged) + Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged) + Q_PROPERTY(bool hasNotice READ hasNotice NOTIFY noticeChanged) + +public: + /// The typed render options, mirroring pdf::PDFRenderer::Features. The + /// defaults reproduce PDFRenderer::getDefaultFeatures() plus the off-by- + /// default diagnostic views. + struct RenderOptions + { + bool antialiasing = true; + bool textAntialiasing = true; + bool smoothPictures = false; + bool displayAnnotations = true; + bool ignoreOptionalContentSettings = false; + bool displayRenderTimes = false; + bool showTextBlocks = false; + bool showTextLines = false; + }; + + /// The option ids the store persists; the settings surface and the command + /// handlers name the same ids. + static inline const QString AntialiasingOptionId = QStringLiteral("antialiasing"); + static inline const QString TextAntialiasingOptionId = QStringLiteral("textAntialiasing"); + static inline const QString SmoothPicturesOptionId = QStringLiteral("smoothPictures"); + static inline const QString DisplayAnnotationsOptionId = QStringLiteral("displayAnnotations"); + static inline const QString IgnoreOptionalContentOptionId = QStringLiteral("ignoreOptionalContentSettings"); + static inline const QString DisplayRenderTimesOptionId = QStringLiteral("displayRenderTimes"); + static inline const QString ShowTextBlocksOptionId = QStringLiteral("showTextBlocks"); + static inline const QString ShowTextLinesOptionId = QStringLiteral("showTextLines"); + + /// The backing store stays with the caller and must outlive this object. A + /// null pointer opens the application's own user-scope settings on first + /// use, which keeps construction free of I/O for a caller that never reads. + explicit EditorSettings(QSettings* settings = nullptr, QObject* parent = nullptr); + ~EditorSettings() override; + + EditorSettings(const EditorSettings&) = delete; + EditorSettings& operator=(const EditorSettings&) = delete; + + /// The typed snapshot, loading the store on first read. + RenderOptions renderOptions(); + + /// True when `id` is enabled in the typed state. An unknown id answers + /// false; it is never defaulted to a value. + bool isOptionEnabled(const QString& id); + + /// Presentation rows for QML: id, label, description, enabled. + QVariantList options(); + + /// Every visible notice: a persisted value that was rejected and replaced, + /// or the last refused operation. Empty when nothing needs reporting. + QString notice(); + bool hasNotice(); + + /// Persists one option. An unknown id is refused. A failed write leaves + /// both the typed state and the store unchanged and is reported. + Q_INVOKABLE bool setOptionEnabled(const QString& id, bool enabled); + + /// Removes every key this store owns and returns the typed state to its + /// defaults. A failed write is reported and leaves the previous values in + /// place; the caller confirms before this runs. + Q_INVOKABLE bool resetToFactory(); + +signals: + void optionsChanged(); + void noticeChanged(); + +private: + struct Option + { + QString id; + QString label; + QString description; + QString settingsKey; + bool defaultValue = true; + bool value = true; + }; + + QSettings& store(); + void load(); + Option* findOption(const QString& id); + bool writeOptionValue(const Option& option, bool enabled); + void setOperationNotice(QString text); + + std::unique_ptr m_ownedStore; + QSettings* m_store = nullptr; + std::vector