diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 743698a9..4132559b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,10 @@ jobs: run: | python3 scripts/ci/test_correction_operation_catalog.py python3 scripts/generate-architecture-catalogs.py --check + - name: Verify command catalog handler coverage (#231) + run: | + python3 scripts/ci/test_command_catalog.py + python3 scripts/verify-command-catalog.py - name: Verify preflight check catalog and coverage backlog (#165) run: python3 scripts/ci/test_preflight_check_catalog.py - name: Verify preflight corpus coverage map (#165) diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index c5625367..5f232b2f 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -35,6 +35,8 @@ qt_add_executable(LoopEditor quickoutput.cpp quickoutput.h editorhost.h + editorsettings.cpp + editorsettings.h focusrestoration.cpp focusrestoration.h quickdocumentmodel.cpp @@ -48,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 diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 7ad871c8..c14cc95f 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 9de04b91..19f544b6 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 4e79570c..b42a1b31 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -44,6 +44,7 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdocumentimageextractor.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" @@ -56,13 +57,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 @@ -171,6 +176,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) @@ -275,6 +289,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; @@ -284,7 +312,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); @@ -306,7 +338,7 @@ struct EditorHost::PreflightWorkerOutcome QJsonObject auditSummary; }; -EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : +EditorHost::EditorHost(EditorSettings* settings, QObject* parent, QString recipesDirectory) : QObject(parent), m_session(std::make_unique(this)), m_findingNavigator(std::make_unique( @@ -320,6 +352,16 @@ EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : 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 @@ -349,6 +391,12 @@ EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : 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()); @@ -421,6 +469,7 @@ EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishInkCoverageJob(snapshot); finishDocumentOutput(snapshot); finishSignatureCheckJob(snapshot); refreshCanvasTrace(); }); @@ -555,6 +604,157 @@ bool EditorHost::setActiveTool(const QString& toolId) return true; } +bool EditorHost::copySelectedText() +{ + pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (!interaction) + { + return false; + } + + // The refusal is typed and named, and the clipboard is left untouched: an + // empty string is never copied as success. + const pdfinteraction::TextCopyResult result = interaction->selectedText(); + if (!result.copied) + { + announceDocumentState(tr("Copy refused: %1").arg(result.refusalReason)); + return false; + } + + QGuiApplication::clipboard()->setText(result.text); + announceDocumentState(tr("Copied %n character(s).", "", result.text.size())); + return true; +} + +QImage EditorHost::composeVisibleCanvasImage() const +{ + pdfinteraction::PageSurfaceCoordinator* surfaces = m_session->surfaces(); + if (!surfaces || !hasDocument()) + { + return QImage(); + } + + const pdfinteraction::CanvasSnapshot& snapshot = surfaces->snapshot(); + if (snapshot.isEmpty() || !snapshot.token.revision.isValid() || + snapshot.token.revision != m_session->facade().currentRevision()) + { + return QImage(); + } + + return pdfinteraction::composeCanvasImage(snapshot, + m_session->viewport().viewportRect(), + m_session->viewport().devicePixelRatio()); +} + +bool EditorHost::captureCanvasFileUrl(const QUrl& url) +{ + const QString path = url.toLocalFile(); + if (path.isEmpty()) + { + announceDocumentState(tr("Choose a local file to save the screenshot to.")); + return false; + } + + const QImage image = composeVisibleCanvasImage(); + if (image.isNull()) + { + announceDocumentState(tr("There is nothing on the canvas to capture.")); + return false; + } + + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG")) + { + announceDocumentState(tr("The screenshot could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(path, png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write the screenshot: %1").arg(result.getErrorMessage())); + return false; + } + + announceDocumentState(tr("Screenshot saved.")); + return true; +} + +bool EditorHost::extractCurrentPageImagesFileUrl(const QUrl& directory) +{ + if (!hasDocument() || !m_session->imageExtractor()) + { + announceDocumentState(tr("Open a document before extracting images.")); + return false; + } + + const QString directoryPath = directory.toLocalFile(); + if (directoryPath.isEmpty() || !QFileInfo(directoryPath).isDir()) + { + announceDocumentState(tr("Choose an existing directory to extract the images into.")); + return false; + } + + QString typedError; + const QList images = m_session->imageExtractor()->extractPage(currentPage(), &typedError); + if (!typedError.isEmpty()) + { + announceDocumentState(tr("Could not extract images: %1").arg(typedError)); + return false; + } + if (images.isEmpty()) + { + announceDocumentState(tr("This page has no images to extract.")); + return false; + } + + // Guard every planned output up front: extraction must not silently clobber + // an existing file, and a run must not stop halfway through the set. + QStringList plannedOutputs; + plannedOutputs.reserve(images.size()); + for (int index = 0; index < images.size(); ++index) + { + plannedOutputs << QDir(directoryPath).filePath(QStringLiteral("page-%1-image-%2.png").arg(currentPage() + 1).arg(index + 1)); + } + + if (!pdf::PDFSafeFileWriter::findOutputConflicts(plannedOutputs, true).isEmpty()) + { + announceDocumentState(tr("Refusing to overwrite an existing file in the destination directory.")); + return false; + } + + for (int index = 0; index < images.size(); ++index) + { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!images[index].image.save(&buffer, "PNG")) + { + announceDocumentState(tr("An extracted image could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(plannedOutputs[index], png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write %1: %2") + .arg(QFileInfo(plannedOutputs[index]).fileName(), result.getErrorMessage())); + return false; + } + } + + announceDocumentState(tr("Extracted %n image(s).", "", images.size())); + return true; +} + QObject* EditorHost::preflight() { return &m_preflight; @@ -1251,10 +1451,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 @@ -1326,6 +1544,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."); } @@ -1631,6 +1863,101 @@ bool EditorHost::allowDeveloperDiagnostics() const #endif } +QString EditorHost::settingsNotice() +{ + 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) @@ -1740,157 +2067,772 @@ QVariantList EditorHost::preflightProfiles() const item.insert(QStringLiteral("diagnostic"), profile.diagnostic); profiles.append(item); } - return profiles; -} + return profiles; +} + +QVariantList EditorHost::preflightVariables() const +{ + const auto it = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId; }); + if (it == m_preflightProfiles.cend()) + { + return {}; + } + + QVariantList variables; + const QStringList names = it->variables.keys(); + for (const QString& name : names) + { + const QJsonObject declaration = it->variables.value(name).toObject(); + QVariantMap item; + item.insert(QStringLiteral("name"), name); + item.insert(QStringLiteral("type"), declaration.value(QStringLiteral("type")).toString()); + item.insert(QStringLiteral("required"), declaration.value(QStringLiteral("required")).toBool()); + item.insert(QStringLiteral("description"), declaration.value(QStringLiteral("description")).toString()); + item.insert(QStringLiteral("value"), m_preflightBindings.contains(name) + ? m_preflightBindings.value(name).toVariant() + : declaration.value(QStringLiteral("default")).toVariant()); + if (declaration.contains(QStringLiteral("min"))) + item.insert(QStringLiteral("min"), declaration.value(QStringLiteral("min")).toVariant()); + if (declaration.contains(QStringLiteral("max"))) + item.insert(QStringLiteral("max"), declaration.value(QStringLiteral("max")).toVariant()); + variables.append(item); + } + return variables; +} + +QString EditorHost::selectedPreflightProfileId() const +{ + return m_selectedPreflightProfileId; +} + +bool EditorHost::preflightProfileEditing() const +{ + return m_preflightProfileDraft.isActive(); +} + +QVariantList EditorHost::preflightEditableChecks() const +{ + return m_preflightProfileDraft.editableChecks(); +} + +QString EditorHost::preflightProfileDraftVersion() const +{ + return m_preflightProfileDraft.suggestedNextVersion(); +} + +QString EditorHost::previewSummary() const +{ + return m_preview.summary(); +} + +QString EditorHost::inspectorTitle() const +{ + return m_inspector.title(); +} + +bool EditorHost::preferReducedMotion() const +{ + const QByteArray env = qgetenv("QT_ACCESSIBILITY_REDUCE_MOTION"); + if (!env.isEmpty()) + { + return env == "1" || env.toLower() == "true"; + } + + return false; +} + +bool EditorHost::highContrast() const +{ + if (QGuiApplication* app = qobject_cast(QCoreApplication::instance())) + { + if (QStyleHints* hints = app->styleHints()) + { + if (const QAccessibilityHints* accessibility = hints->accessibility()) + { + return accessibility->contrastPreference() != Qt::ContrastPreference::NoPreference; + } + } + } + return false; +} + +bool EditorHost::pageFidelityIsExact() const +{ + if (!hasDocument()) + { + 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(); +} + +QString EditorHost::pageFidelityReason() const +{ + if (!hasDocument()) + { + 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()) + { + reasons.append(diagnostics->reasons); + } + + return reasons.join(QStringLiteral(" ")); +} + +bool EditorHost::pageFidelityIsAuthoritative() const +{ + if (!hasDocument()) + { + return false; + } + + return m_session->surfaces()->isPageAuthoritativeOverprint(currentPage()); +} + +void EditorHost::toggleCurrentPageFidelity() +{ + if (!hasDocument()) + { + return; + } + + const int pageIndex = currentPage(); + const bool wasAuthoritative = m_session->surfaces()->isPageAuthoritativeOverprint(pageIndex); + m_session->surfaces()->setPageAuthoritativeOverprint(pageIndex, !wasAuthoritative); + bumpPresentation(); +} + +bool EditorHost::ensureAuthoritativePreview() +{ + if (!hasDocument() || !previewRequiresAuthoritative()) + { + return false; + } + + m_session->surfaces()->setPageAuthoritativeOverprint(currentPage(), true); + bumpPresentation(); + 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; + } -QVariantList EditorHost::preflightVariables() const -{ - const auto it = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), - [this](const PreflightProfileChoice& profile) - { return profile.id == m_selectedPreflightProfileId; }); - if (it == m_preflightProfiles.cend()) + if (!hasDocument()) { - return {}; + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("profile-missing"); + bumpPresentation(); + return false; } - QVariantList variables; - const QStringList names = it->variables.keys(); - for (const QString& name : names) + bool declaresOutputIntent = false; + const std::optional profile = resolveOutputIntentProfile(&declaresOutputIntent); + if (!profile) { - const QJsonObject declaration = it->variables.value(name).toObject(); - QVariantMap item; - item.insert(QStringLiteral("name"), name); - item.insert(QStringLiteral("type"), declaration.value(QStringLiteral("type")).toString()); - item.insert(QStringLiteral("required"), declaration.value(QStringLiteral("required")).toBool()); - item.insert(QStringLiteral("description"), declaration.value(QStringLiteral("description")).toString()); - item.insert(QStringLiteral("value"), m_preflightBindings.contains(name) - ? m_preflightBindings.value(name).toVariant() - : declaration.value(QStringLiteral("default")).toVariant()); - if (declaration.contains(QStringLiteral("min"))) - item.insert(QStringLiteral("min"), declaration.value(QStringLiteral("min")).toVariant()); - if (declaration.contains(QStringLiteral("max"))) - item.insert(QStringLiteral("max"), declaration.value(QStringLiteral("max")).toVariant()); - variables.append(item); + m_softProofEnabled = false; + m_softProofStateName = declaresOutputIntent ? QStringLiteral("profile-invalid") + : QStringLiteral("profile-missing"); + bumpPresentation(); + return false; } - return variables; -} -QString EditorHost::selectedPreflightProfileId() const -{ - return m_selectedPreflightProfileId; + m_softProofProfile = *profile; + m_softProofEnabled = true; + m_softProofStateName = QStringLiteral("simulating"); + m_session->setSoftProofProfile(m_softProofProfile.id); + bumpPresentation(); + return true; } -bool EditorHost::preflightProfileEditing() const +bool EditorHost::toggleSoftProof() { - return m_preflightProfileDraft.isActive(); + if (softProofActive()) + { + setSoftProofEnabled(false); + return false; + } + return setSoftProofEnabled(true); } -QVariantList EditorHost::preflightEditableChecks() const +QVariantMap EditorHost::plateRow(const QString& id) const { - return m_preflightProfileDraft.editableChecks(); + for (const QVariant& entry : m_plates) + { + const QVariantMap row = entry.toMap(); + if (row.value(QStringLiteral("id")).toString() == id) + { + return row; + } + } + return QVariantMap(); } -QString EditorHost::preflightProfileDraftVersion() const +QVariantList EditorHost::productionPlates() const { - return m_preflightProfileDraft.suggestedNextVersion(); + return m_plates; } -QString EditorHost::previewSummary() const +QString EditorHost::plateIsolationSummary() const { - return m_preview.summary(); -} + 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()); + } -QString EditorHost::inspectorTitle() const -{ - return m_inspector.title(); + 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::preferReducedMotion() const +bool EditorHost::setPlateVisible(const QString& plateId, bool visible) { - const QByteArray env = qgetenv("QT_ACCESSIBILITY_REDUCE_MOTION"); - if (!env.isEmpty()) + if (plateRow(plateId).isEmpty()) { - return env == "1" || env.toLower() == "true"; + return false; } - return false; + m_plateVisible.insert(plateId, visible); + if (!visible && m_isolatedPlateId == plateId) + { + m_isolatedPlateId.clear(); + } + reloadProductionPlates(); + bumpPresentation(); + return true; } -bool EditorHost::highContrast() const +bool EditorHost::isolatePlate(const QString& plateId) { - if (QGuiApplication* app = qobject_cast(QCoreApplication::instance())) + if (plateRow(plateId).isEmpty()) { - if (QStyleHints* hints = app->styleHints()) - { - if (const QAccessibilityHints* accessibility = hints->accessibility()) - { - return accessibility->contrastPreference() != Qt::ContrastPreference::NoPreference; - } - } + return false; } - 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::pageFidelityIsExact() const +bool EditorHost::showAllPlates() { - if (!hasDocument()) + m_isolatedPlateId.clear(); + for (const QVariant& entry : m_plates) { - return true; + m_plateVisible.insert(entry.toMap().value(QStringLiteral("id")).toString(), true); } - - const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); - return !diagnostics.has_value() || diagnostics->isExact(); + reloadProductionPlates(); + bumpPresentation(); + return true; } -QString EditorHost::pageFidelityReason() const +void EditorHost::reloadProductionPlates() { + m_plates.clear(); + if (!hasDocument()) { - return QString(); + return; } - const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); - if (!diagnostics.has_value() || diagnostics->reasons.isEmpty()) + pdf::PDFDocumentSession* session = m_session->context().tryGetSession(); + const pdf::PDFDocument* document = m_session->context().getDocument(); + if (!session || !document) { - return QString(); + return; } - return diagnostics->reasons.join(QStringLiteral(" ")); + // 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::pageFidelityIsAuthoritative() const +bool EditorHost::requestInkCoverageAtPoint(qreal pageX, qreal pageY) { - if (!hasDocument()) + if (!hasDocument() || !m_session->revisionSource()) { return false; } - return m_session->surfaces()->isPageAuthoritativeOverprint(currentPage()); + 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; } -void EditorHost::toggleCurrentPageFidelity() +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; + return QPointF(); } + const pdf::PDFDocument* document = m_session->context().getDocument(); const int pageIndex = currentPage(); - const bool wasAuthoritative = m_session->surfaces()->isPageAuthoritativeOverprint(pageIndex); - m_session->surfaces()->setPageAuthoritativeOverprint(pageIndex, !wasAuthoritative); - bumpPresentation(); + 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(); } -bool EditorHost::ensureAuthoritativePreview() +void EditorHost::finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot) { - if (!hasDocument() || !previewRequiresAuthoritative()) + const auto outcome = m_inkCoverageOutcomes.take(snapshot.jobId); + if (!outcome) { - return false; + return; } - m_session->surfaces()->setPageAuthoritativeOverprint(currentPage(), true); + 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(); - return true; } void EditorHost::selectFinding(const QString& findingId) @@ -3911,6 +4853,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() @@ -3999,6 +4960,111 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionProperties"), [this] { setWorkspace(LoopWorkspace::Inspect); }); + // ---- Select, copy and capture (issue #232) ------------------------------------- + // A selection command finishes Failed with a typed code when it applied + // nothing, so "the menu did nothing" is reported rather than implied. + auto bindSelectionCommand = [this](const QString& id, std::function action) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, action = std::move(action)](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool applied = action(); + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : QStringLiteral("text/selection-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindSelectionCommand(QStringLiteral("actionSelectText"), [this] + { return setActiveTool(QStringLiteral("text")); }); + bindSelectionCommand(QStringLiteral("actionSelectTextAll"), [this] + { return m_session->interaction()->selectAllText(); }); + bindSelectionCommand(QStringLiteral("actionDeselectText"), [this] + { return m_session->interaction()->deselectText(); }); + bindSelectionCommand(QStringLiteral("actionSelectTable"), [this] + { return m_session->interaction()->selectTableUnderPointer(); }); + bindSelectionCommand(QStringLiteral("actionGoToNextLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(1); }); + bindSelectionCommand(QStringLiteral("actionGoToPreviousLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(-1); }); + + // Copy reports the controller's typed refusal, so a page with no text layer + // can never be copied as an empty success. + pdfinteraction::CommandCatalog::Handler copy; + copy.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool copied = copySelectedText(); + m_session->catalog().finishInvocation(invocation, + copied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + copied ? QString() : QStringLiteral("text/copy-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionCopyText"), std::move(copy)); + + // Screenshot and image extraction need a destination, so the commands ask + // the shell for one and write nothing themselves. + bind(QStringLiteral("actionScreenshot"), [this] + { Q_EMIT screenshotRequested(); }); + bind(QStringLiteral("actionExtractImage"), [this] + { Q_EMIT imageExtractionRequested(); }); + + // 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) { @@ -4043,7 +5109,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); } @@ -4054,8 +5123,42 @@ void EditorHost::refreshFeatureAvailability() 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()); + m_session->catalog().setEnabledBatch(availability); } @@ -4102,6 +5205,7 @@ void EditorHost::bumpPresentation() { m_canvas->setHighContrast(highContrast()); } + applyDisplayColorToCanvas(); Q_EMIT presentationChanged(); } @@ -4113,6 +5217,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(); @@ -4120,6 +5227,7 @@ void EditorHost::onDocumentReady() syncDocumentLifecycle(); m_searchRow = -1; refreshHitTestSources(); + reloadProductionPlates(); m_documentBound = true; bindCanvas(); updateCanvasAccessibilitySummary(); @@ -4186,6 +5294,16 @@ void EditorHost::onDocumentGone() m_searchRow = -1; m_preview.clear(); m_production.clear(); + // Proofing and the plate list describe the document that just closed, so + // neither may outlive it: a soft proof of a document nobody has open would + // keep the canvas on a simulation with no revision behind it. + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("off"); + m_softProofProfile = SoftProofProfile(); + m_plates.clear(); + m_plateVisible.clear(); + m_isolatedPlateId.clear(); + m_inkCoverageReadout = QVariantMap(); m_session->hitTest()->clearSources(); m_documentBound = false; m_preflightCertificateStateName = QStringLiteral("not-certified"); @@ -4832,8 +5950,36 @@ void EditorHost::updateCanvasAccessibilitySummary() const int pageNumber = currentPage() + 1; const int pages = pageCount(); const int zoomPercent = qRound(zoom() * 100.0); - m_canvas->setAccessibleDocumentSummary( - tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent)); + QString summary = tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent); + + // Measurements and text selections are readouts, so the screen reader gets + // them from the same summary the canvas already exposes. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const pdfinteraction::InteractionState& state = interaction->state(); + const std::optional& selection = state.textSelection(); + if (selection.has_value()) + { + if (!selection->available) + { + summary += QLatin1Char(' ') + tr("Text selection unavailable: %1").arg(selection->unavailableReason); + } + else if (!selection->text.isEmpty()) + { + summary += QLatin1Char(' ') + tr("Selected %n character(s).", "", selection->text.size()); + } + } + + if (!state.measurements().isEmpty()) + { + summary += QLatin1Char(' ') + tr("Measurements: %1. Latest: %2.") + .arg(state.measurements().size()) + .arg(pdfinteraction::measurementReadout(state.measurements().last())); + } + } + + m_canvas->setAccessibleDocumentSummary(summary); } void EditorHost::onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request) diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 605c43b1..8372d0b2 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -46,15 +46,19 @@ #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 +#include #include #include #include @@ -64,6 +68,7 @@ #include #include +#include namespace pdfinteraction { @@ -154,6 +159,16 @@ 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) + // ---- 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 @@ -181,6 +196,28 @@ 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 @@ -203,7 +240,10 @@ class EditorHost final : public QObject }; Q_ENUM(LoopWorkspace) - explicit EditorHost(QObject* parent = nullptr, QString recipesDirectory = QString()); + /// `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; @@ -286,6 +326,35 @@ 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); + /// 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 @@ -338,13 +407,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; @@ -352,6 +423,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 @@ -490,6 +617,21 @@ class EditorHost final : public QObject /// in-flight drag with ToolChanged (#141 AC3). Q_INVOKABLE bool setActiveTool(const QString& toolId); + // ---- Select, copy and capture (issue #232) ------------------------------------- + /// Copies the canvas text selection to the clipboard. The clipboard is only + /// written when there is text to copy; a page with no text layer reports + /// that state and an empty string is never copied as success. + Q_INVOKABLE bool copySelectedText(); + + /// Writes the visible canvas region to `url` as a PNG through the atomic + /// writer, which refuses to overwrite an existing file. + Q_INVOKABLE bool captureCanvasFileUrl(const QUrl& url); + + /// Extracts the current page's images into `directory` through the atomic + /// writer. Refuses up front when any target file already exists, so a run + /// cannot half-overwrite the destination. + Q_INVOKABLE bool extractCurrentPageImagesFileUrl(const QUrl& directory); + Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; Q_INVOKABLE quint64 invokeCommand(const QString& commandId, const QVariantMap& parameters = {}); @@ -542,6 +684,21 @@ class EditorHost final : public QObject void preflightProfileExportRequested(); void preflightProfileSaveRequested(); + /// The Screenshot command needs a destination, so the shell asks for one + /// and answers with captureCanvasFileUrl(). The command itself writes + /// nothing. + void screenshotRequested(); + + /// The Extract Image command needs a destination directory, so the shell + /// asks for one and answers with extractCurrentPageImagesFileUrl(). + void imageExtractionRequested(); + + /// The shell-side surfaces (#239). The commands emit these; QML owns the + /// dialogs and opens them. + void settingsPanelRequested(); + void settingsResetRequested(); + void diagnosticsCollectionRequested(); + private: void connectFacade(); void connectViewport(); @@ -596,6 +753,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; @@ -631,6 +818,23 @@ 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; + QPointer m_canvas; QHash m_activeAsyncJobs; QString m_outputJobId; @@ -659,6 +863,35 @@ class EditorHost final : public QObject QString retentionError; }; QHash> m_rollbackOutcomes; + + /// Display colour mode in force (issue #235 display half). Presentation state: + /// pushed to the canvas item, never to a render request. + pdfquick::CanvasDisplayColorSettings m_displayColor; + + /// Soft proof state (issue #237). The profile is the document's output-intent + /// profile Core resolved; `m_softProofStateName` is `off`, `simulating`, + /// `profile-missing` or `profile-invalid`. + bool m_softProofEnabled = false; + QString m_softProofStateName = QStringLiteral("off"); + SoftProofProfile m_softProofProfile; + + /// Separations Core resolved for the open document, and the operator's own + /// presentation state over them. Visibility is keyed by colorant name so a + /// plate that is still present keeps its state across a reload. + QVariantList m_plates; + QHash m_plateVisible; + QString m_isolatedPlateId; + + /// The last ink-coverage readout, and the worker outcomes still in flight. + QVariantMap m_inkCoverageReadout; + struct InkCoverageOutcome + { + bool ok = false; + QString reason; + int pageIndex = -1; + QVariantMap readout; + }; + QHash> m_inkCoverageOutcomes; QVariantList m_revisionHistory; QString m_historyError; QString m_historyUndoId; diff --git a/LoopEditor/editorsettings.cpp b/LoopEditor/editorsettings.cpp new file mode 100644 index 00000000..f14e81e0 --- /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 00000000..42964e16 --- /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