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 m_options; + QStringList m_loadNotices; + QString m_operationNotice; + bool m_loaded = false; +}; + +#endif // EDITORSETTINGS_H diff --git a/LoopEditor/operatorhelp.cpp b/LoopEditor/operatorhelp.cpp index 5619712d2..24a0fa610 100644 --- a/LoopEditor/operatorhelp.cpp +++ b/LoopEditor/operatorhelp.cpp @@ -3,7 +3,9 @@ #include "operatorhelp.h" +#include #include +#include #include #include #include @@ -12,6 +14,46 @@ #include +namespace +{ + +QJsonValue localizedPresentation(const QJsonValue& value, bool translateStrings = false) +{ + if (value.isString()) + { + return translateStrings + ? QJsonValue(QCoreApplication::translate("OperatorHelp", value.toString().toUtf8().constData())) + : value; + } + if (value.isArray()) + { + QJsonArray translated; + for (const auto& child : value.toArray()) + { + translated.append(localizedPresentation(child, translateStrings)); + } + return translated; + } + if (value.isObject()) + { + static const QSet fields = { + QStringLiteral("name"), QStringLiteral("title"), QStringLiteral("description"), + QStringLiteral("help"), QStringLiteral("getting_started"), QStringLiteral("measures"), + QStringLiteral("limitations"), QStringLiteral("condition"), QStringLiteral("meaning") + }; + QJsonObject translated; + const auto object = value.toObject(); + for (auto it = object.begin(); it != object.end(); ++it) + { + translated.insert(it.key(), localizedPresentation(it.value(), fields.contains(it.key()))); + } + return translated; + } + return value; +} + +} // namespace + OperatorHelp::OperatorHelp(QSettings& settings, QString sampleDirectory, QObject* parent) : QObject(parent), m_settings(settings), @@ -37,7 +79,7 @@ QJsonObject OperatorHelp::readResource(const QString& path) m_diagnostic = tr("Operator help is not valid: %1").arg(path); return {}; } - return document.object(); + return localizedPresentation(document.object()).toObject(); } QVariantList OperatorHelp::sampleJobs() const diff --git a/LoopEditor/operatorhelpstrings.h b/LoopEditor/operatorhelpstrings.h new file mode 100644 index 000000000..88ffbc2a8 --- /dev/null +++ b/LoopEditor/operatorhelpstrings.h @@ -0,0 +1,166 @@ +// Generated presentation strings. Refresh with scripts/update-quick-help-strings.py. +#pragma once + +#include + +[[maybe_unused]] inline constexpr const char* QuickOperatorHelpSources[] = { + QT_TRANSLATE_NOOP("OperatorHelp", "/DestOutputProfile is absent or not a stream while require_embedded_profile is true. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "1. Install the reviewed Windows MSI, or launch the reviewed Linux AppImage. Keep the package and original PDF for handoff."), + QT_TRANSLATE_NOOP("OperatorHelp", "2. Open the correction sample from Sample jobs. Loop creates a local PDF, profile and recipe for the exercise."), + QT_TRANSLATE_NOOP("OperatorHelp", "3. Switch to Preflight, Run preflight and select the bleed finding. Use its Help button to read measures and limitations, then inspect the page evidence."), + QT_TRANSLATE_NOOP("OperatorHelp", "4. Switch to Fix. Validate the imported sample recipe and Plan. Review the candidate and plan; use Production Preview and Compare to inspect the available evidence and its limits."), + QT_TRANSLATE_NOOP("OperatorHelp", "5. Approve the exact reviewed plan and Execute. Read Core's recheck and sign-off. If anything is refused, stale or incomplete, stop and retain that result."), + QT_TRANSLATE_NOOP("OperatorHelp", "6. Keep the original PDF. Sign-off currently covers a temporary copy. Save As creates a separate file whose bytes are not covered by that receipt. Retain this limitation with any delivery handoff."), + QT_TRANSLATE_NOOP("OperatorHelp", "A clear inspection"), + QT_TRANSLATE_NOOP("OperatorHelp", "A colour finding"), + QT_TRANSLATE_NOOP("OperatorHelp", "A strip counts as populated only above a 10% coverage floor (artwork bounds, or raster ink with raster_confirm). Bounds prove a strip populated only through the area that images and filled paths or glyphs actually paint inside it, cut by their clip, so stroked artwork such as hairlines and keylines is judged by raster confirmation or reported check-incomplete. White or near-white fills count as artwork."), + QT_TRANSLATE_NOOP("OperatorHelp", "A synthetic red RGB image is outside the profile's allowed colour spaces. Inspect its finding and limitations."), + QT_TRANSLATE_NOOP("OperatorHelp", "Alias of processing-steps for cutting-die workflows."), + QT_TRANSLATE_NOOP("OperatorHelp", "Allow-list of decoded ICC payload digests; optional."), + QT_TRANSLATE_NOOP("OperatorHelp", "Allow-list of output-condition identifiers; optional."), + QT_TRANSLATE_NOOP("OperatorHelp", "Allow-list of output-intent subtypes; optional."), + QT_TRANSLATE_NOOP("OperatorHelp", "An unsupported declaration"), + QT_TRANSLATE_NOOP("OperatorHelp", "Bleed or off-page length in points; 9 pt is one eighth of an inch."), + QT_TRANSLATE_NOOP("OperatorHelp", "Cap on over-limit regions reported per page; 0 suppresses region findings while still inspecting."), + QT_TRANSLATE_NOOP("OperatorHelp", "Choose the job's profile, then Run preflight. Select a finding to locate its evidence on the page. Open its Help button to see exactly what its check measures and cannot cover. Pass applies only to enabled checks. Incomplete means some checks could not establish a result; never treat it as clear. After editing, run a fresh inspection. Export the report for handoff."), + QT_TRANSLATE_NOOP("OperatorHelp", "Color modes that may be painted; an empty list makes the check return without inspecting."), + QT_TRANSLATE_NOOP("OperatorHelp", "Color spaces the embedded ICC profile may resolve to (output-intent); optional."), + QT_TRANSLATE_NOOP("OperatorHelp", "Compare"), + QT_TRANSLATE_NOOP("OperatorHelp", "Compare before and after evidence for the same reviewed plan and document revision. Read resolved, unchanged, introduced and incomplete findings separately. Navigating a delta helps inspect it but does not approve it. A stale comparison must be planned and inspected again. The receipt, not similarity of the pictures, establishes publication identity."), + QT_TRANSLATE_NOOP("OperatorHelp", "Content covered by later painting."), + QT_TRANSLATE_NOOP("OperatorHelp", "Content marked invisible or outside view."), + QT_TRANSLATE_NOOP("OperatorHelp", "Content whose bounds fall outside the page."), + QT_TRANSLATE_NOOP("OperatorHelp", "Declared widths at or below this value are hairlines."), + QT_TRANSLATE_NOOP("OperatorHelp", "Deprecated and ignored; a page-relative floor let an isolated over-limit element pass clean on a large page. Use min_region_area_mm2."), + QT_TRANSLATE_NOOP("OperatorHelp", "Document"), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not convert color. An ICC-based space is classified by its embedded profile header (gray, RGB or CMYK); a profile that is unreadable, not a device space, or disagrees with its /N or /Alternate is reported as its own disallowed space instead of being passed on the alternate. Separation, DeviceN, Lab and Cal spaces are still mapped to a fixed device family."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not flatten. Overprint composited with a non-Normal blend mode, constant alpha below one or a knockout group is reported from the compositor; the RIP result itself is not simulated."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not generate artwork bleed; box rewrite only."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not impose."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not invent a trim from artwork."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not produce those files and does not evaluate their external-resource or n-colorant output-intent rules. A declared level is reported unsupported. PDF/VT is a separate gap."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not prove press calibration."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not subset or repair; descriptor-less fonts are reported, not fixed."), + QT_TRANSLATE_NOOP("OperatorHelp", "Does not validate die geometry."), + QT_TRANSLATE_NOOP("OperatorHelp", "Effective image DPI against min_dpi."), + QT_TRANSLATE_NOOP("OperatorHelp", "Effective width after CTM; does not inspect filled parts."), + QT_TRANSLATE_NOOP("OperatorHelp", "Embedded font bytes that fail to parse or cmap, and shown character codes or CIDs that resolve to no glyph, .notdef, or an empty outline."), + QT_TRANSLATE_NOOP("OperatorHelp", "Enabled default checks pass. This is not a formal GWG conformance certificate."), + QT_TRANSLATE_NOOP("OperatorHelp", "Evaluates the default configuration under the View and Print usages with its /AS events; alternate configurations and content an /OC entry governs on Form XObjects or annotations that are never painted are not evaluated. A governor that cannot be evaluated is reported incomplete."), + QT_TRANSLATE_NOOP("OperatorHelp", "Expected height in points; with expected_width_pt it arms the size comparison (hasExpectedSize)."), + QT_TRANSLATE_NOOP("OperatorHelp", "Expected width in points; with expected_height_pt it arms the size comparison (hasExpectedSize)."), + QT_TRANSLATE_NOOP("OperatorHelp", "Fix"), + QT_TRANSLATE_NOOP("OperatorHelp", "Fraction of strip coverage that still counts as white."), + QT_TRANSLATE_NOOP("OperatorHelp", "Glyph coverage audits codes shown in page content, Form XObjects and annotation appearance streams of embedded TrueType, Type 1 and CID fonts. Composite CID 0 (default whitespace) and Type 3 glyph procedures are not reported; a page whose content cannot be processed is reported incomplete."), + QT_TRANSLATE_NOOP("OperatorHelp", "Hairline and below-threshold stroke widths."), + QT_TRANSLATE_NOOP("OperatorHelp", "ISO 19593-1 processing-step presence such as cutting-die."), + QT_TRANSLATE_NOOP("OperatorHelp", "Inspect"), + QT_TRANSLATE_NOOP("OperatorHelp", "Inspect page boxes and production geometry. Confirm units, scope and affected pages before applying a change. Page box correction does not create missing artwork. Reinspect any changed job and retain the original."), + QT_TRANSLATE_NOOP("OperatorHelp", "Inspection scope; a check-level object can only narrow the profile scope."), + QT_TRANSLATE_NOOP("OperatorHelp", "Live transparency blend modes and blend-space mismatches."), + QT_TRANSLATE_NOOP("OperatorHelp", "Masks and patterns can be under-counted."), + QT_TRANSLATE_NOOP("OperatorHelp", "Maximum total area coverage percentage; the profile must supply it because 0 is not usable."), + QT_TRANSLATE_NOOP("OperatorHelp", "Media/page size against expected width/height."), + QT_TRANSLATE_NOOP("OperatorHelp", "Minimum effective image resolution; image-resolution rejects 0 or a negative value."), + QT_TRANSLATE_NOOP("OperatorHelp", "Minimum painted stroke or filled-part width in points."), + QT_TRANSLATE_NOOP("OperatorHelp", "Minimum painted stroke width in points measured after the CTM."), + QT_TRANSLATE_NOOP("OperatorHelp", "Minimum process-black percentage for a region to count as rich black."), + QT_TRANSLATE_NOOP("OperatorHelp", "Not a full GWG hidden-content suite. Covers text render mode 3, zero font size, zero-alpha graphics state, zero-area clipping paths, and paint that falls entirely outside the active clip. An entirely empty clipping path is ignored by the content processor and so is not detected. Content hidden by a fully transparent soft mask is not detected, and zero horizontal scaling is not detected because the content processor clamps it to 1% before the state is read."), + QT_TRANSLATE_NOOP("OperatorHelp", "Object classes inspected; clipped parts, negative space, and annotation appearances are opt-in."), + QT_TRANSLATE_NOOP("OperatorHelp", "Off-page tolerance in points added around the effective page box by the shared hidden-content scan; it only widens the off-page test."), + QT_TRANSLATE_NOOP("OperatorHelp", "Open a PDF from File > Open, or start with a sample below. Navigate pages and zoom to read the job. The canvas is for inspection; its pixels are not a publication certificate. Save As writes a separate copy. Keep the original job unchanged."), + QT_TRANSLATE_NOOP("OperatorHelp", "Opt in to the strip raster confirm pass instead of the fast vector bounds pass."), + QT_TRANSLATE_NOOP("OperatorHelp", "Optional content groups and membership dictionaries (/P AnyOn, AllOn, AnyOff, AllOff and /VE) that hide print content, and content whose visibility differs between screen and print."), + QT_TRANSLATE_NOOP("OperatorHelp", "Output intent presence, subtype, identifier, and ICC payload."), + QT_TRANSLATE_NOOP("OperatorHelp", "Pages"), + QT_TRANSLATE_NOOP("OperatorHelp", "Pages that declare overprint (/OP or /op in an ExtGState, including Form XObjects) are also rasterized on the overprint-accurate compositor at probe_dpi; sub-pixel white paint and annotation appearances are not seen there."), + QT_TRANSLATE_NOOP("OperatorHelp", "Painted bounds are intersected with the effective clip before the page test; stroke and degenerate geometry use rectangle bounds, and images and shadings are not inspected."), + QT_TRANSLATE_NOOP("OperatorHelp", "Painted vector geometry is intersected with the effective clip and covered in paint order; images, shadings and transparency are not modelled, a blend mode or soft mask over earlier paint reports the page incomplete, and findings stay heuristic, not a RIP occlusion model."), + QT_TRANSLATE_NOOP("OperatorHelp", "Per-class severity override; an absent class uses the check severity."), + QT_TRANSLATE_NOOP("OperatorHelp", "Per-page mask pixel budget; exceeding it reports the page as incomplete rather than clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "Per-page pixel budget; exceeding it reports the page as incomplete instead of clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "Pixel channel value that counts as ink in the strip raster."), + QT_TRANSLATE_NOOP("OperatorHelp", "Preflight"), + QT_TRANSLATE_NOOP("OperatorHelp", "Probe DPI bounds small tints; not a GWG ink-name dictionary."), + QT_TRANSLATE_NOOP("OperatorHelp", "Processing step types that must each be detected."), + QT_TRANSLATE_NOOP("OperatorHelp", "Production Preview"), + QT_TRANSLATE_NOOP("OperatorHelp", "Production region analysed for total area coverage; bleed falls back to trim, crop, then media."), + QT_TRANSLATE_NOOP("OperatorHelp", "Raster budget can skip a page as incomplete rather than pass."), + QT_TRANSLATE_NOOP("OperatorHelp", "Raster probe; not a plate-ready separation TAC."), + QT_TRANSLATE_NOOP("OperatorHelp", "Raster resolution used to measure a candidate part, which also sets the measurement precision 72/probe_dpi."), + QT_TRANSLATE_NOOP("OperatorHelp", "Read the fidelity message before using the preview. Inspect plates, separations and overprint with the available preview controls. An approximate, pending or failed render is not production proof. Even an authoritative render cannot replace Core's preflight and publication evidence."), + QT_TRANSLATE_NOOP("OperatorHelp", "Require a decodable /DestOutputProfile ICC stream."), + QT_TRANSLATE_NOOP("OperatorHelp", "Resolution of the area-coverage raster."), + QT_TRANSLATE_NOOP("OperatorHelp", "Resolution of the bleed-margin strip raster; the confirm pass only runs when raster_confirm is true."), + QT_TRANSLATE_NOOP("OperatorHelp", "Resolution of the page probe that bounds which tints and rich-black areas are inventoried."), + QT_TRANSLATE_NOOP("OperatorHelp", "Review a blank-page bleed correction"), + QT_TRANSLATE_NOOP("OperatorHelp", "Same as processing-steps."), + QT_TRANSLATE_NOOP("OperatorHelp", "Select an object or finding to read its evidence, location, profile and limitations. The inspector describes the current document revision. A stale or unsupported selection cannot authorize a correction. Return to Preflight to locate a current finding."), + QT_TRANSLATE_NOOP("OperatorHelp", "Select the sample or job's Action List recipe, validate it and create a plan. Review the plan, candidate identity, affected pages, save policy and findings before approval. Approval applies to that exact reviewed plan. Execute only when the host enables it. Read the full recheck and sign-off; refused or incomplete publication is not success. On this candidate the Editor sign-off covers a temporary publication, not a later Save As. Durable delivery qualification is pending; do not describe a separately saved PDF as certified by that receipt."), + QT_TRANSLATE_NOOP("OperatorHelp", "Severity override for hairline-stroke findings; empty falls back to the check severity."), + QT_TRANSLATE_NOOP("OperatorHelp", "Severity override for thin-stroke findings; empty falls back to the check severity."), + QT_TRANSLATE_NOOP("OperatorHelp", "Slack allowed before a measured box is called a mismatch."), + QT_TRANSLATE_NOOP("OperatorHelp", "Smallest over-limit connected region, in mm^2 of physical area, that is reported. The default is about a 0.5 mm square, the smallest solid a press reproduces as a distinct area. A floor finer than four raster pixels at probe_dpi cannot be honored and reports the page incomplete."), + QT_TRANSLATE_NOOP("OperatorHelp", "Spot, separation, and rich-black inventory."), + QT_TRANSLATE_NOOP("OperatorHelp", "Thin filled parts and optional clipped/negative/annotation classes."), + QT_TRANSLATE_NOOP("OperatorHelp", "This blank page is missing bleed. Plan the imported add-bleed recipe, review and approve it, then read Core's recheck and sign-off. This geometry exercise cannot prove production artwork bleed."), + QT_TRANSLATE_NOOP("OperatorHelp", "This sample declares unsupported PDF/X-5n and does not conform to PDF/X. Its learning profile inspects conformance claims. Incomplete is not clear; supported checks cannot certify that declaration."), + QT_TRANSLATE_NOOP("OperatorHelp", "Total area coverage against max_ink_pct."), + QT_TRANSLATE_NOOP("OperatorHelp", "Trim box presence and optional expected size."), + QT_TRANSLATE_NOOP("OperatorHelp", "When false, more than one valid applicable output intent is a finding."), + QT_TRANSLATE_NOOP("OperatorHelp", "When true, a cutting-die processing step must exist."), + QT_TRANSLATE_NOOP("OperatorHelp", "When true, at least one output intent must be present."), + QT_TRANSLATE_NOOP("OperatorHelp", "Whether page color spaces are in the allowed list."), + QT_TRANSLATE_NOOP("OperatorHelp", "Whether painted content reaches the requested bleed margin."), + QT_TRANSLATE_NOOP("OperatorHelp", "Whether required page bleed boxes exist and meet amount_pt."), + QT_TRANSLATE_NOOP("OperatorHelp", "Whether the document declares PDF/X-5n, PDF/X-5g, or PDF/A-3, which Loop does not validate."), + QT_TRANSLATE_NOOP("OperatorHelp", "Whether used fonts are embedded."), + QT_TRANSLATE_NOOP("OperatorHelp", "White/knockout overprint flags on fills and strokes."), + QT_TRANSLATE_NOOP("OperatorHelp", "a clipped filled part measures below min_effective_width_pt and the class is opted in."), + QT_TRANSLATE_NOOP("OperatorHelp", "a connected region of at least min_region_area_mm2 exceeds max_ink_pct. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a detected dieline step is still marked printable (shouldPrint). The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a filled part measures below min_effective_width_pt; severity_by_class['thin-fill'] replaces the check severity when set."), + QT_TRANSLATE_NOOP("OperatorHelp", "a measurement lands within one raster pixel of min_effective_width_pt, so the class is reported incomplete instead of clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "a page BleedBox is missing or meets amount_pt minus tolerance_pt on fewer than four edges. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a page has live transparency whose blend-mode configuration list is non-empty. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a page paints at least one color space outside the allowed list, including an ICCBased space whose profile is unreadable or conflicts with its /N or /Alternate. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a page paints white or near-white content with overprint set, or the compositor applies overprint to paper-white pixels (images, shadings, results of transparency) the page-view scan does not inspect. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a page records one or more transparency blend-space mismatches. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a painted mark's bounds do not intersect the effective page box widened by amount_pt. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a required_types entry has no matching detected processing step. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a rich-black region is inventoried above rich_black_k_percent. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a spot colour is inventoried on the page. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "a stroke is a hairline, meaning its declared width is at or below zero_width_epsilon_pt; hairline_severity replaces the check severity when set."), + QT_TRANSLATE_NOOP("OperatorHelp", "a stroke's effective width after the CTM is below min_effective_width_pt; thin_stroke_severity replaces the check severity when set."), + QT_TRANSLATE_NOOP("OperatorHelp", "a used font has no font descriptor or is not embedded on the page that uses it. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "always info: the page has a bleed gap and is a candidate for the add-bleed fixup."), + QT_TRANSLATE_NOOP("OperatorHelp", "an earlier painted bounds is fully covered by later opaque paint; the scan is heuristic, so it is downgraded to info whenever the profile severity is error and otherwise keeps the profile severity."), + QT_TRANSLATE_NOOP("OperatorHelp", "an edge's bounds touch enough of the strip but do not prove it solid, and raster_confirm is off or the strip raster exceeded max_raster_pixels, so the edge is reported incomplete rather than clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "an embedded font program fails to parse or its cmap cannot be read, or a shown code resolves to no usable glyph; a complete inspection keeps the check severity, an incomplete one is forced to error"), + QT_TRANSLATE_NOOP("OperatorHelp", "an image's effective DPI is below min_dpi. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "an inspected stroke-like part measures below min_effective_width_pt; severity_by_class['thin-stroke'] replaces the check severity when set."), + QT_TRANSLATE_NOOP("OperatorHelp", "an intent has no identifier, or its identifier is not in allowed_identifiers. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "an intent subtype is not in allowed_subtypes. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "an optional-content group referenced by print content is not printable by default, or a membership dictionary governing marked content, a Form XObject or an annotation is hidden in print or differs between screen and print; an optional-content governor that cannot be evaluated is forced to error with inspection_complete false. The profile's check severity (error|warning|info, default error) replaces the others and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "an output separation, spot or process, is inventoried. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "annotation appearance geometry measures below min_effective_width_pt and the class is opted in."), + QT_TRANSLATE_NOOP("OperatorHelp", "catalog metadata is present but cannot be decoded, and no level was declared in the document info keys. The check status is not_inspected."), + QT_TRANSLATE_NOOP("OperatorHelp", "expected_width_pt and expected_height_pt are both set and the MediaBox misses them by more than tolerance_pt. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "expected_width_pt and expected_height_pt are both set and the TrimBox (trim, then crop, then media fallback) misses them by more than tolerance_pt. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "more than one valid applicable intent exists while allow_multiple is false. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "more than one valid intent resolves to conflicting color spaces. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "no output intent exists and required is true. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "raster_confirm is true and a bleed edge strip carries no pixel above probe_threshold. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "required is true and neither an ISO 19593-1 cutting-die OCG nor a legacy dieline spot colour was detected. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the ICC payload cannot be decoded, is not a valid ICC profile, or uses an unsupported color space. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the ICC payload digest is not in allowed_profile_sha256. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the catalog /OutputIntents array holds a non-dictionary or unresolved entry. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the compositor composites overprint under a non-Normal blend mode, constant alpha below one or a knockout group. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the compositor raster exceeded max_raster_pixels, was unavailable, or reported non-exact overprint fidelity, so the page is reported incomplete rather than clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "the declared ProfileCS disagrees with the embedded ICC color space, or that space is not in allowed. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the document declares pdfx-5n, pdfx-5g, or pdfa-3. The finding stays a warning so it is not a content-defect error; the check status is unsupported and the run is incomplete."), + QT_TRANSLATE_NOOP("OperatorHelp", "the fast bounds pass finds a bleed edge with no painted content. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the page raster exceeded max_raster_pixels, was unavailable, reported non-exact fidelity, or probe_dpi is too coarse to resolve min_region_area_mm2, so the page is reported incomplete rather than clean."), + QT_TRANSLATE_NOOP("OperatorHelp", "the page shows text with render mode 3, or font size 0, or paints a path at zero fill/stroke alpha, under a zero-area clipping path, or entirely outside the active clip. The profile's check severity (error|warning|info, default error) replaces it and warning/info route the finding to warnings."), + QT_TRANSLATE_NOOP("OperatorHelp", "the union of fill paths leaves a channel narrower than min_effective_width_pt and the class is opted in."), +}; diff --git a/LoopEditor/qml/ActionListPane.qml b/LoopEditor/qml/ActionListPane.qml index 051fe1d03..ff25443c9 100644 --- a/LoopEditor/qml/ActionListPane.qml +++ b/LoopEditor/qml/ActionListPane.qml @@ -106,10 +106,20 @@ Pane { objectName: "fixApprovePlanButton" text: qsTr("Approve plan") enabled: root.host !== null && root.host.correctionsAllowed && root.lifecycleState === "preview-ready" - onClicked: if (root.host && root.planIdentity) + onClicked: { + if (!root.host || !root.planIdentity) + return + // A plan that invalidates signatures is approved through the + // explicit acknowledgement, so the invalidation was shown in + // the approval and not only in the preview. + if (root.planIdentity.signatureInvalidationApplies) { + signatureInvalidationDialog.open() + return + } root.host.approveActionListPlan(root.planIdentity.planDigest, - root.planIdentity.sourceSha256, - root.planIdentity.plannedRevision) + root.planIdentity.sourceSha256, + root.planIdentity.plannedRevision) + } Accessible.name: qsTr("Approve the planned correction") Accessible.description: qsTr("Binds the approval to this plan digest and revision.") } @@ -185,6 +195,17 @@ Pane { text: qsTr("The preview is incomplete. It is not approval evidence.") Accessible.name: qsTr("Preview incomplete") } + + Label { + objectName: "fixSignatureInvalidationNotice" + Layout.fillWidth: true + wrapMode: Text.Wrap + visible: root.preview !== null && root.preview.signatureInvalidation !== undefined + && root.preview.signatureInvalidation.applies + text: visible ? root.preview.signatureInvalidation.summary : "" + Accessible.name: qsTr("Signature invalidation") + Accessible.description: text + } } } @@ -238,7 +259,7 @@ Pane { return qsTr("Run status %1 · postflight %2 · recheck verdict: %3 · cleared %4 · remaining %5 · introduced %6 · not fully rechecked %7") .arg(root.recheck.status) .arg(root.recheck.pass ? qsTr("passed") : qsTr("not passed")) - .arg(root.recheck.verdictState === "" ? qsTr("not reduced") : root.recheck.verdictState) + .arg(root.recheck.verdictState === "" ? qsTr("not reduced") : root.host.localizedTerm(root.recheck.verdictState)) .arg(root.recheck.resolved.length) .arg(root.recheck.unchanged.length) .arg(root.recheck.introduced.length) @@ -290,7 +311,7 @@ Pane { GroupBox { objectName: "fixRollbackGroup" Layout.fillWidth: true - title: qsTr("Recorded revisions") + title: qsTr("Revision history") Accessible.name: qsTr("Rollback to a recorded revision") ColumnLayout { @@ -305,6 +326,61 @@ Pane { Accessible.name: qsTr("Rollback availability") } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.host ? root.host.revisionHistorySummary : "" + Accessible.name: qsTr("Governed revision history summary") + } + RowLayout { + Button { + objectName: "historyUndoButton" + text: qsTr("Undo") + enabled: root.host && root.host.historyUndoAvailable + onClicked: root.host.invokeCommand("actionUndo") + Accessible.name: qsTr("Undo by publishing a forward rollback") + } + Button { + objectName: "historyRedoButton" + text: qsTr("Redo") + enabled: root.host && root.host.historyRedoAvailable + onClicked: root.host.invokeCommand("actionRedo") + Accessible.name: qsTr("Redo by publishing a forward rollback") + } + Button { + text: qsTr("Open published artifact") + enabled: root.host && root.host.hasDocument + onClicked: root.host.openGovernedArtifact() + Accessible.description: root.host ? root.host.governedArtifactPath : "" + } + } + Repeater { + model: root.host ? root.host.revisionHistory : [] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Event %1 · %2 · revision %3%4 · approval %5 by %6") + .arg(modelData.sequence).arg(modelData.status) + .arg(modelData.output ? modelData.output.sha256 : modelData.documentRevisionDigest) + .arg(modelData.current ? qsTr(" (current)") : "") + .arg(modelData.approval.decision || qsTr("none")) + .arg(modelData.approval.actorId || qsTr("none")) + Accessible.name: text + } + TextArea { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + visible: modelData.receiptText !== "{}" + text: modelData.receiptText + Accessible.name: qsTr("Revision approval and publication receipt") + } + } + } Repeater { model: root.host ? root.host.fixRollbackPoints : [] @@ -325,7 +401,7 @@ Pane { Button { text: qsTr("Return to this revision") - enabled: root.host !== null && root.host.fixRollbackAvailable + enabled: root.host !== null && root.host.fixRollbackAvailable && modelData.canReturn onClicked: { root.pendingRollbackId = modelData.rollbackId rollbackConfirm.open() @@ -357,6 +433,36 @@ Pane { onRejected: root.pendingRollbackId = "" } + Dialog { + id: signatureInvalidationDialog + objectName: "fixSignatureInvalidationDialog" + title: qsTr("Approve and accept signature invalidation") + modal: true + anchors.centerIn: parent + standardButtons: Dialog.Ok | Dialog.Cancel + + Label { + width: 360 + wrapMode: Text.Wrap + text: root.preview !== null && root.preview.signatureInvalidation !== undefined + && root.preview.signatureInvalidation.applies + ? root.preview.signatureInvalidation.summary + : qsTr("This plan invalidates document signatures.") + Accessible.name: qsTr("Signature invalidation notice") + Accessible.description: text + } + + onAccepted: { + if (!root.host || !root.planIdentity) + return + if (!root.host.acknowledgePlanSignatureInvalidation(root.planIdentity.planDigest)) + return + root.host.approveActionListPlan(root.planIdentity.planDigest, + root.planIdentity.sourceSha256, + root.planIdentity.plannedRevision) + } + } + RowLayout { Layout.fillWidth: true diff --git a/LoopEditor/qml/DiagnosticsDialog.qml b/LoopEditor/qml/DiagnosticsDialog.qml new file mode 100644 index 000000000..1f7a5a07e --- /dev/null +++ b/LoopEditor/qml/DiagnosticsDialog.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Diagnostics collection (#239): the ADR-006 bundle surface. It shows the +// privacy boundary, takes the operator's parent folder and forwards the +// collection to EditorHost; the result label renders the host's own projection +// of the collector's outcome. +Dialog { + id: root + objectName: "diagnosticsDialog" + + property var host: null + property var folderDialog: null + + readonly property var bundle: host ? host.diagnosticsBundle : ({}) + readonly property string chosenFolder: folderDialog && folderDialog.selectedFolder ? folderDialog.selectedFolder.toString() : "" + + title: qsTr("Collect diagnostics") + modal: true + width: Math.min(560, parent ? parent.width - 32 : 560) + height: Math.min(520, parent ? parent.height - 32 : 520) + anchors.centerIn: parent + closePolicy: Popup.CloseOnEscape + onClosed: if (host && host.focusRestoration) host.focusRestoration.restore() + + contentItem: ColumnLayout { + Accessible.role: Accessible.Grouping + Accessible.name: root.title + spacing: 8 + + Label { + objectName: "diagnosticsPrivacyNotice" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("The bundle contains a versioned manifest, build and runtime metadata, the scrubbed application logs and a README. It never contains the PDF, document content, extracted text, your settings, recent files, environment variables or crash dumps. Inspect the folder before sharing it.") + Accessible.name: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "diagnosticsChooseFolderButton" + text: qsTr("Choose folder…") + Accessible.name: text + onClicked: if (root.folderDialog) root.folderDialog.open() + } + + Label { + objectName: "diagnosticsChosenFolder" + Layout.fillWidth: true + elide: Text.ElideMiddle + text: root.chosenFolder + Accessible.name: qsTr("Bundle parent folder") + Accessible.description: text + } + } + + Button { + objectName: "diagnosticsCollectButton" + Layout.fillWidth: true + enabled: root.chosenFolder.length > 0 + text: qsTr("Collect bundle") + Accessible.name: text + onClicked: if (root.host && root.folderDialog) root.host.collectDiagnosticsFileUrl(root.folderDialog.selectedFolder) + } + + Label { + objectName: "diagnosticsResult" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: text.length > 0 + text: { + if (!root.bundle.status || root.bundle.status === "idle") + return "" + if (root.bundle.status === "failed") + return qsTr("The bundle could not be written: %1").arg(root.bundle.error) + return qsTr("Bundle written to %1 (%2 files). Review it before sharing.").arg(root.bundle.directory).arg(root.bundle.fileCount) + } + Accessible.name: text + } + } +} diff --git a/LoopEditor/qml/DocumentPane.qml b/LoopEditor/qml/DocumentPane.qml index f95ac7258..4fe4d2603 100644 --- a/LoopEditor/qml/DocumentPane.qml +++ b/LoopEditor/qml/DocumentPane.qml @@ -10,7 +10,7 @@ Item { Accessible.role: Accessible.Pane Accessible.name: qsTr("Document workspace") - Accessible.description: qsTr("Page navigation, document outline, search, PDF canvas, and contextual inspection.") + Accessible.description: qsTr("Page navigation, document outline, search, annotations, PDF canvas, and contextual inspection.") property var host: editorHost property var documentModel: host ? host.documentModel : null @@ -42,18 +42,50 @@ Item { TabButton { objectName: "pagesTabButton" text: qsTr("Pages") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } Accessible.name: qsTr("Pages tab") } TabButton { objectName: "outlineTabButton" text: qsTr("Outline") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } Accessible.name: qsTr("Outline tab") } TabButton { objectName: "searchTabButton" text: qsTr("Search") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } Accessible.name: qsTr("Search tab") } + TabButton { + objectName: "annotationsTabButton" + text: qsTr("Annotations") + Accessible.name: qsTr("Annotations tab") + } } StackLayout { @@ -183,6 +215,66 @@ Item { } } } + + ColumnLayout { + objectName: "annotationsReviewColumn" + spacing: 8 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.documentModel + ? qsTr("%1 annotation(s)").arg(root.documentModel.annotationCount) + : "" + Accessible.name: qsTr("Annotation count") + } + + ListView { + id: annotationsView + objectName: "annotationsView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible && tabBar.currentIndex === 3 + activeFocusOnTab: true + keyNavigationEnabled: true + highlightFollowsCurrentItem: true + model: root.documentModel ? root.documentModel.annotations : null + Accessible.role: Accessible.List + Accessible.name: qsTr("Document annotations") + Accessible.description: qsTr("Read-only annotations in this document. Activating one navigates to its page region.") + + delegate: ItemDelegate { + required property int index + required property int pageNumber + required property string kind + required property string title + required property string contents + required property bool hidden + required property bool selected + width: annotationsView.width + activeFocusOnTab: true + highlighted: selected + text: qsTr("Page %1 — %2%3").arg(pageNumber).arg(kind).arg(hidden ? qsTr(" (hidden)") : "") + Accessible.role: Accessible.ListItem + Accessible.name: text + Accessible.description: qsTr("Annotation %1 by %2: %3") + .arg(index + 1) + .arg(title.length > 0 ? title : qsTr("unknown author")) + .arg(contents) + onClicked: if (root.host) + root.host.navigateToAnnotation(index) + } + } + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + visible: annotationsView.count === 0 + text: qsTr("No annotations") + Accessible.name: qsTr("No annotations") + } + } } } } diff --git a/LoopEditor/qml/InspectorPane.qml b/LoopEditor/qml/InspectorPane.qml index f314d793f..2fae4394e 100644 --- a/LoopEditor/qml/InspectorPane.qml +++ b/LoopEditor/qml/InspectorPane.qml @@ -143,6 +143,15 @@ Pane { .arg(root.documentModel && root.documentModel.canCopy ? qsTr("allowed") : qsTr("restricted")) .arg(root.documentModel && root.documentModel.canAssemble ? qsTr("allowed") : qsTr("restricted")) } + + StateBadge { + objectName: "signatureStateBadge" + Layout.fillWidth: true + visual: root.host ? root.host.signatureStateVisual : null + stateColor: root.host ? root.host.signatureStateColor : "transparent" + labelText: root.host ? root.host.signatureSummary : "" + badgePrefix: "signatureState" + } } } } diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b815..52c521220 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -30,6 +30,19 @@ ApplicationWindow { host: window.host } + SettingsDialog { + id: settingsDialog + parent: Overlay.overlay + host: window.host + } + + DiagnosticsDialog { + id: diagnosticsDialog + parent: Overlay.overlay + host: window.host + folderDialog: diagnosticsFolderDialog + } + Shortcut { sequence: "F1" onActivated: { @@ -131,6 +144,36 @@ ApplicationWindow { saveAsDialog: saveAsDialog } + Dialog { + id: historyConfirm + objectName: "historyNavigationConfirmDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: commandId === "actionUndo" ? qsTr("Undo published correction") : qsTr("Redo published correction") + standardButtons: Dialog.Ok | Dialog.Cancel + property string commandId: "" + property string rollbackId: "" + property string revision: "" + Label { + width: 360 + wrapMode: Text.Wrap + text: qsTr("Publish a new rollback artifact after revalidation? Existing revisions, approvals and receipts are retained.") + Accessible.name: text + } + onAccepted: if (window.host) window.host.invokeCommand(commandId, {rollbackId: rollbackId, revision: revision}) + } + + Connections { + target: window.host + function onHistoryNavigationRequested(commandId, rollbackId, revision) { + historyConfirm.commandId = commandId + historyConfirm.rollbackId = rollbackId + historyConfirm.revision = revision + historyConfirm.open() + } + } + FileDialog { id: openDialog title: qsTr("Open PDF") @@ -144,6 +187,117 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + Dialog { + id: outputDialog + objectName: "documentOutputDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + title: printing ? qsTr("Print to PDF") : qsTr("Export document") + standardButtons: Dialog.Ok | Dialog.Cancel + property bool printing: false + property string revision: "" + Accessible.name: title + contentItem: ColumnLayout { + Label { + text: host ? host.previewFidelitySummary : "" + wrapMode: Text.WordWrap + Layout.preferredWidth: 440 + } + Label { + text: outputDialog.printing ? qsTr("Print every page to a PDF file using the authoritative renderer.") : + qsTr("PDF copies the exact document bytes. PNG and TIFF export the current page.") + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + ComboBox { + id: outputFormat + visible: !outputDialog.printing + model: [qsTr("PDF"), qsTr("PNG"), qsTr("TIFF")] + Accessible.name: qsTr("Export format") + Layout.fillWidth: true + } + Label { + text: qsTr("Resolution (dpi)") + visible: outputResolution.visible + } + SpinBox { + id: outputResolution + from: 72 + to: outputDialog.printing && host && !host.documentModel.canHighResolutionPrint ? 150 : 600 + value: 300 + editable: true + visible: outputDialog.printing || outputFormat.currentIndex !== 0 + Accessible.name: qsTr("Resolution in dots per inch") + } + CheckBox { + id: outputAcknowledgment + text: qsTr("I acknowledge fidelity warnings. This output is not print proof.") + Accessible.name: text + Layout.fillWidth: true + } + } + onOpened: printing ? outputResolution.forceActiveFocus() : outputFormat.forceActiveFocus() + onAccepted: outputFileDialog.open() + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FileDialog { + id: outputFileDialog + title: outputDialog.title + fileMode: FileDialog.SaveFile + readonly property string format: outputDialog.printing ? "print-pdf" : ["pdf", "png", "tiff"][outputFormat.currentIndex] + nameFilters: format === "png" ? [qsTr("PNG files (*.png)")] : + format === "tiff" ? [qsTr("TIFF files (*.tiff *.tif)")] : [qsTr("PDF files (*.pdf)")] + onAccepted: { + if (host) host.exportDocumentFileUrl(selectedFile, format, outputResolution.value, outputDialog.revision, outputAcknowledgment.checked) + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + Connections { + target: host + function onDocumentOutputRequested(printing) { + if (host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + outputDialog.printing = printing + outputDialog.revision = host.outputRevision() + outputAcknowledgment.checked = false + outputResolution.value = printing && !host.documentModel.canHighResolutionPrint ? 150 : 300 + outputDialog.open() + } + function onDocumentOutputFinished() { outputResultDialog.open() } + } + + Dialog { + id: outputResultDialog + objectName: "documentOutputResultDialog" + parent: Overlay.overlay + anchors.centerIn: parent + title: qsTr("Output result") + standardButtons: Dialog.Close + Accessible.name: title + contentItem: ColumnLayout { + Label { + Layout.preferredWidth: 500 + wrapMode: Text.WrapAnywhere + text: { + const record = host ? host.outputRecord : ({}) + if (record.error) return record.error + const artifact = record.artifact || ({}) + const receipt = record.publicationReceipt || ({}) + return qsTr("Saved artifact: %1\nRevision: %2\n%3\n%4\nThis output is not print proof.") + .arg(artifact.sha256 || "").arg(record.revision || "") + .arg(record.renderer || "") + .arg(receipt.plan_digest ? qsTr("Correction receipt plan: %1").arg(receipt.plan_digest) : + record.dpi ? qsTr("%1 dpi — %2").arg(record.dpi).arg(record.colorHandling) : qsTr("Original source bytes")) + } + Accessible.name: text + } + } + onClosed: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: saveAsDialog title: qsTr("Save PDF As") @@ -158,6 +312,32 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: screenshotDialog + title: qsTr("Save Screenshot") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + if (host) { + host.captureCanvasFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FolderDialog { + id: extractImagesDialog + title: qsTr("Extract Images Into Folder") + onAccepted: { + if (host) { + host.extractCurrentPageImagesFileUrl(selectedFolder) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: preflightReportDialog title: qsTr("Export Preflight Report") @@ -228,6 +408,29 @@ ApplicationWindow { profileSaveVersionField.text = host ? host.preflightProfileDraftVersion : "" preflightProfileSaveDialog.open() } + function onScreenshotRequested() { + screenshotDialog.open() + } + function onImageExtractionRequested() { + extractImagesDialog.open() + } + function onSettingsPanelRequested() { + if (host && host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + settingsDialog.open() + } + function onSettingsResetRequested() { + if (host && host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + settingsDialog.open() + } + function onDiagnosticsCollectionRequested() { + if (host && host.focusRestoration) host.focusRestoration.remember(window.activeFocusItem) + diagnosticsDialog.open() + } + } + + FolderDialog { + id: diagnosticsFolderDialog + title: qsTr("Choose a folder for the diagnostics bundle") } TextField { diff --git a/LoopEditor/qml/MenuModel.qml b/LoopEditor/qml/MenuModel.qml index 34069d28d..3ed992db4 100644 --- a/LoopEditor/qml/MenuModel.qml +++ b/LoopEditor/qml/MenuModel.qml @@ -78,17 +78,126 @@ QtObject { } function labelForEntry(entry) { + if (entry && entry.id === "actionPrint") return qsTr("Print to PDF…") + if (entry && entry.id === "actionExportDocument") return qsTr("Export document…") if (!entry || !entry.labelKey) { return entry && entry.id ? entry.id : "" } - const key = entry.labelKey - const suffix = key.startsWith("command.") ? key.slice("command.".length) : key - const base = suffix.endsWith(".label") ? suffix.slice(0, -".label".length) : suffix - const words = base.replace(/^action/, "").replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2") - if (words.length === 0) { - return entry.id + switch (entry.id) { + case "actionAbout": return qsTr("About") + case "actionAddAttachment": return qsTr("Add Attachment") + case "actionAutomaticDocumentRefresh": return qsTr("Automatic Document Refresh") + case "actionBecomeASponsor": return qsTr("Become ASponsor") + case "actionBleedFixup": return qsTr("Bleed Fixup") + case "actionBookmarkAutoGenerate": return qsTr("Bookmark Auto Generate") + case "actionBookmarkExport": return qsTr("Bookmark Export") + case "actionBookmarkImport": return qsTr("Bookmark Import") + case "actionBookmarkPage": return qsTr("Bookmark Page") + case "actionCertificateManager": return qsTr("Certificate Manager") + case "actionClearRecentFileHistory": return qsTr("Clear Recent File History") + case "actionClose": return qsTr("Close") + case "actionCollectDiagnostics": return qsTr("Collect Diagnostics") + case "actionColorBitonal": return qsTr("Color Bitonal") + case "actionColorCustom": return qsTr("Color Custom") + case "actionColorGrayscale": return qsTr("Color Grayscale") + case "actionColorHighContrast": return qsTr("Color High Contrast") + case "actionColorInvert": return qsTr("Color Invert") + case "actionCopyText": return qsTr("Copy Text") + case "actionCreateBitonalDocument": return qsTr("Create Bitonal Document") + case "actionCreateEllipse": return qsTr("Create Ellipse") + case "actionCreateFreehandCurve": return qsTr("Create Freehand Curve") + case "actionCreateHyperlink": return qsTr("Create Hyperlink") + case "actionCreateHyperlinkToThisPDFFit": return qsTr("Create Hyperlink To This PDFFit") + case "actionCreateHyperlinkToThisPDFFitB": return qsTr("Create Hyperlink To This PDFFit B") + case "actionCreateHyperlinkToThisPDFFitBH": return qsTr("Create Hyperlink To This PDFFit BH") + case "actionCreateHyperlinkToThisPDFFitBV": return qsTr("Create Hyperlink To This PDFFit BV") + case "actionCreateHyperlinkToThisPDFFitH": return qsTr("Create Hyperlink To This PDFFit H") + case "actionCreateHyperlinkToThisPDFFitR": return qsTr("Create Hyperlink To This PDFFit R") + case "actionCreateHyperlinkToThisPDFFitV": return qsTr("Create Hyperlink To This PDFFit V") + case "actionCreateHyperlinkToThisPDFXYZ": return qsTr("Create Hyperlink To This PDFXYZ") + case "actionCreateHyperlinkToThisPDFXYZInheritZoom": return qsTr("Create Hyperlink To This PDFXYZInherit Zoom") + case "actionCreatePolygon": return qsTr("Create Polygon") + case "actionCreatePolyline": return qsTr("Create Polyline") + case "actionCreateRectangle": return qsTr("Create Rectangle") + case "actionCreateStraightLine": return qsTr("Create Straight Line") + case "actionCreateTextHighlight": return qsTr("Create Text Highlight") + case "actionCreateTextSquiggly": return qsTr("Create Text Squiggly") + case "actionCreateTextStrikeout": return qsTr("Create Text Strikeout") + case "actionCreateTextUnderline": return qsTr("Create Text Underline") + case "actionDeselectText": return qsTr("Deselect Text") + case "actionEditDocumentMetadata": return qsTr("Edit Document Metadata") + case "actionEncryption": return qsTr("Encryption") + case "actionExtractImage": return qsTr("Extract Image") + case "actionFind": return qsTr("Find") + case "actionFindNext": return qsTr("Find Next") + case "actionFindPrevious": return qsTr("Find Previous") + case "actionFirstPageOnRightSide": return qsTr("First Page On Right Side") + case "actionFitHeight": return qsTr("Fit Height") + case "actionFitPage": return qsTr("Fit Page") + case "actionFitWidth": return qsTr("Fit Width") + case "actionFullscreenMode": return qsTr("Fullscreen Mode") + case "actionGetSource": return qsTr("Get Source") + case "actionGoToDocumentEnd": return qsTr("Go To Document End") + case "actionGoToDocumentStart": return qsTr("Go To Document Start") + case "actionGotoNextBookmark": return qsTr("Goto Next Bookmark") + case "actionGoToNextLine": return qsTr("Go To Next Line") + case "actionGoToNextPage": return qsTr("Go To Next Page") + case "actionGotoPreviousBookmark": return qsTr("Goto Previous Bookmark") + case "actionGoToPreviousLine": return qsTr("Go To Previous Line") + case "actionGoToPreviousPage": return qsTr("Go To Previous Page") + case "actionInlineText": return qsTr("Inline Text") + case "actionInsertPageNumbers": return qsTr("Insert Page Numbers") + case "actionMagnifier": return qsTr("Magnifier") + case "actionMoveSelection": return qsTr("Move Selection") + case "actionOpen": return qsTr("Open") + case "actionOptimize": return qsTr("Optimize") + case "actionOptimizeImages": return qsTr("Optimize Images") + case "actionOptions": return qsTr("Options") + case "actionPageGeometry": return qsTr("Page Geometry") + case "actionPageLayoutContinuous": return qsTr("Page Layout Continuous") + case "actionPageLayoutSinglePage": return qsTr("Page Layout Single Page") + case "actionPageLayoutTwoColumns": return qsTr("Page Layout Two Columns") + case "actionPageLayoutTwoPages": return qsTr("Page Layout Two Pages") + case "actionPrint": return qsTr("Print") + case "actionProperties": return qsTr("Properties") + case "actionQuit": return qsTr("Quit") + case "actionRedo": return qsTr("Redo") + case "actionRemoveExternalLinks": return qsTr("Remove External Links") + case "actionRemoveAttachment": return qsTr("Remove Attachment") + case "actionRender_to_Images": return qsTr("Render to Images") + case "actionRendering_Errors": return qsTr("Rendering Errors") + case "actionRenderOptionAntialiasing": return qsTr("Render Option Antialiasing") + case "actionRenderOptionDisplayAnnotations": return qsTr("Render Option Display Annotations") + case "actionRenderOptionDisplayRenderTimes": return qsTr("Render Option Display Render Times") + case "actionRenderOptionIgnoreOptionalContentSettings": return qsTr("Render Option Ignore Optional Content Settings") + case "actionRenderOptionSmoothPictures": return qsTr("Render Option Smooth Pictures") + case "actionRenderOptionTextAntialiasing": return qsTr("Render Option Text Antialiasing") + case "actionResetToFactorySettings": return qsTr("Reset To Factory Settings") + case "actionRotateLeft": return qsTr("Rotate Left") + case "actionRotateRight": return qsTr("Rotate Right") + case "actionSanitize": return qsTr("Sanitize") + case "actionSave": return qsTr("Save") + case "actionSave_As": return qsTr("Save As") + case "actionScreenshot": return qsTr("Screenshot") + case "actionSelectTable": return qsTr("Select Table") + case "actionSelectText": return qsTr("Select Text") + case "actionSelectTextAll": return qsTr("Select Text All") + case "actionSend_by_E_Mail": return qsTr("Send by E Mail") + case "actionSetFormFieldValue": return qsTr("Set Form Field Value") + case "actionShow_Text_Blocks": return qsTr("Show Text Blocks") + case "actionShow_Text_Lines": return qsTr("Show Text Lines") + case "actionStickyNoteComment": return qsTr("Sticky Note Comment") + case "actionStickyNoteHelp": return qsTr("Sticky Note Help") + case "actionStickyNoteInsert": return qsTr("Sticky Note Insert") + case "actionStickyNoteKey": return qsTr("Sticky Note Key") + case "actionStickyNoteNewParagraph": return qsTr("Sticky Note New Paragraph") + case "actionStickyNoteNote": return qsTr("Sticky Note Note") + case "actionStickyNoteParagraph": return qsTr("Sticky Note Paragraph") + case "actionUndo": return qsTr("Undo") + case "actionZoom_In": return qsTr("Zoom In") + case "actionZoom_Out": return qsTr("Zoom Out") + default: return entry.id } - return words.charAt(0).toUpperCase() + words.slice(1) } function commandEnabled(commandId) { diff --git a/LoopEditor/qml/OperatorHelpDialog.qml b/LoopEditor/qml/OperatorHelpDialog.qml index d33d3c21a..3765dc942 100644 --- a/LoopEditor/qml/OperatorHelpDialog.qml +++ b/LoopEditor/qml/OperatorHelpDialog.qml @@ -70,10 +70,54 @@ Dialog { id: sections objectName: "operatorHelpSections" Layout.fillWidth: true - TabButton { text: qsTr("Getting started") } - TabButton { text: qsTr("Workspaces") } - TabButton { text: qsTr("Findings") } - TabButton { text: qsTr("Sample jobs") } + TabButton { + text: qsTr("Getting started") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } + } + TabButton { + text: qsTr("Workspaces") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } + } + TabButton { + text: qsTr("Findings") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } + } + TabButton { + text: qsTr("Sample jobs") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } + } } StackLayout { Layout.fillWidth: true @@ -180,7 +224,7 @@ Dialog { Label { required property var modelData Layout.fillWidth: true - text: "%1: %2".arg(modelData.finding_type).arg(modelData.condition) + text: qsTr("%1: %2").arg(root.host ? root.host.localizedTerm(modelData.finding_type) : modelData.finding_type).arg(modelData.condition) wrapMode: Text.WordWrap activeFocusOnTab: true Accessible.name: text diff --git a/LoopEditor/qml/PreflightPane.qml b/LoopEditor/qml/PreflightPane.qml index 5961d9923..bbdbd8c66 100644 --- a/LoopEditor/qml/PreflightPane.qml +++ b/LoopEditor/qml/PreflightPane.qml @@ -408,22 +408,22 @@ Pane { delegate: ItemDelegate { width: findingsView.width activeFocusOnTab: true - text: "%1 — %2".arg(model.severity).arg(model.message) + text: qsTr("%1 — %2: %3").arg(root.host.localizedTerm(model.severity)).arg(root.host.localizedTerm(model.type)).arg(model.message) highlighted: model.selected Accessible.selected: model.selected Accessible.role: Accessible.ListItem - Accessible.name: model.message + Accessible.name: qsTr("%1: %2").arg(root.host.localizedTerm(model.type)).arg(model.message) Accessible.description: qsTr("Severity %1, scope %2, page %3, object %4, check %5, evidence %6") - .arg(model.severity).arg(model.scope).arg(model.page).arg(model.objectId).arg(model.checkId).arg(model.evidenceIds.join(", ")) + .arg(root.host.localizedTerm(model.severity)).arg(model.scope).arg(model.page).arg(model.objectId).arg(model.checkId).arg(model.evidenceIds.join(", ")) contentItem: RowLayout { Label { Layout.fillWidth: true - text: "%1 — %2".arg(model.severity).arg(model.message) + text: qsTr("%1 — %2: %3").arg(root.host.localizedTerm(model.severity)).arg(root.host.localizedTerm(model.type)).arg(model.message) wrapMode: Text.WordWrap } ToolButton { text: qsTr("Help") - Accessible.name: qsTr("Help for %1 finding").arg(model.type) + Accessible.name: qsTr("Help for %1 finding").arg(root.host.localizedTerm(model.type)) onClicked: root.helpRequested(model.checkId, model.type) } } diff --git a/LoopEditor/qml/ProductionPreviewPane.qml b/LoopEditor/qml/ProductionPreviewPane.qml index 58e7fd2e4..9c9af0178 100644 --- a/LoopEditor/qml/ProductionPreviewPane.qml +++ b/LoopEditor/qml/ProductionPreviewPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts // Production Preview workspace (#586): the identity-bound before/after preview. @@ -138,39 +139,227 @@ Pane { Accessible.description: qsTr("Re-renders the current page only; the document stays open.") } } + + Label { + objectName: "productionPreviewSoftProofState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host + ? "" + : qsTr("Soft proof %1: %2") + .arg(root.host.softProofStateName) + .arg(root.host.softProofSummary()) + Accessible.name: qsTr("Soft proof state") + Accessible.description: text + } + + Label { + objectName: "productionPreviewSoftProofProfile" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: root.host !== null && root.host.softProofProfileId.length > 0 + text: root.host + ? qsTr("Output-intent profile %1 (identifier %2, type %3).") + .arg(root.host.softProofProfileName) + .arg(root.host.softProofProfileId) + .arg(root.host.softProofProfileTypeName) + : "" + Accessible.name: qsTr("Soft-proof profile identity") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewSoftProofToggle" + text: root.host && root.host.softProofActive + ? qsTr("Stop the soft proof") + : qsTr("Simulate the output intent (soft proof)") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.toggleSoftProof() + Accessible.name: qsTr("Toggle the soft-proof simulation of the output intent") + Accessible.description: qsTr("Renders the canvas through Core's colour management under proofing settings. A simulation of print, never recorded as approval.") + } + } } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Display colour") + Accessible.name: qsTr("Display colour modes") + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + ComboBox { + objectName: "productionPreviewDisplayColorMode" + Layout.fillWidth: true + enabled: root.host !== null + model: [ "normal", "invert", "grayscale", "high-contrast", "bitonal", "custom" ] + currentIndex: { + if (!root.host) + return 0 + const index = model.indexOf(root.host.displayColorModeName) + return index < 0 ? 0 : index + } + onActivated: if (root.host) root.host.setDisplayColorMode(model[currentIndex]) + Accessible.name: qsTr("Display colour mode") + Accessible.description: qsTr("Presentation only: filters the canvas pixels and asks for no render.") + } + + Label { + objectName: "productionPreviewDisplayColorState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host || !root.host.displayColorModeActive + ? qsTr("No display colour mode is active.") + : qsTr("Display colour simulation (%1): presentation only, never a production render.") + .arg(root.host.displayColorModeName) + Accessible.name: qsTr("Display colour state") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewCustomForeground" + text: qsTr("Custom foreground") + enabled: root.host !== null + onClicked: customForegroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's foreground colour") + } + + Button { + objectName: "productionPreviewCustomBackground" + text: qsTr("Custom background") + enabled: root.host !== null + onClicked: customBackgroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's background colour") + } + } + } + } + + ColorDialog { + id: customForegroundDialog + title: qsTr("Custom mode foreground") + selectedColor: root.host ? root.host.displayColorCustomForeground : "black" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(selectedColor, root.host.displayColorCustomBackground) + } + + ColorDialog { + id: customBackgroundDialog + title: qsTr("Custom mode background") + selectedColor: root.host ? root.host.displayColorCustomBackground : "white" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(root.host.displayColorCustomForeground, selectedColor) + } + GroupBox { Layout.fillWidth: true Layout.fillHeight: true title: qsTr("Plates and separations") Accessible.name: qsTr("Plates and separations") - ListView { - id: platesView - objectName: "productionPreviewPlatesView" + ColumnLayout { anchors.fill: parent - clip: true - focus: visible - activeFocusOnTab: true - model: root.production - - Accessible.role: Accessible.List - Accessible.name: qsTr("Plates") - Accessible.description: qsTr("Inks and plates for the current page.") - - delegate: ItemDelegate { - id: plateRow - width: platesView.width - text: plateRow.displayName - Accessible.role: Accessible.ListItem - Accessible.name: plateRow.displayName - Accessible.description: qsTr("Kind %1. Spot colour %2. Overprint %3. Prints %4.") - .arg(plateRow.kind) - .arg(plateRow.spotColorName) - .arg(plateRow.overprint ? qsTr("yes") : qsTr("no")) - .arg(plateRow.shouldPrint ? qsTr("yes") : qsTr("no")) + spacing: 4 + + Label { + objectName: "productionPreviewPlateIsolation" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.host ? root.host.plateIsolationSummary : "" + Accessible.name: qsTr("Plate isolation state") + Accessible.description: text + } + + ListView { + id: platesView + objectName: "productionPreviewPlatesView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.host ? root.host.productionPlates : [] + + Accessible.role: Accessible.List + Accessible.name: qsTr("Plates") + Accessible.description: qsTr("Inks and plates for the current page. Each plate can be shown alone or hidden.") + + delegate: ItemDelegate { + id: plateRow + width: platesView.width + text: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.role: Accessible.ListItem + Accessible.name: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.description: qsTr("Kind %1. Spot colour %2. Shown %3. Isolated %4.") + .arg(plateRow.modelData ? plateRow.modelData.kind : "") + .arg(plateRow.modelData ? plateRow.modelData.spotColorName : "") + .arg(plateRow.modelData && plateRow.modelData.visible ? qsTr("yes") : qsTr("no")) + .arg(plateRow.modelData && plateRow.modelData.isolated ? qsTr("yes") : qsTr("no")) + + CheckBox { + objectName: "productionPreviewPlateVisibleCheck" + anchors.verticalCenter: parent.verticalCenter + checked: plateRow.modelData ? plateRow.modelData.visible : false + onToggled: if (root.host && plateRow.modelData) root.host.setPlateVisible(plateRow.modelData.id, checked) + Accessible.name: qsTr("Show plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + + Button { + objectName: "productionPreviewPlateIsolate" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Isolate") + onClicked: if (root.host && plateRow.modelData) root.host.isolatePlate(plateRow.modelData.id) + Accessible.name: qsTr("Show only plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewShowAllPlates" + text: qsTr("Show all plates") + enabled: root.host !== null + onClicked: if (root.host) root.host.showAllPlates() + Accessible.name: qsTr("Show every plate") + } + + Button { + objectName: "productionPreviewInkCoverage" + text: qsTr("Measure ink at the page centre") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.requestInkCoverageAtPoint(root.host.currentPageCentreX, root.host.currentPageCentreY) + Accessible.name: qsTr("Measure ink coverage at the page centre") + Accessible.description: qsTr("Renders the current page through Core's separation-accurate path and reports the ink coverage at one point.") + } + } + + Label { + objectName: "productionPreviewInkCoverageReadout" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: { + if (!root.host || !root.host.inkCoverageReadout || !root.host.inkCoverageReadout.available) + return root.host && root.host.inkCoverageReadout && root.host.inkCoverageReadout.reason + ? qsTr("Ink coverage is not measured: %1.").arg(root.host.inkCoverageReadout.reason) + : qsTr("Ink coverage has not been measured at a point.") + return qsTr("Ink coverage at (%1, %2) pt: %3 total across %4 plate(s).") + .arg(Number(root.host.inkCoverageReadout.pageX).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.pageY).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.totalInkCoverage).toFixed(3)) + .arg(root.host.inkCoverageReadout.channelCount) + } + Accessible.name: qsTr("Ink coverage readout") + Accessible.description: text } } } diff --git a/LoopEditor/qml/SettingsDialog.qml b/LoopEditor/qml/SettingsDialog.qml new file mode 100644 index 000000000..895a0c2ac --- /dev/null +++ b/LoopEditor/qml/SettingsDialog.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Shell settings (#239): the one settings surface. It renders the typed option +// rows of EditorHost's settings store and forwards every toggle and the +// confirmed factory reset through the store's typed invokables. It derives no +// value of its own and keeps no settings copy. +Dialog { + id: root + objectName: "settingsDialog" + + property var host: null + + readonly property var settings: host ? host.settings : null + readonly property var optionRows: settings ? settings.options : [] + + title: qsTr("Shell settings") + modal: true + width: Math.min(560, parent ? parent.width - 32 : 560) + height: Math.min(640, parent ? parent.height - 32 : 640) + anchors.centerIn: parent + closePolicy: Popup.CloseOnEscape + onClosed: { + if (host) { + host.cancelSettingsReset() + if (host.focusRestoration) + host.focusRestoration.restore() + } + } + + contentItem: ColumnLayout { + Accessible.role: Accessible.Grouping + Accessible.name: root.title + spacing: 8 + + Label { + objectName: "settingsNotice" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: text.length > 0 + text: root.host ? root.host.settingsNotice : "" + Accessible.name: qsTr("Settings notice") + Accessible.description: text + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 4 + + Repeater { + model: root.optionRows + + delegate: CheckBox { + id: optionBox + required property var modelData + objectName: "settingsOption_" + modelData.id + Layout.fillWidth: true + text: modelData.label + checked: modelData.enabled + onClicked: { + if (!root.settings || root.settings.setOptionEnabled(modelData.id, checked)) + return + // Refused: re-assert the store's value so the row cannot + // show a change that did not persist. + checked = Qt.binding(function() { return optionBox.modelData.enabled }) + } + Accessible.name: modelData.label + Accessible.description: modelData.description + } + } + } + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Render options change the interactive preview only; they never change the document.") + Accessible.name: text + } + + Button { + objectName: "settingsResetButton" + Layout.fillWidth: true + visible: root.host && !root.host.settingsResetPending + text: qsTr("Reset to factory settings…") + Accessible.name: text + onClicked: { + if (root.host) + root.host.requestSettingsReset() + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: root.host && root.host.settingsResetPending + + Label { + objectName: "settingsResetConfirmation" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: qsTr("Reset every shell setting to its factory default? This cannot be undone.") + Accessible.name: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "settingsResetConfirmButton" + Layout.fillWidth: true + text: qsTr("Reset now") + Accessible.name: text + onClicked: { + if (root.host) + root.host.confirmSettingsReset() + } + } + + Button { + objectName: "settingsResetCancelButton" + Layout.fillWidth: true + text: qsTr("Cancel") + Accessible.name: text + onClicked: { + if (root.host) + root.host.cancelSettingsReset() + } + } + } + } + } +} diff --git a/LoopEditor/qml/ShellToolBar.qml b/LoopEditor/qml/ShellToolBar.qml index bfad8d5ae..b2668d348 100644 --- a/LoopEditor/qml/ShellToolBar.qml +++ b/LoopEditor/qml/ShellToolBar.qml @@ -10,6 +10,7 @@ ToolBar { Accessible.name: qsTr("Document toolbar") Accessible.description: qsTr("Commands for opening, editing, viewing, and moving through the document.") activeFocusOnTab: true + implicitHeight: toolbarCommands.implicitHeight + topPadding + bottomPadding property var host: editorHost property var window: null @@ -71,8 +72,9 @@ ToolBar { } } - RowLayout { - anchors.fill: parent + Flow { + id: toolbarCommands + width: parent.width spacing: 6 ToolButton { @@ -157,6 +159,38 @@ ToolBar { value: !!root.host && root.host.activeTool === "hand" } } + ToolButton { + id: textToolButton + objectName: "textToolButton" + text: qsTr("Text") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("text") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Text selection tool") + ButtonGroup.group: toolGroup + Binding { + target: textToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "text" + } + } + ToolButton { + id: measureToolButton + objectName: "measureToolButton" + text: qsTr("Measure") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("measure") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Measure tool") + ButtonGroup.group: toolGroup + Binding { + target: measureToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "measure" + } + } ToolSeparator {} ToolButton { objectName: "zoomInButton" @@ -192,6 +226,5 @@ ToolBar { Accessible.name: qsTr("Open production preview workspace") } - Item { Layout.fillWidth: true } } } diff --git a/LoopEditor/qml/Workspace.qml b/LoopEditor/qml/Workspace.qml index c3b01eeca..a3bbb3812 100644 --- a/LoopEditor/qml/Workspace.qml +++ b/LoopEditor/qml/Workspace.qml @@ -126,6 +126,15 @@ Item { Layout.fillWidth: true objectName: "workspaceButton_" + modelData.workspace text: modelData.label + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } checkable: true enabled: host ? host.isWorkspaceEnabled(modelData.workspace) : false activeFocusOnTab: true @@ -143,6 +152,15 @@ Item { objectName: "workspaceHelpButton" Layout.fillWidth: true text: qsTr("Help (F1)") + contentItem: Text { + text: parent.text + font: parent.font + color: parent.palette.buttonText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Accessible.ignored: true + } Accessible.name: qsTr("Help for this workspace") onClicked: root.helpRequested("", "") } diff --git a/LoopEditor/quickdocumentmodel.cpp b/LoopEditor/quickdocumentmodel.cpp index a8c68216b..2fe94bb0e 100644 --- a/LoopEditor/quickdocumentmodel.cpp +++ b/LoopEditor/quickdocumentmodel.cpp @@ -7,6 +7,7 @@ #include "pdfdocumentcontext.h" #include "pdfdocumentsearch.h" #include "pdfdocumentsession.h" +#include "pdfexception.h" #include "pdfform.h" #include "pdfoutline.h" #include "pdfpage.h" @@ -236,11 +237,238 @@ void QuickSearchResultModel::clear() replace({}, {}, {}); } +namespace +{ + +/// Stable, operator-facing kind name for an annotation type. A local switch +/// rather than Core's GUI caption: the caption is a display string with +/// historical quirks (it labels Link as Line) and is not a vocabulary. +QString annotationKindName(pdf::AnnotationType type) +{ + switch (type) + { + case pdf::AnnotationType::Text: + return QStringLiteral("text"); + case pdf::AnnotationType::Link: + return QStringLiteral("link"); + case pdf::AnnotationType::FreeText: + return QStringLiteral("free-text"); + case pdf::AnnotationType::Line: + return QStringLiteral("line"); + case pdf::AnnotationType::Square: + return QStringLiteral("square"); + case pdf::AnnotationType::Circle: + return QStringLiteral("circle"); + case pdf::AnnotationType::Polygon: + return QStringLiteral("polygon"); + case pdf::AnnotationType::Polyline: + return QStringLiteral("polyline"); + case pdf::AnnotationType::Highlight: + return QStringLiteral("highlight"); + case pdf::AnnotationType::Underline: + return QStringLiteral("underline"); + case pdf::AnnotationType::Squiggly: + return QStringLiteral("squiggly"); + case pdf::AnnotationType::StrikeOut: + return QStringLiteral("strike-out"); + case pdf::AnnotationType::Stamp: + return QStringLiteral("stamp"); + case pdf::AnnotationType::Caret: + return QStringLiteral("caret"); + case pdf::AnnotationType::Ink: + return QStringLiteral("ink"); + case pdf::AnnotationType::Popup: + return QStringLiteral("popup"); + case pdf::AnnotationType::FileAttachment: + return QStringLiteral("file-attachment"); + case pdf::AnnotationType::Sound: + return QStringLiteral("sound"); + case pdf::AnnotationType::Movie: + return QStringLiteral("movie"); + case pdf::AnnotationType::Widget: + return QStringLiteral("widget"); + case pdf::AnnotationType::Screen: + return QStringLiteral("screen"); + case pdf::AnnotationType::PrinterMark: + return QStringLiteral("printer-mark"); + case pdf::AnnotationType::TrapNet: + return QStringLiteral("trap-net"); + case pdf::AnnotationType::Watermark: + return QStringLiteral("watermark"); + case pdf::AnnotationType::Redact: + return QStringLiteral("redact"); + case pdf::AnnotationType::Projection: + return QStringLiteral("projection"); + case pdf::AnnotationType::_3D: + return QStringLiteral("3d"); + case pdf::AnnotationType::RichMedia: + return QStringLiteral("rich-media"); + case pdf::AnnotationType::Invalid: + case pdf::AnnotationType::Unknown: + break; + } + + return QStringLiteral("unknown"); +} + +/// Reads the document's annotations, page by page, into review rows. Read-only: +/// Core's own parse of each `/Annots` entry, no annotation manager, no mutation +/// surface. One unparseable annotation is skipped rather than failing the read. +QList readAnnotations(const pdf::PDFDocument* document) +{ + QList rows; + const pdf::PDFCatalog* catalog = document->getCatalog(); + const size_t pageCount = catalog->getPageCount(); + for (size_t pageIndex = 0; pageIndex < pageCount; ++pageIndex) + { + const pdf::PDFPage* page = catalog->getPage(pageIndex); + if (!page) + { + continue; + } + + for (const pdf::PDFObjectReference& reference : page->getAnnotations()) + { + try + { + const pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), reference); + if (!annotation || annotation->getType() == pdf::AnnotationType::Popup) + { + // A popup is the open state of a parent annotation, not a + // separate review row. + continue; + } + + QuickAnnotation row; + row.pageIndex = int(pageIndex); + row.kindName = annotationKindName(annotation->getType()); + row.bounds = annotation->getRectangle(); + row.contents = annotation->getContents(); + if (const pdf::PDFMarkupAnnotation* markup = annotation->asMarkupAnnotation()) + { + row.title = markup->getWindowTitle(); + row.subject = markup->getSubject(); + row.created = markup->getCreationDate().toString(Qt::ISODate); + } + row.reference = QStringLiteral("%1 %2 R").arg(reference.objectNumber).arg(reference.generation); + const pdf::PDFAnnotation::Flags flags = annotation->getEffectiveFlags(); + row.hidden = flags.testFlag(pdf::PDFAnnotation::Hidden) || + flags.testFlag(pdf::PDFAnnotation::Invisible) || + flags.testFlag(pdf::PDFAnnotation::NoView); + rows.append(row); + } + catch (const pdf::PDFException&) + { + continue; + } + } + } + return rows; +} + +} // namespace + +QuickAnnotationModel::QuickAnnotationModel(QObject* parent) : + QAbstractListModel(parent) +{ +} + +int QuickAnnotationModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : int(m_annotations.size()); +} + +QVariant QuickAnnotationModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_annotations.size()) + return {}; + const QuickAnnotation& annotation = m_annotations.at(index.row()); + switch (role) + { + case Qt::DisplayRole: + case KindRole: + return annotation.kindName; + case PageIndexRole: + return annotation.pageIndex; + case PageNumberRole: + return annotation.pageIndex + 1; + case BoundsRole: + return annotation.bounds; + case TitleRole: + return annotation.title; + case ContentsRole: + return annotation.contents; + case SubjectRole: + return annotation.subject; + case CreatedRole: + return annotation.created; + case ReferenceRole: + return annotation.reference; + case HiddenRole: + return annotation.hidden; + case SelectedRole: + return index.row() == m_selectedRow; + } + return {}; +} + +QHash QuickAnnotationModel::roleNames() const +{ + return { { PageIndexRole, "pageIndex" }, + { PageNumberRole, "pageNumber" }, + { KindRole, "kind" }, + { BoundsRole, "bounds" }, + { TitleRole, "title" }, + { ContentsRole, "contents" }, + { SubjectRole, "subject" }, + { CreatedRole, "created" }, + { ReferenceRole, "reference" }, + { HiddenRole, "hidden" }, + { SelectedRole, "selected" } }; +} + +void QuickAnnotationModel::replace(QList annotations, QString revision) +{ + beginResetModel(); + m_annotations = std::move(annotations); + m_revision = std::move(revision); + m_selectedRow = -1; + endResetModel(); +} + +void QuickAnnotationModel::clear() +{ + replace({}, {}); +} + +void QuickAnnotationModel::setSelectedRow(int row) +{ + const int bounded = row >= 0 && row < m_annotations.size() ? row : -1; + if (bounded == m_selectedRow) + { + return; + } + const int previous = m_selectedRow; + m_selectedRow = bounded; + const auto notifyRow = [this](int changed) + { + if (changed < 0) + { + return; + } + const QModelIndex index = this->index(changed, 0); + Q_EMIT dataChanged(index, index, { SelectedRole }); + }; + notifyRow(previous); + notifyRow(bounded); +} + QuickDocumentModel::QuickDocumentModel(QObject* parent) : QObject(parent), m_pages(this), m_outline(this), - m_searchResults(this) + m_searchResults(this), + m_annotations(this) { } @@ -258,6 +486,7 @@ void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) m_pages.replace(document); m_outline.replace(document); + m_annotations.replace(readAnnotations(document), context->getRevision().toString()); const pdf::PDFDocumentInfo* info = document->getInfo(); const pdf::PDFCatalog* catalog = document->getCatalog(); m_title = info->title; @@ -319,6 +548,7 @@ void QuickDocumentModel::clear() m_pages.clear(); m_outline.clear(); m_searchResults.clear(); + m_annotations.clear(); m_context = nullptr; m_session = nullptr; m_title.clear(); diff --git a/LoopEditor/quickdocumentmodel.h b/LoopEditor/quickdocumentmodel.h index 6a39028d8..b8e96e824 100644 --- a/LoopEditor/quickdocumentmodel.h +++ b/LoopEditor/quickdocumentmodel.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -133,12 +134,72 @@ class QuickSearchResultModel final : public QAbstractListModel QString m_revision; }; +/// One read-only annotation row (issue #125). Geometry is page space; the row +/// carries no editing intent and no mutation surface. +struct QuickAnnotation +{ + int pageIndex = -1; + QString kindName; + QRectF bounds; + QString title; + QString contents; + QString subject; + QString created; + QString reference; + bool hidden = false; +}; + +/// Read-only annotation review list. Rows are captured per document revision by +/// the host's read pass; QML renders them and asks the host to navigate. +class QuickAnnotationModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + PageIndexRole = Qt::UserRole + 1, + PageNumberRole, + KindRole, + BoundsRole, + TitleRole, + ContentsRole, + SubjectRole, + CreatedRole, + ReferenceRole, + HiddenRole, + SelectedRole, + }; + + explicit QuickAnnotationModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + /// Replaces the rows for a captured revision. The revision is retained so a + /// caller can say which document the list describes. + void replace(QList annotations, QString revision); + void clear(); + QString revision() const noexcept { return m_revision; } + int selectedRow() const noexcept { return m_selectedRow; } + void setSelectedRow(int row); + const QList& rows() const noexcept { return m_annotations; } + +private: + QList m_annotations; + QString m_revision; + int m_selectedRow = -1; +}; + class QuickDocumentModel final : public QObject { Q_OBJECT Q_PROPERTY(QAbstractItemModel* pages READ pages CONSTANT) Q_PROPERTY(QAbstractItemModel* outline READ outline CONSTANT) Q_PROPERTY(QAbstractItemModel* searchResults READ searchResults CONSTANT) + Q_PROPERTY(QAbstractItemModel* annotations READ annotations CONSTANT) + Q_PROPERTY(int annotationCount READ annotationCount NOTIFY changed) Q_PROPERTY(QString title READ title NOTIFY changed) Q_PROPERTY(QString author READ author NOTIFY changed) Q_PROPERTY(QString subject READ subject NOTIFY changed) @@ -175,6 +236,11 @@ class QuickDocumentModel final : public QObject QAbstractItemModel* pages() noexcept { return &m_pages; } QAbstractItemModel* outline() noexcept { return &m_outline; } QAbstractItemModel* searchResults() noexcept { return &m_searchResults; } + QAbstractItemModel* annotations() noexcept { return &m_annotations; } + int annotationCount() const noexcept { return m_annotations.rowCount(); } + + /// C++ access to the annotation rows for the host's overlay and navigation. + QuickAnnotationModel& annotationModel() noexcept { return m_annotations; } QString title() const { return m_title; } QString author() const { return m_author; } @@ -228,6 +294,7 @@ class QuickDocumentModel final : public QObject QuickPageModel m_pages; QuickOutlineModel m_outline; QuickSearchResultModel m_searchResults; + QuickAnnotationModel m_annotations; pdf::PDFDocumentContext* m_context = nullptr; pdf::PDFDocumentSession* m_session = nullptr; QString m_title; diff --git a/LoopEditor/quickoutput.cpp b/LoopEditor/quickoutput.cpp new file mode 100644 index 000000000..b3526aa46 --- /dev/null +++ b/LoopEditor/quickoutput.cpp @@ -0,0 +1,225 @@ +// MIT License +#include "quickoutput.h" +#include "pdfartifactidentity.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsession.h" +#include "pdfoptionalcontent.h" +#include "pdfpage.h" +#include "pdftransparencyrenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace loopeditor +{ +namespace +{ +class OutputRenderer final : public pdf::PDFTransparencyRenderer +{ +public: + using pdf::PDFPageContentProcessor::getRenderErrors; + using pdf::PDFTransparencyRenderer::PDFTransparencyRenderer; +}; +} + +void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job) +{ + if (!request.document || request.revision.isEmpty() || request.dpi < 72 || request.dpi > 600) + { + result.error = QStringLiteral("output/invalid-request"); + return; + } + result.staging = std::make_shared(); + result.stagedPath = result.staging->filePath(QStringLiteral("output")); + if (!result.staging->isValid()) + { + result.error = QStringLiteral("output/staging-unavailable"); + return; + } + const auto hash = [](const QByteArray& bytes) + { return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); }; + result.record = QJsonObject{ + { QStringLiteral("revision"), request.revision }, + { QStringLiteral("printProof"), false }, + { QStringLiteral("fidelityAcknowledged"), request.fidelityAcknowledged } + }; + if (job.isCancellationRequested()) + return; + + if (request.format == OutputFormat::PublishedPdf) + { + QByteArray bytes = request.publishedBytes; + if (bytes.isEmpty()) + { + QFile source(request.sourcePath); + if (!source.open(QIODevice::ReadOnly)) + { + result.error = QStringLiteral("output/source-unavailable"); + return; + } + bytes = source.readAll(); + if (source.error() != QFile::NoError || hash(bytes) != QString::fromLatin1(request.document->getSourceDataHash().toHex())) + { + result.error = QStringLiteral("output/source-changed"); + return; + } + } + else if (request.publicationReceipt.value(QStringLiteral("status")).toString() != QLatin1String("published") || + request.publicationReceipt.value(QStringLiteral("published_sha256")).toString() != hash(bytes)) + { + result.error = QStringLiteral("output/publication-identity-mismatch"); + return; + } + QFile output(result.stagedPath); + if (!output.open(QIODevice::WriteOnly) || output.write(bytes) != bytes.size() || !output.flush()) + { + result.error = QStringLiteral("output/staging-write-failed"); + return; + } + result.record.insert(QStringLiteral("publicationReceipt"), request.publicationReceipt); + result.record.insert(QStringLiteral("renderer"), QStringLiteral("none: exact PDF byte copy")); + } + else + { + const auto* catalog = request.document->getCatalog(); + const bool printing = request.format == OutputFormat::PrintPdf; + const int first = printing ? 0 : request.page; + const int last = printing ? int(catalog->getPageCount()) - 1 : request.page; + if (first < 0 || last >= int(catalog->getPageCount()) || first > last) + { + result.error = QStringLiteral("output/invalid-page"); + return; + } + pdf::PDFDocumentContext context(request.document); + auto* session = context.getSession(); + pdf::PDFOptionalContentActivity optionalContent(request.document.data(), printing ? pdf::OCUsage::Print : pdf::OCUsage::Export, nullptr); + pdf::PDFInkMapper inks(nullptr, request.document.data()); + inks.createSpotColors(true); + pdf::PDFTransparencyRendererSettings settings; + settings.renderPolicy = pdf::PDFRenderPolicy::forOutputPreview(); + settings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + settings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SmoothImageTransformation, true); + pdf::PDFRenderDiagnostics diagnostics; + std::unique_ptr writer; + QPainter painter; + if (printing) + { + writer = std::make_unique(result.stagedPath); + writer->setResolution(request.dpi); + writer->setTitle(QStringLiteral("Loop print-to-PDF")); + writer->setCreator(QStringLiteral("Loop / PDFTransparencyRenderer")); + } + for (int index = first; index <= last; ++index) + { + if (job.isCancellationRequested()) + return; + const auto* page = catalog->getPage(size_t(index)); + if (!page->getAnnotations().empty()) + { + diagnostics.record(pdf::PDFRenderFidelity::Unsupported, QStringLiteral("Annotation appearances are not included in this page-content output.")); + } + const QSizeF points = page->getRotatedMediaBox().size(); + if (!points.isValid() || !std::isfinite(points.width()) || !std::isfinite(points.height()) || + points.width() * request.dpi / 72.0 > 20000 || points.height() * request.dpi / 72.0 > 20000) + { + result.error = QStringLiteral("output/invalid-page-size"); + return; + } + const QSize pixels(qCeil(points.width() * request.dpi / 72.0), qCeil(points.height() * request.dpi / 72.0)); + job.processingBudget().chargeRenderPixels(uint64_t(pixels.width()) * uint64_t(pixels.height()), QStringLiteral("Quick output")); + const QTransform matrix = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, QRectF(QPointF(), QSizeF(pixels))); + OutputRenderer renderer(page, request.document.data(), session->getFontCache(), session->getCMS(), + &optionalContent, &inks, settings, matrix); + renderer.setOperationControl(job.operationControl()); + renderer.beginPaint(pixels); + renderer.processContents(); + renderer.endPaint(); + for (const auto& error : renderer.getRenderErrors()) + { + if (error.type == pdf::RenderErrorType::Error) + { + result.error = QStringLiteral("output/render-failed: %1").arg(error.message); + return; + } + if (error.type != pdf::RenderErrorType::Information) + diagnostics.record(pdf::PDFRenderFidelity::Unsupported, error.message); + } + diagnostics.merge(renderer.getRenderDiagnostics()); + if (!diagnostics.isExact() && !request.fidelityAcknowledged) + { + result.error = QStringLiteral("output/fidelity-acknowledgment-required"); + return; + } + QImage image = renderer.toImage(false, true, pdf::PDFRGB{ 1.0f, 1.0f, 1.0f }); + if (image.isNull()) + { + result.error = QStringLiteral("output/render-failed"); + return; + } + image.setDotsPerMeterX(qRound(request.dpi / 0.0254)); + image.setDotsPerMeterY(qRound(request.dpi / 0.0254)); + if (printing) + { + writer->setPageSize(QPageSize(points, QPageSize::Point)); + writer->setPageMargins(QMarginsF(), QPageLayout::Point); + if ((index == first && !painter.begin(writer.get())) || (index != first && !writer->newPage())) + { + result.error = QStringLiteral("output/pdf-write-failed"); + return; + } + painter.drawImage(QRect(0, 0, writer->width(), writer->height()), image); + } + else + { + QImageWriter imageWriter(result.stagedPath, request.format == OutputFormat::Png ? QByteArray("png") : QByteArray("tiff")); + imageWriter.setText(QStringLiteral("Renderer"), QStringLiteral("Loop Core PDFTransparencyRenderer")); + imageWriter.setText(QStringLiteral("Color handling"), QStringLiteral("Core CMS; RGB composite; spot separation simulation; white paper")); + if (!imageWriter.write(image)) + { + result.error = QStringLiteral("output/image-encoder-unavailable-or-failed"); + return; + } + } + job.reportProgress((index - first + 1) * 90 / (last - first + 1)); + } + if (printing && !painter.end()) + { + result.error = QStringLiteral("output/pdf-write-failed"); + return; + } + writer.reset(); + result.record.insert(QStringLiteral("renderer"), QStringLiteral("Loop Core PDFTransparencyRenderer")); + result.record.insert(QStringLiteral("dpi"), request.dpi); + result.record.insert(QStringLiteral("firstPage"), first + 1); + result.record.insert(QStringLiteral("lastPage"), last + 1); + result.record.insert(QStringLiteral("colorHandling"), QStringLiteral("Core CMS; RGB composite; spot separation simulation; white paper")); + result.record.insert(QStringLiteral("fidelity"), diagnostics.isExact() ? QStringLiteral("exact-supported") : QStringLiteral("limited")); + result.record.insert(QStringLiteral("limitations"), QJsonArray::fromStringList(diagnostics.reasons)); + } + QFile file(result.stagedPath); + if (!file.open(QIODevice::ReadOnly)) + { + result.error = QStringLiteral("output/staging-read-failed"); + return; + } + QCryptographicHash digest(QCryptographicHash::Sha256); + if (!digest.addData(&file)) + { + result.error = QStringLiteral("output/staging-read-failed"); + return; + } + pdf::PDFArtifactIdentity artifact; + artifact.sha256 = QString::fromLatin1(digest.result().toHex()); + artifact.size = file.size(); + artifact.mediaType = request.format == OutputFormat::Png ? QStringLiteral("image/png") : request.format == OutputFormat::Tiff ? QStringLiteral("image/tiff") + : QStringLiteral("application/pdf"); + result.record.insert(QStringLiteral("artifact"), artifact.toJson()); + job.setOutputArtifact(artifact); +} +} diff --git a/LoopEditor/quickoutput.h b/LoopEditor/quickoutput.h new file mode 100644 index 000000000..225baad1d --- /dev/null +++ b/LoopEditor/quickoutput.h @@ -0,0 +1,44 @@ +// MIT License +#ifndef QUICKOUTPUT_H +#define QUICKOUTPUT_H + +#include "pdfdocument.h" +#include "pdfjobscheduler.h" +#include +#include +#include + +namespace loopeditor +{ +enum class OutputFormat +{ + PublishedPdf, + PrintPdf, + Png, + Tiff +}; + +struct OutputRequest +{ + OutputFormat format = OutputFormat::PublishedPdf; + pdf::PDFDocumentPointer document; + QString sourcePath; + QByteArray publishedBytes; + QJsonObject publicationReceipt; + QString revision; + int page = 0; + int dpi = 300; + bool fidelityAcknowledged = false; +}; + +struct OutputResult +{ + std::shared_ptr staging; + QString stagedPath; + QString error; + QJsonObject record; +}; + +void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job); +} +#endif diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index 6a5d44998..bd66d6e0b 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -28,6 +28,8 @@ add_library(LoopLibCore SHARED sources/pdfalgorithmlcs.h sources/pdfannotation.cpp sources/pdfannotation.h + sources/pdfannotationauthoring.cpp + sources/pdfannotationauthoring.h sources/pdfblendfunction.cpp sources/pdfblendfunction.h sources/pdfccittfaxdecoder.cpp @@ -136,6 +138,8 @@ add_library(LoopLibCore SHARED sources/pdfdocumenttextfloweditormodel.h sources/pdfdocumentwriter.cpp sources/pdfdocumentwriter.h + sources/pdfdirectedit.cpp + sources/pdfdirectedit.h sources/pdfwriteobjectvisitor.cpp sources/pdfwriteobjectvisitor_p.h sources/pdfexecutionpolicy.cpp @@ -166,6 +170,8 @@ add_library(LoopLibCore SHARED sources/pdfoptimizer.h sources/pdfoptionalcontent.cpp sources/pdfoptionalcontent.h + sources/pdfauthoringedit.cpp + sources/pdfauthoringedit.h sources/pdfoutline.cpp sources/pdfoutline.h sources/pdfpagenavigation.cpp @@ -286,6 +292,8 @@ add_library(LoopLibCore SHARED sources/pdfartifactidentity.cpp sources/pdfartifactstore.h sources/pdfartifactstore.cpp + sources/pdfauthoringhistory.h + sources/pdfauthoringhistory.cpp sources/pdfoperationhistory.h sources/pdfoperationhistory.cpp sources/pdfoperationhistorystore.h diff --git a/LoopLibCore/sources/pdfannotationauthoring.cpp b/LoopLibCore/sources/pdfannotationauthoring.cpp new file mode 100644 index 000000000..183ecaf8b --- /dev/null +++ b/LoopLibCore/sources/pdfannotationauthoring.cpp @@ -0,0 +1,398 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfannotationauthoring.h" + +#include "pdfdocumentbuilder.h" + +#include + +namespace pdf +{ + +namespace +{ + +/// A rectangle is usable only when it is valid and encloses a positive area. +/// An inverted drag is the caller's to normalize: silently repairing geometry +/// here would create an annotation the operator never drew. +bool isUsableRectangle(const QRectF& rectangle) +{ + return rectangle.isValid() && rectangle.width() > 0.0 && rectangle.height() > 0.0; +} + +QColor defaultColorForSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + return QColor(Qt::yellow); + + default: + return QColor(Qt::red); + } +} + +} // namespace + +const char* getAnnotationAuthoringSubtypeName(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + return "text-highlight"; + case AnnotationAuthoringSubtype::TextUnderline: + return "text-underline"; + case AnnotationAuthoringSubtype::TextStrikeout: + return "text-strikeout"; + case AnnotationAuthoringSubtype::TextSquiggly: + return "text-squiggly"; + case AnnotationAuthoringSubtype::Rectangle: + return "rectangle"; + case AnnotationAuthoringSubtype::Ellipse: + return "ellipse"; + case AnnotationAuthoringSubtype::StraightLine: + return "straight-line"; + case AnnotationAuthoringSubtype::Polyline: + return "polyline"; + case AnnotationAuthoringSubtype::Polygon: + return "polygon"; + case AnnotationAuthoringSubtype::FreehandCurve: + return "freehand-curve"; + case AnnotationAuthoringSubtype::InlineText: + return "inline-text"; + case AnnotationAuthoringSubtype::StickyNoteComment: + return "sticky-note-comment"; + case AnnotationAuthoringSubtype::StickyNoteHelp: + return "sticky-note-help"; + case AnnotationAuthoringSubtype::StickyNoteInsert: + return "sticky-note-insert"; + case AnnotationAuthoringSubtype::StickyNoteKey: + return "sticky-note-key"; + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + return "sticky-note-new-paragraph"; + case AnnotationAuthoringSubtype::StickyNoteNote: + return "sticky-note-note"; + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return "sticky-note-paragraph"; + } + + return "invalid"; +} + +bool parseAnnotationAuthoringSubtype(const QString& name, AnnotationAuthoringSubtype* subtype) +{ + for (const AnnotationAuthoringSubtype candidate : + { AnnotationAuthoringSubtype::TextHighlight, AnnotationAuthoringSubtype::TextUnderline, + AnnotationAuthoringSubtype::TextStrikeout, AnnotationAuthoringSubtype::TextSquiggly, + AnnotationAuthoringSubtype::Rectangle, AnnotationAuthoringSubtype::Ellipse, + AnnotationAuthoringSubtype::StraightLine, AnnotationAuthoringSubtype::Polyline, + AnnotationAuthoringSubtype::Polygon, AnnotationAuthoringSubtype::FreehandCurve, + AnnotationAuthoringSubtype::InlineText, AnnotationAuthoringSubtype::StickyNoteComment, + AnnotationAuthoringSubtype::StickyNoteHelp, AnnotationAuthoringSubtype::StickyNoteInsert, + AnnotationAuthoringSubtype::StickyNoteKey, AnnotationAuthoringSubtype::StickyNoteNewParagraph, + AnnotationAuthoringSubtype::StickyNoteNote, AnnotationAuthoringSubtype::StickyNoteParagraph }) + { + if (name == QLatin1String(getAnnotationAuthoringSubtypeName(candidate))) + { + if (subtype) + { + *subtype = candidate; + } + return true; + } + } + + return false; +} + +namespace +{ + +/// Sticky-note subtypes share one builder primitive; the icon name is the only +/// difference, so the mapping lives here rather than at each call site. +bool isStickyNoteSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return true; + + default: + return false; + } +} + +TextAnnotationIcon stickyNoteIcon(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::StickyNoteComment: + return TextAnnotationIcon::Comment; + case AnnotationAuthoringSubtype::StickyNoteHelp: + return TextAnnotationIcon::Help; + case AnnotationAuthoringSubtype::StickyNoteInsert: + return TextAnnotationIcon::Insert; + case AnnotationAuthoringSubtype::StickyNoteKey: + return TextAnnotationIcon::Key; + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + return TextAnnotationIcon::NewParagraph; + case AnnotationAuthoringSubtype::StickyNoteNote: + return TextAnnotationIcon::Note; + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return TextAnnotationIcon::Paragraph; + + default: + return TextAnnotationIcon::Note; + } +} + +} // namespace + +PDFDocumentPointer createAuthoredAnnotation(const PDFDocument* document, + const AnnotationAuthoringSpec& spec, + AnnotationAuthoringResult* result) +{ + Q_ASSERT(result); + *result = {}; + + if (!document) + { + result->typedError = QStringLiteral("authoring/document-required"); + return nullptr; + } + + const auto verticesAreDistinct = [&spec](qsizetype minimumCount) + { + if (qsizetype(spec.vertices.size()) < minimumCount) + { + return false; + } + for (const QPointF& point : spec.vertices) + { + if (!std::isfinite(point.x()) || !std::isfinite(point.y())) + { + return false; + } + } + return true; + }; + + // Geometry is validated per subtype before the document is copied: a + // refusal must cost nothing and must never leave a half-written annotation. + switch (spec.subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + case AnnotationAuthoringSubtype::TextUnderline: + case AnnotationAuthoringSubtype::TextStrikeout: + case AnnotationAuthoringSubtype::TextSquiggly: + case AnnotationAuthoringSubtype::Rectangle: + case AnnotationAuthoringSubtype::Ellipse: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::StraightLine: + if (spec.vertices.size() != 2 || !verticesAreDistinct(2) || + spec.vertices[0] == spec.vertices[1]) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::Polyline: + case AnnotationAuthoringSubtype::FreehandCurve: + if (!verticesAreDistinct(2)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::Polygon: + if (!verticesAreDistinct(3)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::InlineText: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + if (spec.contents.trimmed().isEmpty()) + { + result->typedError = QStringLiteral("authoring/missing-contents"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + if (spec.contents.trimmed().isEmpty()) + { + result->typedError = QStringLiteral("authoring/missing-contents"); + return nullptr; + } + break; + } + + // Page references come from the catalog's walked page tree rather than the + // builder's direct Kids array, so a nested page tree addresses pages by the + // same index the rest of Core uses. + const PDFCatalog* catalog = document->getCatalog(); + if (!catalog || spec.pageIndex < 0 || std::size_t(spec.pageIndex) >= catalog->getPageCount()) + { + result->typedError = QStringLiteral("authoring/invalid-page"); + return nullptr; + } + const PDFPage* page = catalog->getPage(std::size_t(spec.pageIndex)); + if (!page) + { + result->typedError = QStringLiteral("authoring/invalid-page"); + return nullptr; + } + + PDFDocumentBuilder builder(document); + const PDFObjectReference pageReference = page->getPageReference(); + const QColor color = spec.color.isValid() ? spec.color : defaultColorForSubtype(spec.subtype); + const QString title = spec.title; + const QString subject = spec.subject; + const QString contents = spec.contents; + const PDFReal borderWidth = spec.borderWidth > 0.0 ? spec.borderWidth : 1.0; + + PDFObjectReference annotation; + switch (spec.subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + annotation = builder.createAnnotationHighlight(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextUnderline: + annotation = builder.createAnnotationUnderline(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextStrikeout: + annotation = builder.createAnnotationStrikeout(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextSquiggly: + annotation = builder.createAnnotationSquiggly(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::Rectangle: + annotation = builder.createAnnotationSquare(pageReference, spec.rectangle, borderWidth, QColor(), color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::Ellipse: + annotation = builder.createAnnotationCircle(pageReference, spec.rectangle, borderWidth, QColor(), color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::StraightLine: + { + const QRectF bounding = QRectF(spec.vertices[0], spec.vertices[1]).normalized(); + annotation = builder.createAnnotationLine(pageReference, bounding, spec.vertices[0], spec.vertices[1], + borderWidth, QColor(), color, title, subject, contents, + AnnotationLineEnding::None, AnnotationLineEnding::None); + break; + } + case AnnotationAuthoringSubtype::Polyline: + { + QPolygonF polyline; + for (const QPointF& point : spec.vertices) + { + polyline.append(point); + } + annotation = builder.createAnnotationPolyline(pageReference, polyline, borderWidth, QColor(), color, + title, subject, contents, + AnnotationLineEnding::None, AnnotationLineEnding::None); + break; + } + case AnnotationAuthoringSubtype::Polygon: + { + QPolygonF polygon; + for (const QPointF& point : spec.vertices) + { + polygon.append(point); + } + annotation = builder.createAnnotationPolygon(pageReference, polygon, borderWidth, QColor(), color, + title, subject, contents); + break; + } + case AnnotationAuthoringSubtype::FreehandCurve: + { + QPolygonF ink; + for (const QPointF& point : spec.vertices) + { + ink.append(point); + } + annotation = builder.createAnnotationInk(pageReference, ink, borderWidth, color, title, subject, contents); + break; + } + case AnnotationAuthoringSubtype::InlineText: + annotation = builder.createAnnotationFreeText(pageReference, spec.rectangle, title, subject, contents, + Qt::AlignLeft | Qt::AlignTop); + break; + + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + Q_ASSERT(isStickyNoteSubtype(spec.subtype)); + annotation = builder.createAnnotationText(pageReference, spec.rectangle, stickyNoteIcon(spec.subtype), + title, subject, contents, false); + break; + } + + if (!annotation.isValid()) + { + result->typedError = QStringLiteral("authoring/creation-failed"); + return nullptr; + } + + result->created = true; + result->annotation = annotation; + result->page = pageReference; + return PDFDocumentPointer(new PDFDocument(builder.build())); +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfannotationauthoring.h b/LoopLibCore/sources/pdfannotationauthoring.h new file mode 100644 index 000000000..636895353 --- /dev/null +++ b/LoopLibCore/sources/pdfannotationauthoring.h @@ -0,0 +1,130 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFANNOTATIONAUTHORING_H +#define PDFANNOTATIONAUTHORING_H + +#include "pdfdocument.h" +#include "pdfannotation.h" + +#include +#include +#include +#include + +#include + +namespace pdf +{ + +/// The closed set of annotation subtypes the Quick authoring commands create +/// (issue #234). Every value maps onto an existing pdf::AnnotationType through +/// PDFDocumentBuilder's createAnnotation* primitives; no parallel annotation +/// model is introduced. The vocabulary is closed on purpose: an id outside it +/// is refused at the boundary rather than coerced into a default. +enum class AnnotationAuthoringSubtype +{ + TextHighlight, + TextUnderline, + TextStrikeout, + TextSquiggly, + Rectangle, + Ellipse, + StraightLine, + Polyline, + Polygon, + FreehandCurve, + InlineText, + StickyNoteComment, + StickyNoteHelp, + StickyNoteInsert, + StickyNoteKey, + StickyNoteNewParagraph, + StickyNoteNote, + StickyNoteParagraph +}; + +/// Stable name of a subtype, as used by summaries and tests. Never localized. +LOOPLIBCORESHARED_EXPORT const char* getAnnotationAuthoringSubtypeName(AnnotationAuthoringSubtype subtype); + +/// Parses a subtype name. Returns false rather than guessing, so an unknown +/// name is a typed refusal and not a silently defaulted subtype. +LOOPLIBCORESHARED_EXPORT bool parseAnnotationAuthoringSubtype(const QString& name, + AnnotationAuthoringSubtype* subtype); + +/// One typed authoring edit for a single annotation. Geometry is in page space, +/// the coordinate system PDFAnnotation::getRectangle() reads back in. +struct LOOPLIBCORESHARED_EXPORT AnnotationAuthoringSpec +{ + AnnotationAuthoringSubtype subtype = AnnotationAuthoringSubtype::TextHighlight; + PDFInteger pageIndex = 0; + + /// Bounding area for markup, rectangle, ellipse, inline text and the sticky + /// note icon. A sticky note uses this as its icon position and size. + QRectF rectangle; + + /// Vertices for StraightLine (exactly two), Polyline/Polygon (at least + /// three for a polygon, two for a polyline) and FreehandCurve (at least two). + std::vector vertices; + + /// Stroke (and, for closed shapes, interior) colour. An invalid colour + /// selects the subtype's documented default. + QColor color; + + /// Note or inline text. Required for InlineText and the sticky notes. + QString contents; + QString title; + QString subject; + PDFReal borderWidth = 1.0; +}; + +/// Icon rectangle edge length a sticky note is created with. Fixed at the size +/// PDFTextAnnotation draws its icon at, so the rectangle the operator asked for +/// and the rectangle the annotation reads back with agree. +inline constexpr PDFReal StickyNoteIconSize = 32.0; + +/// Outcome of one creation attempt. `created` is false exactly when +/// `typedError` names why nothing was written; there is no partial success. +struct LOOPLIBCORESHARED_EXPORT AnnotationAuthoringResult +{ + bool created = false; + QString typedError; + PDFObjectReference annotation; + PDFObjectReference page; +}; + +/// Creates one annotation of the spec's subtype on a copy of `document`, using +/// the PDFDocumentBuilder machinery that also produces the annotation's normal +/// appearance stream. The input document is never touched: on success the +/// returned pointer owns a complete new revision, and on refusal nullptr is +/// returned with `result->typedError` set. Validation is fail-closed: an +/// unsupported subtype, an out-of-range page or degenerate geometry is refused +/// before anything is built. +/// +/// `result` must not be null. +LOOPLIBCORESHARED_EXPORT PDFDocumentPointer createAuthoredAnnotation(const PDFDocument* document, + const AnnotationAuthoringSpec& spec, + AnnotationAuthoringResult* result); + +} // namespace pdf + +#endif // PDFANNOTATIONAUTHORING_H diff --git a/LoopLibCore/sources/pdfauthoringedit.cpp b/LoopLibCore/sources/pdfauthoringedit.cpp new file mode 100644 index 000000000..b63f561f7 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringedit.cpp @@ -0,0 +1,735 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfauthoringedit.h" + +#include "pdfannotation.h" +#include "pdfartifactidentity.h" +#include "pdfcatalog.h" +#include "pdfdocumentbuilder.h" +#include "pdfoutline.h" +#include "pdfpage.h" + +#include +#include +#include +#include +#include + +#include + +namespace pdf +{ + +namespace +{ + +constexpr int BookmarkFileSchemaVersion = 1; + +void refusedEdit(const QString& typedError, PDFAuthoringEditResult* result) +{ + result->kind = PDFAuthoringEditResult::Kind::Refused; + result->typedError = typedError; + result->document.clear(); +} + +/// The document's outline as interchange records; `nullopt` when an item has no +/// page target to carry. Defined beside `readRecord` below. +std::optional> outlineRecords(const PDFDocument* document, QString& typedError); + +QString pageTitle(int pageIndex) +{ + // 1-based, matching the shell's own page labels. + return QStringLiteral("Page %1").arg(pageIndex + 1); +} + +bool isPageIndexInRange(const PDFCatalog* catalog, int pageIndex) +{ + return pageIndex >= 0 && static_cast(pageIndex) < catalog->getPageCount(); +} + +PDFObjectReference pageReference(const PDFCatalog* catalog, int pageIndex) +{ + return catalog->getPage(static_cast(pageIndex))->getPageReference(); +} + +/// Resolves a destination's page. A destination may name the page by reference +/// or by index; a reference that resolves to no page is not a page target. +bool resolveDestinationPage(const PDFCatalog* catalog, const PDFDestination& destination, int* pageIndex) +{ + if (destination.getPageReference().isValid()) + { + const size_t resolved = catalog->getPageIndexFromPageReference(destination.getPageReference()); + if (resolved == PDFCatalog::INVALID_PAGE_INDEX) + { + return false; + } + + *pageIndex = static_cast(resolved); + return true; + } + + const PDFInteger index = destination.getPageIndex(); + if (!isPageIndexInRange(catalog, static_cast(index))) + { + return false; + } + + *pageIndex = static_cast(index); + return true; +} + +bool isUsableRectangle(const QRectF& rectangle) +{ + return rectangle.isValid() && !rectangle.isEmpty() && std::isfinite(rectangle.left()) && + std::isfinite(rectangle.top()) && std::isfinite(rectangle.right()) && std::isfinite(rectangle.bottom()); +} + +QSharedPointer bookmarkItem(const PDFCatalog* catalog, const QString& title, int pageIndex) +{ + QSharedPointer item(new PDFOutlineItem()); + item->setTitle(title); + item->setAction(PDFActionPtr(new PDFActionGoTo(PDFDestination::createFit(pageReference(catalog, pageIndex)), + PDFDestination()))); + return item; +} + +QSharedPointer cloneOutlineRoot(const PDFDocument* document) +{ + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + return root ? root->clone() : QSharedPointer(new PDFOutlineItem()); +} + +void collectTargetedPages(const PDFCatalog* catalog, const PDFOutlineItem* item, QSet& pages) +{ + int pageIndex = -1; + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &pageIndex)) + { + pages.insert(pageIndex); + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + collectTargetedPages(catalog, item->getChild(i), pages); + } +} + +/// Removes every item whose destination resolves to `pageIndex`, at any depth. +bool removePageBookmarks(const PDFCatalog* catalog, PDFOutlineItem* item, int pageIndex) +{ + bool removed = false; + for (size_t i = item->getChildCount(); i > 0; --i) + { + const QSharedPointer child = item->getChildPtr(i - 1); + int childPage = -1; + const PDFActionGoTo* goTo = dynamic_cast(child->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &childPage) && childPage == pageIndex) + { + item->removeChild(i - 1); + removed = true; + } + else + { + removed = removePageBookmarks(catalog, child.data(), pageIndex) || removed; + } + } + return removed; +} + +/// True when the builder's outline writer can write this item back unchanged. +/// It writes Title, a GoTo Dest, a GoToDp action, colour and flags; a +/// structure element or any other action would be silently dropped. +bool isRewritableItem(const PDFOutlineItem* item) +{ + if (item->getStructureElement().isValid()) + { + return false; + } + + const PDFAction* action = item->getAction(); + if (!action) + { + // A title-only item has no state to lose. + return true; + } + + return dynamic_cast(action) != nullptr || + dynamic_cast(action) != nullptr; +} + +bool canRewriteOutline(const PDFOutlineItem* item) +{ + if (!isRewritableItem(item)) + { + return false; + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + if (!canRewriteOutline(item->getChild(i))) + { + return false; + } + } + return true; +} + +void writeOutline(PDFDocumentModifier& modifier, const QSharedPointer& root) +{ + modifier.markReset(); + modifier.getBuilder()->setOutline(root.data()); +} + +PDFAuthoringEditResult finishOutlineEdit(PDFDocumentModifier& modifier, PDFAuthoringEditResult::Kind kind) +{ + PDFAuthoringEditResult result; + if (!modifier.finalize()) + { + // An edit that writes exactly the outline the document already has is + // reported, not counted as a new revision. + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = kind; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +PDFAuthoringEditResult applyToggleBookmark(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("bookmark/page-out-of-range"), &result); + return result; + } + + const QSharedPointer root = cloneOutlineRoot(document); + if (!canRewriteOutline(root.data())) + { + refusedEdit(QStringLiteral("bookmark/unsupported-outline-item"), &result); + return result; + } + + const bool removed = removePageBookmarks(catalog, root.data(), edit.pageIndex); + if (!removed) + { + root->addChild(bookmarkItem(catalog, + edit.text.isEmpty() ? pageTitle(edit.pageIndex) : edit.text, + edit.pageIndex)); + } + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + return finishOutlineEdit(modifier, removed ? PDFAuthoringEditResult::Kind::BookmarkRemoved + : PDFAuthoringEditResult::Kind::BookmarkAdded); +} + +PDFAuthoringEditResult applyGenerateBookmarks(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + Q_UNUSED(edit); + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (catalog->getPageCount() == 0) + { + refusedEdit(QStringLiteral("bookmark/no-pages"), &result); + return result; + } + + const QSharedPointer root = cloneOutlineRoot(document); + if (!canRewriteOutline(root.data())) + { + refusedEdit(QStringLiteral("bookmark/unsupported-outline-item"), &result); + return result; + } + + QSet targeted; + collectTargetedPages(catalog, root.data(), targeted); + + int generated = 0; + for (size_t page = 0; page < catalog->getPageCount(); ++page) + { + if (targeted.contains(static_cast(page))) + { + continue; + } + + root->addChild(bookmarkItem(catalog, pageTitle(static_cast(page)), static_cast(page))); + ++generated; + } + + if (generated == 0) + { + refusedEdit(QStringLiteral("bookmark/nothing-to-generate"), &result); + return result; + } + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + result = finishOutlineEdit(modifier, PDFAuthoringEditResult::Kind::BookmarksGenerated); + result.affectedCount = generated; + return result; +} + +bool validateRecords(const PDFCatalog* catalog, const QList& records, QString& typedError) +{ + for (const PDFBookmarkRecord& record : records) + { + if (record.title.isEmpty() || !isPageIndexInRange(catalog, record.pageIndex)) + { + typedError = QStringLiteral("bookmark/invalid-record"); + return false; + } + + if (!validateRecords(catalog, record.children, typedError)) + { + return false; + } + } + return true; +} + +int countRecords(const QList& records) +{ + int count = 0; + for (const PDFBookmarkRecord& record : records) + { + count += 1 + countRecords(record.children); + } + return count; +} + +void appendRecords(const PDFCatalog* catalog, PDFOutlineItem* parent, const QList& records) +{ + for (const PDFBookmarkRecord& record : records) + { + const QSharedPointer item = bookmarkItem(catalog, record.title, record.pageIndex); + appendRecords(catalog, item.data(), record.children); + parent->addChild(item); + } +} + +PDFAuthoringEditResult applyReplaceBookmarks(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + QString typedError; + if (!validateRecords(catalog, edit.bookmarks, typedError)) + { + refusedEdit(typedError, &result); + return result; + } + + // Replacing the outline with exactly the records it already carries would + // write a second identical revision; it is reported instead. An outline the + // interchange format cannot read back (structure elements, foreign + // actions) is not comparable, and replacing it is an explicit discard, so + // the edit proceeds. + QString outlineError; + const std::optional> current = outlineRecords(document, outlineError); + if (current.has_value() && *current == edit.bookmarks) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + QSharedPointer root(new PDFOutlineItem()); + appendRecords(catalog, root.data(), edit.bookmarks); + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + result = finishOutlineEdit(modifier, PDFAuthoringEditResult::Kind::BookmarksReplaced); + result.affectedCount = countRecords(edit.bookmarks); + return result; +} + +PDFAuthoringEditResult applyUriLink(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (edit.text.isEmpty()) + { + refusedEdit(QStringLiteral("hyperlink/uri-required"), &result); + return result; + } + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/page-out-of-range"), &result); + return result; + } + if (!isUsableRectangle(edit.rectangle)) + { + refusedEdit(QStringLiteral("hyperlink/link-rectangle-required"), &result); + return result; + } + + PDFDocumentModifier modifier(document); + modifier.getBuilder()->createAnnotationLink(pageReference(catalog, edit.pageIndex), + edit.rectangle.normalized(), + edit.text, + LinkHighlightMode::Invert); + modifier.markAnnotationsChanged(); + if (!modifier.finalize()) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = PDFAuthoringEditResult::Kind::LinkCreated; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +PDFAuthoringEditResult applyDestinationLink(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/page-out-of-range"), &result); + return result; + } + if (!isPageIndexInRange(catalog, edit.targetPageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/target-page-out-of-range"), &result); + return result; + } + if (!isUsableRectangle(edit.rectangle)) + { + refusedEdit(QStringLiteral("hyperlink/link-rectangle-required"), &result); + return result; + } + if (edit.destinationType == DestinationType::Invalid || edit.destinationType == DestinationType::Named) + { + refusedEdit(QStringLiteral("hyperlink/unsupported-destination"), &result); + return result; + } + if (edit.destinationType == DestinationType::FitR && !isUsableRectangle(edit.targetRectangle)) + { + refusedEdit(QStringLiteral("hyperlink/target-rectangle-required"), &result); + return result; + } + + // The host builds the page-space rectangle exactly as the surface named its + // corners: QRectF(QPointF(left, bottom), QPointF(right, top)). Page space is + // y-up, so the PDF top is the numerically larger y; that construction stores + // it in QRectF::bottom() (and the PDF bottom in QRectF::top()). + const QRectF target = edit.targetRectangle.normalized(); + PDFDestination destination; + destination.setDestinationType(edit.destinationType); + destination.setPageIndex(edit.targetPageIndex); + destination.setPageReference(pageReference(catalog, edit.targetPageIndex)); + switch (edit.destinationType) + { + case DestinationType::XYZ: + destination.setLeft(target.isValid() && !target.isEmpty() ? target.left() + : std::numeric_limits::quiet_NaN()); + destination.setTop(target.isValid() && !target.isEmpty() ? target.bottom() + : std::numeric_limits::quiet_NaN()); + destination.setZoom(edit.zoom); + break; + + case DestinationType::FitH: + case DestinationType::FitBH: + destination.setTop(target.isValid() && !target.isEmpty() ? target.bottom() + : std::numeric_limits::quiet_NaN()); + break; + + case DestinationType::FitV: + case DestinationType::FitBV: + destination.setLeft(target.isValid() && !target.isEmpty() ? target.left() + : std::numeric_limits::quiet_NaN()); + break; + + case DestinationType::FitR: + destination.setLeft(target.left()); + destination.setBottom(target.top()); + destination.setRight(target.right()); + destination.setTop(target.bottom()); + break; + + case DestinationType::Fit: + case DestinationType::FitB: + case DestinationType::Invalid: + case DestinationType::Named: + break; + } + + PDFDocumentModifier modifier(document); + const PDFObjectReference action = modifier.getBuilder()->createActionGoTo(destination); + modifier.getBuilder()->createAnnotationLink(pageReference(catalog, edit.pageIndex), + edit.rectangle.normalized(), + action, + LinkHighlightMode::Invert); + modifier.markAnnotationsChanged(); + if (!modifier.finalize()) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = PDFAuthoringEditResult::Kind::LinkCreated; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +bool readRecord(const PDFCatalog* catalog, const PDFOutlineItem* item, PDFBookmarkRecord& record, QString& typedError) +{ + record.title = item->getTitle(); + + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (!goTo || !resolveDestinationPage(catalog, goTo->getDestination(), &record.pageIndex)) + { + typedError = QStringLiteral("bookmark/unsupported-outline-item"); + return false; + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + PDFBookmarkRecord child; + if (!readRecord(catalog, item->getChild(i), child, typedError)) + { + return false; + } + record.children.append(child); + } + return true; +} + +std::optional> outlineRecords(const PDFDocument* document, QString& typedError) +{ + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + if (!root) + { + return QList(); + } + + QList records; + for (size_t i = 0; i < root->getChildCount(); ++i) + { + PDFBookmarkRecord record; + if (!readRecord(document->getCatalog(), root->getChild(i), record, typedError)) + { + return std::nullopt; + } + records.append(record); + } + return records; +} + +void collectBookmarkPages(const PDFCatalog* catalog, const PDFOutlineItem* item, QList& pages) +{ + int pageIndex = -1; + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &pageIndex)) + { + pages.append(pageIndex); + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + collectBookmarkPages(catalog, item->getChild(i), pages); + } +} + +QJsonObject recordToJson(const PDFBookmarkRecord& record) +{ + QJsonObject object; + object.insert(QStringLiteral("title"), record.title); + object.insert(QStringLiteral("page"), record.pageIndex); + if (!record.children.isEmpty()) + { + QJsonArray children; + for (const PDFBookmarkRecord& child : record.children) + { + children.append(recordToJson(child)); + } + object.insert(QStringLiteral("children"), children); + } + return object; +} + +bool recordFromJson(const QJsonValue& value, PDFBookmarkRecord& record, QString& typedError) +{ + if (!value.isObject()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + const QJsonObject object = value.toObject(); + const QJsonValue title = object.value(QStringLiteral("title")); + const QJsonValue page = object.value(QStringLiteral("page")); + if (!title.isString() || title.toString().isEmpty() || !page.isDouble() || + page.toDouble() != std::floor(page.toDouble())) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + record.title = title.toString(); + record.pageIndex = static_cast(page.toDouble()); + record.children.clear(); + + if (object.contains(QStringLiteral("children"))) + { + const QJsonValue children = object.value(QStringLiteral("children")); + if (!children.isArray()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + for (const QJsonValue& child : children.toArray()) + { + PDFBookmarkRecord childRecord; + if (!recordFromJson(child, childRecord, typedError)) + { + return false; + } + record.children.append(childRecord); + } + } + return true; +} + +} // namespace + +std::optional PDFBookmarkFile::fromJson(const QByteArray& payload, QString& typedError) +{ + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return std::nullopt; + } + + const QJsonObject object = document.object(); + const QJsonValue schemaVersion = object.value(QStringLiteral("schema_version")); + const QJsonValue bookmarks = object.value(QStringLiteral("bookmarks")); + if (!schemaVersion.isDouble() || static_cast(schemaVersion.toDouble()) != BookmarkFileSchemaVersion || + !bookmarks.isArray()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return std::nullopt; + } + + PDFBookmarkFile file; + for (const QJsonValue& value : bookmarks.toArray()) + { + PDFBookmarkRecord record; + if (!recordFromJson(value, record, typedError)) + { + return std::nullopt; + } + file.bookmarks.append(record); + } + return file; +} + +QByteArray PDFBookmarkFile::toJson() const +{ + QJsonArray array; + for (const PDFBookmarkRecord& record : bookmarks) + { + array.append(recordToJson(record)); + } + + QJsonObject object; + object.insert(QStringLiteral("schema_version"), BookmarkFileSchemaVersion); + object.insert(QStringLiteral("bookmarks"), array); + return canonicalJson(QJsonValue(object)); +} + +int PDFBookmarkFile::count() const +{ + return countRecords(bookmarks); +} + +PDFAuthoringEditResult PDFAuthoringEditWriter::apply(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + if (!document) + { + PDFAuthoringEditResult result; + refusedEdit(QStringLiteral("edit/document-missing"), &result); + return result; + } + + switch (edit.kind) + { + case PDFAuthoringEdit::Kind::ToggleBookmark: + return applyToggleBookmark(document, edit); + + case PDFAuthoringEdit::Kind::GenerateBookmarks: + return applyGenerateBookmarks(document, edit); + + case PDFAuthoringEdit::Kind::ReplaceBookmarks: + return applyReplaceBookmarks(document, edit); + + case PDFAuthoringEdit::Kind::AddUriLink: + return applyUriLink(document, edit); + + case PDFAuthoringEdit::Kind::AddDestinationLink: + return applyDestinationLink(document, edit); + } + + PDFAuthoringEditResult result; + refusedEdit(QStringLiteral("edit/unknown-kind"), &result); + return result; +} + +std::optional> PDFAuthoringEditWriter::readOutline(const PDFDocument* document, + QString& typedError) +{ + if (!document) + { + typedError = QStringLiteral("bookmark/document-missing"); + return std::nullopt; + } + + return outlineRecords(document, typedError); +} + +QList PDFAuthoringEditWriter::bookmarkPages(const PDFDocument* document) +{ + QList pages; + if (!document) + { + return pages; + } + + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + if (root) + { + collectBookmarkPages(document->getCatalog(), root.data(), pages); + } + return pages; +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfauthoringedit.h b/LoopLibCore/sources/pdfauthoringedit.h new file mode 100644 index 000000000..5b8ca35ba --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringedit.h @@ -0,0 +1,155 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFAUTHORINGEDIT_H +#define PDFAUTHORINGEDIT_H + +#include "pdfaction.h" +#include "pdfdocument.h" +#include "pdfglobal.h" + +#include +#include +#include + +#include +#include + +namespace pdf +{ + +/// One entry of the bookmark interchange file: a title and the 0-based page it +/// targets. `children` keeps the file's order, which is the outline order. +struct PDFBookmarkRecord +{ + QString title; + int pageIndex = -1; + QList children; + + /// Explicit rather than defaulted: a defaulted equality makes GCC 13's + /// `has_operator_equal` probe instantiate the definition while QList's own + /// equality is still being formed, which is a hard error there (MSVC + /// accepts it). The explicit declaration short-circuits the probe. + bool operator==(const PDFBookmarkRecord& other) const + { + return title == other.title && pageIndex == other.pageIndex && children == other.children; + } +}; + +/// The bookmark interchange file of the direct-edit family (ADR-014). +/// +/// Canonical JSON: `{"schema_version":1,"bookmarks":[{"title":…,"page":…, +/// "children":[…]}]}`. The format carries titles, page targets and nesting +/// only; outline state it cannot represent (structure elements, non-GoTo +/// actions) is not silently dropped by export — export refuses instead. +/// Parsing is total: malformed input returns a typed reason and no partial +/// tree. +class LOOPLIBCORESHARED_EXPORT PDFBookmarkFile +{ +public: + static std::optional fromJson(const QByteArray& payload, QString& typedError); + QByteArray toJson() const; + + /// Total records, including nested children. + int count() const; + + QList bookmarks; +}; + +/// What one authoring edit asks Core to write (ADR-014). +/// +/// All rectangles are in PDF page space (y-up); the writer normalizes inverted +/// corners and refuses degenerate ones. `zoom` uses a quiet NaN to write the +/// PDF `null` zoom, which is the "inherit the viewer's zoom" destination. +struct PDFAuthoringEdit +{ + enum class Kind + { + ToggleBookmark, ///< Add the page's bookmark, or remove it when one targets the page. + GenerateBookmarks, ///< Add a bookmark for every page that has none. + ReplaceBookmarks, ///< Replace the outline with `bookmarks`. + AddUriLink, ///< Link annotation with a URI action. + AddDestinationLink ///< Link annotation with a GoTo action of `destinationType`. + }; + + Kind kind = Kind::ToggleBookmark; + QString text; ///< Bookmark title, or the URI of a URI link. + int pageIndex = -1; ///< Bookmark target page, or the link rectangle's page. + QRectF rectangle; ///< Link rectangle, page space. + int targetPageIndex = -1; ///< Link destination page. + DestinationType destinationType = DestinationType::Fit; + QRectF targetRectangle; ///< FitR / XYZ destination rectangle, page space. + PDFReal zoom = std::numeric_limits::quiet_NaN(); ///< XYZ zoom; NaN inherits. + QList bookmarks; ///< ReplaceBookmarks payload. +}; + +/// How one edit resolved. `Refused` means no document was written and +/// `typedError` says why; every other kind carries the new document. +struct PDFAuthoringEditResult +{ + enum class Kind + { + Refused, + BookmarkAdded, + BookmarkRemoved, + BookmarksGenerated, + BookmarksReplaced, + LinkCreated + }; + + Kind kind = Kind::Refused; + QString typedError; ///< Set exactly when kind == Refused. + int affectedCount = 0; ///< Bookmarks written by Generated/Replaced. + PDFDocumentPointer document; + PDFModifiedDocument::ModificationFlags flags; + + bool applied() const { return kind != Kind::Refused; } +}; + +/// The Core writer behind the interaction layer's direct authoring edits +/// (ADR-014). Every edit builds a modified document through +/// PDFDocumentModifier/PDFDocumentBuilder; a refusal carries a typed reason and +/// leaves the input document untouched. +/// +/// An outline rewrite cannot represent state the builder's outline writer does +/// not write back (structure elements, actions that are neither GoTo nor +/// GoToDp), so edits that keep existing items refuse with +/// `bookmark/unsupported-outline-item` rather than dropping that state. +class LOOPLIBCORESHARED_EXPORT PDFAuthoringEditWriter +{ +public: + static PDFAuthoringEditResult apply(const PDFDocument* document, const PDFAuthoringEdit& edit); + + /// The document's outline as interchange records, in outline order. An + /// item without a resolvable page destination is refused with + /// `bookmark/unsupported-outline-item` rather than skipped. + static std::optional> readOutline(const PDFDocument* document, + QString& typedError); + + /// Flattened bookmark page targets in outline order, for bookmark + /// navigation. Items without a page target are not visited. + static QList bookmarkPages(const PDFDocument* document); +}; + +} // namespace pdf + +#endif // PDFAUTHORINGEDIT_H diff --git a/LoopLibCore/sources/pdfauthoringhistory.cpp b/LoopLibCore/sources/pdfauthoringhistory.cpp new file mode 100644 index 000000000..027a9bc49 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringhistory.cpp @@ -0,0 +1,156 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfauthoringhistory.h" + +#include "pdfartifactstore.h" + +#include +#include +#include + +#include + +namespace pdf +{ + +namespace +{ + +QString sidecarDirectory(const QString& documentPath) +{ + return QFileInfo(documentPath).absoluteFilePath() + QStringLiteral(".loop-history"); +} + +} // namespace + +PDFOperationResult appendAuthoringEditEvent(const QString& documentPath, + const QByteArray& inputBytes, + const QByteArray& outputBytes, + const QString& operationId, + const QString& operatorIdentity, + const QJsonObject& resultSummary, + quint64* sequence) +{ + if (documentPath.trimmed().isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs the document path the sidecar belongs to.")); + } + if (inputBytes.isEmpty() || outputBytes.isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs both the pre-edit and the new revision bytes.")); + } + if (operationId.trimmed().isEmpty() || operatorIdentity.trimmed().isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs an operation and an operator identity.")); + } + + const QString directory = sidecarDirectory(documentPath); + if (!QDir().mkpath(directory)) + { + return PDFOperationResult(QStringLiteral("Could not create the history sidecar directory.")); + } + + const PDFArtifactStore artifacts(directory); + const PDFArtifactStoreResult inputImport = artifacts.importBytes( + inputBytes, { QStringLiteral("application/pdf"), QStringLiteral("authoring-input.pdf") }); + if (!inputImport.success) + { + return PDFOperationResult(inputImport.errorMessage); + } + const PDFArtifactStoreResult outputImport = artifacts.importBytes( + outputBytes, { QStringLiteral("application/pdf"), QStringLiteral("authoring-output.pdf") }); + if (!outputImport.success) + { + return PDFOperationResult(outputImport.errorMessage); + } + + PDFOperationHistoryStore history(QDir(directory).filePath(QStringLiteral("history.sqlite3"))); + QString error; + if (!history.open(&error)) + { + return PDFOperationResult(error); + } + + // The first edit's undo target is the as-opened revision; it is registered + // once as the protected original input. Later edits chain onto the previous + // edit's output point, so no new original is registered for them. + QString pointsError; + const QList points = history.rollbackPoints(&pointsError); + if (!pointsError.isEmpty()) + { + return PDFOperationResult(pointsError); + } + const bool inputHasRollbackPoint = std::any_of( + points.cbegin(), points.cend(), + [&inputImport](const PDFRollbackPoint& point) + { return point.documentRevisionDigest == inputImport.artifact.sha256; }); + if (!inputHasRollbackPoint) + { + const PDFOperationResult registered = history.registerOriginalInput(inputImport.artifact); + if (!registered) + { + return registered; + } + } + + PDFOperationHistoryExecution execution; + execution.operationId = operationId; + execution.input = inputImport.artifact; + execution.startedUtc = QDateTime::currentDateTimeUtc(); + QUuid executionId; + if (const PDFOperationResult begun = history.beginExecution(execution, &executionId); !begun) + { + return begun; + } + + PDFOperationHistoryEvent accepted; + accepted.executionId = executionId; + accepted.kind = PDFOperationHistoryEventKind::FixApplied; + accepted.status = PDFOperationHistoryStatus::Accepted; + accepted.operatorIdentity = operatorIdentity; + // The digest describes the revision the edit produced, which is what a later + // undo resolves against (QUICK_REVISION_HISTORY.md). + accepted.documentRevisionDigest = outputImport.artifact.sha256; + accepted.output = outputImport.artifact; + accepted.resultSummary = resultSummary; + accepted.resultSummary.insert(QStringLiteral("model"), QStringLiteral("direct-revision")); + accepted.approval.kind = PDFApprovalKind::System; + accepted.approval.actorId = operatorIdentity; + accepted.approval.decision = QStringLiteral("accept"); + accepted.approval.policyId = QStringLiteral("desktop-authoring"); + accepted.approval.rationale = QStringLiteral("Interactive authoring edit applied as a direct revision (ADR-014)."); + accepted.approval.decidedUtc = QDateTime::currentDateTimeUtc(); + + qint64 appendedSequence = 0; + if (const PDFOperationResult appended = history.appendEvent(accepted, &appendedSequence); !appended) + { + return appended; + } + if (sequence) + { + *sequence = quint64(appendedSequence); + } + return PDFOperationResult(true); +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfauthoringhistory.h b/LoopLibCore/sources/pdfauthoringhistory.h new file mode 100644 index 000000000..b634e9f62 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringhistory.h @@ -0,0 +1,63 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFAUTHORINGHISTORY_H +#define PDFAUTHORINGHISTORY_H + +#include "pdfoperationhistorystore.h" + +#include +#include +#include + +namespace pdf +{ + +/// Appends one accepted interactive authoring edit to the document's +/// `.loop-history` sidecar, the same chain docs/OPERATION_HISTORY.md defines and +/// issue #124's forward-rollback undo reads: +/// +/// 1. the new revision's bytes are imported into the sidecar's artifact store; +/// 2. the pre-edit revision is registered as the protected original input when +/// the chain holds no rollback point for it yet, so the first edit's undo has +/// a target; +/// 3. one accepted `FixApplied` event is appended; appendEvent creates the +/// matching `PDFRollbackPoint` from the output artifact. +/// +/// The sidecar lives at `.loop-history/history.sqlite3` and is +/// created on first use, so an unsaved interactive session still leaves a +/// verifiable chain beside the document. +/// +/// Returns false with the store's message when the chain could not be extended. +/// The caller's in-memory edit is already applied and is never rolled back here: +/// a provenance failure is reported, not repaired. +LOOPLIBCORESHARED_EXPORT PDFOperationResult appendAuthoringEditEvent(const QString& documentPath, + const QByteArray& inputBytes, + const QByteArray& outputBytes, + const QString& operationId, + const QString& operatorIdentity, + const QJsonObject& resultSummary, + quint64* sequence = nullptr); + +} // namespace pdf + +#endif // PDFAUTHORINGHISTORY_H diff --git a/LoopLibCore/sources/pdfdirectedit.cpp b/LoopLibCore/sources/pdfdirectedit.cpp new file mode 100644 index 000000000..529d4b858 --- /dev/null +++ b/LoopLibCore/sources/pdfdirectedit.cpp @@ -0,0 +1,848 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdirectedit.h" + +#include "pdfcatalog.h" +#include "pdfdocumentbuilder.h" +#include "pdffile.h" +#include "pdffilenamesanitizer.h" +#include "pdfform.h" +#include "pdfsafefilewriter.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include
Windows installer fix
This release was withdrawn.
Initial beta release for non-production testing