diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index 3f310ca2..c5625367 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -32,6 +32,8 @@ qt_add_executable(LoopEditor documentviewsession.cpp documentviewsession.h editorhost.cpp + quickoutput.cpp + quickoutput.h editorhost.h focusrestoration.cpp focusrestoration.h diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 0f508c23..4e79570c 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -421,6 +421,7 @@ EditorHost::EditorHost(QObject* parent, QString recipesDirectory) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishDocumentOutput(snapshot); finishSignatureCheckJob(snapshot); refreshCanvasTrace(); }); } @@ -432,6 +433,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 @@ -3575,6 +3578,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()) @@ -3781,6 +3939,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; @@ -3870,6 +4054,8 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionUndo"), historyUndoAvailable()); availability.insert(QStringLiteral("actionRedo"), historyRedoAvailable()); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); + availability.insert(QStringLiteral("actionPrint"), ready && !outputBusy() && m_documentModel.canPrint()); + availability.insert(QStringLiteral("actionExportDocument"), ready && !outputBusy() && m_documentModel.canCopy()); m_session->catalog().setEnabledBatch(availability); } @@ -3969,6 +4155,12 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { + if (outputBusy()) + m_session->scheduler().cancel(m_outputJobId); + m_publishedOutputBytes.clear(); + m_publicationReceipt = {}; + m_publicationRevision.clear(); + m_outputRecord.clear(); m_publishedHistoryPath.clear(); m_publishedHistoryRevision.clear(); cancelPreflight(); @@ -4227,6 +4419,10 @@ void EditorHost::finishActionListJob(const pdf::PDFJobSnapshot& snapshot) m_actionListController.acceptExecution(snapshot.jobId, snapshot.documentRevision, outcome->executionResult)) { m_session->context().setDocument(outcome->candidate); + m_session->scheduler().setCurrentRevision(m_session->revisionSource()->documentKey(), m_session->facade().currentRevision().toString()); + m_publishedOutputBytes = outcome->publishedBytes; + m_publicationReceipt = outcome->executionResult.governed.value(QStringLiteral("receipt")).toObject(); + m_publicationRevision = m_session->facade().currentRevision().toString(); m_session->scheduler().setCurrentRevision(m_session->revisionSource()->documentKey(), m_session->facade().currentRevision().toString()); m_publishedHistoryPath = outcome->publishedPath; @@ -4431,6 +4627,14 @@ void EditorHost::syncRevisionModels() const QString documentKey = m_session->revisionSource()->documentKey(); const QString documentRevision = m_session->facade().currentRevision().toString(); m_preflight.setCurrentRevision(documentKey, documentRevision); + if (outputBusy() && documentRevision != m_outputRevision) + m_session->scheduler().cancel(m_outputJobId); + if (documentRevision != m_publicationRevision) + { + m_publishedOutputBytes.clear(); + m_publicationReceipt = {}; + m_publicationRevision.clear(); + } m_actionListController.setCurrentRevision(documentKey, documentRevision); m_inspector.setCurrentRevision(documentKey, documentRevision); m_preview.setCurrentRevision(documentKey, documentRevision); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 2a3d7829..605c43b1 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -47,6 +47,7 @@ #include "focusrestoration.h" #include "documentviewsession.h" #include "quickdocumentmodel.h" +#include "quickoutput.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -87,6 +88,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) @@ -208,6 +211,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; @@ -528,6 +536,8 @@ class EditorHost final : public QObject void preflightProfileDraftChanged(); void actionListRecipesChanged(); void preflightReportExportRequested(); + void documentOutputRequested(bool printing); + void documentOutputFinished(); void preflightProfileImportRequested(); void preflightProfileExportRequested(); void preflightProfileSaveRequested(); @@ -561,6 +571,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(); @@ -621,6 +633,16 @@ class EditorHost final : public QObject QPointer m_canvas; QHash m_activeAsyncJobs; + QString m_outputJobId; + QString m_outputRevision; + QString m_outputDestination; + bool m_outputCancelled = false; + pdfinteraction::CommandInvocationId m_outputInvocation = pdfinteraction::InvalidCommandInvocation; + std::shared_ptr m_outputResult; + QVariantMap m_outputRecord; + QByteArray m_publishedOutputBytes; + QJsonObject m_publicationReceipt; + QString m_publicationRevision; struct PreflightWorkerOutcome; QHash> m_preflightOutcomes; QHash> m_actionListOutcomes; diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 543daa6e..24b86dc2 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -174,6 +174,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") diff --git a/LoopEditor/qml/MenuModel.qml b/LoopEditor/qml/MenuModel.qml index 9f84e936..6af44814 100644 --- a/LoopEditor/qml/MenuModel.qml +++ b/LoopEditor/qml/MenuModel.qml @@ -78,6 +78,8 @@ QtObject { } function labelForEntry(entry) { + if (entry && entry.id === "actionPrint") return qsTr("Print to PDF…") + if (entry && entry.id === "actionExportDocument") return qsTr("Export document…") if (!entry || !entry.labelKey) { return entry && entry.id ? entry.id : "" } diff --git a/LoopEditor/quickoutput.cpp b/LoopEditor/quickoutput.cpp new file mode 100644 index 00000000..b3526aa4 --- /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 00000000..225baad1 --- /dev/null +++ b/LoopEditor/quickoutput.h @@ -0,0 +1,44 @@ +// MIT License +#ifndef QUICKOUTPUT_H +#define QUICKOUTPUT_H + +#include "pdfdocument.h" +#include "pdfjobscheduler.h" +#include +#include +#include + +namespace loopeditor +{ +enum class OutputFormat +{ + PublishedPdf, + PrintPdf, + Png, + Tiff +}; + +struct OutputRequest +{ + OutputFormat format = OutputFormat::PublishedPdf; + pdf::PDFDocumentPointer document; + QString sourcePath; + QByteArray publishedBytes; + QJsonObject publicationReceipt; + QString revision; + int page = 0; + int dpi = 300; + bool fidelityAcknowledged = false; +}; + +struct OutputResult +{ + std::shared_ptr staging; + QString stagedPath; + QString error; + QJsonObject record; +}; + +void prepareOutput(const OutputRequest& request, OutputResult& result, pdf::PDFJobContext& job); +} +#endif diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp index 54d92f86..b3a24be7 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.cpp +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.cpp @@ -444,6 +444,10 @@ ActionListRunWorker makeActionListRunWorker(ActionListRunPhase phase, } pdf::PDFGovernedMutationReceipt receipt; const pdf::PDFOperationResult governedResult = pdf::executeGovernedMutation(mutation, &receipt); + if (governedResult && receipt.isPublished()) + { + outcome->publishedBytes = candidateData; + } if (receipt.isPublished() && !publicationSourcePath.isEmpty()) outcome->publishedPath = publicationPath; outcome->executionResult.governed = QJsonObject{ diff --git a/LoopLibInteraction/sources/actionlistrunsubmitter.h b/LoopLibInteraction/sources/actionlistrunsubmitter.h index bdaac1e1..afafee6a 100644 --- a/LoopLibInteraction/sources/actionlistrunsubmitter.h +++ b/LoopLibInteraction/sources/actionlistrunsubmitter.h @@ -53,6 +53,7 @@ struct ActionListWorkerOutcome QVector validationSteps; pdf::PDFActionListExecutionResult executionResult; pdf::PDFDocumentPointer candidate; + QByteArray publishedBytes; QString publishedPath; }; diff --git a/UnitTests/phase4-tests.cmake b/UnitTests/phase4-tests.cmake index 1cd3ed81..250d6083 100644 --- a/UnitTests/phase4-tests.cmake +++ b/UnitTests/phase4-tests.cmake @@ -69,6 +69,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) tst_editorhosttest.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/app.qrc ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -105,6 +107,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsProductOperatorLoop tst_productoperatorloop.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -309,6 +313,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsShellKeyboard tst_shellkeyboardtest.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -336,6 +342,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) ${CMAKE_SOURCE_DIR}/LoopEditor/operatorhelp.h ${CMAKE_SOURCE_DIR}/LoopEditor/app.qrc ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h @@ -364,6 +372,8 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_executable(UnitTestsShellInspectorDispatch tst_shellinspectordispatch.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.cpp + ${CMAKE_SOURCE_DIR}/LoopEditor/quickoutput.h ${CMAKE_SOURCE_DIR}/LoopEditor/editorhost.h ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.cpp ${CMAKE_SOURCE_DIR}/LoopEditor/documentviewsession.h diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index da7cfdf1..0a1fc8b4 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -407,7 +407,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() // library that lost its resource would present an empty command set as a // working one, so this also pins the resource wiring. QVERIFY2(catalog.isLoaded(), qPrintable(catalog.loadError())); - QCOMPARE(catalog.descriptors().size(), 108); + QCOMPARE(catalog.descriptors().size(), 109); const pdfinteraction::CommandDescriptor* open = catalog.descriptor(pdfinteraction::DocumentFacade::OpenCommandId); @@ -439,7 +439,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 28); + QCOMPARE(implemented, 30); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 53d8eb50..d5994a5d 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,10 @@ #include "pdfblockingthreadguard.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentwriter.h" +#include "pdfdocumentreader.h" +#include "quickoutput.h" +#include +#include #include "pdfsecurityhandler.h" #include "pdfoperationhistorystore.h" #include "pdfsettings.h" @@ -273,6 +278,12 @@ class EditorHostTest : public QObject std::unique_ptr m_fixtureRecipes; private slots: + void outputBytesAndFences(); + void outputPublishedPacket(); + void outputRenderFormats_data(); + void outputRenderFormats(); + void outputOverprintWarning(); + void outputRenderFailure(); void init(); void cleanup(); void encryptedOpen_data(); @@ -1964,6 +1975,14 @@ void EditorHostTest::fixJourneyPublishesOnlyAnApprovedPlanBoundToTheDisplayedIde QVERIFY2(host.fixLifecycleSummary().contains(QStringLiteral("published")), qPrintable(host.fixLifecycleSummary())); + const QString exportPath = directory.filePath(QStringLiteral("accepted-correction.pdf")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(exportPath), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY2(host.outputRecord().value(QStringLiteral("status")).toString() == QLatin1String("saved"), qPrintable(host.outputRecord().value(QStringLiteral("error")).toString())); + QCOMPARE(QString::fromLatin1(fileDigest(exportPath)), publishedSha256); + QCOMPARE(host.outputRecord().value(QStringLiteral("publicationReceipt")).toMap().value(QStringLiteral("published_sha256")).toString(), publishedSha256); + + // The as-received input is never the publication target. QCOMPARE(fileDigest(documentPath), sourceDigest); const QString publishedPath = host.governedArtifactPath(); QVERIFY(publishedPath != documentPath); @@ -2846,6 +2865,201 @@ void EditorHostTest::executeApprovedActionListPlanRefusesAnUnreviewedPlan() QVERIFY(host.executeApprovedActionListPlan()); } +void EditorHostTest::outputBytesAndFences() +{ + QTemporaryDir directory; + const QString source = directory.filePath(QStringLiteral("source.pdf")); + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 72, 144)); + const auto document = builder.build(); + QVERIFY(pdf::PDFDocumentWriter(nullptr).write(source, &document, true)); + QFile input(source); + QVERIFY(input.open(QIODevice::ReadOnly)); + const auto bytes = input.readAll(); + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(source)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + const QString destination = directory.filePath(QStringLiteral("export.pdf")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("pdf"), 300, QStringLiteral("stale"), true)); + QVERIFY(!QFile::exists(destination)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/stale-or-busy-document")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QCOMPARE(host.outputRecord().value(QStringLiteral("status")).toString(), QStringLiteral("saved")); + QFile exported(destination); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); + exported.close(); + QCOMPARE(host.outputRecord().value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString(), + QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex())); + QVERIFY(!host.outputRecord().value(QStringLiteral("printProof")).toBool()); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(source), QStringLiteral("pdf"), 300, host.outputRevision(), true)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/invalid-or-source-destination")); + + const QVariantMap parameters{ { QStringLiteral("path"), destination }, { QStringLiteral("format"), QStringLiteral("print-pdf") }, { QStringLiteral("dpi"), 72 }, { QStringLiteral("revision"), host.outputRevision() }, { QStringLiteral("fidelityAcknowledged"), true } }; + const auto invocation = host.invokeCommand(QStringLiteral("actionPrint"), parameters); + QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); + QVERIFY(host.cancelCommand(invocation)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); + exported.close(); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("print-pdf"), 72, host.outputRevision(), true)); + host.invokeCommand(QStringLiteral("actionClose")); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 10000); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), bytes); +} + +void EditorHostTest::outputPublishedPacket() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 72, 144)); + loopeditor::OutputRequest request; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); + request.revision = QStringLiteral("accepted-revision"); + request.publishedBytes = QByteArray("%PDF-1.7\nexact published packet\n%%EOF"); + const QString digest = QString::fromLatin1(QCryptographicHash::hash(request.publishedBytes, QCryptographicHash::Sha256).toHex()); + request.publicationReceipt = { { QStringLiteral("status"), QStringLiteral("published") }, + { QStringLiteral("published_sha256"), digest }, + { QStringLiteral("plan_digest"), QStringLiteral("reviewed-plan") } }; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::OutputResult result; + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.isEmpty(), qPrintable(result.error)); + QFile exported(result.stagedPath); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), request.publishedBytes); + QCOMPARE(result.record.value(QStringLiteral("publicationReceipt")).toObject(), request.publicationReceipt); + request.publicationReceipt.insert(QStringLiteral("published_sha256"), QString(64, QLatin1Char('0'))); + loopeditor::OutputResult refused; + loopeditor::prepareOutput(request, refused, job); + QCOMPARE(refused.error, QStringLiteral("output/publication-identity-mismatch")); + QVERIFY(!QFile::exists(refused.stagedPath)); +} + +void EditorHostTest::outputRenderFormats_data() +{ + QTest::addColumn("format"); + QTest::newRow("print-to-PDF") << QStringLiteral("print-pdf"); + QTest::newRow("PNG") << QStringLiteral("png"); + QTest::newRow("TIFF") << QStringLiteral("tiff"); +} + +void EditorHostTest::outputRenderFormats() +{ + QFETCH(QString, format); + if (format == QLatin1String("tiff") && !QImageWriter::supportedImageFormats().contains(QByteArray("tiff"))) + QSKIP("Qt TIFF encoder is unavailable; the product refuses TIFF output explicitly."); + QTemporaryDir directory; + const QString source = directory.filePath(QStringLiteral("source.pdf")); + pdf::PDFDocumentBuilder builder; + const auto firstPage = builder.appendPage(QRectF(0, 0, 72, 144)); + builder.appendPage(QRectF(0, 0, 144, 72)); + pdf::PDFPageContentStreamBuilder contents(&builder); + auto* painter = contents.begin(firstPage); + QVERIFY(painter); + painter->fillRect(QRectF(0, 0, 36, 144), Qt::black); + contents.end(painter); + const auto document = builder.build(); + QVERIFY(pdf::PDFDocumentWriter(nullptr).write(source, &document, true)); + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(source)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + const QString destination = directory.filePath(QStringLiteral("output")); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + host.goToPage(0); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), format, 72, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 20000); + const auto record = host.outputRecord(); + QVERIFY2(record.value(QStringLiteral("status")).toString() == QLatin1String("saved"), qPrintable(record.value(QStringLiteral("error")).toString())); + QCOMPARE(record.value(QStringLiteral("dpi")).toInt(), 72); + QCOMPARE(record.value(QStringLiteral("renderer")).toString(), QStringLiteral("Loop Core PDFTransparencyRenderer")); + QVERIFY(!record.value(QStringLiteral("colorHandling")).toString().isEmpty()); + QVERIFY(!record.value(QStringLiteral("printProof")).toBool()); + if (format == QLatin1String("print-pdf")) + { + pdf::PDFDocumentReader reader(nullptr, {}, true, false); + const auto printed = reader.readFromFile(destination); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + QCOMPARE(printed.getCatalog()->getPageCount(), size_t(2)); + QCOMPARE(printed.getCatalog()->getPage(0)->getMediaBox().size(), QSizeF(72, 144)); + QCOMPARE(printed.getCatalog()->getPage(1)->getMediaBox().size(), QSizeF(144, 72)); + loopeditor::OutputRequest request; + request.format = loopeditor::OutputFormat::Png; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(printed)); + request.revision = QStringLiteral("printed-fixture"); + request.dpi = 72; + request.fidelityAcknowledged = true; + loopeditor::OutputResult result; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.isEmpty(), qPrintable(result.error)); + const auto image = QImageReader(result.stagedPath).read(); + QCOMPARE(image.pixelColor(18, 72), QColor(Qt::black)); + QCOMPARE(image.pixelColor(54, 72), QColor(Qt::white)); + } + else + { + QImageReader reader(destination); + const auto image = reader.read(); + QCOMPARE(image.size(), QSize(72, 144)); + QCOMPARE(image.pixelColor(18, 72), QColor(Qt::black)); + QCOMPARE(image.pixelColor(54, 72), QColor(Qt::white)); + QVERIFY(qAbs(image.dotsPerMeterX() - qRound(72 / 0.0254)) <= 1); + } + QFile artifact(destination); + QVERIFY(artifact.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(artifact.readAll(), QCryptographicHash::Sha256).toHex()), + record.value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString()); + qInfo().noquote() << "quick-output-fixture" << format << "source_sha256=" + << QString::fromLatin1(host.sessionForTest()->context().getDocumentPointer()->getSourceDataHash().toHex()) + << "artifact_sha256=" << record.value(QStringLiteral("artifact")).toMap().value(QStringLiteral("sha256")).toString(); +} + +void EditorHostTest::outputOverprintWarning() +{ + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(preflightFixturesDir() + QStringLiteral("/overprint-cmyk-mode0-on.pdf"))); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 10000); + host.goToPage(0); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_VERIFY_WITH_TIMEOUT(!host.pageFidelityIsExact(), 10000); + QTemporaryDir directory; + const QString destination = directory.filePath(QStringLiteral("output.png")); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("png"), 72, host.outputRevision(), false)); + QCOMPARE(host.outputRecord().value(QStringLiteral("error")).toString(), QStringLiteral("output/fidelity-acknowledgment-required")); + QVERIFY(!QFile::exists(destination)); + QVERIFY(host.exportDocumentFileUrl(QUrl::fromLocalFile(destination), QStringLiteral("png"), 72, host.outputRevision(), true)); + QTRY_VERIFY_WITH_TIMEOUT(!host.outputBusy(), 20000); + QCOMPARE(host.outputRecord().value(QStringLiteral("status")).toString(), QStringLiteral("saved")); + QVERIFY(host.outputRecord().value(QStringLiteral("fidelityAcknowledged")).toBool()); + QVERIFY(!host.outputRecord().value(QStringLiteral("printProof")).toBool()); +} + +void EditorHostTest::outputRenderFailure() +{ + pdf::PDFDocumentBuilder builder; + const auto page = builder.appendPage(QRectF(0, 0, 72, 144)); + const auto stream = builder.addObject(pdf::PDFObject::createStream( + std::make_shared(pdf::PDFDictionary(), QByteArray("unknownOperator")))); + pdf::PDFDictionary dictionary; + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(stream)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(dictionary)))); + loopeditor::OutputRequest request; + request.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); + request.format = loopeditor::OutputFormat::Png; + request.revision = QStringLiteral("broken-content-fixture"); + request.dpi = 72; + request.fidelityAcknowledged = true; + loopeditor::OutputResult result; + pdf::PDFJobContext job(std::make_shared(), pdf::PDFProcessingLimits::conservativeDefaults(), {}); + loopeditor::prepareOutput(request, result, job); + QVERIFY2(result.error.startsWith(QLatin1String("output/render-failed")), qPrintable(result.error)); + QVERIFY(!QFile::exists(result.stagedPath)); + QVERIFY(!result.record.contains(QStringLiteral("artifact"))); +} + QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" diff --git a/agent-policy.json b/agent-policy.json index 67c60b34..6a950133 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -218,6 +218,8 @@ "LoopLibQuick/**", "LoopEditor/editorhost.cpp", "LoopEditor/editorhost.h", + "LoopEditor/quickoutput.cpp", + "LoopEditor/quickoutput.h", "LoopEditor/quickdocumentmodel.cpp", "LoopEditor/quickdocumentmodel.h", "UnitTests/tst_quickdocumentmodeltest.cpp", diff --git a/changes/feat-issue-123-quick-print-export.evidence.yaml b/changes/feat-issue-123-quick-print-export.evidence.yaml new file mode 100644 index 00000000..fcfefd87 --- /dev/null +++ b/changes/feat-issue-123-quick-print-export.evidence.yaml @@ -0,0 +1,18 @@ +format_version: 1 +kind: evidence +claims: + - id: quick-print-to-pdf-and-export + evidence: + - unit:agent-policy:quick + - unit:agent-policy:interaction + - unit:agent-policy:build_policy + - unit:agent-policy:schemas_contracts + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py + - unit:UnitTests/tst_editorhosttest.cpp + - integration:tools/ProductQuickAccessibilitySmoke/main.cpp + - architecture:docs/QUICK_PRINT_EXPORT.md +unresolved: + - clean-machine-windows-print-to-file + - clean-machine-linux-print-to-file diff --git a/changes/feat-issue-123-quick-print-export.md b/changes/feat-issue-123-quick-print-export.md new file mode 100644 index 00000000..36f4dee9 --- /dev/null +++ b/changes/feat-issue-123-quick-print-export.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators +Breaking-Change: no +Summary: Add authoritative print-to-PDF and PDF/PNG/TIFF export from Quick, with revision fencing, fidelity acknowledgment, artifact identity, and the correction publication receipt. diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index c575b355..67135a3d 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -43,7 +43,7 @@ workspace rail (7 IDs) | workspace stack | Document: pages rail | PDF canvas | c status: document state · production state · preflight state · page/zoom ``` -Manifest-driven menus group the 108 catalog actions by shell menu and +Manifest-driven menus group the 109 catalog actions by shell menu and workspace `target`, with `ADVANCED` actions gated behind the release-profile developer diagnostics flag. Compare is enabled: it presents the before/after artifact identities, technical finding delta, preserved attributes and diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md index 6ca9f1c3..2c766bbb 100644 --- a/docs/QUICK_PDF_PARITY.md +++ b/docs/QUICK_PDF_PARITY.md @@ -29,10 +29,14 @@ slice is intentionally model-driven: The following remain deliberately declared or policy-excluded until their typed bridge and revision-fenced tests land: annotation/form overlays and -editing, attachments and metadata editing, print/export, encryption +editing, attachments and metadata editing, native printer spooling, encryption creation or changes, sanitization, optimization, signature verification, OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. +Print-to-PDF and PDF/PNG/TIFF exports use the revision-fenced Core output bridge +described in [Quick print and export](QUICK_PRINT_EXPORT.md). Corrected PDF +export copies the exact publication bytes and retains their Core receipt. + Search currently runs through the Core model on the host thread. It is a functional read-only bridge, but its next hardening step is to submit the same snapshot computation through `PDFJobScheduler` and admit the value on the diff --git a/docs/QUICK_PRINT_EXPORT.md b/docs/QUICK_PRINT_EXPORT.md new file mode 100644 index 00000000..4db809d2 --- /dev/null +++ b/docs/QUICK_PRINT_EXPORT.md @@ -0,0 +1,49 @@ +# Quick print and export + +Issue [#123](https://github.com/studio-berry/loop/issues/123) adds File → Export +document and File → Print to PDF. The approved scope for this PR is +print-to-PDF and PDF/PNG/TIFF exports. Native printer spooling remains outside +this slice; the product continues to exclude Qt PrintSupport and Widgets. + +PDF export copies bytes without rendering or reserializing. For an accepted +correction, the Editor retains the exact bytes passed through Core's governed +publication gateway, together with its existing mutation receipt. Export checks +the receipt's published digest and binds the packet to the accepted revision. +An unmodified input is copied only if its on-disk digest still matches the +loaded document. An unpublished modified revision is refused. + +Print to PDF renders every page through Core's `PDFTransparencyRenderer` and +`PDFRenderPolicy::forOutputPreview()`, then paints those new renders into a +QtGui PDF writer. PNG and TIFF render the current page through the same +compositor. These operations never read canvas pixels. They use Print or Export +optional-content usage respectively, preserve rotated media-page dimensions, +and offer 72–600 dpi. Restricted low-resolution printing is capped at 150 dpi. +The output is a color-managed RGB composite with separation simulation and +white paper; it is not a separations file. Annotation appearances are an +explicit fidelity limitation and require acknowledgment. + +The dialog shows the current page's fidelity summary. An active warning, or a +limitation discovered during output rendering, requires explicit acknowledgment. +The result records renderer, resolution, color handling, page range, artifact +SHA-256, document revision, and any correction receipt. Output never certifies +print safety. A missing TIFF encoder is an explicit failure. + +The scheduler prepares output in a private temporary directory. The owner +checks request identity, cancellation, lifecycle, and revision before Core's +atomic writer commits the selected destination. The writer verifies the staged +digest while copying. Cancelled, stale, failed, or refused requests preserve +an existing destination, and exporting over the input is refused. + +`UnitTestsEditorHost` covers exact input and publication-byte copying, receipt +identity mismatch, stale requests, cancellation/replacement, output geometry, +raster dimensions and resolution, and overprint acknowledgment. Its existing +governed correction journey also exports the accepted artifact and verifies its +digest against the Core sign-off and mutation receipt. + +Clean-machine print-to-file qualification on Windows and Linux is still a +separate acceptance lane. Record exact source SHA, fixture and artifact identity, +platform/toolchain, and hosted run before claiming that lane complete. + +The final diff review retains Core's existing renderer, safe writer, receipt, +and command catalog. It introduces no renderer, publication authority, stored +receipt schema, dependency, or protected build-contract change. diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index 69cdc39f..fa297929 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -2,7 +2,7 @@ "schema_version": 1, "issue": 193, "source_ui": "LoopEditor Quick shell (Widgets pdfeditormainwindow.ui deleted Issue 17)", - "expected_action_count": 108, + "expected_action_count": 109, "actions": [ { "id": "actionAbout", @@ -1041,10 +1041,36 @@ "shortcut": { "standard_key": "Print" }, - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "dpi", + "type": "integer", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + }, + { + "name": "fidelityAcknowledged", + "type": "boolean", + "required": false + } + ], + "capability": "document.read", + "cancellable": true, + "availability": "implemented" }, "menu_group": "File" }, @@ -1563,6 +1589,45 @@ "availability": "implemented" }, "menu_group": "View" + }, + { + "id": "actionExportDocument", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionExportDocument.label", + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "format", + "type": "string", + "required": false + }, + { + "name": "dpi", + "type": "integer", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + }, + { + "name": "fidelityAcknowledged", + "type": "boolean", + "required": false + } + ], + "capability": "document.read", + "cancellable": true, + "availability": "implemented" + }, + "menu_group": "File" } ] } diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index 0cb35a4f..61768395 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -69,6 +69,8 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionPrint", + "actionExportDocument", "actionUndo", "actionRedo", } diff --git a/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt b/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt index 9c22c583..7f0788ac 100644 --- a/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt +++ b/tools/ProductQuickAccessibilitySmoke/CMakeLists.txt @@ -6,6 +6,8 @@ qt_add_executable(ProductQuickAccessibilitySmoke ../../LoopEditor/operatorhelp.h ../../LoopEditor/app.qrc ../../LoopEditor/editorhost.cpp + ../../LoopEditor/quickoutput.cpp + ../../LoopEditor/quickoutput.h ../../LoopEditor/editorhost.h ../../LoopEditor/focusrestoration.cpp ../../LoopEditor/focusrestoration.h diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 543daa6e..24b86dc2 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -174,6 +174,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") diff --git a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml index 9f84e936..6af44814 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml @@ -78,6 +78,8 @@ QtObject { } function labelForEntry(entry) { + if (entry && entry.id === "actionPrint") return qsTr("Print to PDF…") + if (entry && entry.id === "actionExportDocument") return qsTr("Export document…") if (!entry || !entry.labelKey) { return entry && entry.id ? entry.id : "" }