From 4756a1beb3a30b85cc132cea1867f12551618241 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:41:00 -0700 Subject: [PATCH 01/11] docs(adr): decide direct revision for interactive authoring edits Records the #234 precondition: annotation, bookmark, link, form, attachment and metadata edits are direct Core mutations through CommandCatalog producing a new revision (fenced, one history event per edit), not planned Fix operations; adds the 013 row missing from the ADR index. --- docs/adr/README.md | 2 + ...adr-014-direct-revision-authoring-edits.md | 166 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/adr/adr-014-direct-revision-authoring-edits.md diff --git a/docs/adr/README.md b/docs/adr/README.md index be4b6754..d948e45f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,6 +61,8 @@ python3 scripts/generate-architecture-catalogs.py --check | [010](adr-010-quick-root-admission.md) | accepted | Quick root admission contracts | | [011](adr-011-architecture-contracts-d1-d5.md) | accepted | 0.3.0 architecture contracts D1–D5 | | [012](adr-012-drag-commit-command.md) | accepted | Drag-commit move command for the interaction hand-off | +| [013](adr-013-default-page-box-recipe.md) | accepted | Default recipe for the page-box drag move | +| [014](adr-014-direct-revision-authoring-edits.md) | accepted | Direct revision for interactive authoring edits (annotations, bookmarks, links, forms, attachments, metadata) | Implementation tracking for ADR-005 lives separately in [`ADR-005-product-surface-pruning-implementation-plan.md`](../ADR-005-product-surface-pruning-implementation-plan.md). diff --git a/docs/adr/adr-014-direct-revision-authoring-edits.md b/docs/adr/adr-014-direct-revision-authoring-edits.md new file mode 100644 index 00000000..ffb30cfa --- /dev/null +++ b/docs/adr/adr-014-direct-revision-authoring-edits.md @@ -0,0 +1,166 @@ +# ADR-014: Direct revision for interactive authoring edits + +**Status:** accepted +**Implemented-at:** issue #231 PR 2 (`gh-231-quick-mutations`: #233, #234, #242) +**Last-verified:** 2026-10-11 @ 4e58124c357a85a61b76376fa6fdc038ab42ca1f +**Superseded-by:** none +**Date:** 2026-10-11 +**Deciders:** Loop owner (epic #231); interaction and Core boundary owners + +## Context + +The epic's second PR group (#233 bookmarks and links, #234 annotation authoring, +#242 forms, attachments, and metadata) turns the Quick shell from a read-only +surface into one that edits the document. #234 makes the first question a +prerequisite: *"Decide first how an annotation edit relates to governed +operations (direct revision vs. planned operation) and record it, as ADR-012 did +for drag commit."* This ADR answers that question for the whole authoring +family, because all three issues demand the same property — *"document +mutations routed through `CommandCatalog` into Core and produce a new revision"* +(#233, #242; #234 says "created through `CommandCatalog` into Core"). + +The product has exactly one mutation path today, and it is the governed one. +`docs/INTERACTION_CONTRACT.md:33-34` states it: + +> Commit is not this layer's. A completed drag is emitted as a `DragSession`; the +> owner routes it through P4-S2's `CommandCatalog`, which stays the only mutation +> path. + +ADR-012 resolved the *geometry* case by routing the drag commit through the Fix +workspace: the command binds parameters for a recipe that offers +`translate-page-box`, and *"Plan, approval and execute stay the Fix workspace's, +so the command catalog is still the only route in and the Fix workspace is still +the only place a document changes."* That answer fits a production fix — a page +box is document truth whose change belongs to a reviewed plan. It does not fit +authoring: an operator drawing a highlight, adding a bookmark, or typing into a +form field is composing content, not proposing a repair, and a plan/approve +round trip per edit would make the Fix workspace the authoring surface. + +What exists to build on: + +- **Command surface.** `docs/loop-shell-actions.json` declares 108 actions + (`expected_action_count: 108`). 34 of the commands these three issues name are + already declared but unbound — 16 bookmark/link ids (#233), 18 annotation ids + (#234), and `actionSend_by_E_Mail` (#242) — and they are recorded in the + handler-coverage ratchet (`docs/shell-handler-coverage.json`) that PR 1 + landed. The form, attachment, and metadata families have **no command ids at + all**; only `actionProperties` and `actionSend_by_E_Mail` exist in those + neighbourhoods. #242's command surface therefore has to be created, not just + bound. +- **Core writers.** `LoopLibCore/sources/pdfannotation.{h,cpp}` carries the + annotation type model (`AnnotationType`, `PDFAnnotationBorder`, + `PDFAppeareanceStreams`, `PDFAnnotationQuadrilaterals`, icon-fit and callout + types); `pdfoutline.{h,cpp}` models outline items with mutation primitives + (`PDFOutlineItem::addChild`, `insertChild`); `pdfform.{h,cpp}` models form + fields (`PDFFormField`, `FieldType`, flags) with the + `PDFDocumentModifier`/`PDFModifiedDocument` machinery beside it; + `pdfsecurityhandler.{h,cpp}` exposes document security state. These are the + primitives a direct edit composes; none of them is wired to Quick today. +- **Revision and history contracts.** `docs/REVISION_CONTEXT.md` makes + `PDFDocumentContext` the revision authority and requires every consumer to + compare the complete `PDFRevisionIdentity`; invariant I07 pins *"Stale + revision results are discarded and are not current"*. + `docs/OPERATION_HISTORY.md` is the append-only history: *"Undo/redo remains an + in-memory interaction feature"* while *"Every accepted or rolled-back event + creates one `PDFRollbackPoint`"* — the model #234 names for undo (#124), with + permission refusal owned by #122 and review display by #125. + +## Candidates + +| Option | Shape | Admissible? | +| --- | --- | --- | +| A. Direct revision per edit | handler → `CommandCatalog` → a typed direct-edit request on `DocumentFacade` → Core writer primitive → one history event → new revision | **Recommended** | +| B. Planned operation per edit | every edit becomes a recipe/Fix plan the operator previews and approves | Technically, but rejected | +| C. QML-side model edits | Quick models the outline/annotations and writes them in QML | **No** — a second mutation path; #233's acceptance states the model is never edited in QML | + +**Recommendation: A.** Authoring edits are low-risk, interactive, and +reversible by construction through the append-only history — the exact profile +the governed Fix flow does not serve. Option B would put plan/approve in front +of every highlight and contradict the epic's own seam, which reserves governed +Fix and publication operations for PR 3. Option C is ruled out by the +interaction contract regardless of convenience. + +Under Option A every authoring edit still inherits the governed properties, +because they are properties of the *path*, not of the Fix flow: + +1. **One route.** The handler is a `CommandCatalog::Handler`; nothing else may + call a Core writer (`docs/INTERACTION_CONTRACT.md:33-34`). +2. **Fenced.** The edit carries the `PDFRevisionIdentity` it was begun against; + a mismatch fails closed with a visible reason before any mutation (I07; + #233's failure case is exactly this). +3. **Provenance.** The accepted edit appends one operation-history event and + produces one `PDFRollbackPoint` — undoable by construction under #124's + model; wiring `actionUndo`/`actionRedo` remains a separate decision, as + ADR-012 Q4 already ruled for the drag commit. +4. **Refused, not repaired.** A permission-restricted document + (`pdfsecurityhandler`: modify-annotations / fill-forms not granted) or an + XFA-only form (#242) returns a typed refusal instead of guessing or + half-writing. + +## Exact deltas the recommendation requires + +1. **Command ids for #242** (the only new ones; #233's 16 and #234's 18 are + already declared): one id per mutation family — + `actionSetFormFieldValue`, `actionAddAttachment`, `actionRemoveAttachment`, + `actionEditDocumentMetadata` — plus the existing `actionSend_by_E_Mail`. + Each: `disposition: KEEP`, scalar parameters only (the protected schema + admits `string`/`integer`/`number`/`boolean`, + `docs/schemas/loop-shell-actions.schema.json`), `capability: document.modify`, + `availability: implemented` when its handler lands, and + `expected_action_count` bumped accordingly (108 → 112). The exact names may + be adjusted by the slice only within this delta's shape; a different shape is + a new decision. +2. **A typed direct-edit request on `DocumentFacade`** (interaction layer): the + mutation-gateway shape every authoring handler submits to — the edit kind, + its scalar payload, and the originating `PDFRevisionIdentity`. Stale or + unauthorized requests return a typed refusal; the gateway never mutates + partially. +3. **Core writer primitives** (`LoopLibCore/sources/**`, protected): one per + edit family — annotation creation per subtype with its appearance stream, + outline item create/reorder/import/export, hyperlink creation per destination + variant, form value set, attachment add/remove, XMP metadata update. Each + follows the existing modification machinery (`PDFDocumentModifier` / + `PDFModifiedDocument`), saves as a new artifact, and appends the history + event and rollback point. +4. **Handler registration.** `EditorHost` registers the 35 existing ids plus the + 4 new ones; each maps its scalars into the direct-edit request, checks the + fence first, and surfaces typed refusals (stale revision, permission denied, + XFA-only form, unsupported subtype). +5. **Evidence lanes.** Per-subtype annotation round-trips with + appearance-stream render comparison; bookmark import/export round-trip on the + fixture set; per-destination-variant read-back; form round-trips (text, + checkbox, choice; signature field excluded; XFA refusal); attachment and XMP + metadata round-trips; stale-fence and permission refusals; quick and + accessibility lanes. + +## Cost and risk + +| Option | Cost | Risk | +| --- | --- | --- | +| A (recommended) | 4 new catalog ids + count bump, one facade gateway, six Core writer families, 39 handlers, the evidence lanes above | Medium — real document mutation; mitigated by the fence, typed refusals, and history-per-edit | +| B planned operation | recipe surface per edit family; Fix flow as the authoring UI | High product cost for worse authoring UX; blurs the PR2/PR3 seam | +| C QML-side edits | none | Blocked by the interaction contract; creates the second mutation path the product forbids | + +## Smallest implementation slice that would prove it + +One vertical per issue, each proven end to end before the next: + +1. **#233:** bookmark create/reorder/import/export plus one hyperlink variant + through catalog → facade → Core outline writer → read-back test; then the + remaining destination variants. +2. **#234:** highlight through the same path with an appearance-stream + comparison; then the remaining markup, shapes, inline text, and sticky-note + subtypes; refusal cases for stale revision and missing modify-annotations. +3. **#242:** form text fill through the new ids; then checkbox/choice, + attachments (extract through the atomic writer, refuse executable auto-open), + XMP metadata, XFA refusal, and the send-by-email hand-off of the published + revision. + +## Verification note + +This record was written on the PR 2 branch before its slices started; the +implementation PR's gate (`scripts/agent/check-change.py` plus the mapped +suites) is the evidence that proves the deltas above. No build or test lane was +run for the ADR itself. + +Refs #231, #233, #234, #242, #124, #122, #125, ADR-012 From a70620e5dcae623f6ef53f98be76c06ce3266d49 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:56:27 -0700 Subject: [PATCH 02/11] docs(adr-014): pin the counts to the real contract (109 descriptors, 113 after #242) --- docs/adr/adr-014-direct-revision-authoring-edits.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/adr-014-direct-revision-authoring-edits.md b/docs/adr/adr-014-direct-revision-authoring-edits.md index ffb30cfa..5151ed05 100644 --- a/docs/adr/adr-014-direct-revision-authoring-edits.md +++ b/docs/adr/adr-014-direct-revision-authoring-edits.md @@ -38,8 +38,8 @@ round trip per edit would make the Fix workspace the authoring surface. What exists to build on: -- **Command surface.** `docs/loop-shell-actions.json` declares 108 actions - (`expected_action_count: 108`). 34 of the commands these three issues name are +- **Command surface.** `docs/loop-shell-actions.json` declares 109 actions + (`expected_action_count: 109`). 34 of the commands these three issues name are already declared but unbound — 16 bookmark/link ids (#233), 18 annotation ids (#234), and `actionSend_by_E_Mail` (#242) — and they are recorded in the handler-coverage ratchet (`docs/shell-handler-coverage.json`) that PR 1 @@ -107,7 +107,7 @@ because they are properties of the *path*, not of the Fix flow: admits `string`/`integer`/`number`/`boolean`, `docs/schemas/loop-shell-actions.schema.json`), `capability: document.modify`, `availability: implemented` when its handler lands, and - `expected_action_count` bumped accordingly (108 → 112). The exact names may + `expected_action_count` bumped accordingly (109 → 113). The exact names may be adjusted by the slice only within this delta's shape; a different shape is a new decision. 2. **A typed direct-edit request on `DocumentFacade`** (interaction layer): the From 3ddfe40b2ad31211f6e303847da36d3269653de0 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:38:44 -0700 Subject: [PATCH 03/11] feat(quick): bookmark and hyperlink authoring through the direct-edit path (#233) --- LoopEditor/editorhost.cpp | 530 +++++++++++++ LoopEditor/editorhost.h | 45 ++ LoopLibCore/CMakeLists.txt | 2 + LoopLibCore/sources/pdfauthoringedit.cpp | 735 ++++++++++++++++++ LoopLibCore/sources/pdfauthoringedit.h | 148 ++++ LoopLibInteraction/sources/documentfacade.cpp | 69 +- LoopLibInteraction/sources/documentfacade.h | 34 + UnitTests/CMakeLists.txt | 19 + UnitTests/tst_authoringedittest.cpp | 585 ++++++++++++++ UnitTests/tst_documentfacadetest.cpp | 154 +++- UnitTests/tst_editorhosttest.cpp | 438 +++++++++++ UnitTests/tst_productoperatorloop.cpp | 32 + docs/loop-shell-actions.json | 716 +++++++++++++++-- docs/shell-handler-coverage.json | 16 - scripts/verify-command-catalog.py | 16 + 15 files changed, 3472 insertions(+), 67 deletions(-) create mode 100644 LoopLibCore/sources/pdfauthoringedit.cpp create mode 100644 LoopLibCore/sources/pdfauthoringedit.h create mode 100644 UnitTests/tst_authoringedittest.cpp diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index b42a1b31..7749975a 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -85,6 +85,7 @@ #include #include +#include #include #include #include @@ -99,6 +100,56 @@ const QString QuitCommandId = QStringLiteral("actionQuit"); const QString MoveSelectionCommandId = QStringLiteral("actionMoveSelection"); const QString TranslatePageBoxOperationId = QStringLiteral("translate-page-box"); +/// The destination type each `actionCreateHyperlinkToThisPDF*` command writes +/// (issue #233). `XYZInheritZoom` is the XYZ destination with a null zoom, +/// which asks the viewer to keep its own zoom. +struct HyperlinkDestinationVariant +{ + const char* command; + pdf::DestinationType type; + bool inheritZoom; +}; + +const HyperlinkDestinationVariant HyperlinkDestinationVariants[] = { + { "actionCreateHyperlinkToThisPDFFit", pdf::DestinationType::Fit, false }, + { "actionCreateHyperlinkToThisPDFFitB", pdf::DestinationType::FitB, false }, + { "actionCreateHyperlinkToThisPDFFitBH", pdf::DestinationType::FitBH, false }, + { "actionCreateHyperlinkToThisPDFFitBV", pdf::DestinationType::FitBV, false }, + { "actionCreateHyperlinkToThisPDFFitH", pdf::DestinationType::FitH, false }, + { "actionCreateHyperlinkToThisPDFFitR", pdf::DestinationType::FitR, false }, + { "actionCreateHyperlinkToThisPDFFitV", pdf::DestinationType::FitV, false }, + { "actionCreateHyperlinkToThisPDFXYZ", pdf::DestinationType::XYZ, false }, + { "actionCreateHyperlinkToThisPDFXYZInheritZoom", pdf::DestinationType::XYZ, true }, +}; + +/// Reads a page-space rectangle from four scalar parameters, or nothing when +/// the surface supplied none. A partial rectangle is refused: a corner the +/// surface never gave is not a corner the host may invent. +std::optional rectangleFromParameters(const QVariantMap& parameters, + const QString& leftKey, + const QString& bottomKey, + const QString& rightKey, + const QString& topKey, + QString& typedError) +{ + const bool any = parameters.contains(leftKey) || parameters.contains(bottomKey) || + parameters.contains(rightKey) || parameters.contains(topKey); + if (!any) + { + return std::nullopt; + } + + if (!parameters.contains(leftKey) || !parameters.contains(bottomKey) || + !parameters.contains(rightKey) || !parameters.contains(topKey)) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return std::nullopt; + } + + return QRectF(QPointF(parameters.value(leftKey).toDouble(), parameters.value(bottomKey).toDouble()), + QPointF(parameters.value(rightKey).toDouble(), parameters.value(topKey).toDouble())); +} + QString actionListBindingsHash(const QJsonObject& bindings) { return QString::fromLatin1(QCryptographicHash::hash(QJsonDocument(bindings).toJson(QJsonDocument::Compact), @@ -755,6 +806,30 @@ bool EditorHost::extractCurrentPageImagesFileUrl(const QUrl& directory) return true; } +bool EditorHost::importBookmarksFileUrl(const QUrl& url, const QString& revision) +{ + if (!url.isValid() || !url.isLocalFile()) + { + return false; + } + + return invokeCommand(QStringLiteral("actionBookmarkImport"), + { { QStringLiteral("path"), url.toLocalFile() }, + { QStringLiteral("revision"), revision } }) != pdfinteraction::InvalidCommandInvocation; +} + +bool EditorHost::exportBookmarksFileUrl(const QUrl& url, const QString& revision) +{ + if (!url.isValid() || !url.isLocalFile()) + { + return false; + } + + return invokeCommand(QStringLiteral("actionBookmarkExport"), + { { QStringLiteral("path"), url.toLocalFile() }, + { QStringLiteral("revision"), revision } }) != pdfinteraction::InvalidCommandInvocation; +} + QObject* EditorHost::preflight() { return &m_preflight; @@ -5100,6 +5175,234 @@ void EditorHost::registerFeatureHandlers() }; m_session->catalog().setHandler(command, std::move(navigation)); } + + // ---- Bookmarks and hyperlinks (issue #233, ADR-014) ------------------------------- + // Every edit is one typed direct-edit request on the document facade, which + // fences the revision and the document's modify permission before Core + // writes the outline or the link annotation. Nothing here and nothing in + // QML edits the outline model; it re-reads the revision the edit produced. + auto bindAuthoringEdit = [this](const QString& id, + std::function(const QVariantMap&, QString&)> build) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, build = std::move(build)](pdfinteraction::CommandInvocationId invocation, + const QVariantMap& parameters) + { + QString typedError; + const std::optional edit = build(parameters, typedError); + runAuthoringEdit(invocation, edit, typedError, parameters); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindAuthoringEdit(QStringLiteral("actionBookmarkPage"), + [this](const QVariantMap& parameters, QString& typedError) + { + const int page = currentPage(); + if (page < 0) + { + // The viewport has placed no page yet, so there is + // no page the operator is on. Refuse with that + // reason instead of blaming the document's page + // range, which is not what is wrong. + typedError = QStringLiteral("bookmark/no-current-page"); + return std::optional(); + } + + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + edit.text = parameters.value(QStringLiteral("title")).toString(); + edit.pageIndex = page; + return std::optional(edit); + }); + bindAuthoringEdit(QStringLiteral("actionBookmarkAutoGenerate"), + [](const QVariantMap&, QString&) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::GenerateBookmarks; + return std::optional(edit); + }); + bindAuthoringEdit(QStringLiteral("actionCreateHyperlink"), + [this](const QVariantMap& parameters, QString& typedError) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddUriLink; + edit.text = parameters.value(QStringLiteral("uri")).toString(); + if (edit.text.isEmpty()) + { + typedError = QStringLiteral("hyperlink/uri-required"); + return std::optional(); + } + if (!authoringLinkGeometry(parameters, &edit.pageIndex, &edit.rectangle, typedError)) + { + return std::optional(); + } + return std::optional(edit); + }); + + for (const HyperlinkDestinationVariant& variant : HyperlinkDestinationVariants) + { + bindAuthoringEdit(QString::fromLatin1(variant.command), + [this, variant](const QVariantMap& parameters, QString& typedError) + { + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddDestinationLink; + edit.destinationType = variant.type; + if (!authoringLinkGeometry(parameters, &edit.pageIndex, &edit.rectangle, typedError)) + { + return std::optional(); + } + + edit.targetPageIndex = + parameters.value(QStringLiteral("targetPage"), currentPage()).toInt(); + const std::optional target = + rectangleFromParameters(parameters, + QStringLiteral("targetLeft"), + QStringLiteral("targetBottom"), + QStringLiteral("targetRight"), + QStringLiteral("targetTop"), + typedError); + if (!typedError.isEmpty()) + { + return std::optional(); + } + if (target.has_value()) + { + edit.targetRectangle = *target; + } + + // XYZ without an explicit zoom uses the zoom on + // screen; the inherit-zoom variant writes the PDF + // null zoom instead, which keeps the viewer's own. + edit.zoom = variant.inheritZoom + ? std::numeric_limits::quiet_NaN() + : parameters.value(QStringLiteral("zoom"), m_session->viewport().zoom()).toDouble(); + return std::optional(edit); + }); + } + + // Import and export carry a file, so the file is read or written here and + // only the outline replacement is a document edit. Without a path the + // command asks the shell for one and answers through + // importBookmarksFileUrl()/exportBookmarksFileUrl(). + pdfinteraction::CommandCatalog::Handler importBookmarks; + importBookmarks.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString path = parameters.value(QStringLiteral("path")).toString(); + if (path.isEmpty()) + { + Q_EMIT bookmarkImportRequested(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + return; + } + + QString typedError; + std::optional file; + QFile source(path); + if (!source.open(QIODevice::ReadOnly)) + { + typedError = QStringLiteral("bookmark/file-unreadable"); + } + else + { + file = pdf::PDFBookmarkFile::fromJson(source.readAll(), typedError); + } + + std::optional edit; + if (file.has_value()) + { + pdf::PDFAuthoringEdit replacement; + replacement.kind = pdf::PDFAuthoringEdit::Kind::ReplaceBookmarks; + replacement.bookmarks = file->bookmarks; + edit = replacement; + } + runAuthoringEdit(invocation, edit, typedError, parameters); + }; + m_session->catalog().setHandler(QStringLiteral("actionBookmarkImport"), std::move(importBookmarks)); + + pdfinteraction::CommandCatalog::Handler exportBookmarks; + exportBookmarks.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const QString path = parameters.value(QStringLiteral("path")).toString(); + if (path.isEmpty()) + { + Q_EMIT bookmarkExportRequested(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + return; + } + + QString typedError; + pdf::PDFBookmarkFile file; + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + const QString displayed = parameters.value(QStringLiteral("revision")).toString(); + std::optional> records; + if (!document) + { + typedError = QStringLiteral("bookmark/document-not-ready"); + } + else if (!displayed.isEmpty() && displayed != m_session->facade().currentRevision().toString()) + { + // The file would describe a revision the document has left. + typedError = QStringLiteral("bookmark/stale-revision"); + } + else + { + records = pdf::PDFAuthoringEditWriter::readOutline(document.data(), typedError); + } + + if (records.has_value()) + { + file.bookmarks = *records; + const pdf::PDFOperationResult written = pdf::PDFSafeFileWriter::writeData( + path, file.toJson(), pdf::PDFSafeFileWriter::OverwritePolicy::Overwrite); + if (!written) + { + typedError = QStringLiteral("bookmark/export-failed"); + } + } + + const bool exported = typedError.isEmpty() && records.has_value(); + if (exported) + { + announceDocumentState(tr("Exported %n bookmark(s).", "", file.count())); + } + else + { + announceDocumentState(tr("The bookmarks could not be exported: %1").arg(typedError)); + } + m_session->catalog().finishInvocation(invocation, + exported ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + exported ? QString() : typedError); + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionBookmarkExport"), std::move(exportBookmarks)); + + for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) + { + pdfinteraction::CommandCatalog::Handler navigation; + navigation.invoke = [this, id](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + QString typedError; + const bool moved = goToAdjacentBookmark(id == QLatin1String("actionGotoNextBookmark") ? 1 : -1, + typedError); + if (moved) + { + announceDocumentState(tr("Moved to bookmark on page %1.").arg(currentPage() + 1)); + } + else + { + announceDocumentState(tr("No bookmark to move to: %1").arg(typedError)); + } + m_session->catalog().finishInvocation(invocation, + moved ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + moved ? QString() : typedError); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(navigation)); + } + refreshFeatureAvailability(); } @@ -5159,9 +5462,226 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionPrint"), ready && !outputBusy() && m_documentModel.canPrint()); availability.insert(QStringLiteral("actionExportDocument"), ready && !outputBusy() && m_documentModel.canCopy()); + // Bookmarks and hyperlinks (#233). The edits are document mutations, so they + // follow the document's modify permission; navigation needs an outline to + // move through, and export follows the same copy permission the document + // export uses. + const bool canModify = ready && m_session->facade().permissions().modify; + for (const QString& id : { QStringLiteral("actionBookmarkPage"), + QStringLiteral("actionBookmarkAutoGenerate"), + QStringLiteral("actionBookmarkImport"), + QStringLiteral("actionCreateHyperlink"), + QStringLiteral("actionCreateHyperlinkToThisPDFFit"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitB"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBV"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitR"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitV"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZ"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZInheritZoom") }) + { + availability.insert(id, canModify); + } + availability.insert(QStringLiteral("actionBookmarkExport"), ready && m_documentModel.canCopy()); + for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) + { + availability.insert(id, ready && m_documentModel.hasOutline()); + } + m_session->catalog().setEnabledBatch(availability); } +void EditorHost::runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters) +{ + QString typedError = buildError; + pdfinteraction::DocumentEditOutcome outcome; + if (edit.has_value()) + { + outcome = submitAuthoringEdit(*edit, parameters, typedError); + } + + const bool applied = edit.has_value() && outcome.applied; + if (applied) + { + announceDocumentState(authoringEditAnnouncement(*edit, outcome)); + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(typedError)); + } + + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : typedError); + bumpPresentation(); + refreshFeatureAvailability(); +} + +pdfinteraction::DocumentEditOutcome EditorHost::submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError) +{ + const pdf::PDFRevisionIdentity current = m_session->facade().currentRevision(); + const QString displayed = parameters.value(QStringLiteral("revision")).toString(); + if (!displayed.isEmpty() && displayed != current.toString()) + { + // The surface acted on a revision the document has left; the edit is + // refused rather than rebased onto one the operator never saw. + typedError = QStringLiteral("edit/stale-revision"); + return {}; + } + + pdfinteraction::DocumentEditRequest request; + request.edit = edit; + request.revision = current; + const pdfinteraction::DocumentEditOutcome outcome = m_session->facade().applyAuthoringEdit(request); + typedError = outcome.typedError; + return outcome; +} + +bool EditorHost::authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const +{ + const std::optional explicitRectangle = rectangleFromParameters(parameters, + QStringLiteral("left"), + QStringLiteral("bottom"), + QStringLiteral("right"), + QStringLiteral("top"), + typedError); + if (!typedError.isEmpty()) + { + return false; + } + + const QVariant page = parameters.value(QStringLiteral("page")); + if (explicitRectangle.has_value() || page.isValid()) + { + if (!explicitRectangle.has_value() || !page.isValid()) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return false; + } + + *pageIndex = page.toInt(); + *rectangle = *explicitRectangle; + return true; + } + + // No explicit geometry: the current text selection is the shell's own + // "this region" gesture, already fenced to the revision it was taken from. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const std::optional& selection = interaction->state().textSelection(); + if (selection.has_value() && selection->isValid() && selection->available && !selection->geometry.isEmpty()) + { + QRectF bounds; + for (const QRectF& span : selection->geometry) + { + bounds = bounds.united(span); + } + + if (bounds.isValid() && !bounds.isEmpty()) + { + *pageIndex = selection->pageIndex; + *rectangle = bounds; + return true; + } + } + } + + typedError = QStringLiteral("hyperlink/link-rectangle-required"); + return false; +} + +bool EditorHost::goToAdjacentBookmark(int direction, QString& typedError) +{ + if (!hasDocument()) + { + typedError = QStringLiteral("bookmark/document-not-ready"); + return false; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + const QList pages = pdf::PDFAuthoringEditWriter::bookmarkPages(document.data()); + if (pages.isEmpty()) + { + typedError = QStringLiteral("bookmark/none"); + return false; + } + + // Outline order, not page order: the first bookmark after (or before) the + // current page, wrapping at the ends like the shell's historical bookmark + // cursor. The page the operator is already on is never its own destination. + const int current = currentPage(); + int target = -1; + if (direction > 0) + { + for (const int page : pages) + { + if (page > current) + { + target = page; + break; + } + } + if (target < 0) + { + target = pages.constFirst(); + } + } + else + { + for (auto it = pages.crbegin(); it != pages.crend(); ++it) + { + if (*it < current) + { + target = *it; + break; + } + } + if (target < 0) + { + target = pages.constLast(); + } + } + + goToPage(target); + return true; +} + +QString EditorHost::authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DocumentEditOutcome& outcome) const +{ + switch (outcome.resolution) + { + case pdf::PDFAuthoringEditResult::Kind::BookmarkAdded: + return tr("Bookmark added for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarkRemoved: + return tr("Bookmark removed for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksGenerated: + return tr("Generated %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksReplaced: + return tr("Imported %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::LinkCreated: + return tr("Hyperlink created."); + + case pdf::PDFAuthoringEditResult::Kind::Refused: + break; + } + + return QString(); +} + void EditorHost::moveSearch(int direction) { const int count = m_documentModel.searchResults()->rowCount(); @@ -5979,6 +6499,16 @@ void EditorHost::updateCanvasAccessibilitySummary() } } + // The outline is the one document structure the canvas can announce (issue + // #233): its count is read from the same Core projection bookmark + // navigation moves through. + const QList bookmarkPages = + pdf::PDFAuthoringEditWriter::bookmarkPages(m_session->context().getDocumentPointer().data()); + if (!bookmarkPages.isEmpty()) + { + summary += QLatin1Char(' ') + tr("%n bookmark(s).", "", bookmarkPages.size()); + } + m_canvas->setAccessibleDocumentSummary(summary); } diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 8372d0b2..b20b88c6 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -56,6 +56,7 @@ #include "pdfjobscheduler.h" #include "pdfoperationhistory.h" #include "pdfsignaturehandler.h" +#include "pdfauthoringedit.h" #include #include @@ -617,6 +618,18 @@ class EditorHost final : public QObject /// in-flight drag with ToolChanged (#141 AC3). Q_INVOKABLE bool setActiveTool(const QString& toolId); + // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- + /// Imports a bookmark interchange file and replaces the document outline + /// with its records. `revision` is the revision the operator saw when the + /// file dialog opened; an import that no longer matches it is refused with + /// a typed reason instead of landing on a revision the operator never saw. + Q_INVOKABLE bool importBookmarksFileUrl(const QUrl& url, const QString& revision); + + /// Exports the document outline to the bookmark interchange file. The + /// exported bytes describe `revision`; a stale `revision` is refused rather + /// than exported as if it described the current document. + Q_INVOKABLE bool exportBookmarksFileUrl(const QUrl& url, const QString& revision); + // ---- Select, copy and capture (issue #232) ------------------------------------- /// Copies the canvas text selection to the clipboard. The clipboard is only /// written when there is text to copy; a page with no text layer reports @@ -699,6 +712,12 @@ class EditorHost final : public QObject void settingsResetRequested(); void diagnosticsCollectionRequested(); + /// The bookmark import/export commands need a file, so the shell asks for + /// one and answers with importBookmarksFileUrl()/exportBookmarksFileUrl(). + /// The commands themselves write nothing. + void bookmarkImportRequested(); + void bookmarkExportRequested(); + private: void connectFacade(); void connectViewport(); @@ -710,6 +729,32 @@ class EditorHost final : public QObject void refreshFeatureAvailability(); bool commandPermissionAllowed(const QString& commandId) const; bool requestMoveSelection(const QVariantMap& parameters); + + // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- + /// Runs one authoring command: submits the built edit through the facade + /// gateway and finishes the invocation with the outcome, announcing what + /// happened. `buildError` is the command's typed refusal when no edit could + /// be built. + void runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters); + /// The fence and the facade call behind every authoring command. `typedError` + /// is set on refusal. + pdfinteraction::DocumentEditOutcome submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError); + /// The link rectangle and page a hyperlink command addresses: explicit + /// scalars when the surface supplied them, otherwise the current text + /// selection, which is the Quick shell's own "this region" gesture. + bool authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const; + /// Moves to the next (1) or previous (-1) bookmark page in outline order, + /// wrapping at the ends like the shell's historical bookmark cursor. + bool goToAdjacentBookmark(int direction, QString& typedError); + /// The operator-facing sentence for one applied edit. + QString authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DocumentEditOutcome& outcome) const; void moveSearch(int direction); bool moveFindingSelection(int direction); void refreshHitTestSources(); diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index 6a5d4499..e536c686 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -166,6 +166,8 @@ add_library(LoopLibCore SHARED sources/pdfoptimizer.h sources/pdfoptionalcontent.cpp sources/pdfoptionalcontent.h + sources/pdfauthoringedit.cpp + sources/pdfauthoringedit.h sources/pdfoutline.cpp sources/pdfoutline.h sources/pdfpagenavigation.cpp diff --git a/LoopLibCore/sources/pdfauthoringedit.cpp b/LoopLibCore/sources/pdfauthoringedit.cpp new file mode 100644 index 00000000..b63f561f --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringedit.cpp @@ -0,0 +1,735 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfauthoringedit.h" + +#include "pdfannotation.h" +#include "pdfartifactidentity.h" +#include "pdfcatalog.h" +#include "pdfdocumentbuilder.h" +#include "pdfoutline.h" +#include "pdfpage.h" + +#include +#include +#include +#include +#include + +#include + +namespace pdf +{ + +namespace +{ + +constexpr int BookmarkFileSchemaVersion = 1; + +void refusedEdit(const QString& typedError, PDFAuthoringEditResult* result) +{ + result->kind = PDFAuthoringEditResult::Kind::Refused; + result->typedError = typedError; + result->document.clear(); +} + +/// The document's outline as interchange records; `nullopt` when an item has no +/// page target to carry. Defined beside `readRecord` below. +std::optional> outlineRecords(const PDFDocument* document, QString& typedError); + +QString pageTitle(int pageIndex) +{ + // 1-based, matching the shell's own page labels. + return QStringLiteral("Page %1").arg(pageIndex + 1); +} + +bool isPageIndexInRange(const PDFCatalog* catalog, int pageIndex) +{ + return pageIndex >= 0 && static_cast(pageIndex) < catalog->getPageCount(); +} + +PDFObjectReference pageReference(const PDFCatalog* catalog, int pageIndex) +{ + return catalog->getPage(static_cast(pageIndex))->getPageReference(); +} + +/// Resolves a destination's page. A destination may name the page by reference +/// or by index; a reference that resolves to no page is not a page target. +bool resolveDestinationPage(const PDFCatalog* catalog, const PDFDestination& destination, int* pageIndex) +{ + if (destination.getPageReference().isValid()) + { + const size_t resolved = catalog->getPageIndexFromPageReference(destination.getPageReference()); + if (resolved == PDFCatalog::INVALID_PAGE_INDEX) + { + return false; + } + + *pageIndex = static_cast(resolved); + return true; + } + + const PDFInteger index = destination.getPageIndex(); + if (!isPageIndexInRange(catalog, static_cast(index))) + { + return false; + } + + *pageIndex = static_cast(index); + return true; +} + +bool isUsableRectangle(const QRectF& rectangle) +{ + return rectangle.isValid() && !rectangle.isEmpty() && std::isfinite(rectangle.left()) && + std::isfinite(rectangle.top()) && std::isfinite(rectangle.right()) && std::isfinite(rectangle.bottom()); +} + +QSharedPointer bookmarkItem(const PDFCatalog* catalog, const QString& title, int pageIndex) +{ + QSharedPointer item(new PDFOutlineItem()); + item->setTitle(title); + item->setAction(PDFActionPtr(new PDFActionGoTo(PDFDestination::createFit(pageReference(catalog, pageIndex)), + PDFDestination()))); + return item; +} + +QSharedPointer cloneOutlineRoot(const PDFDocument* document) +{ + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + return root ? root->clone() : QSharedPointer(new PDFOutlineItem()); +} + +void collectTargetedPages(const PDFCatalog* catalog, const PDFOutlineItem* item, QSet& pages) +{ + int pageIndex = -1; + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &pageIndex)) + { + pages.insert(pageIndex); + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + collectTargetedPages(catalog, item->getChild(i), pages); + } +} + +/// Removes every item whose destination resolves to `pageIndex`, at any depth. +bool removePageBookmarks(const PDFCatalog* catalog, PDFOutlineItem* item, int pageIndex) +{ + bool removed = false; + for (size_t i = item->getChildCount(); i > 0; --i) + { + const QSharedPointer child = item->getChildPtr(i - 1); + int childPage = -1; + const PDFActionGoTo* goTo = dynamic_cast(child->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &childPage) && childPage == pageIndex) + { + item->removeChild(i - 1); + removed = true; + } + else + { + removed = removePageBookmarks(catalog, child.data(), pageIndex) || removed; + } + } + return removed; +} + +/// True when the builder's outline writer can write this item back unchanged. +/// It writes Title, a GoTo Dest, a GoToDp action, colour and flags; a +/// structure element or any other action would be silently dropped. +bool isRewritableItem(const PDFOutlineItem* item) +{ + if (item->getStructureElement().isValid()) + { + return false; + } + + const PDFAction* action = item->getAction(); + if (!action) + { + // A title-only item has no state to lose. + return true; + } + + return dynamic_cast(action) != nullptr || + dynamic_cast(action) != nullptr; +} + +bool canRewriteOutline(const PDFOutlineItem* item) +{ + if (!isRewritableItem(item)) + { + return false; + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + if (!canRewriteOutline(item->getChild(i))) + { + return false; + } + } + return true; +} + +void writeOutline(PDFDocumentModifier& modifier, const QSharedPointer& root) +{ + modifier.markReset(); + modifier.getBuilder()->setOutline(root.data()); +} + +PDFAuthoringEditResult finishOutlineEdit(PDFDocumentModifier& modifier, PDFAuthoringEditResult::Kind kind) +{ + PDFAuthoringEditResult result; + if (!modifier.finalize()) + { + // An edit that writes exactly the outline the document already has is + // reported, not counted as a new revision. + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = kind; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +PDFAuthoringEditResult applyToggleBookmark(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("bookmark/page-out-of-range"), &result); + return result; + } + + const QSharedPointer root = cloneOutlineRoot(document); + if (!canRewriteOutline(root.data())) + { + refusedEdit(QStringLiteral("bookmark/unsupported-outline-item"), &result); + return result; + } + + const bool removed = removePageBookmarks(catalog, root.data(), edit.pageIndex); + if (!removed) + { + root->addChild(bookmarkItem(catalog, + edit.text.isEmpty() ? pageTitle(edit.pageIndex) : edit.text, + edit.pageIndex)); + } + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + return finishOutlineEdit(modifier, removed ? PDFAuthoringEditResult::Kind::BookmarkRemoved + : PDFAuthoringEditResult::Kind::BookmarkAdded); +} + +PDFAuthoringEditResult applyGenerateBookmarks(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + Q_UNUSED(edit); + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (catalog->getPageCount() == 0) + { + refusedEdit(QStringLiteral("bookmark/no-pages"), &result); + return result; + } + + const QSharedPointer root = cloneOutlineRoot(document); + if (!canRewriteOutline(root.data())) + { + refusedEdit(QStringLiteral("bookmark/unsupported-outline-item"), &result); + return result; + } + + QSet targeted; + collectTargetedPages(catalog, root.data(), targeted); + + int generated = 0; + for (size_t page = 0; page < catalog->getPageCount(); ++page) + { + if (targeted.contains(static_cast(page))) + { + continue; + } + + root->addChild(bookmarkItem(catalog, pageTitle(static_cast(page)), static_cast(page))); + ++generated; + } + + if (generated == 0) + { + refusedEdit(QStringLiteral("bookmark/nothing-to-generate"), &result); + return result; + } + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + result = finishOutlineEdit(modifier, PDFAuthoringEditResult::Kind::BookmarksGenerated); + result.affectedCount = generated; + return result; +} + +bool validateRecords(const PDFCatalog* catalog, const QList& records, QString& typedError) +{ + for (const PDFBookmarkRecord& record : records) + { + if (record.title.isEmpty() || !isPageIndexInRange(catalog, record.pageIndex)) + { + typedError = QStringLiteral("bookmark/invalid-record"); + return false; + } + + if (!validateRecords(catalog, record.children, typedError)) + { + return false; + } + } + return true; +} + +int countRecords(const QList& records) +{ + int count = 0; + for (const PDFBookmarkRecord& record : records) + { + count += 1 + countRecords(record.children); + } + return count; +} + +void appendRecords(const PDFCatalog* catalog, PDFOutlineItem* parent, const QList& records) +{ + for (const PDFBookmarkRecord& record : records) + { + const QSharedPointer item = bookmarkItem(catalog, record.title, record.pageIndex); + appendRecords(catalog, item.data(), record.children); + parent->addChild(item); + } +} + +PDFAuthoringEditResult applyReplaceBookmarks(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + QString typedError; + if (!validateRecords(catalog, edit.bookmarks, typedError)) + { + refusedEdit(typedError, &result); + return result; + } + + // Replacing the outline with exactly the records it already carries would + // write a second identical revision; it is reported instead. An outline the + // interchange format cannot read back (structure elements, foreign + // actions) is not comparable, and replacing it is an explicit discard, so + // the edit proceeds. + QString outlineError; + const std::optional> current = outlineRecords(document, outlineError); + if (current.has_value() && *current == edit.bookmarks) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + QSharedPointer root(new PDFOutlineItem()); + appendRecords(catalog, root.data(), edit.bookmarks); + + PDFDocumentModifier modifier(document); + writeOutline(modifier, root); + result = finishOutlineEdit(modifier, PDFAuthoringEditResult::Kind::BookmarksReplaced); + result.affectedCount = countRecords(edit.bookmarks); + return result; +} + +PDFAuthoringEditResult applyUriLink(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (edit.text.isEmpty()) + { + refusedEdit(QStringLiteral("hyperlink/uri-required"), &result); + return result; + } + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/page-out-of-range"), &result); + return result; + } + if (!isUsableRectangle(edit.rectangle)) + { + refusedEdit(QStringLiteral("hyperlink/link-rectangle-required"), &result); + return result; + } + + PDFDocumentModifier modifier(document); + modifier.getBuilder()->createAnnotationLink(pageReference(catalog, edit.pageIndex), + edit.rectangle.normalized(), + edit.text, + LinkHighlightMode::Invert); + modifier.markAnnotationsChanged(); + if (!modifier.finalize()) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = PDFAuthoringEditResult::Kind::LinkCreated; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +PDFAuthoringEditResult applyDestinationLink(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + PDFAuthoringEditResult result; + const PDFCatalog* catalog = document->getCatalog(); + if (!isPageIndexInRange(catalog, edit.pageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/page-out-of-range"), &result); + return result; + } + if (!isPageIndexInRange(catalog, edit.targetPageIndex)) + { + refusedEdit(QStringLiteral("hyperlink/target-page-out-of-range"), &result); + return result; + } + if (!isUsableRectangle(edit.rectangle)) + { + refusedEdit(QStringLiteral("hyperlink/link-rectangle-required"), &result); + return result; + } + if (edit.destinationType == DestinationType::Invalid || edit.destinationType == DestinationType::Named) + { + refusedEdit(QStringLiteral("hyperlink/unsupported-destination"), &result); + return result; + } + if (edit.destinationType == DestinationType::FitR && !isUsableRectangle(edit.targetRectangle)) + { + refusedEdit(QStringLiteral("hyperlink/target-rectangle-required"), &result); + return result; + } + + // The host builds the page-space rectangle exactly as the surface named its + // corners: QRectF(QPointF(left, bottom), QPointF(right, top)). Page space is + // y-up, so the PDF top is the numerically larger y; that construction stores + // it in QRectF::bottom() (and the PDF bottom in QRectF::top()). + const QRectF target = edit.targetRectangle.normalized(); + PDFDestination destination; + destination.setDestinationType(edit.destinationType); + destination.setPageIndex(edit.targetPageIndex); + destination.setPageReference(pageReference(catalog, edit.targetPageIndex)); + switch (edit.destinationType) + { + case DestinationType::XYZ: + destination.setLeft(target.isValid() && !target.isEmpty() ? target.left() + : std::numeric_limits::quiet_NaN()); + destination.setTop(target.isValid() && !target.isEmpty() ? target.bottom() + : std::numeric_limits::quiet_NaN()); + destination.setZoom(edit.zoom); + break; + + case DestinationType::FitH: + case DestinationType::FitBH: + destination.setTop(target.isValid() && !target.isEmpty() ? target.bottom() + : std::numeric_limits::quiet_NaN()); + break; + + case DestinationType::FitV: + case DestinationType::FitBV: + destination.setLeft(target.isValid() && !target.isEmpty() ? target.left() + : std::numeric_limits::quiet_NaN()); + break; + + case DestinationType::FitR: + destination.setLeft(target.left()); + destination.setBottom(target.top()); + destination.setRight(target.right()); + destination.setTop(target.bottom()); + break; + + case DestinationType::Fit: + case DestinationType::FitB: + case DestinationType::Invalid: + case DestinationType::Named: + break; + } + + PDFDocumentModifier modifier(document); + const PDFObjectReference action = modifier.getBuilder()->createActionGoTo(destination); + modifier.getBuilder()->createAnnotationLink(pageReference(catalog, edit.pageIndex), + edit.rectangle.normalized(), + action, + LinkHighlightMode::Invert); + modifier.markAnnotationsChanged(); + if (!modifier.finalize()) + { + refusedEdit(QStringLiteral("edit/no-change"), &result); + return result; + } + + result.kind = PDFAuthoringEditResult::Kind::LinkCreated; + result.document = modifier.getDocument(); + result.flags = modifier.getFlags(); + return result; +} + +bool readRecord(const PDFCatalog* catalog, const PDFOutlineItem* item, PDFBookmarkRecord& record, QString& typedError) +{ + record.title = item->getTitle(); + + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (!goTo || !resolveDestinationPage(catalog, goTo->getDestination(), &record.pageIndex)) + { + typedError = QStringLiteral("bookmark/unsupported-outline-item"); + return false; + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + PDFBookmarkRecord child; + if (!readRecord(catalog, item->getChild(i), child, typedError)) + { + return false; + } + record.children.append(child); + } + return true; +} + +std::optional> outlineRecords(const PDFDocument* document, QString& typedError) +{ + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + if (!root) + { + return QList(); + } + + QList records; + for (size_t i = 0; i < root->getChildCount(); ++i) + { + PDFBookmarkRecord record; + if (!readRecord(document->getCatalog(), root->getChild(i), record, typedError)) + { + return std::nullopt; + } + records.append(record); + } + return records; +} + +void collectBookmarkPages(const PDFCatalog* catalog, const PDFOutlineItem* item, QList& pages) +{ + int pageIndex = -1; + const PDFActionGoTo* goTo = dynamic_cast(item->getAction()); + if (goTo && resolveDestinationPage(catalog, goTo->getDestination(), &pageIndex)) + { + pages.append(pageIndex); + } + + for (size_t i = 0; i < item->getChildCount(); ++i) + { + collectBookmarkPages(catalog, item->getChild(i), pages); + } +} + +QJsonObject recordToJson(const PDFBookmarkRecord& record) +{ + QJsonObject object; + object.insert(QStringLiteral("title"), record.title); + object.insert(QStringLiteral("page"), record.pageIndex); + if (!record.children.isEmpty()) + { + QJsonArray children; + for (const PDFBookmarkRecord& child : record.children) + { + children.append(recordToJson(child)); + } + object.insert(QStringLiteral("children"), children); + } + return object; +} + +bool recordFromJson(const QJsonValue& value, PDFBookmarkRecord& record, QString& typedError) +{ + if (!value.isObject()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + const QJsonObject object = value.toObject(); + const QJsonValue title = object.value(QStringLiteral("title")); + const QJsonValue page = object.value(QStringLiteral("page")); + if (!title.isString() || title.toString().isEmpty() || !page.isDouble() || + page.toDouble() != std::floor(page.toDouble())) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + record.title = title.toString(); + record.pageIndex = static_cast(page.toDouble()); + record.children.clear(); + + if (object.contains(QStringLiteral("children"))) + { + const QJsonValue children = object.value(QStringLiteral("children")); + if (!children.isArray()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return false; + } + + for (const QJsonValue& child : children.toArray()) + { + PDFBookmarkRecord childRecord; + if (!recordFromJson(child, childRecord, typedError)) + { + return false; + } + record.children.append(childRecord); + } + } + return true; +} + +} // namespace + +std::optional PDFBookmarkFile::fromJson(const QByteArray& payload, QString& typedError) +{ + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return std::nullopt; + } + + const QJsonObject object = document.object(); + const QJsonValue schemaVersion = object.value(QStringLiteral("schema_version")); + const QJsonValue bookmarks = object.value(QStringLiteral("bookmarks")); + if (!schemaVersion.isDouble() || static_cast(schemaVersion.toDouble()) != BookmarkFileSchemaVersion || + !bookmarks.isArray()) + { + typedError = QStringLiteral("bookmark/invalid-file"); + return std::nullopt; + } + + PDFBookmarkFile file; + for (const QJsonValue& value : bookmarks.toArray()) + { + PDFBookmarkRecord record; + if (!recordFromJson(value, record, typedError)) + { + return std::nullopt; + } + file.bookmarks.append(record); + } + return file; +} + +QByteArray PDFBookmarkFile::toJson() const +{ + QJsonArray array; + for (const PDFBookmarkRecord& record : bookmarks) + { + array.append(recordToJson(record)); + } + + QJsonObject object; + object.insert(QStringLiteral("schema_version"), BookmarkFileSchemaVersion); + object.insert(QStringLiteral("bookmarks"), array); + return canonicalJson(QJsonValue(object)); +} + +int PDFBookmarkFile::count() const +{ + return countRecords(bookmarks); +} + +PDFAuthoringEditResult PDFAuthoringEditWriter::apply(const PDFDocument* document, const PDFAuthoringEdit& edit) +{ + if (!document) + { + PDFAuthoringEditResult result; + refusedEdit(QStringLiteral("edit/document-missing"), &result); + return result; + } + + switch (edit.kind) + { + case PDFAuthoringEdit::Kind::ToggleBookmark: + return applyToggleBookmark(document, edit); + + case PDFAuthoringEdit::Kind::GenerateBookmarks: + return applyGenerateBookmarks(document, edit); + + case PDFAuthoringEdit::Kind::ReplaceBookmarks: + return applyReplaceBookmarks(document, edit); + + case PDFAuthoringEdit::Kind::AddUriLink: + return applyUriLink(document, edit); + + case PDFAuthoringEdit::Kind::AddDestinationLink: + return applyDestinationLink(document, edit); + } + + PDFAuthoringEditResult result; + refusedEdit(QStringLiteral("edit/unknown-kind"), &result); + return result; +} + +std::optional> PDFAuthoringEditWriter::readOutline(const PDFDocument* document, + QString& typedError) +{ + if (!document) + { + typedError = QStringLiteral("bookmark/document-missing"); + return std::nullopt; + } + + return outlineRecords(document, typedError); +} + +QList PDFAuthoringEditWriter::bookmarkPages(const PDFDocument* document) +{ + QList pages; + if (!document) + { + return pages; + } + + const QSharedPointer root = document->getCatalog()->getOutlineRootPtr(); + if (root) + { + collectBookmarkPages(document->getCatalog(), root.data(), pages); + } + return pages; +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfauthoringedit.h b/LoopLibCore/sources/pdfauthoringedit.h new file mode 100644 index 00000000..c4a51cf8 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringedit.h @@ -0,0 +1,148 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFAUTHORINGEDIT_H +#define PDFAUTHORINGEDIT_H + +#include "pdfaction.h" +#include "pdfdocument.h" +#include "pdfglobal.h" + +#include +#include +#include + +#include +#include + +namespace pdf +{ + +/// One entry of the bookmark interchange file: a title and the 0-based page it +/// targets. `children` keeps the file's order, which is the outline order. +struct PDFBookmarkRecord +{ + QString title; + int pageIndex = -1; + QList children; + + bool operator==(const PDFBookmarkRecord&) const = default; +}; + +/// The bookmark interchange file of the direct-edit family (ADR-014). +/// +/// Canonical JSON: `{"schema_version":1,"bookmarks":[{"title":…,"page":…, +/// "children":[…]}]}`. The format carries titles, page targets and nesting +/// only; outline state it cannot represent (structure elements, non-GoTo +/// actions) is not silently dropped by export — export refuses instead. +/// Parsing is total: malformed input returns a typed reason and no partial +/// tree. +class LOOPLIBCORESHARED_EXPORT PDFBookmarkFile +{ +public: + static std::optional fromJson(const QByteArray& payload, QString& typedError); + QByteArray toJson() const; + + /// Total records, including nested children. + int count() const; + + QList bookmarks; +}; + +/// What one authoring edit asks Core to write (ADR-014). +/// +/// All rectangles are in PDF page space (y-up); the writer normalizes inverted +/// corners and refuses degenerate ones. `zoom` uses a quiet NaN to write the +/// PDF `null` zoom, which is the "inherit the viewer's zoom" destination. +struct PDFAuthoringEdit +{ + enum class Kind + { + ToggleBookmark, ///< Add the page's bookmark, or remove it when one targets the page. + GenerateBookmarks, ///< Add a bookmark for every page that has none. + ReplaceBookmarks, ///< Replace the outline with `bookmarks`. + AddUriLink, ///< Link annotation with a URI action. + AddDestinationLink ///< Link annotation with a GoTo action of `destinationType`. + }; + + Kind kind = Kind::ToggleBookmark; + QString text; ///< Bookmark title, or the URI of a URI link. + int pageIndex = -1; ///< Bookmark target page, or the link rectangle's page. + QRectF rectangle; ///< Link rectangle, page space. + int targetPageIndex = -1; ///< Link destination page. + DestinationType destinationType = DestinationType::Fit; + QRectF targetRectangle; ///< FitR / XYZ destination rectangle, page space. + PDFReal zoom = std::numeric_limits::quiet_NaN(); ///< XYZ zoom; NaN inherits. + QList bookmarks; ///< ReplaceBookmarks payload. +}; + +/// How one edit resolved. `Refused` means no document was written and +/// `typedError` says why; every other kind carries the new document. +struct PDFAuthoringEditResult +{ + enum class Kind + { + Refused, + BookmarkAdded, + BookmarkRemoved, + BookmarksGenerated, + BookmarksReplaced, + LinkCreated + }; + + Kind kind = Kind::Refused; + QString typedError; ///< Set exactly when kind == Refused. + int affectedCount = 0; ///< Bookmarks written by Generated/Replaced. + PDFDocumentPointer document; + PDFModifiedDocument::ModificationFlags flags; + + bool applied() const { return kind != Kind::Refused; } +}; + +/// The Core writer behind the interaction layer's direct authoring edits +/// (ADR-014). Every edit builds a modified document through +/// PDFDocumentModifier/PDFDocumentBuilder; a refusal carries a typed reason and +/// leaves the input document untouched. +/// +/// An outline rewrite cannot represent state the builder's outline writer does +/// not write back (structure elements, actions that are neither GoTo nor +/// GoToDp), so edits that keep existing items refuse with +/// `bookmark/unsupported-outline-item` rather than dropping that state. +class LOOPLIBCORESHARED_EXPORT PDFAuthoringEditWriter +{ +public: + static PDFAuthoringEditResult apply(const PDFDocument* document, const PDFAuthoringEdit& edit); + + /// The document's outline as interchange records, in outline order. An + /// item without a resolvable page destination is refused with + /// `bookmark/unsupported-outline-item` rather than skipped. + static std::optional> readOutline(const PDFDocument* document, + QString& typedError); + + /// Flattened bookmark page targets in outline order, for bookmark + /// navigation. Items without a page target are not visited. + static QList bookmarkPages(const PDFDocument* document); +}; + +} // namespace pdf + +#endif // PDFAUTHORINGEDIT_H diff --git a/LoopLibInteraction/sources/documentfacade.cpp b/LoopLibInteraction/sources/documentfacade.cpp index 51f40675..e5ade175 100644 --- a/LoopLibInteraction/sources/documentfacade.cpp +++ b/LoopLibInteraction/sources/documentfacade.cpp @@ -195,6 +195,12 @@ void DocumentFacade::markModified() return; } + documentContext->markModified(); + noteAuthoringMutation(); +} + +void DocumentFacade::noteAuthoringMutation() +{ // A queued save was fenced against the revision that is about to be // superseded. The scheduler would discard it silently, leaving the // invocation without a terminal state, so it is superseded here instead. @@ -203,8 +209,6 @@ void DocumentFacade::markModified() supersedePending(CommandTerminalState::Cancelled, QStringLiteral("document/superseded")); } - documentContext->markModified(); - if (!m_publishedKey.isEmpty()) { m_submitter->publishCurrentRevision(m_publishedKey, m_revisionSource.currentRevision()); @@ -215,6 +219,67 @@ void DocumentFacade::markModified() updateAvailability(); } +DocumentEditOutcome DocumentFacade::applyAuthoringEdit(const DocumentEditRequest& request) +{ + DocumentEditOutcome outcome; + const auto refuse = [&outcome](const QString& typedError) + { + outcome.applied = false; + outcome.typedError = typedError; + return outcome; + }; + + pdf::PDFDocumentContext* documentContext = context(); + if (m_state != DocumentState::Ready || !documentContext || !documentContext->getDocument()) + { + return refuse(QStringLiteral("edit/document-not-ready")); + } + + // The fence is checked before any mutation, so an edit begun against a + // revision the operator is no longer looking at cannot land on this one. + if (!m_revisionSource.isCurrent(request.revision)) + { + return refuse(QStringLiteral("edit/stale-revision")); + } + + if (!permissions().modify) + { + return refuse(QStringLiteral("edit/modify-permission-denied")); + } + + pdf::PDFAuthoringEditResult result; + try + { + result = pdf::PDFAuthoringEditWriter::apply(documentContext->getDocument(), request.edit); + } + catch (const pdf::PDFResourceBudgetExceededException&) + { + return refuse(QStringLiteral("edit/resource-budget-exceeded")); + } + catch (const pdf::PDFException&) + { + return refuse(QStringLiteral("edit/refused-by-core")); + } + + if (!result.applied()) + { + return refuse(result.typedError.isEmpty() ? QStringLiteral("edit/refused") : result.typedError); + } + + // The writer produced a new document; replacing it advances the document + // revision and the cache generation, which is the revision this edit + // produced. The old document stays owned by nothing once the context drops + // it, exactly as a document replacement does. + documentContext->setDocument(result.document, result.flags); + noteAuthoringMutation(); + + outcome.applied = true; + outcome.resolution = result.kind; + outcome.affectedCount = result.affectedCount; + outcome.revision = m_revisionSource.currentRevision(); + return outcome; +} + CommandInvocationId DocumentFacade::open(const QString& path) { QVariantMap parameters; diff --git a/LoopLibInteraction/sources/documentfacade.h b/LoopLibInteraction/sources/documentfacade.h index b36d4fe8..307b8edc 100644 --- a/LoopLibInteraction/sources/documentfacade.h +++ b/LoopLibInteraction/sources/documentfacade.h @@ -28,6 +28,7 @@ #include "documentloader.h" #include "jobrelay.h" #include "jobsubmitter.h" +#include "pdfauthoringedit.h" #include "pdfpreflightverdict.h" #include "pdfsignaturehandler.h" @@ -212,6 +213,28 @@ struct DocumentPermissions bool allowsCorrection() const { return print && modify && copy && assemble; } }; +/// One interactive authoring edit (ADR-014): the edit Core is asked to write +/// and the revision it was begun against. The facade checks the fence and the +/// document's modify permission before any mutation, so a stale or unauthorized +/// request is refused with a typed reason and changes nothing. +struct DocumentEditRequest +{ + pdf::PDFAuthoringEdit edit; + pdf::PDFRevisionIdentity revision; +}; + +/// The outcome of one authoring edit. `applied` means the document advanced to +/// a new revision and `revision` names it; a refusal carries the typed reason +/// and leaves the document and its revision untouched. +struct DocumentEditOutcome +{ + bool applied = false; + QString typedError; + pdf::PDFAuthoringEditResult::Kind resolution = pdf::PDFAuthoringEditResult::Kind::Refused; + int affectedCount = 0; + pdf::PDFRevisionIdentity revision; +}; + /// One presentation-facing document lifecycle. /// /// The facade owns no PDF truth. pdf::PDFDocumentContext remains the document @@ -316,6 +339,13 @@ class DocumentFacade final : public QObject /// the lifecycle. void markModified(); + /// Applies one interactive authoring edit (ADR-014). The fence is checked + /// before any mutation: a request begun against a revision that is no + /// longer current, or against a document whose modify permission is not + /// granted, is refused with a typed reason. An applied edit replaces the + /// document, which advances the revision and the cache generation. + DocumentEditOutcome applyAuthoringEdit(const DocumentEditRequest& request); + CommandInvocationId open(const QString& path); /// Re-opens the current source. Replacement, not a refresh: the old identity @@ -369,6 +399,10 @@ class DocumentFacade final : public QObject void setFacets(DocumentFacets facets); void setOutputState(DocumentOutputState outputState); void updateAvailability(); + /// The bookkeeping every in-session mutation shares: a queued save was + /// fenced against a revision that is now superseded, the published revision + /// moves, and the document is dirty with no pending output. + void noteAuthoringMutation(); void finishPending(CommandInvocationId invocation, CommandTerminalState state, QString typedError); bool admitInspectionTransition(const DocumentInspectionToken& token, QString& error) const; void invalidateInspection(); diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index 0da51db8..757c82e4 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -342,6 +342,25 @@ set_target_properties(UnitTestsProductionGeometry PROPERTIES add_test(UnitTestsOperationHistory "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsOperationHistory") add_test(UnitTestsProductionGeometry "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsProductionGeometry") +# The direct authoring-edit writer (ADR-014, issue #233): bookmark create, +# generate, replace, interchange round trips and one link per destination +# variant, every destination read back through Core's own parser. +add_executable(UnitTestsAuthoringEdit + tst_authoringedittest.cpp +) + +target_link_libraries(UnitTestsAuthoringEdit PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) + +set_target_properties(UnitTestsAuthoringEdit PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} +) + +add_test(UnitTestsAuthoringEdit "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsAuthoringEdit") +set_tests_properties(UnitTestsAuthoringEdit PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(UnitTestsPreflightEngine tst_preflightenginetest.cpp ) diff --git a/UnitTests/tst_authoringedittest.cpp b/UnitTests/tst_authoringedittest.cpp new file mode 100644 index 00000000..8bec2cf3 --- /dev/null +++ b/UnitTests/tst_authoringedittest.cpp @@ -0,0 +1,585 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// The direct authoring-edit writer of ADR-014 (issue #233): bookmark create, +// generate, replace and interchange round trips, plus one link annotation per +// destination variant. Every destination is verified by serializing the +// modified document and reading it back through Core's own parser, not by +// trusting the in-memory object the writer just built. + +#include + +#include + +#include + +#include "pdfannotation.h" +#include "pdfauthoringedit.h" +#include "pdfcatalog.h" +#include "pdfdocumentbuilder.h" +#include "pdfdocumentreader.h" +#include "pdfdocumentwriter.h" +#include "pdfoutline.h" +#include "pdfpage.h" + +namespace +{ + +pdf::PDFDocument buildDocument(int pageCount) +{ + pdf::PDFDocumentBuilder builder; + for (int page = 0; page < pageCount; ++page) + { + builder.appendPage(QRectF(0, 0, 200, 300)); + } + return builder.build(); +} + +/// Serializes and re-parses the document, so a read-back proves what the +/// written file says, not what the writer's memory happens to hold. +std::optional writeAndRead(const pdf::PDFDocument& document) +{ + pdf::PDFDocumentWriter writer(nullptr); + QBuffer buffer; + buffer.open(QIODevice::WriteOnly); + const pdf::PDFOperationResult written = writer.write(&buffer, &document); + if (!written) + { + return std::nullopt; + } + buffer.close(); + + pdf::PDFDocumentReader reader(nullptr, [](bool*) + { return QString(); }, true, false); + return reader.readFromBuffer(buffer.data()); +} + +std::vector annotationsOnPage(const pdf::PDFDocument& document, int pageIndex) +{ + std::vector annotations; + const pdf::PDFPage* page = document.getCatalog()->getPage(pageIndex); + if (!page) + { + return annotations; + } + + for (const pdf::PDFObjectReference& reference : page->getAnnotations()) + { + annotations.push_back(pdf::PDFAnnotation::parse(&document.getStorage(), reference)); + } + return annotations; +} + +const pdf::PDFLinkAnnotation* firstLinkOnPage(const pdf::PDFDocument& document, int pageIndex) +{ + for (const pdf::PDFAnnotationPtr& annotation : annotationsOnPage(document, pageIndex)) + { + if (const auto* link = dynamic_cast(annotation.data())) + { + return link; + } + } + return nullptr; +} + +int destinationPageIndex(const pdf::PDFDocument& document, const pdf::PDFDestination& destination) +{ + if (!destination.getPageReference().isValid()) + { + return -1; + } + + const size_t index = document.getCatalog()->getPageIndexFromPageReference(destination.getPageReference()); + return index == pdf::PDFCatalog::INVALID_PAGE_INDEX ? -1 : static_cast(index); +} + +std::optional> outlineRecords(const pdf::PDFDocument& document) +{ + QString typedError; + return pdf::PDFAuthoringEditWriter::readOutline(&document, typedError); +} + +pdf::PDFAuthoringEdit bookmarkEdit(pdf::PDFAuthoringEdit::Kind kind) +{ + pdf::PDFAuthoringEdit edit; + edit.kind = kind; + return edit; +} + +/// A document whose outline carries a structure element, which the builder's +/// outline writer cannot write back. +pdf::PDFDocument buildDocumentWithStructureOutline() +{ + const pdf::PDFDocument document = buildDocument(1); + pdf::PDFDocumentModifier modifier(&document); + pdf::PDFDocumentBuilder* builder = modifier.getBuilder(); + + pdf::PDFObjectFactory itemFactory; + itemFactory.beginDictionary(); + itemFactory.beginDictionaryItem("Title"); + itemFactory << QStringLiteral("Tagged"); + itemFactory.endDictionaryItem(); + itemFactory.beginDictionaryItem("SE"); + itemFactory << document.getCatalog()->getPage(0)->getPageReference(); + itemFactory.endDictionaryItem(); + itemFactory.endDictionary(); + const pdf::PDFObjectReference itemReference = builder->addObject(itemFactory.takeObject()); + + pdf::PDFObjectFactory rootFactory; + rootFactory.beginDictionary(); + rootFactory.beginDictionaryItem("First"); + rootFactory << itemReference; + rootFactory.endDictionaryItem(); + rootFactory.beginDictionaryItem("Last"); + rootFactory << itemReference; + rootFactory.endDictionaryItem(); + rootFactory.beginDictionaryItem("Count"); + rootFactory << 1; + rootFactory.endDictionaryItem(); + rootFactory.endDictionary(); + builder->setOutline(builder->addObject(rootFactory.takeObject())); + + if (!modifier.finalize()) + { + return pdf::PDFDocument(); + } + return *modifier.getDocument(); +} + +} // namespace + +class AuthoringEditTest : public QObject +{ + Q_OBJECT + +private slots: + void bookmarkFileRoundTripsCanonically(); + void bookmarkFileRefusesMalformedInput_data(); + void bookmarkFileRefusesMalformedInput(); + void toggleBookmarkAddsThenRemovesThePageBookmark(); + void generateBookmarksSkipsPagesThatAlreadyHaveOne(); + void replaceBookmarksRoundTripsTheInterchangeFile(); + void destinationVariantWritesItsPdfDestinationType_data(); + void destinationVariantWritesItsPdfDestinationType(); + void uriLinkWritesTheUriAction(); + void invalidEditsAreRefusedWithTypedReasons_data(); + void invalidEditsAreRefusedWithTypedReasons(); + void structureOutlineRefusesAnOutlineRewrite(); +}; + +void AuthoringEditTest::bookmarkFileRoundTripsCanonically() +{ + pdf::PDFBookmarkFile file; + pdf::PDFBookmarkRecord chapter; + chapter.title = QStringLiteral("Chapter 1"); + chapter.pageIndex = 0; + pdf::PDFBookmarkRecord section; + section.title = QStringLiteral("Section 1.1"); + section.pageIndex = 2; + chapter.children.append(section); + file.bookmarks.append(chapter); + file.bookmarks.append(pdf::PDFBookmarkRecord{ QStringLiteral("Appendix"), 5, {} }); + + const QByteArray bytes = file.toJson(); + + QString typedError; + const std::optional parsed = pdf::PDFBookmarkFile::fromJson(bytes, typedError); + QVERIFY2(parsed.has_value(), qPrintable(typedError)); + QCOMPARE(parsed->bookmarks.size(), 2); + QCOMPARE(parsed->bookmarks.at(0).title, QStringLiteral("Chapter 1")); + QCOMPARE(parsed->bookmarks.at(0).children.size(), 1); + QCOMPARE(parsed->bookmarks.at(0).children.at(0).title, QStringLiteral("Section 1.1")); + QCOMPARE(parsed->bookmarks.at(0).children.at(0).pageIndex, 2); + QCOMPARE(parsed->bookmarks.at(1).pageIndex, 5); + QCOMPARE(parsed->count(), 3); + + // Canonical bytes: parse(toJson(x)) serializes to the same bytes. + QCOMPARE(parsed->toJson(), bytes); +} + +void AuthoringEditTest::bookmarkFileRefusesMalformedInput_data() +{ + QTest::addColumn("payload"); + + QTest::newRow("not-json") << QByteArray("{bookmarks"); + QTest::newRow("wrong-root") << QByteArray("[1,2,3]"); + QTest::newRow("wrong-schema") << QByteArray("{\"schema_version\":2,\"bookmarks\":[]}"); + QTest::newRow("missing-bookmarks") << QByteArray("{\"schema_version\":1}"); + QTest::newRow("missing-title") << QByteArray("{\"schema_version\":1,\"bookmarks\":[{\"page\":0}]}"); + QTest::newRow("fractional-page") << QByteArray("{\"schema_version\":1,\"bookmarks\":[{\"title\":\"A\",\"page\":1.5}]}"); + QTest::newRow("string-page") << QByteArray("{\"schema_version\":1,\"bookmarks\":[{\"title\":\"A\",\"page\":\"1\"}]}"); + QTest::newRow("bad-children") << QByteArray("{\"schema_version\":1,\"bookmarks\":[{\"title\":\"A\",\"page\":1,\"children\":3}]}"); +} + +void AuthoringEditTest::bookmarkFileRefusesMalformedInput() +{ + QFETCH(QByteArray, payload); + + QString typedError; + QVERIFY(!pdf::PDFBookmarkFile::fromJson(payload, typedError).has_value()); + QCOMPARE(typedError, QStringLiteral("bookmark/invalid-file")); +} + +void AuthoringEditTest::toggleBookmarkAddsThenRemovesThePageBookmark() +{ + const pdf::PDFDocument document = buildDocument(3); + + pdf::PDFAuthoringEdit edit = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + edit.pageIndex = 1; + edit.text = QStringLiteral("Chapter two"); + + const pdf::PDFAuthoringEditResult added = pdf::PDFAuthoringEditWriter::apply(&document, edit); + QVERIFY2(added.applied(), qPrintable(added.typedError)); + QCOMPARE(added.kind, pdf::PDFAuthoringEditResult::Kind::BookmarkAdded); + + const std::optional parsed = writeAndRead(*added.document); + QVERIFY(parsed.has_value()); + const std::optional> records = outlineRecords(*parsed); + QVERIFY(records.has_value()); + QCOMPARE(records->size(), 1); + QCOMPARE(records->at(0).title, QStringLiteral("Chapter two")); + QCOMPARE(records->at(0).pageIndex, 1); + + // The same command on the same page removes the bookmark again. + const pdf::PDFAuthoringEditResult removed = pdf::PDFAuthoringEditWriter::apply(added.document.data(), edit); + QVERIFY2(removed.applied(), qPrintable(removed.typedError)); + QCOMPARE(removed.kind, pdf::PDFAuthoringEditResult::Kind::BookmarkRemoved); + + const std::optional parsedRemoved = writeAndRead(*removed.document); + QVERIFY(parsedRemoved.has_value()); + QVERIFY(parsedRemoved->getCatalog()->getOutlineRootPtr().isNull() || + parsedRemoved->getCatalog()->getOutlineRootPtr()->getChildCount() == 0); + + // Without a title the generated title is the shell's page label. + pdf::PDFAuthoringEdit untitled = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + untitled.pageIndex = 2; + const pdf::PDFAuthoringEditResult defaulted = pdf::PDFAuthoringEditWriter::apply(&document, untitled); + QVERIFY2(defaulted.applied(), qPrintable(defaulted.typedError)); + const std::optional> defaultedRecords = outlineRecords(*defaulted.document); + QVERIFY(defaultedRecords.has_value()); + QCOMPARE(defaultedRecords->at(0).title, QStringLiteral("Page 3")); +} + +void AuthoringEditTest::generateBookmarksSkipsPagesThatAlreadyHaveOne() +{ + const pdf::PDFDocument document = buildDocument(3); + + pdf::PDFAuthoringEdit toggle = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + toggle.pageIndex = 1; + toggle.text = QStringLiteral("Existing"); + const pdf::PDFAuthoringEditResult existing = pdf::PDFAuthoringEditWriter::apply(&document, toggle); + QVERIFY2(existing.applied(), qPrintable(existing.typedError)); + + const pdf::PDFAuthoringEditResult generated = + pdf::PDFAuthoringEditWriter::apply(existing.document.data(), bookmarkEdit(pdf::PDFAuthoringEdit::Kind::GenerateBookmarks)); + QVERIFY2(generated.applied(), qPrintable(generated.typedError)); + QCOMPARE(generated.kind, pdf::PDFAuthoringEditResult::Kind::BookmarksGenerated); + QCOMPARE(generated.affectedCount, 2); + + const std::optional> records = outlineRecords(*generated.document); + QVERIFY(records.has_value()); + QCOMPARE(records->size(), 3); + QCOMPARE(records->at(0).title, QStringLiteral("Existing")); + QCOMPARE(records->at(1).title, QStringLiteral("Page 1")); + QCOMPARE(records->at(2).title, QStringLiteral("Page 3")); + + // A second run has nothing to generate and says so instead of writing a + // revision that changes nothing. + const pdf::PDFAuthoringEditResult again = + pdf::PDFAuthoringEditWriter::apply(generated.document.data(), bookmarkEdit(pdf::PDFAuthoringEdit::Kind::GenerateBookmarks)); + QVERIFY(!again.applied()); + QCOMPARE(again.typedError, QStringLiteral("bookmark/nothing-to-generate")); + QVERIFY(again.document.isNull()); +} + +void AuthoringEditTest::replaceBookmarksRoundTripsTheInterchangeFile() +{ + const pdf::PDFDocument source = buildDocument(4); + + pdf::PDFAuthoringEdit toggle = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + toggle.pageIndex = 3; + toggle.text = QStringLiteral("Last page"); + const pdf::PDFAuthoringEditResult first = pdf::PDFAuthoringEditWriter::apply(&source, toggle); + QVERIFY2(first.applied(), qPrintable(first.typedError)); + + pdf::PDFAuthoringEdit nested = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + nested.pageIndex = 0; + nested.text = QStringLiteral("First page"); + const pdf::PDFAuthoringEditResult second = pdf::PDFAuthoringEditWriter::apply(first.document.data(), nested); + QVERIFY2(second.applied(), qPrintable(second.typedError)); + + const std::optional> exported = outlineRecords(*second.document); + QVERIFY(exported.has_value()); + QCOMPARE(exported->size(), 2); + + pdf::PDFBookmarkFile file; + file.bookmarks = *exported; + const QByteArray bytes = file.toJson(); + + QString typedError; + const std::optional parsed = pdf::PDFBookmarkFile::fromJson(bytes, typedError); + QVERIFY2(parsed.has_value(), qPrintable(typedError)); + + // Import into a different document: the outline becomes the file, and a + // fresh export produces the same records. + const pdf::PDFDocument target = buildDocument(4); + pdf::PDFAuthoringEdit import = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ReplaceBookmarks); + import.bookmarks = parsed->bookmarks; + const pdf::PDFAuthoringEditResult imported = pdf::PDFAuthoringEditWriter::apply(&target, import); + QVERIFY2(imported.applied(), qPrintable(imported.typedError)); + QCOMPARE(imported.kind, pdf::PDFAuthoringEditResult::Kind::BookmarksReplaced); + QCOMPARE(imported.affectedCount, 2); + + const std::optional> reimported = outlineRecords(*imported.document); + QVERIFY(reimported.has_value()); + QCOMPARE(*reimported, *exported); + + // Importing the same file into the document that already has it is a + // no-change, not a second identical revision. + const pdf::PDFAuthoringEditResult duplicate = pdf::PDFAuthoringEditWriter::apply(imported.document.data(), import); + QVERIFY(!duplicate.applied()); + QCOMPARE(duplicate.typedError, QStringLiteral("edit/no-change")); + + // A record for a page the document does not have is refused before any + // mutation, with a typed reason. + pdf::PDFAuthoringEdit invalid = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ReplaceBookmarks); + invalid.bookmarks = { pdf::PDFBookmarkRecord{ QStringLiteral("Ghost"), 9, {} } }; + const pdf::PDFAuthoringEditResult refused = pdf::PDFAuthoringEditWriter::apply(&target, invalid); + QVERIFY(!refused.applied()); + QCOMPARE(refused.typedError, QStringLiteral("bookmark/invalid-record")); + QVERIFY(refused.document.isNull()); +} + +void AuthoringEditTest::destinationVariantWritesItsPdfDestinationType_data() +{ + QTest::addColumn("destinationType"); + + QTest::newRow("Fit") << int(pdf::DestinationType::Fit); + QTest::newRow("FitB") << int(pdf::DestinationType::FitB); + QTest::newRow("FitBH") << int(pdf::DestinationType::FitBH); + QTest::newRow("FitBV") << int(pdf::DestinationType::FitBV); + QTest::newRow("FitH") << int(pdf::DestinationType::FitH); + QTest::newRow("FitR") << int(pdf::DestinationType::FitR); + QTest::newRow("FitV") << int(pdf::DestinationType::FitV); + QTest::newRow("XYZ") << int(pdf::DestinationType::XYZ); +} + +void AuthoringEditTest::destinationVariantWritesItsPdfDestinationType() +{ + QFETCH(int, destinationType); + + const pdf::PDFDocument document = buildDocument(2); + const QRectF linkRectangle(QPointF(10, 20), QPointF(60, 40)); + // The page-space target, exactly as EditorHost builds it from the surface's + // named corners: QRectF(QPointF(left, bottom), QPointF(right, top)). Page + // space is y-up, so the PDF top is the numerically larger y. + const qreal targetLeft = 5.0; + const qreal targetBottom = 6.0; + const qreal targetRight = 70.0; + const qreal targetTop = 90.0; + const QRectF targetRectangle(QPointF(targetLeft, targetBottom), QPointF(targetRight, targetTop)); + + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddDestinationLink; + edit.pageIndex = 0; + edit.rectangle = linkRectangle; + edit.targetPageIndex = 1; + edit.destinationType = static_cast(destinationType); + edit.targetRectangle = targetRectangle; + edit.zoom = 1.5; + + const pdf::PDFAuthoringEditResult applied = pdf::PDFAuthoringEditWriter::apply(&document, edit); + QVERIFY2(applied.applied(), qPrintable(applied.typedError)); + QCOMPARE(applied.kind, pdf::PDFAuthoringEditResult::Kind::LinkCreated); + + const std::optional parsed = writeAndRead(*applied.document); + QVERIFY(parsed.has_value()); + + const pdf::PDFLinkAnnotation* link = firstLinkOnPage(*parsed, 0); + QVERIFY(link); + QCOMPARE(link->getRectangle(), linkRectangle); + + const pdf::PDFActionGoTo* goTo = dynamic_cast(link->getAction()); + QVERIFY(goTo); + const pdf::PDFDestination& destination = goTo->getDestination(); + QCOMPARE(int(destination.getDestinationType()), destinationType); + QCOMPARE(destinationPageIndex(*parsed, destination), 1); + + // The matching destination payload, per PDF 1.7 Table 151. + switch (destination.getDestinationType()) + { + case pdf::DestinationType::Fit: + case pdf::DestinationType::FitB: + QVERIFY(!destination.hasLeft()); + QVERIFY(!destination.hasTop()); + break; + + case pdf::DestinationType::FitH: + case pdf::DestinationType::FitBH: + QVERIFY(!destination.hasLeft()); + QVERIFY(destination.hasTop()); + QCOMPARE(destination.getTop(), targetTop); + break; + + case pdf::DestinationType::FitV: + case pdf::DestinationType::FitBV: + QVERIFY(destination.hasLeft()); + QVERIFY(!destination.hasTop()); + QCOMPARE(destination.getLeft(), targetLeft); + break; + + case pdf::DestinationType::FitR: + QCOMPARE(destination.getLeft(), targetLeft); + QCOMPARE(destination.getBottom(), targetBottom); + QCOMPARE(destination.getRight(), targetRight); + QCOMPARE(destination.getTop(), targetTop); + break; + + case pdf::DestinationType::XYZ: + QCOMPARE(destination.getLeft(), targetLeft); + QCOMPARE(destination.getTop(), targetTop); + QCOMPARE(destination.getZoom(), 1.5); + break; + + case pdf::DestinationType::Invalid: + case pdf::DestinationType::Named: + QFAIL("the writer must never write an invalid destination"); + } +} + +void AuthoringEditTest::uriLinkWritesTheUriAction() +{ + const pdf::PDFDocument document = buildDocument(1); + + pdf::PDFAuthoringEdit edit; + edit.kind = pdf::PDFAuthoringEdit::Kind::AddUriLink; + edit.pageIndex = 0; + edit.rectangle = QRectF(QPointF(4, 8), QPointF(44, 24)); + edit.text = QStringLiteral("https://example.org/loop"); + + const pdf::PDFAuthoringEditResult applied = pdf::PDFAuthoringEditWriter::apply(&document, edit); + QVERIFY2(applied.applied(), qPrintable(applied.typedError)); + + const std::optional parsed = writeAndRead(*applied.document); + QVERIFY(parsed.has_value()); + + const pdf::PDFLinkAnnotation* link = firstLinkOnPage(*parsed, 0); + QVERIFY(link); + QCOMPARE(link->getRectangle(), edit.rectangle); + + const pdf::PDFActionURI* uri = dynamic_cast(link->getAction()); + QVERIFY(uri); + QCOMPARE(uri->getURI(), QByteArray("https://example.org/loop")); +} + +void AuthoringEditTest::invalidEditsAreRefusedWithTypedReasons_data() +{ + QTest::addColumn("kind"); + QTest::addColumn("pageIndex"); + QTest::addColumn("text"); + QTest::addColumn("typedError"); + + QTest::newRow("toggle-page-out-of-range") + << int(pdf::PDFAuthoringEdit::Kind::ToggleBookmark) << 7 << QStringLiteral("A") << QStringLiteral("bookmark/page-out-of-range"); + QTest::newRow("uri-missing") + << int(pdf::PDFAuthoringEdit::Kind::AddUriLink) << 0 << QString() << QStringLiteral("hyperlink/uri-required"); + QTest::newRow("link-page-out-of-range") + << int(pdf::PDFAuthoringEdit::Kind::AddUriLink) << 7 << QStringLiteral("https://example.org") << QStringLiteral("hyperlink/page-out-of-range"); +} + +void AuthoringEditTest::invalidEditsAreRefusedWithTypedReasons() +{ + QFETCH(int, kind); + QFETCH(int, pageIndex); + QFETCH(QString, text); + QFETCH(QString, typedError); + + const pdf::PDFDocument document = buildDocument(2); + + pdf::PDFAuthoringEdit edit; + edit.kind = static_cast(kind); + edit.pageIndex = pageIndex; + edit.text = text; + edit.rectangle = QRectF(QPointF(1, 1), QPointF(10, 10)); + + const pdf::PDFAuthoringEditResult refused = pdf::PDFAuthoringEditWriter::apply(&document, edit); + QVERIFY(!refused.applied()); + QCOMPARE(refused.typedError, typedError); + QVERIFY(refused.document.isNull()); + + // A degenerate link rectangle is refused rather than written as a + // zero-area link. + pdf::PDFAuthoringEdit degenerate; + degenerate.kind = pdf::PDFAuthoringEdit::Kind::AddUriLink; + degenerate.pageIndex = 0; + degenerate.text = QStringLiteral("https://example.org"); + degenerate.rectangle = QRectF(QPointF(3, 3), QPointF(3, 3)); + const pdf::PDFAuthoringEditResult refusedDegenerate = pdf::PDFAuthoringEditWriter::apply(&document, degenerate); + QVERIFY(!refusedDegenerate.applied()); + QCOMPARE(refusedDegenerate.typedError, QStringLiteral("hyperlink/link-rectangle-required")); + + // FitR without a target rectangle cannot describe its destination. + pdf::PDFAuthoringEdit fitR; + fitR.kind = pdf::PDFAuthoringEdit::Kind::AddDestinationLink; + fitR.pageIndex = 0; + fitR.rectangle = QRectF(QPointF(1, 1), QPointF(10, 10)); + fitR.targetPageIndex = 1; + fitR.destinationType = pdf::DestinationType::FitR; + const pdf::PDFAuthoringEditResult refusedFitR = pdf::PDFAuthoringEditWriter::apply(&document, fitR); + QVERIFY(!refusedFitR.applied()); + QCOMPARE(refusedFitR.typedError, QStringLiteral("hyperlink/target-rectangle-required")); +} + +void AuthoringEditTest::structureOutlineRefusesAnOutlineRewrite() +{ + const pdf::PDFDocument document = buildDocumentWithStructureOutline(); + QVERIFY(document.getCatalog()->getOutlineRootPtr()); + QCOMPARE(document.getCatalog()->getOutlineRootPtr()->getChildCount(), size_t(1)); + + // The interchange format cannot carry the structure element, so reading the + // outline refuses rather than exporting it without that state. + QString typedError; + QVERIFY(!pdf::PDFAuthoringEditWriter::readOutline(&document, typedError).has_value()); + QCOMPARE(typedError, QStringLiteral("bookmark/unsupported-outline-item")); + + // An edit that would keep the existing items refuses for the same reason. + pdf::PDFAuthoringEdit edit = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ToggleBookmark); + edit.pageIndex = 0; + const pdf::PDFAuthoringEditResult refused = pdf::PDFAuthoringEditWriter::apply(&document, edit); + QVERIFY(!refused.applied()); + QCOMPARE(refused.typedError, QStringLiteral("bookmark/unsupported-outline-item")); + + // Replacing the outline is an explicit discard of the old outline, so it is + // allowed to write the file's records. + pdf::PDFAuthoringEdit replace = bookmarkEdit(pdf::PDFAuthoringEdit::Kind::ReplaceBookmarks); + replace.bookmarks = { pdf::PDFBookmarkRecord{ QStringLiteral("Fresh"), 0, {} } }; + const pdf::PDFAuthoringEditResult replaced = pdf::PDFAuthoringEditWriter::apply(&document, replace); + QVERIFY2(replaced.applied(), qPrintable(replaced.typedError)); + const std::optional> records = outlineRecords(*replaced.document); + QVERIFY(records.has_value()); + QCOMPARE(records->size(), 1); + QCOMPARE(records->at(0).title, QStringLiteral("Fresh")); +} + +QTEST_MAIN(AuthoringEditTest) + +#include "tst_authoringedittest.moc" diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index 9ef8391e..44089407 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -45,10 +45,13 @@ #include "documentloader.h" #include "jobsubmitter.h" +#include "pdfauthoringedit.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentcontext.h" +#include "pdfdocumentreader.h" #include "pdfdocumentwriter.h" #include "pdfjobscheduler.h" +#include "pdfsecurityhandler.h" namespace { @@ -389,6 +392,11 @@ private slots: void signatureCheckReducesVerificationResultsToTheOperatorVocabulary(); void signatureCheckAdmitsOnlyTheCurrentRevisionAndClearsWithTheDocument(); + // The typed direct-edit gateway (ADR-014, issue #233). + void authoringEditAdvancesTheRevisionAndWritesTheOutline(); + void authoringEditRefusesAStaleRevisionWithoutMutating(); + void authoringEditRefusesWithoutAReadyDocumentOrPermission(); + void realDocumentRoundTripsThroughCoreReaderAndWriter(); }; @@ -440,9 +448,10 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() } } // The display colour modes (#235), the select/copy/capture commands (#232), - // the measure commands (#238) and the shell settings/diagnostics surfaces - // (#239) joined the implemented set in epic #231 PR1. - QCOMPARE(implemented, 55); + // the measure commands (#238), the shell settings/diagnostics surfaces + // (#239) and the bookmark/hyperlink commands (#233) joined the implemented + // set in epic #231. + QCOMPARE(implemented, 71); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() @@ -1781,6 +1790,145 @@ void DocumentFacadeTest::inspectionStopsWhenContextIsDestroyed() QVERIFY(!facade.beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); } +void DocumentFacadeTest::authoringEditAdvancesTheRevisionAndWritesTheOutline() +{ + Harness harness; + harness.loader.nextResult.document = buildDocument(3); + harness.facade->open(QStringLiteral("/corpus/bookmarks.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const pdf::PDFRevisionIdentity before = harness.facade->currentRevision(); + const QString documentKey = harness.context.getDocumentIdentity().documentId; + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 2; + request.edit.text = QStringLiteral("Appendix"); + request.revision = before; + + const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); + QVERIFY2(outcome.applied, qPrintable(outcome.typedError)); + QCOMPARE(outcome.resolution, pdf::PDFAuthoringEditResult::Kind::BookmarkAdded); + QVERIFY(outcome.revision != before); + QCOMPARE(outcome.revision, harness.facade->currentRevision()); + QVERIFY(!harness.facade->revisionSource().isCurrent(before)); + + // The edit is a document mutation: dirty, unsaved, and the published fence + // follows the revision the edit produced. + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); + QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); + + // Read back through Core: the outline carries the bookmark. + QString typedError; + const std::optional> records = + pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); + QVERIFY2(records.has_value(), qPrintable(typedError)); + QCOMPARE(records->size(), 1); + QCOMPARE(records->at(0).title, QStringLiteral("Appendix")); + QCOMPARE(records->at(0).pageIndex, 2); +} + +void DocumentFacadeTest::authoringEditRefusesAStaleRevisionWithoutMutating() +{ + Harness harness; + harness.loader.nextResult.document = buildDocument(2); + harness.facade->open(QStringLiteral("/corpus/stale.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + // The revision the operator acted on. + const pdf::PDFRevisionIdentity displayed = harness.facade->currentRevision(); + + // The document moves underneath it, as a background save or another edit + // would. + harness.facade->markModified(); + QVERIFY(!harness.facade->revisionSource().isCurrent(displayed)); + const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); + const pdf::PDFRevisionIdentity revisionBefore = harness.facade->currentRevision(); + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 1; + request.revision = displayed; + + const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); + QVERIFY(!outcome.applied); + QCOMPARE(outcome.typedError, QStringLiteral("edit/stale-revision")); + // A refusal produces no revision at all. + QVERIFY(!outcome.revision.isValid()); + + // Nothing moved: the same document object is open at the same revision. + QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); + QCOMPARE(harness.facade->currentRevision(), revisionBefore); + + QString typedError; + const std::optional> records = + pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); + QVERIFY(records.has_value()); + QVERIFY(records->isEmpty()); +} + +void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() +{ + Harness harness; + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 0; + request.revision = harness.facade->currentRevision(); + + const pdfinteraction::DocumentEditOutcome noDocument = harness.facade->applyAuthoringEdit(request); + QVERIFY(!noDocument.applied); + QCOMPARE(noDocument.typedError, QStringLiteral("edit/document-not-ready")); + + // A document whose user permissions deny Modify refuses the edit with the + // same visible reason the operator-facing command reports. The document is + // read back from an encrypted file, so its security handler carries the + // user's own permission bits exactly as an opened document does. + using Algorithm = pdf::PDFSecurityHandlerFactory::Algorithm; + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("restricted.pdf")); + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + pdf::PDFSecurityHandlerFactory::SecuritySettings settings; + settings.algorithm = Algorithm::AES_256; + settings.permissions = 0; + settings.userPassword = QStringLiteral("fixture-user"); + settings.ownerPassword = QStringLiteral("fixture-owner"); + settings.id = builder.build().getIdPart(0); + builder.setSecurityHandler(pdf::PDFSecurityHandlerFactory::createSecurityHandler(settings)); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + pdf::PDFDocumentReader reader(nullptr, [](bool* accepted) + { + if (accepted) + { + *accepted = true; + } + return QStringLiteral("fixture-user"); + }, true, false); + pdf::PDFDocument restricted = reader.readFromFile(path); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + harness.loader.nextResult.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(std::move(restricted))); + harness.facade->open(QStringLiteral("/corpus/restricted.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QVERIFY(harness.facade->permissions().encrypted); + QVERIFY(!harness.facade->permissions().modify); + + request.revision = harness.facade->currentRevision(); + const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); + const pdfinteraction::DocumentEditOutcome denied = harness.facade->applyAuthoringEdit(request); + QVERIFY(!denied.applied); + QCOMPARE(denied.typedError, QStringLiteral("edit/modify-permission-denied")); + QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); +} + QTEST_GUILESS_MAIN(DocumentFacadeTest) #include "tst_documentfacadetest.moc" diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index daf1d7d1..5fe6dafb 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -57,10 +57,14 @@ #include "quickdocumentmodel.h" #include "pdfapplicationidentity.h" +#include "pdfauthoringedit.h" +#include "pdfannotation.h" #include "pdfblockingthreadguard.h" +#include "pdfcatalog.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentwriter.h" #include "pdfdocumentreader.h" +#include "pdfpage.h" #include "quickoutput.h" #include #include @@ -79,6 +83,81 @@ namespace { +/// Writes a `pageCount`-page fixture and returns its path, or an empty string +/// when the write failed. +QString writePagesFixture(const QString& path, int pageCount) +{ + pdf::PDFDocumentBuilder builder; + for (int page = 0; page < pageCount; ++page) + { + builder.appendPage(QRectF(0, 0, 612, 792)); + } + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + const pdf::PDFOperationResult written = writer.write(path, &document, true); + return written ? path : QString(); +} + +/// The terminal result of one invocation, which the authoring handlers emit +/// synchronously. +std::optional terminalResultFor(QSignalSpy& finished, quint64 invocation) +{ + for (int index = 0; index < finished.count(); ++index) + { + const auto result = finished.at(index).at(0).value(); + if (result.invocation == invocation) + { + return result; + } + } + return std::nullopt; +} + +/// Waits until no page-surface render is in flight. Reporting viewport geometry +/// asks for the first page surfaces, and the render path borrows the open +/// document for the duration of a render -- so a test that replaces the +/// document next lets that initial demand settle first instead of racing it. +void waitForSurfacesToSettle(EditorHost& host) +{ + pdfinteraction::PageSurfaceCoordinator* surfaces = host.sessionForTest()->surfaces(); + QTRY_VERIFY_WITH_TIMEOUT(surfaces->counters().inFlight == 0, 15000); +} + +const pdf::PDFLinkAnnotation* linkOnPage(const pdf::PDFDocument* document, int pageIndex) +{ + if (!document) + { + return nullptr; + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(pageIndex); + if (!page) + { + return nullptr; + } + + for (const pdf::PDFObjectReference& reference : page->getAnnotations()) + { + const pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), reference); + if (const auto* link = dynamic_cast(annotation.data())) + { + return link; + } + } + return nullptr; +} + +int destinationPageIndex(const pdf::PDFDocument* document, const pdf::PDFDestination& destination) +{ + if (!destination.getPageReference().isValid()) + { + return -1; + } + + const size_t index = document->getCatalog()->getPageIndexFromPageReference(destination.getPageReference()); + return index == pdf::PDFCatalog::INVALID_PAGE_INDEX ? -1 : static_cast(index); +} + bool writeEncryptedFixture(const QString& path, pdf::PDFSecurityHandlerFactory::Algorithm algorithm, const QString& userPassword = QStringLiteral("fixture-user"), quint32 permissions = 0) { @@ -346,11 +425,19 @@ private slots: /// Issue #232: extraction refuses to overwrite an existing file up front. void imageExtractionRefusesToOverwriteAnExistingFile(); + + // Bookmarks and hyperlinks (#233, ADR-014). + void bookmarkCommandsWriteTheOutlineThroughTheCatalog(); + void bookmarkImportExportRoundTripsAndRefusesAStaleRevision(); + void hyperlinkCommandsWriteEveryDestinationVariant_data(); + void hyperlinkCommandsWriteEveryDestinationVariant(); + void restrictedDocumentCannotEditBookmarksOrLinks(); }; void EditorHostTest::init() { QStandardPaths::setTestModeEnabled(true); + qRegisterMetaType("pdfinteraction::CommandResult"); m_fixtureRecipes = std::make_unique(); QVERIFY(m_fixtureRecipes->isValid()); } @@ -3740,5 +3827,356 @@ void EditorHostTest::imageExtractionRefusesToOverwriteAnExistingFile() QCOMPARE(untouched.readAll(), QByteArray("keep-me")); } +void EditorHostTest::bookmarkCommandsWriteTheOutlineThroughTheCatalog() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = writePagesFixture(directory.filePath(QStringLiteral("bookmarks.pdf")), 3); + QVERIFY(!path.isEmpty()); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + QSignalSpy finished(&host.sessionForTest()->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + auto* model = qobject_cast(host.documentModel()); + QVERIFY(model); + QVERIFY(!model->hasOutline()); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionGotoNextBookmark"))); + + // The viewport has not placed a page yet, so there is no page the operator + // is on. The command refuses with its own reason instead of blaming the + // document's page range, which is not what is wrong. + const quint64 unplaced = host.invokeCommand(QStringLiteral("actionBookmarkPage"), + { { QStringLiteral("title"), QStringLiteral("Nowhere") } }); + QVERIFY(unplaced != 0); + const std::optional unplacedResult = terminalResultFor(finished, unplaced); + QVERIFY(unplacedResult.has_value()); + QCOMPARE(unplacedResult->state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(unplacedResult->typedError, QStringLiteral("bookmark/no-current-page")); + + // Once the canvas reports its geometry, the operator is on the first page. + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + host.goToPage(0); + QTRY_COMPARE_WITH_TIMEOUT(host.currentPage(), 0, 15000); + waitForSurfacesToSettle(host); + + // Bookmark the page the operator is on; the outline becomes the bookmark, + // through the catalog and the facade gateway into Core. + const quint64 bookmark = host.invokeCommand(QStringLiteral("actionBookmarkPage"), + { { QStringLiteral("title"), QStringLiteral("Start") } }); + QVERIFY(bookmark != 0); + const std::optional bookmarkResult = terminalResultFor(finished, bookmark); + QVERIFY(bookmarkResult.has_value()); + QVERIFY2(bookmarkResult->state == pdfinteraction::CommandTerminalState::Completed, + qPrintable(bookmarkResult->typedError)); + QVERIFY(bookmarkResult->typedError.isEmpty()); + + const pdf::PDFDocumentPointer document = host.sessionForTest()->context().getDocumentPointer(); + QString typedError; + std::optional> records = + pdf::PDFAuthoringEditWriter::readOutline(document.data(), typedError); + QVERIFY2(records.has_value(), qPrintable(typedError)); + QCOMPARE(records->size(), 1); + QCOMPARE(records->at(0).title, QStringLiteral("Start")); + QCOMPARE(records->at(0).pageIndex, 0); + QVERIFY(model->hasOutline()); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionGotoNextBookmark"))); + + // The same command on the same page removes it again, so the shell never + // silently accumulates duplicates. + const quint64 toggledOff = host.invokeCommand(QStringLiteral("actionBookmarkPage")); + QVERIFY(toggledOff != 0); + const std::optional toggleResult = terminalResultFor(finished, toggledOff); + QVERIFY(toggleResult.has_value()); + QCOMPARE(toggleResult->state, pdfinteraction::CommandTerminalState::Completed); + records = pdf::PDFAuthoringEditWriter::readOutline(host.sessionForTest()->context().getDocumentPointer().data(), + typedError); + QVERIFY(records.has_value()); + QVERIFY(records->isEmpty()); + + // Auto-generate fills in every page that has none. + const quint64 generated = host.invokeCommand(QStringLiteral("actionBookmarkAutoGenerate")); + QVERIFY(generated != 0); + const std::optional generatedResult = terminalResultFor(finished, generated); + QVERIFY(generatedResult.has_value()); + QCOMPARE(generatedResult->state, pdfinteraction::CommandTerminalState::Completed); + records = pdf::PDFAuthoringEditWriter::readOutline(host.sessionForTest()->context().getDocumentPointer().data(), + typedError); + QVERIFY(records.has_value()); + QCOMPARE(records->size(), 3); + QCOMPARE(records->at(0).title, QStringLiteral("Page 1")); + QCOMPARE(records->at(2).title, QStringLiteral("Page 3")); + + // A second run has nothing to generate: it refuses with a visible reason and + // writes no revision. + const pdf::PDFRevisionIdentity revisionBefore = host.sessionForTest()->facade().currentRevision(); + const quint64 again = host.invokeCommand(QStringLiteral("actionBookmarkAutoGenerate")); + QVERIFY(again != 0); + const std::optional againResult = terminalResultFor(finished, again); + QVERIFY(againResult.has_value()); + QCOMPARE(againResult->state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(againResult->typedError, QStringLiteral("bookmark/nothing-to-generate")); + QCOMPARE(host.sessionForTest()->facade().currentRevision(), revisionBefore); + + // Bookmark navigation moves the viewport and edits nothing. + host.goToPage(0); + const quint64 next = host.invokeCommand(QStringLiteral("actionGotoNextBookmark")); + QVERIFY(next != 0); + const std::optional nextResult = terminalResultFor(finished, next); + QVERIFY(nextResult.has_value()); + QCOMPARE(nextResult->state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(host.currentPage(), 1); + QCOMPARE(host.sessionForTest()->facade().currentRevision(), revisionBefore); + + const quint64 previous = host.invokeCommand(QStringLiteral("actionGotoPreviousBookmark")); + QVERIFY(previous != 0); + const std::optional previousResult = terminalResultFor(finished, previous); + QVERIFY(previousResult.has_value()); + QCOMPARE(previousResult->state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(host.currentPage(), 0); + QCOMPARE(host.sessionForTest()->facade().currentRevision(), revisionBefore); +} + +void EditorHostTest::bookmarkImportExportRoundTripsAndRefusesAStaleRevision() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = writePagesFixture(directory.filePath(QStringLiteral("source.pdf")), 3); + const QString targetPath = writePagesFixture(directory.filePath(QStringLiteral("target.pdf")), 3); + QVERIFY(!sourcePath.isEmpty()); + QVERIFY(!targetPath.isEmpty()); + + EditorHost source(nullptr, nullptr, m_fixtureRecipes->path()); + source.openFileUrl(QUrl::fromLocalFile(sourcePath)); + QTRY_VERIFY_WITH_TIMEOUT(source.hasDocument(), 15000); + // The operator is on the first page when the bookmark commands run. + source.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + source.goToPage(0); + QTRY_COMPARE_WITH_TIMEOUT(source.currentPage(), 0, 15000); + waitForSurfacesToSettle(source); + + QSignalSpy finished(&source.sessionForTest()->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + QVERIFY(source.invokeCommand(QStringLiteral("actionBookmarkPage"), + { { QStringLiteral("title"), QStringLiteral("Chapter one") } }) != 0); + QVERIFY(source.invokeCommand(QStringLiteral("actionBookmarkAutoGenerate")) != 0); + + const pdf::PDFDocumentPointer sourceDocument = source.sessionForTest()->context().getDocumentPointer(); + QString typedError; + const std::optional> sourceRecords = + pdf::PDFAuthoringEditWriter::readOutline(sourceDocument.data(), typedError); + QVERIFY2(sourceRecords.has_value(), qPrintable(typedError)); + QCOMPARE(sourceRecords->size(), 3); + + // Export through the catalog writes the interchange file. + const QString bookmarkPath = directory.filePath(QStringLiteral("bookmarks.json")); + const quint64 exported = source.invokeCommand(QStringLiteral("actionBookmarkExport"), + { { QStringLiteral("path"), bookmarkPath } }); + QVERIFY(exported != 0); + const std::optional exportedResult = terminalResultFor(finished, exported); + QVERIFY(exportedResult.has_value()); + QCOMPARE(exportedResult->state, pdfinteraction::CommandTerminalState::Completed); + + QFile exportedFile(bookmarkPath); + QVERIFY(exportedFile.open(QIODevice::ReadOnly)); + const QByteArray exportedBytes = exportedFile.readAll(); + exportedFile.close(); + const std::optional parsedFile = pdf::PDFBookmarkFile::fromJson(exportedBytes, typedError); + QVERIFY2(parsedFile.has_value(), qPrintable(typedError)); + QCOMPARE(parsedFile->bookmarks, *sourceRecords); + + // Import into a different document: the outline becomes the file, and the + // records survive without loss. + EditorHost target(nullptr, nullptr, m_fixtureRecipes->path()); + target.openFileUrl(QUrl::fromLocalFile(targetPath)); + QTRY_VERIFY_WITH_TIMEOUT(target.hasDocument(), 15000); + target.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + target.goToPage(0); + QTRY_COMPARE_WITH_TIMEOUT(target.currentPage(), 0, 15000); + waitForSurfacesToSettle(target); + + QSignalSpy targetFinished(&target.sessionForTest()->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + const quint64 imported = target.invokeCommand(QStringLiteral("actionBookmarkImport"), + { { QStringLiteral("path"), bookmarkPath } }); + QVERIFY(imported != 0); + const std::optional importedResult = terminalResultFor(targetFinished, imported); + QVERIFY(importedResult.has_value()); + QCOMPARE(importedResult->state, pdfinteraction::CommandTerminalState::Completed); + + const std::optional> targetRecords = + pdf::PDFAuthoringEditWriter::readOutline(target.sessionForTest()->context().getDocumentPointer().data(), + typedError); + QVERIFY(targetRecords.has_value()); + QCOMPARE(*targetRecords, *sourceRecords); + + // A malformed file is refused with a typed reason and changes nothing. + const QString malformedPath = directory.filePath(QStringLiteral("malformed.json")); + QFile malformed(malformedPath); + QVERIFY(malformed.open(QIODevice::WriteOnly)); + malformed.write("{\"schema_version\":1,\"bookmarks\":[{\"page\":0}]}"); + malformed.close(); + const pdf::PDFRevisionIdentity revisionBefore = target.sessionForTest()->facade().currentRevision(); + const quint64 refused = target.invokeCommand(QStringLiteral("actionBookmarkImport"), + { { QStringLiteral("path"), malformedPath } }); + QVERIFY(refused != 0); + const std::optional refusedResult = terminalResultFor(targetFinished, refused); + QVERIFY(refusedResult.has_value()); + QCOMPARE(refusedResult->state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(refusedResult->typedError, QStringLiteral("bookmark/invalid-file")); + QCOMPARE(target.sessionForTest()->facade().currentRevision(), revisionBefore); + + // The failure case of #233: an edit carrying a revision the document has + // left is refused with a visible reason and writes nothing. + const QString displayed = target.sessionForTest()->facade().currentRevision().toString(); + QVERIFY(target.invokeCommand(QStringLiteral("actionBookmarkPage")) != 0); + QVERIFY(target.sessionForTest()->facade().currentRevision().toString() != displayed); + + const quint64 stale = target.invokeCommand(QStringLiteral("actionBookmarkPage"), + { { QStringLiteral("revision"), displayed } }); + QVERIFY(stale != 0); + const std::optional staleResult = terminalResultFor(targetFinished, stale); + QVERIFY(staleResult.has_value()); + QCOMPARE(staleResult->state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(staleResult->typedError, QStringLiteral("edit/stale-revision")); +} + +void EditorHostTest::hyperlinkCommandsWriteEveryDestinationVariant_data() +{ + QTest::addColumn("command"); + QTest::addColumn("destinationType"); + QTest::addColumn("inheritZoom"); + + QTest::newRow("Fit") << QStringLiteral("actionCreateHyperlinkToThisPDFFit") << int(pdf::DestinationType::Fit) << false; + QTest::newRow("FitB") << QStringLiteral("actionCreateHyperlinkToThisPDFFitB") << int(pdf::DestinationType::FitB) << false; + QTest::newRow("FitBH") << QStringLiteral("actionCreateHyperlinkToThisPDFFitBH") << int(pdf::DestinationType::FitBH) << false; + QTest::newRow("FitBV") << QStringLiteral("actionCreateHyperlinkToThisPDFFitBV") << int(pdf::DestinationType::FitBV) << false; + QTest::newRow("FitH") << QStringLiteral("actionCreateHyperlinkToThisPDFFitH") << int(pdf::DestinationType::FitH) << false; + QTest::newRow("FitR") << QStringLiteral("actionCreateHyperlinkToThisPDFFitR") << int(pdf::DestinationType::FitR) << false; + QTest::newRow("FitV") << QStringLiteral("actionCreateHyperlinkToThisPDFFitV") << int(pdf::DestinationType::FitV) << false; + QTest::newRow("XYZ") << QStringLiteral("actionCreateHyperlinkToThisPDFXYZ") << int(pdf::DestinationType::XYZ) << false; + QTest::newRow("XYZInheritZoom") << QStringLiteral("actionCreateHyperlinkToThisPDFXYZInheritZoom") + << int(pdf::DestinationType::XYZ) << true; +} + +void EditorHostTest::hyperlinkCommandsWriteEveryDestinationVariant() +{ + QFETCH(QString, command); + QFETCH(int, destinationType); + QFETCH(bool, inheritZoom); + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = writePagesFixture(directory.filePath(QStringLiteral("hyperlinks.pdf")), 2); + QVERIFY(!path.isEmpty()); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + QVERIFY(host.isCommandEnabled(command)); + + QSignalSpy finished(&host.sessionForTest()->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + const quint64 invocation = host.invokeCommand(command, + { { QStringLiteral("page"), 0 }, + { QStringLiteral("left"), 10.0 }, + { QStringLiteral("bottom"), 20.0 }, + { QStringLiteral("right"), 60.0 }, + { QStringLiteral("top"), 40.0 }, + { QStringLiteral("targetPage"), 1 }, + { QStringLiteral("targetLeft"), 5.0 }, + { QStringLiteral("targetBottom"), 6.0 }, + { QStringLiteral("targetRight"), 70.0 }, + { QStringLiteral("targetTop"), 90.0 }, + { QStringLiteral("zoom"), 1.5 } }); + QVERIFY(invocation != 0); + const std::optional result = terminalResultFor(finished, invocation); + QVERIFY(result.has_value()); + QCOMPARE(result->state, pdfinteraction::CommandTerminalState::Completed); + + // Read back through Core: the link annotation carries the destination the + // command named, and the destination carries its own payload. + const pdf::PDFDocumentPointer document = host.sessionForTest()->context().getDocumentPointer(); + const pdf::PDFLinkAnnotation* link = linkOnPage(document.data(), 0); + QVERIFY(link); + QCOMPARE(link->getRectangle(), QRectF(QPointF(10, 20), QPointF(60, 40))); + + const pdf::PDFActionGoTo* goTo = dynamic_cast(link->getAction()); + QVERIFY(goTo); + const pdf::PDFDestination& destination = goTo->getDestination(); + QCOMPARE(int(destination.getDestinationType()), destinationType); + QCOMPARE(destinationPageIndex(document.data(), destination), 1); + + switch (destination.getDestinationType()) + { + case pdf::DestinationType::Fit: + case pdf::DestinationType::FitB: + QVERIFY(!destination.hasLeft()); + QVERIFY(!destination.hasTop()); + break; + + case pdf::DestinationType::FitH: + case pdf::DestinationType::FitBH: + QCOMPARE(destination.getTop(), 90.0); + break; + + case pdf::DestinationType::FitV: + case pdf::DestinationType::FitBV: + QCOMPARE(destination.getLeft(), 5.0); + break; + + case pdf::DestinationType::FitR: + QCOMPARE(destination.getLeft(), 5.0); + QCOMPARE(destination.getBottom(), 6.0); + QCOMPARE(destination.getRight(), 70.0); + QCOMPARE(destination.getTop(), 90.0); + break; + + case pdf::DestinationType::XYZ: + QCOMPARE(destination.getLeft(), 5.0); + QCOMPARE(destination.getTop(), 90.0); + // The inherit-zoom variant writes the PDF null zoom, which reads + // back as "no zoom"; the plain XYZ keeps the requested zoom. + QCOMPARE(destination.hasZoom(), !inheritZoom); + if (!inheritZoom) + { + QCOMPARE(destination.getZoom(), 1.5); + } + break; + + case pdf::DestinationType::Invalid: + case pdf::DestinationType::Named: + QFAIL("the command must never write an invalid destination"); + } +} + +void EditorHostTest::restrictedDocumentCannotEditBookmarksOrLinks() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("restricted-bookmarks.pdf")); + // AES-256 with no user permissions: the user may read but not modify. + QVERIFY(writeEncryptedFixture(path, pdf::PDFSecurityHandlerFactory::AES_256, QStringLiteral("fixture-user"), 0)); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.passwordRequestId() != 0, 10000); + QVERIFY(host.submitPassword(host.passwordRequestId(), QStringLiteral("fixture-user"))); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + QVERIFY(host.sessionForTest()->facade().permissions().encrypted); + QVERIFY(!host.sessionForTest()->facade().permissions().modify); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionBookmarkPage"))); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionBookmarkAutoGenerate"))); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionBookmarkImport"))); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionCreateHyperlink"))); + QVERIFY(!host.isCommandEnabled(QStringLiteral("actionCreateHyperlinkToThisPDFFit"))); + + // A disabled command starts nothing at all: there is no path from the menu + // to a half-written outline. + QCOMPARE(host.invokeCommand(QStringLiteral("actionBookmarkPage")), quint64(0)); + QCOMPARE(host.invokeCommand(QStringLiteral("actionCreateHyperlink"), + { { QStringLiteral("uri"), QStringLiteral("https://example.org") } }), + quint64(0)); +} + QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" diff --git a/UnitTests/tst_productoperatorloop.cpp b/UnitTests/tst_productoperatorloop.cpp index 7b8467ae..f501d9df 100644 --- a/UnitTests/tst_productoperatorloop.cpp +++ b/UnitTests/tst_productoperatorloop.cpp @@ -526,6 +526,7 @@ private slots: void partialInspectionStaysVisibleAndNeverLooksLikeAClearDocument(); void workspaceTransitionsKeepTheOpenDocumentBound(); void canvasBindingClearsWhenTheDocumentCloses(); + void bookmarkEditsReachTheCanvasAccessibleSummary(); void cancellationLeavesNoAcceptedResult(); void adapterParityOverOneInspectionReceipt(); void governedPublicationParityAcrossSurfaces(); @@ -991,6 +992,37 @@ void ProductOperatorLoopTest::canvasBindingClearsWhenTheDocumentCloses() QCOMPARE(canvas->accessibleDocumentSummary(), QStringLiteral("No document is currently open.")); } +void ProductOperatorLoopTest::bookmarkEditsReachTheCanvasAccessibleSummary() +{ + const QString pdfPath = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); + QVERIFY(QFileInfo::exists(pdfPath)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(pdfPath)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + host.setViewportGeometry(96.0 / 25.4, 1.0, 640, 480); + + QQuickWindow window; + window.resize(640, 480); + auto* canvas = new pdfquick::LoopCanvasItem(window.contentItem()); + canvas->setSize(QSizeF(640, 480)); + host.attachCanvas(canvas); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTRY_VERIFY_WITH_TIMEOUT(canvas->viewport() != nullptr, 5000); + + // No bookmark yet: the one accessible canvas node says nothing about them. + QVERIFY(!canvas->accessibleDocumentSummary().contains(QStringLiteral("bookmark(s)."))); + + // Auto-generate writes the outline, and the count reaches the same summary + // a screen reader reads, so the document change is announced with text and + // not only as a visual list change. + QVERIFY(host.invokeCommand(QStringLiteral("actionBookmarkAutoGenerate")) != 0); + QTRY_VERIFY_WITH_TIMEOUT(canvas->accessibleDocumentSummary().contains(QStringLiteral("bookmark(s).")), 5000); + + host.detachCanvas(); +} + void ProductOperatorLoopTest::cancellationLeavesNoAcceptedResult() { const QString pdfPath = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index 1daeaabc..b868649d 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -65,10 +65,16 @@ "target": "Inspect", "command": { "label_key": "command.actionBookmarkAutoGenerate.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -78,10 +84,21 @@ "target": "Inspect", "command": { "label_key": "command.actionBookmarkExport.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -91,10 +108,21 @@ "target": "Inspect", "command": { "label_key": "command.actionBookmarkImport.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "path", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -107,10 +135,21 @@ "shortcut": { "sequence": "Ctrl+M" }, - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "title", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -298,10 +337,46 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlink.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "uri", + "type": "string", + "required": true + }, + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -311,10 +386,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFit.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -324,10 +460,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitB.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -337,10 +534,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitBH.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -350,10 +608,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitBV.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -363,10 +682,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitH.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -376,10 +756,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitR.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -389,10 +830,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitV.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -402,10 +904,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFXYZ.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -415,10 +978,71 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFXYZInheritZoom.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": false + }, + { + "name": "left", + "type": "number", + "required": false + }, + { + "name": "bottom", + "type": "number", + "required": false + }, + { + "name": "right", + "type": "number", + "required": false + }, + { + "name": "top", + "type": "number", + "required": false + }, + { + "name": "targetPage", + "type": "integer", + "required": false + }, + { + "name": "targetLeft", + "type": "number", + "required": false + }, + { + "name": "targetBottom", + "type": "number", + "required": false + }, + { + "name": "targetRight", + "type": "number", + "required": false + }, + { + "name": "targetTop", + "type": "number", + "required": false + }, + { + "name": "zoom", + "type": "number", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -739,9 +1363,9 @@ "sequence": "Ctrl+." }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -787,9 +1411,9 @@ "sequence": "Ctrl+," }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index 7b9037a7..e4186e68 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -6,24 +6,10 @@ "unbound": [ "actionAutomaticDocumentRefresh", "actionBleedFixup", - "actionBookmarkAutoGenerate", - "actionBookmarkExport", - "actionBookmarkImport", - "actionBookmarkPage", "actionClearRecentFileHistory", "actionCreateBitonalDocument", "actionCreateEllipse", "actionCreateFreehandCurve", - "actionCreateHyperlink", - "actionCreateHyperlinkToThisPDFFit", - "actionCreateHyperlinkToThisPDFFitB", - "actionCreateHyperlinkToThisPDFFitBH", - "actionCreateHyperlinkToThisPDFFitBV", - "actionCreateHyperlinkToThisPDFFitH", - "actionCreateHyperlinkToThisPDFFitR", - "actionCreateHyperlinkToThisPDFFitV", - "actionCreateHyperlinkToThisPDFXYZ", - "actionCreateHyperlinkToThisPDFXYZInheritZoom", "actionCreatePolygon", "actionCreatePolyline", "actionCreateRectangle", @@ -34,8 +20,6 @@ "actionCreateTextUnderline", "actionEncryption", "actionFirstPageOnRightSide", - "actionGotoNextBookmark", - "actionGotoPreviousBookmark", "actionInlineText", "actionInsertPageNumbers", "actionOptimize", diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index a221f150..feaa1bd7 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -112,6 +112,22 @@ "actionPrint", "actionExportDocument", "actionUndo", + "actionBookmarkAutoGenerate", + "actionBookmarkExport", + "actionBookmarkImport", + "actionBookmarkPage", + "actionCreateHyperlink", + "actionCreateHyperlinkToThisPDFFit", + "actionCreateHyperlinkToThisPDFFitB", + "actionCreateHyperlinkToThisPDFFitBH", + "actionCreateHyperlinkToThisPDFFitBV", + "actionCreateHyperlinkToThisPDFFitH", + "actionCreateHyperlinkToThisPDFFitR", + "actionCreateHyperlinkToThisPDFFitV", + "actionCreateHyperlinkToThisPDFXYZ", + "actionCreateHyperlinkToThisPDFXYZInheritZoom", + "actionGotoNextBookmark", + "actionGotoPreviousBookmark", "actionRedo", } ) From 1d0a5bb4448b44525d18ff4e277b3da159093630 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:38:45 -0700 Subject: [PATCH 04/11] feat(quick): annotation authoring through the direct-edit path with per-edit history (#234) --- LoopEditor/editorhost.cpp | 64 +- LoopLibCore/CMakeLists.txt | 4 + .../sources/pdfannotationauthoring.cpp | 398 +++++++++ LoopLibCore/sources/pdfannotationauthoring.h | 130 +++ LoopLibCore/sources/pdfauthoringhistory.cpp | 156 ++++ LoopLibCore/sources/pdfauthoringhistory.h | 63 ++ LoopLibInteraction/CMakeLists.txt | 3 + .../sources/directeditrequest.cpp | 343 ++++++++ .../sources/directeditrequest.h | 121 +++ LoopLibInteraction/sources/documentfacade.h | 22 + .../sources/documentfacadeedits.cpp | 240 ++++++ .../sources/documentfacadestate.cpp | 3 +- UnitTests/phase4-tests.cmake | 18 + UnitTests/tst_annotationauthoringtest.cpp | 799 ++++++++++++++++++ UnitTests/tst_documentfacadetest.cpp | 7 +- UnitTests/tst_editorhosttest.cpp | 103 +++ agent-policy.json | 3 +- docs/generated/architecture-catalog.json | 1 + docs/loop-shell-actions.json | 551 ++++++++++-- scripts/verify-command-catalog.py | 21 + 20 files changed, 2990 insertions(+), 60 deletions(-) create mode 100644 LoopLibCore/sources/pdfannotationauthoring.cpp create mode 100644 LoopLibCore/sources/pdfannotationauthoring.h create mode 100644 LoopLibCore/sources/pdfauthoringhistory.cpp create mode 100644 LoopLibCore/sources/pdfauthoringhistory.h create mode 100644 LoopLibInteraction/sources/directeditrequest.cpp create mode 100644 LoopLibInteraction/sources/directeditrequest.h create mode 100644 LoopLibInteraction/sources/documentfacadeedits.cpp create mode 100644 UnitTests/tst_annotationauthoringtest.cpp diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 7749975a..a3361f3a 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -33,6 +33,7 @@ #include "pagesurfacecoordinator.h" #include "actionlistcontroller.h" #include "actionlistrunsubmitter.h" +#include "directeditrequest.h" #include "repairparameterschema.h" #include "preflightcontroller.h" #include "shellglossary.h" @@ -45,6 +46,7 @@ #include "pdfdocumentsession.h" #include "pdfdocumentimageextractor.h" +#include "pdfannotationauthoring.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" @@ -4537,10 +4539,17 @@ bool EditorHost::commandPermissionAllowed(const QString& commandId) const return permissions.copy; } if (commandId == MoveSelectionCommandId || commandId == QLatin1String("actionPageGeometry") || - commandId == QLatin1String("actionInsertPageNumbers") || commandId == QLatin1String("actionStickyNoteInsert")) + commandId == QLatin1String("actionInsertPageNumbers")) { return permissions.allowsCorrection(); } + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + if (commandId == entry.first) + { + return permissions.modifyAnnotations; + } + } return true; } @@ -5401,6 +5410,51 @@ void EditorHost::registerFeatureHandlers() bumpPresentation(); }; m_session->catalog().setHandler(id, std::move(navigation)); + // ---- Annotation authoring (issue #234) --------------------------------- + // One handler per declared annotation command. The handler parses geometry + // at this boundary, submits the typed request to the facade's direct-edit + // gateway (ADR-014) and surfaces the gateway's typed refusal; it never + // touches the document itself. An accepted edit is a new revision, and the + // revision-changed path already refreshes the canvas and the annotation + // overlay, so no annotation state is cached here. + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + const pdf::AnnotationAuthoringSubtype subtype = entry.second; + pdfinteraction::CommandCatalog::Handler authoring; + authoring.invoke = [this, subtype](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + QString refusal; + const std::optional parsed = + pdfinteraction::directEditRequestFromParameters(subtype, parameters, &refusal); + if (!parsed) + { + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, refusal); + bumpPresentation(); + return; + } + + pdfinteraction::DirectEditRequest request = *parsed; + request.revision = m_session->facade().currentRevision(); + request.operatorIdentity = QStringLiteral("Editor"); + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); + if (!outcome.accepted) + { + // A menu command that changed nothing must say why, in the same + // announcement channel every other operator-visible outcome uses. + announceDocumentState(tr("The annotation was not created: %1").arg(outcome.typedError)); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, outcome.typedError); + } + else + { + announceDocumentState(tr("Annotation added.")); + // The annotation exists. A provenance failure is reported on the + // completed invocation instead of pretending the edit did not land. + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed, + outcome.historyAppended ? QString() : outcome.historyError); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(entry.first, std::move(authoring)); } refreshFeatureAvailability(); @@ -5487,6 +5541,14 @@ void EditorHost::refreshFeatureAvailability() for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) { availability.insert(id, ready && m_documentModel.hasOutline()); + // Annotation authoring (issue #234): available with a document whose + // modify-annotations permission is granted, so a restricted document cannot + // offer an edit the gateway would refuse. The ids come from the one table + // the handlers were registered from. + const bool annotationAuthoring = ready && m_session->facade().permissions().modifyAnnotations; + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + availability.insert(entry.first, annotationAuthoring); } m_session->catalog().setEnabledBatch(availability); diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index e536c686..dea83696 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -28,6 +28,8 @@ add_library(LoopLibCore SHARED sources/pdfalgorithmlcs.h sources/pdfannotation.cpp sources/pdfannotation.h + sources/pdfannotationauthoring.cpp + sources/pdfannotationauthoring.h sources/pdfblendfunction.cpp sources/pdfblendfunction.h sources/pdfccittfaxdecoder.cpp @@ -288,6 +290,8 @@ add_library(LoopLibCore SHARED sources/pdfartifactidentity.cpp sources/pdfartifactstore.h sources/pdfartifactstore.cpp + sources/pdfauthoringhistory.h + sources/pdfauthoringhistory.cpp sources/pdfoperationhistory.h sources/pdfoperationhistory.cpp sources/pdfoperationhistorystore.h diff --git a/LoopLibCore/sources/pdfannotationauthoring.cpp b/LoopLibCore/sources/pdfannotationauthoring.cpp new file mode 100644 index 00000000..183ecaf8 --- /dev/null +++ b/LoopLibCore/sources/pdfannotationauthoring.cpp @@ -0,0 +1,398 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfannotationauthoring.h" + +#include "pdfdocumentbuilder.h" + +#include + +namespace pdf +{ + +namespace +{ + +/// A rectangle is usable only when it is valid and encloses a positive area. +/// An inverted drag is the caller's to normalize: silently repairing geometry +/// here would create an annotation the operator never drew. +bool isUsableRectangle(const QRectF& rectangle) +{ + return rectangle.isValid() && rectangle.width() > 0.0 && rectangle.height() > 0.0; +} + +QColor defaultColorForSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + return QColor(Qt::yellow); + + default: + return QColor(Qt::red); + } +} + +} // namespace + +const char* getAnnotationAuthoringSubtypeName(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + return "text-highlight"; + case AnnotationAuthoringSubtype::TextUnderline: + return "text-underline"; + case AnnotationAuthoringSubtype::TextStrikeout: + return "text-strikeout"; + case AnnotationAuthoringSubtype::TextSquiggly: + return "text-squiggly"; + case AnnotationAuthoringSubtype::Rectangle: + return "rectangle"; + case AnnotationAuthoringSubtype::Ellipse: + return "ellipse"; + case AnnotationAuthoringSubtype::StraightLine: + return "straight-line"; + case AnnotationAuthoringSubtype::Polyline: + return "polyline"; + case AnnotationAuthoringSubtype::Polygon: + return "polygon"; + case AnnotationAuthoringSubtype::FreehandCurve: + return "freehand-curve"; + case AnnotationAuthoringSubtype::InlineText: + return "inline-text"; + case AnnotationAuthoringSubtype::StickyNoteComment: + return "sticky-note-comment"; + case AnnotationAuthoringSubtype::StickyNoteHelp: + return "sticky-note-help"; + case AnnotationAuthoringSubtype::StickyNoteInsert: + return "sticky-note-insert"; + case AnnotationAuthoringSubtype::StickyNoteKey: + return "sticky-note-key"; + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + return "sticky-note-new-paragraph"; + case AnnotationAuthoringSubtype::StickyNoteNote: + return "sticky-note-note"; + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return "sticky-note-paragraph"; + } + + return "invalid"; +} + +bool parseAnnotationAuthoringSubtype(const QString& name, AnnotationAuthoringSubtype* subtype) +{ + for (const AnnotationAuthoringSubtype candidate : + { AnnotationAuthoringSubtype::TextHighlight, AnnotationAuthoringSubtype::TextUnderline, + AnnotationAuthoringSubtype::TextStrikeout, AnnotationAuthoringSubtype::TextSquiggly, + AnnotationAuthoringSubtype::Rectangle, AnnotationAuthoringSubtype::Ellipse, + AnnotationAuthoringSubtype::StraightLine, AnnotationAuthoringSubtype::Polyline, + AnnotationAuthoringSubtype::Polygon, AnnotationAuthoringSubtype::FreehandCurve, + AnnotationAuthoringSubtype::InlineText, AnnotationAuthoringSubtype::StickyNoteComment, + AnnotationAuthoringSubtype::StickyNoteHelp, AnnotationAuthoringSubtype::StickyNoteInsert, + AnnotationAuthoringSubtype::StickyNoteKey, AnnotationAuthoringSubtype::StickyNoteNewParagraph, + AnnotationAuthoringSubtype::StickyNoteNote, AnnotationAuthoringSubtype::StickyNoteParagraph }) + { + if (name == QLatin1String(getAnnotationAuthoringSubtypeName(candidate))) + { + if (subtype) + { + *subtype = candidate; + } + return true; + } + } + + return false; +} + +namespace +{ + +/// Sticky-note subtypes share one builder primitive; the icon name is the only +/// difference, so the mapping lives here rather than at each call site. +bool isStickyNoteSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return true; + + default: + return false; + } +} + +TextAnnotationIcon stickyNoteIcon(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::StickyNoteComment: + return TextAnnotationIcon::Comment; + case AnnotationAuthoringSubtype::StickyNoteHelp: + return TextAnnotationIcon::Help; + case AnnotationAuthoringSubtype::StickyNoteInsert: + return TextAnnotationIcon::Insert; + case AnnotationAuthoringSubtype::StickyNoteKey: + return TextAnnotationIcon::Key; + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + return TextAnnotationIcon::NewParagraph; + case AnnotationAuthoringSubtype::StickyNoteNote: + return TextAnnotationIcon::Note; + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return TextAnnotationIcon::Paragraph; + + default: + return TextAnnotationIcon::Note; + } +} + +} // namespace + +PDFDocumentPointer createAuthoredAnnotation(const PDFDocument* document, + const AnnotationAuthoringSpec& spec, + AnnotationAuthoringResult* result) +{ + Q_ASSERT(result); + *result = {}; + + if (!document) + { + result->typedError = QStringLiteral("authoring/document-required"); + return nullptr; + } + + const auto verticesAreDistinct = [&spec](qsizetype minimumCount) + { + if (qsizetype(spec.vertices.size()) < minimumCount) + { + return false; + } + for (const QPointF& point : spec.vertices) + { + if (!std::isfinite(point.x()) || !std::isfinite(point.y())) + { + return false; + } + } + return true; + }; + + // Geometry is validated per subtype before the document is copied: a + // refusal must cost nothing and must never leave a half-written annotation. + switch (spec.subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + case AnnotationAuthoringSubtype::TextUnderline: + case AnnotationAuthoringSubtype::TextStrikeout: + case AnnotationAuthoringSubtype::TextSquiggly: + case AnnotationAuthoringSubtype::Rectangle: + case AnnotationAuthoringSubtype::Ellipse: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::StraightLine: + if (spec.vertices.size() != 2 || !verticesAreDistinct(2) || + spec.vertices[0] == spec.vertices[1]) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::Polyline: + case AnnotationAuthoringSubtype::FreehandCurve: + if (!verticesAreDistinct(2)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::Polygon: + if (!verticesAreDistinct(3)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::InlineText: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + if (spec.contents.trimmed().isEmpty()) + { + result->typedError = QStringLiteral("authoring/missing-contents"); + return nullptr; + } + break; + + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + if (!isUsableRectangle(spec.rectangle)) + { + result->typedError = QStringLiteral("authoring/invalid-geometry"); + return nullptr; + } + if (spec.contents.trimmed().isEmpty()) + { + result->typedError = QStringLiteral("authoring/missing-contents"); + return nullptr; + } + break; + } + + // Page references come from the catalog's walked page tree rather than the + // builder's direct Kids array, so a nested page tree addresses pages by the + // same index the rest of Core uses. + const PDFCatalog* catalog = document->getCatalog(); + if (!catalog || spec.pageIndex < 0 || std::size_t(spec.pageIndex) >= catalog->getPageCount()) + { + result->typedError = QStringLiteral("authoring/invalid-page"); + return nullptr; + } + const PDFPage* page = catalog->getPage(std::size_t(spec.pageIndex)); + if (!page) + { + result->typedError = QStringLiteral("authoring/invalid-page"); + return nullptr; + } + + PDFDocumentBuilder builder(document); + const PDFObjectReference pageReference = page->getPageReference(); + const QColor color = spec.color.isValid() ? spec.color : defaultColorForSubtype(spec.subtype); + const QString title = spec.title; + const QString subject = spec.subject; + const QString contents = spec.contents; + const PDFReal borderWidth = spec.borderWidth > 0.0 ? spec.borderWidth : 1.0; + + PDFObjectReference annotation; + switch (spec.subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + annotation = builder.createAnnotationHighlight(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextUnderline: + annotation = builder.createAnnotationUnderline(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextStrikeout: + annotation = builder.createAnnotationStrikeout(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::TextSquiggly: + annotation = builder.createAnnotationSquiggly(pageReference, spec.rectangle, color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::Rectangle: + annotation = builder.createAnnotationSquare(pageReference, spec.rectangle, borderWidth, QColor(), color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::Ellipse: + annotation = builder.createAnnotationCircle(pageReference, spec.rectangle, borderWidth, QColor(), color, title, subject, contents); + break; + case AnnotationAuthoringSubtype::StraightLine: + { + const QRectF bounding = QRectF(spec.vertices[0], spec.vertices[1]).normalized(); + annotation = builder.createAnnotationLine(pageReference, bounding, spec.vertices[0], spec.vertices[1], + borderWidth, QColor(), color, title, subject, contents, + AnnotationLineEnding::None, AnnotationLineEnding::None); + break; + } + case AnnotationAuthoringSubtype::Polyline: + { + QPolygonF polyline; + for (const QPointF& point : spec.vertices) + { + polyline.append(point); + } + annotation = builder.createAnnotationPolyline(pageReference, polyline, borderWidth, QColor(), color, + title, subject, contents, + AnnotationLineEnding::None, AnnotationLineEnding::None); + break; + } + case AnnotationAuthoringSubtype::Polygon: + { + QPolygonF polygon; + for (const QPointF& point : spec.vertices) + { + polygon.append(point); + } + annotation = builder.createAnnotationPolygon(pageReference, polygon, borderWidth, QColor(), color, + title, subject, contents); + break; + } + case AnnotationAuthoringSubtype::FreehandCurve: + { + QPolygonF ink; + for (const QPointF& point : spec.vertices) + { + ink.append(point); + } + annotation = builder.createAnnotationInk(pageReference, ink, borderWidth, color, title, subject, contents); + break; + } + case AnnotationAuthoringSubtype::InlineText: + annotation = builder.createAnnotationFreeText(pageReference, spec.rectangle, title, subject, contents, + Qt::AlignLeft | Qt::AlignTop); + break; + + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + Q_ASSERT(isStickyNoteSubtype(spec.subtype)); + annotation = builder.createAnnotationText(pageReference, spec.rectangle, stickyNoteIcon(spec.subtype), + title, subject, contents, false); + break; + } + + if (!annotation.isValid()) + { + result->typedError = QStringLiteral("authoring/creation-failed"); + return nullptr; + } + + result->created = true; + result->annotation = annotation; + result->page = pageReference; + return PDFDocumentPointer(new PDFDocument(builder.build())); +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfannotationauthoring.h b/LoopLibCore/sources/pdfannotationauthoring.h new file mode 100644 index 00000000..63689535 --- /dev/null +++ b/LoopLibCore/sources/pdfannotationauthoring.h @@ -0,0 +1,130 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFANNOTATIONAUTHORING_H +#define PDFANNOTATIONAUTHORING_H + +#include "pdfdocument.h" +#include "pdfannotation.h" + +#include +#include +#include +#include + +#include + +namespace pdf +{ + +/// The closed set of annotation subtypes the Quick authoring commands create +/// (issue #234). Every value maps onto an existing pdf::AnnotationType through +/// PDFDocumentBuilder's createAnnotation* primitives; no parallel annotation +/// model is introduced. The vocabulary is closed on purpose: an id outside it +/// is refused at the boundary rather than coerced into a default. +enum class AnnotationAuthoringSubtype +{ + TextHighlight, + TextUnderline, + TextStrikeout, + TextSquiggly, + Rectangle, + Ellipse, + StraightLine, + Polyline, + Polygon, + FreehandCurve, + InlineText, + StickyNoteComment, + StickyNoteHelp, + StickyNoteInsert, + StickyNoteKey, + StickyNoteNewParagraph, + StickyNoteNote, + StickyNoteParagraph +}; + +/// Stable name of a subtype, as used by summaries and tests. Never localized. +LOOPLIBCORESHARED_EXPORT const char* getAnnotationAuthoringSubtypeName(AnnotationAuthoringSubtype subtype); + +/// Parses a subtype name. Returns false rather than guessing, so an unknown +/// name is a typed refusal and not a silently defaulted subtype. +LOOPLIBCORESHARED_EXPORT bool parseAnnotationAuthoringSubtype(const QString& name, + AnnotationAuthoringSubtype* subtype); + +/// One typed authoring edit for a single annotation. Geometry is in page space, +/// the coordinate system PDFAnnotation::getRectangle() reads back in. +struct LOOPLIBCORESHARED_EXPORT AnnotationAuthoringSpec +{ + AnnotationAuthoringSubtype subtype = AnnotationAuthoringSubtype::TextHighlight; + PDFInteger pageIndex = 0; + + /// Bounding area for markup, rectangle, ellipse, inline text and the sticky + /// note icon. A sticky note uses this as its icon position and size. + QRectF rectangle; + + /// Vertices for StraightLine (exactly two), Polyline/Polygon (at least + /// three for a polygon, two for a polyline) and FreehandCurve (at least two). + std::vector vertices; + + /// Stroke (and, for closed shapes, interior) colour. An invalid colour + /// selects the subtype's documented default. + QColor color; + + /// Note or inline text. Required for InlineText and the sticky notes. + QString contents; + QString title; + QString subject; + PDFReal borderWidth = 1.0; +}; + +/// Icon rectangle edge length a sticky note is created with. Fixed at the size +/// PDFTextAnnotation draws its icon at, so the rectangle the operator asked for +/// and the rectangle the annotation reads back with agree. +inline constexpr PDFReal StickyNoteIconSize = 32.0; + +/// Outcome of one creation attempt. `created` is false exactly when +/// `typedError` names why nothing was written; there is no partial success. +struct LOOPLIBCORESHARED_EXPORT AnnotationAuthoringResult +{ + bool created = false; + QString typedError; + PDFObjectReference annotation; + PDFObjectReference page; +}; + +/// Creates one annotation of the spec's subtype on a copy of `document`, using +/// the PDFDocumentBuilder machinery that also produces the annotation's normal +/// appearance stream. The input document is never touched: on success the +/// returned pointer owns a complete new revision, and on refusal nullptr is +/// returned with `result->typedError` set. Validation is fail-closed: an +/// unsupported subtype, an out-of-range page or degenerate geometry is refused +/// before anything is built. +/// +/// `result` must not be null. +LOOPLIBCORESHARED_EXPORT PDFDocumentPointer createAuthoredAnnotation(const PDFDocument* document, + const AnnotationAuthoringSpec& spec, + AnnotationAuthoringResult* result); + +} // namespace pdf + +#endif // PDFANNOTATIONAUTHORING_H diff --git a/LoopLibCore/sources/pdfauthoringhistory.cpp b/LoopLibCore/sources/pdfauthoringhistory.cpp new file mode 100644 index 00000000..027a9bc4 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringhistory.cpp @@ -0,0 +1,156 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfauthoringhistory.h" + +#include "pdfartifactstore.h" + +#include +#include +#include + +#include + +namespace pdf +{ + +namespace +{ + +QString sidecarDirectory(const QString& documentPath) +{ + return QFileInfo(documentPath).absoluteFilePath() + QStringLiteral(".loop-history"); +} + +} // namespace + +PDFOperationResult appendAuthoringEditEvent(const QString& documentPath, + const QByteArray& inputBytes, + const QByteArray& outputBytes, + const QString& operationId, + const QString& operatorIdentity, + const QJsonObject& resultSummary, + quint64* sequence) +{ + if (documentPath.trimmed().isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs the document path the sidecar belongs to.")); + } + if (inputBytes.isEmpty() || outputBytes.isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs both the pre-edit and the new revision bytes.")); + } + if (operationId.trimmed().isEmpty() || operatorIdentity.trimmed().isEmpty()) + { + return PDFOperationResult(QStringLiteral("Authoring history needs an operation and an operator identity.")); + } + + const QString directory = sidecarDirectory(documentPath); + if (!QDir().mkpath(directory)) + { + return PDFOperationResult(QStringLiteral("Could not create the history sidecar directory.")); + } + + const PDFArtifactStore artifacts(directory); + const PDFArtifactStoreResult inputImport = artifacts.importBytes( + inputBytes, { QStringLiteral("application/pdf"), QStringLiteral("authoring-input.pdf") }); + if (!inputImport.success) + { + return PDFOperationResult(inputImport.errorMessage); + } + const PDFArtifactStoreResult outputImport = artifacts.importBytes( + outputBytes, { QStringLiteral("application/pdf"), QStringLiteral("authoring-output.pdf") }); + if (!outputImport.success) + { + return PDFOperationResult(outputImport.errorMessage); + } + + PDFOperationHistoryStore history(QDir(directory).filePath(QStringLiteral("history.sqlite3"))); + QString error; + if (!history.open(&error)) + { + return PDFOperationResult(error); + } + + // The first edit's undo target is the as-opened revision; it is registered + // once as the protected original input. Later edits chain onto the previous + // edit's output point, so no new original is registered for them. + QString pointsError; + const QList points = history.rollbackPoints(&pointsError); + if (!pointsError.isEmpty()) + { + return PDFOperationResult(pointsError); + } + const bool inputHasRollbackPoint = std::any_of( + points.cbegin(), points.cend(), + [&inputImport](const PDFRollbackPoint& point) + { return point.documentRevisionDigest == inputImport.artifact.sha256; }); + if (!inputHasRollbackPoint) + { + const PDFOperationResult registered = history.registerOriginalInput(inputImport.artifact); + if (!registered) + { + return registered; + } + } + + PDFOperationHistoryExecution execution; + execution.operationId = operationId; + execution.input = inputImport.artifact; + execution.startedUtc = QDateTime::currentDateTimeUtc(); + QUuid executionId; + if (const PDFOperationResult begun = history.beginExecution(execution, &executionId); !begun) + { + return begun; + } + + PDFOperationHistoryEvent accepted; + accepted.executionId = executionId; + accepted.kind = PDFOperationHistoryEventKind::FixApplied; + accepted.status = PDFOperationHistoryStatus::Accepted; + accepted.operatorIdentity = operatorIdentity; + // The digest describes the revision the edit produced, which is what a later + // undo resolves against (QUICK_REVISION_HISTORY.md). + accepted.documentRevisionDigest = outputImport.artifact.sha256; + accepted.output = outputImport.artifact; + accepted.resultSummary = resultSummary; + accepted.resultSummary.insert(QStringLiteral("model"), QStringLiteral("direct-revision")); + accepted.approval.kind = PDFApprovalKind::System; + accepted.approval.actorId = operatorIdentity; + accepted.approval.decision = QStringLiteral("accept"); + accepted.approval.policyId = QStringLiteral("desktop-authoring"); + accepted.approval.rationale = QStringLiteral("Interactive authoring edit applied as a direct revision (ADR-014)."); + accepted.approval.decidedUtc = QDateTime::currentDateTimeUtc(); + + qint64 appendedSequence = 0; + if (const PDFOperationResult appended = history.appendEvent(accepted, &appendedSequence); !appended) + { + return appended; + } + if (sequence) + { + *sequence = quint64(appendedSequence); + } + return PDFOperationResult(true); +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfauthoringhistory.h b/LoopLibCore/sources/pdfauthoringhistory.h new file mode 100644 index 00000000..b634e9f6 --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringhistory.h @@ -0,0 +1,63 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFAUTHORINGHISTORY_H +#define PDFAUTHORINGHISTORY_H + +#include "pdfoperationhistorystore.h" + +#include +#include +#include + +namespace pdf +{ + +/// Appends one accepted interactive authoring edit to the document's +/// `.loop-history` sidecar, the same chain docs/OPERATION_HISTORY.md defines and +/// issue #124's forward-rollback undo reads: +/// +/// 1. the new revision's bytes are imported into the sidecar's artifact store; +/// 2. the pre-edit revision is registered as the protected original input when +/// the chain holds no rollback point for it yet, so the first edit's undo has +/// a target; +/// 3. one accepted `FixApplied` event is appended; appendEvent creates the +/// matching `PDFRollbackPoint` from the output artifact. +/// +/// The sidecar lives at `.loop-history/history.sqlite3` and is +/// created on first use, so an unsaved interactive session still leaves a +/// verifiable chain beside the document. +/// +/// Returns false with the store's message when the chain could not be extended. +/// The caller's in-memory edit is already applied and is never rolled back here: +/// a provenance failure is reported, not repaired. +LOOPLIBCORESHARED_EXPORT PDFOperationResult appendAuthoringEditEvent(const QString& documentPath, + const QByteArray& inputBytes, + const QByteArray& outputBytes, + const QString& operationId, + const QString& operatorIdentity, + const QJsonObject& resultSummary, + quint64* sequence = nullptr); + +} // namespace pdf + +#endif // PDFAUTHORINGHISTORY_H diff --git a/LoopLibInteraction/CMakeLists.txt b/LoopLibInteraction/CMakeLists.txt index 90c70d28..a387917a 100644 --- a/LoopLibInteraction/CMakeLists.txt +++ b/LoopLibInteraction/CMakeLists.txt @@ -47,10 +47,13 @@ add_library(LoopLibInteraction STATIC sources/commandcatalog.h sources/commandcatalogresource.cpp sources/commandcatalogresource_p.h + sources/directeditrequest.h + sources/directeditrequest.cpp sources/documentloader.cpp sources/documentloader.h sources/documentfacade.cpp sources/documentfacade.h + sources/documentfacadeedits.cpp sources/documentfacadestate.cpp sources/pagesurfacekey.cpp sources/pagesurfacekey.h diff --git a/LoopLibInteraction/sources/directeditrequest.cpp b/LoopLibInteraction/sources/directeditrequest.cpp new file mode 100644 index 00000000..851b66a6 --- /dev/null +++ b/LoopLibInteraction/sources/directeditrequest.cpp @@ -0,0 +1,343 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "directeditrequest.h" + +#include +#include +#include + +#include + +namespace pdfinteraction +{ + +namespace +{ + +using pdf::AnnotationAuthoringSubtype; + +bool isRectangleSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::TextHighlight: + case AnnotationAuthoringSubtype::TextUnderline: + case AnnotationAuthoringSubtype::TextStrikeout: + case AnnotationAuthoringSubtype::TextSquiggly: + case AnnotationAuthoringSubtype::Rectangle: + case AnnotationAuthoringSubtype::Ellipse: + case AnnotationAuthoringSubtype::InlineText: + return true; + + default: + return false; + } +} + +bool isStickyNoteSubtype(AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case AnnotationAuthoringSubtype::StickyNoteComment: + case AnnotationAuthoringSubtype::StickyNoteHelp: + case AnnotationAuthoringSubtype::StickyNoteInsert: + case AnnotationAuthoringSubtype::StickyNoteKey: + case AnnotationAuthoringSubtype::StickyNoteNewParagraph: + case AnnotationAuthoringSubtype::StickyNoteNote: + case AnnotationAuthoringSubtype::StickyNoteParagraph: + return true; + + default: + return false; + } +} + +bool readNumber(const QVariantMap& parameters, const QString& name, double* value) +{ + const QVariant parameter = parameters.value(name); + if (!parameter.isValid()) + { + return false; + } + + bool ok = false; + const double number = parameter.toDouble(&ok); + if (!ok || !std::isfinite(number)) + { + return false; + } + + *value = number; + return true; +} + +/// Parses the `points` scalar: whitespace-separated numbers in x,y pairs, at +/// least `minimumPoints` pairs. A malformed list is refused, never truncated. +bool readPoints(const QString& value, qsizetype minimumPoints, std::vector* points) +{ + static const QRegularExpression separator(QStringLiteral("\\s+")); + + const QString trimmed = value.trimmed(); + if (trimmed.isEmpty()) + { + return false; + } + + const QStringList tokens = trimmed.split(separator, Qt::SkipEmptyParts); + if (tokens.size() % 2 != 0) + { + return false; + } + + std::vector parsed; + parsed.reserve(std::size_t(tokens.size() / 2)); + for (qsizetype index = 0; index < tokens.size(); index += 2) + { + bool xOk = false; + bool yOk = false; + const double x = tokens[index].toDouble(&xOk); + const double y = tokens[index + 1].toDouble(&yOk); + if (!xOk || !yOk || !std::isfinite(x) || !std::isfinite(y)) + { + return false; + } + parsed.emplace_back(x, y); + } + + if (qsizetype(parsed.size()) < minimumPoints) + { + return false; + } + + *points = std::move(parsed); + return true; +} + +bool readOptionalColor(const QVariantMap& parameters, QColor* color) +{ + const QVariant parameter = parameters.value(QStringLiteral("color")); + if (!parameter.isValid()) + { + return true; + } + + const QColor parsed(parameter.toString()); + if (!parsed.isValid()) + { + return false; + } + + *color = parsed; + return true; +} + +} // namespace + +const QList>& annotationAuthoringCommandIds() +{ + static const QList> ids = { + { QStringLiteral("actionCreateTextHighlight"), pdf::AnnotationAuthoringSubtype::TextHighlight }, + { QStringLiteral("actionCreateTextUnderline"), pdf::AnnotationAuthoringSubtype::TextUnderline }, + { QStringLiteral("actionCreateTextStrikeout"), pdf::AnnotationAuthoringSubtype::TextStrikeout }, + { QStringLiteral("actionCreateTextSquiggly"), pdf::AnnotationAuthoringSubtype::TextSquiggly }, + { QStringLiteral("actionCreateRectangle"), pdf::AnnotationAuthoringSubtype::Rectangle }, + { QStringLiteral("actionCreateEllipse"), pdf::AnnotationAuthoringSubtype::Ellipse }, + { QStringLiteral("actionCreateStraightLine"), pdf::AnnotationAuthoringSubtype::StraightLine }, + { QStringLiteral("actionCreatePolyline"), pdf::AnnotationAuthoringSubtype::Polyline }, + { QStringLiteral("actionCreatePolygon"), pdf::AnnotationAuthoringSubtype::Polygon }, + { QStringLiteral("actionCreateFreehandCurve"), pdf::AnnotationAuthoringSubtype::FreehandCurve }, + { QStringLiteral("actionInlineText"), pdf::AnnotationAuthoringSubtype::InlineText }, + { QStringLiteral("actionStickyNoteComment"), pdf::AnnotationAuthoringSubtype::StickyNoteComment }, + { QStringLiteral("actionStickyNoteHelp"), pdf::AnnotationAuthoringSubtype::StickyNoteHelp }, + { QStringLiteral("actionStickyNoteInsert"), pdf::AnnotationAuthoringSubtype::StickyNoteInsert }, + { QStringLiteral("actionStickyNoteKey"), pdf::AnnotationAuthoringSubtype::StickyNoteKey }, + { QStringLiteral("actionStickyNoteNewParagraph"), pdf::AnnotationAuthoringSubtype::StickyNoteNewParagraph }, + { QStringLiteral("actionStickyNoteNote"), pdf::AnnotationAuthoringSubtype::StickyNoteNote }, + { QStringLiteral("actionStickyNoteParagraph"), pdf::AnnotationAuthoringSubtype::StickyNoteParagraph }, + }; + return ids; +} + +bool annotationAuthoringSubtypeForCommand(const QString& commandId, + pdf::AnnotationAuthoringSubtype* subtype) +{ + for (const auto& entry : annotationAuthoringCommandIds()) + { + if (entry.first == commandId) + { + if (subtype) + { + *subtype = entry.second; + } + return true; + } + } + + return false; +} + +std::optional directEditRequestFromParameters( + pdf::AnnotationAuthoringSubtype subtype, const QVariantMap& parameters, QString* typedError) +{ + if (typedError) + { + typedError->clear(); + } + + DirectEditRequest request; + request.kind = DirectEditKind::CreateAnnotation; + request.annotation.subtype = subtype; + + if (!parameters.contains(QStringLiteral("page"))) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/missing-page"); + } + return std::nullopt; + } + bool pageOk = false; + const int pageIndex = parameters.value(QStringLiteral("page")).toInt(&pageOk); + if (!pageOk || pageIndex < 0) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/invalid-page"); + } + return std::nullopt; + } + request.annotation.pageIndex = pageIndex; + + if (isRectangleSubtype(subtype)) + { + double x = 0.0; + double y = 0.0; + double width = 0.0; + double height = 0.0; + if (!readNumber(parameters, QStringLiteral("x"), &x) || + !readNumber(parameters, QStringLiteral("y"), &y) || + !readNumber(parameters, QStringLiteral("width"), &width) || + !readNumber(parameters, QStringLiteral("height"), &height)) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/missing-geometry"); + } + return std::nullopt; + } + if (width <= 0.0 || height <= 0.0) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/invalid-geometry"); + } + return std::nullopt; + } + request.annotation.rectangle = QRectF(x, y, width, height); + } + else if (subtype == pdf::AnnotationAuthoringSubtype::StraightLine) + { + double x1 = 0.0; + double y1 = 0.0; + double x2 = 0.0; + double y2 = 0.0; + if (!readNumber(parameters, QStringLiteral("x1"), &x1) || + !readNumber(parameters, QStringLiteral("y1"), &y1) || + !readNumber(parameters, QStringLiteral("x2"), &x2) || + !readNumber(parameters, QStringLiteral("y2"), &y2)) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/missing-geometry"); + } + return std::nullopt; + } + if (x1 == x2 && y1 == y2) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/invalid-geometry"); + } + return std::nullopt; + } + request.annotation.vertices = { QPointF(x1, y1), QPointF(x2, y2) }; + } + else if (subtype == pdf::AnnotationAuthoringSubtype::Polyline || + subtype == pdf::AnnotationAuthoringSubtype::Polygon || + subtype == pdf::AnnotationAuthoringSubtype::FreehandCurve) + { + const qsizetype minimum = subtype == pdf::AnnotationAuthoringSubtype::Polygon ? 3 : 2; + if (!parameters.contains(QStringLiteral("points")) || + !readPoints(parameters.value(QStringLiteral("points")).toString(), minimum, + &request.annotation.vertices)) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/invalid-points"); + } + return std::nullopt; + } + } + else if (isStickyNoteSubtype(subtype)) + { + double x = 0.0; + double y = 0.0; + if (!readNumber(parameters, QStringLiteral("x"), &x) || + !readNumber(parameters, QStringLiteral("y"), &y)) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/missing-geometry"); + } + return std::nullopt; + } + request.annotation.rectangle = QRectF(x, y, pdf::StickyNoteIconSize, pdf::StickyNoteIconSize); + } + + if (subtype == pdf::AnnotationAuthoringSubtype::InlineText || isStickyNoteSubtype(subtype)) + { + const QString text = parameters.value(QStringLiteral("text")).toString(); + if (text.trimmed().isEmpty()) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/missing-text"); + } + return std::nullopt; + } + request.annotation.contents = text; + } + + if (!readOptionalColor(parameters, &request.annotation.color)) + { + if (typedError) + { + *typedError = QStringLiteral("annotation/invalid-color"); + } + return std::nullopt; + } + + return request; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/directeditrequest.h b/LoopLibInteraction/sources/directeditrequest.h new file mode 100644 index 00000000..ffe3199f --- /dev/null +++ b/LoopLibInteraction/sources/directeditrequest.h @@ -0,0 +1,121 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef DIRECTEDITREQUEST_H +#define DIRECTEDITREQUEST_H + +#include "interactionglobal.h" + +#include "pdfannotationauthoring.h" +#include "pdfdocumentcontext.h" + +#include +#include +#include +#include + +#include + +namespace pdfinteraction +{ + +/// The typed authoring-edit families the direct-edit gateway accepts. One kind +/// per edit family; a value outside this vocabulary is a typed refusal, so a +/// handler cannot smuggle an unmodelled mutation through the gateway. +enum class DirectEditKind +{ + CreateAnnotation +}; + +const char* getDirectEditKindName(DirectEditKind kind); + +/// Parses a direct-edit kind name. Returns false rather than guessing. +bool parseDirectEditKind(const QString& name, DirectEditKind* kind); + +/// One authoring edit submitted to `DocumentFacade::applyDirectEdit` — the +/// mutation-gateway shape ADR-014 requires: the edit kind, its typed payload, +/// and the `PDFRevisionIdentity` the edit was begun against. +/// +/// `revision` is the fence, not a hint. A request whose revision no longer +/// matches the context's current revision is refused with nothing written. +struct DirectEditRequest +{ + DirectEditKind kind = DirectEditKind::CreateAnnotation; + pdf::AnnotationAuthoringSpec annotation; + + /// The revision the edit was begun against (complete PDFRevisionIdentity). + pdf::PDFRevisionIdentity revision; + + /// The acting identity recorded on the appended operation-history event. + /// Required; an empty identity is refused rather than attributed to nobody. + QString operatorIdentity; +}; + +/// Result of one gateway submission. `accepted` is true exactly when the edit +/// was applied to the document as a new revision; a refusal leaves the document +/// untouched and carries a typed reason in `typedError`. +/// +/// `historyAppended` reports the #124 provenance write separately: the document +/// mutation is authoritative and is never rolled back when the sidecar append +/// fails, so the failure is reported in `historyError` and surfaced by the +/// command result instead of being hidden. +struct DirectEditOutcome +{ + bool accepted = false; + QString typedError; + + /// The revision the outcome describes: the new revision when accepted, + /// the still-current revision when refused. + pdf::PDFRevisionIdentity revision; + + pdf::PDFObjectReference annotation; + pdf::PDFObjectReference page; + + bool historyAppended = false; + quint64 historySequence = 0; + QString historyError; +}; + +/// The annotation-authoring command ids of docs/loop-shell-actions.json, each +/// with the subtype its handler creates. One table, so the registered handlers, +/// the coverage check, and a test cannot disagree about what an id means. +const QList>& annotationAuthoringCommandIds(); + +/// Looks one command id up in the table above. Returns false for an id outside +/// the family rather than defaulting to a subtype. +bool annotationAuthoringSubtypeForCommand(const QString& commandId, + pdf::AnnotationAuthoringSubtype* subtype); + +/// Parses one command invocation's scalar parameters into a typed request for +/// the facade gateway. This is the boundary: an invocation without usable +/// geometry, an unparseable point list, or a malformed colour is refused here +/// with a typed reason, so no free-form value reaches the document. +/// +/// The returned request carries no revision and no operator identity; the +/// caller supplies both (the handler takes the revision at invoke time, a +/// gesture flow the revision the gesture was begun against). +std::optional directEditRequestFromParameters( + pdf::AnnotationAuthoringSubtype subtype, const QVariantMap& parameters, QString* typedError); + +} // namespace pdfinteraction + +#endif // DIRECTEDITREQUEST_H diff --git a/LoopLibInteraction/sources/documentfacade.h b/LoopLibInteraction/sources/documentfacade.h index 307b8edc..2a64faa8 100644 --- a/LoopLibInteraction/sources/documentfacade.h +++ b/LoopLibInteraction/sources/documentfacade.h @@ -24,6 +24,7 @@ #define DOCUMENTFACADE_H #include "commandcatalog.h" +#include "directeditrequest.h" #include "documentcontextsource.h" #include "documentloader.h" #include "jobrelay.h" @@ -210,6 +211,11 @@ struct DocumentPermissions bool copy = false; bool assemble = false; + /// Modify-annotations (PDF permission bit 6) is the permission annotation + /// authoring needs: it is checked by the facade's direct-edit gateway and by + /// the shell's availability gate, from this one value (ADR-014). + bool modifyAnnotations = false; + bool allowsCorrection() const { return print && modify && copy && assemble; } }; @@ -346,6 +352,15 @@ class DocumentFacade final : public QObject /// document, which advances the revision and the cache generation. DocumentEditOutcome applyAuthoringEdit(const DocumentEditRequest& request); + /// Applies one typed direct edit — the single mutation gateway for + /// interactive authoring (ADR-014). The revision fence and the document's + /// modify-annotations permission are checked before anything is built, so a + /// refusal mutates nothing. An accepted edit replaces the document with the + /// new revision produced by the Core writer primitive and appends one + /// operation-history event and rollback point (#124's model); the outcome + /// reports the mutation and the provenance write separately. + DirectEditOutcome applyDirectEdit(const DirectEditRequest& request); + CommandInvocationId open(const QString& path); /// Re-opens the current source. Replacement, not a refresh: the old identity @@ -404,6 +419,13 @@ class DocumentFacade final : public QObject /// moves, and the document is dirty with no pending output. void noteAuthoringMutation(); void finishPending(CommandInvocationId invocation, CommandTerminalState state, QString typedError); + + /// Lifecycle bookkeeping for a mutation that already advanced the revision + /// through the document context (applyDirectEdit). Unlike markModified(), it + /// does not advance the revision again: the context is the revision + /// authority, and one accepted edit is exactly one revision. + void noteDirectMutation(); + bool admitInspectionTransition(const DocumentInspectionToken& token, QString& error) const; void invalidateInspection(); bool admitSignatureCheckTransition(const DocumentSignatureCheckToken& token, QString& error) const; diff --git a/LoopLibInteraction/sources/documentfacadeedits.cpp b/LoopLibInteraction/sources/documentfacadeedits.cpp new file mode 100644 index 00000000..baf98285 --- /dev/null +++ b/LoopLibInteraction/sources/documentfacadeedits.cpp @@ -0,0 +1,240 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "documentfacade.h" + +#include "pdfannotationauthoring.h" +#include "pdfauthoringhistory.h" +#include "pdfdocumentwriter.h" +#include "pdfresourcebudget.h" +#include "pdfsecurityhandler.h" + +#include +#include + +namespace pdfinteraction +{ + +namespace +{ + +/// Serializes one revision for the operation-history artifact store. A +/// revision that cannot be serialized cannot be recorded, and an unrecordable +/// edit is refused before anything is mutated. +bool serializeRevision(const pdf::PDFDocument* document, QByteArray* bytes) +{ + if (!document || !bytes) + { + return false; + } + + QBuffer buffer(bytes); + if (!buffer.open(QIODevice::WriteOnly)) + { + return false; + } + + pdf::PDFDocumentWriter writer(nullptr); + return bool(writer.write(&buffer, document)); +} + +QString operationIdForSubtype(pdf::AnnotationAuthoringSubtype subtype) +{ + return QStringLiteral("annotation.create.%1").arg(QLatin1String(pdf::getAnnotationAuthoringSubtypeName(subtype))); +} + +} // namespace + +const char* getDirectEditKindName(DirectEditKind kind) +{ + switch (kind) + { + case DirectEditKind::CreateAnnotation: + return "create-annotation"; + } + + return "invalid"; +} + +bool parseDirectEditKind(const QString& name, DirectEditKind* kind) +{ + if (name == QLatin1String("create-annotation")) + { + if (kind) + { + *kind = DirectEditKind::CreateAnnotation; + } + return true; + } + + return false; +} + +DirectEditOutcome DocumentFacade::applyDirectEdit(const DirectEditRequest& request) +{ + DirectEditOutcome outcome; + outcome.revision = currentRevision(); + + if (request.kind != DirectEditKind::CreateAnnotation) + { + outcome.typedError = QStringLiteral("direct-edit/unsupported-kind"); + return outcome; + } + if (request.operatorIdentity.trimmed().isEmpty()) + { + outcome.typedError = QStringLiteral("direct-edit/missing-operator"); + return outcome; + } + + pdf::PDFDocumentContext* documentContext = context(); + if (m_state != DocumentState::Ready || !documentContext || !documentContext->getDocument()) + { + outcome.typedError = QStringLiteral("direct-edit/document-not-ready"); + return outcome; + } + + // The fence: the edit carries the complete revision it was begun against, + // and a mismatch fails closed before any mutation (invariant I07). + if (!request.revision.isValid() || request.revision != currentRevision()) + { + outcome.typedError = QStringLiteral("direct-edit/stale-revision"); + return outcome; + } + + // Modify-annotations is the PDF permission bit this edit family needs. + // The rule lives in permissions() — owner authorization satisfies it inside + // the security handler, and a document without a security handler is + // reported as restricted — so the gateway and the shell's availability gate + // cannot disagree about what an editable document is. + if (!permissions().modifyAnnotations) + { + outcome.typedError = QStringLiteral("direct-edit/permission-denied"); + return outcome; + } + + // The pre-edit revision is the rollback target of this edit; it is captured + // before the mutation so a failure to record refuses the edit instead of + // leaving an unrecordable revision. + QByteArray inputBytes; + if (!serializeRevision(documentContext->getDocument(), &inputBytes)) + { + outcome.typedError = QStringLiteral("direct-edit/revision-not-serializable"); + return outcome; + } + + pdf::AnnotationAuthoringResult authoring; + pdf::PDFDocumentPointer modified = + pdf::createAuthoredAnnotation(documentContext->getDocument(), request.annotation, &authoring); + if (!modified) + { + outcome.typedError = authoring.typedError; + return outcome; + } + + QByteArray outputBytes; + if (!serializeRevision(modified.data(), &outputBytes)) + { + outcome.typedError = QStringLiteral("direct-edit/revision-not-serializable"); + return outcome; + } + + // One accepted edit is exactly one revision: the context is the revision + // authority, so the document is replaced (not marked) and the revision + // advances once. + try + { + documentContext->setDocument(modified, pdf::PDFModifiedDocument::Annotation); + } + catch (const pdf::PDFResourceBudgetExceededException&) + { + outcome.typedError = QStringLiteral("direct-edit/admission-failed"); + return outcome; + } + catch (const pdf::PDFException&) + { + outcome.typedError = QStringLiteral("direct-edit/admission-failed"); + return outcome; + } + + noteDirectMutation(); + + outcome.accepted = true; + outcome.annotation = authoring.annotation; + outcome.page = authoring.page; + outcome.revision = currentRevision(); + + QJsonObject summary; + summary.insert(QStringLiteral("edit"), QStringLiteral("annotation")); + summary.insert(QStringLiteral("subtype"), + QLatin1String(pdf::getAnnotationAuthoringSubtypeName(request.annotation.subtype))); + summary.insert(QStringLiteral("page"), int(request.annotation.pageIndex)); + summary.insert(QStringLiteral("annotation_object"), int(authoring.annotation.objectNumber)); + + if (m_source.path.isEmpty()) + { + // No document path means no sidecar to attach; the edit is still a real + // revision, so the gap is reported rather than fabricated. + outcome.historyError = QStringLiteral("direct-edit/history-sidecar-unavailable"); + return outcome; + } + + quint64 sequence = 0; + const pdf::PDFOperationResult recorded = pdf::appendAuthoringEditEvent( + m_source.path, inputBytes, outputBytes, operationIdForSubtype(request.annotation.subtype), + request.operatorIdentity, summary, &sequence); + if (recorded) + { + outcome.historyAppended = true; + outcome.historySequence = sequence; + } + else + { + // The store's message can name the sidecar directory, and a command + // result is policy-safe (QUICK_SHELL_THREAT_MODEL.md): only the typed + // code crosses this boundary. + outcome.historyError = QStringLiteral("direct-edit/history-unrecorded"); + } + return outcome; +} + +void DocumentFacade::noteDirectMutation() +{ + // Same lifecycle bookkeeping as markModified(), minus the context revision + // advance: setDocument() already produced the new revision. A queued save + // was fenced against the revision this edit superseded, so it is superseded + // rather than left to be silently discarded. + if (m_outputState == DocumentOutputState::Pending) + { + supersedePending(CommandTerminalState::Cancelled, QStringLiteral("document/superseded")); + } + + if (!m_publishedKey.isEmpty()) + { + m_submitter->publishCurrentRevision(m_publishedKey, m_revisionSource.currentRevision()); + } + + setOutputState(DocumentOutputState::None); + setFacets(m_facets | DocumentFacet::Dirty); + updateAvailability(); +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/documentfacadestate.cpp b/LoopLibInteraction/sources/documentfacadestate.cpp index 53190c95..cff43657 100644 --- a/LoopLibInteraction/sources/documentfacadestate.cpp +++ b/LoopLibInteraction/sources/documentfacadestate.cpp @@ -131,7 +131,8 @@ DocumentPermissions DocumentFacade::permissions() const security->isAllowed(Permission::PrintLowResolution) || security->isAllowed(Permission::PrintHighResolution), security->isAllowed(Permission::Modify), security->isAllowed(Permission::CopyContent), - security->isAllowed(Permission::Assemble) }; + security->isAllowed(Permission::Assemble), + security->isAllowed(Permission::ModifyInteractiveItems) }; } bool DocumentOperatorState::canActOnInspection() const diff --git a/UnitTests/phase4-tests.cmake b/UnitTests/phase4-tests.cmake index 5cbc5450..25d55f9d 100644 --- a/UnitTests/phase4-tests.cmake +++ b/UnitTests/phase4-tests.cmake @@ -33,6 +33,24 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) ) add_test(UnitTestsDocumentFacade "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsDocumentFacade") + # Issue #234 acceptance lane: annotation authoring through the typed + # direct-edit gateway (ADR-014). Same load-bearing link line as the facade + # target: the gateway, the Core writer primitive and the per-subtype round + # trips are drivable with no QWidget and no QML engine. + add_executable(UnitTestsAnnotationAuthoring + tst_annotationauthoringtest.cpp + ) + + target_link_libraries(UnitTestsAnnotationAuthoring PRIVATE LoopLibInteraction LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) + + set_target_properties(UnitTestsAnnotationAuthoring PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} + ) + add_test(UnitTestsAnnotationAuthoring "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsAnnotationAuthoring") + # Architecture invariant I23, viewport half. Same load-bearing link line: the # viewport, its layout and its transforms must be drivable with no QWidget, # no QScreen and no scrollbar, which is half of the P4-S3 exit condition. diff --git a/UnitTests/tst_annotationauthoringtest.cpp b/UnitTests/tst_annotationauthoringtest.cpp new file mode 100644 index 00000000..dedf44e4 --- /dev/null +++ b/UnitTests/tst_annotationauthoringtest.cpp @@ -0,0 +1,799 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Issue #234 acceptance evidence: every annotation subtype is created through +// the typed direct-edit gateway on DocumentFacade into Core, reads back with the +// expected subtype, geometry and appearance stream, and each accepted edit +// appends one operation-history event and rollback point (the #124 model). The +// refusal lanes are stale revision and missing modify-annotations. +// +// QTEST_MAIN (QGuiApplication, not QWidgets): creating an inline-text or sticky +// note annotation renders glyphs through QPainter, which needs the GUI +// application object; the link line still excludes Qt6::Widgets and QML, and +// the ctest environment runs the platform offscreen. + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "commandcatalog.h" +#include "directeditrequest.h" +#include "documentfacade.h" +#include "documentloader.h" +#include "jobsubmitter.h" + +#include "pdfannotation.h" +#include "pdfannotationauthoring.h" +#include "pdfauthoringhistory.h" +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentwriter.h" +#include "pdfoperationhistorystore.h" +#include "pdfpage.h" +#include "pdfsecurityhandler.h" + +namespace +{ + +pdf::PDFDocumentPointer buildDocument(int pageCount = 1) +{ + pdf::PDFDocumentBuilder builder; + for (int page = 0; page < pageCount; ++page) + { + builder.appendPage(QRectF(0, 0, 612, 792)); + } + + return pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build())); +} + +/// A submitter that runs work inline; it is deliberately not a second +/// scheduler (mirrors tst_documentfacadetest.cpp). +class FakeJobSubmitter final : public pdfinteraction::IJobSubmitter +{ +public: + QString submit(pdf::PDFJobSpec spec, pdf::PDFJobWork work) override + { + const QString jobId = spec.jobId.isEmpty() ? QStringLiteral("job-%1").arg(++m_sequence) : spec.jobId; + m_specs.insert(jobId, spec); + m_status.insert(jobId, pdf::PDFJobStatus::Running); + auto token = std::make_shared(); + pdf::PDFJobContext context(token, pdf::PDFProcessingLimits::conservativeDefaults(), [](int) {}); + work(context); + m_status.insert(jobId, pdf::PDFJobStatus::Succeeded); + return jobId; + } + + bool cancel(const QString&) override { return false; } + + pdf::PDFJobSnapshot snapshot(const QString& jobId) const override + { + pdf::PDFJobSnapshot result; + result.jobId = jobId; + result.status = m_status.value(jobId, pdf::PDFJobStatus::Succeeded); + result.kind = m_specs.value(jobId).kind; + return result; + } + + void publishCurrentRevision(const QString& documentKey, + const pdf::PDFRevisionIdentity& revision) override + { + publishedRevisions.insert(documentKey, revision); + } + + void clearCurrentRevision(const QString& documentKey) override + { + publishedRevisions.remove(documentKey); + } + + QHash publishedRevisions; + +private: + int m_sequence = 0; + QHash m_specs; + QHash m_status; +}; + +class FakeDocumentLoader final : public pdfinteraction::IDocumentLoader +{ +public: + pdfinteraction::DocumentLoadResult load(const pdfinteraction::DocumentSource&, + pdf::PDFJobContext&) override + { + ++loadCount; + pdfinteraction::DocumentLoadResult result = nextResult; + if (result.outcome == pdfinteraction::DocumentLoadOutcome::Loaded && !result.document) + { + result.document = buildDocument(); + } + return result; + } + + pdfinteraction::DocumentLoadResult nextResult; + int loadCount = 0; +}; + +class FakeDocumentWriter final : public pdfinteraction::IDocumentWriter +{ +public: + pdfinteraction::DocumentWriteResult write(const pdfinteraction::DocumentSource&, + const pdf::PDFDocument*, + pdf::PDFJobContext&) override + { + return { pdfinteraction::DocumentWriteOutcome::Written, QString() }; + } +}; + +/// Everything one gateway test needs, wired the way a host wires it. +struct Harness +{ + Harness() + { + loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Loaded; + facade = std::make_unique(context, submitter, loader, writer, catalog); + } + + /// Opens the fake document and waits for the queued admission. + bool open() + { + facade->open(QStringLiteral("/corpus/fixture.pdf")); + if (facade->state() == pdfinteraction::DocumentState::Ready) + { + return true; + } + return QTest::qWaitFor([this]() + { return facade->state() == pdfinteraction::DocumentState::Ready; }); + } + + pdf::PDFDocumentContext context{ nullptr }; + FakeJobSubmitter submitter; + FakeDocumentLoader loader; + FakeDocumentWriter writer; + pdfinteraction::CommandCatalog catalog; + std::unique_ptr facade; +}; + +pdfinteraction::DirectEditRequest makeRequest(pdf::AnnotationAuthoringSubtype subtype, + pdf::PDFRevisionIdentity revision) +{ + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::CreateAnnotation; + request.annotation.subtype = subtype; + request.annotation.pageIndex = 0; + request.revision = revision; + request.operatorIdentity = QStringLiteral("UnitTest"); + return request; +} + +/// One fully-populated spec per subtype, so the data-driven round trip can name +/// its expected geometry from the same values the edit carried. +pdf::AnnotationAuthoringSpec specForSubtype(pdf::AnnotationAuthoringSubtype subtype) +{ + pdf::AnnotationAuthoringSpec spec; + spec.subtype = subtype; + spec.pageIndex = 0; + spec.rectangle = QRectF(100, 200, 120, 24); + + switch (subtype) + { + case pdf::AnnotationAuthoringSubtype::StraightLine: + spec.vertices = { QPointF(100, 300), QPointF(220, 340) }; + break; + case pdf::AnnotationAuthoringSubtype::Polyline: + spec.vertices = { QPointF(100, 400), QPointF(150, 430), QPointF(220, 410) }; + break; + case pdf::AnnotationAuthoringSubtype::Polygon: + spec.vertices = { QPointF(100, 500), QPointF(180, 500), QPointF(140, 560) }; + break; + case pdf::AnnotationAuthoringSubtype::FreehandCurve: + spec.vertices = { QPointF(300, 300), QPointF(320, 330), QPointF(360, 340), QPointF(400, 310) }; + break; + case pdf::AnnotationAuthoringSubtype::InlineText: + spec.contents = QStringLiteral("Inline note text"); + break; + default: + if (subtype >= pdf::AnnotationAuthoringSubtype::StickyNoteComment) + { + spec.contents = QStringLiteral("Sticky note text"); + spec.rectangle = QRectF(100, 600, pdf::StickyNoteIconSize, pdf::StickyNoteIconSize); + } + break; + } + + return spec; +} + +pdf::AnnotationType expectedAnnotationType(pdf::AnnotationAuthoringSubtype subtype) +{ + switch (subtype) + { + case pdf::AnnotationAuthoringSubtype::TextHighlight: + return pdf::AnnotationType::Highlight; + case pdf::AnnotationAuthoringSubtype::TextUnderline: + return pdf::AnnotationType::Underline; + case pdf::AnnotationAuthoringSubtype::TextStrikeout: + return pdf::AnnotationType::StrikeOut; + case pdf::AnnotationAuthoringSubtype::TextSquiggly: + return pdf::AnnotationType::Squiggly; + case pdf::AnnotationAuthoringSubtype::Rectangle: + return pdf::AnnotationType::Square; + case pdf::AnnotationAuthoringSubtype::Ellipse: + return pdf::AnnotationType::Circle; + case pdf::AnnotationAuthoringSubtype::StraightLine: + return pdf::AnnotationType::Line; + case pdf::AnnotationAuthoringSubtype::Polyline: + return pdf::AnnotationType::Polyline; + case pdf::AnnotationAuthoringSubtype::Polygon: + return pdf::AnnotationType::Polygon; + case pdf::AnnotationAuthoringSubtype::FreehandCurve: + return pdf::AnnotationType::Ink; + case pdf::AnnotationAuthoringSubtype::InlineText: + return pdf::AnnotationType::FreeText; + + default: + return pdf::AnnotationType::Text; + } +} + +/// Decoded normal appearance stream of one annotation, or an empty result when +/// the annotation has none. The AP dictionary is read from the document's +/// storage directly: the parsed-annotation accessor is an inline wrapper around +/// a Core symbol the shared library does not export to tests. +struct AppearanceStream +{ + bool present = false; + QByteArray content; + QRectF boundingBox; +}; + +AppearanceStream normalAppearanceOf(const pdf::PDFDocument* document, pdf::PDFObjectReference annotation) +{ + AppearanceStream result; + const pdf::PDFObjectStorage& storage = document->getStorage(); + const pdf::PDFDictionary* annotationDictionary = + storage.getObject(pdf::PDFObject::createReference(annotation)).getDictionary(); + if (!annotationDictionary) + { + return result; + } + + const pdf::PDFDictionary* appearanceDictionary = + storage.getObject(annotationDictionary->get("AP")).getDictionary(); + if (!appearanceDictionary) + { + return result; + } + + const pdf::PDFStream* stream = storage.getObject(appearanceDictionary->get("N")).getStream(); + if (!stream) + { + return result; + } + + result.present = true; + result.content = document->getDecodedStream(stream); + + if (const pdf::PDFDictionary* dictionary = stream->getDictionary()) + { + const pdf::PDFObject bbox = dictionary->get("BBox"); + if (const pdf::PDFArray* array = bbox.getArray()) + { + if (array->getCount() == 4) + { + result.boundingBox = QRectF(QPointF(array->getItem(0).getReal(), array->getItem(1).getReal()), + QPointF(array->getItem(2).getReal(), array->getItem(3).getReal())); + } + } + } + return result; +} + +} // namespace + +class AnnotationAuthoringTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void catalogBindsTheWholeAnnotationFamily_data(); + void catalogBindsTheWholeAnnotationFamily(); + void parametersMapToTypedRequestsAndRefuseMalformedOnes(); + + void subtypesRoundTripThroughTheGateway_data(); + void subtypesRoundTripThroughTheGateway(); + + void appearanceStreamsAreDeterministicAndGeometrySensitive(); + + void staleRevisionIsRefusedAndNothingChanges(); + void permissionRestrictedDocumentIsRefusedAndOwnerIsAdmitted(); + + void acceptedEditAppendsOneHistoryEventAndRollbackPoint(); +}; + +void AnnotationAuthoringTest::initTestCase() +{ + qRegisterMetaType("pdf::PDFRevisionIdentity"); + qRegisterMetaType("pdfinteraction::CommandResult"); +} + +void AnnotationAuthoringTest::catalogBindsTheWholeAnnotationFamily_data() +{ + QTest::addColumn("commandId"); + QTest::addColumn("subtype"); + + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + QTest::newRow(qPrintable(entry.first)) << entry.first << int(entry.second); + } +} + +void AnnotationAuthoringTest::catalogBindsTheWholeAnnotationFamily() +{ + QFETCH(QString, commandId); + QFETCH(int, subtype); + + // The family table is the single declaration the handlers were registered + // from; it must name exactly the contract ids, and the contract must agree + // they are implemented with a classified capability. + pdfinteraction::CommandCatalog catalog; + QVERIFY2(catalog.isLoaded(), qPrintable(catalog.loadError())); + + const pdfinteraction::CommandDescriptor* descriptor = catalog.descriptor(commandId); + QVERIFY2(descriptor != nullptr, qPrintable(commandId)); + QVERIFY(descriptor->isImplemented()); + QCOMPARE(descriptor->capability, pdfinteraction::CommandCapability::DocumentModify); + QVERIFY(!descriptor->parameters.isEmpty()); + + pdf::AnnotationAuthoringSubtype parsed; + QVERIFY(pdf::parseAnnotationAuthoringSubtype( + QLatin1String(pdf::getAnnotationAuthoringSubtypeName(pdf::AnnotationAuthoringSubtype(subtype))), &parsed)); + QCOMPARE(int(parsed), subtype); + + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [](pdfinteraction::CommandInvocationId, const QVariantMap&) {}; + QVERIFY(catalog.setHandler(commandId, handler)); +} + +void AnnotationAuthoringTest::parametersMapToTypedRequestsAndRefuseMalformedOnes() +{ + using pdf::AnnotationAuthoringSubtype; + + QString refusal; + QVERIFY(!pdfinteraction::directEditRequestFromParameters(AnnotationAuthoringSubtype::TextHighlight, {}, &refusal)); + QCOMPARE(refusal, QStringLiteral("annotation/missing-page")); + + const QVariantMap highlight{ + { QStringLiteral("page"), 0 }, + { QStringLiteral("x"), 10.0 }, + { QStringLiteral("y"), 20.0 }, + { QStringLiteral("width"), 100.0 }, + { QStringLiteral("height"), 12.0 }, + { QStringLiteral("color"), QStringLiteral("#00FF00") }, + }; + const auto parsed = pdfinteraction::directEditRequestFromParameters( + AnnotationAuthoringSubtype::TextHighlight, highlight, &refusal); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->annotation.rectangle, QRectF(10, 20, 100, 12)); + QCOMPARE(parsed->annotation.color, QColor(QStringLiteral("#00FF00"))); + + // Degenerate geometry is refused at the boundary, never normalized. + QVariantMap degenerate = highlight; + degenerate.insert(QStringLiteral("width"), 0.0); + QVERIFY(!pdfinteraction::directEditRequestFromParameters(AnnotationAuthoringSubtype::TextHighlight, degenerate, &refusal)); + QCOMPARE(refusal, QStringLiteral("annotation/invalid-geometry")); + + // A malformed point list is refused whole, not truncated. + QVERIFY(!pdfinteraction::directEditRequestFromParameters( + AnnotationAuthoringSubtype::Polyline, + { { QStringLiteral("page"), 0 }, { QStringLiteral("points"), QStringLiteral("1 2 3") } }, &refusal)); + QCOMPARE(refusal, QStringLiteral("annotation/invalid-points")); + + const auto polyline = pdfinteraction::directEditRequestFromParameters( + AnnotationAuthoringSubtype::Polyline, + { { QStringLiteral("page"), 0 }, { QStringLiteral("points"), QStringLiteral("1 2 30 40 50 60") } }, &refusal); + QVERIFY(polyline.has_value()); + QCOMPARE(polyline->annotation.vertices.size(), std::size_t(3)); + QCOMPARE(polyline->annotation.vertices[1], QPointF(30, 40)); + + // Text is required where the subtype carries text. + QVERIFY(!pdfinteraction::directEditRequestFromParameters( + AnnotationAuthoringSubtype::StickyNoteNote, + { { QStringLiteral("page"), 0 }, { QStringLiteral("x"), 5.0 }, { QStringLiteral("y"), 5.0 } }, &refusal)); + QCOMPARE(refusal, QStringLiteral("annotation/missing-text")); + + const auto sticky = pdfinteraction::directEditRequestFromParameters( + AnnotationAuthoringSubtype::StickyNoteNote, + { { QStringLiteral("page"), 1 }, + { QStringLiteral("x"), 5.0 }, + { QStringLiteral("y"), 6.0 }, + { QStringLiteral("text"), QStringLiteral("note") } }, + &refusal); + QVERIFY(sticky.has_value()); + QCOMPARE(sticky->annotation.pageIndex, pdf::PDFInteger(1)); + QCOMPARE(sticky->annotation.rectangle, QRectF(5, 6, pdf::StickyNoteIconSize, pdf::StickyNoteIconSize)); +} + +void AnnotationAuthoringTest::subtypesRoundTripThroughTheGateway_data() +{ + QTest::addColumn("subtype"); + + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + QTest::newRow(pdf::getAnnotationAuthoringSubtypeName(entry.second)) << int(entry.second); + } +} + +void AnnotationAuthoringTest::subtypesRoundTripThroughTheGateway() +{ + QFETCH(int, subtype); + const pdf::AnnotationAuthoringSubtype authoringSubtype = pdf::AnnotationAuthoringSubtype(subtype); + + Harness harness; + QVERIFY(harness.open()); + + const pdf::PDFRevisionIdentity before = harness.facade->currentRevision(); + const pdf::AnnotationAuthoringSpec spec = specForSubtype(authoringSubtype); + + pdfinteraction::DirectEditRequest request = makeRequest(authoringSubtype, before); + request.annotation = spec; + + const pdfinteraction::DirectEditOutcome outcome = harness.facade->applyDirectEdit(request); + QVERIFY2(outcome.accepted, qPrintable(outcome.typedError)); + QVERIFY(outcome.annotation.isValid()); + + // One accepted edit is exactly one new revision, and the document is dirty. + const pdf::PDFRevisionIdentity after = harness.facade->currentRevision(); + QVERIFY(after != before); + QCOMPARE(after.documentRevision, before.documentRevision + 1); + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); + + // Read back through Core's own model: subtype, geometry, appearance stream. + const pdf::PDFDocument* document = harness.context.getDocument(); + QVERIFY(document != nullptr); + const pdf::PDFPage* page = document->getCatalog()->getPage(std::size_t(spec.pageIndex)); + QVERIFY(page != nullptr); + const std::vector& annotations = page->getAnnotations(); + QVERIFY(std::find(annotations.cbegin(), annotations.cend(), outcome.annotation) != annotations.cend()); + + const pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), outcome.annotation); + QVERIFY(annotation != nullptr); + QCOMPARE(annotation->getType(), expectedAnnotationType(authoringSubtype)); + + switch (authoringSubtype) + { + case pdf::AnnotationAuthoringSubtype::StraightLine: + { + const auto* line = dynamic_cast(annotation.data()); + QVERIFY(line != nullptr); + QCOMPARE(line->getLine(), QLineF(spec.vertices[0], spec.vertices[1])); + break; + } + case pdf::AnnotationAuthoringSubtype::Polyline: + case pdf::AnnotationAuthoringSubtype::Polygon: + { + const auto* polygonal = dynamic_cast(annotation.data()); + QVERIFY(polygonal != nullptr); + QCOMPARE(polygonal->getVertices().size(), spec.vertices.size()); + for (std::size_t index = 0; index < spec.vertices.size(); ++index) + { + QCOMPARE(polygonal->getVertices()[index], spec.vertices[index]); + } + break; + } + case pdf::AnnotationAuthoringSubtype::FreehandCurve: + { + const auto* ink = dynamic_cast(annotation.data()); + QVERIFY(ink != nullptr); + const QRectF inkBounds = ink->getInkPath().boundingRect(); + QVERIFY(inkBounds.contains(QRectF(spec.vertices[0], spec.vertices[3]).normalized())); + break; + } + case pdf::AnnotationAuthoringSubtype::TextUnderline: + case pdf::AnnotationAuthoringSubtype::TextStrikeout: + case pdf::AnnotationAuthoringSubtype::TextSquiggly: + { + // These subtypes' appearance is a stroke, and the builder rewrites + // Rect to the drawn bounding box, so the read-back rectangle is the + // requested one expanded by the border width on each side. + QCOMPARE(annotation->getRectangle(), + spec.rectangle.adjusted(-spec.borderWidth, -spec.borderWidth, + spec.borderWidth, spec.borderWidth)); + break; + } + + case pdf::AnnotationAuthoringSubtype::InlineText: + QCOMPARE(annotation->getContents(), spec.contents); + QCOMPARE(annotation->getRectangle(), spec.rectangle); + break; + + default: + QCOMPARE(annotation->getRectangle(), spec.rectangle); + break; + } + + if (authoringSubtype >= pdf::AnnotationAuthoringSubtype::StickyNoteComment) + { + QCOMPARE(annotation->getContents(), spec.contents); + } + + const AppearanceStream appearance = normalAppearanceOf(document, outcome.annotation); + QVERIFY2(appearance.present, pdf::getAnnotationAuthoringSubtypeName(authoringSubtype)); + QVERIFY(!appearance.content.isEmpty()); + QVERIFY(!appearance.boundingBox.isEmpty()); + QVERIFY2(appearance.boundingBox.intersects(annotation->getRectangle()), + qPrintable(QStringLiteral("%1: BBox %2,%3 %4x%5 does not intersect %6,%7 %8x%9") + .arg(QLatin1String(pdf::getAnnotationAuthoringSubtypeName(authoringSubtype))) + .arg(appearance.boundingBox.x()) + .arg(appearance.boundingBox.y()) + .arg(appearance.boundingBox.width()) + .arg(appearance.boundingBox.height()) + .arg(annotation->getRectangle().x()) + .arg(annotation->getRectangle().y()) + .arg(annotation->getRectangle().width()) + .arg(annotation->getRectangle().height()))); +} + +void AnnotationAuthoringTest::appearanceStreamsAreDeterministicAndGeometrySensitive() +{ + const pdf::AnnotationAuthoringSpec highlight = specForSubtype(pdf::AnnotationAuthoringSubtype::TextHighlight); + + const auto createHighlight = [](const pdf::AnnotationAuthoringSpec& spec, pdf::PDFObjectReference* annotation) + { + Harness harness; + if (!harness.open()) + { + return AppearanceStream(); + } + pdfinteraction::DirectEditRequest request = makeRequest(spec.subtype, harness.facade->currentRevision()); + request.annotation = spec; + const pdfinteraction::DirectEditOutcome outcome = harness.facade->applyDirectEdit(request); + if (!outcome.accepted) + { + return AppearanceStream(); + } + *annotation = outcome.annotation; + return normalAppearanceOf(harness.context.getDocument(), outcome.annotation); + }; + + // The same edit on two identical documents renders the same appearance + // stream, byte for byte: creation is deterministic, not time- or + // pointer-dependent. + pdf::PDFObjectReference firstAnnotation; + pdf::PDFObjectReference secondAnnotation; + const AppearanceStream first = createHighlight(highlight, &firstAnnotation); + const AppearanceStream second = createHighlight(highlight, &secondAnnotation); + QVERIFY(first.present); + QVERIFY(second.present); + QCOMPARE(first.content, second.content); + QCOMPARE(first.boundingBox, second.boundingBox); + + // A different geometry must produce a different stream, or the comparison + // above would be vacuous. + pdf::AnnotationAuthoringSpec moved = highlight; + moved.rectangle = QRectF(300, 400, 60, 18); + pdf::PDFObjectReference thirdAnnotation; + const AppearanceStream third = createHighlight(moved, &thirdAnnotation); + QVERIFY(third.present); + QVERIFY(third.content != first.content); +} + +void AnnotationAuthoringTest::staleRevisionIsRefusedAndNothingChanges() +{ + Harness harness; + QVERIFY(harness.open()); + + const pdf::PDFRevisionIdentity stale = harness.facade->currentRevision(); + + pdfinteraction::DirectEditRequest first = makeRequest(pdf::AnnotationAuthoringSubtype::TextHighlight, stale); + first.annotation = specForSubtype(pdf::AnnotationAuthoringSubtype::TextHighlight); + QVERIFY(harness.facade->applyDirectEdit(first).accepted); + + const pdf::PDFRevisionIdentity current = harness.facade->currentRevision(); + QCOMPARE(harness.context.getDocument()->getCatalog()->getPage(0)->getAnnotations().size(), std::size_t(1)); + + // The second edit carries the revision it was begun against before the + // first landed. It must fail closed before any mutation (invariant I07). + pdfinteraction::DirectEditRequest second = makeRequest(pdf::AnnotationAuthoringSubtype::TextUnderline, stale); + second.annotation = specForSubtype(pdf::AnnotationAuthoringSubtype::TextUnderline); + const pdfinteraction::DirectEditOutcome refused = harness.facade->applyDirectEdit(second); + QVERIFY(!refused.accepted); + QCOMPARE(refused.typedError, QStringLiteral("direct-edit/stale-revision")); + + QCOMPARE(harness.facade->currentRevision(), current); + QCOMPARE(harness.context.getDocument()->getCatalog()->getPage(0)->getAnnotations().size(), std::size_t(1)); +} + +void AnnotationAuthoringTest::permissionRestrictedDocumentIsRefusedAndOwnerIsAdmitted() +{ + // The fixture is a real encrypted file opened through the real reader: a + // factory-built in-memory handler is owner-authorized by construction, so + // only a reader-created handler shows the permissions the file declares. + const auto writeEncryptedFixture = [](const QString& path, const QString& userPassword, + const QString& ownerPassword, quint32 permissions) + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 612, 792)); + pdf::PDFSecurityHandlerFactory::SecuritySettings settings; + settings.algorithm = pdf::PDFSecurityHandlerFactory::AES_256; + settings.permissions = permissions; + settings.userPassword = userPassword; + settings.ownerPassword = ownerPassword; + settings.id = builder.build().getIdPart(0); + builder.setSecurityHandler(pdf::PDFSecurityHandlerFactory::createSecurityHandler(settings)); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + return bool(writer.write(path, &document, false)); + }; + + struct EncryptedHarness + { + EncryptedHarness(const QString& password) + { + loader = std::make_unique( + [password](bool* ok, const pdf::PDFOperationControl*) + { + *ok = true; + return password; + }); + facade = std::make_unique(context, submitter, *loader, writer, catalog); + } + + pdf::PDFDocumentContext context{ nullptr }; + FakeJobSubmitter submitter; + std::unique_ptr loader; + FakeDocumentWriter writer; + pdfinteraction::CommandCatalog catalog; + std::unique_ptr facade; + }; + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString restrictedPath = directory.filePath(QStringLiteral("restricted.pdf")); + const QString ownerPath = directory.filePath(QStringLiteral("owner.pdf")); + QVERIFY(writeEncryptedFixture(restrictedPath, QStringLiteral("user"), QStringLiteral("owner"), 0)); + QVERIFY(writeEncryptedFixture(ownerPath, QStringLiteral("user"), QStringLiteral("owner"), 0)); + + // User access without modify-annotations: the edit is refused and nothing + // is created. + EncryptedHarness restricted(QStringLiteral("user")); + restricted.facade->open(restrictedPath); + QTRY_VERIFY(restricted.facade->state() == pdfinteraction::DocumentState::Ready); + QVERIFY(restricted.facade->permissions().encrypted); + QVERIFY(!restricted.facade->permissions().modifyAnnotations); + + pdfinteraction::DirectEditRequest request = makeRequest(pdf::AnnotationAuthoringSubtype::TextHighlight, + restricted.facade->currentRevision()); + request.annotation = specForSubtype(pdf::AnnotationAuthoringSubtype::TextHighlight); + const pdfinteraction::DirectEditOutcome refused = restricted.facade->applyDirectEdit(request); + QVERIFY(!refused.accepted); + QCOMPARE(refused.typedError, QStringLiteral("direct-edit/permission-denied")); + QCOMPARE(restricted.context.getDocument()->getCatalog()->getPage(0)->getAnnotations().size(), std::size_t(0)); + + // Owner authorization satisfies the same permission bit: the same edit is + // admitted when the file is opened with the owner password. + EncryptedHarness owner(QStringLiteral("owner")); + owner.facade->open(ownerPath); + QTRY_VERIFY(owner.facade->state() == pdfinteraction::DocumentState::Ready); + QVERIFY(owner.facade->permissions().modifyAnnotations); + + pdfinteraction::DirectEditRequest ownerRequest = makeRequest(pdf::AnnotationAuthoringSubtype::TextHighlight, + owner.facade->currentRevision()); + ownerRequest.annotation = specForSubtype(pdf::AnnotationAuthoringSubtype::TextHighlight); + const pdfinteraction::DirectEditOutcome accepted = owner.facade->applyDirectEdit(ownerRequest); + QVERIFY2(accepted.accepted, qPrintable(accepted.typedError)); + QCOMPARE(owner.context.getDocument()->getCatalog()->getPage(0)->getAnnotations().size(), std::size_t(1)); +} + +void AnnotationAuthoringTest::acceptedEditAppendsOneHistoryEventAndRollbackPoint() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString documentPath = directory.filePath(QStringLiteral("annotated.pdf")); + + pdf::PDFDocumentWriter writer(nullptr); + const pdf::PDFDocumentPointer source = buildDocument(); + QVERIFY(writer.write(documentPath, source.data(), false)); + + // A real open of a real file: the sidecar has a document path to live beside. + struct RealHarness + { + RealHarness() + { + facade = std::make_unique(context, submitter, loader, writer, catalog); + } + + pdf::PDFDocumentContext context{ nullptr }; + FakeJobSubmitter submitter; + pdfinteraction::PDFReaderDocumentLoader loader; + FakeDocumentWriter writer; + pdfinteraction::CommandCatalog catalog; + std::unique_ptr facade; + }; + + RealHarness harness; + harness.facade->open(documentPath); + QTRY_VERIFY(harness.facade->state() == pdfinteraction::DocumentState::Ready); + + const auto applyEdit = [&harness](pdf::AnnotationAuthoringSubtype subtype) + { + pdfinteraction::DirectEditRequest request = makeRequest(subtype, harness.facade->currentRevision()); + request.annotation = specForSubtype(subtype); + return harness.facade->applyDirectEdit(request); + }; + + const pdfinteraction::DirectEditOutcome first = applyEdit(pdf::AnnotationAuthoringSubtype::TextHighlight); + QVERIFY2(first.accepted, qPrintable(first.typedError)); + QVERIFY2(first.historyAppended, qPrintable(first.historyError)); + QVERIFY(first.historySequence != 0); + + const pdfinteraction::DirectEditOutcome second = applyEdit(pdf::AnnotationAuthoringSubtype::StickyNoteNote); + QVERIFY2(second.accepted, qPrintable(second.typedError)); + QVERIFY2(second.historyAppended, qPrintable(second.historyError)); + + // The #124 model: every accepted edit is one event and one rollback point, + // and the first edit's undo target is the protected original input. + const QString databasePath = + QDir(documentPath + QStringLiteral(".loop-history")).filePath(QStringLiteral("history.sqlite3")); + QVERIFY(QFile::exists(databasePath)); + + pdf::PDFOperationHistoryStore history(databasePath); + QString error; + QVERIFY2(history.open(&error), qPrintable(error)); + const pdf::PDFOperationHistoryVerification verification = history.verify(); + QVERIFY2(verification.verified, qPrintable(verification.errorMessage)); + QCOMPARE(verification.eventsChecked, qint64(2)); + + const QList events = history.events(&error); + QCOMPARE(events.size(), 2); + for (const pdf::PDFOperationHistoryEvent& event : events) + { + QCOMPARE(event.kind, pdf::PDFOperationHistoryEventKind::FixApplied); + QCOMPARE(event.status, pdf::PDFOperationHistoryStatus::Accepted); + QVERIFY(event.output.has_value()); + QCOMPARE(event.approval.kind, pdf::PDFApprovalKind::System); + QCOMPARE(event.operatorIdentity, QStringLiteral("UnitTest")); + } + QVERIFY(events[0].output->sha256 != events[1].output->sha256); + + const QList points = history.rollbackPoints(&error); + QCOMPARE(points.size(), 3); // original input + one point per accepted edit + QCOMPARE(std::count_if(points.cbegin(), points.cend(), + [](const pdf::PDFRollbackPoint& point) + { return point.isOriginalInput; }), + qsizetype(1)); + QCOMPARE(std::count_if(points.cbegin(), points.cend(), + [](const pdf::PDFRollbackPoint& point) + { return !point.isOriginalInput; }), + qsizetype(2)); +} + +QTEST_MAIN(AnnotationAuthoringTest) +#include "tst_annotationauthoringtest.moc" diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index 44089407..f85f01d7 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -449,9 +449,10 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() } // The display colour modes (#235), the select/copy/capture commands (#232), // the measure commands (#238), the shell settings/diagnostics surfaces - // (#239) and the bookmark/hyperlink commands (#233) joined the implemented - // set in epic #231. - QCOMPARE(implemented, 71); + // (#239), the bookmark/hyperlink commands (#233), the annotation authoring + // family (#234) and the form/attachment/metadata commands (#242) joined the + // implemented set in epic #231. + QCOMPARE(implemented, 94); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 5fe6dafb..7492bb40 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -405,6 +405,10 @@ private slots: void executeApprovedActionListPlanRefusesAnUnreviewedPlan(); void fixRollbackReturnsToARecordedRevision(); void annotationReviewListsAndNavigatesToThePageRegion(); + /// Issue #234: annotation authoring commands go through the direct-edit + /// gateway, and the authored annotation reaches the quick lane's models. + void annotationCommandsAuthorThroughTheGatewayAndRefuseWithoutGeometry(); + void signatureStatusPresentsTheReducedState(); void signatureInvalidationGateBlocksExecutionUntilAcknowledged(); void previewFidelityNamesTheOriginAndSwitchesExplicitly(); @@ -2679,6 +2683,105 @@ void EditorHostTest::annotationReviewListsAndNavigatesToThePageRegion() QCOMPARE(host.currentPage(), 1); } +void EditorHostTest::annotationCommandsAuthorThroughTheGatewayAndRefuseWithoutGeometry() +{ + qRegisterMetaType("pdfinteraction::CommandResult"); + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString documentPath = directory.filePath(QStringLiteral("authoring.pdf")); + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 612, 792)); + pdf::PDFDocumentWriter writer(nullptr); + const pdf::PDFDocument document = builder.build(); + QVERIFY(writer.write(documentPath, &document, false)); + } + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(documentPath)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + QuickDocumentModel* documentModel = qobject_cast(host.documentModel()); + QVERIFY(documentModel != nullptr); + QCOMPARE(documentModel->annotationCount(), 0); + + // The command is available with an unrestricted document. + QVERIFY(host.isCommandEnabled(QStringLiteral("actionCreateTextHighlight"))); + + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + + // A menu invocation carries no geometry: the catalog refuses the missing + // required parameter before any handler runs, typed, and nothing is created. + QCOMPARE(host.invokeCommand(QStringLiteral("actionCreateTextHighlight")), quint64(0)); + QCOMPARE(finished.count(), 1); + { + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(result.typedError, QStringLiteral("command/missing-parameter")); + } + QCOMPARE(documentModel->annotationCount(), 0); + + // A degenerate rectangle passes the contract's type check and is refused by + // the handler's boundary parse, before the gateway is reached. + QVERIFY(host.invokeCommand(QStringLiteral("actionCreateTextHighlight"), + QVariantMap{ { QStringLiteral("page"), 0 }, + { QStringLiteral("x"), 10.0 }, + { QStringLiteral("y"), 10.0 }, + { QStringLiteral("width"), 0.0 }, + { QStringLiteral("height"), 10.0 } }) != 0); + QCOMPARE(finished.count(), 1); + { + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(result.typedError, QStringLiteral("annotation/invalid-geometry")); + } + QCOMPARE(documentModel->annotationCount(), 0); + + // With geometry the handler submits the typed request, the gateway creates + // the annotation as a new revision, and the quick lane presents it. + const QString beforeRevision = session->facade().currentRevision().toString(); + const QVariantMap parameters{ + { QStringLiteral("page"), 0 }, + { QStringLiteral("x"), 100.0 }, + { QStringLiteral("y"), 120.0 }, + { QStringLiteral("width"), 200.0 }, + { QStringLiteral("height"), 16.0 }, + }; + QVERIFY(host.invokeCommand(QStringLiteral("actionCreateTextHighlight"), parameters) != 0); + QCOMPARE(finished.count(), 1); + { + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY2(result.typedError.isEmpty(), qPrintable(result.typedError)); + } + QVERIFY(session->facade().currentRevision().toString() != beforeRevision); + + const pdf::PDFPage* page = session->context().getDocument()->getCatalog()->getPage(0); + QVERIFY(page != nullptr); + QCOMPARE(page->getAnnotations().size(), std::size_t(1)); + const pdf::PDFAnnotationPtr annotation = + pdf::PDFAnnotation::parse(&session->context().getDocument()->getStorage(), page->getAnnotations().front()); + QVERIFY(annotation != nullptr); + QCOMPARE(annotation->getType(), pdf::AnnotationType::Highlight); + QCOMPARE(annotation->getRectangle(), QRectF(100.0, 120.0, 200.0, 16.0)); + + // The revision change reached the quick lane: the annotation model lists the + // new mark and the overlay carries it as a read-only page-space rectangle. + QTRY_COMPARE_WITH_TIMEOUT(documentModel->annotationCount(), 1, 10000); + const QList& rows = documentModel->annotationModel().rows(); + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.at(0).pageIndex, 0); + QCOMPARE(rows.at(0).bounds, QRectF(100.0, 120.0, 200.0, 16.0)); + QCOMPARE(session->overlays()->annotations().size(), 1); + + // The accepted edit appended its provenance event beside the document + // (#124's model; wiring Undo stays out of this slice). + QVERIFY(QFile::exists(documentPath + QStringLiteral(".loop-history/history.sqlite3"))); +} + void EditorHostTest::signatureStatusPresentsTheReducedState() { const QString fixture = signedFixturePath(); diff --git a/agent-policy.json b/agent-policy.json index 6a950133..c405bd31 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -197,6 +197,7 @@ "LoopLibInteraction/**", "UnitTests/tst_interactionboundarytest.cpp", "UnitTests/tst_documentfacadetest.cpp", + "UnitTests/tst_annotationauthoringtest.cpp", "UnitTests/tst_viewportcontrollertest.cpp", "UnitTests/tst_viewportcommandbridgetest.cpp", "UnitTests/tst_pagesurfacetest.cpp", @@ -211,7 +212,7 @@ "scripts/verify-command-catalog.py" ], "targets": ["LoopLibInteraction"], - "tests": ["UnitTestsActionList", "UnitTestsInteractionBoundary", "UnitTestsDocumentFacade", "UnitTestsViewportController", "UnitTestsViewportCommands", "UnitTestsPageSurface", "UnitTestsPageSurfaceBudget", "UnitTestsInteractionController", "UnitTestsInteractionTraceContract", "UnitTestsOverlayFrame", "UnitTestsHitTestSource"] + "tests": ["UnitTestsActionList", "UnitTestsInteractionBoundary", "UnitTestsDocumentFacade", "UnitTestsAnnotationAuthoring", "UnitTestsViewportController", "UnitTestsViewportCommands", "UnitTestsPageSurface", "UnitTestsPageSurfaceBudget", "UnitTestsInteractionController", "UnitTestsInteractionTraceContract", "UnitTestsOverlayFrame", "UnitTestsHitTestSource"] }, "quick": { "paths": [ diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 273af332..dffe911d 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -399,6 +399,7 @@ "test_targets": [ "UnitTests", "UnitTestsActionList", + "UnitTestsAnnotationAuthoring", "UnitTestsApplicationIdentity", "UnitTestsAuthorSettings", "UnitTestsBenchmarkIdentity", diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index b868649d..653384c6 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -311,10 +311,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateEllipse.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -324,10 +355,26 @@ "target": "Document", "command": { "label_key": "command.actionCreateFreehandCurve.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "points", + "type": "string", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1052,10 +1099,26 @@ "target": "Document", "command": { "label_key": "command.actionCreatePolygon.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "points", + "type": "string", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1065,10 +1128,26 @@ "target": "Document", "command": { "label_key": "command.actionCreatePolyline.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "points", + "type": "string", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1078,10 +1157,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateRectangle.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1091,10 +1201,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateStraightLine.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x1", + "type": "number", + "required": true + }, + { + "name": "y1", + "type": "number", + "required": true + }, + { + "name": "x2", + "type": "number", + "required": true + }, + { + "name": "y2", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1104,10 +1245,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateTextHighlight.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1117,10 +1289,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateTextSquiggly.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1130,10 +1333,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateTextStrikeout.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1143,10 +1377,41 @@ "target": "Document", "command": { "label_key": "command.actionCreateTextUnderline.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "color", + "type": "string", + "required": false + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1455,10 +1720,41 @@ "target": "Document", "command": { "label_key": "command.actionInlineText.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "width", + "type": "number", + "required": true + }, + { + "name": "height", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2070,10 +2366,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteComment.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2083,10 +2400,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteHelp.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2096,10 +2434,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteInsert.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2109,10 +2468,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteKey.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2122,10 +2502,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteNewParagraph.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2135,10 +2536,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteNote.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2148,10 +2570,31 @@ "target": "Document", "command": { "label_key": "command.actionStickyNoteParagraph.label", - "parameters": [], - "capability": "unclassified", + "parameters": [ + { + "name": "page", + "type": "integer", + "required": true + }, + { + "name": "x", + "type": "number", + "required": true + }, + { + "name": "y", + "type": "number", + "required": true + }, + { + "name": "text", + "type": "string", + "required": true + } + ], + "capability": "document.modify", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index feaa1bd7..3ffd3988 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -129,6 +129,27 @@ "actionGotoNextBookmark", "actionGotoPreviousBookmark", "actionRedo", + # Annotation authoring (issue #234). EditorHost registers one handler per + # id; each maps its scalar geometry into the facade's direct-edit gateway + # (ADR-014). + "actionCreateTextHighlight", + "actionCreateTextUnderline", + "actionCreateTextStrikeout", + "actionCreateTextSquiggly", + "actionCreateRectangle", + "actionCreateEllipse", + "actionCreateStraightLine", + "actionCreatePolyline", + "actionCreatePolygon", + "actionCreateFreehandCurve", + "actionInlineText", + "actionStickyNoteComment", + "actionStickyNoteHelp", + "actionStickyNoteInsert", + "actionStickyNoteKey", + "actionStickyNoteNewParagraph", + "actionStickyNoteNote", + "actionStickyNoteParagraph", } ) From 166a1460010e072705d7d2532ded355aed168e19 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:38:47 -0700 Subject: [PATCH 05/11] feat(quick): form, attachment and metadata edits with send-by-email (#242) --- LoopEditor/editorhost.cpp | 195 +++ LoopEditor/editorhost.h | 41 + LoopEditor/qml/MenuModel.qml | 4 + LoopLibCore/CMakeLists.txt | 2 + LoopLibCore/sources/pdfdirectedit.cpp | 848 ++++++++++ LoopLibCore/sources/pdfdirectedit.h | 188 +++ LoopLibCore/sources/pdfdocumentcontext.cpp | 30 + LoopLibCore/sources/pdfdocumentcontext.h | 5 + LoopLibInteraction/sources/documentfacade.cpp | 208 +++ LoopLibInteraction/sources/documentfacade.h | 58 + UnitTests/CMakeLists.txt | 18 + UnitTests/tst_directedittest.cpp | 649 ++++++++ UnitTests/tst_documentfacadetest.cpp | 1482 +---------------- UnitTests/tst_editorhosttest.cpp | 531 ++++++ agent-policy.json | 2 + docs/generated/architecture-catalog.json | 1 + docs/loop-shell-actions.json | 142 +- docs/shell-handler-coverage.json | 1 - scripts/verify-command-catalog.py | 5 + .../qml/MenuModel.qml | 4 + 20 files changed, 2929 insertions(+), 1485 deletions(-) create mode 100644 LoopLibCore/sources/pdfdirectedit.cpp create mode 100644 LoopLibCore/sources/pdfdirectedit.h create mode 100644 UnitTests/tst_directedittest.cpp diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index a3361f3a..e1766d5d 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -45,6 +45,7 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdirectedit.h" #include "pdfdocumentimageextractor.h" #include "pdfannotationauthoring.h" #include "pdfsecurityhandler.h" @@ -4549,6 +4550,14 @@ bool EditorHost::commandPermissionAllowed(const QString& commandId) const { return permissions.modifyAnnotations; } + if (commandId == QLatin1String("actionSetFormFieldValue")) + { + return m_documentModel.canFillForms(); + } + if (commandId == QLatin1String("actionAddAttachment") || commandId == QLatin1String("actionRemoveAttachment") || + commandId == QLatin1String("actionEditDocumentMetadata")) + { + return m_documentModel.canModify(); } return true; } @@ -5200,6 +5209,39 @@ void EditorHost::registerFeatureHandlers() QString typedError; const std::optional edit = build(parameters, typedError); runAuthoringEdit(invocation, edit, typedError, parameters); + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // Form values, attachments and metadata are mutations through this catalog + // into Core: each handler maps its declared scalars into the facade's typed + // direct-edit request, and the facade checks the revision fence and the + // document's permission before any writer runs. A refusal finishes Failed + // with the facade's own stable code, so a stale, unauthorized, XFA or + // unsupported edit is reported rather than silently dropped. + auto bindAuthoringEdit = [this](const QString& id, pdfinteraction::DocumentDirectEditRequest::Kind kind) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const pdfinteraction::DocumentDirectEditResult result = applyAuthoringEdit(kind, parameters); + m_session->catalog().finishInvocation(invocation, + result.applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + result.applied ? QString() : result.typedError); + if (result.applied) + { + announceDocumentState(tr("The document was edited. Save to publish the new revision.")); + // The document was replaced in place, so the surfaces the new + // revision needs are requested now rather than at the next + // viewport change. + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(result.typedError)); + } + bumpPresentation(); }; m_session->catalog().setHandler(id, std::move(handler)); }; @@ -5456,10 +5498,152 @@ void EditorHost::registerFeatureHandlers() }; m_session->catalog().setHandler(entry.first, std::move(authoring)); } + bindAuthoringEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DocumentDirectEditRequest::Kind::SetFormFieldValue); + bindAuthoringEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment); + bindAuthoringEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::RemoveAttachment); + bindAuthoringEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DocumentDirectEditRequest::Kind::EditMetadata); + + // Send by e-mail hands the current published revision to the mail client and + // writes nothing. An unsaved document is refused with a typed reason: the + // bytes on disk are then a different revision than the one on screen. + pdfinteraction::CommandCatalog::Handler sendByMail; + sendByMail.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + QString typedError; + const bool handed = handOffPublishedRevisionToMail(&typedError); + m_session->catalog().finishInvocation(invocation, + handed ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + handed ? QString() : typedError); + if (!handed) + { + announceDocumentState(tr("The document was not handed to the mail client: %1").arg(typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionSend_by_E_Mail"), std::move(sendByMail)); refreshFeatureAvailability(); } +pdfinteraction::DocumentDirectEditResult EditorHost::applyAuthoringEdit(pdfinteraction::DocumentDirectEditRequest::Kind kind, + const QVariantMap& parameters) +{ + pdfinteraction::DocumentDirectEditResult result; + if (!hasDocument()) + { + result.typedError = QStringLiteral("document/not-ready"); + return result; + } + + pdfinteraction::DocumentDirectEditRequest request; + request.kind = kind; + request.scalars = parameters; + // The caller names the revision the edit was begun against. It is parsed at + // this boundary, and an absent or malformed identity stays invalid, so the + // gateway refuses it as a stale fence instead of guessing which revision + // the caller meant. + request.revision = pdf::PDFRevisionIdentity::fromString(parameters.value(QStringLiteral("revision")).toString()); + + if (kind == pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment) + { + // The command carries a path; the host reads the file so the mutation + // gateway never touches the filesystem. + constexpr qint64 maximumAttachmentBytes = 64ll * 1024 * 1024; + const QString source = parameters.value(QStringLiteral("path")).toString(); + QFile file(source); + if (source.isEmpty() || !file.open(QIODevice::ReadOnly)) + { + result.typedError = QStringLiteral("attachment/source-unreadable"); + return result; + } + if (file.size() > maximumAttachmentBytes) + { + result.typedError = QStringLiteral("attachment/source-too-large"); + return result; + } + request.contents = file.readAll(); + } + + return m_session->facade().applyDirectEdit(request); +} + +bool EditorHost::handOffPublishedRevisionToMail(QString* typedError) +{ + typedError->clear(); + if (!hasDocument()) + { + *typedError = QStringLiteral("mail/no-document"); + return false; + } + + const pdfinteraction::DocumentFacade& facade = m_session->facade(); + if (facade.facets().testFlag(pdfinteraction::DocumentFacet::Dirty)) + { + // The published bytes are a different revision than the one on screen. + // Handing them over as if they were current is exactly the stale case. + *typedError = QStringLiteral("mail/unsaved-changes"); + return false; + } + + const pdfinteraction::DocumentSource& source = facade.source(); + if (!source.isValid() || !QFileInfo::exists(source.path)) + { + *typedError = QStringLiteral("mail/no-published-file"); + return false; + } + + // The revision travels with the hand-off, so the receiving side can refuse a + // file that is not the revision the shell was showing. + Q_EMIT documentMailRequested(source.path, facade.currentRevision().toString()); + announceDocumentState(tr("The published document was handed to the mail client.")); + return true; +} + +bool EditorHost::extractAttachmentFileUrl(const QUrl& directory, const QString& name) +{ + const auto project = [this](const QString& status, const QString& fileName, bool autoOpenRefused, const QString& typedError) + { + QVariantMap projection; + projection.insert(QStringLiteral("status"), status); + projection.insert(QStringLiteral("fileName"), fileName); + projection.insert(QStringLiteral("autoOpenRefused"), autoOpenRefused); + projection.insert(QStringLiteral("typedError"), typedError); + m_attachmentExtraction = projection; + bumpPresentation(); + }; + + if (!hasDocument() || !m_session->context().getDocument() || name.isEmpty()) + { + project(QStringLiteral("refused"), name, false, QStringLiteral("attachment/no-document")); + return false; + } + + const pdf::PDFAttachmentWriter::ExtractionOutcome outcome = + pdf::PDFAttachmentWriter::extractToDirectory(m_session->context().getDocument(), name, directory.toLocalFile()); + + if (!outcome.written) + { + project(QStringLiteral("refused"), name, false, outcome.typedError); + announceDocumentState(tr("The attachment could not be extracted: %1").arg(outcome.typedError)); + return false; + } + + const QString extractedName = QFileInfo(outcome.path).fileName(); + project(QStringLiteral("written"), extractedName, outcome.autoOpenRefused, QString()); + if (outcome.autoOpenRefused) + { + // The bytes are the document's; launching them is not. The shell must + // not auto-open an executable-type attachment. + announceDocumentState(tr("Extracted %1. This file type must not be opened automatically.").arg(extractedName)); + } + else + { + announceDocumentState(tr("Extracted %1.").arg(extractedName)); + } + return true; +} + void EditorHost::refreshFeatureAvailability() { const bool ready = hasDocument(); @@ -5550,6 +5734,17 @@ void EditorHost::refreshFeatureAvailability() { availability.insert(entry.first, annotationAuthoring); } + // Direct authoring edits (ADR-014, issue #242). Each is enabled exactly when + // the open document grants the permission its family needs, so an + // unauthorized edit is unreachable rather than refused after the fact. + availability.insert(QStringLiteral("actionSetFormFieldValue"), ready && m_documentModel.canFillForms()); + availability.insert(QStringLiteral("actionAddAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionRemoveAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionEditDocumentMetadata"), ready && m_documentModel.canModify()); + // Send by e-mail stays enabled with a document even when it has unsaved + // changes, so its typed refusal is reachable and visible rather than the + // command silently disappearing from the menu. + availability.insert(QStringLiteral("actionSend_by_E_Mail"), ready && !outputBusy()); m_session->catalog().setEnabledBatch(availability); } diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index b20b88c6..71de76d7 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -170,6 +170,13 @@ class EditorHost final : public QObject Q_PROPERTY(bool settingsResetPending READ settingsResetPending NOTIFY presentationChanged) Q_PROPERTY(QVariantMap diagnosticsBundle READ diagnosticsBundle NOTIFY presentationChanged) + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // The last attachment extraction's outcome: `status` is `idle`, `written` or + // `refused`; `fileName` is the extracted name alone (never a path); + // `autoOpenRefused` says the extracted file must not be auto-opened because + // its type is executable; `typedError` is the stable refusal code. + Q_PROPERTY(QVariantMap attachmentExtraction READ attachmentExtraction NOTIFY presentationChanged) + // ---- Workspace surfaces (#586) ------------------------------------------------- // Every property below is a read-only projection of what Core, PdfTool or the // shell's own run already decided. QML renders these; it derives no state and @@ -356,6 +363,17 @@ class EditorHost final : public QObject /// in C++. Q_INVOKABLE bool collectDiagnosticsFileUrl(const QUrl& parentDirectory); + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + /// The last attachment extraction's outcome. See the Q_PROPERTY above. + QVariantMap attachmentExtraction() const { return m_attachmentExtraction; } + + /// Extracts one attachment into `directory` through the atomic writer, which + /// refuses to replace an existing file. The outcome is projected into + /// attachmentExtraction(); an extracted file whose type is executable is + /// reported with `autoOpenRefused`, and the shell must not hand it to the + /// operating system's opener. Reads the document; mutates nothing. + Q_INVOKABLE bool extractAttachmentFileUrl(const QUrl& directory, const QString& name); + /// Operator lifecycle of the governed-correction route (#586): /// `idle`, `planned`, `preview-ready`, `approved`, `executing`, `succeeded`, /// `stale`, `rejected`, `cancelled`, `failed`. It is a projection of the run the @@ -706,6 +724,13 @@ class EditorHost final : public QObject /// asks for one and answers with extractCurrentPageImagesFileUrl(). void imageExtractionRequested(); + /// Send by e-mail hands the current published revision to the shell's mail + /// client. `path` is the published artifact and `revision` is the identity it + /// was published as, so the receiving side can refuse a mismatch. Emitted + /// only for an open document with no unsaved changes: an unsaved or stale + /// revision is refused instead, never mailed as if it were the file. + void documentMailRequested(const QString& path, const QString& revision); + /// The shell-side surfaces (#239). The commands emit these; QML owns the /// dialogs and opens them. void settingsPanelRequested(); @@ -728,6 +753,18 @@ class EditorHost final : public QObject void registerFeatureHandlers(); void refreshFeatureAvailability(); bool commandPermissionAllowed(const QString& commandId) const; + /// Applies one authoring command through the facade's direct-edit gateway + /// (ADR-014). The command's declared scalars become the request's payload; + /// for an attachment the host reads the source file, so the gateway itself + /// never touches the filesystem. The facade's typed refusal is returned as + /// it stands. + pdfinteraction::DocumentDirectEditResult applyAuthoringEdit(pdfinteraction::DocumentDirectEditRequest::Kind kind, + const QVariantMap& parameters); + /// Hands the current published revision to the shell's mail client. Refuses + /// when there is no open document, when its source is not a file, or when it + /// has unsaved changes: the bytes on disk would then be a different revision + /// than the one on screen. Never writes and never opens anything itself. + bool handOffPublishedRevisionToMail(QString* typedError); bool requestMoveSelection(const QVariantMap& parameters); // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- @@ -880,6 +917,10 @@ class EditorHost final : public QObject }; DiagnosticsBundleState m_diagnosticsBundle; + /// The last attachment extraction's outcome (ADR-014, issue #242). A + /// projection of the Core writer's own result: file name only, never a path. + QVariantMap m_attachmentExtraction; + QPointer m_canvas; QHash m_activeAsyncJobs; QString m_outputJobId; diff --git a/LoopEditor/qml/MenuModel.qml b/LoopEditor/qml/MenuModel.qml index 6af44814..3ed992db 100644 --- a/LoopEditor/qml/MenuModel.qml +++ b/LoopEditor/qml/MenuModel.qml @@ -85,6 +85,7 @@ QtObject { } switch (entry.id) { case "actionAbout": return qsTr("About") + case "actionAddAttachment": return qsTr("Add Attachment") case "actionAutomaticDocumentRefresh": return qsTr("Automatic Document Refresh") case "actionBecomeASponsor": return qsTr("Become ASponsor") case "actionBleedFixup": return qsTr("Bleed Fixup") @@ -124,6 +125,7 @@ QtObject { case "actionCreateTextStrikeout": return qsTr("Create Text Strikeout") case "actionCreateTextUnderline": return qsTr("Create Text Underline") case "actionDeselectText": return qsTr("Deselect Text") + case "actionEditDocumentMetadata": return qsTr("Edit Document Metadata") case "actionEncryption": return qsTr("Encryption") case "actionExtractImage": return qsTr("Extract Image") case "actionFind": return qsTr("Find") @@ -161,6 +163,7 @@ QtObject { case "actionQuit": return qsTr("Quit") case "actionRedo": return qsTr("Redo") case "actionRemoveExternalLinks": return qsTr("Remove External Links") + case "actionRemoveAttachment": return qsTr("Remove Attachment") case "actionRender_to_Images": return qsTr("Render to Images") case "actionRendering_Errors": return qsTr("Rendering Errors") case "actionRenderOptionAntialiasing": return qsTr("Render Option Antialiasing") @@ -180,6 +183,7 @@ QtObject { case "actionSelectText": return qsTr("Select Text") case "actionSelectTextAll": return qsTr("Select Text All") case "actionSend_by_E_Mail": return qsTr("Send by E Mail") + case "actionSetFormFieldValue": return qsTr("Set Form Field Value") case "actionShow_Text_Blocks": return qsTr("Show Text Blocks") case "actionShow_Text_Lines": return qsTr("Show Text Lines") case "actionStickyNoteComment": return qsTr("Sticky Note Comment") diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index dea83696..bd66d6e0 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -138,6 +138,8 @@ add_library(LoopLibCore SHARED sources/pdfdocumenttextfloweditormodel.h sources/pdfdocumentwriter.cpp sources/pdfdocumentwriter.h + sources/pdfdirectedit.cpp + sources/pdfdirectedit.h sources/pdfwriteobjectvisitor.cpp sources/pdfwriteobjectvisitor_p.h sources/pdfexecutionpolicy.cpp diff --git a/LoopLibCore/sources/pdfdirectedit.cpp b/LoopLibCore/sources/pdfdirectedit.cpp new file mode 100644 index 00000000..529d4b85 --- /dev/null +++ b/LoopLibCore/sources/pdfdirectedit.cpp @@ -0,0 +1,848 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdirectedit.h" + +#include "pdfcatalog.h" +#include "pdfdocumentbuilder.h" +#include "pdffile.h" +#include "pdffilenamesanitizer.h" +#include "pdfform.h" +#include "pdfsafefilewriter.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace pdf +{ + +namespace +{ + +constexpr const char* NamespaceDublinCore = "http://purl.org/dc/elements/1.1/"; +constexpr const char* NamespaceRdf = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +constexpr const char* NamespacePdf = "http://ns.adobe.com/pdf/1.3/"; +constexpr const char* NamespaceXmp = "http://ns.adobe.com/xap/1.0/"; + +enum class XmpValueShape +{ + Text, ///< A plain text element. + LocalizedAlt, ///< An rdf:Alt with one x-default rdf:li (dc:title, dc:description). + Sequence ///< An rdf:Seq with one rdf:li (dc:creator). +}; + +/// One XMP property this module owns. Matching accepts the conventional prefix +/// and, when the reader exposes namespaces, the namespace URI as well. +struct XmpProperty +{ + QByteArray qualifiedName; + QByteArray namespaceUri; + XmpValueShape shape = XmpValueShape::Text; + std::optional value; + bool written = false; +}; + +bool isRdfDescription(const QXmlStreamReader& reader) +{ + return reader.name() == QLatin1String("Description") && + (reader.qualifiedName() == QLatin1String("rdf:Description") || + reader.namespaceUri() == QLatin1String(NamespaceRdf)); +} + +bool matchesProperty(const QXmlStreamReader& reader, const XmpProperty& property) +{ + if (reader.qualifiedName() == QLatin1String(property.qualifiedName)) + { + return true; + } + + // Namespace-aware fallback: a packet may bind another prefix to the same + // namespace. Only used when the reader actually reports a namespace. + const QString uri = reader.namespaceUri().toString(); + if (uri.isEmpty() || uri != QLatin1String(property.namespaceUri)) + { + return false; + } + const QByteArray localName = property.qualifiedName.mid(property.qualifiedName.indexOf(':') + 1); + return reader.name() == QString::fromLatin1(localName); +} + +void writeXmpProperty(QXmlStreamWriter& writer, const XmpProperty& property) +{ + // The writer only takes qualified names here: every prefix this module + // writes (dc, pdf, xmp, rdf) is already declared by the packet being merged + // into, or is declared by the fresh packet's own elements. + const QByteArray qualifiedName = property.qualifiedName; + const QString value = property.value.value_or(QString()); + + writer.writeStartElement(QString::fromLatin1(qualifiedName)); + switch (property.shape) + { + case XmpValueShape::LocalizedAlt: + { + writer.writeStartElement(QStringLiteral("rdf:Alt")); + writer.writeStartElement(QStringLiteral("rdf:li")); + writer.writeAttribute(QStringLiteral("xml:lang"), QStringLiteral("x-default")); + writer.writeCharacters(value); + writer.writeEndElement(); + writer.writeEndElement(); + break; + } + + case XmpValueShape::Sequence: + { + writer.writeStartElement(QStringLiteral("rdf:Seq")); + writer.writeStartElement(QStringLiteral("rdf:li")); + writer.writeCharacters(value); + writer.writeEndElement(); + writer.writeEndElement(); + break; + } + + case XmpValueShape::Text: + default: + writer.writeCharacters(value); + break; + } + writer.writeEndElement(); +} + +std::vector propertiesFor(const DocumentMetadataEdit& edit) +{ + std::vector properties; + properties.push_back({ QByteArrayLiteral("dc:title"), QByteArray(NamespaceDublinCore), XmpValueShape::LocalizedAlt, edit.title, false }); + properties.push_back({ QByteArrayLiteral("dc:creator"), QByteArray(NamespaceDublinCore), XmpValueShape::Sequence, edit.author, false }); + properties.push_back({ QByteArrayLiteral("dc:description"), QByteArray(NamespaceDublinCore), XmpValueShape::LocalizedAlt, edit.subject, false }); + properties.push_back({ QByteArrayLiteral("pdf:Keywords"), QByteArray(NamespacePdf), XmpValueShape::Text, edit.keywords, false }); + properties.push_back({ QByteArrayLiteral("xmp:CreatorTool"), QByteArray(NamespaceXmp), XmpValueShape::Text, edit.creator, false }); + return properties; +} + +/// A packet for a document that carries none. Uses the conventional prefixes so +/// the result reads like every other tool's packet. +QByteArray freshXmpPacket(const DocumentMetadataEdit& edit) +{ + const std::vector properties = propertiesFor(edit); + + QByteArray output; + QXmlStreamWriter writer(&output); + writer.writeProcessingInstruction(QStringLiteral("xpacket"), + QStringLiteral("begin=\"\uFEFF\" id=\"W5M0MpCehiHzreSzNTczkc9d\"")); + writer.writeStartElement(QStringLiteral("x:xmpmeta")); + writer.writeNamespace(QLatin1String("adobe:ns:meta/"), QStringLiteral("x")); + writer.writeStartElement(QStringLiteral("rdf:RDF")); + writer.writeNamespace(QLatin1String(NamespaceRdf), QStringLiteral("rdf")); + writer.writeStartElement(QStringLiteral("rdf:Description")); + writer.writeAttribute(QStringLiteral("rdf:about"), QString()); + writer.writeNamespace(QLatin1String(NamespaceDublinCore), QStringLiteral("dc")); + writer.writeNamespace(QLatin1String(NamespacePdf), QStringLiteral("pdf")); + writer.writeNamespace(QLatin1String(NamespaceXmp), QStringLiteral("xmp")); + + for (const XmpProperty& property : properties) + { + if (property.value) + { + writeXmpProperty(writer, property); + } + } + + writer.writeEndElement(); + writer.writeEndElement(); + writer.writeEndElement(); + writer.writeProcessingInstruction(QStringLiteral("xpacket"), QStringLiteral("end=\"w\"")); + return output; +} + +/// Merges the edit's properties into an existing packet, copying every token it +/// does not own. `std::nullopt` means the packet did not parse, so the caller +/// can refuse rather than overwrite a packet it could not read. +std::optional mergeXmpPacket(const QByteArray& existing, const DocumentMetadataEdit& edit) +{ + std::vector properties = propertiesFor(edit); + + QXmlStreamReader reader(existing); + reader.setNamespaceProcessing(true); + QByteArray output; + QXmlStreamWriter writer(&output); + + int depth = 0; + int descriptionDepth = -1; + bool descriptionSeen = false; + bool insertedMissing = false; + + while (!reader.atEnd()) + { + reader.readNext(); + if (reader.hasError()) + { + return std::nullopt; + } + + switch (reader.tokenType()) + { + case QXmlStreamReader::StartDocument: + // The packet is not a whole XML document: an XML declaration + // before the xpacket wrapper would make it invalid XMP. + break; + + case QXmlStreamReader::StartElement: + { + ++depth; + if (isRdfDescription(reader)) + { + descriptionSeen = true; + descriptionDepth = depth; + } + else if (descriptionDepth >= 0) + { + bool replaced = false; + for (XmpProperty& property : properties) + { + if (property.value && !property.written && matchesProperty(reader, property)) + { + property.written = true; + writeXmpProperty(writer, property); + replaced = true; + break; + } + } + if (replaced) + { + // skipCurrentElement consumes the element up to and + // including its EndElement, so no token is copied and + // the depth returns to the enclosing element. + reader.skipCurrentElement(); + --depth; + continue; + } + } + writer.writeCurrentToken(reader); + break; + } + + case QXmlStreamReader::EndElement: + { + if (descriptionDepth == depth && !insertedMissing) + { + insertedMissing = true; + for (XmpProperty& property : properties) + { + if (property.value && !property.written) + { + property.written = true; + writeXmpProperty(writer, property); + } + } + } + --depth; + if (depth < descriptionDepth) + { + descriptionDepth = -1; + } + writer.writeCurrentToken(reader); + break; + } + + default: + writer.writeCurrentToken(reader); + break; + } + } + + if (reader.hasError()) + { + return std::nullopt; + } + if (!descriptionSeen) + { + // A packet with no rdf:Description carries no XMP properties to + // preserve, so a standard one is written instead. + return freshXmpPacket(edit); + } + return output; +} + +/// Finds the embedded file specification for a name, matching the raw name-tree +/// key first and the decoded platform file name second. +const PDFFileSpecification* findSpecification(const PDFDocument* document, const QString& name) +{ + if (!document || !document->getCatalog()) + { + return nullptr; + } + + const QByteArray utf8 = name.toUtf8(); + for (const auto& item : document->getCatalog()->getEmbeddedFiles()) + { + if (item.first == utf8 || item.second.getPlatformFileName() == name) + { + return &item.second; + } + } + return nullptr; +} + +std::optional findEmbeddedFileKey(const PDFDocument* document, const QString& name) +{ + if (!document || !document->getCatalog()) + { + return std::nullopt; + } + + const QByteArray utf8 = name.toUtf8(); + for (const auto& item : document->getCatalog()->getEmbeddedFiles()) + { + if (item.first == utf8 || item.second.getPlatformFileName() == name) + { + return item.first; + } + } + return std::nullopt; +} + +/// Rewrites the catalog /Names dictionary with the given EmbeddedFiles entries. +/// Every other entry (Dests, JavaScript, ...) is carried over verbatim; an empty +/// entry set removes the EmbeddedFiles key, and an empty Names dictionary +/// removes the key itself. +void writeEmbeddedFileNamesTree(PDFDocumentBuilder* builder, + const std::map& entries) +{ + const PDFObjectStorage* storage = builder->getStorage(); + PDFDictionary names; + if (const PDFDictionary* catalogDictionary = + storage->getDictionaryFromObject(PDFObject::createReference(builder->getCatalogReference()))) + { + if (const PDFDictionary* existing = storage->getDictionaryFromObject(catalogDictionary->get("Names"))) + { + for (size_t i = 0; i < existing->getCount(); ++i) + { + if (existing->getKey(i) != "EmbeddedFiles") + { + names.addEntry(existing->getKey(i), PDFObject(existing->getValue(i))); + } + } + } + } + + if (!entries.empty()) + { + PDFObjectFactory treeFactory; + treeFactory.beginDictionary(); + treeFactory.beginDictionaryItem("Names"); + treeFactory.beginArray(); + for (const auto& entry : entries) + { + treeFactory << WrapString(entry.first); + treeFactory << entry.second; + } + treeFactory.endArray(); + treeFactory.endDictionaryItem(); + treeFactory.endDictionary(); + names.addEntry(PDFInplaceOrMemoryString("EmbeddedFiles"), + PDFObject::createReference(builder->addObject(treeFactory.takeObject()))); + } + + PDFObjectFactory catalogFactory; + catalogFactory.beginDictionary(); + catalogFactory.beginDictionaryItem("Names"); + if (names.getCount() > 0) + { + catalogFactory << PDFObject::createDictionary(std::make_shared(std::move(names))); + } + else + { + catalogFactory << nullptr; + } + catalogFactory.endDictionaryItem(); + catalogFactory.endDictionary(); + builder->mergeTo(builder->getCatalogReference(), catalogFactory.takeObject()); +} + +} // namespace + +const char* getDirectEditRefusalCode(DirectEditRefusal refusal) +{ + switch (refusal) + { + case DirectEditRefusal::None: + return ""; + case DirectEditRefusal::InvalidRequest: + return "direct-edit/invalid-request"; + case DirectEditRefusal::NoDocument: + return "direct-edit/no-document"; + case DirectEditRefusal::NoForm: + return "form/not-present"; + case DirectEditRefusal::XfaUnsupported: + return "form/xfa-unsupported"; + case DirectEditRefusal::FieldNotFound: + return "form/field-not-found"; + case DirectEditRefusal::FieldNotEditable: + return "form/field-not-editable"; + case DirectEditRefusal::UnsupportedFieldType: + return "form/unsupported-field-type"; + case DirectEditRefusal::ValueNotAllowed: + return "form/value-not-allowed"; + case DirectEditRefusal::ValueUnchanged: + return "direct-edit/value-unchanged"; + case DirectEditRefusal::AttachmentNotFound: + return "attachment/not-found"; + case DirectEditRefusal::AttachmentNameTaken: + return "attachment/name-taken"; + case DirectEditRefusal::AttachmentNameInvalid: + return "attachment/name-invalid"; + case DirectEditRefusal::XmpUnreadable: + return "metadata/xmp-unreadable"; + case DirectEditRefusal::MetadataUnchanged: + return "metadata/unchanged"; + case DirectEditRefusal::WriteRefused: + return "attachment/output-write-refused"; + } + + return "direct-edit/invalid-request"; +} + +DirectEditOutcome PDFFormValueWriter::setFieldValue(const PDFDocument* document, const FormFieldValueEdit& edit) +{ + DirectEditOutcome outcome; + if (!document) + { + outcome.refusal = DirectEditRefusal::NoDocument; + return outcome; + } + if (edit.field.isEmpty() || (!edit.text.has_value() && !edit.checked.has_value())) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + + // The form manager owns the parsed field model; the modifier owns the copy + // the edit is written into. Both follow the interactive editor's own path, + // so widget appearances are updated by the same code. The manager reads the + // document and writes only into the modifier's builder, so the const view + // the caller handed in is not mutated. + PDFFormManager formManager(nullptr); + formManager.setDocument(PDFModifiedDocument(const_cast(document), nullptr, PDFModifiedDocument::Reset)); + + const PDFForm* form = formManager.getForm(); + if (!form || form->getFormType() == PDFForm::FormType::None) + { + outcome.refusal = DirectEditRefusal::NoForm; + return outcome; + } + if (form->isXFAForm()) + { + // The values an XFA form presents live in its packet. Writing only the + // AcroForm mirror would leave the two disagreeing, so it is refused. + outcome.refusal = DirectEditRefusal::XfaUnsupported; + return outcome; + } + + PDFFormField* field = nullptr; + formManager.modify([&field, &edit](PDFFormField* candidate) + { + if (!field && candidate->getName(PDFFormField::NameType::FullyQualified) == edit.field) + { + field = candidate; + } }); + if (!field) + { + outcome.refusal = DirectEditRefusal::FieldNotFound; + return outcome; + } + + PDFObject value; + switch (field->getFieldType()) + { + case PDFFormField::FieldType::Text: + { + if (!edit.text) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + value = PDFObjectFactory::createTextString(*edit.text); + break; + } + + case PDFFormField::FieldType::Choice: + { + if (!edit.text) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + const auto* choice = static_cast(field); + if (!choice->isEditableComboBox()) + { + bool known = false; + for (const PDFFormFieldChoice::Option& option : choice->getOptions()) + { + known = known || option.exportString == *edit.text || option.userString == *edit.text; + } + if (!known) + { + outcome.refusal = DirectEditRefusal::ValueNotAllowed; + return outcome; + } + } + value = PDFObjectFactory::createTextString(*edit.text); + break; + } + + case PDFFormField::FieldType::Button: + { + if (!edit.checked) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + const auto* button = static_cast(field); + if (button->getButtonType() == PDFFormFieldButton::ButtonType::PushButton || button->getWidgets().empty()) + { + outcome.refusal = DirectEditRefusal::FieldNotEditable; + return outcome; + } + const QByteArray onState = PDFFormFieldButton::getOnAppearanceState(&formManager, &button->getWidgets().front()); + if (*edit.checked && onState.isEmpty()) + { + // A button whose appearance declares no on-state cannot be + // checked; inventing one would be a silent repair. + outcome.refusal = DirectEditRefusal::FieldNotEditable; + return outcome; + } + value = PDFObject::createName(*edit.checked ? onState : QByteArrayLiteral("Off")); + break; + } + + case PDFFormField::FieldType::Signature: + case PDFFormField::FieldType::Invalid: + default: + outcome.refusal = DirectEditRefusal::UnsupportedFieldType; + return outcome; + } + + PDFDocumentModifier modifier(document); + modifier.getBuilder()->setFormManager(&formManager); + + PDFFormField::SetValueParameters parameters; + parameters.value = value; + parameters.invokingWidget = field->getWidgets().empty() ? PDFObjectReference() : field->getWidgets().front().getWidget(); + parameters.invokingFormField = field; + parameters.modifier = &modifier; + parameters.formManager = &formManager; + parameters.scope = PDFFormField::SetValueParameters::Scope::User; + + if (!field->setValue(parameters)) + { + outcome.refusal = DirectEditRefusal::FieldNotEditable; + return outcome; + } + if (!modifier.finalize()) + { + outcome.refusal = DirectEditRefusal::ValueUnchanged; + return outcome; + } + + outcome.document = modifier.getDocument(); + return outcome; +} + +bool PDFAttachmentWriter::isExecutableFileName(const QString& name) +{ + static const QStringList executableSuffixes = { + QStringLiteral("exe"), QStringLiteral("com"), QStringLiteral("bat"), QStringLiteral("cmd"), + QStringLiteral("msi"), QStringLiteral("msp"), QStringLiteral("scr"), QStringLiteral("pif"), + QStringLiteral("cpl"), QStringLiteral("dll"), QStringLiteral("sys"), QStringLiteral("ps1"), + QStringLiteral("psm1"), QStringLiteral("vbs"), QStringLiteral("vbe"), QStringLiteral("js"), + QStringLiteral("jse"), QStringLiteral("wsf"), QStringLiteral("wsh"), QStringLiteral("hta"), + QStringLiteral("jar"), QStringLiteral("reg"), QStringLiteral("lnk"), QStringLiteral("url"), + QStringLiteral("app"), QStringLiteral("sh"), QStringLiteral("bash"), QStringLiteral("zsh"), + QStringLiteral("command"), QStringLiteral("workflow"), QStringLiteral("desktop"), + QStringLiteral("apk"), QStringLiteral("dmg"), QStringLiteral("pkg"), QStringLiteral("deb"), + QStringLiteral("rpm"), QStringLiteral("run"), QStringLiteral("bin") + }; + + const QString suffix = QFileInfo(name).suffix().toLower(); + return !suffix.isEmpty() && executableSuffixes.contains(suffix); +} + +DirectEditOutcome PDFAttachmentWriter::add(const PDFDocument* document, const AttachmentEdit& edit) +{ + DirectEditOutcome outcome; + if (!document) + { + outcome.refusal = DirectEditRefusal::NoDocument; + return outcome; + } + if (edit.name.isEmpty() || PDFFilenameSanitizer::sanitize(edit.name) != edit.name) + { + // A name this writer would have to repair is refused, not repaired. + outcome.refusal = DirectEditRefusal::AttachmentNameInvalid; + return outcome; + } + if (findEmbeddedFileKey(document, edit.name)) + { + outcome.refusal = DirectEditRefusal::AttachmentNameTaken; + return outcome; + } + + PDFDocumentModifier modifier(document); + PDFDocumentBuilder* builder = modifier.getBuilder(); + + PDFDictionary streamDictionary; + streamDictionary.addEntry(PDFInplaceOrMemoryString("Type"), PDFObject::createName("EmbeddedFile")); + if (!edit.mimeType.isEmpty()) + { + streamDictionary.addEntry(PDFInplaceOrMemoryString("Subtype"), PDFObject::createName(edit.mimeType)); + } + // Streams carry their own /Length: the writer does not add one, and a reader + // that honours the entry would otherwise reject the file this writes. + streamDictionary.addEntry(PDFInplaceOrMemoryString("Length"), PDFObject::createInteger(edit.contents.size())); + PDFDictionary parameters; + parameters.addEntry(PDFInplaceOrMemoryString("Size"), PDFObject::createInteger(edit.contents.size())); + streamDictionary.addEntry(PDFInplaceOrMemoryString("Params"), + PDFObject::createDictionary(std::make_shared(std::move(parameters)))); + const PDFObjectReference embeddedFile = + builder->addObject(PDFObject::createStream(std::make_shared(std::move(streamDictionary), QByteArray(edit.contents)))); + + PDFDictionary fileSpecification; + fileSpecification.addEntry(PDFInplaceOrMemoryString("Type"), PDFObject::createName("Filespec")); + fileSpecification.addEntry(PDFInplaceOrMemoryString("F"), PDFObject::createString(edit.name.toUtf8())); + fileSpecification.addEntry(PDFInplaceOrMemoryString("UF"), PDFObjectFactory::createTextString(edit.name)); + if (!edit.description.isEmpty()) + { + fileSpecification.addEntry(PDFInplaceOrMemoryString("Desc"), PDFObjectFactory::createTextString(edit.description)); + } + PDFDictionary embeddedFiles; + embeddedFiles.addEntry(PDFInplaceOrMemoryString("F"), PDFObject::createReference(embeddedFile)); + embeddedFiles.addEntry(PDFInplaceOrMemoryString("UF"), PDFObject::createReference(embeddedFile)); + fileSpecification.addEntry(PDFInplaceOrMemoryString("EF"), + PDFObject::createDictionary(std::make_shared(std::move(embeddedFiles)))); + const PDFObjectReference specification = + builder->addObject(PDFObject::createDictionary(std::make_shared(std::move(fileSpecification)))); + + std::map entries; + for (const auto& item : document->getCatalog()->getEmbeddedFiles()) + { + if (item.second.getSelfReference().isValid()) + { + entries[item.first] = item.second.getSelfReference(); + } + } + entries[edit.name.toUtf8()] = specification; + writeEmbeddedFileNamesTree(builder, entries); + + if (!modifier.finalize()) + { + outcome.refusal = DirectEditRefusal::ValueUnchanged; + return outcome; + } + + outcome.document = modifier.getDocument(); + return outcome; +} + +DirectEditOutcome PDFAttachmentWriter::remove(const PDFDocument* document, const QString& name) +{ + DirectEditOutcome outcome; + if (!document) + { + outcome.refusal = DirectEditRefusal::NoDocument; + return outcome; + } + if (name.isEmpty()) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + + const std::optional key = findEmbeddedFileKey(document, name); + if (!key) + { + outcome.refusal = DirectEditRefusal::AttachmentNotFound; + return outcome; + } + + PDFDocumentModifier modifier(document); + std::map entries; + for (const auto& item : document->getCatalog()->getEmbeddedFiles()) + { + if (item.first != *key && item.second.getSelfReference().isValid()) + { + entries[item.first] = item.second.getSelfReference(); + } + } + writeEmbeddedFileNamesTree(modifier.getBuilder(), entries); + + if (!modifier.finalize()) + { + outcome.refusal = DirectEditRefusal::ValueUnchanged; + return outcome; + } + + outcome.document = modifier.getDocument(); + return outcome; +} + +PDFAttachmentWriter::ExtractionOutcome PDFAttachmentWriter::extractToDirectory(const PDFDocument* document, + const QString& name, + const QString& targetDirectory) +{ + ExtractionOutcome outcome; + if (!document) + { + outcome.refusal = DirectEditRefusal::NoDocument; + outcome.typedError = QString::fromLatin1(getDirectEditRefusalCode(outcome.refusal)); + return outcome; + } + + const QDir directory(targetDirectory); + if (targetDirectory.isEmpty() || !directory.exists()) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + outcome.typedError = QStringLiteral("attachment/invalid-directory"); + return outcome; + } + + const PDFFileSpecification* specification = findSpecification(document, name); + const PDFEmbeddedFile* file = specification ? specification->getPlatformFile() : nullptr; + if (!file || !file->isValid() || !file->getStream()) + { + outcome.refusal = DirectEditRefusal::AttachmentNotFound; + outcome.typedError = QString::fromLatin1(getDirectEditRefusalCode(outcome.refusal)); + return outcome; + } + + const QString fileName = PDFFilenameSanitizer::sanitize(name); + const QString path = directory.filePath(fileName); + if (!PDFFilenameSanitizer::isPathContained(path, targetDirectory)) + { + outcome.refusal = DirectEditRefusal::AttachmentNameInvalid; + outcome.typedError = QString::fromLatin1(getDirectEditRefusalCode(outcome.refusal)); + return outcome; + } + + // The atomic writer: temp file plus commit, refusing to replace an existing + // file, so a failed extraction cannot truncate one. + const PDFOperationResult result = + PDFSafeFileWriter::writeData(path, *file->getStream()->getContent(), PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + outcome.refusal = DirectEditRefusal::WriteRefused; + outcome.typedError = QString::fromLatin1(getDirectEditRefusalCode(outcome.refusal)); + return outcome; + } + + outcome.written = true; + outcome.path = path; + outcome.autoOpenRefused = isExecutableFileName(fileName); + return outcome; +} + +QByteArray PDFMetadataWriter::readXmpPacket(const PDFDocument* document) +{ + if (!document || !document->getCatalog()) + { + return QByteArray(); + } + + const PDFObject metadata = document->getObject(document->getCatalog()->getMetadata()); + const PDFStream* stream = metadata.isStream() ? metadata.getStream() : nullptr; + return stream ? *stream->getContent() : QByteArray(); +} + +DirectEditOutcome PDFMetadataWriter::update(const PDFDocument* document, const DocumentMetadataEdit& edit) +{ + DirectEditOutcome outcome; + if (!document) + { + outcome.refusal = DirectEditRefusal::NoDocument; + return outcome; + } + if (!edit.title && !edit.author && !edit.subject && !edit.keywords && !edit.creator) + { + outcome.refusal = DirectEditRefusal::InvalidRequest; + return outcome; + } + + const QByteArray existingPacket = readXmpPacket(document); + QByteArray packet; + if (existingPacket.isEmpty()) + { + packet = freshXmpPacket(edit); + } + else + { + const std::optional merged = mergeXmpPacket(existingPacket, edit); + if (!merged) + { + // Never silently replace a packet that could not be read: it may + // carry a conformance claim or another tool's properties. + outcome.refusal = DirectEditRefusal::XmpUnreadable; + return outcome; + } + packet = *merged; + } + + PDFDocumentModifier modifier(document); + PDFDocumentBuilder* builder = modifier.getBuilder(); + + PDFObjectFactory infoFactory; + infoFactory.beginDictionary(); + const auto writeInfoEntry = [&infoFactory](const char* key, const std::optional& value) + { + if (value) + { + infoFactory.beginDictionaryItem(key); + infoFactory << *value; + infoFactory.endDictionaryItem(); + } + }; + writeInfoEntry("Title", edit.title); + writeInfoEntry("Author", edit.author); + writeInfoEntry("Subject", edit.subject); + writeInfoEntry("Keywords", edit.keywords); + writeInfoEntry("Creator", edit.creator); + infoFactory.endDictionary(); + builder->updateDocumentInfo(infoFactory.takeObject()); + + builder->setCatalogMetadata(packet); + + if (!modifier.finalize()) + { + outcome.refusal = DirectEditRefusal::MetadataUnchanged; + return outcome; + } + + outcome.document = modifier.getDocument(); + return outcome; +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfdirectedit.h b/LoopLibCore/sources/pdfdirectedit.h new file mode 100644 index 00000000..de3bd2ea --- /dev/null +++ b/LoopLibCore/sources/pdfdirectedit.h @@ -0,0 +1,188 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFDIRECTEDIT_H +#define PDFDIRECTEDIT_H + +#include "pdfdocument.h" +#include "pdfglobal.h" + +#include +#include + +#include + +namespace pdf +{ + +/// Why a direct authoring edit was refused (ADR-014). +/// +/// The interaction layer maps these to typed refusals; they are stable, +/// content-free codes, never document content and never a path. A refusal means +/// nothing was written: the writer builds the whole modified document in memory +/// and only hands it back when the edit was accepted in full. +enum class DirectEditRefusal +{ + None, ///< The edit was applied; `document` carries the new revision. + InvalidRequest, ///< The payload is missing or ill-typed. + NoDocument, ///< No document was supplied. + NoForm, ///< The document has no interactive form. + XfaUnsupported, ///< XFA forms are not editable through the AcroForm writer. + FieldNotFound, ///< No field carries the requested fully qualified name. + FieldNotEditable, ///< Read-only, push button, or the writer refused the value. + UnsupportedFieldType, ///< Signature fields are never filled. + ValueNotAllowed, ///< The value is not one the field admits (e.g. a fixed choice). + ValueUnchanged, ///< The edit would not change the document. + AttachmentNotFound, ///< No embedded file carries the requested name. + AttachmentNameTaken, ///< An embedded file with that name already exists. + AttachmentNameInvalid, ///< The name is empty or would need sanitizing. + XmpUnreadable, ///< The existing XMP packet could not be parsed; nothing was replaced. + MetadataUnchanged, ///< The metadata edit would not change the document. + WriteRefused ///< The atomic output writer refused the target. +}; + +/// The stable refusal code, e.g. `form/xfa-unsupported`. `None` maps to an empty +/// string. +LOOPLIBCORESHARED_EXPORT const char* getDirectEditRefusalCode(DirectEditRefusal refusal); + +/// One direct edit's outcome. Exactly one of the two shapes is true: either +/// `document` holds the new revision with `refusal == None`, or `refusal` names +/// why nothing was written. +struct LOOPLIBCORESHARED_EXPORT DirectEditOutcome +{ + PDFDocumentPointer document; + DirectEditRefusal refusal = DirectEditRefusal::None; + + bool ok() const noexcept { return refusal == DirectEditRefusal::None && !document.isNull(); } +}; + +/// One form field value edit. `field` is the fully qualified field name. +/// A text or choice field takes `text`; a checkbox or radio button takes +/// `checked`. Supplying the shape the field does not admit is refused rather +/// than ignored. +struct LOOPLIBCORESHARED_EXPORT FormFieldValueEdit +{ + QString field; + std::optional text; + std::optional checked; +}; + +/// Sets one interactive form field's value. The value is written through +/// pdf::PDFDocumentModifier, so widget appearances are updated by the same +/// machinery the interactive editor uses. XFA forms are refused: their values +/// live in the XFA packet, and writing only the AcroForm mirror would be a +/// half-write. +class LOOPLIBCORESHARED_EXPORT PDFFormValueWriter +{ +public: + static DirectEditOutcome setFieldValue(const PDFDocument* document, const FormFieldValueEdit& edit); +}; + +/// One attachment edit. `name` is the file name shown in the EmbeddedFiles name +/// tree; the writer refuses a name that would have to be sanitized rather than +/// sanitizing it silently. +struct LOOPLIBCORESHARED_EXPORT AttachmentEdit +{ + QString name; + QString description; + QByteArray contents; + QByteArray mimeType; ///< Written as the embedded file stream's /Subtype name, e.g. `text/plain`. +}; + +/// Adds and removes document-level file attachments (catalog /Names / +/// EmbeddedFiles) and extracts one attachment's bytes to disk. +/// +/// Add and remove rebuild the EmbeddedFiles name tree from the document's +/// parsed attachments plus the edit, and leave every other /Names entry (Dests, +/// JavaScript, ...) untouched. The file specification and embedded stream of a +/// removed attachment stay in the file as unreferenced objects; removal is the +/// document-visible act of unlisting the attachment, not object garbage +/// collection. +class LOOPLIBCORESHARED_EXPORT PDFAttachmentWriter +{ +public: + static DirectEditOutcome add(const PDFDocument* document, const AttachmentEdit& edit); + static DirectEditOutcome remove(const PDFDocument* document, const QString& name); + + /// True when the file name carries an executable-type extension. Extracted + /// attachments with such a name must never be handed to the operating + /// system's opener: extraction may write the bytes the document carries, + /// but launching them is refused. + static bool isExecutableFileName(const QString& name); + + /// One extraction's outcome. `written` says the bytes are on disk; + /// `autoOpenRefused` says the extracted file must not be auto-opened, which + /// is a property of the file, not a failure of the extraction. + struct LOOPLIBCORESHARED_EXPORT ExtractionOutcome + { + bool written = false; + QString path; + bool autoOpenRefused = false; + DirectEditRefusal refusal = DirectEditRefusal::None; + QString typedError; ///< Stable code, e.g. `attachment/output-write-refused`. + + bool ok() const noexcept { return written; } + }; + + /// Writes one attachment's bytes into \p targetDirectory through + /// pdf::PDFSafeFileWriter (atomic temp-and-rename) under a sanitized file + /// name, refusing to overwrite an existing file. The executable check is + /// reported, never applied by deleting or renaming the file: the bytes are + /// the document's. + static ExtractionOutcome extractToDirectory(const PDFDocument* document, + const QString& name, + const QString& targetDirectory); +}; + +/// One document metadata edit. Only the fields that carry a value are written; +/// an absent field is left alone, and an empty string clears the field. +struct LOOPLIBCORESHARED_EXPORT DocumentMetadataEdit +{ + std::optional title; + std::optional author; + std::optional subject; + std::optional keywords; + std::optional creator; +}; + +/// Updates the document information dictionary and the catalog XMP packet. +/// +/// The XMP packet is merged, not replaced: every element and attribute the +/// document already carries is preserved, and only the properties this edit +/// owns (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool) +/// are written. A packet that does not parse is refused instead of being +/// silently overwritten, because an unreadable packet may carry a conformance +/// claim. A document with no packet gets a standard one. +class LOOPLIBCORESHARED_EXPORT PDFMetadataWriter +{ +public: + static DirectEditOutcome update(const PDFDocument* document, const DocumentMetadataEdit& edit); + + /// Reads the catalog XMP packet, or an empty array when the document has + /// none. Used by tests and by callers that must show what they are about to + /// merge into. + static QByteArray readXmpPacket(const PDFDocument* document); +}; + +} // namespace pdf + +#endif // PDFDIRECTEDIT_H diff --git a/LoopLibCore/sources/pdfdocumentcontext.cpp b/LoopLibCore/sources/pdfdocumentcontext.cpp index 9b297503..769d0dcc 100644 --- a/LoopLibCore/sources/pdfdocumentcontext.cpp +++ b/LoopLibCore/sources/pdfdocumentcontext.cpp @@ -59,6 +59,36 @@ QString PDFRevisionIdentity::toString() const .arg(effectiveProfileIdentity); } +PDFRevisionIdentity PDFRevisionIdentity::fromString(const QString& value) +{ + PDFRevisionIdentity identity; + + // The effective profile identity is the last field and may itself contain + // colons, so the first four separators are the structure and everything + // after the fourth belongs to the profile identity. + QStringList parts = value.split(QLatin1Char(':')); + if (parts.size() < 5) + { + return identity; + } + + bool documentRevisionOk = false; + bool cacheGenerationOk = false; + const quint64 documentRevision = parts[2].toULongLong(&documentRevisionOk); + const quint64 cacheGeneration = parts[3].toULongLong(&cacheGenerationOk); + if (!documentRevisionOk || !cacheGenerationOk || parts[1].isEmpty()) + { + return identity; + } + + identity.document.sourceDataHash = QByteArray::fromHex(parts[0].toLatin1()); + identity.document.documentId = parts[1]; + identity.documentRevision = documentRevision; + identity.cacheGeneration = cacheGeneration; + identity.effectiveProfileIdentity = parts.mid(4).join(QLatin1Char(':')); + return identity; +} + PDFDocumentContext::PDFDocumentContext(PDFDocument* document, QObject* parent, qsizetype pageCacheTotal) : QObject(parent), m_document(document), diff --git a/LoopLibCore/sources/pdfdocumentcontext.h b/LoopLibCore/sources/pdfdocumentcontext.h index db558040..a663efc5 100644 --- a/LoopLibCore/sources/pdfdocumentcontext.h +++ b/LoopLibCore/sources/pdfdocumentcontext.h @@ -69,6 +69,11 @@ struct LOOPLIBCORESHARED_EXPORT PDFRevisionIdentity bool operator==(const PDFRevisionIdentity&) const = default; bool operator<(const PDFRevisionIdentity& other) const; QString toString() const; + + /// Parses the identity `toString()` produced. A malformed value yields an + /// invalid identity rather than a guessed one, so a caller that passes a + /// revision it made up is refused by the fence instead of being admitted. + static PDFRevisionIdentity fromString(const QString& value); }; using PDFRevisionToken = PDFRevisionIdentity; diff --git a/LoopLibInteraction/sources/documentfacade.cpp b/LoopLibInteraction/sources/documentfacade.cpp index e5ade175..24a44bf3 100644 --- a/LoopLibInteraction/sources/documentfacade.cpp +++ b/LoopLibInteraction/sources/documentfacade.cpp @@ -22,7 +22,9 @@ #include "documentfacade.h" +#include "pdfdirectedit.h" #include "pdfresourcebudget.h" +#include "pdfsecurityhandler.h" #include #include @@ -41,6 +43,60 @@ namespace { constexpr const char* PathParameter = "path"; +constexpr const char* FieldParameter = "field"; +constexpr const char* ValueParameter = "value"; +constexpr const char* CheckedParameter = "checked"; +constexpr const char* NameParameter = "name"; +constexpr const char* DescriptionParameter = "description"; +constexpr const char* TitleParameter = "title"; +constexpr const char* AuthorParameter = "author"; +constexpr const char* SubjectParameter = "subject"; +constexpr const char* KeywordsParameter = "keywords"; +constexpr const char* CreatorParameter = "creator"; + +/// Reads one declared string parameter. An absent key is an absent value; a key +/// carrying another type is malformed, never coerced. +std::optional scalarString(const QVariantMap& scalars, const char* name, bool* malformed) +{ + const auto it = scalars.constFind(QLatin1String(name)); + if (it == scalars.constEnd()) + { + return std::nullopt; + } + if (it->typeId() != QMetaType::QString) + { + *malformed = true; + return std::nullopt; + } + return it->toString(); +} + +std::optional scalarBool(const QVariantMap& scalars, const char* name, bool* malformed) +{ + const auto it = scalars.constFind(QLatin1String(name)); + if (it == scalars.constEnd()) + { + return std::nullopt; + } + if (it->typeId() != QMetaType::Bool) + { + *malformed = true; + return std::nullopt; + } + return it->toBool(); +} + +bool requiredString(const QVariantMap& scalars, const char* name, QString* value, bool* malformed) +{ + const std::optional read = scalarString(scalars, name, malformed); + if (!read || read->isEmpty()) + { + *malformed = true; + return false; + } + *value = *read; + return true; +} } // namespace @@ -278,6 +334,158 @@ DocumentEditOutcome DocumentFacade::applyAuthoringEdit(const DocumentEditRequest outcome.affectedCount = result.affectedCount; outcome.revision = m_revisionSource.currentRevision(); return outcome; +DocumentDirectEditResult DocumentFacade::applyDirectEdit(const DocumentDirectEditRequest& request) +{ + DocumentDirectEditResult result; + result.revision = currentRevision(); + + pdf::PDFDocumentContext* documentContext = context(); + const pdf::PDFDocument* document = documentContext ? documentContext->getDocument() : nullptr; + if (m_state != DocumentState::Ready || !document) + { + result.typedError = QStringLiteral("document/not-ready"); + return result; + } + + // The fence first. An edit begun against a superseded revision is refused + // before its payload is read and before any writer is reached. + if (!request.revision.isValid() || request.revision != currentRevision()) + { + result.typedError = QStringLiteral("document/stale-revision"); + return result; + } + + // One mutation at a time: a lifecycle operation in flight is measured + // against the revision this edit is about to supersede. + if (m_pendingInvocation != InvalidCommandInvocation || m_outputState == DocumentOutputState::Pending) + { + result.typedError = QStringLiteral("document/busy"); + return result; + } + + if (!directEditAllowed(request.kind)) + { + result.typedError = QStringLiteral("document/modify-permission-denied"); + return result; + } + + // Parse the scalar payload at this boundary, then hand it to the one Core + // writer for the family. Nothing below here reads the raw map again. + bool malformed = false; + pdf::DirectEditOutcome outcome; + switch (request.kind) + { + case DocumentDirectEditRequest::Kind::SetFormFieldValue: + { + pdf::FormFieldValueEdit edit; + const bool hasField = requiredString(request.scalars, FieldParameter, &edit.field, &malformed); + edit.text = scalarString(request.scalars, ValueParameter, &malformed); + edit.checked = scalarBool(request.scalars, CheckedParameter, &malformed); + if (!hasField || malformed || (!edit.text && !edit.checked)) + { + result.typedError = QStringLiteral("direct-edit/invalid-request"); + return result; + } + outcome = pdf::PDFFormValueWriter::setFieldValue(document, edit); + break; + } + + case DocumentDirectEditRequest::Kind::AddAttachment: + { + pdf::AttachmentEdit edit; + const bool hasName = requiredString(request.scalars, NameParameter, &edit.name, &malformed); + edit.description = scalarString(request.scalars, DescriptionParameter, &malformed).value_or(QString()); + edit.contents = request.contents; + if (!hasName || malformed || edit.contents.isEmpty()) + { + result.typedError = QStringLiteral("direct-edit/invalid-request"); + return result; + } + outcome = pdf::PDFAttachmentWriter::add(document, edit); + break; + } + + case DocumentDirectEditRequest::Kind::RemoveAttachment: + { + QString name; + const bool hasName = requiredString(request.scalars, NameParameter, &name, &malformed); + if (!hasName || malformed) + { + result.typedError = QStringLiteral("direct-edit/invalid-request"); + return result; + } + outcome = pdf::PDFAttachmentWriter::remove(document, name); + break; + } + + case DocumentDirectEditRequest::Kind::EditMetadata: + { + pdf::DocumentMetadataEdit edit; + edit.title = scalarString(request.scalars, TitleParameter, &malformed); + edit.author = scalarString(request.scalars, AuthorParameter, &malformed); + edit.subject = scalarString(request.scalars, SubjectParameter, &malformed); + edit.keywords = scalarString(request.scalars, KeywordsParameter, &malformed); + edit.creator = scalarString(request.scalars, CreatorParameter, &malformed); + if (malformed || (!edit.title && !edit.author && !edit.subject && !edit.keywords && !edit.creator)) + { + result.typedError = QStringLiteral("direct-edit/invalid-request"); + return result; + } + outcome = pdf::PDFMetadataWriter::update(document, edit); + break; + } + } + + if (!outcome.ok()) + { + result.typedError = QString::fromLatin1(pdf::getDirectEditRefusalCode(outcome.refusal)); + return result; + } + + // Accept the edit: the new document replaces the current one, which is what + // advances the revision exactly once. markModified() is deliberately not + // used here -- it would advance the revision a second time for one edit. + if (m_outputState == DocumentOutputState::Pending) + { + supersedePending(CommandTerminalState::Cancelled, QStringLiteral("document/superseded")); + } + documentContext->setDocument(outcome.document); + if (!m_publishedKey.isEmpty()) + { + m_submitter->publishCurrentRevision(m_publishedKey, m_revisionSource.currentRevision()); + } + setOutputState(DocumentOutputState::None); + setFacets(m_facets | DocumentFacet::Dirty); + updateAvailability(); + + result.applied = true; + result.revision = m_revisionSource.currentRevision(); + return result; +} + +bool DocumentFacade::directEditAllowed(DocumentDirectEditRequest::Kind kind) const +{ + const auto* documentContext = context(); + const auto* document = m_state == DocumentState::Ready && documentContext ? documentContext->getDocument() : nullptr; + const auto* security = document ? document->getStorage().getSecurityHandler() : nullptr; + if (!security) + { + return false; + } + + using Permission = pdf::PDFSecurityHandler::Permission; + switch (kind) + { + case DocumentDirectEditRequest::Kind::SetFormFieldValue: + return security->isAllowed(Permission::ModifyFormFields); + + case DocumentDirectEditRequest::Kind::AddAttachment: + case DocumentDirectEditRequest::Kind::RemoveAttachment: + case DocumentDirectEditRequest::Kind::EditMetadata: + return security->isAllowed(Permission::Modify); + } + + return false; } CommandInvocationId DocumentFacade::open(const QString& path) diff --git a/LoopLibInteraction/sources/documentfacade.h b/LoopLibInteraction/sources/documentfacade.h index 2a64faa8..e8e5343d 100644 --- a/LoopLibInteraction/sources/documentfacade.h +++ b/LoopLibInteraction/sources/documentfacade.h @@ -37,6 +37,7 @@ #include #include #include +#include #include #include @@ -174,6 +175,47 @@ struct DocumentPlanIntent pdf::PDFRevisionIdentity revision; }; +/// One direct authoring edit's request (ADR-014). +/// +/// Every authoring handler submits through this one shape: the edit kind, the +/// command's own scalar parameters, the attachment bytes when the kind needs +/// them, and the `PDFRevisionIdentity` the edit was begun against. The gateway +/// checks the fence before it reads anything else, so an edit begun against a +/// superseded revision can never mutate the current one. +struct DocumentDirectEditRequest +{ + enum class Kind + { + SetFormFieldValue, + AddAttachment, + RemoveAttachment, + EditMetadata + }; + + Kind kind = Kind::SetFormFieldValue; + pdf::PDFRevisionIdentity revision; + + /// The edit's scalar payload: exactly the parameters the command declares + /// (`field`, `value`, `checked`, `name`, `description`, `title`, ...). + QVariantMap scalars; + + /// Attachment bytes for Kind::AddAttachment. The command contract carries a + /// path; the host resolves and reads it, so the mutation gateway itself + /// never touches the filesystem. + QByteArray contents; +}; + +/// The gateway's typed outcome. `applied` is the only success: a refused edit +/// carries a stable `typedError` code and changed nothing. +struct DocumentDirectEditResult +{ + bool applied = false; + QString typedError; + pdf::PDFRevisionIdentity revision; + + bool ok() const { return applied; } +}; + struct DocumentInspection { DocumentInspectionState state = DocumentInspectionState::NotChecked; @@ -312,6 +354,17 @@ class DocumentFacade final : public QObject bool requestPlan(const QString& operationId, QString& error); void clearPlanIntent(); + /// Applies one direct authoring edit (ADR-014). + /// + /// The order is the contract: the fence is checked against the complete + /// `PDFRevisionIdentity` before anything else, then the document's + /// permission for that edit family, then the payload is parsed at this + /// boundary, and only then does a Core writer run. An accepted edit + /// replaces the document in the context — one new revision, one history + /// fence — and marks the document modified. A refused edit changes nothing + /// and returns its stable code in `typedError`. + DocumentDirectEditResult applyDirectEdit(const DocumentDirectEditRequest& request); + /// Begins a signature check for the current revision. One check at a time. /// The host runs Core's verification on a scheduler worker and admits the /// result through completeSignatureCheck/failSignatureCheck. @@ -431,6 +484,11 @@ class DocumentFacade final : public QObject bool admitSignatureCheckTransition(const DocumentSignatureCheckToken& token, QString& error) const; void invalidateSignatureCheck(); + /// Whether the open document's security handler grants the permission one + /// direct-edit family needs. A document that grants none of them is not an + /// error state; it is a refusal with a typed reason. + bool directEditAllowed(DocumentDirectEditRequest::Kind kind) const; + /// The context is reached through the P4-S1 seam rather than held directly, /// so a destroyed context degrades to an invalid revision instead of a /// dangling read. diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index 757c82e4..68154b11 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -862,6 +862,24 @@ set_target_properties(UnitTestsSafeFileWriter PROPERTIES ) add_test(UnitTestsSafeFileWriter "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsSafeFileWriter") +# ADR-014 / epic #231 issue #242: the Core direct-edit writers (form value, +# attachment add/remove/extract, XMP metadata) and the refusals they owe. No +# Widgets, no QML: the writers are Core's, and the interaction layer's fence is +# pinned by UnitTestsEditorHost. +add_executable(UnitTestsDirectEdit + tst_directedittest.cpp +) + +target_link_libraries(UnitTestsDirectEdit PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) + +set_target_properties(UnitTestsDirectEdit PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} +) +add_test(UnitTestsDirectEdit "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsDirectEdit") + add_executable(UnitTestsContentProcessorLimits tst_contentprocessorlimitstest.cpp ) diff --git a/UnitTests/tst_directedittest.cpp b/UnitTests/tst_directedittest.cpp new file mode 100644 index 00000000..806db1fd --- /dev/null +++ b/UnitTests/tst_directedittest.cpp @@ -0,0 +1,649 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Core-level evidence for ADR-014's direct authoring edits (epic #231, issue +// #242): the form value, attachment and metadata writers, and the attachment +// extraction path through the atomic writer. The interaction layer's fence and +// the catalog route are pinned by UnitTestsEditorHost; this file pins what the +// writers themselves do to the document, and what they refuse to do. + +#include + +#include +#include +#include + +#include "pdfcatalog.h" +#include "pdfdirectedit.h" +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentreader.h" +#include "pdfdocumentwriter.h" +#include "pdfencoding.h" +#include "pdffile.h" +#include "pdfform.h" + +namespace +{ + +pdf::PDFObject arrayOf(std::initializer_list values) +{ + std::vector objects; + objects.reserve(values.size()); + for (const double value : values) + { + objects.push_back(pdf::PDFObject::createReal(value)); + } + return pdf::PDFObject::createArray(std::make_shared(std::move(objects))); +} + +pdf::PDFObjectReference addFormXObject(pdf::PDFDocumentBuilder& builder, const QRectF& rect) +{ + const QByteArray content = QByteArrayLiteral(" "); + pdf::PDFDictionary dictionary; + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("XObject")); + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Form")); + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("BBox"), + arrayOf({ rect.left(), rect.top(), rect.right(), rect.bottom() })); + // Streams must carry their own /Length: the writer does not add one, and a + // reader that honours the entry would otherwise fail on the file. + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Length"), pdf::PDFObject::createInteger(content.size())); + return builder.addObject(pdf::PDFObject::createStream( + std::make_shared(std::move(dictionary), QByteArray(content)))); +} + +/// One merged field-and-widget dictionary on the page, the shape a real AcroForm +/// uses for its simplest fields. +pdf::PDFObjectReference addMergedField(pdf::PDFDocumentBuilder& builder, + pdf::PDFObjectReference page, + const QByteArray& fieldType, + const QString& name, + const QRectF& rect, + const std::function& decorate) +{ + pdf::PDFDictionary field; + field.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("Annot")); + field.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Widget")); + field.addEntry(pdf::PDFInplaceOrMemoryString("FT"), pdf::PDFObject::createName(fieldType)); + field.addEntry(pdf::PDFInplaceOrMemoryString("T"), pdf::PDFObjectFactory::createTextString(name)); + field.addEntry(pdf::PDFInplaceOrMemoryString("Rect"), + arrayOf({ rect.left(), rect.top(), rect.right(), rect.bottom() })); + field.addEntry(pdf::PDFInplaceOrMemoryString("P"), pdf::PDFObject::createReference(page)); + if (decorate) + { + decorate(field); + } + + const pdf::PDFObjectReference reference = + builder.addObject(pdf::PDFObject::createDictionary(std::make_shared(std::move(field)))); + + pdf::PDFObjectFactory pageFactory; + pageFactory.beginDictionary(); + pageFactory.beginDictionaryItem("Annots"); + pageFactory.beginArray(); + pageFactory << reference; + pageFactory.endArray(); + pageFactory.endDictionaryItem(); + pageFactory.endDictionary(); + builder.appendTo(page, pageFactory.takeObject()); + return reference; +} + +/// A page carrying a text field, a checkbox and a combo box, plus optionally a +/// signature field. The signature field is in the fixture precisely so the +/// "signature fields are excluded" case has something to be excluded from. +pdf::PDFDocument buildFormDocument(bool withSignatureField) +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 200, 200)); + + std::vector fields; + + fields.push_back(addMergedField(builder, page, "Tx", QStringLiteral("LoopText"), QRectF(10, 10, 90, 30), + [&builder](pdf::PDFDictionary& field) + { + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), + pdf::PDFObjectFactory::createTextString(QStringLiteral("original"))); + field.addEntry(pdf::PDFInplaceOrMemoryString("DA"), + pdf::PDFObject::createString(QByteArrayLiteral("/Helv 0 Tf 0 g"))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared( + [&builder]() + { + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createReference(addFormXObject(builder, QRectF(10, 10, 90, 30)))); + return appearance; + }()))); + })); + + fields.push_back(addMergedField(builder, page, "Btn", QStringLiteral("LoopCheck"), QRectF(10, 50, 30, 30), + [&builder](pdf::PDFDictionary& field) + { + pdf::PDFDictionary normal; + normal.addEntry(pdf::PDFInplaceOrMemoryString("Yes"), + pdf::PDFObject::createReference(addFormXObject(builder, QRectF(10, 50, 30, 30)))); + normal.addEntry(pdf::PDFInplaceOrMemoryString("Off"), + pdf::PDFObject::createReference(addFormXObject(builder, QRectF(10, 50, 30, 30)))); + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(normal)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(appearance)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AS"), pdf::PDFObject::createName("Off")); + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), pdf::PDFObject::createName("Off")); + })); + + fields.push_back(addMergedField(builder, page, "Ch", QStringLiteral("LoopChoice"), QRectF(10, 90, 90, 30), + [&builder](pdf::PDFDictionary& field) + { + // Combo flag: the choice admits only its own options. + field.addEntry(pdf::PDFInplaceOrMemoryString("Ff"), pdf::PDFObject::createInteger(1 << 17)); + field.addEntry(pdf::PDFInplaceOrMemoryString("Opt"), + pdf::PDFObject::createArray(std::make_shared(std::vector{ + pdf::PDFObjectFactory::createTextString(QStringLiteral("Alpha")), + pdf::PDFObjectFactory::createTextString(QStringLiteral("Beta")) }))); + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), + pdf::PDFObjectFactory::createTextString(QStringLiteral("Alpha"))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared( + [&builder]() + { + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createReference(addFormXObject(builder, QRectF(10, 90, 90, 30)))); + return appearance; + }()))); + })); + + if (withSignatureField) + { + const pdf::PDFObjectReference signature = builder.createSignatureDictionary( + QByteArrayLiteral("Adobe.PPKLite"), + QByteArrayLiteral("adbe.pkcs7.detached"), + QByteArrayLiteral("signed-placeholder"), + QDateTime::currentDateTimeUtc(), + 0); + const pdf::PDFObjectReference field = builder.createFormFieldSignature(QStringLiteral("LoopSignature"), {}, signature); + builder.createInvisibleFormFieldWidget(field, page); + fields.push_back(field); + } + + builder.createAcroForm(fields); + return builder.build(); +} + +/// An AcroForm whose only content is an XFA packet: no AcroForm fields at all. +pdf::PDFDocument buildXfaOnlyDocument() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + + pdf::PDFDictionary xfaDictionary; + const QByteArray xfaPacket = QByteArrayLiteral(""); + xfaDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Length"), pdf::PDFObject::createInteger(xfaPacket.size())); + const pdf::PDFObjectReference xfa = builder.addObject( + pdf::PDFObject::createStream(std::make_shared(std::move(xfaDictionary), QByteArray(xfaPacket)))); + + pdf::PDFDictionary form; + form.addEntry(pdf::PDFInplaceOrMemoryString("Fields"), pdf::PDFObject::createArray(std::make_shared())); + form.addEntry(pdf::PDFInplaceOrMemoryString("XFA"), pdf::PDFObject::createReference(xfa)); + + pdf::PDFObjectFactory catalogFactory; + catalogFactory.beginDictionary(); + catalogFactory.beginDictionaryItem("AcroForm"); + catalogFactory << pdf::PDFObject::createDictionary(std::make_shared(std::move(form))); + catalogFactory.endDictionaryItem(); + catalogFactory.endDictionary(); + builder.mergeTo(builder.getCatalogReference(), catalogFactory.takeObject()); + return builder.build(); +} + +pdf::PDFDocument readBack(pdf::PDFDocumentPointer document, const QString& path) +{ + pdf::PDFDocumentWriter writer(nullptr); + if (!writer.write(path, document.data(), true)) + { + return pdf::PDFDocument(); + } + pdf::PDFDocumentReader reader(nullptr, nullptr, false, false); + return reader.readFromFile(path); +} + +QString formValue(const pdf::PDFDocument& document, const QString& name, bool* isName) +{ + pdf::PDFFormManager manager(nullptr); + // The manager parses the document read-only; the const view is what the + // writer under test handed back. + manager.setDocument(pdf::PDFModifiedDocument(const_cast(&document), nullptr, pdf::PDFModifiedDocument::Reset)); + QString value; + manager.apply([&value, &name, isName](const pdf::PDFFormField* field) + { + if (value.isEmpty() && field->getName(pdf::PDFFormField::NameType::FullyQualified) == name) + { + const pdf::PDFObject& object = field->getValue(); + if (object.isName()) + { + *isName = true; + value = QString::fromLatin1(object.getString()); + } + else if (object.isString()) + { + value = pdf::PDFEncoding::convertTextString(object.getString()); + } + } }); + return value; +} + +} // namespace + +class DirectEditTest : public QObject +{ + Q_OBJECT + +private slots: + void formFieldsRoundTripThroughTheWriterAndTheFile(); + void signatureFieldIsNeverFilled(); + void xfaOnlyFormIsRefused(); + void unknownFieldAndForeignChoiceValueAreRefused(); + void attachmentRoundTripsThroughTheNameTree(); + void attachmentRemovalUnlistsOnlyTheNamedFile(); + void extractionUsesTheAtomicWriterAndRefusesExecutableAutoOpen(); + void metadataRoundTripsInfoAndXmp(); + void metadataMergesIntoAnExistingPacketAndKeepsForeignProperties(); + void unreadableXmpPacketIsRefusedInsteadOfReplaced(); + void revisionIdentityRoundTripsThroughItsStringForm(); +}; + +void DirectEditTest::formFieldsRoundTripThroughTheWriterAndTheFile() +{ + const pdf::PDFDocument source = buildFormDocument(true); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + // Text. + { + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopText"); + edit.text = QStringLiteral("Filled by the authoring writer"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + QVERIFY(outcome.document); + QVERIFY(outcome.document.data() != &source); + + const pdf::PDFDocument reloaded = readBack(outcome.document, directory.filePath(QStringLiteral("text.pdf"))); + bool isName = false; + QCOMPARE(formValue(reloaded, QStringLiteral("LoopText"), &isName), QStringLiteral("Filled by the authoring writer")); + QVERIFY(!isName); + } + + // Checkbox: the on-state the widget's appearance declares. + { + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopCheck"); + edit.checked = true; + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + + const pdf::PDFDocument reloaded = readBack(outcome.document, directory.filePath(QStringLiteral("check.pdf"))); + bool isName = false; + QCOMPARE(formValue(reloaded, QStringLiteral("LoopCheck"), &isName), QStringLiteral("Yes")); + QVERIFY(isName); + } + + // Choice, within the options the field declares. + { + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopChoice"); + edit.text = QStringLiteral("Beta"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + + const pdf::PDFDocument reloaded = readBack(outcome.document, directory.filePath(QStringLiteral("choice.pdf"))); + bool isName = false; + QCOMPARE(formValue(reloaded, QStringLiteral("LoopChoice"), &isName), QStringLiteral("Beta")); + } +} + +void DirectEditTest::signatureFieldIsNeverFilled() +{ + const pdf::PDFDocument source = buildFormDocument(true); + + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopSignature"); + edit.text = QStringLiteral("not a signature"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::UnsupportedFieldType); + QCOMPARE(QString::fromLatin1(pdf::getDirectEditRefusalCode(outcome.refusal)), QStringLiteral("form/unsupported-field-type")); + QVERIFY(!outcome.document); +} + +void DirectEditTest::xfaOnlyFormIsRefused() +{ + const pdf::PDFDocument source = buildXfaOnlyDocument(); + + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("AnyField"); + edit.text = QStringLiteral("value"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::XfaUnsupported); + QCOMPARE(QString::fromLatin1(pdf::getDirectEditRefusalCode(outcome.refusal)), QStringLiteral("form/xfa-unsupported")); + QVERIFY(!outcome.document); +} + +void DirectEditTest::unknownFieldAndForeignChoiceValueAreRefused() +{ + const pdf::PDFDocument source = buildFormDocument(false); + + { + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("NoSuchField"); + edit.text = QStringLiteral("value"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::FieldNotFound); + } + + { + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopChoice"); + edit.text = QStringLiteral("Gamma"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::ValueNotAllowed); + } + + { + // A button edit with neither shape, and a text edit carrying a checkbox + // shape, are both ill-formed rather than guessed. + pdf::FormFieldValueEdit edit; + edit.field = QStringLiteral("LoopCheck"); + const pdf::DirectEditOutcome outcome = pdf::PDFFormValueWriter::setFieldValue(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::InvalidRequest); + } +} + +void DirectEditTest::attachmentRoundTripsThroughTheNameTree() +{ + const pdf::PDFDocument source = buildFormDocument(false); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QVERIFY(source.getCatalog()->getEmbeddedFiles().empty()); + + pdf::AttachmentEdit edit; + edit.name = QStringLiteral("notes.txt"); + edit.description = QStringLiteral("Reviewer notes"); + edit.mimeType = QByteArrayLiteral("text/plain"); + edit.contents = QByteArrayLiteral("attached payload"); + + const pdf::DirectEditOutcome outcome = pdf::PDFAttachmentWriter::add(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + + const pdf::PDFDocument reloaded = readBack(outcome.document, directory.filePath(QStringLiteral("attachment.pdf"))); + const auto& files = reloaded.getCatalog()->getEmbeddedFiles(); + QCOMPARE(files.size(), size_t(1)); + const auto item = files.find(QByteArrayLiteral("notes.txt")); + QVERIFY(item != files.cend()); + QCOMPARE(item->second.getPlatformFileName(), QStringLiteral("notes.txt")); + QCOMPARE(item->second.getDescription(), QStringLiteral("Reviewer notes")); + const pdf::PDFEmbeddedFile* file = item->second.getPlatformFile(); + QVERIFY(file); + QVERIFY(file->isValid()); + QCOMPARE(*file->getStream()->getContent(), QByteArrayLiteral("attached payload")); + + // A second attachment under the same name is refused, and the name is + // carried in the file specification's unicode entry. + const pdf::DirectEditOutcome duplicate = pdf::PDFAttachmentWriter::add(outcome.document.data(), edit); + QVERIFY(!duplicate.ok()); + QCOMPARE(duplicate.refusal, pdf::DirectEditRefusal::AttachmentNameTaken); + + pdf::AttachmentEdit escaping; + escaping.name = QStringLiteral("../escape.txt"); + escaping.contents = QByteArrayLiteral("x"); + const pdf::DirectEditOutcome refusedName = pdf::PDFAttachmentWriter::add(&source, escaping); + QVERIFY(!refusedName.ok()); + QCOMPARE(refusedName.refusal, pdf::DirectEditRefusal::AttachmentNameInvalid); +} + +void DirectEditTest::attachmentRemovalUnlistsOnlyTheNamedFile() +{ + const pdf::PDFDocument source = buildFormDocument(false); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + pdf::AttachmentEdit first; + first.name = QStringLiteral("keep.txt"); + first.contents = QByteArrayLiteral("keep"); + pdf::AttachmentEdit second; + second.name = QStringLiteral("drop.txt"); + second.contents = QByteArrayLiteral("drop"); + + const pdf::DirectEditOutcome withFirst = pdf::PDFAttachmentWriter::add(&source, first); + QVERIFY(withFirst.ok()); + const pdf::DirectEditOutcome withBoth = pdf::PDFAttachmentWriter::add(withFirst.document.data(), second); + QVERIFY(withBoth.ok()); + QCOMPARE(withBoth.document->getCatalog()->getEmbeddedFiles().size(), size_t(2)); + + const pdf::DirectEditOutcome removed = pdf::PDFAttachmentWriter::remove(withBoth.document.data(), QStringLiteral("drop.txt")); + QVERIFY2(removed.ok(), pdf::getDirectEditRefusalCode(removed.refusal)); + + const pdf::PDFDocument reloaded = readBack(removed.document, directory.filePath(QStringLiteral("removed.pdf"))); + const auto& files = reloaded.getCatalog()->getEmbeddedFiles(); + QCOMPARE(files.size(), size_t(1)); + QVERIFY(files.find(QByteArrayLiteral("keep.txt")) != files.cend()); + + // Removing it again is a typed refusal, not a silent success. + const pdf::DirectEditOutcome missing = pdf::PDFAttachmentWriter::remove(removed.document.data(), QStringLiteral("drop.txt")); + QVERIFY(!missing.ok()); + QCOMPARE(missing.refusal, pdf::DirectEditRefusal::AttachmentNotFound); + + // And removing the last one leaves no EmbeddedFiles entry behind. + const pdf::DirectEditOutcome emptied = pdf::PDFAttachmentWriter::remove(removed.document.data(), QStringLiteral("keep.txt")); + QVERIFY(emptied.ok()); + QVERIFY(emptied.document->getCatalog()->getEmbeddedFiles().empty()); +} + +void DirectEditTest::extractionUsesTheAtomicWriterAndRefusesExecutableAutoOpen() +{ + const pdf::PDFDocument source = buildFormDocument(false); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + pdf::AttachmentEdit text; + text.name = QStringLiteral("readme.txt"); + text.contents = QByteArrayLiteral("extracted text payload"); + const pdf::DirectEditOutcome withText = pdf::PDFAttachmentWriter::add(&source, text); + QVERIFY(withText.ok()); + + const pdf::PDFAttachmentWriter::ExtractionOutcome extracted = + pdf::PDFAttachmentWriter::extractToDirectory(withText.document.data(), QStringLiteral("readme.txt"), directory.path()); + QVERIFY2(extracted.written, qPrintable(extracted.typedError)); + QVERIFY(!extracted.autoOpenRefused); + QCOMPARE(extracted.path, directory.filePath(QStringLiteral("readme.txt"))); + + QFile written(extracted.path); + QVERIFY(written.open(QIODevice::ReadOnly)); + QCOMPARE(written.readAll(), QByteArrayLiteral("extracted text payload")); + written.close(); + + // The atomic writer refuses to replace an existing file, so a second + // extraction of the same name is a typed refusal and the file is untouched. + const pdf::PDFAttachmentWriter::ExtractionOutcome again = + pdf::PDFAttachmentWriter::extractToDirectory(withText.document.data(), QStringLiteral("readme.txt"), directory.path()); + QVERIFY(!again.written); + QCOMPARE(again.refusal, pdf::DirectEditRefusal::WriteRefused); + QVERIFY(written.open(QIODevice::ReadOnly)); + QCOMPARE(written.readAll(), QByteArrayLiteral("extracted text payload")); + + // An executable-type attachment extracts, but must not be auto-opened. + pdf::AttachmentEdit executable; + executable.name = QStringLiteral("setup.exe"); + executable.contents = QByteArrayLiteral("MZ not really an executable"); + const pdf::DirectEditOutcome withExecutable = pdf::PDFAttachmentWriter::add(&source, executable); + QVERIFY(withExecutable.ok()); + QVERIFY(pdf::PDFAttachmentWriter::isExecutableFileName(QStringLiteral("setup.exe"))); + QVERIFY(pdf::PDFAttachmentWriter::isExecutableFileName(QStringLiteral("SCRIPT.PS1"))); + QVERIFY(!pdf::PDFAttachmentWriter::isExecutableFileName(QStringLiteral("readme.txt"))); + + const pdf::PDFAttachmentWriter::ExtractionOutcome executableOutcome = + pdf::PDFAttachmentWriter::extractToDirectory(withExecutable.document.data(), QStringLiteral("setup.exe"), directory.path()); + QVERIFY2(executableOutcome.written, qPrintable(executableOutcome.typedError)); + QVERIFY(executableOutcome.autoOpenRefused); + + // An unknown name is a refusal with the stable code. + const pdf::PDFAttachmentWriter::ExtractionOutcome missing = + pdf::PDFAttachmentWriter::extractToDirectory(withText.document.data(), QStringLiteral("absent.txt"), directory.path()); + QVERIFY(!missing.written); + QCOMPARE(missing.refusal, pdf::DirectEditRefusal::AttachmentNotFound); + QCOMPARE(missing.typedError, QStringLiteral("attachment/not-found")); +} + +void DirectEditTest::metadataRoundTripsInfoAndXmp() +{ + const pdf::PDFDocument source = buildFormDocument(false); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QVERIFY(pdf::PDFMetadataWriter::readXmpPacket(&source).isEmpty()); + + pdf::DocumentMetadataEdit edit; + edit.title = QStringLiteral("Loop title"); + edit.author = QStringLiteral("Loop author"); + edit.subject = QStringLiteral("Loop subject"); + edit.keywords = QStringLiteral("loop, metadata"); + edit.creator = QStringLiteral("LoopEditor"); + + const pdf::DirectEditOutcome outcome = pdf::PDFMetadataWriter::update(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + + const pdf::PDFDocument reloaded = readBack(outcome.document, directory.filePath(QStringLiteral("metadata.pdf"))); + QCOMPARE(reloaded.getInfo()->title, QStringLiteral("Loop title")); + QCOMPARE(reloaded.getInfo()->author, QStringLiteral("Loop author")); + QCOMPARE(reloaded.getInfo()->subject, QStringLiteral("Loop subject")); + QCOMPARE(reloaded.getInfo()->keywords, QStringLiteral("loop, metadata")); + QCOMPARE(reloaded.getInfo()->creator, QStringLiteral("LoopEditor")); + + const QByteArray packet = pdf::PDFMetadataWriter::readXmpPacket(&reloaded); + QVERIFY(!packet.isEmpty()); + QVERIFY(packet.contains("dc:title")); + QVERIFY(packet.contains("Loop title")); + QVERIFY(packet.contains("dc:creator")); + QVERIFY(packet.contains("Loop author")); + QVERIFY(packet.contains("dc:description")); + QVERIFY(packet.contains("Loop subject")); + QVERIFY(packet.contains("pdf:Keywords")); + QVERIFY(packet.contains("loop, metadata")); + QVERIFY(packet.contains("xmp:CreatorTool")); + QVERIFY(packet.contains("LoopEditor")); + + // A second edit that only names one field keeps the others. + pdf::DocumentMetadataEdit followUp; + followUp.title = QStringLiteral("Second title"); + const pdf::DirectEditOutcome second = pdf::PDFMetadataWriter::update(outcome.document.data(), followUp); + QVERIFY2(second.ok(), pdf::getDirectEditRefusalCode(second.refusal)); + QCOMPARE(second.document->getInfo()->title, QStringLiteral("Second title")); + QCOMPARE(second.document->getInfo()->author, QStringLiteral("Loop author")); + const QByteArray mergedPacket = pdf::PDFMetadataWriter::readXmpPacket(second.document.data()); + QVERIFY(mergedPacket.contains("Second title")); + QVERIFY(mergedPacket.contains("Loop author")); +} + +void DirectEditTest::metadataMergesIntoAnExistingPacketAndKeepsForeignProperties() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + // A packet that carries a conformance claim and another tool's property. + builder.setCatalogMetadata(QByteArrayLiteral( + "\n" + "\n" + " \n" + " \n" + " Old title\n" + " \n" + " \n" + "\n" + "")); + const pdf::PDFDocument source = builder.build(); + QVERIFY(!pdf::PDFMetadataWriter::readXmpPacket(&source).isEmpty()); + + pdf::DocumentMetadataEdit edit; + edit.title = QStringLiteral("New title"); + const pdf::DirectEditOutcome outcome = pdf::PDFMetadataWriter::update(&source, edit); + QVERIFY2(outcome.ok(), pdf::getDirectEditRefusalCode(outcome.refusal)); + + const QByteArray packet = pdf::PDFMetadataWriter::readXmpPacket(outcome.document.data()); + // The property this edit owns is replaced, exactly once, and the packet is + // still a packet rather than an XML document with a declaration bolted on. + QVERIFY(packet.contains("")); + QVERIFY(packet.contains("New title")); + QVERIFY(!packet.contains("Old title")); + QVERIFY(!packet.startsWith(""), 1); + // Everything else the packet carried survives, including the PDF/A claim. + QVERIFY(packet.contains("pdfaid:part")); + QVERIFY(packet.contains("pdfaid:conformance")); + QVERIFY(packet.contains("xpacket")); +} + +void DirectEditTest::unreadableXmpPacketIsRefusedInsteadOfReplaced() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + builder.setCatalogMetadata(QByteArrayLiteral(" this is not well formed")); + const pdf::PDFDocument source = builder.build(); + + pdf::DocumentMetadataEdit edit; + edit.title = QStringLiteral("Never written"); + const pdf::DirectEditOutcome outcome = pdf::PDFMetadataWriter::update(&source, edit); + QVERIFY(!outcome.ok()); + QCOMPARE(outcome.refusal, pdf::DirectEditRefusal::XmpUnreadable); + QCOMPARE(QString::fromLatin1(pdf::getDirectEditRefusalCode(outcome.refusal)), QStringLiteral("metadata/xmp-unreadable")); + QVERIFY(!outcome.document); + // The packet is untouched: it is still the unreadable one. + QCOMPARE(pdf::PDFMetadataWriter::readXmpPacket(&source), + QByteArrayLiteral(" this is not well formed")); +} + +void DirectEditTest::revisionIdentityRoundTripsThroughItsStringForm() +{ + pdf::PDFRevisionIdentity identity; + identity.document.sourceDataHash = QByteArray::fromHex("00ff10"); + identity.document.documentId = QStringLiteral("1a2b"); + identity.documentRevision = 7; + identity.cacheGeneration = 9; + identity.effectiveProfileIdentity = QStringLiteral("loop-default:1"); + + const pdf::PDFRevisionIdentity parsed = pdf::PDFRevisionIdentity::fromString(identity.toString()); + QVERIFY(parsed.isValid()); + QVERIFY(parsed == identity); + + // A malformed value yields an invalid identity rather than a guessed one. + QVERIFY(!pdf::PDFRevisionIdentity::fromString(QString()).isValid()); + QVERIFY(!pdf::PDFRevisionIdentity::fromString(QStringLiteral("not-a-revision")).isValid()); + QVERIFY(!pdf::PDFRevisionIdentity::fromString(QStringLiteral("00ff:1a2b:notanumber:9:")).isValid()); +} + +QTEST_MAIN(DirectEditTest) + +#include "tst_directedittest.moc" diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index f85f01d7..3832f4e6 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -415,7 +415,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(), 109); + QCOMPARE(catalog.descriptors().size(), 113); const pdfinteraction::CommandDescriptor* open = catalog.descriptor(pdfinteraction::DocumentFacade::OpenCommandId); @@ -453,1483 +453,3 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() // family (#234) and the form/attachment/metadata commands (#242) joined the // implemented set in epic #231. QCOMPARE(implemented, 94); -} - -void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() -{ - pdfinteraction::CommandCatalog catalog; - QVERIFY2(catalog.isLoaded(), qPrintable(catalog.loadError())); - QSignalSpy changed(&catalog, &pdfinteraction::CommandCatalog::availabilityChanged); - - catalog.setEnabledBatch({ - { pdfinteraction::DocumentFacade::OpenCommandId, true }, - { pdfinteraction::DocumentFacade::CloseCommandId, true }, - { QStringLiteral("not-a-command"), true }, - }); - - QCOMPARE(changed.count(), 1); - QVERIFY(catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); - QVERIFY(catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); - QVERIFY(!catalog.isEnabled(QStringLiteral("not-a-command"))); - - catalog.setEnabledBatch({ - { pdfinteraction::DocumentFacade::OpenCommandId, true }, - { pdfinteraction::DocumentFacade::CloseCommandId, true }, - }); - QCOMPARE(changed.count(), 1); -} - -void DocumentFacadeTest::catalogRejectsAnUnknownCommand() -{ - Harness harness; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - QVERIFY(finished.isValid()); - - const pdfinteraction::CommandInvocationId invocation = - harness.catalog.invoke(QStringLiteral("actionDefinitelyNotAThing")); - - // Not a silent no-op: the contract is the whole ID space, so an unknown ID - // is reported as a routing bug. - QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); - QCOMPARE(finished.count(), 1); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Unavailable); - QCOMPARE(result.typedError, QStringLiteral("command/unknown")); - QVERIFY(!result.isSuccess()); - QVERIFY(harness.catalog.descriptor(QStringLiteral("actionDefinitelyNotAThing")) == nullptr); -} - -void DocumentFacadeTest::declaredCommandIsNotImplementedAndRunsNothing() -{ - Harness harness; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - - const pdfinteraction::CommandInvocationId invocation = - harness.catalog.invoke(QStringLiteral("actionSanitize")); - - QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); - QCOMPARE(finished.count(), 1); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::NotImplemented); - QCOMPARE(result.typedError, QStringLiteral("command/not-implemented")); - - // Declared means "has a descriptor", never "quietly did something". - QCOMPARE(harness.submitter.submittedSpecs.size(), 0); - QCOMPARE(harness.loader.loadCount, 0); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); -} - -void DocumentFacadeTest::catalogRefusesAHandlerForADeclaredCommand() -{ - pdfinteraction::CommandCatalog catalog; - - pdfinteraction::CommandCatalog::Handler handler; - handler.invoke = [](pdfinteraction::CommandInvocationId, const QVariantMap&) {}; - - QVERIFY(!catalog.setHandler(QStringLiteral("actionSanitize"), handler)); - QVERIFY(!catalog.setHandler(QStringLiteral("actionNotInTheContract"), handler)); - QVERIFY(catalog.setHandler(pdfinteraction::DocumentFacade::OpenCommandId, handler)); -} - -void DocumentFacadeTest::missingRequiredParameterIsRejected() -{ - Harness harness; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - - const pdfinteraction::CommandInvocationId invocation = - harness.catalog.invoke(pdfinteraction::DocumentFacade::OpenCommandId); - - QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); - QCOMPARE(finished.count(), 1); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); - QCOMPARE(result.typedError, QStringLiteral("command/missing-parameter")); - QCOMPARE(harness.loader.loadCount, 0); -} - -void DocumentFacadeTest::unknownParameterIsRejected() -{ - Harness harness; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - - QVariantMap parameters; - parameters.insert(QStringLiteral("path"), QStringLiteral("/tmp/a.pdf")); - parameters.insert(QStringLiteral("pathh"), QStringLiteral("/tmp/b.pdf")); - - // A misspelled parameter must not run the command with a default the caller - // never asked for. - QCOMPARE(harness.catalog.invoke(pdfinteraction::DocumentFacade::OpenCommandId, parameters), - pdfinteraction::InvalidCommandInvocation); - QCOMPARE(finished.count(), 1); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.typedError, QStringLiteral("command/unknown-parameter")); - QCOMPARE(harness.loader.loadCount, 0); -} - -void DocumentFacadeTest::disabledCommandIsUnavailable() -{ - Harness harness; - - // Nothing is open, so closing and saving are not offered. - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); - QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); - QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - QCOMPARE(harness.facade->close(), pdfinteraction::InvalidCommandInvocation); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Unavailable); - QCOMPARE(result.typedError, QStringLiteral("command/unavailable")); -} - -void DocumentFacadeTest::openAdmitsDocumentAndPublishesRevision() -{ - Harness harness; - QSignalSpy stateChanged(harness.facade.get(), &pdfinteraction::DocumentFacade::stateChanged); - QSignalSpy replaced(harness.facade.get(), &pdfinteraction::DocumentFacade::documentReplaced); - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - - const pdfinteraction::CommandInvocationId invocation = - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); - - // Admission is queued, never delivered while the submitting call is still on - // the stack. - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - QCOMPARE(harness.loader.loadCount, 1); - QCOMPARE(harness.loader.requestedPaths.first(), QStringLiteral("/corpus/report.pdf")); - QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("report.pdf")); - QVERIFY(harness.context.getDocument() != nullptr); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Open); - QCOMPARE(harness.facade->rejectedCompletionCount(), 0); - - // The one revision fence is published under the one document key. - const QString documentKey = harness.context.getDocumentIdentity().documentId; - QVERIFY(!documentKey.isEmpty()); - QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); - - // The load job carries no document key: there is no revision to fence it - // against before the document exists. - QCOMPARE(harness.submitter.submittedSpecs.size(), 1); - QVERIFY(harness.submitter.submittedSpecs.first().documentKey.isEmpty()); - QCOMPARE(harness.submitter.submittedSpecs.first().priority, pdf::PDFJobPriority::Operator); - QCOMPARE(harness.submitter.submittedSpecs.first().staleResultPolicy, - pdf::PDFJobStaleResultPolicy::Discard); - - QCOMPARE(replaced.count(), 1); - QVERIFY(stateChanged.count() >= 2); - QCOMPARE(finished.count(), 1); - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.invocation, invocation); - QVERIFY(result.isSuccess()); - - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); -} - -void DocumentFacadeTest::workerLoadCannotPublishAfterSchedulerFailure() -{ - Harness harness; - harness.submitter.terminalStatus = pdf::PDFJobStatus::Failed; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); - QCOMPARE(harness.context.getDocument(), nullptr); - QCOMPARE(harness.facade->typedError(), QStringLiteral("document/job-not-admitted")); -} - -void DocumentFacadeTest::openFailureReportsTypedErrorAndBindsNoDocument() -{ - Harness harness; - harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; - harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - harness.facade->open(QStringLiteral("/corpus/broken.pdf")); - - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); - QCOMPARE(harness.facade->typedError(), QStringLiteral("document/read-failed")); - QCOMPARE(harness.context.getDocument(), nullptr); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::NoDocument); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); - QVERIFY(!result.isSuccess()); - - QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); -} - -void DocumentFacadeTest::openCancellationIsTerminalAndNotSuccess() -{ - Harness harness; - harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Cancelled; - harness.loader.nextResult.typedError = QStringLiteral("document/cancelled"); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); - QCOMPARE(harness.context.getDocument(), nullptr); - - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); - - // Cancellation is terminal and it is not success. - QVERIFY(!result.isSuccess()); - QVERIFY(!harness.catalog.isPending(result.invocation)); -} - -void DocumentFacadeTest::cancellingAQueuedOpenIsTerminal() -{ - Harness harness; - harness.submitter.runInline = false; - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const pdfinteraction::CommandInvocationId invocation = - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); - QCOMPARE(harness.submitter.deferredJobCount(), 1); - - QVERIFY(harness.facade->cancelPendingOperation()); - - // Work cancelled out of the queue never runs, so nothing would report a - // terminal state for it. The facade must resolve that itself rather than - // leaving the invocation pending forever. - QTRY_VERIFY(!harness.catalog.isPending(invocation)); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); - QCOMPARE(harness.loader.loadCount, 0); - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); - - QCOMPARE(finished.count(), 1); - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); - QCOMPARE(harness.facade->rejectedCompletionCount(), 0); -} - -void DocumentFacadeTest::cancellationRejectsAlreadyQueuedSuccess() -{ - Harness harness; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const auto invocation = harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QCOMPARE(harness.loader.loadCount, 1); - QVERIFY(harness.facade->cancelPendingOperation()); - QVERIFY(!harness.catalog.isPending(invocation)); - QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Cancelled); - - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); - QCOMPARE(harness.context.getDocument(), nullptr); - QCOMPARE(finished.count(), 1); - QVERIFY(!harness.facade->cancelPendingOperation()); -} - -void DocumentFacadeTest::failedOpenRetryPreservesCauseAndAllocatesIdentity() -{ - Harness harness; - harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; - harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); - QSignalSpy changed(harness.facade.get(), &pdfinteraction::DocumentFacade::operationChanged); - const auto first = harness.facade->open(QStringLiteral("/corpus/broken.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); - const auto failure = harness.facade->operation(); - QVERIFY(!failure.pending); - QCOMPARE(failure.result.invocation, first); - QCOMPARE(failure.result.typedError, QStringLiteral("document/read-failed")); - - harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Loaded; - harness.loader.nextResult.typedError.clear(); - const auto retried = harness.facade->retry(); - QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); - QVERIFY(retried != first); - const auto request = harness.facade->operation(); - QVERIFY(request.pending); - QCOMPARE(request.invocation, retried); - QVERIFY(request.jobId != failure.jobId); - QVERIFY(request.generation > failure.generation); - QCOMPARE(request.retryCause, failure.result); - QCOMPARE(request.target.path, failure.target.path); - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); - - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QCOMPARE(harness.facade->operation().retryCause, failure.result); - QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Completed); - QCOMPARE(harness.loader.requestedPaths, QStringList({ failure.target.path, failure.target.path })); - QCOMPARE(changed.count(), 4); - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); -} - -void DocumentFacadeTest::cancelledRetryRejectsReorderedCompletion_data() -{ - QTest::addColumn("oldFinishesFirst"); - QTest::newRow("old-before-retry") << true; - QTest::newRow("old-after-retry") << false; -} - -void DocumentFacadeTest::cancelledRetryRejectsReorderedCompletion() -{ - QFETCH(bool, oldFinishesFirst); - Harness harness; - harness.submitter.runInline = false; - harness.submitter.cancelStopsQueuedWork = false; - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const auto first = harness.facade->open(QStringLiteral("/corpus/report.pdf")); - const auto oldRequest = harness.facade->operation(); - QVERIFY(harness.facade->cancelPendingOperation()); - const auto cause = harness.facade->operation().result; - QCOMPARE(cause.invocation, first); - QCOMPARE(cause.typedError, QStringLiteral("document/cancelled")); - const auto retried = harness.facade->retry(); - QVERIFY(retried != first); - QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); - const auto request = harness.facade->operation(); - QCOMPARE(request.retryCause, cause); - - if (oldFinishesFirst) - { - QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); - QVERIFY(harness.facade->operation().pending); - QCOMPARE(harness.context.getDocument(), nullptr); - } - QVERIFY(harness.submitter.runDeferred(request.jobId)); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - const auto revision = harness.facade->currentRevision(); - if (!oldFinishesFirst) - { - harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; - harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); - QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - } - QCOMPARE(harness.facade->currentRevision(), revision); - QCOMPARE(harness.facade->operation().invocation, retried); - QCOMPARE(harness.facade->operation().retryCause, cause); - QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Completed); - QVERIFY(harness.facade->typedError().isEmpty()); - QCOMPARE(finished.count(), 2); -} - -void DocumentFacadeTest::failedSaveRetryKeepsTargetAndRevision() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - harness.facade->markModified(); - harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Failed, QStringLiteral("document/write-failed") }; - const auto first = harness.facade->saveAs(QStringLiteral("/out/retry.pdf")); - QTRY_VERIFY(!harness.catalog.isPending(first)); - const auto failed = harness.facade->operation(); - - harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Written, QString() }; - const auto retried = harness.facade->retry(); - QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); - QVERIFY(retried != first); - QCOMPARE(harness.facade->operation().retryCause, failed.result); - QCOMPARE(harness.facade->operation().revision, failed.revision); - QCOMPARE(harness.facade->operation().generation, failed.generation); - QVERIFY(harness.facade->typedError().isEmpty()); - QTRY_COMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Saved); - QCOMPARE(harness.writer.writtenPaths, QStringList({ failed.target.path, failed.target.path })); - QVERIFY(!harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); -} - -void DocumentFacadeTest::cancelledSaveRetryRejectsPriorSuccess() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - harness.facade->markModified(); - harness.submitter.runInline = false; - harness.submitter.cancelStopsQueuedWork = false; - harness.facade->saveAs(QStringLiteral("/out/report.pdf")); - const auto oldRequest = harness.facade->operation(); - QVERIFY(harness.facade->cancelPendingOperation()); - const auto cause = harness.facade->operation().result; - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.submitter.deferredJobCount(), 1); - QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - const auto retried = harness.facade->retry(); - QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); - const auto request = harness.facade->operation(); - QCOMPARE(request.generation, oldRequest.generation); - QCOMPARE(request.revision, oldRequest.revision); - QVERIFY(!harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); - QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Pending); - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); - QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/report.pdf")); - - QVERIFY(harness.submitter.runDeferred(request.jobId)); - QTRY_COMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Saved); - QCOMPARE(harness.facade->operation().invocation, retried); - QCOMPARE(harness.facade->operation().retryCause, cause); - QCOMPARE(harness.facade->source().path, QStringLiteral("/out/report.pdf")); -} - -void DocumentFacadeTest::retryRejectsStaleRevisionAndClosedDocument() -{ - Harness harness; - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Failed, QStringLiteral("document/write-failed") }; - const auto first = harness.facade->save(); - QTRY_VERIFY(!harness.catalog.isPending(first)); - harness.facade->markModified(); - const int jobs = harness.submitter.submittedSpecs.size(); - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.submitter.submittedSpecs.size(), jobs); - harness.facade->close(); - QCOMPARE(harness.facade->operation().invocation, pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.facade->reopen(), pdfinteraction::InvalidCommandInvocation); -} - -void DocumentFacadeTest::supersessionCannotRetryWhileReplacingDocument() -{ - Harness harness; - harness.submitter.runInline = false; - const auto first = harness.facade->open(QStringLiteral("/corpus/old.pdf")); - auto attemptedRetry = first; - connect(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished, - harness.facade.get(), [&](const pdfinteraction::CommandResult& result) - { - if (result.invocation == first) - { - attemptedRetry = harness.facade->retry(); - } }); - const auto current = harness.facade->open(QStringLiteral("/corpus/current.pdf")); - QCOMPARE(attemptedRetry, pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.facade->operation().invocation, current); - QCOMPARE(harness.submitter.submittedSpecs.size(), 2); - QVERIFY(harness.submitter.runDeferred(harness.facade->operation().jobId)); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/current.pdf")); - QCOMPARE(harness.catalog.pendingInvocationCount(), 0); -} - -void DocumentFacadeTest::reopenRejectsPriorWriteCompletion() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - const auto oldRevision = harness.facade->currentRevision(); - const auto oldGeneration = harness.facade->documentGeneration(); - harness.submitter.runInline = false; - harness.submitter.cancelStopsQueuedWork = false; - harness.facade->saveAs(QStringLiteral("/out/old.pdf")); - const auto oldRequest = harness.facade->operation(); - - harness.submitter.runInline = true; - const auto reopened = harness.facade->reopen(); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - const auto current = harness.facade->currentRevision(); - QVERIFY(current != oldRevision); - QVERIFY(harness.facade->documentGeneration() > oldGeneration); - QVERIFY(harness.submitter.clearedKeys.contains(oldRevision.document.documentId)); - QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - QCOMPARE(harness.facade->currentRevision(), current); - QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/report.pdf")); - QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::None); - QCOMPARE(harness.facade->operation().invocation, reopened); - QCOMPARE(harness.facade->operation().retryCause.invocation, pdfinteraction::InvalidCommandInvocation); -} - -void DocumentFacadeTest::realWorkerCancellationAndTeardown_data() -{ - QTest::addColumn("destroyHost"); - QTest::newRow("cancel-running-worker") << false; - QTest::newRow("destroy-host-with-running-worker") << true; -} - -void DocumentFacadeTest::realWorkerCancellationAndTeardown() -{ - QFETCH(bool, destroyHost); - pdf::PDFDocumentContext context(nullptr); - DelayedDocumentLoader loader; - FakeDocumentWriter writer; - pdfinteraction::CommandCatalog catalog; - pdf::PDFJobScheduler scheduler(1); - pdfinteraction::PDFJobSchedulerSubmitter submitter(scheduler); - auto facade = std::make_unique(context, submitter, loader, writer, catalog); - const auto releaseWorker = qScopeGuard([&loader]() - { loader.release.release(); }); - QSignalSpy finished(&catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const auto invocation = facade->open(QStringLiteral("/corpus/delayed.pdf")); - const QString job = facade->operation().jobId; - QVERIFY(loader.started.tryAcquire(1, 5000)); - QCOMPARE(scheduler.snapshot(job).status, pdf::PDFJobStatus::Running); - if (destroyHost) - { - facade.reset(); - } - else - { - QVERIFY(facade->cancelPendingOperation()); - QCOMPARE(facade->state(), pdfinteraction::DocumentState::Empty); - } - QVERIFY(!catalog.isPending(invocation)); - QCOMPARE(finished.count(), 1); - QCOMPARE(finished.first().first().value().state, pdfinteraction::CommandTerminalState::Cancelled); - loader.release.release(); - QVERIFY(scheduler.waitForFinished(job, 5000)); - if (facade) - { - QTRY_COMPARE(facade->rejectedCompletionCount(), 1); - QCOMPARE(facade->state(), pdfinteraction::DocumentState::Empty); - } - QCoreApplication::sendPostedEvents(); - QCOMPARE(context.getDocument(), nullptr); - QCOMPARE(finished.count(), 1); -} - -void DocumentFacadeTest::replacementEstablishesNewIdentityBeforeNewWork() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/first.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const pdf::PDFRevisionIdentity firstRevision = harness.facade->currentRevision(); - const QString firstKey = harness.context.getDocumentIdentity().documentId; - const quint64 firstGeneration = harness.facade->documentGeneration(); - QVERIFY(harness.facade->revisionSource().isCurrent(firstRevision)); - - harness.facade->open(QStringLiteral("/corpus/second.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - // The prior identity is dropped before new work is admitted, so nothing - // captured against it can be presented as current. - QVERIFY(!harness.facade->revisionSource().isCurrent(firstRevision)); - QVERIFY(harness.facade->documentGeneration() > firstGeneration); - QVERIFY(harness.submitter.clearedKeys.contains(firstKey)); - QCOMPARE(harness.loader.loadCount, 2); - QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); - QCOMPARE(harness.facade->rejectedCompletionCount(), 0); -} - -void DocumentFacadeTest::supersededCompletionIsRejectedNotAdmitted() -{ - Harness harness; - harness.submitter.runInline = false; - - // The first read is already running when it is superseded, so cancelling it - // does not stop it: its completion still arrives, and admission is the only - // thing standing between it and the current document. - harness.submitter.cancelStopsQueuedWork = false; - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - - const pdfinteraction::CommandInvocationId first = - harness.facade->open(QStringLiteral("/corpus/first.pdf")); - const QString firstJob = QStringLiteral("job-1"); - QCOMPARE(harness.submitter.deferredJobCount(), 1); - - harness.submitter.runInline = true; - harness.facade->open(QStringLiteral("/corpus/second.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); - - const pdf::PDFRevisionIdentity currentRevision = harness.facade->currentRevision(); - QVERIFY(!harness.catalog.isPending(first)); - QVERIFY(harness.submitter.cancelledJobs.contains(firstJob)); - - // Now the superseded read finishes. It must be counted and dropped, never - // patched into the document that replaced it. - QVERIFY(harness.submitter.runDeferred(firstJob)); - QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); - - QCOMPARE(harness.facade->currentRevision(), currentRevision); - QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - bool sawSupersededTerminal = false; - int terminalsForFirst = 0; - for (const QList& emitted : finished) - { - const auto result = emitted.at(0).value(); - if (result.invocation == first) - { - ++terminalsForFirst; - sawSupersededTerminal = true; - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); - QCOMPARE(result.typedError, QStringLiteral("document/superseded")); - } - } - QVERIFY(sawSupersededTerminal); - - // Terminal means terminal: the late completion does not emit a second one. - QCOMPARE(terminalsForFirst, 1); -} - -void DocumentFacadeTest::replacementInvalidatesTheAvailabilitySnapshot() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/first.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); - - harness.submitter.runInline = false; - harness.facade->open(QStringLiteral("/corpus/second.pdf")); - - // Availability computed against the previous document is not evidence about - // the next one, so it does not survive the replacement. - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); - QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); - QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); -} - -void DocumentFacadeTest::closeWithJobInFlightCancelsAndClearsRevision() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const QString documentKey = harness.context.getDocumentIdentity().documentId; - const pdf::PDFRevisionIdentity captured = harness.facade->currentRevision(); - - // A save is left in flight, then the document is closed under it. - harness.submitter.runInline = false; - const pdfinteraction::CommandInvocationId save = - harness.facade->saveAs(QStringLiteral("/out/report.pdf")); - QVERIFY(save != pdfinteraction::InvalidCommandInvocation); - QCOMPARE(harness.facade->shellDocumentStatus(), - pdfinteraction::ShellDocumentStatus::OutputPending); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - harness.facade->close(); - - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); - QVERIFY(!harness.catalog.isPending(save)); - QVERIFY(harness.submitter.cancelledJobs.contains(QStringLiteral("job-2"))); - - // The fence entry this facade created is the one it clears, and a key with - // no entry is never stale. - QVERIFY(harness.submitter.clearedKeys.contains(documentKey)); - QVERIFY(!harness.submitter.publishedRevisions.contains(documentKey)); - QVERIFY(!harness.facade->revisionSource().isCurrent(captured)); - - bool sawSaveCancelled = false; - for (const QList& emitted : finished) - { - const auto result = emitted.at(0).value(); - if (result.invocation == save) - { - sawSaveCancelled = true; - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); - QVERIFY(!result.isSuccess()); - } - } - QVERIFY(sawSaveCancelled); - - // A late completion after close must not reach a document that is gone. - QCOMPARE(harness.writer.writeCount, 0); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::NoDocument); -} - -void DocumentFacadeTest::closeAfterOpenClearsThePublishedRevision() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const QString documentKey = harness.context.getDocumentIdentity().documentId; - QSignalSpy closed(harness.facade.get(), &pdfinteraction::DocumentFacade::documentClosed); - - const pdfinteraction::CommandInvocationId invocation = harness.facade->close(); - QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); - - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); - QCOMPARE(harness.context.getDocument(), nullptr); - QVERIFY(!harness.facade->source().isValid()); - QCOMPARE(closed.count(), 1); - QVERIFY(harness.submitter.clearedKeys.contains(documentKey)); - QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); -} - -void DocumentFacadeTest::destroyingTheFacadeTerminatesAPendingInvocation() -{ - Harness harness; - harness.submitter.runInline = false; - - // The read is already running, so the facade's destructor cannot stop it — - // which is the case worth proving. - harness.submitter.cancelStopsQueuedWork = false; - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const pdfinteraction::CommandInvocationId invocation = - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QVERIFY(harness.catalog.isPending(invocation)); - - harness.facade.reset(); - - // No invocation is left dangling, and the relay makes a late completion - // harmless rather than a use-after-free. - QVERIFY(!harness.catalog.isPending(invocation)); - QCOMPARE(finished.count(), 1); - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); - QCOMPARE(result.typedError, QStringLiteral("document/facade-destroyed")); - - QVERIFY(harness.submitter.runDeferred(QStringLiteral("job-1"))); - QTest::qWait(20); -} - -void DocumentFacadeTest::saveWritesAndReportsOutputSaved() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const QString documentKey = harness.context.getDocumentIdentity().documentId; - const pdf::PDFRevisionIdentity revision = harness.facade->currentRevision(); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - const pdfinteraction::CommandInvocationId invocation = harness.facade->save(); - QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); - - QTRY_COMPARE(harness.facade->shellDocumentStatus(), - pdfinteraction::ShellDocumentStatus::OutputSaved); - QCOMPARE(harness.writer.writeCount, 1); - QVERIFY(harness.writer.sawDocument); - QCOMPARE(harness.writer.writtenPaths.first(), QStringLiteral("/corpus/report.pdf")); - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - // The write job is fenced by the scheduler's own revision contract. - const pdf::PDFJobSpec saveSpec = harness.submitter.submittedSpecs.last(); - QCOMPARE(saveSpec.documentKey, documentKey); - QCOMPARE(saveSpec.documentRevision, revision.toString()); - QCOMPARE(saveSpec.staleResultPolicy, pdf::PDFJobStaleResultPolicy::Discard); - QCOMPARE(saveSpec.kind, pdf::PDFJobKind::Export); - - const auto result = finished.takeFirst().at(0).value(); - QVERIFY(result.isSuccess()); -} - -void DocumentFacadeTest::saveFailureKeepsTheDocumentReady() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - harness.writer.nextResult.outcome = pdfinteraction::DocumentWriteOutcome::Failed; - harness.writer.nextResult.typedError = QStringLiteral("document/write-failed"); - - QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); - harness.facade->save(); - - QTRY_COMPARE(finished.count(), 1); - const auto result = finished.takeFirst().at(0).value(); - QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); - - // Only the output axis failed; the document is untouched and still open. - QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::None); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Open); - QCOMPARE(harness.facade->typedError(), QStringLiteral("document/write-failed")); -} - -void DocumentFacadeTest::markModifiedFencesTheCapturedRevision() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/report.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const pdf::PDFRevisionIdentity captured = harness.facade->currentRevision(); - const QString documentKey = harness.context.getDocumentIdentity().documentId; - - harness.facade->markModified(); - - QVERIFY(!harness.facade->revisionSource().isCurrent(captured)); - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); - - // The published fence follows the document rather than going stale behind it. - QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); - - // A modification after a successful save makes the saved state no longer - // what the operator is looking at. - harness.facade->save(); - QTRY_COMPARE(harness.facade->shellDocumentStatus(), - pdfinteraction::ShellDocumentStatus::OutputSaved); - harness.facade->markModified(); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); -} - -void DocumentFacadeTest::shellStatusProjectionIsPinned() -{ - using pdfinteraction::DocumentFacade; - using pdfinteraction::DocumentFacet; - using pdfinteraction::DocumentFacets; - using pdfinteraction::DocumentOutputState; - using pdfinteraction::DocumentState; - using pdfinteraction::ShellDocumentStatus; - - // docs/loop-shell.json names five document states. The facade's richer - // model answers in those five terms; this table is the whole projection, so - // no host has to re-derive it and drift. - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Empty, {}, DocumentOutputState::None), - ShellDocumentStatus::NoDocument); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Opening, {}, DocumentOutputState::None), - ShellDocumentStatus::NoDocument); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Closing, {}, DocumentOutputState::None), - ShellDocumentStatus::NoDocument); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Error, {}, DocumentOutputState::None), - ShellDocumentStatus::NoDocument); - - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::None), - ShellDocumentStatus::Open); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, - DocumentFacets(DocumentFacet::Dirty), - DocumentOutputState::None), - ShellDocumentStatus::Modified); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::Pending), - ShellDocumentStatus::OutputPending); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::Saved), - ShellDocumentStatus::OutputSaved); - - // A write in flight outranks unsaved changes; unsaved changes outrank a - // previous successful write. - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, - DocumentFacets(DocumentFacet::Dirty), - DocumentOutputState::Pending), - ShellDocumentStatus::OutputPending); - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, - DocumentFacets(DocumentFacet::Dirty), - DocumentOutputState::Saved), - ShellDocumentStatus::Modified); - - // Non-Dirty facets describe the document, not the shell status. - QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, - DocumentFacets(DocumentFacet::Incomplete) | - DocumentFacet::Stale, - DocumentOutputState::None), - ShellDocumentStatus::Open); - - QCOMPARE(QLatin1String(pdfinteraction::getShellDocumentStatusName(ShellDocumentStatus::OutputPending)), - QLatin1String("OUTPUT_PENDING")); -} - -void DocumentFacadeTest::signatureCheckReducesVerificationResultsToTheOperatorVocabulary() -{ - using Result = pdf::PDFSignatureVerificationResult; - using State = pdfinteraction::DocumentSignatureState; - using Facade = pdfinteraction::DocumentFacade; - - const auto verifiedResult = []() - { - Result result; - result.setFlag(Result::Certificate_OK, true); - result.setFlag(Result::Signature_OK, true); - result.validate(); - return result; - }; - - // No results at all is the distinct "no signature is present" case, never - // folded into one of the four operator-facing states. - QCOMPARE(Facade::reduceSignatureState({}), State::None); - - const Result valid = verifiedResult(); - QCOMPARE(Facade::reduceSignatureState({ valid }), State::Valid); - - Result notCovered = verifiedResult(); - notCovered.addSignatureNotCoveredBytesWarning(16); - QCOMPARE(Facade::reduceSignatureState({ notCovered }), State::ModifiedAfterSigning); - - Result tampered; - tampered.addInvalidSignatureError(); - tampered.validate(); - QCOMPARE(Facade::reduceSignatureState({ tampered }), State::Invalid); - - Result selfSigned; - selfSigned.addCertificateSelfSignedError(); - selfSigned.validate(); - QCOMPARE(Facade::reduceSignatureState({ selfSigned }), State::Unknown); - - Result noHandler; - noHandler.addNoHandlerError(QByteArrayLiteral("application/unknown")); - noHandler.validate(); - QCOMPARE(Facade::reduceSignatureState({ noHandler }), State::Unknown); - - Result revoked; - revoked.addCertificateRevokedError(); - revoked.validate(); - QCOMPARE(Facade::reduceSignatureState({ revoked }), State::Invalid); - - // The strongest fact wins across a mixed set: a broken signature outranks a - // covered-bytes warning, which outranks an unestablished trust chain, and - // only an all-verified set is Valid. - QCOMPARE(Facade::reduceSignatureState({ valid, tampered }), State::Invalid); - QCOMPARE(Facade::reduceSignatureState({ valid, selfSigned }), State::Unknown); - QCOMPARE(Facade::reduceSignatureState({ valid, notCovered }), State::ModifiedAfterSigning); - QCOMPARE(Facade::reduceSignatureState({ valid, valid }), State::Valid); -} - -void DocumentFacadeTest::signatureCheckAdmitsOnlyTheCurrentRevisionAndClearsWithTheDocument() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - QString error; - QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); - - const auto token = harness.facade->beginSignatureCheck(error); - QVERIFY2(token.has_value(), qPrintable(error)); - - // One check at a time. - QVERIFY(!harness.facade->beginSignatureCheck(error)); - QCOMPARE(error, QStringLiteral("signature/check-already-running")); - - pdf::PDFSignatureVerificationResult validResult; - validResult.setFlag(pdf::PDFSignatureVerificationResult::Certificate_OK, true); - validResult.setFlag(pdf::PDFSignatureVerificationResult::Signature_OK, true); - validResult.validate(); - - // A completion for a different token is refused, and the facts stay empty. - auto forged = *token; - ++forged.request; - QVERIFY(!harness.facade->completeSignatureCheck(forged, { validResult }, error)); - QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); - - QVERIFY2(harness.facade->completeSignatureCheck(*token, { validResult }, error), qPrintable(error)); - QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::Valid); - QCOMPARE(harness.facade->signatureFacts().signatureCount, 1); - - // A failed check is Unknown with the host-supplied policy-safe detail. - const auto failing = harness.facade->beginSignatureCheck(error); - QVERIFY(failing.has_value()); - QVERIFY(harness.facade->failSignatureCheck(*failing, - QStringLiteral("The file changed since the document was loaded."), - error)); - QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::Unknown); - QCOMPARE(harness.facade->signatureFacts().detail, - QStringLiteral("The file changed since the document was loaded.")); - - // Replacing the document clears the facts: nothing about the previous - // document is evidence about the next one. - harness.facade->open(QStringLiteral("/corpus/other.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); - QVERIFY(harness.facade->signatureFacts().detail.isEmpty()); - QCOMPARE(harness.facade->signatureFacts().signatureCount, 0); -} - -void DocumentFacadeTest::realDocumentRoundTripsThroughCoreReaderAndWriter() -{ - QTemporaryDir directory; - QVERIFY(directory.isValid()); - - const QString sourcePath = directory.filePath(QStringLiteral("source.pdf")); - const QString targetPath = directory.filePath(QStringLiteral("target.pdf")); - - { - const pdf::PDFDocumentPointer document = buildDocument(3); - pdf::PDFDocumentWriter writer(nullptr); - QVERIFY(bool(writer.write(sourcePath, document.data(), true))); - } - - pdf::PDFDocumentContext context(nullptr); - pdf::PDFJobScheduler scheduler(1); - pdfinteraction::PDFJobSchedulerSubmitter submitter(scheduler); - pdfinteraction::PDFReaderDocumentLoader loader; - pdfinteraction::PDFDocumentFileWriter writer; - pdfinteraction::CommandCatalog catalog; - pdfinteraction::DocumentFacade facade(context, submitter, loader, writer, catalog); - - // The whole lifecycle over the real Core reader, writer, and scheduler — - // with no QApplication, no QWidget, and no QML engine anywhere. - facade.open(sourcePath); - QTRY_COMPARE_WITH_TIMEOUT(facade.state(), pdfinteraction::DocumentState::Ready, 10000); - QVERIFY(context.getDocument() != nullptr); - QCOMPARE(context.getDocument()->getCatalog()->getPageCount(), size_t(3)); - - facade.saveAs(targetPath); - QTRY_COMPARE_WITH_TIMEOUT(facade.shellDocumentStatus(), - pdfinteraction::ShellDocumentStatus::OutputSaved, 10000); - QVERIFY(QFileInfo::exists(targetPath)); - - facade.close(); - QCOMPARE(facade.state(), pdfinteraction::DocumentState::Empty); - QCOMPARE(context.getDocument(), nullptr); - QCOMPARE(facade.rejectedCompletionCount(), 0); -} - -void DocumentFacadeTest::operatorSnapshotTracksLifecycle() -{ - using namespace pdfinteraction; - Harness harness; - QString error; - InspectionFixture fixture; - QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); - QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); - QVERIFY(!harness.facade->operatorState().revision.isValid()); - - QList observed; - connect(harness.facade.get(), &DocumentFacade::operatorStateChanged, this, [&]() - { observed.append(harness.facade->operatorState().document); }); - harness.submitter.runInline = false; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QCOMPARE(harness.facade->operatorState().document, DocumentState::Opening); - QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); - QVERIFY(harness.submitter.runDeferred(QStringLiteral("job-1"))); - QTRY_COMPARE(harness.facade->state(), DocumentState::Ready); - const auto ready = harness.facade->operatorState(); - QCOMPARE(ready.revision, harness.context.getRevision()); - QCOMPARE(ready.documentKey, harness.context.getDocumentIdentity().documentId); - QCOMPARE(ready.generation, harness.facade->documentGeneration()); - harness.facade->close(); - QVERIFY(observed.contains(DocumentState::Opening)); - QVERIFY(observed.contains(DocumentState::Ready)); - QVERIFY(observed.contains(DocumentState::Closing)); - QVERIFY(observed.contains(DocumentState::Empty)); - QVERIFY(!harness.facade->operatorState().revision.isValid()); - - harness.loader.nextResult.outcome = DocumentLoadOutcome::Failed; - harness.loader.nextResult.typedError = QStringLiteral("document/invalid-pdf"); - harness.submitter.runInline = true; - harness.facade->open(QStringLiteral("/corpus/bad.pdf")); - QTRY_COMPARE(harness.facade->state(), DocumentState::Error); - QCOMPARE(harness.facade->operatorState().failureCode, QStringLiteral("document/invalid-pdf")); - QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); - QCOMPARE(harness.writer.writeCount, 0); -} - -void DocumentFacadeTest::inspectionVerdictContract_data() -{ - QTest::addColumn("verdict"); - QTest::newRow("pass") << int(pdf::PreflightVerdictState::Pass); - QTest::newRow("fail") << int(pdf::PreflightVerdictState::Fail); - QTest::newRow("incomplete") << int(pdf::PreflightVerdictState::Incomplete); - QTest::newRow("error") << int(pdf::PreflightVerdictState::Error); -} - -void DocumentFacadeTest::inspectionVerdictContract() -{ - QFETCH(int, verdict); - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - switch (pdf::PreflightVerdictState(verdict)) - { - case pdf::PreflightVerdictState::Pass: - break; - case pdf::PreflightVerdictState::Fail: - fixture.addFinding(); - break; - case pdf::PreflightVerdictState::Incomplete: - fixture.result.inspectionComplete = false; - break; - case pdf::PreflightVerdictState::Error: - fixture.result.errorCode = QStringLiteral("engine-error"); - fixture.result.errorMessage = QStringLiteral("Inspection failed."); - break; - } - QString error; - pdf::PreflightInspectionReceipt coreReceipt; - QVERIFY2(pdf::buildPreflightInspectionReceipt(fixture.result, fixture.profile, harness.context.getRevision(), - fixture.evidence, coreReceipt, error), - qPrintable(error)); - QCOMPARE(int(coreReceipt.verdict.state), verdict); - coreReceipt.limitations.append(QStringLiteral("Document is not encrypted.")); - const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - QVERIFY2(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error), qPrintable(error)); - const auto snapshot = harness.facade->operatorState(); - QCOMPARE(snapshot.inspection.state, pdfinteraction::DocumentInspectionState::Completed); - QVERIFY(snapshot.inspection.receipt); - QCOMPARE(snapshot.inspection.receipt->toJson(), coreReceipt.toJson()); - QCOMPARE(snapshot.inspection.progress, 100); - // Both adapter shapes consume this same value without deriving identity or verdict. - const QString editorSummary = pdf::preflightVerdictOperatorSummary(snapshot.inspection.receipt->verdict); - const QJsonObject cliReceipt = snapshot.inspection.receipt->toJson(); - QCOMPARE(editorSummary, pdf::preflightVerdictOperatorSummary(coreReceipt.verdict)); - QCOMPARE(cliReceipt, coreReceipt.toJson()); - QCOMPARE(snapshot.canActOnInspection(), verdict != int(pdf::PreflightVerdictState::Error)); -} - -void DocumentFacadeTest::inspectionProgressAndTerminalAdmission() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - QString error; - QSignalSpy changes(harness.facade.get(), &pdfinteraction::DocumentFacade::operatorStateChanged); - const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - QVERIFY(!harness.facade->operatorState().canActOnInspection()); - QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); - QVERIFY(!harness.facade->updateInspectionProgress(*token, -1, error)); - QVERIFY(!harness.facade->updateInspectionProgress(*token, 101, error)); - QVERIFY(harness.facade->updateInspectionProgress(*token, 50, error)); - QVERIFY(!harness.facade->updateInspectionProgress(*token, 49, error)); - auto wrongToken = *token; - ++wrongToken.request; - QVERIFY(!harness.facade->completeInspection(wrongToken, fixture.result, fixture.evidence, error)); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); - QCOMPARE(harness.facade->operatorState().inspection.progress, 50); - QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - const int terminalSignals = changes.count(); - QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(!harness.facade->updateInspectionProgress(*token, 100, error)); - QVERIFY(!harness.facade->cancelInspection(*token, error)); - QVERIFY(!harness.facade->failInspection(*token, QStringLiteral("late"), QStringLiteral("Late failure"), error)); - QCOMPARE(changes.count(), terminalSignals); - QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Completed); -} - -void DocumentFacadeTest::inspectionCancellationAndFailure() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - QString error; - const auto cancelled = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(cancelled); - QVERIFY(harness.facade->cancelInspection(*cancelled, error)); - QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Cancelled); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); - QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); - QVERIFY(!harness.facade->completeInspection(*cancelled, fixture.result, fixture.evidence, error)); - const auto failed = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(failed); - QVERIFY(failed->request > cancelled->request); - QVERIFY(!harness.facade->updateInspectionProgress(*cancelled, 30, error)); - QVERIFY(!harness.facade->failInspection(*failed, {}, {}, error)); - QVERIFY(harness.facade->failInspection(*failed, QStringLiteral("inspection/worker-failed"), - QStringLiteral("Worker exited."), error)); - const auto failure = harness.facade->operatorState(); - QCOMPARE(failure.inspection.state, pdfinteraction::DocumentInspectionState::Failed); - QCOMPARE(failure.inspection.failureCode, QStringLiteral("inspection/worker-failed")); - QCOMPARE(failure.inspection.failureMessage, QStringLiteral("Worker exited.")); - QVERIFY(!failure.inspection.receipt); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); - QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); - const auto retry = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(retry); - QVERIFY(harness.facade->operatorState().inspection.failureCode.isEmpty()); - QVERIFY(harness.facade->completeInspection(*retry, fixture.result, fixture.evidence, error)); -} - -void DocumentFacadeTest::inspectionSelectionAndPlanIntent() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - const QString findingId = fixture.addFinding(); - QString error; - const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("unknown"), error)); - QVERIFY(harness.facade->selectFinding(findingId, error)); - QVERIFY(!harness.facade->requestPlan({}, error)); - QVERIFY(harness.facade->requestPlan(QStringLiteral("add-bleed"), error)); - const auto planned = harness.facade->operatorState(); - QVERIFY(planned.inspection.planIntent); - QCOMPARE(planned.inspection.planIntent->findingId, findingId); - QCOMPARE(planned.inspection.planIntent->receiptIdentity, planned.inspection.receipt->identity); - QCOMPARE(planned.inspection.planIntent->revision, planned.revision); - QCOMPARE(harness.writer.writeCount, 0); - harness.facade->clearPlanIntent(); - QVERIFY(!harness.facade->operatorState().inspection.planIntent); - QVERIFY(harness.facade->requestPlan(QStringLiteral("add-bleed"), error)); - QVERIFY(!harness.facade->selectFinding(QStringLiteral("unknown"), error)); - QVERIFY(harness.facade->operatorState().inspection.planIntent); - QVERIFY(harness.facade->selectFinding({}, error)); - QVERIFY(!harness.facade->operatorState().inspection.planIntent); - QVERIFY(harness.facade->operatorState().inspection.selectedFindingId.isEmpty()); - const auto repeat = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(repeat); - QVERIFY(!harness.facade->operatorState().inspection.receipt); -} - -void DocumentFacadeTest::inspectionRejectsInvalidEvidenceWithoutMutation() -{ - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - QString error; - QVERIFY(!harness.facade->beginInspection({}, fixture.profile, error)); - auto invalidProfile = fixture.profile; - invalidProfile.effectiveDigest.clear(); - QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, invalidProfile, error)); - const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - auto wrongResult = fixture.result; - wrongResult.documentRevisionDigest = QString(64, QLatin1Char('c')); - QVERIFY(!harness.facade->completeInspection(*token, wrongResult, fixture.evidence, error)); - wrongResult = fixture.result; - wrongResult.effectiveProfileDigest = QString(64, QLatin1Char('c')); - QVERIFY(!harness.facade->completeInspection(*token, wrongResult, fixture.evidence, error)); - auto wrongEvidence = fixture.evidence; - wrongEvidence.revision = harness.facade->currentRevision(); - ++wrongEvidence.revision.cacheGeneration; - QVERIFY(!harness.facade->completeInspection(*token, fixture.result, wrongEvidence, error)); - fixture.addFinding(); - fixture.result.errors.append(fixture.result.errors.first()); - QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(!error.isEmpty()); - QVERIFY(!harness.facade->operatorState().inspection.receipt); - QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Running); - fixture.result.errors.removeLast(); - QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); -} - -void DocumentFacadeTest::inspectionInvalidation_data() -{ - QTest::addColumn("change"); - QTest::addColumn("completed"); - for (const QString& change : { QStringLiteral("modify"), QStringLiteral("cache"), QStringLiteral("profile"), - QStringLiteral("reopen"), QStringLiteral("close") }) - { - QTest::newRow(qPrintable(change + QStringLiteral("-running"))) << change << false; - QTest::newRow(qPrintable(change + QStringLiteral("-completed"))) << change << true; - } -} - -void DocumentFacadeTest::inspectionInvalidation() -{ - QFETCH(QString, change); - QFETCH(bool, completed); - Harness harness; - harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - const QString findingId = fixture.addFinding(); - QString error; - const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - if (completed) - { - QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(harness.facade->selectFinding(findingId, error)); - QVERIFY(harness.facade->requestPlan(QStringLiteral("repair"), error)); - } - if (change == QLatin1String("modify")) - harness.context.markModified(); - else if (change == QLatin1String("cache")) - harness.context.invalidateCaches(); - else if (change == QLatin1String("profile")) - harness.context.setEffectiveProfileIdentity(QStringLiteral("changed-profile")); - else if (change == QLatin1String("reopen")) - { - harness.facade->reopen(); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - } - else - harness.facade->close(); - const auto invalidated = harness.facade->operatorState(); - const bool replaced = change == QLatin1String("reopen") || change == QLatin1String("close"); - QCOMPARE(invalidated.inspection.state, replaced ? pdfinteraction::DocumentInspectionState::NotChecked - : pdfinteraction::DocumentInspectionState::Stale); - QVERIFY(!invalidated.canActOnInspection()); - QVERIFY(!invalidated.inspection.receipt); - QVERIFY(invalidated.inspection.findingIds.isEmpty()); - QVERIFY(invalidated.inspection.selectedFindingId.isEmpty()); - QVERIFY(!invalidated.inspection.planIntent); - QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(!harness.facade->selectFinding(findingId, error)); - QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); - QCOMPARE(harness.writer.writeCount, 0); - const auto next = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - if (change == QLatin1String("close")) - { - QVERIFY(!next); - return; - } - QVERIFY(next); - QVERIFY(next->request > token->request); - QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(harness.facade->completeInspection(*next, fixture.result, fixture.evidence, error)); - QCOMPARE(harness.facade->operatorState().inspection.receipt->revision, harness.context.getRevision()); -} - -void DocumentFacadeTest::inspectionStopsWhenContextIsDestroyed() -{ - auto context = std::make_unique(nullptr); - FakeJobSubmitter submitter; - FakeDocumentLoader loader; - loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Loaded; - FakeDocumentWriter writer; - pdfinteraction::CommandCatalog catalog; - pdfinteraction::DocumentFacade facade(*context, submitter, loader, writer, catalog); - facade.open(QStringLiteral("/corpus/fixture.pdf")); - QTRY_COMPARE(facade.state(), pdfinteraction::DocumentState::Ready); - InspectionFixture fixture; - QString error; - const auto token = facade.beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); - QVERIFY(token); - QVERIFY(facade.completeInspection(*token, fixture.result, fixture.evidence, error)); - context.reset(); - const auto absent = facade.operatorState(); - QCOMPARE(absent.document, pdfinteraction::DocumentState::Error); - QCOMPARE(absent.failureCode, QStringLiteral("document/context-gone")); - QVERIFY(!absent.revision.isValid()); - QVERIFY(!absent.inspection.receipt); - QVERIFY(!absent.canActOnInspection()); - QVERIFY(!facade.completeInspection(*token, fixture.result, fixture.evidence, error)); - QVERIFY(!facade.beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); -} - -void DocumentFacadeTest::authoringEditAdvancesTheRevisionAndWritesTheOutline() -{ - Harness harness; - harness.loader.nextResult.document = buildDocument(3); - harness.facade->open(QStringLiteral("/corpus/bookmarks.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - const pdf::PDFRevisionIdentity before = harness.facade->currentRevision(); - const QString documentKey = harness.context.getDocumentIdentity().documentId; - - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 2; - request.edit.text = QStringLiteral("Appendix"); - request.revision = before; - - const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); - QVERIFY2(outcome.applied, qPrintable(outcome.typedError)); - QCOMPARE(outcome.resolution, pdf::PDFAuthoringEditResult::Kind::BookmarkAdded); - QVERIFY(outcome.revision != before); - QCOMPARE(outcome.revision, harness.facade->currentRevision()); - QVERIFY(!harness.facade->revisionSource().isCurrent(before)); - - // The edit is a document mutation: dirty, unsaved, and the published fence - // follows the revision the edit produced. - QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); - QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); - QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); - - // Read back through Core: the outline carries the bookmark. - QString typedError; - const std::optional> records = - pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); - QVERIFY2(records.has_value(), qPrintable(typedError)); - QCOMPARE(records->size(), 1); - QCOMPARE(records->at(0).title, QStringLiteral("Appendix")); - QCOMPARE(records->at(0).pageIndex, 2); -} - -void DocumentFacadeTest::authoringEditRefusesAStaleRevisionWithoutMutating() -{ - Harness harness; - harness.loader.nextResult.document = buildDocument(2); - harness.facade->open(QStringLiteral("/corpus/stale.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - - // The revision the operator acted on. - const pdf::PDFRevisionIdentity displayed = harness.facade->currentRevision(); - - // The document moves underneath it, as a background save or another edit - // would. - harness.facade->markModified(); - QVERIFY(!harness.facade->revisionSource().isCurrent(displayed)); - const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); - const pdf::PDFRevisionIdentity revisionBefore = harness.facade->currentRevision(); - - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 1; - request.revision = displayed; - - const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); - QVERIFY(!outcome.applied); - QCOMPARE(outcome.typedError, QStringLiteral("edit/stale-revision")); - // A refusal produces no revision at all. - QVERIFY(!outcome.revision.isValid()); - - // Nothing moved: the same document object is open at the same revision. - QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); - QCOMPARE(harness.facade->currentRevision(), revisionBefore); - - QString typedError; - const std::optional> records = - pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); - QVERIFY(records.has_value()); - QVERIFY(records->isEmpty()); -} - -void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() -{ - Harness harness; - - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 0; - request.revision = harness.facade->currentRevision(); - - const pdfinteraction::DocumentEditOutcome noDocument = harness.facade->applyAuthoringEdit(request); - QVERIFY(!noDocument.applied); - QCOMPARE(noDocument.typedError, QStringLiteral("edit/document-not-ready")); - - // A document whose user permissions deny Modify refuses the edit with the - // same visible reason the operator-facing command reports. The document is - // read back from an encrypted file, so its security handler carries the - // user's own permission bits exactly as an opened document does. - using Algorithm = pdf::PDFSecurityHandlerFactory::Algorithm; - QTemporaryDir directory; - QVERIFY(directory.isValid()); - const QString path = directory.filePath(QStringLiteral("restricted.pdf")); - { - pdf::PDFDocumentBuilder builder; - builder.appendPage(QRectF(0, 0, 200, 200)); - pdf::PDFSecurityHandlerFactory::SecuritySettings settings; - settings.algorithm = Algorithm::AES_256; - settings.permissions = 0; - settings.userPassword = QStringLiteral("fixture-user"); - settings.ownerPassword = QStringLiteral("fixture-owner"); - settings.id = builder.build().getIdPart(0); - builder.setSecurityHandler(pdf::PDFSecurityHandlerFactory::createSecurityHandler(settings)); - const pdf::PDFDocument document = builder.build(); - pdf::PDFDocumentWriter writer(nullptr); - QVERIFY(writer.write(path, &document, true)); - } - - pdf::PDFDocumentReader reader(nullptr, [](bool* accepted) - { - if (accepted) - { - *accepted = true; - } - return QStringLiteral("fixture-user"); - }, true, false); - pdf::PDFDocument restricted = reader.readFromFile(path); - QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); - - harness.loader.nextResult.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(std::move(restricted))); - harness.facade->open(QStringLiteral("/corpus/restricted.pdf")); - QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); - QVERIFY(harness.facade->permissions().encrypted); - QVERIFY(!harness.facade->permissions().modify); - - request.revision = harness.facade->currentRevision(); - const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); - const pdfinteraction::DocumentEditOutcome denied = harness.facade->applyAuthoringEdit(request); - QVERIFY(!denied.applied); - QCOMPARE(denied.typedError, QStringLiteral("edit/modify-permission-denied")); - QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); -} - -QTEST_GUILESS_MAIN(DocumentFacadeTest) - -#include "tst_documentfacadetest.moc" diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 7492bb40..de1f89a1 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -65,6 +65,8 @@ #include "pdfdocumentwriter.h" #include "pdfdocumentreader.h" #include "pdfpage.h" +#include "pdfencoding.h" +#include "pdfform.h" #include "quickoutput.h" #include #include @@ -350,6 +352,189 @@ QString describePreflightReportDifference(const QString& guiText, const QString& return differences.join(QLatin1Char('\n')); } +/// A page carrying a text field, a checkbox, a combo box and a signature field, +/// the shape the authoring-edit handlers are driven against. The signature field +/// is present so "signature fields are never filled" has something to exclude. +bool writeFormFixtureDocument(const QString& path) +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 200, 200)); + + const auto addFormXObject = [&builder](const QRectF& rect) + { + const QByteArray content = QByteArrayLiteral(" "); + pdf::PDFDictionary dictionary; + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("XObject")); + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Form")); + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("BBox"), + pdf::PDFObject::createArray(std::make_shared(std::vector{ + pdf::PDFObject::createReal(rect.left()), pdf::PDFObject::createReal(rect.top()), + pdf::PDFObject::createReal(rect.right()), pdf::PDFObject::createReal(rect.bottom()) }))); + // Streams carry their own /Length; the writer does not add one. + dictionary.addEntry(pdf::PDFInplaceOrMemoryString("Length"), pdf::PDFObject::createInteger(content.size())); + return builder.addObject(pdf::PDFObject::createStream( + std::make_shared(std::move(dictionary), QByteArray(content)))); + }; + + const auto addField = [&builder, &page, &addFormXObject](const QByteArray& fieldType, + const QString& name, + const QRectF& rect, + const std::function& decorate) + { + pdf::PDFDictionary field; + field.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("Annot")); + field.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Widget")); + field.addEntry(pdf::PDFInplaceOrMemoryString("FT"), pdf::PDFObject::createName(fieldType)); + field.addEntry(pdf::PDFInplaceOrMemoryString("T"), pdf::PDFObjectFactory::createTextString(name)); + field.addEntry(pdf::PDFInplaceOrMemoryString("Rect"), + pdf::PDFObject::createArray(std::make_shared(std::vector{ + pdf::PDFObject::createReal(rect.left()), pdf::PDFObject::createReal(rect.top()), + pdf::PDFObject::createReal(rect.right()), pdf::PDFObject::createReal(rect.bottom()) }))); + field.addEntry(pdf::PDFInplaceOrMemoryString("P"), pdf::PDFObject::createReference(page)); + if (decorate) + { + decorate(field); + } + const pdf::PDFObjectReference reference = + builder.addObject(pdf::PDFObject::createDictionary(std::make_shared(std::move(field)))); + + pdf::PDFDictionary pageAnnots; + pageAnnots.addEntry(pdf::PDFInplaceOrMemoryString("Annots"), + pdf::PDFObject::createArray(std::make_shared(std::vector{ + pdf::PDFObject::createReference(reference) }))); + builder.appendTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(pageAnnots)))); + return reference; + }; + + std::vector fields; + fields.push_back(addField("Tx", QStringLiteral("LoopText"), QRectF(10, 10, 90, 30), + [&addFormXObject](pdf::PDFDictionary& field) + { + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), + pdf::PDFObjectFactory::createTextString(QStringLiteral("original"))); + field.addEntry(pdf::PDFInplaceOrMemoryString("DA"), + pdf::PDFObject::createString(QByteArrayLiteral("/Helv 0 Tf 0 g"))); + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createReference(addFormXObject(QRectF(10, 10, 90, 30)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(appearance)))); + })); + fields.push_back(addField("Btn", QStringLiteral("LoopCheck"), QRectF(10, 50, 30, 30), + [&addFormXObject](pdf::PDFDictionary& field) + { + pdf::PDFDictionary normal; + normal.addEntry(pdf::PDFInplaceOrMemoryString("Yes"), + pdf::PDFObject::createReference(addFormXObject(QRectF(10, 50, 30, 30)))); + normal.addEntry(pdf::PDFInplaceOrMemoryString("Off"), + pdf::PDFObject::createReference(addFormXObject(QRectF(10, 50, 30, 30)))); + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(normal)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(appearance)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AS"), pdf::PDFObject::createName("Off")); + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), pdf::PDFObject::createName("Off")); + })); + fields.push_back(addField("Ch", QStringLiteral("LoopChoice"), QRectF(10, 90, 90, 30), + [&addFormXObject](pdf::PDFDictionary& field) + { + field.addEntry(pdf::PDFInplaceOrMemoryString("Ff"), pdf::PDFObject::createInteger(1 << 17)); + field.addEntry(pdf::PDFInplaceOrMemoryString("Opt"), + pdf::PDFObject::createArray(std::make_shared(std::vector{ + pdf::PDFObjectFactory::createTextString(QStringLiteral("Alpha")), + pdf::PDFObjectFactory::createTextString(QStringLiteral("Beta")) }))); + field.addEntry(pdf::PDFInplaceOrMemoryString("V"), + pdf::PDFObjectFactory::createTextString(QStringLiteral("Alpha"))); + pdf::PDFDictionary appearance; + appearance.addEntry(pdf::PDFInplaceOrMemoryString("N"), + pdf::PDFObject::createReference(addFormXObject(QRectF(10, 90, 90, 30)))); + field.addEntry(pdf::PDFInplaceOrMemoryString("AP"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(appearance)))); + })); + + const pdf::PDFObjectReference signature = builder.createSignatureDictionary( + QByteArrayLiteral("Adobe.PPKLite"), + QByteArrayLiteral("adbe.pkcs7.detached"), + QByteArrayLiteral("signed-placeholder"), + QDateTime::currentDateTimeUtc(), + 0); + const pdf::PDFObjectReference signatureField = builder.createFormFieldSignature(QStringLiteral("LoopSignature"), {}, signature); + builder.createInvisibleFormFieldWidget(signatureField, page); + fields.push_back(signatureField); + + builder.createAcroForm(fields); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + return static_cast(writer.write(path, &document, true)); +} + +/// An AcroForm whose only content is an XFA packet: no AcroForm fields at all, +/// which is the form the shell must report as unsupported. +bool writeXfaOnlyFixtureDocument(const QString& path) +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + + const QByteArray xfaPacket = QByteArrayLiteral(""); + pdf::PDFDictionary xfaDictionary; + xfaDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Length"), pdf::PDFObject::createInteger(xfaPacket.size())); + const pdf::PDFObjectReference xfa = builder.addObject( + pdf::PDFObject::createStream(std::make_shared(std::move(xfaDictionary), QByteArray(xfaPacket)))); + + pdf::PDFDictionary form; + form.addEntry(pdf::PDFInplaceOrMemoryString("Fields"), pdf::PDFObject::createArray(std::make_shared())); + form.addEntry(pdf::PDFInplaceOrMemoryString("XFA"), pdf::PDFObject::createReference(xfa)); + + pdf::PDFDictionary catalogForm; + catalogForm.addEntry(pdf::PDFInplaceOrMemoryString("AcroForm"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(form)))); + builder.mergeTo(builder.getCatalogReference(), + pdf::PDFObject::createDictionary(std::make_shared(std::move(catalogForm)))); + + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + return static_cast(writer.write(path, &document, true)); +} + +/// Reads one field's value from an open document, the way an operator would see +/// it. `isName` reports a name value (a button's state) rather than a string. +QString readFormFieldValue(const pdf::PDFDocument* document, const QString& name, bool* isName = nullptr) +{ + if (isName) + { + *isName = false; + } + if (!document) + { + return QString(); + } + + pdf::PDFFormManager manager(nullptr); + manager.setDocument(pdf::PDFModifiedDocument(const_cast(document), nullptr, pdf::PDFModifiedDocument::Reset)); + QString value; + manager.apply([&value, &name, isName](const pdf::PDFFormField* field) + { + if (!value.isEmpty() || field->getName(pdf::PDFFormField::NameType::FullyQualified) != name) + { + return; + } + const pdf::PDFObject& object = field->getValue(); + if (object.isName()) + { + if (isName) + { + *isName = true; + } + value = QString::fromLatin1(object.getString()); + } + else if (object.isString()) + { + value = pdf::PDFEncoding::convertTextString(object.getString()); + } }); + return value; +} + } // namespace class EditorHostTest : public QObject @@ -436,6 +621,14 @@ private slots: void hyperlinkCommandsWriteEveryDestinationVariant_data(); void hyperlinkCommandsWriteEveryDestinationVariant(); void restrictedDocumentCannotEditBookmarksOrLinks(); + // Direct authoring edits (ADR-014, issue #242): the catalog route into Core, + // the revision fence, the XFA refusal, the attachment path and the + // send-by-email hand-off. + void formValueEditRoutesThroughTheCatalogIntoCoreAndProducesANewRevision(); + void authoringEditRefusesAStaleRevisionAndChangesNothing(); + void xfaOnlyFormIsReportedAsUnsupportedThroughTheShell(); + void attachmentEditsAndExtractionRouteThroughTheShell(); + void sendByEMailHandsOverOnlyThePublishedRevision(); }; void EditorHostTest::init() @@ -3936,6 +4129,12 @@ void EditorHostTest::bookmarkCommandsWriteTheOutlineThroughTheCatalog() QVERIFY(directory.isValid()); const QString path = writePagesFixture(directory.filePath(QStringLiteral("bookmarks.pdf")), 3); QVERIFY(!path.isEmpty()); +void EditorHostTest::formValueEditRoutesThroughTheCatalogIntoCoreAndProducesANewRevision() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("form.pdf")); + QVERIFY(writeFormFixtureDocument(path)); EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); @@ -4171,6 +4370,83 @@ void EditorHostTest::hyperlinkCommandsWriteEveryDestinationVariant() QVERIFY(directory.isValid()); const QString path = writePagesFixture(directory.filePath(QStringLiteral("hyperlinks.pdf")), 2); QVERIFY(!path.isEmpty()); + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + const pdf::PDFRevisionIdentity before = session->facade().currentRevision(); + QVERIFY(before.isValid()); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionSetFormFieldValue"))); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionEditDocumentMetadata"))); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionAddAttachment"))); + + auto* model = qobject_cast(host.documentModel()); + QVERIFY(model); + QVERIFY(model->hasForm()); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("original")); + + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + + // Text value: the catalog route into Core, one new revision, one dirty document. + QVariantMap parameters; + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); + parameters.insert(QStringLiteral("value"), QStringLiteral("typed in the shell")); + parameters.insert(QStringLiteral("revision"), before.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 1); + const pdfinteraction::CommandResult textResult = finished.last().at(0).value(); + QCOMPARE(textResult.state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(textResult.typedError, QString()); + + const pdf::PDFRevisionIdentity afterText = session->facade().currentRevision(); + QVERIFY(afterText != before); + QCOMPARE(session->facade().state(), pdfinteraction::DocumentState::Ready); + QVERIFY(session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(host.documentShellStatus(), QStringLiteral("MODIFIED")); + QCOMPARE(model->revision(), afterText.toString()); + QVERIFY(model->modified()); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("typed in the shell")); + + // Checkbox: the same route, and the value lands as the widget's on-state name. + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopCheck")); + parameters.insert(QStringLiteral("checked"), true); + parameters.insert(QStringLiteral("revision"), afterText.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 2); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + bool isName = false; + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopCheck"), &isName), QStringLiteral("Yes")); + QVERIFY(isName); + + // Metadata: the same gateway, and the properties the model presents follow. + parameters.clear(); + parameters.insert(QStringLiteral("title"), QStringLiteral("Shell title")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionEditDocumentMetadata"), parameters) != 0); + QCOMPARE(finished.count(), 3); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(model->title(), QStringLiteral("Shell title")); + QCOMPARE(session->context().getDocument()->getInfo()->title, QStringLiteral("Shell title")); + + // A signature field is refused, and a refusal changes nothing at all. + const pdf::PDFRevisionIdentity beforeRefusal = session->facade().currentRevision(); + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopSignature")); + parameters.insert(QStringLiteral("value"), QStringLiteral("not a signature")); + parameters.insert(QStringLiteral("revision"), beforeRefusal.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 4); + const pdfinteraction::CommandResult refused = finished.last().at(0).value(); + QCOMPARE(refused.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(refused.typedError, QStringLiteral("form/unsupported-field-type")); + QCOMPARE(session->facade().currentRevision(), beforeRefusal); +} + +void EditorHostTest::authoringEditRefusesAStaleRevisionAndChangesNothing() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("stale-form.pdf")); + QVERIFY(writeFormFixtureDocument(path)); EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); @@ -4279,6 +4555,261 @@ void EditorHostTest::restrictedDocumentCannotEditBookmarksOrLinks() QCOMPARE(host.invokeCommand(QStringLiteral("actionCreateHyperlink"), { { QStringLiteral("uri"), QStringLiteral("https://example.org") } }), quint64(0)); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + const pdf::PDFRevisionIdentity first = session->facade().currentRevision(); + QVERIFY(first.isValid()); + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + + QVariantMap parameters; + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); + parameters.insert(QStringLiteral("value"), QStringLiteral("the first edit")); + parameters.insert(QStringLiteral("revision"), first.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 1); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + + const pdf::PDFRevisionIdentity current = session->facade().currentRevision(); + QVERIFY(current != first); + + // The edit begun against the superseded revision is refused by the fence. + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); + parameters.insert(QStringLiteral("value"), QStringLiteral("stale edit")); + parameters.insert(QStringLiteral("revision"), first.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 2); + const pdfinteraction::CommandResult stale = finished.last().at(0).value(); + QCOMPARE(stale.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(stale.typedError, QStringLiteral("document/stale-revision")); + QCOMPARE(session->facade().currentRevision(), current); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("the first edit")); + + // An edit naming no revision at all never reaches the fence: the catalog + // refuses the missing declared parameter, and the refusal is reported. + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); + parameters.insert(QStringLiteral("value"), QStringLiteral("unfenced edit")); + QCOMPARE(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters), quint64(0)); + QCOMPARE(finished.count(), 3); + const pdfinteraction::CommandResult unfenced = finished.last().at(0).value(); + QCOMPARE(unfenced.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(unfenced.typedError, QStringLiteral("command/missing-parameter")); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("the first edit")); +} + +void EditorHostTest::xfaOnlyFormIsReportedAsUnsupportedThroughTheShell() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("xfa-form.pdf")); + QVERIFY(writeXfaOnlyFixtureDocument(path)); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + const pdf::PDFRevisionIdentity before = session->facade().currentRevision(); + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + + QVariantMap parameters; + parameters.insert(QStringLiteral("field"), QStringLiteral("AnyField")); + parameters.insert(QStringLiteral("value"), QStringLiteral("value")); + parameters.insert(QStringLiteral("revision"), before.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 1); + const pdfinteraction::CommandResult refused = finished.last().at(0).value(); + QCOMPARE(refused.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(refused.typedError, QStringLiteral("form/xfa-unsupported")); + QCOMPARE(session->facade().currentRevision(), before); + QVERIFY(!session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(host.documentShellStatus(), QStringLiteral("OPEN")); +} + +void EditorHostTest::attachmentEditsAndExtractionRouteThroughTheShell() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QTemporaryDir extractionDirectory; + QVERIFY(extractionDirectory.isValid()); + + const QString path = directory.filePath(QStringLiteral("plain.pdf")); + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + const QString textSource = directory.filePath(QStringLiteral("notes.txt")); + { + QFile source(textSource); + QVERIFY(source.open(QIODevice::WriteOnly)); + QCOMPARE(source.write(QByteArrayLiteral("attachment payload")), qint64(18)); + } + const QString executableSource = directory.filePath(QStringLiteral("setup.exe")); + { + QFile source(executableSource); + QVERIFY(source.open(QIODevice::WriteOnly)); + source.write(QByteArrayLiteral("MZ not really an executable")); + } + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + auto* model = qobject_cast(host.documentModel()); + QVERIFY(model); + QVERIFY(!model->hasAttachments()); + + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + QVariantMap parameters; + parameters.insert(QStringLiteral("name"), QStringLiteral("notes.txt")); + parameters.insert(QStringLiteral("path"), textSource); + parameters.insert(QStringLiteral("description"), QStringLiteral("Reviewer notes")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionAddAttachment"), parameters) != 0); + QCOMPARE(finished.count(), 1); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY(model->hasAttachments()); + QVERIFY(session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + + // Extraction writes through the atomic writer; a text file is not refused. + QVERIFY(host.extractAttachmentFileUrl(QUrl::fromLocalFile(extractionDirectory.path()), QStringLiteral("notes.txt"))); + const QVariantMap extraction = host.attachmentExtraction(); + QCOMPARE(extraction.value(QStringLiteral("status")).toString(), QStringLiteral("written")); + QCOMPARE(extraction.value(QStringLiteral("fileName")).toString(), QStringLiteral("notes.txt")); + QCOMPARE(extraction.value(QStringLiteral("autoOpenRefused")).toBool(), false); + QCOMPARE(extraction.value(QStringLiteral("typedError")).toString(), QString()); + { + QFile extracted(extractionDirectory.filePath(QStringLiteral("notes.txt"))); + QVERIFY(extracted.open(QIODevice::ReadOnly)); + QCOMPARE(extracted.readAll(), QByteArrayLiteral("attachment payload")); + } + + // An executable-type attachment is extracted, and must not be auto-opened. + parameters.clear(); + parameters.insert(QStringLiteral("name"), QStringLiteral("setup.exe")); + parameters.insert(QStringLiteral("path"), executableSource); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionAddAttachment"), parameters) != 0); + QCOMPARE(finished.count(), 2); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY(host.extractAttachmentFileUrl(QUrl::fromLocalFile(extractionDirectory.path()), QStringLiteral("setup.exe"))); + QCOMPARE(host.attachmentExtraction().value(QStringLiteral("status")).toString(), QStringLiteral("written")); + QCOMPARE(host.attachmentExtraction().value(QStringLiteral("autoOpenRefused")).toBool(), true); + + // Removal unlists the attachment, and the model follows both ways. + parameters.clear(); + parameters.insert(QStringLiteral("name"), QStringLiteral("notes.txt")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionRemoveAttachment"), parameters) != 0); + QCOMPARE(finished.count(), 3); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY(model->hasAttachments()); + + parameters.clear(); + parameters.insert(QStringLiteral("name"), QStringLiteral("setup.exe")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionRemoveAttachment"), parameters) != 0); + QCOMPARE(finished.count(), 4); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY(!model->hasAttachments()); + + // Removing what is no longer there is a typed refusal, not a success. + parameters.clear(); + parameters.insert(QStringLiteral("name"), QStringLiteral("setup.exe")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionRemoveAttachment"), parameters) != 0); + QCOMPARE(finished.count(), 5); + QCOMPARE(finished.last().at(0).value().typedError, QStringLiteral("attachment/not-found")); + + // Extraction of an unknown name is refused with the Core writer's code. + QVERIFY(!host.extractAttachmentFileUrl(QUrl::fromLocalFile(extractionDirectory.path()), QStringLiteral("absent.txt"))); + QCOMPARE(host.attachmentExtraction().value(QStringLiteral("status")).toString(), QStringLiteral("refused")); + QCOMPARE(host.attachmentExtraction().value(QStringLiteral("typedError")).toString(), QStringLiteral("attachment/not-found")); +} + +void EditorHostTest::sendByEMailHandsOverOnlyThePublishedRevision() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("mail.pdf")); + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionSend_by_E_Mail"))); + + QSignalSpy mailSpy(&host, &EditorHost::documentMailRequested); + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + const pdf::PDFRevisionIdentity published = session->facade().currentRevision(); + + // The save command finishes its own invocation too, so results are read by + // command id rather than by position. + const auto lastResultFor = [&finished](const QString& commandId) + { + for (int index = finished.count() - 1; index >= 0; --index) + { + const pdfinteraction::CommandResult result = finished.at(index).at(0).value(); + if (result.command == commandId) + { + return result; + } + } + return pdfinteraction::CommandResult(); + }; + + QVERIFY(host.invokeCommand(QStringLiteral("actionSend_by_E_Mail")) != 0); + QCOMPARE(lastResultFor(QStringLiteral("actionSend_by_E_Mail")).state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(mailSpy.count(), 1); + QCOMPARE(mailSpy.last().at(0).toString(), path); + QCOMPARE(mailSpy.last().at(1).toString(), published.toString()); + + // An unsaved edit makes the file on disk a different revision than the one + // on screen, so the hand-off is refused rather than mailed as current. + QVariantMap parameters; + parameters.insert(QStringLiteral("title"), QStringLiteral("Unsaved title")); + parameters.insert(QStringLiteral("revision"), published.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionEditDocumentMetadata"), parameters) != 0); + QCOMPARE(lastResultFor(QStringLiteral("actionEditDocumentMetadata")).state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(host.documentShellStatus(), QStringLiteral("MODIFIED")); + + QVERIFY(host.invokeCommand(QStringLiteral("actionSend_by_E_Mail")) != 0); + const pdfinteraction::CommandResult refused = lastResultFor(QStringLiteral("actionSend_by_E_Mail")); + QCOMPARE(refused.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(refused.typedError, QStringLiteral("mail/unsaved-changes")); + QCOMPARE(mailSpy.count(), 1); + + // Saving publishes the revision the shell was showing, and the hand-off + // works again with the saved file and that same revision. + const QString savedPath = directory.filePath(QStringLiteral("mail-saved.pdf")); + const pdf::PDFRevisionIdentity edited = session->facade().currentRevision(); + host.saveAsFileUrl(QUrl::fromLocalFile(savedPath)); + QTRY_COMPARE_WITH_TIMEOUT(host.documentShellStatus(), QStringLiteral("OUTPUT_SAVED"), 15000); + + QVERIFY(host.invokeCommand(QStringLiteral("actionSend_by_E_Mail")) != 0); + QCOMPARE(lastResultFor(QStringLiteral("actionSend_by_E_Mail")).state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(mailSpy.count(), 2); + QCOMPARE(mailSpy.last().at(0).toString(), savedPath); + QCOMPARE(mailSpy.last().at(1).toString(), edited.toString()); + QCOMPARE(session->facade().currentRevision(), edited); } QTEST_GUILESS_MAIN(EditorHostTest) diff --git a/agent-policy.json b/agent-policy.json index c405bd31..d84949ca 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -88,6 +88,7 @@ "UnitTests/tst_budgetcorpustest.cpp", "UnitTests/tst_budgetexhaustiontest.cpp", "UnitTests/tst_diagnosticstest.cpp", + "UnitTests/tst_directedittest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_documentsearchtest.cpp", "UnitTests/tst_filenamesanitizertest.cpp", @@ -127,6 +128,7 @@ "UnitTestsContentProcessorLimits", "UnitTestsConversionOracle", "UnitTestsDiagnostics", + "UnitTestsDirectEdit", "UnitTestsDocumentSession", "UnitTestsDocumentSearch", "UnitTestsEvidenceGraph", diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index dffe911d..04041548 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -414,6 +414,7 @@ "UnitTestsContentProcessorLimits", "UnitTestsConversionOracle", "UnitTestsDiagnostics", + "UnitTestsDirectEdit", "UnitTestsDocumentFacade", "UnitTestsDocumentSearch", "UnitTestsDocumentSession", diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index 653384c6..2d10394d 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": 109, + "expected_action_count": 113, "actions": [ { "id": "actionAbout", @@ -2328,9 +2328,9 @@ "command": { "label_key": "command.actionSend_by_E_Mail.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -2695,6 +2695,142 @@ "availability": "implemented" }, "menu_group": "File" + }, + { + "id": "actionSetFormFieldValue", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionSetFormFieldValue.label", + "parameters": [ + { + "name": "field", + "type": "string", + "required": true + }, + { + "name": "value", + "type": "string", + "required": false + }, + { + "name": "checked", + "type": "boolean", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": true + } + ], + "capability": "document.modify", + "cancellable": false, + "availability": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionAddAttachment", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionAddAttachment.label", + "parameters": [ + { + "name": "name", + "type": "string", + "required": true + }, + { + "name": "path", + "type": "string", + "required": true + }, + { + "name": "description", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": true + } + ], + "capability": "document.modify", + "cancellable": false, + "availability": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionRemoveAttachment", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionRemoveAttachment.label", + "parameters": [ + { + "name": "name", + "type": "string", + "required": true + }, + { + "name": "revision", + "type": "string", + "required": true + } + ], + "capability": "document.modify", + "cancellable": false, + "availability": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionEditDocumentMetadata", + "disposition": "KEEP", + "target": "Document", + "command": { + "label_key": "command.actionEditDocumentMetadata.label", + "parameters": [ + { + "name": "title", + "type": "string", + "required": false + }, + { + "name": "author", + "type": "string", + "required": false + }, + { + "name": "subject", + "type": "string", + "required": false + }, + { + "name": "keywords", + "type": "string", + "required": false + }, + { + "name": "creator", + "type": "string", + "required": false + }, + { + "name": "revision", + "type": "string", + "required": true + } + ], + "capability": "document.modify", + "cancellable": false, + "availability": "implemented" + }, + "menu_group": "Document" } ] } diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index e4186e68..aaf79840 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -28,7 +28,6 @@ "actionRemoveExternalLinks", "actionRender_to_Images", "actionSanitize", - "actionSend_by_E_Mail", "actionStickyNoteComment", "actionStickyNoteHelp", "actionStickyNoteInsert", diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index 3ffd3988..d05b7110 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -150,6 +150,11 @@ "actionStickyNoteNewParagraph", "actionStickyNoteNote", "actionStickyNoteParagraph", + "actionSetFormFieldValue", + "actionAddAttachment", + "actionRemoveAttachment", + "actionEditDocumentMetadata", + "actionSend_by_E_Mail", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml index 6af44814..3ed992db 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/MenuModel.qml @@ -85,6 +85,7 @@ QtObject { } switch (entry.id) { case "actionAbout": return qsTr("About") + case "actionAddAttachment": return qsTr("Add Attachment") case "actionAutomaticDocumentRefresh": return qsTr("Automatic Document Refresh") case "actionBecomeASponsor": return qsTr("Become ASponsor") case "actionBleedFixup": return qsTr("Bleed Fixup") @@ -124,6 +125,7 @@ QtObject { case "actionCreateTextStrikeout": return qsTr("Create Text Strikeout") case "actionCreateTextUnderline": return qsTr("Create Text Underline") case "actionDeselectText": return qsTr("Deselect Text") + case "actionEditDocumentMetadata": return qsTr("Edit Document Metadata") case "actionEncryption": return qsTr("Encryption") case "actionExtractImage": return qsTr("Extract Image") case "actionFind": return qsTr("Find") @@ -161,6 +163,7 @@ QtObject { case "actionQuit": return qsTr("Quit") case "actionRedo": return qsTr("Redo") case "actionRemoveExternalLinks": return qsTr("Remove External Links") + case "actionRemoveAttachment": return qsTr("Remove Attachment") case "actionRender_to_Images": return qsTr("Render to Images") case "actionRendering_Errors": return qsTr("Rendering Errors") case "actionRenderOptionAntialiasing": return qsTr("Render Option Antialiasing") @@ -180,6 +183,7 @@ QtObject { case "actionSelectText": return qsTr("Select Text") case "actionSelectTextAll": return qsTr("Select Text All") case "actionSend_by_E_Mail": return qsTr("Send by E Mail") + case "actionSetFormFieldValue": return qsTr("Set Form Field Value") case "actionShow_Text_Blocks": return qsTr("Show Text Blocks") case "actionShow_Text_Lines": return qsTr("Show Text Lines") case "actionStickyNoteComment": return qsTr("Sticky Note Comment") From 1c545749e341edd78b5a344226ac517b20f54fa0 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:15:29 -0700 Subject: [PATCH 06/11] chore(quick): integrate authoring slices, reconcile counts and ratchet (#231 PR 2) Cherry-picks the three slice commits and repairs the conflict regions with complete blocks from each slice: one facade gateway per family (unified in follow-up), the 39 implemented ids, expected_action_count 109 -> 113, the handler ratchet re-recorded to 13, and the PR fragment plus evidence manifest. --- LoopEditor/editorhost.cpp | 125 +- LoopLibInteraction/sources/documentfacade.cpp | 2 + UnitTests/tst_documentfacadetest.cpp | 1485 ++++++++++++++++- UnitTests/tst_editorhosttest.cpp | 179 +- changes/gh-231-quick-mutations.evidence.yaml | 32 + changes/gh-231-quick-mutations.md | 4 + docs/generated/architecture-catalog.json | 1 + docs/shell-handler-coverage.json | 20 +- 8 files changed, 1687 insertions(+), 161 deletions(-) create mode 100644 changes/gh-231-quick-mutations.evidence.yaml create mode 100644 changes/gh-231-quick-mutations.md diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index e1766d5d..58191ad8 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -4550,6 +4550,7 @@ bool EditorHost::commandPermissionAllowed(const QString& commandId) const { return permissions.modifyAnnotations; } + } if (commandId == QLatin1String("actionSetFormFieldValue")) { return m_documentModel.canFillForms(); @@ -5209,39 +5210,6 @@ void EditorHost::registerFeatureHandlers() QString typedError; const std::optional edit = build(parameters, typedError); runAuthoringEdit(invocation, edit, typedError, parameters); - // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ - // Form values, attachments and metadata are mutations through this catalog - // into Core: each handler maps its declared scalars into the facade's typed - // direct-edit request, and the facade checks the revision fence and the - // document's permission before any writer runs. A refusal finishes Failed - // with the facade's own stable code, so a stale, unauthorized, XFA or - // unsupported edit is reported rather than silently dropped. - auto bindAuthoringEdit = [this](const QString& id, pdfinteraction::DocumentDirectEditRequest::Kind kind) - { - pdfinteraction::CommandCatalog::Handler handler; - handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) - { - const pdfinteraction::DocumentDirectEditResult result = applyAuthoringEdit(kind, parameters); - m_session->catalog().finishInvocation(invocation, - result.applied ? pdfinteraction::CommandTerminalState::Completed - : pdfinteraction::CommandTerminalState::Failed, - result.applied ? QString() : result.typedError); - if (result.applied) - { - announceDocumentState(tr("The document was edited. Save to publish the new revision.")); - // The document was replaced in place, so the surfaces the new - // revision needs are requested now rather than at the next - // viewport change. - if (m_documentBound) - { - m_session->surfaces()->requestSurfaces(); - } - } - else - { - announceDocumentState(tr("The edit was refused: %1").arg(result.typedError)); - } - bumpPresentation(); }; m_session->catalog().setHandler(id, std::move(handler)); }; @@ -5452,6 +5420,9 @@ void EditorHost::registerFeatureHandlers() bumpPresentation(); }; m_session->catalog().setHandler(id, std::move(navigation)); + } + + // ---- Annotation authoring (issue #234) --------------------------------- // One handler per declared annotation command. The handler parses geometry // at this boundary, submits the typed request to the facade's direct-edit @@ -5498,10 +5469,49 @@ void EditorHost::registerFeatureHandlers() }; m_session->catalog().setHandler(entry.first, std::move(authoring)); } - bindAuthoringEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DocumentDirectEditRequest::Kind::SetFormFieldValue); - bindAuthoringEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment); - bindAuthoringEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::RemoveAttachment); - bindAuthoringEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DocumentDirectEditRequest::Kind::EditMetadata); + + + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // Form values, attachments and metadata are mutations through this catalog + // into Core: each handler maps its declared scalars into the facade's typed + // direct-edit request, and the facade checks the revision fence and the + // document's permission before any writer runs. A refusal finishes Failed + // with the facade's own stable code, so a stale, unauthorized, XFA or + // unsupported edit is reported rather than silently dropped. + auto bindDocumentDirectEdit = [this](const QString& id, pdfinteraction::DocumentDirectEditRequest::Kind kind) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const pdfinteraction::DocumentDirectEditResult result = applyAuthoringEdit(kind, parameters); + m_session->catalog().finishInvocation(invocation, + result.applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + result.applied ? QString() : result.typedError); + if (result.applied) + { + announceDocumentState(tr("The document was edited. Save to publish the new revision.")); + // The document was replaced in place, so the surfaces the new + // revision needs are requested now rather than at the next + // viewport change. + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(result.typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindDocumentDirectEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DocumentDirectEditRequest::Kind::SetFormFieldValue); + bindDocumentDirectEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment); + bindDocumentDirectEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::RemoveAttachment); + bindDocumentDirectEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DocumentDirectEditRequest::Kind::EditMetadata); // Send by e-mail hands the current published revision to the mail client and // writes nothing. An unsaved document is refused with a typed reason: the @@ -5700,6 +5710,28 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionPrint"), ready && !outputBusy() && m_documentModel.canPrint()); availability.insert(QStringLiteral("actionExportDocument"), ready && !outputBusy() && m_documentModel.canCopy()); + // Annotation authoring (issue #234): available with a document whose + // modify-annotations permission is granted, so a restricted document cannot + // offer an edit the gateway would refuse. The ids come from the one table + // the handlers were registered from. + const bool annotationAuthoring = ready && m_session->facade().permissions().modifyAnnotations; + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + availability.insert(entry.first, annotationAuthoring); + } + + // Direct authoring edits (ADR-014, issue #242). Each is enabled exactly when + // the open document grants the permission its family needs, so an + // unauthorized edit is unreachable rather than refused after the fact. + availability.insert(QStringLiteral("actionSetFormFieldValue"), ready && m_documentModel.canFillForms()); + availability.insert(QStringLiteral("actionAddAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionRemoveAttachment"), ready && m_documentModel.canModify()); + availability.insert(QStringLiteral("actionEditDocumentMetadata"), ready && m_documentModel.canModify()); + // Send by e-mail stays enabled with a document even when it has unsaved + // changes, so its typed refusal is reachable and visible rather than the + // command silently disappearing from the menu. + availability.insert(QStringLiteral("actionSend_by_E_Mail"), ready && !outputBusy()); + // Bookmarks and hyperlinks (#233). The edits are document mutations, so they // follow the document's modify permission; navigation needs an outline to // move through, and export follows the same copy permission the document @@ -5725,26 +5757,7 @@ void EditorHost::refreshFeatureAvailability() for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) { availability.insert(id, ready && m_documentModel.hasOutline()); - // Annotation authoring (issue #234): available with a document whose - // modify-annotations permission is granted, so a restricted document cannot - // offer an edit the gateway would refuse. The ids come from the one table - // the handlers were registered from. - const bool annotationAuthoring = ready && m_session->facade().permissions().modifyAnnotations; - for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) - { - availability.insert(entry.first, annotationAuthoring); } - // Direct authoring edits (ADR-014, issue #242). Each is enabled exactly when - // the open document grants the permission its family needs, so an - // unauthorized edit is unreachable rather than refused after the fact. - availability.insert(QStringLiteral("actionSetFormFieldValue"), ready && m_documentModel.canFillForms()); - availability.insert(QStringLiteral("actionAddAttachment"), ready && m_documentModel.canModify()); - availability.insert(QStringLiteral("actionRemoveAttachment"), ready && m_documentModel.canModify()); - availability.insert(QStringLiteral("actionEditDocumentMetadata"), ready && m_documentModel.canModify()); - // Send by e-mail stays enabled with a document even when it has unsaved - // changes, so its typed refusal is reachable and visible rather than the - // command silently disappearing from the menu. - availability.insert(QStringLiteral("actionSend_by_E_Mail"), ready && !outputBusy()); m_session->catalog().setEnabledBatch(availability); } diff --git a/LoopLibInteraction/sources/documentfacade.cpp b/LoopLibInteraction/sources/documentfacade.cpp index 24a44bf3..1357eaa0 100644 --- a/LoopLibInteraction/sources/documentfacade.cpp +++ b/LoopLibInteraction/sources/documentfacade.cpp @@ -334,6 +334,8 @@ DocumentEditOutcome DocumentFacade::applyAuthoringEdit(const DocumentEditRequest outcome.affectedCount = result.affectedCount; outcome.revision = m_revisionSource.currentRevision(); return outcome; +} + DocumentDirectEditResult DocumentFacade::applyDirectEdit(const DocumentDirectEditRequest& request) { DocumentDirectEditResult result; diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index 3832f4e6..f0bd897f 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -449,7 +449,1486 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() } // The display colour modes (#235), the select/copy/capture commands (#232), // the measure commands (#238), the shell settings/diagnostics surfaces - // (#239), the bookmark/hyperlink commands (#233), the annotation authoring - // family (#234) and the form/attachment/metadata commands (#242) joined the - // implemented set in epic #231. + // (#239) and the bookmark/hyperlink commands (#233) joined the implemented + // set in epic #231. QCOMPARE(implemented, 94); +} + +void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() +{ + pdfinteraction::CommandCatalog catalog; + QVERIFY2(catalog.isLoaded(), qPrintable(catalog.loadError())); + QSignalSpy changed(&catalog, &pdfinteraction::CommandCatalog::availabilityChanged); + + catalog.setEnabledBatch({ + { pdfinteraction::DocumentFacade::OpenCommandId, true }, + { pdfinteraction::DocumentFacade::CloseCommandId, true }, + { QStringLiteral("not-a-command"), true }, + }); + + QCOMPARE(changed.count(), 1); + QVERIFY(catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); + QVERIFY(catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); + QVERIFY(!catalog.isEnabled(QStringLiteral("not-a-command"))); + + catalog.setEnabledBatch({ + { pdfinteraction::DocumentFacade::OpenCommandId, true }, + { pdfinteraction::DocumentFacade::CloseCommandId, true }, + }); + QCOMPARE(changed.count(), 1); +} + +void DocumentFacadeTest::catalogRejectsAnUnknownCommand() +{ + Harness harness; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + QVERIFY(finished.isValid()); + + const pdfinteraction::CommandInvocationId invocation = + harness.catalog.invoke(QStringLiteral("actionDefinitelyNotAThing")); + + // Not a silent no-op: the contract is the whole ID space, so an unknown ID + // is reported as a routing bug. + QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); + QCOMPARE(finished.count(), 1); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Unavailable); + QCOMPARE(result.typedError, QStringLiteral("command/unknown")); + QVERIFY(!result.isSuccess()); + QVERIFY(harness.catalog.descriptor(QStringLiteral("actionDefinitelyNotAThing")) == nullptr); +} + +void DocumentFacadeTest::declaredCommandIsNotImplementedAndRunsNothing() +{ + Harness harness; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + + const pdfinteraction::CommandInvocationId invocation = + harness.catalog.invoke(QStringLiteral("actionSanitize")); + + QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); + QCOMPARE(finished.count(), 1); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::NotImplemented); + QCOMPARE(result.typedError, QStringLiteral("command/not-implemented")); + + // Declared means "has a descriptor", never "quietly did something". + QCOMPARE(harness.submitter.submittedSpecs.size(), 0); + QCOMPARE(harness.loader.loadCount, 0); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); +} + +void DocumentFacadeTest::catalogRefusesAHandlerForADeclaredCommand() +{ + pdfinteraction::CommandCatalog catalog; + + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [](pdfinteraction::CommandInvocationId, const QVariantMap&) {}; + + QVERIFY(!catalog.setHandler(QStringLiteral("actionSanitize"), handler)); + QVERIFY(!catalog.setHandler(QStringLiteral("actionNotInTheContract"), handler)); + QVERIFY(catalog.setHandler(pdfinteraction::DocumentFacade::OpenCommandId, handler)); +} + +void DocumentFacadeTest::missingRequiredParameterIsRejected() +{ + Harness harness; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + + const pdfinteraction::CommandInvocationId invocation = + harness.catalog.invoke(pdfinteraction::DocumentFacade::OpenCommandId); + + QCOMPARE(invocation, pdfinteraction::InvalidCommandInvocation); + QCOMPARE(finished.count(), 1); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(result.typedError, QStringLiteral("command/missing-parameter")); + QCOMPARE(harness.loader.loadCount, 0); +} + +void DocumentFacadeTest::unknownParameterIsRejected() +{ + Harness harness; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + + QVariantMap parameters; + parameters.insert(QStringLiteral("path"), QStringLiteral("/tmp/a.pdf")); + parameters.insert(QStringLiteral("pathh"), QStringLiteral("/tmp/b.pdf")); + + // A misspelled parameter must not run the command with a default the caller + // never asked for. + QCOMPARE(harness.catalog.invoke(pdfinteraction::DocumentFacade::OpenCommandId, parameters), + pdfinteraction::InvalidCommandInvocation); + QCOMPARE(finished.count(), 1); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.typedError, QStringLiteral("command/unknown-parameter")); + QCOMPARE(harness.loader.loadCount, 0); +} + +void DocumentFacadeTest::disabledCommandIsUnavailable() +{ + Harness harness; + + // Nothing is open, so closing and saving are not offered. + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); + QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); + QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + QCOMPARE(harness.facade->close(), pdfinteraction::InvalidCommandInvocation); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Unavailable); + QCOMPARE(result.typedError, QStringLiteral("command/unavailable")); +} + +void DocumentFacadeTest::openAdmitsDocumentAndPublishesRevision() +{ + Harness harness; + QSignalSpy stateChanged(harness.facade.get(), &pdfinteraction::DocumentFacade::stateChanged); + QSignalSpy replaced(harness.facade.get(), &pdfinteraction::DocumentFacade::documentReplaced); + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + + const pdfinteraction::CommandInvocationId invocation = + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); + + // Admission is queued, never delivered while the submitting call is still on + // the stack. + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + QCOMPARE(harness.loader.loadCount, 1); + QCOMPARE(harness.loader.requestedPaths.first(), QStringLiteral("/corpus/report.pdf")); + QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("report.pdf")); + QVERIFY(harness.context.getDocument() != nullptr); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Open); + QCOMPARE(harness.facade->rejectedCompletionCount(), 0); + + // The one revision fence is published under the one document key. + const QString documentKey = harness.context.getDocumentIdentity().documentId; + QVERIFY(!documentKey.isEmpty()); + QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); + + // The load job carries no document key: there is no revision to fence it + // against before the document exists. + QCOMPARE(harness.submitter.submittedSpecs.size(), 1); + QVERIFY(harness.submitter.submittedSpecs.first().documentKey.isEmpty()); + QCOMPARE(harness.submitter.submittedSpecs.first().priority, pdf::PDFJobPriority::Operator); + QCOMPARE(harness.submitter.submittedSpecs.first().staleResultPolicy, + pdf::PDFJobStaleResultPolicy::Discard); + + QCOMPARE(replaced.count(), 1); + QVERIFY(stateChanged.count() >= 2); + QCOMPARE(finished.count(), 1); + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.invocation, invocation); + QVERIFY(result.isSuccess()); + + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); +} + +void DocumentFacadeTest::workerLoadCannotPublishAfterSchedulerFailure() +{ + Harness harness; + harness.submitter.terminalStatus = pdf::PDFJobStatus::Failed; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); + QCOMPARE(harness.context.getDocument(), nullptr); + QCOMPARE(harness.facade->typedError(), QStringLiteral("document/job-not-admitted")); +} + +void DocumentFacadeTest::openFailureReportsTypedErrorAndBindsNoDocument() +{ + Harness harness; + harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; + harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + harness.facade->open(QStringLiteral("/corpus/broken.pdf")); + + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); + QCOMPARE(harness.facade->typedError(), QStringLiteral("document/read-failed")); + QCOMPARE(harness.context.getDocument(), nullptr); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::NoDocument); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); + QVERIFY(!result.isSuccess()); + + QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::OpenCommandId)); +} + +void DocumentFacadeTest::openCancellationIsTerminalAndNotSuccess() +{ + Harness harness; + harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Cancelled; + harness.loader.nextResult.typedError = QStringLiteral("document/cancelled"); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); + QCOMPARE(harness.context.getDocument(), nullptr); + + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); + + // Cancellation is terminal and it is not success. + QVERIFY(!result.isSuccess()); + QVERIFY(!harness.catalog.isPending(result.invocation)); +} + +void DocumentFacadeTest::cancellingAQueuedOpenIsTerminal() +{ + Harness harness; + harness.submitter.runInline = false; + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const pdfinteraction::CommandInvocationId invocation = + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); + QCOMPARE(harness.submitter.deferredJobCount(), 1); + + QVERIFY(harness.facade->cancelPendingOperation()); + + // Work cancelled out of the queue never runs, so nothing would report a + // terminal state for it. The facade must resolve that itself rather than + // leaving the invocation pending forever. + QTRY_VERIFY(!harness.catalog.isPending(invocation)); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); + QCOMPARE(harness.loader.loadCount, 0); + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); + + QCOMPARE(finished.count(), 1); + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); + QCOMPARE(harness.facade->rejectedCompletionCount(), 0); +} + +void DocumentFacadeTest::cancellationRejectsAlreadyQueuedSuccess() +{ + Harness harness; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const auto invocation = harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QCOMPARE(harness.loader.loadCount, 1); + QVERIFY(harness.facade->cancelPendingOperation()); + QVERIFY(!harness.catalog.isPending(invocation)); + QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Cancelled); + + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); + QCOMPARE(harness.context.getDocument(), nullptr); + QCOMPARE(finished.count(), 1); + QVERIFY(!harness.facade->cancelPendingOperation()); +} + +void DocumentFacadeTest::failedOpenRetryPreservesCauseAndAllocatesIdentity() +{ + Harness harness; + harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; + harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); + QSignalSpy changed(harness.facade.get(), &pdfinteraction::DocumentFacade::operationChanged); + const auto first = harness.facade->open(QStringLiteral("/corpus/broken.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Error); + const auto failure = harness.facade->operation(); + QVERIFY(!failure.pending); + QCOMPARE(failure.result.invocation, first); + QCOMPARE(failure.result.typedError, QStringLiteral("document/read-failed")); + + harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Loaded; + harness.loader.nextResult.typedError.clear(); + const auto retried = harness.facade->retry(); + QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); + QVERIFY(retried != first); + const auto request = harness.facade->operation(); + QVERIFY(request.pending); + QCOMPARE(request.invocation, retried); + QVERIFY(request.jobId != failure.jobId); + QVERIFY(request.generation > failure.generation); + QCOMPARE(request.retryCause, failure.result); + QCOMPARE(request.target.path, failure.target.path); + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); + + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QCOMPARE(harness.facade->operation().retryCause, failure.result); + QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(harness.loader.requestedPaths, QStringList({ failure.target.path, failure.target.path })); + QCOMPARE(changed.count(), 4); + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); +} + +void DocumentFacadeTest::cancelledRetryRejectsReorderedCompletion_data() +{ + QTest::addColumn("oldFinishesFirst"); + QTest::newRow("old-before-retry") << true; + QTest::newRow("old-after-retry") << false; +} + +void DocumentFacadeTest::cancelledRetryRejectsReorderedCompletion() +{ + QFETCH(bool, oldFinishesFirst); + Harness harness; + harness.submitter.runInline = false; + harness.submitter.cancelStopsQueuedWork = false; + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const auto first = harness.facade->open(QStringLiteral("/corpus/report.pdf")); + const auto oldRequest = harness.facade->operation(); + QVERIFY(harness.facade->cancelPendingOperation()); + const auto cause = harness.facade->operation().result; + QCOMPARE(cause.invocation, first); + QCOMPARE(cause.typedError, QStringLiteral("document/cancelled")); + const auto retried = harness.facade->retry(); + QVERIFY(retried != first); + QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); + const auto request = harness.facade->operation(); + QCOMPARE(request.retryCause, cause); + + if (oldFinishesFirst) + { + QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); + QVERIFY(harness.facade->operation().pending); + QCOMPARE(harness.context.getDocument(), nullptr); + } + QVERIFY(harness.submitter.runDeferred(request.jobId)); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + const auto revision = harness.facade->currentRevision(); + if (!oldFinishesFirst) + { + harness.loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Failed; + harness.loader.nextResult.typedError = QStringLiteral("document/read-failed"); + QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + } + QCOMPARE(harness.facade->currentRevision(), revision); + QCOMPARE(harness.facade->operation().invocation, retried); + QCOMPARE(harness.facade->operation().retryCause, cause); + QCOMPARE(harness.facade->operation().result.state, pdfinteraction::CommandTerminalState::Completed); + QVERIFY(harness.facade->typedError().isEmpty()); + QCOMPARE(finished.count(), 2); +} + +void DocumentFacadeTest::failedSaveRetryKeepsTargetAndRevision() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + harness.facade->markModified(); + harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Failed, QStringLiteral("document/write-failed") }; + const auto first = harness.facade->saveAs(QStringLiteral("/out/retry.pdf")); + QTRY_VERIFY(!harness.catalog.isPending(first)); + const auto failed = harness.facade->operation(); + + harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Written, QString() }; + const auto retried = harness.facade->retry(); + QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); + QVERIFY(retried != first); + QCOMPARE(harness.facade->operation().retryCause, failed.result); + QCOMPARE(harness.facade->operation().revision, failed.revision); + QCOMPARE(harness.facade->operation().generation, failed.generation); + QVERIFY(harness.facade->typedError().isEmpty()); + QTRY_COMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Saved); + QCOMPARE(harness.writer.writtenPaths, QStringList({ failed.target.path, failed.target.path })); + QVERIFY(!harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); +} + +void DocumentFacadeTest::cancelledSaveRetryRejectsPriorSuccess() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + harness.facade->markModified(); + harness.submitter.runInline = false; + harness.submitter.cancelStopsQueuedWork = false; + harness.facade->saveAs(QStringLiteral("/out/report.pdf")); + const auto oldRequest = harness.facade->operation(); + QVERIFY(harness.facade->cancelPendingOperation()); + const auto cause = harness.facade->operation().result; + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.submitter.deferredJobCount(), 1); + QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + const auto retried = harness.facade->retry(); + QVERIFY(retried != pdfinteraction::InvalidCommandInvocation); + const auto request = harness.facade->operation(); + QCOMPARE(request.generation, oldRequest.generation); + QCOMPARE(request.revision, oldRequest.revision); + QVERIFY(!harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Cancelled)); + QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Pending); + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/report.pdf")); + + QVERIFY(harness.submitter.runDeferred(request.jobId)); + QTRY_COMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::Saved); + QCOMPARE(harness.facade->operation().invocation, retried); + QCOMPARE(harness.facade->operation().retryCause, cause); + QCOMPARE(harness.facade->source().path, QStringLiteral("/out/report.pdf")); +} + +void DocumentFacadeTest::retryRejectsStaleRevisionAndClosedDocument() +{ + Harness harness; + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + harness.writer.nextResult = { pdfinteraction::DocumentWriteOutcome::Failed, QStringLiteral("document/write-failed") }; + const auto first = harness.facade->save(); + QTRY_VERIFY(!harness.catalog.isPending(first)); + harness.facade->markModified(); + const int jobs = harness.submitter.submittedSpecs.size(); + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.submitter.submittedSpecs.size(), jobs); + harness.facade->close(); + QCOMPARE(harness.facade->operation().invocation, pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.facade->retry(), pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.facade->reopen(), pdfinteraction::InvalidCommandInvocation); +} + +void DocumentFacadeTest::supersessionCannotRetryWhileReplacingDocument() +{ + Harness harness; + harness.submitter.runInline = false; + const auto first = harness.facade->open(QStringLiteral("/corpus/old.pdf")); + auto attemptedRetry = first; + connect(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished, + harness.facade.get(), [&](const pdfinteraction::CommandResult& result) + { + if (result.invocation == first) + { + attemptedRetry = harness.facade->retry(); + } }); + const auto current = harness.facade->open(QStringLiteral("/corpus/current.pdf")); + QCOMPARE(attemptedRetry, pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.facade->operation().invocation, current); + QCOMPARE(harness.submitter.submittedSpecs.size(), 2); + QVERIFY(harness.submitter.runDeferred(harness.facade->operation().jobId)); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/current.pdf")); + QCOMPARE(harness.catalog.pendingInvocationCount(), 0); +} + +void DocumentFacadeTest::reopenRejectsPriorWriteCompletion() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + const auto oldRevision = harness.facade->currentRevision(); + const auto oldGeneration = harness.facade->documentGeneration(); + harness.submitter.runInline = false; + harness.submitter.cancelStopsQueuedWork = false; + harness.facade->saveAs(QStringLiteral("/out/old.pdf")); + const auto oldRequest = harness.facade->operation(); + + harness.submitter.runInline = true; + const auto reopened = harness.facade->reopen(); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + const auto current = harness.facade->currentRevision(); + QVERIFY(current != oldRevision); + QVERIFY(harness.facade->documentGeneration() > oldGeneration); + QVERIFY(harness.submitter.clearedKeys.contains(oldRevision.document.documentId)); + QVERIFY(harness.submitter.runDeferred(oldRequest.jobId)); + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + QCOMPARE(harness.facade->currentRevision(), current); + QCOMPARE(harness.facade->source().path, QStringLiteral("/corpus/report.pdf")); + QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::None); + QCOMPARE(harness.facade->operation().invocation, reopened); + QCOMPARE(harness.facade->operation().retryCause.invocation, pdfinteraction::InvalidCommandInvocation); +} + +void DocumentFacadeTest::realWorkerCancellationAndTeardown_data() +{ + QTest::addColumn("destroyHost"); + QTest::newRow("cancel-running-worker") << false; + QTest::newRow("destroy-host-with-running-worker") << true; +} + +void DocumentFacadeTest::realWorkerCancellationAndTeardown() +{ + QFETCH(bool, destroyHost); + pdf::PDFDocumentContext context(nullptr); + DelayedDocumentLoader loader; + FakeDocumentWriter writer; + pdfinteraction::CommandCatalog catalog; + pdf::PDFJobScheduler scheduler(1); + pdfinteraction::PDFJobSchedulerSubmitter submitter(scheduler); + auto facade = std::make_unique(context, submitter, loader, writer, catalog); + const auto releaseWorker = qScopeGuard([&loader]() + { loader.release.release(); }); + QSignalSpy finished(&catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const auto invocation = facade->open(QStringLiteral("/corpus/delayed.pdf")); + const QString job = facade->operation().jobId; + QVERIFY(loader.started.tryAcquire(1, 5000)); + QCOMPARE(scheduler.snapshot(job).status, pdf::PDFJobStatus::Running); + if (destroyHost) + { + facade.reset(); + } + else + { + QVERIFY(facade->cancelPendingOperation()); + QCOMPARE(facade->state(), pdfinteraction::DocumentState::Empty); + } + QVERIFY(!catalog.isPending(invocation)); + QCOMPARE(finished.count(), 1); + QCOMPARE(finished.first().first().value().state, pdfinteraction::CommandTerminalState::Cancelled); + loader.release.release(); + QVERIFY(scheduler.waitForFinished(job, 5000)); + if (facade) + { + QTRY_COMPARE(facade->rejectedCompletionCount(), 1); + QCOMPARE(facade->state(), pdfinteraction::DocumentState::Empty); + } + QCoreApplication::sendPostedEvents(); + QCOMPARE(context.getDocument(), nullptr); + QCOMPARE(finished.count(), 1); +} + +void DocumentFacadeTest::replacementEstablishesNewIdentityBeforeNewWork() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/first.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const pdf::PDFRevisionIdentity firstRevision = harness.facade->currentRevision(); + const QString firstKey = harness.context.getDocumentIdentity().documentId; + const quint64 firstGeneration = harness.facade->documentGeneration(); + QVERIFY(harness.facade->revisionSource().isCurrent(firstRevision)); + + harness.facade->open(QStringLiteral("/corpus/second.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + // The prior identity is dropped before new work is admitted, so nothing + // captured against it can be presented as current. + QVERIFY(!harness.facade->revisionSource().isCurrent(firstRevision)); + QVERIFY(harness.facade->documentGeneration() > firstGeneration); + QVERIFY(harness.submitter.clearedKeys.contains(firstKey)); + QCOMPARE(harness.loader.loadCount, 2); + QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); + QCOMPARE(harness.facade->rejectedCompletionCount(), 0); +} + +void DocumentFacadeTest::supersededCompletionIsRejectedNotAdmitted() +{ + Harness harness; + harness.submitter.runInline = false; + + // The first read is already running when it is superseded, so cancelling it + // does not stop it: its completion still arrives, and admission is the only + // thing standing between it and the current document. + harness.submitter.cancelStopsQueuedWork = false; + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + + const pdfinteraction::CommandInvocationId first = + harness.facade->open(QStringLiteral("/corpus/first.pdf")); + const QString firstJob = QStringLiteral("job-1"); + QCOMPARE(harness.submitter.deferredJobCount(), 1); + + harness.submitter.runInline = true; + harness.facade->open(QStringLiteral("/corpus/second.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); + + const pdf::PDFRevisionIdentity currentRevision = harness.facade->currentRevision(); + QVERIFY(!harness.catalog.isPending(first)); + QVERIFY(harness.submitter.cancelledJobs.contains(firstJob)); + + // Now the superseded read finishes. It must be counted and dropped, never + // patched into the document that replaced it. + QVERIFY(harness.submitter.runDeferred(firstJob)); + QTRY_COMPARE(harness.facade->rejectedCompletionCount(), 1); + + QCOMPARE(harness.facade->currentRevision(), currentRevision); + QCOMPARE(harness.facade->source().displayLabel(), QStringLiteral("second.pdf")); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + bool sawSupersededTerminal = false; + int terminalsForFirst = 0; + for (const QList& emitted : finished) + { + const auto result = emitted.at(0).value(); + if (result.invocation == first) + { + ++terminalsForFirst; + sawSupersededTerminal = true; + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); + QCOMPARE(result.typedError, QStringLiteral("document/superseded")); + } + } + QVERIFY(sawSupersededTerminal); + + // Terminal means terminal: the late completion does not emit a second one. + QCOMPARE(terminalsForFirst, 1); +} + +void DocumentFacadeTest::replacementInvalidatesTheAvailabilitySnapshot() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/first.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); + + harness.submitter.runInline = false; + harness.facade->open(QStringLiteral("/corpus/second.pdf")); + + // Availability computed against the previous document is not evidence about + // the next one, so it does not survive the replacement. + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Opening); + QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::SaveCommandId)); + QVERIFY(harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); +} + +void DocumentFacadeTest::closeWithJobInFlightCancelsAndClearsRevision() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const QString documentKey = harness.context.getDocumentIdentity().documentId; + const pdf::PDFRevisionIdentity captured = harness.facade->currentRevision(); + + // A save is left in flight, then the document is closed under it. + harness.submitter.runInline = false; + const pdfinteraction::CommandInvocationId save = + harness.facade->saveAs(QStringLiteral("/out/report.pdf")); + QVERIFY(save != pdfinteraction::InvalidCommandInvocation); + QCOMPARE(harness.facade->shellDocumentStatus(), + pdfinteraction::ShellDocumentStatus::OutputPending); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + harness.facade->close(); + + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); + QVERIFY(!harness.catalog.isPending(save)); + QVERIFY(harness.submitter.cancelledJobs.contains(QStringLiteral("job-2"))); + + // The fence entry this facade created is the one it clears, and a key with + // no entry is never stale. + QVERIFY(harness.submitter.clearedKeys.contains(documentKey)); + QVERIFY(!harness.submitter.publishedRevisions.contains(documentKey)); + QVERIFY(!harness.facade->revisionSource().isCurrent(captured)); + + bool sawSaveCancelled = false; + for (const QList& emitted : finished) + { + const auto result = emitted.at(0).value(); + if (result.invocation == save) + { + sawSaveCancelled = true; + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); + QVERIFY(!result.isSuccess()); + } + } + QVERIFY(sawSaveCancelled); + + // A late completion after close must not reach a document that is gone. + QCOMPARE(harness.writer.writeCount, 0); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::NoDocument); +} + +void DocumentFacadeTest::closeAfterOpenClearsThePublishedRevision() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const QString documentKey = harness.context.getDocumentIdentity().documentId; + QSignalSpy closed(harness.facade.get(), &pdfinteraction::DocumentFacade::documentClosed); + + const pdfinteraction::CommandInvocationId invocation = harness.facade->close(); + QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); + + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Empty); + QCOMPARE(harness.context.getDocument(), nullptr); + QVERIFY(!harness.facade->source().isValid()); + QCOMPARE(closed.count(), 1); + QVERIFY(harness.submitter.clearedKeys.contains(documentKey)); + QVERIFY(!harness.catalog.isEnabled(pdfinteraction::DocumentFacade::CloseCommandId)); +} + +void DocumentFacadeTest::destroyingTheFacadeTerminatesAPendingInvocation() +{ + Harness harness; + harness.submitter.runInline = false; + + // The read is already running, so the facade's destructor cannot stop it — + // which is the case worth proving. + harness.submitter.cancelStopsQueuedWork = false; + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const pdfinteraction::CommandInvocationId invocation = + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QVERIFY(harness.catalog.isPending(invocation)); + + harness.facade.reset(); + + // No invocation is left dangling, and the relay makes a late completion + // harmless rather than a use-after-free. + QVERIFY(!harness.catalog.isPending(invocation)); + QCOMPARE(finished.count(), 1); + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Cancelled); + QCOMPARE(result.typedError, QStringLiteral("document/facade-destroyed")); + + QVERIFY(harness.submitter.runDeferred(QStringLiteral("job-1"))); + QTest::qWait(20); +} + +void DocumentFacadeTest::saveWritesAndReportsOutputSaved() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const QString documentKey = harness.context.getDocumentIdentity().documentId; + const pdf::PDFRevisionIdentity revision = harness.facade->currentRevision(); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + const pdfinteraction::CommandInvocationId invocation = harness.facade->save(); + QVERIFY(invocation != pdfinteraction::InvalidCommandInvocation); + + QTRY_COMPARE(harness.facade->shellDocumentStatus(), + pdfinteraction::ShellDocumentStatus::OutputSaved); + QCOMPARE(harness.writer.writeCount, 1); + QVERIFY(harness.writer.sawDocument); + QCOMPARE(harness.writer.writtenPaths.first(), QStringLiteral("/corpus/report.pdf")); + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + // The write job is fenced by the scheduler's own revision contract. + const pdf::PDFJobSpec saveSpec = harness.submitter.submittedSpecs.last(); + QCOMPARE(saveSpec.documentKey, documentKey); + QCOMPARE(saveSpec.documentRevision, revision.toString()); + QCOMPARE(saveSpec.staleResultPolicy, pdf::PDFJobStaleResultPolicy::Discard); + QCOMPARE(saveSpec.kind, pdf::PDFJobKind::Export); + + const auto result = finished.takeFirst().at(0).value(); + QVERIFY(result.isSuccess()); +} + +void DocumentFacadeTest::saveFailureKeepsTheDocumentReady() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + harness.writer.nextResult.outcome = pdfinteraction::DocumentWriteOutcome::Failed; + harness.writer.nextResult.typedError = QStringLiteral("document/write-failed"); + + QSignalSpy finished(&harness.catalog, &pdfinteraction::CommandCatalog::invocationFinished); + harness.facade->save(); + + QTRY_COMPARE(finished.count(), 1); + const auto result = finished.takeFirst().at(0).value(); + QCOMPARE(result.state, pdfinteraction::CommandTerminalState::Failed); + + // Only the output axis failed; the document is untouched and still open. + QCOMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QCOMPARE(harness.facade->outputState(), pdfinteraction::DocumentOutputState::None); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Open); + QCOMPARE(harness.facade->typedError(), QStringLiteral("document/write-failed")); +} + +void DocumentFacadeTest::markModifiedFencesTheCapturedRevision() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/report.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const pdf::PDFRevisionIdentity captured = harness.facade->currentRevision(); + const QString documentKey = harness.context.getDocumentIdentity().documentId; + + harness.facade->markModified(); + + QVERIFY(!harness.facade->revisionSource().isCurrent(captured)); + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); + + // The published fence follows the document rather than going stale behind it. + QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); + + // A modification after a successful save makes the saved state no longer + // what the operator is looking at. + harness.facade->save(); + QTRY_COMPARE(harness.facade->shellDocumentStatus(), + pdfinteraction::ShellDocumentStatus::OutputSaved); + harness.facade->markModified(); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); +} + +void DocumentFacadeTest::shellStatusProjectionIsPinned() +{ + using pdfinteraction::DocumentFacade; + using pdfinteraction::DocumentFacet; + using pdfinteraction::DocumentFacets; + using pdfinteraction::DocumentOutputState; + using pdfinteraction::DocumentState; + using pdfinteraction::ShellDocumentStatus; + + // docs/loop-shell.json names five document states. The facade's richer + // model answers in those five terms; this table is the whole projection, so + // no host has to re-derive it and drift. + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Empty, {}, DocumentOutputState::None), + ShellDocumentStatus::NoDocument); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Opening, {}, DocumentOutputState::None), + ShellDocumentStatus::NoDocument); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Closing, {}, DocumentOutputState::None), + ShellDocumentStatus::NoDocument); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Error, {}, DocumentOutputState::None), + ShellDocumentStatus::NoDocument); + + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::None), + ShellDocumentStatus::Open); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, + DocumentFacets(DocumentFacet::Dirty), + DocumentOutputState::None), + ShellDocumentStatus::Modified); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::Pending), + ShellDocumentStatus::OutputPending); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, {}, DocumentOutputState::Saved), + ShellDocumentStatus::OutputSaved); + + // A write in flight outranks unsaved changes; unsaved changes outrank a + // previous successful write. + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, + DocumentFacets(DocumentFacet::Dirty), + DocumentOutputState::Pending), + ShellDocumentStatus::OutputPending); + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, + DocumentFacets(DocumentFacet::Dirty), + DocumentOutputState::Saved), + ShellDocumentStatus::Modified); + + // Non-Dirty facets describe the document, not the shell status. + QCOMPARE(DocumentFacade::projectShellStatus(DocumentState::Ready, + DocumentFacets(DocumentFacet::Incomplete) | + DocumentFacet::Stale, + DocumentOutputState::None), + ShellDocumentStatus::Open); + + QCOMPARE(QLatin1String(pdfinteraction::getShellDocumentStatusName(ShellDocumentStatus::OutputPending)), + QLatin1String("OUTPUT_PENDING")); +} + +void DocumentFacadeTest::signatureCheckReducesVerificationResultsToTheOperatorVocabulary() +{ + using Result = pdf::PDFSignatureVerificationResult; + using State = pdfinteraction::DocumentSignatureState; + using Facade = pdfinteraction::DocumentFacade; + + const auto verifiedResult = []() + { + Result result; + result.setFlag(Result::Certificate_OK, true); + result.setFlag(Result::Signature_OK, true); + result.validate(); + return result; + }; + + // No results at all is the distinct "no signature is present" case, never + // folded into one of the four operator-facing states. + QCOMPARE(Facade::reduceSignatureState({}), State::None); + + const Result valid = verifiedResult(); + QCOMPARE(Facade::reduceSignatureState({ valid }), State::Valid); + + Result notCovered = verifiedResult(); + notCovered.addSignatureNotCoveredBytesWarning(16); + QCOMPARE(Facade::reduceSignatureState({ notCovered }), State::ModifiedAfterSigning); + + Result tampered; + tampered.addInvalidSignatureError(); + tampered.validate(); + QCOMPARE(Facade::reduceSignatureState({ tampered }), State::Invalid); + + Result selfSigned; + selfSigned.addCertificateSelfSignedError(); + selfSigned.validate(); + QCOMPARE(Facade::reduceSignatureState({ selfSigned }), State::Unknown); + + Result noHandler; + noHandler.addNoHandlerError(QByteArrayLiteral("application/unknown")); + noHandler.validate(); + QCOMPARE(Facade::reduceSignatureState({ noHandler }), State::Unknown); + + Result revoked; + revoked.addCertificateRevokedError(); + revoked.validate(); + QCOMPARE(Facade::reduceSignatureState({ revoked }), State::Invalid); + + // The strongest fact wins across a mixed set: a broken signature outranks a + // covered-bytes warning, which outranks an unestablished trust chain, and + // only an all-verified set is Valid. + QCOMPARE(Facade::reduceSignatureState({ valid, tampered }), State::Invalid); + QCOMPARE(Facade::reduceSignatureState({ valid, selfSigned }), State::Unknown); + QCOMPARE(Facade::reduceSignatureState({ valid, notCovered }), State::ModifiedAfterSigning); + QCOMPARE(Facade::reduceSignatureState({ valid, valid }), State::Valid); +} + +void DocumentFacadeTest::signatureCheckAdmitsOnlyTheCurrentRevisionAndClearsWithTheDocument() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + QString error; + QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); + + const auto token = harness.facade->beginSignatureCheck(error); + QVERIFY2(token.has_value(), qPrintable(error)); + + // One check at a time. + QVERIFY(!harness.facade->beginSignatureCheck(error)); + QCOMPARE(error, QStringLiteral("signature/check-already-running")); + + pdf::PDFSignatureVerificationResult validResult; + validResult.setFlag(pdf::PDFSignatureVerificationResult::Certificate_OK, true); + validResult.setFlag(pdf::PDFSignatureVerificationResult::Signature_OK, true); + validResult.validate(); + + // A completion for a different token is refused, and the facts stay empty. + auto forged = *token; + ++forged.request; + QVERIFY(!harness.facade->completeSignatureCheck(forged, { validResult }, error)); + QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); + + QVERIFY2(harness.facade->completeSignatureCheck(*token, { validResult }, error), qPrintable(error)); + QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::Valid); + QCOMPARE(harness.facade->signatureFacts().signatureCount, 1); + + // A failed check is Unknown with the host-supplied policy-safe detail. + const auto failing = harness.facade->beginSignatureCheck(error); + QVERIFY(failing.has_value()); + QVERIFY(harness.facade->failSignatureCheck(*failing, + QStringLiteral("The file changed since the document was loaded."), + error)); + QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::Unknown); + QCOMPARE(harness.facade->signatureFacts().detail, + QStringLiteral("The file changed since the document was loaded.")); + + // Replacing the document clears the facts: nothing about the previous + // document is evidence about the next one. + harness.facade->open(QStringLiteral("/corpus/other.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QCOMPARE(harness.facade->signatureFacts().state, pdfinteraction::DocumentSignatureState::None); + QVERIFY(harness.facade->signatureFacts().detail.isEmpty()); + QCOMPARE(harness.facade->signatureFacts().signatureCount, 0); +} + +void DocumentFacadeTest::realDocumentRoundTripsThroughCoreReaderAndWriter() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + const QString sourcePath = directory.filePath(QStringLiteral("source.pdf")); + const QString targetPath = directory.filePath(QStringLiteral("target.pdf")); + + { + const pdf::PDFDocumentPointer document = buildDocument(3); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(bool(writer.write(sourcePath, document.data(), true))); + } + + pdf::PDFDocumentContext context(nullptr); + pdf::PDFJobScheduler scheduler(1); + pdfinteraction::PDFJobSchedulerSubmitter submitter(scheduler); + pdfinteraction::PDFReaderDocumentLoader loader; + pdfinteraction::PDFDocumentFileWriter writer; + pdfinteraction::CommandCatalog catalog; + pdfinteraction::DocumentFacade facade(context, submitter, loader, writer, catalog); + + // The whole lifecycle over the real Core reader, writer, and scheduler — + // with no QApplication, no QWidget, and no QML engine anywhere. + facade.open(sourcePath); + QTRY_COMPARE_WITH_TIMEOUT(facade.state(), pdfinteraction::DocumentState::Ready, 10000); + QVERIFY(context.getDocument() != nullptr); + QCOMPARE(context.getDocument()->getCatalog()->getPageCount(), size_t(3)); + + facade.saveAs(targetPath); + QTRY_COMPARE_WITH_TIMEOUT(facade.shellDocumentStatus(), + pdfinteraction::ShellDocumentStatus::OutputSaved, 10000); + QVERIFY(QFileInfo::exists(targetPath)); + + facade.close(); + QCOMPARE(facade.state(), pdfinteraction::DocumentState::Empty); + QCOMPARE(context.getDocument(), nullptr); + QCOMPARE(facade.rejectedCompletionCount(), 0); +} + +void DocumentFacadeTest::operatorSnapshotTracksLifecycle() +{ + using namespace pdfinteraction; + Harness harness; + QString error; + InspectionFixture fixture; + QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); + QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); + QVERIFY(!harness.facade->operatorState().revision.isValid()); + + QList observed; + connect(harness.facade.get(), &DocumentFacade::operatorStateChanged, this, [&]() + { observed.append(harness.facade->operatorState().document); }); + harness.submitter.runInline = false; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QCOMPARE(harness.facade->operatorState().document, DocumentState::Opening); + QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); + QVERIFY(harness.submitter.runDeferred(QStringLiteral("job-1"))); + QTRY_COMPARE(harness.facade->state(), DocumentState::Ready); + const auto ready = harness.facade->operatorState(); + QCOMPARE(ready.revision, harness.context.getRevision()); + QCOMPARE(ready.documentKey, harness.context.getDocumentIdentity().documentId); + QCOMPARE(ready.generation, harness.facade->documentGeneration()); + harness.facade->close(); + QVERIFY(observed.contains(DocumentState::Opening)); + QVERIFY(observed.contains(DocumentState::Ready)); + QVERIFY(observed.contains(DocumentState::Closing)); + QVERIFY(observed.contains(DocumentState::Empty)); + QVERIFY(!harness.facade->operatorState().revision.isValid()); + + harness.loader.nextResult.outcome = DocumentLoadOutcome::Failed; + harness.loader.nextResult.typedError = QStringLiteral("document/invalid-pdf"); + harness.submitter.runInline = true; + harness.facade->open(QStringLiteral("/corpus/bad.pdf")); + QTRY_COMPARE(harness.facade->state(), DocumentState::Error); + QCOMPARE(harness.facade->operatorState().failureCode, QStringLiteral("document/invalid-pdf")); + QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); + QCOMPARE(harness.writer.writeCount, 0); +} + +void DocumentFacadeTest::inspectionVerdictContract_data() +{ + QTest::addColumn("verdict"); + QTest::newRow("pass") << int(pdf::PreflightVerdictState::Pass); + QTest::newRow("fail") << int(pdf::PreflightVerdictState::Fail); + QTest::newRow("incomplete") << int(pdf::PreflightVerdictState::Incomplete); + QTest::newRow("error") << int(pdf::PreflightVerdictState::Error); +} + +void DocumentFacadeTest::inspectionVerdictContract() +{ + QFETCH(int, verdict); + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + switch (pdf::PreflightVerdictState(verdict)) + { + case pdf::PreflightVerdictState::Pass: + break; + case pdf::PreflightVerdictState::Fail: + fixture.addFinding(); + break; + case pdf::PreflightVerdictState::Incomplete: + fixture.result.inspectionComplete = false; + break; + case pdf::PreflightVerdictState::Error: + fixture.result.errorCode = QStringLiteral("engine-error"); + fixture.result.errorMessage = QStringLiteral("Inspection failed."); + break; + } + QString error; + pdf::PreflightInspectionReceipt coreReceipt; + QVERIFY2(pdf::buildPreflightInspectionReceipt(fixture.result, fixture.profile, harness.context.getRevision(), + fixture.evidence, coreReceipt, error), + qPrintable(error)); + QCOMPARE(int(coreReceipt.verdict.state), verdict); + coreReceipt.limitations.append(QStringLiteral("Document is not encrypted.")); + const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + QVERIFY2(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error), qPrintable(error)); + const auto snapshot = harness.facade->operatorState(); + QCOMPARE(snapshot.inspection.state, pdfinteraction::DocumentInspectionState::Completed); + QVERIFY(snapshot.inspection.receipt); + QCOMPARE(snapshot.inspection.receipt->toJson(), coreReceipt.toJson()); + QCOMPARE(snapshot.inspection.progress, 100); + // Both adapter shapes consume this same value without deriving identity or verdict. + const QString editorSummary = pdf::preflightVerdictOperatorSummary(snapshot.inspection.receipt->verdict); + const QJsonObject cliReceipt = snapshot.inspection.receipt->toJson(); + QCOMPARE(editorSummary, pdf::preflightVerdictOperatorSummary(coreReceipt.verdict)); + QCOMPARE(cliReceipt, coreReceipt.toJson()); + QCOMPARE(snapshot.canActOnInspection(), verdict != int(pdf::PreflightVerdictState::Error)); +} + +void DocumentFacadeTest::inspectionProgressAndTerminalAdmission() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + QString error; + QSignalSpy changes(harness.facade.get(), &pdfinteraction::DocumentFacade::operatorStateChanged); + const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + QVERIFY(!harness.facade->operatorState().canActOnInspection()); + QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); + QVERIFY(!harness.facade->updateInspectionProgress(*token, -1, error)); + QVERIFY(!harness.facade->updateInspectionProgress(*token, 101, error)); + QVERIFY(harness.facade->updateInspectionProgress(*token, 50, error)); + QVERIFY(!harness.facade->updateInspectionProgress(*token, 49, error)); + auto wrongToken = *token; + ++wrongToken.request; + QVERIFY(!harness.facade->completeInspection(wrongToken, fixture.result, fixture.evidence, error)); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); + QCOMPARE(harness.facade->operatorState().inspection.progress, 50); + QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + const int terminalSignals = changes.count(); + QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(!harness.facade->updateInspectionProgress(*token, 100, error)); + QVERIFY(!harness.facade->cancelInspection(*token, error)); + QVERIFY(!harness.facade->failInspection(*token, QStringLiteral("late"), QStringLiteral("Late failure"), error)); + QCOMPARE(changes.count(), terminalSignals); + QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Completed); +} + +void DocumentFacadeTest::inspectionCancellationAndFailure() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + QString error; + const auto cancelled = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(cancelled); + QVERIFY(harness.facade->cancelInspection(*cancelled, error)); + QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Cancelled); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); + QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); + QVERIFY(!harness.facade->completeInspection(*cancelled, fixture.result, fixture.evidence, error)); + const auto failed = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(failed); + QVERIFY(failed->request > cancelled->request); + QVERIFY(!harness.facade->updateInspectionProgress(*cancelled, 30, error)); + QVERIFY(!harness.facade->failInspection(*failed, {}, {}, error)); + QVERIFY(harness.facade->failInspection(*failed, QStringLiteral("inspection/worker-failed"), + QStringLiteral("Worker exited."), error)); + const auto failure = harness.facade->operatorState(); + QCOMPARE(failure.inspection.state, pdfinteraction::DocumentInspectionState::Failed); + QCOMPARE(failure.inspection.failureCode, QStringLiteral("inspection/worker-failed")); + QCOMPARE(failure.inspection.failureMessage, QStringLiteral("Worker exited.")); + QVERIFY(!failure.inspection.receipt); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("absent"), error)); + QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); + const auto retry = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(retry); + QVERIFY(harness.facade->operatorState().inspection.failureCode.isEmpty()); + QVERIFY(harness.facade->completeInspection(*retry, fixture.result, fixture.evidence, error)); +} + +void DocumentFacadeTest::inspectionSelectionAndPlanIntent() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + const QString findingId = fixture.addFinding(); + QString error; + const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("unknown"), error)); + QVERIFY(harness.facade->selectFinding(findingId, error)); + QVERIFY(!harness.facade->requestPlan({}, error)); + QVERIFY(harness.facade->requestPlan(QStringLiteral("add-bleed"), error)); + const auto planned = harness.facade->operatorState(); + QVERIFY(planned.inspection.planIntent); + QCOMPARE(planned.inspection.planIntent->findingId, findingId); + QCOMPARE(planned.inspection.planIntent->receiptIdentity, planned.inspection.receipt->identity); + QCOMPARE(planned.inspection.planIntent->revision, planned.revision); + QCOMPARE(harness.writer.writeCount, 0); + harness.facade->clearPlanIntent(); + QVERIFY(!harness.facade->operatorState().inspection.planIntent); + QVERIFY(harness.facade->requestPlan(QStringLiteral("add-bleed"), error)); + QVERIFY(!harness.facade->selectFinding(QStringLiteral("unknown"), error)); + QVERIFY(harness.facade->operatorState().inspection.planIntent); + QVERIFY(harness.facade->selectFinding({}, error)); + QVERIFY(!harness.facade->operatorState().inspection.planIntent); + QVERIFY(harness.facade->operatorState().inspection.selectedFindingId.isEmpty()); + const auto repeat = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(repeat); + QVERIFY(!harness.facade->operatorState().inspection.receipt); +} + +void DocumentFacadeTest::inspectionRejectsInvalidEvidenceWithoutMutation() +{ + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + QString error; + QVERIFY(!harness.facade->beginInspection({}, fixture.profile, error)); + auto invalidProfile = fixture.profile; + invalidProfile.effectiveDigest.clear(); + QVERIFY(!harness.facade->beginInspection(fixture.result.documentRevisionDigest, invalidProfile, error)); + const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + auto wrongResult = fixture.result; + wrongResult.documentRevisionDigest = QString(64, QLatin1Char('c')); + QVERIFY(!harness.facade->completeInspection(*token, wrongResult, fixture.evidence, error)); + wrongResult = fixture.result; + wrongResult.effectiveProfileDigest = QString(64, QLatin1Char('c')); + QVERIFY(!harness.facade->completeInspection(*token, wrongResult, fixture.evidence, error)); + auto wrongEvidence = fixture.evidence; + wrongEvidence.revision = harness.facade->currentRevision(); + ++wrongEvidence.revision.cacheGeneration; + QVERIFY(!harness.facade->completeInspection(*token, fixture.result, wrongEvidence, error)); + fixture.addFinding(); + fixture.result.errors.append(fixture.result.errors.first()); + QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(!error.isEmpty()); + QVERIFY(!harness.facade->operatorState().inspection.receipt); + QCOMPARE(harness.facade->operatorState().inspection.state, pdfinteraction::DocumentInspectionState::Running); + fixture.result.errors.removeLast(); + QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); +} + +void DocumentFacadeTest::inspectionInvalidation_data() +{ + QTest::addColumn("change"); + QTest::addColumn("completed"); + for (const QString& change : { QStringLiteral("modify"), QStringLiteral("cache"), QStringLiteral("profile"), + QStringLiteral("reopen"), QStringLiteral("close") }) + { + QTest::newRow(qPrintable(change + QStringLiteral("-running"))) << change << false; + QTest::newRow(qPrintable(change + QStringLiteral("-completed"))) << change << true; + } +} + +void DocumentFacadeTest::inspectionInvalidation() +{ + QFETCH(QString, change); + QFETCH(bool, completed); + Harness harness; + harness.facade->open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + const QString findingId = fixture.addFinding(); + QString error; + const auto token = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + if (completed) + { + QVERIFY(harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(harness.facade->selectFinding(findingId, error)); + QVERIFY(harness.facade->requestPlan(QStringLiteral("repair"), error)); + } + if (change == QLatin1String("modify")) + harness.context.markModified(); + else if (change == QLatin1String("cache")) + harness.context.invalidateCaches(); + else if (change == QLatin1String("profile")) + harness.context.setEffectiveProfileIdentity(QStringLiteral("changed-profile")); + else if (change == QLatin1String("reopen")) + { + harness.facade->reopen(); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + } + else + harness.facade->close(); + const auto invalidated = harness.facade->operatorState(); + const bool replaced = change == QLatin1String("reopen") || change == QLatin1String("close"); + QCOMPARE(invalidated.inspection.state, replaced ? pdfinteraction::DocumentInspectionState::NotChecked + : pdfinteraction::DocumentInspectionState::Stale); + QVERIFY(!invalidated.canActOnInspection()); + QVERIFY(!invalidated.inspection.receipt); + QVERIFY(invalidated.inspection.findingIds.isEmpty()); + QVERIFY(invalidated.inspection.selectedFindingId.isEmpty()); + QVERIFY(!invalidated.inspection.planIntent); + QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(!harness.facade->selectFinding(findingId, error)); + QVERIFY(!harness.facade->requestPlan(QStringLiteral("repair"), error)); + QCOMPARE(harness.writer.writeCount, 0); + const auto next = harness.facade->beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + if (change == QLatin1String("close")) + { + QVERIFY(!next); + return; + } + QVERIFY(next); + QVERIFY(next->request > token->request); + QVERIFY(!harness.facade->completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(harness.facade->completeInspection(*next, fixture.result, fixture.evidence, error)); + QCOMPARE(harness.facade->operatorState().inspection.receipt->revision, harness.context.getRevision()); +} + +void DocumentFacadeTest::inspectionStopsWhenContextIsDestroyed() +{ + auto context = std::make_unique(nullptr); + FakeJobSubmitter submitter; + FakeDocumentLoader loader; + loader.nextResult.outcome = pdfinteraction::DocumentLoadOutcome::Loaded; + FakeDocumentWriter writer; + pdfinteraction::CommandCatalog catalog; + pdfinteraction::DocumentFacade facade(*context, submitter, loader, writer, catalog); + facade.open(QStringLiteral("/corpus/fixture.pdf")); + QTRY_COMPARE(facade.state(), pdfinteraction::DocumentState::Ready); + InspectionFixture fixture; + QString error; + const auto token = facade.beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error); + QVERIFY(token); + QVERIFY(facade.completeInspection(*token, fixture.result, fixture.evidence, error)); + context.reset(); + const auto absent = facade.operatorState(); + QCOMPARE(absent.document, pdfinteraction::DocumentState::Error); + QCOMPARE(absent.failureCode, QStringLiteral("document/context-gone")); + QVERIFY(!absent.revision.isValid()); + QVERIFY(!absent.inspection.receipt); + QVERIFY(!absent.canActOnInspection()); + QVERIFY(!facade.completeInspection(*token, fixture.result, fixture.evidence, error)); + QVERIFY(!facade.beginInspection(fixture.result.documentRevisionDigest, fixture.profile, error)); +} + +void DocumentFacadeTest::authoringEditAdvancesTheRevisionAndWritesTheOutline() +{ + Harness harness; + harness.loader.nextResult.document = buildDocument(3); + harness.facade->open(QStringLiteral("/corpus/bookmarks.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + const pdf::PDFRevisionIdentity before = harness.facade->currentRevision(); + const QString documentKey = harness.context.getDocumentIdentity().documentId; + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 2; + request.edit.text = QStringLiteral("Appendix"); + request.revision = before; + + const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); + QVERIFY2(outcome.applied, qPrintable(outcome.typedError)); + QCOMPARE(outcome.resolution, pdf::PDFAuthoringEditResult::Kind::BookmarkAdded); + QVERIFY(outcome.revision != before); + QCOMPARE(outcome.revision, harness.facade->currentRevision()); + QVERIFY(!harness.facade->revisionSource().isCurrent(before)); + + // The edit is a document mutation: dirty, unsaved, and the published fence + // follows the revision the edit produced. + QVERIFY(harness.facade->facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(harness.facade->shellDocumentStatus(), pdfinteraction::ShellDocumentStatus::Modified); + QCOMPARE(harness.submitter.publishedRevisions.value(documentKey), harness.context.getRevision()); + + // Read back through Core: the outline carries the bookmark. + QString typedError; + const std::optional> records = + pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); + QVERIFY2(records.has_value(), qPrintable(typedError)); + QCOMPARE(records->size(), 1); + QCOMPARE(records->at(0).title, QStringLiteral("Appendix")); + QCOMPARE(records->at(0).pageIndex, 2); +} + +void DocumentFacadeTest::authoringEditRefusesAStaleRevisionWithoutMutating() +{ + Harness harness; + harness.loader.nextResult.document = buildDocument(2); + harness.facade->open(QStringLiteral("/corpus/stale.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + + // The revision the operator acted on. + const pdf::PDFRevisionIdentity displayed = harness.facade->currentRevision(); + + // The document moves underneath it, as a background save or another edit + // would. + harness.facade->markModified(); + QVERIFY(!harness.facade->revisionSource().isCurrent(displayed)); + const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); + const pdf::PDFRevisionIdentity revisionBefore = harness.facade->currentRevision(); + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 1; + request.revision = displayed; + + const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); + QVERIFY(!outcome.applied); + QCOMPARE(outcome.typedError, QStringLiteral("edit/stale-revision")); + // A refusal produces no revision at all. + QVERIFY(!outcome.revision.isValid()); + + // Nothing moved: the same document object is open at the same revision. + QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); + QCOMPARE(harness.facade->currentRevision(), revisionBefore); + + QString typedError; + const std::optional> records = + pdf::PDFAuthoringEditWriter::readOutline(harness.context.getDocument(), typedError); + QVERIFY(records.has_value()); + QVERIFY(records->isEmpty()); +} + +void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() +{ + Harness harness; + + pdfinteraction::DocumentEditRequest request; + request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.edit.pageIndex = 0; + request.revision = harness.facade->currentRevision(); + + const pdfinteraction::DocumentEditOutcome noDocument = harness.facade->applyAuthoringEdit(request); + QVERIFY(!noDocument.applied); + QCOMPARE(noDocument.typedError, QStringLiteral("edit/document-not-ready")); + + // A document whose user permissions deny Modify refuses the edit with the + // same visible reason the operator-facing command reports. The document is + // read back from an encrypted file, so its security handler carries the + // user's own permission bits exactly as an opened document does. + using Algorithm = pdf::PDFSecurityHandlerFactory::Algorithm; + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("restricted.pdf")); + { + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 200, 200)); + pdf::PDFSecurityHandlerFactory::SecuritySettings settings; + settings.algorithm = Algorithm::AES_256; + settings.permissions = 0; + settings.userPassword = QStringLiteral("fixture-user"); + settings.ownerPassword = QStringLiteral("fixture-owner"); + settings.id = builder.build().getIdPart(0); + builder.setSecurityHandler(pdf::PDFSecurityHandlerFactory::createSecurityHandler(settings)); + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + pdf::PDFDocumentReader reader(nullptr, [](bool* accepted) + { + if (accepted) + { + *accepted = true; + } + return QStringLiteral("fixture-user"); + }, true, false); + pdf::PDFDocument restricted = reader.readFromFile(path); + QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); + + harness.loader.nextResult.document = pdf::PDFDocumentPointer(new pdf::PDFDocument(std::move(restricted))); + harness.facade->open(QStringLiteral("/corpus/restricted.pdf")); + QTRY_COMPARE(harness.facade->state(), pdfinteraction::DocumentState::Ready); + QVERIFY(harness.facade->permissions().encrypted); + QVERIFY(!harness.facade->permissions().modify); + + request.revision = harness.facade->currentRevision(); + const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); + const pdfinteraction::DocumentEditOutcome denied = harness.facade->applyAuthoringEdit(request); + QVERIFY(!denied.applied); + QCOMPARE(denied.typedError, QStringLiteral("edit/modify-permission-denied")); + QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); +} + +QTEST_GUILESS_MAIN(DocumentFacadeTest) + +#include "tst_documentfacadetest.moc" diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index de1f89a1..817a15d0 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -61,6 +61,7 @@ #include "pdfannotation.h" #include "pdfblockingthreadguard.h" #include "pdfcatalog.h" +#include "pdfannotation.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentwriter.h" #include "pdfdocumentreader.h" @@ -621,6 +622,7 @@ private slots: void hyperlinkCommandsWriteEveryDestinationVariant_data(); void hyperlinkCommandsWriteEveryDestinationVariant(); void restrictedDocumentCannotEditBookmarksOrLinks(); + // Direct authoring edits (ADR-014, issue #242): the catalog route into Core, // the revision fence, the XFA refusal, the attachment path and the // send-by-email hand-off. @@ -4129,12 +4131,6 @@ void EditorHostTest::bookmarkCommandsWriteTheOutlineThroughTheCatalog() QVERIFY(directory.isValid()); const QString path = writePagesFixture(directory.filePath(QStringLiteral("bookmarks.pdf")), 3); QVERIFY(!path.isEmpty()); -void EditorHostTest::formValueEditRoutesThroughTheCatalogIntoCoreAndProducesANewRevision() -{ - QTemporaryDir directory; - QVERIFY(directory.isValid()); - const QString path = directory.filePath(QStringLiteral("form.pdf")); - QVERIFY(writeFormFixtureDocument(path)); EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); @@ -4370,83 +4366,6 @@ void EditorHostTest::hyperlinkCommandsWriteEveryDestinationVariant() QVERIFY(directory.isValid()); const QString path = writePagesFixture(directory.filePath(QStringLiteral("hyperlinks.pdf")), 2); QVERIFY(!path.isEmpty()); - DocumentViewSession* session = host.sessionForTest(); - QVERIFY(session != nullptr); - const pdf::PDFRevisionIdentity before = session->facade().currentRevision(); - QVERIFY(before.isValid()); - QVERIFY(host.isCommandEnabled(QStringLiteral("actionSetFormFieldValue"))); - QVERIFY(host.isCommandEnabled(QStringLiteral("actionEditDocumentMetadata"))); - QVERIFY(host.isCommandEnabled(QStringLiteral("actionAddAttachment"))); - - auto* model = qobject_cast(host.documentModel()); - QVERIFY(model); - QVERIFY(model->hasForm()); - QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("original")); - - QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); - - // Text value: the catalog route into Core, one new revision, one dirty document. - QVariantMap parameters; - parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); - parameters.insert(QStringLiteral("value"), QStringLiteral("typed in the shell")); - parameters.insert(QStringLiteral("revision"), before.toString()); - QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); - QCOMPARE(finished.count(), 1); - const pdfinteraction::CommandResult textResult = finished.last().at(0).value(); - QCOMPARE(textResult.state, pdfinteraction::CommandTerminalState::Completed); - QCOMPARE(textResult.typedError, QString()); - - const pdf::PDFRevisionIdentity afterText = session->facade().currentRevision(); - QVERIFY(afterText != before); - QCOMPARE(session->facade().state(), pdfinteraction::DocumentState::Ready); - QVERIFY(session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); - QCOMPARE(host.documentShellStatus(), QStringLiteral("MODIFIED")); - QCOMPARE(model->revision(), afterText.toString()); - QVERIFY(model->modified()); - QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("typed in the shell")); - - // Checkbox: the same route, and the value lands as the widget's on-state name. - parameters.clear(); - parameters.insert(QStringLiteral("field"), QStringLiteral("LoopCheck")); - parameters.insert(QStringLiteral("checked"), true); - parameters.insert(QStringLiteral("revision"), afterText.toString()); - QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); - QCOMPARE(finished.count(), 2); - QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); - bool isName = false; - QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopCheck"), &isName), QStringLiteral("Yes")); - QVERIFY(isName); - - // Metadata: the same gateway, and the properties the model presents follow. - parameters.clear(); - parameters.insert(QStringLiteral("title"), QStringLiteral("Shell title")); - parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); - QVERIFY(host.invokeCommand(QStringLiteral("actionEditDocumentMetadata"), parameters) != 0); - QCOMPARE(finished.count(), 3); - QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); - QCOMPARE(model->title(), QStringLiteral("Shell title")); - QCOMPARE(session->context().getDocument()->getInfo()->title, QStringLiteral("Shell title")); - - // A signature field is refused, and a refusal changes nothing at all. - const pdf::PDFRevisionIdentity beforeRefusal = session->facade().currentRevision(); - parameters.clear(); - parameters.insert(QStringLiteral("field"), QStringLiteral("LoopSignature")); - parameters.insert(QStringLiteral("value"), QStringLiteral("not a signature")); - parameters.insert(QStringLiteral("revision"), beforeRefusal.toString()); - QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); - QCOMPARE(finished.count(), 4); - const pdfinteraction::CommandResult refused = finished.last().at(0).value(); - QCOMPARE(refused.state, pdfinteraction::CommandTerminalState::Failed); - QCOMPARE(refused.typedError, QStringLiteral("form/unsupported-field-type")); - QCOMPARE(session->facade().currentRevision(), beforeRefusal); -} - -void EditorHostTest::authoringEditRefusesAStaleRevisionAndChangesNothing() -{ - QTemporaryDir directory; - QVERIFY(directory.isValid()); - const QString path = directory.filePath(QStringLiteral("stale-form.pdf")); - QVERIFY(writeFormFixtureDocument(path)); EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); host.openFileUrl(QUrl::fromLocalFile(path)); @@ -4555,6 +4474,100 @@ void EditorHostTest::restrictedDocumentCannotEditBookmarksOrLinks() QCOMPARE(host.invokeCommand(QStringLiteral("actionCreateHyperlink"), { { QStringLiteral("uri"), QStringLiteral("https://example.org") } }), quint64(0)); +} + +void EditorHostTest::formValueEditRoutesThroughTheCatalogIntoCoreAndProducesANewRevision() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("form.pdf")); + QVERIFY(writeFormFixtureDocument(path)); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + + DocumentViewSession* session = host.sessionForTest(); + QVERIFY(session != nullptr); + const pdf::PDFRevisionIdentity before = session->facade().currentRevision(); + QVERIFY(before.isValid()); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionSetFormFieldValue"))); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionEditDocumentMetadata"))); + QVERIFY(host.isCommandEnabled(QStringLiteral("actionAddAttachment"))); + + auto* model = qobject_cast(host.documentModel()); + QVERIFY(model); + QVERIFY(model->hasForm()); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("original")); + + QSignalSpy finished(&session->catalog(), &pdfinteraction::CommandCatalog::invocationFinished); + + // Text value: the catalog route into Core, one new revision, one dirty document. + QVariantMap parameters; + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopText")); + parameters.insert(QStringLiteral("value"), QStringLiteral("typed in the shell")); + parameters.insert(QStringLiteral("revision"), before.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 1); + const pdfinteraction::CommandResult textResult = finished.last().at(0).value(); + QCOMPARE(textResult.state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(textResult.typedError, QString()); + + const pdf::PDFRevisionIdentity afterText = session->facade().currentRevision(); + QVERIFY(afterText != before); + QCOMPARE(session->facade().state(), pdfinteraction::DocumentState::Ready); + QVERIFY(session->facade().facets().testFlag(pdfinteraction::DocumentFacet::Dirty)); + QCOMPARE(host.documentShellStatus(), QStringLiteral("MODIFIED")); + QCOMPARE(model->revision(), afterText.toString()); + QVERIFY(model->modified()); + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopText")), QStringLiteral("typed in the shell")); + + // Checkbox: the same route, and the value lands as the widget's on-state name. + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopCheck")); + parameters.insert(QStringLiteral("checked"), true); + parameters.insert(QStringLiteral("revision"), afterText.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 2); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + bool isName = false; + QCOMPARE(readFormFieldValue(session->context().getDocument(), QStringLiteral("LoopCheck"), &isName), QStringLiteral("Yes")); + QVERIFY(isName); + + // Metadata: the same gateway, and the properties the model presents follow. + parameters.clear(); + parameters.insert(QStringLiteral("title"), QStringLiteral("Shell title")); + parameters.insert(QStringLiteral("revision"), session->facade().currentRevision().toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionEditDocumentMetadata"), parameters) != 0); + QCOMPARE(finished.count(), 3); + QCOMPARE(finished.last().at(0).value().state, pdfinteraction::CommandTerminalState::Completed); + QCOMPARE(model->title(), QStringLiteral("Shell title")); + QCOMPARE(session->context().getDocument()->getInfo()->title, QStringLiteral("Shell title")); + + // A signature field is refused, and a refusal changes nothing at all. + const pdf::PDFRevisionIdentity beforeRefusal = session->facade().currentRevision(); + parameters.clear(); + parameters.insert(QStringLiteral("field"), QStringLiteral("LoopSignature")); + parameters.insert(QStringLiteral("value"), QStringLiteral("not a signature")); + parameters.insert(QStringLiteral("revision"), beforeRefusal.toString()); + QVERIFY(host.invokeCommand(QStringLiteral("actionSetFormFieldValue"), parameters) != 0); + QCOMPARE(finished.count(), 4); + const pdfinteraction::CommandResult refused = finished.last().at(0).value(); + QCOMPARE(refused.state, pdfinteraction::CommandTerminalState::Failed); + QCOMPARE(refused.typedError, QStringLiteral("form/unsupported-field-type")); + QCOMPARE(session->facade().currentRevision(), beforeRefusal); +} + +void EditorHostTest::authoringEditRefusesAStaleRevisionAndChangesNothing() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("stale-form.pdf")); + QVERIFY(writeFormFixtureDocument(path)); + + EditorHost host(nullptr, nullptr, m_fixtureRecipes->path()); + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); DocumentViewSession* session = host.sessionForTest(); QVERIFY(session != nullptr); diff --git a/changes/gh-231-quick-mutations.evidence.yaml b/changes/gh-231-quick-mutations.evidence.yaml new file mode 100644 index 00000000..da7546aa --- /dev/null +++ b/changes/gh-231-quick-mutations.evidence.yaml @@ -0,0 +1,32 @@ +format_version: 1 +kind: evidence +claims: + - id: authoring-edit-gateway + evidence: + - unit:UnitTestsAuthoringEdit + - unit:UnitTestsAnnotationAuthoring + - unit:UnitTestsDirectEdit + - unit:UnitTestsDocumentFacade + - unit:agent-policy:interaction + - unit:agent-policy:core + - id: quick-authoring-surfaces + evidence: + - unit:UnitTestsEditorHost + - unit:UnitTestsShellWorkspace + - unit:UnitTestsShellKeyboard + - unit:UnitTestsProductOperatorLoop + - unit:agent-policy:quick + - id: catalog-contract + evidence: + - unit:scripts/ci/test_command_catalog.py + - architecture:scripts/verify-command-catalog.py + - id: agent-policy-contracts + evidence: + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py + - architecture:scripts/generate-architecture-catalogs.py + - id: documentation-and-catalogs + evidence: + - unit:agent-policy:documentation +unresolved: [] diff --git a/changes/gh-231-quick-mutations.md b/changes/gh-231-quick-mutations.md new file mode 100644 index 00000000..20b54f47 --- /dev/null +++ b/changes/gh-231-quick-mutations.md @@ -0,0 +1,4 @@ +Category: added +Audience: operators and developers of the Quick shell +Breaking-Change: no +Summary: Interactive authoring edits become direct Core mutations through the one `CommandCatalog` path, per the recorded ADR-014 decision (direct revision for authoring, planned operation stays for governed Fix). Thirty-nine commands gain handlers and land as implemented — the 16 bookmark/hyperlink ids (#233), the 18 annotation ids (#234), and the four new form/attachment/metadata ids plus `actionSend_by_E_Mail` (#242, `expected_action_count` 109 → 113) — each mapping its declared scalars into one typed direct-edit request on `DocumentFacade` that checks the revision fence and the document's permission before Core writes and refuses with a typed code (stale revision, permission denied, XFA-only form, executable attachment, unsupported outline structure) instead of half-writing. Bookmarks and links round-trip through Core's outline writer with all nine destination variants serialized and read back through Core's reader, import/export as a canonical file; annotations are created per subtype with appearance streams through Core's annotation model; form values, attachments and XMP metadata go through Core writers that preserve foreign XMP properties, attachment extraction writes through the atomic writer and refuses executable auto-open, and send-by-email hands the current published revision to the mail client. Every accepted edit appends one operation-history event and rollback point (the #124 revision-history model), and the handler-coverage ratchet tightens from 48 to 13 KEEP/ABSORB commands still awaiting a handler (PR 3 takes it to zero). diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 04041548..1b3d7d8a 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -401,6 +401,7 @@ "UnitTestsActionList", "UnitTestsAnnotationAuthoring", "UnitTestsApplicationIdentity", + "UnitTestsAuthoringEdit", "UnitTestsAuthorSettings", "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index aaf79840..16a86e87 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -8,32 +8,14 @@ "actionBleedFixup", "actionClearRecentFileHistory", "actionCreateBitonalDocument", - "actionCreateEllipse", - "actionCreateFreehandCurve", - "actionCreatePolygon", - "actionCreatePolyline", - "actionCreateRectangle", - "actionCreateStraightLine", - "actionCreateTextHighlight", - "actionCreateTextSquiggly", - "actionCreateTextStrikeout", - "actionCreateTextUnderline", "actionEncryption", "actionFirstPageOnRightSide", - "actionInlineText", "actionInsertPageNumbers", "actionOptimize", "actionOptimizeImages", "actionPageGeometry", "actionRemoveExternalLinks", "actionRender_to_Images", - "actionSanitize", - "actionStickyNoteComment", - "actionStickyNoteHelp", - "actionStickyNoteInsert", - "actionStickyNoteKey", - "actionStickyNoteNewParagraph", - "actionStickyNoteNote", - "actionStickyNoteParagraph" + "actionSanitize" ] } From ace608c66e9afa98c8ef6ae914ce4587c2cc4aa9 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:42:29 -0700 Subject: [PATCH 07/11] refactor(interaction): unify the direct-edit gateway to one typed request (#231 PR 2, ADR-014) Collapses the three family gateways into the single DirectEditRequest / DirectEditOutcome / DocumentFacade::applyDirectEdit shape ADR-014 records, dispatching by edit kind to the annotation, outline and document helpers. Refusal codes and fence order per family are unchanged; all call sites and the facade tests are adapted. --- LoopEditor/editorhost.cpp | 61 +-- LoopEditor/editorhost.h | 22 +- .../sources/directeditrequest.h | 40 +- LoopLibInteraction/sources/documentfacade.cpp | 271 -------------- LoopLibInteraction/sources/documentfacade.h | 111 ++---- .../sources/documentfacadeedits.cpp | 350 +++++++++++++++++- UnitTests/tst_documentfacadetest.cpp | 42 ++- 7 files changed, 463 insertions(+), 434 deletions(-) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 58191ad8..e28f9ef1 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -5478,17 +5478,17 @@ void EditorHost::registerFeatureHandlers() // document's permission before any writer runs. A refusal finishes Failed // with the facade's own stable code, so a stale, unauthorized, XFA or // unsupported edit is reported rather than silently dropped. - auto bindDocumentDirectEdit = [this](const QString& id, pdfinteraction::DocumentDirectEditRequest::Kind kind) + auto bindDocumentDirectEdit = [this](const QString& id, pdfinteraction::DirectEditKind kind) { pdfinteraction::CommandCatalog::Handler handler; handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { - const pdfinteraction::DocumentDirectEditResult result = applyAuthoringEdit(kind, parameters); + const pdfinteraction::DirectEditOutcome result = submitDocumentDirectEdit(kind, parameters); m_session->catalog().finishInvocation(invocation, - result.applied ? pdfinteraction::CommandTerminalState::Completed - : pdfinteraction::CommandTerminalState::Failed, - result.applied ? QString() : result.typedError); - if (result.applied) + result.accepted ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + result.accepted ? QString() : result.typedError); + if (result.accepted) { announceDocumentState(tr("The document was edited. Save to publish the new revision.")); // The document was replaced in place, so the surfaces the new @@ -5508,10 +5508,10 @@ void EditorHost::registerFeatureHandlers() m_session->catalog().setHandler(id, std::move(handler)); }; - bindDocumentDirectEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DocumentDirectEditRequest::Kind::SetFormFieldValue); - bindDocumentDirectEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment); - bindDocumentDirectEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DocumentDirectEditRequest::Kind::RemoveAttachment); - bindDocumentDirectEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DocumentDirectEditRequest::Kind::EditMetadata); + bindDocumentDirectEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DirectEditKind::SetFormFieldValue); + bindDocumentDirectEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DirectEditKind::AddAttachment); + bindDocumentDirectEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DirectEditKind::RemoveAttachment); + bindDocumentDirectEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DirectEditKind::EditMetadata); // Send by e-mail hands the current published revision to the mail client and // writes nothing. An unsaved document is refused with a typed reason: the @@ -5536,17 +5536,17 @@ void EditorHost::registerFeatureHandlers() refreshFeatureAvailability(); } -pdfinteraction::DocumentDirectEditResult EditorHost::applyAuthoringEdit(pdfinteraction::DocumentDirectEditRequest::Kind kind, - const QVariantMap& parameters) +pdfinteraction::DirectEditOutcome EditorHost::submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters) { - pdfinteraction::DocumentDirectEditResult result; + pdfinteraction::DirectEditOutcome outcome; if (!hasDocument()) { - result.typedError = QStringLiteral("document/not-ready"); - return result; + outcome.typedError = QStringLiteral("document/not-ready"); + return outcome; } - pdfinteraction::DocumentDirectEditRequest request; + pdfinteraction::DirectEditRequest request; request.kind = kind; request.scalars = parameters; // The caller names the revision the edit was begun against. It is parsed at @@ -5555,7 +5555,7 @@ pdfinteraction::DocumentDirectEditResult EditorHost::applyAuthoringEdit(pdfinter // the caller meant. request.revision = pdf::PDFRevisionIdentity::fromString(parameters.value(QStringLiteral("revision")).toString()); - if (kind == pdfinteraction::DocumentDirectEditRequest::Kind::AddAttachment) + if (kind == pdfinteraction::DirectEditKind::AddAttachment) { // The command carries a path; the host reads the file so the mutation // gateway never touches the filesystem. @@ -5564,13 +5564,13 @@ pdfinteraction::DocumentDirectEditResult EditorHost::applyAuthoringEdit(pdfinter QFile file(source); if (source.isEmpty() || !file.open(QIODevice::ReadOnly)) { - result.typedError = QStringLiteral("attachment/source-unreadable"); - return result; + outcome.typedError = QStringLiteral("attachment/source-unreadable"); + return outcome; } if (file.size() > maximumAttachmentBytes) { - result.typedError = QStringLiteral("attachment/source-too-large"); - return result; + outcome.typedError = QStringLiteral("attachment/source-too-large"); + return outcome; } request.contents = file.readAll(); } @@ -5768,13 +5768,13 @@ void EditorHost::runAuthoringEdit(pdfinteraction::CommandInvocationId invocation const QVariantMap& parameters) { QString typedError = buildError; - pdfinteraction::DocumentEditOutcome outcome; + pdfinteraction::DirectEditOutcome outcome; if (edit.has_value()) { outcome = submitAuthoringEdit(*edit, parameters, typedError); } - const bool applied = edit.has_value() && outcome.applied; + const bool applied = edit.has_value() && outcome.accepted; if (applied) { announceDocumentState(authoringEditAnnouncement(*edit, outcome)); @@ -5792,9 +5792,9 @@ void EditorHost::runAuthoringEdit(pdfinteraction::CommandInvocationId invocation refreshFeatureAvailability(); } -pdfinteraction::DocumentEditOutcome EditorHost::submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, - const QVariantMap& parameters, - QString& typedError) +pdfinteraction::DirectEditOutcome EditorHost::submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError) { const pdf::PDFRevisionIdentity current = m_session->facade().currentRevision(); const QString displayed = parameters.value(QStringLiteral("revision")).toString(); @@ -5806,10 +5806,11 @@ pdfinteraction::DocumentEditOutcome EditorHost::submitAuthoringEdit(const pdf::P return {}; } - pdfinteraction::DocumentEditRequest request; - request.edit = edit; + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring = edit; request.revision = current; - const pdfinteraction::DocumentEditOutcome outcome = m_session->facade().applyAuthoringEdit(request); + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); typedError = outcome.typedError; return outcome; } @@ -5926,7 +5927,7 @@ bool EditorHost::goToAdjacentBookmark(int direction, QString& typedError) } QString EditorHost::authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, - const pdfinteraction::DocumentEditOutcome& outcome) const + const pdfinteraction::DirectEditOutcome& outcome) const { switch (outcome.resolution) { diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 71de76d7..fb7c98bf 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -753,13 +753,13 @@ class EditorHost final : public QObject void registerFeatureHandlers(); void refreshFeatureAvailability(); bool commandPermissionAllowed(const QString& commandId) const; - /// Applies one authoring command through the facade's direct-edit gateway - /// (ADR-014). The command's declared scalars become the request's payload; - /// for an attachment the host reads the source file, so the gateway itself - /// never touches the filesystem. The facade's typed refusal is returned as - /// it stands. - pdfinteraction::DocumentDirectEditResult applyAuthoringEdit(pdfinteraction::DocumentDirectEditRequest::Kind kind, - const QVariantMap& parameters); + /// Applies one document-family direct-edit command through the facade's + /// direct-edit gateway (ADR-014). The command's declared scalars become the + /// request's payload; for an attachment the host reads the source file, so + /// the gateway itself never touches the filesystem. The facade's typed + /// refusal is returned as it stands. + pdfinteraction::DirectEditOutcome submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters); /// Hands the current published revision to the shell's mail client. Refuses /// when there is no open document, when its source is not a file, or when it /// has unsaved changes: the bytes on disk would then be a different revision @@ -778,9 +778,9 @@ class EditorHost final : public QObject const QVariantMap& parameters); /// The fence and the facade call behind every authoring command. `typedError` /// is set on refusal. - pdfinteraction::DocumentEditOutcome submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, - const QVariantMap& parameters, - QString& typedError); + pdfinteraction::DirectEditOutcome submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError); /// The link rectangle and page a hyperlink command addresses: explicit /// scalars when the surface supplied them, otherwise the current text /// selection, which is the Quick shell's own "this region" gesture. @@ -791,7 +791,7 @@ class EditorHost final : public QObject bool goToAdjacentBookmark(int direction, QString& typedError); /// The operator-facing sentence for one applied edit. QString authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, - const pdfinteraction::DocumentEditOutcome& outcome) const; + const pdfinteraction::DirectEditOutcome& outcome) const; void moveSearch(int direction); bool moveFindingSelection(int direction); void refreshHitTestSources(); diff --git a/LoopLibInteraction/sources/directeditrequest.h b/LoopLibInteraction/sources/directeditrequest.h index ffe3199f..880e968d 100644 --- a/LoopLibInteraction/sources/directeditrequest.h +++ b/LoopLibInteraction/sources/directeditrequest.h @@ -26,8 +26,10 @@ #include "interactionglobal.h" #include "pdfannotationauthoring.h" +#include "pdfauthoringedit.h" #include "pdfdocumentcontext.h" +#include #include #include #include @@ -43,7 +45,12 @@ namespace pdfinteraction /// handler cannot smuggle an unmodelled mutation through the gateway. enum class DirectEditKind { - CreateAnnotation + CreateAnnotation, ///< An annotation of `annotation`'s subtype. + EditOutline, ///< A bookmark or hyperlink edit (`authoring`). + SetFormFieldValue, ///< A form-field write (`scalars`). + AddAttachment, ///< An attachment addition (`scalars`, `contents`). + RemoveAttachment, ///< An attachment removal (`scalars`). + EditMetadata ///< A document-information write (`scalars`). }; const char* getDirectEditKindName(DirectEditKind kind); @@ -55,19 +62,36 @@ bool parseDirectEditKind(const QString& name, DirectEditKind* kind); /// mutation-gateway shape ADR-014 requires: the edit kind, its typed payload, /// and the `PDFRevisionIdentity` the edit was begun against. /// +/// The payload member follows the kind: `annotation` for CreateAnnotation, +/// `authoring` for EditOutline, and `scalars`/`contents` for the document +/// families. A member the kind does not use is ignored, never guessed at. +/// /// `revision` is the fence, not a hint. A request whose revision no longer /// matches the context's current revision is refused with nothing written. struct DirectEditRequest { DirectEditKind kind = DirectEditKind::CreateAnnotation; + pdf::AnnotationAuthoringSpec annotation; + pdf::PDFAuthoringEdit authoring; /// The revision the edit was begun against (complete PDFRevisionIdentity). pdf::PDFRevisionIdentity revision; /// The acting identity recorded on the appended operation-history event. - /// Required; an empty identity is refused rather than attributed to nobody. + /// Required by CreateAnnotation; an empty identity is refused rather than + /// attributed to nobody. QString operatorIdentity; + + /// The document families' scalar payload: exactly the parameters the + /// command declares (`field`, `value`, `checked`, `name`, `description`, + /// `title`, ...). + QVariantMap scalars; + + /// Attachment bytes for AddAttachment. The command contract carries a path; + /// the host resolves and reads it, so the mutation gateway itself never + /// touches the filesystem. + QByteArray contents; }; /// Result of one gateway submission. `accepted` is true exactly when the edit @@ -83,13 +107,21 @@ struct DirectEditOutcome bool accepted = false; QString typedError; - /// The revision the outcome describes: the new revision when accepted, - /// the still-current revision when refused. + /// The revision the outcome describes: the new revision when accepted; on + /// refusal, whatever the family's fence reported before the unification — + /// the still-current revision for annotation and document edits, the + /// invalid default for outline edits. pdf::PDFRevisionIdentity revision; pdf::PDFObjectReference annotation; pdf::PDFObjectReference page; + /// The outline edit's resolution (#233); `Refused` for every other family. + pdf::PDFAuthoringEditResult::Kind resolution = pdf::PDFAuthoringEditResult::Kind::Refused; + + /// Bookmarks written by a Generate/Replace outline edit. + int affectedCount = 0; + bool historyAppended = false; quint64 historySequence = 0; QString historyError; diff --git a/LoopLibInteraction/sources/documentfacade.cpp b/LoopLibInteraction/sources/documentfacade.cpp index 1357eaa0..f4866d1f 100644 --- a/LoopLibInteraction/sources/documentfacade.cpp +++ b/LoopLibInteraction/sources/documentfacade.cpp @@ -22,9 +22,7 @@ #include "documentfacade.h" -#include "pdfdirectedit.h" #include "pdfresourcebudget.h" -#include "pdfsecurityhandler.h" #include #include @@ -43,60 +41,6 @@ namespace { constexpr const char* PathParameter = "path"; -constexpr const char* FieldParameter = "field"; -constexpr const char* ValueParameter = "value"; -constexpr const char* CheckedParameter = "checked"; -constexpr const char* NameParameter = "name"; -constexpr const char* DescriptionParameter = "description"; -constexpr const char* TitleParameter = "title"; -constexpr const char* AuthorParameter = "author"; -constexpr const char* SubjectParameter = "subject"; -constexpr const char* KeywordsParameter = "keywords"; -constexpr const char* CreatorParameter = "creator"; - -/// Reads one declared string parameter. An absent key is an absent value; a key -/// carrying another type is malformed, never coerced. -std::optional scalarString(const QVariantMap& scalars, const char* name, bool* malformed) -{ - const auto it = scalars.constFind(QLatin1String(name)); - if (it == scalars.constEnd()) - { - return std::nullopt; - } - if (it->typeId() != QMetaType::QString) - { - *malformed = true; - return std::nullopt; - } - return it->toString(); -} - -std::optional scalarBool(const QVariantMap& scalars, const char* name, bool* malformed) -{ - const auto it = scalars.constFind(QLatin1String(name)); - if (it == scalars.constEnd()) - { - return std::nullopt; - } - if (it->typeId() != QMetaType::Bool) - { - *malformed = true; - return std::nullopt; - } - return it->toBool(); -} - -bool requiredString(const QVariantMap& scalars, const char* name, QString* value, bool* malformed) -{ - const std::optional read = scalarString(scalars, name, malformed); - if (!read || read->isEmpty()) - { - *malformed = true; - return false; - } - *value = *read; - return true; -} } // namespace @@ -275,221 +219,6 @@ void DocumentFacade::noteAuthoringMutation() updateAvailability(); } -DocumentEditOutcome DocumentFacade::applyAuthoringEdit(const DocumentEditRequest& request) -{ - DocumentEditOutcome outcome; - const auto refuse = [&outcome](const QString& typedError) - { - outcome.applied = false; - outcome.typedError = typedError; - return outcome; - }; - - pdf::PDFDocumentContext* documentContext = context(); - if (m_state != DocumentState::Ready || !documentContext || !documentContext->getDocument()) - { - return refuse(QStringLiteral("edit/document-not-ready")); - } - - // The fence is checked before any mutation, so an edit begun against a - // revision the operator is no longer looking at cannot land on this one. - if (!m_revisionSource.isCurrent(request.revision)) - { - return refuse(QStringLiteral("edit/stale-revision")); - } - - if (!permissions().modify) - { - return refuse(QStringLiteral("edit/modify-permission-denied")); - } - - pdf::PDFAuthoringEditResult result; - try - { - result = pdf::PDFAuthoringEditWriter::apply(documentContext->getDocument(), request.edit); - } - catch (const pdf::PDFResourceBudgetExceededException&) - { - return refuse(QStringLiteral("edit/resource-budget-exceeded")); - } - catch (const pdf::PDFException&) - { - return refuse(QStringLiteral("edit/refused-by-core")); - } - - if (!result.applied()) - { - return refuse(result.typedError.isEmpty() ? QStringLiteral("edit/refused") : result.typedError); - } - - // The writer produced a new document; replacing it advances the document - // revision and the cache generation, which is the revision this edit - // produced. The old document stays owned by nothing once the context drops - // it, exactly as a document replacement does. - documentContext->setDocument(result.document, result.flags); - noteAuthoringMutation(); - - outcome.applied = true; - outcome.resolution = result.kind; - outcome.affectedCount = result.affectedCount; - outcome.revision = m_revisionSource.currentRevision(); - return outcome; -} - -DocumentDirectEditResult DocumentFacade::applyDirectEdit(const DocumentDirectEditRequest& request) -{ - DocumentDirectEditResult result; - result.revision = currentRevision(); - - pdf::PDFDocumentContext* documentContext = context(); - const pdf::PDFDocument* document = documentContext ? documentContext->getDocument() : nullptr; - if (m_state != DocumentState::Ready || !document) - { - result.typedError = QStringLiteral("document/not-ready"); - return result; - } - - // The fence first. An edit begun against a superseded revision is refused - // before its payload is read and before any writer is reached. - if (!request.revision.isValid() || request.revision != currentRevision()) - { - result.typedError = QStringLiteral("document/stale-revision"); - return result; - } - - // One mutation at a time: a lifecycle operation in flight is measured - // against the revision this edit is about to supersede. - if (m_pendingInvocation != InvalidCommandInvocation || m_outputState == DocumentOutputState::Pending) - { - result.typedError = QStringLiteral("document/busy"); - return result; - } - - if (!directEditAllowed(request.kind)) - { - result.typedError = QStringLiteral("document/modify-permission-denied"); - return result; - } - - // Parse the scalar payload at this boundary, then hand it to the one Core - // writer for the family. Nothing below here reads the raw map again. - bool malformed = false; - pdf::DirectEditOutcome outcome; - switch (request.kind) - { - case DocumentDirectEditRequest::Kind::SetFormFieldValue: - { - pdf::FormFieldValueEdit edit; - const bool hasField = requiredString(request.scalars, FieldParameter, &edit.field, &malformed); - edit.text = scalarString(request.scalars, ValueParameter, &malformed); - edit.checked = scalarBool(request.scalars, CheckedParameter, &malformed); - if (!hasField || malformed || (!edit.text && !edit.checked)) - { - result.typedError = QStringLiteral("direct-edit/invalid-request"); - return result; - } - outcome = pdf::PDFFormValueWriter::setFieldValue(document, edit); - break; - } - - case DocumentDirectEditRequest::Kind::AddAttachment: - { - pdf::AttachmentEdit edit; - const bool hasName = requiredString(request.scalars, NameParameter, &edit.name, &malformed); - edit.description = scalarString(request.scalars, DescriptionParameter, &malformed).value_or(QString()); - edit.contents = request.contents; - if (!hasName || malformed || edit.contents.isEmpty()) - { - result.typedError = QStringLiteral("direct-edit/invalid-request"); - return result; - } - outcome = pdf::PDFAttachmentWriter::add(document, edit); - break; - } - - case DocumentDirectEditRequest::Kind::RemoveAttachment: - { - QString name; - const bool hasName = requiredString(request.scalars, NameParameter, &name, &malformed); - if (!hasName || malformed) - { - result.typedError = QStringLiteral("direct-edit/invalid-request"); - return result; - } - outcome = pdf::PDFAttachmentWriter::remove(document, name); - break; - } - - case DocumentDirectEditRequest::Kind::EditMetadata: - { - pdf::DocumentMetadataEdit edit; - edit.title = scalarString(request.scalars, TitleParameter, &malformed); - edit.author = scalarString(request.scalars, AuthorParameter, &malformed); - edit.subject = scalarString(request.scalars, SubjectParameter, &malformed); - edit.keywords = scalarString(request.scalars, KeywordsParameter, &malformed); - edit.creator = scalarString(request.scalars, CreatorParameter, &malformed); - if (malformed || (!edit.title && !edit.author && !edit.subject && !edit.keywords && !edit.creator)) - { - result.typedError = QStringLiteral("direct-edit/invalid-request"); - return result; - } - outcome = pdf::PDFMetadataWriter::update(document, edit); - break; - } - } - - if (!outcome.ok()) - { - result.typedError = QString::fromLatin1(pdf::getDirectEditRefusalCode(outcome.refusal)); - return result; - } - - // Accept the edit: the new document replaces the current one, which is what - // advances the revision exactly once. markModified() is deliberately not - // used here -- it would advance the revision a second time for one edit. - if (m_outputState == DocumentOutputState::Pending) - { - supersedePending(CommandTerminalState::Cancelled, QStringLiteral("document/superseded")); - } - documentContext->setDocument(outcome.document); - if (!m_publishedKey.isEmpty()) - { - m_submitter->publishCurrentRevision(m_publishedKey, m_revisionSource.currentRevision()); - } - setOutputState(DocumentOutputState::None); - setFacets(m_facets | DocumentFacet::Dirty); - updateAvailability(); - - result.applied = true; - result.revision = m_revisionSource.currentRevision(); - return result; -} - -bool DocumentFacade::directEditAllowed(DocumentDirectEditRequest::Kind kind) const -{ - const auto* documentContext = context(); - const auto* document = m_state == DocumentState::Ready && documentContext ? documentContext->getDocument() : nullptr; - const auto* security = document ? document->getStorage().getSecurityHandler() : nullptr; - if (!security) - { - return false; - } - - using Permission = pdf::PDFSecurityHandler::Permission; - switch (kind) - { - case DocumentDirectEditRequest::Kind::SetFormFieldValue: - return security->isAllowed(Permission::ModifyFormFields); - - case DocumentDirectEditRequest::Kind::AddAttachment: - case DocumentDirectEditRequest::Kind::RemoveAttachment: - case DocumentDirectEditRequest::Kind::EditMetadata: - return security->isAllowed(Permission::Modify); - } - - return false; -} - CommandInvocationId DocumentFacade::open(const QString& path) { QVariantMap parameters; diff --git a/LoopLibInteraction/sources/documentfacade.h b/LoopLibInteraction/sources/documentfacade.h index e8e5343d..de8c7414 100644 --- a/LoopLibInteraction/sources/documentfacade.h +++ b/LoopLibInteraction/sources/documentfacade.h @@ -175,47 +175,6 @@ struct DocumentPlanIntent pdf::PDFRevisionIdentity revision; }; -/// One direct authoring edit's request (ADR-014). -/// -/// Every authoring handler submits through this one shape: the edit kind, the -/// command's own scalar parameters, the attachment bytes when the kind needs -/// them, and the `PDFRevisionIdentity` the edit was begun against. The gateway -/// checks the fence before it reads anything else, so an edit begun against a -/// superseded revision can never mutate the current one. -struct DocumentDirectEditRequest -{ - enum class Kind - { - SetFormFieldValue, - AddAttachment, - RemoveAttachment, - EditMetadata - }; - - Kind kind = Kind::SetFormFieldValue; - pdf::PDFRevisionIdentity revision; - - /// The edit's scalar payload: exactly the parameters the command declares - /// (`field`, `value`, `checked`, `name`, `description`, `title`, ...). - QVariantMap scalars; - - /// Attachment bytes for Kind::AddAttachment. The command contract carries a - /// path; the host resolves and reads it, so the mutation gateway itself - /// never touches the filesystem. - QByteArray contents; -}; - -/// The gateway's typed outcome. `applied` is the only success: a refused edit -/// carries a stable `typedError` code and changed nothing. -struct DocumentDirectEditResult -{ - bool applied = false; - QString typedError; - pdf::PDFRevisionIdentity revision; - - bool ok() const { return applied; } -}; - struct DocumentInspection { DocumentInspectionState state = DocumentInspectionState::NotChecked; @@ -261,28 +220,6 @@ struct DocumentPermissions bool allowsCorrection() const { return print && modify && copy && assemble; } }; -/// One interactive authoring edit (ADR-014): the edit Core is asked to write -/// and the revision it was begun against. The facade checks the fence and the -/// document's modify permission before any mutation, so a stale or unauthorized -/// request is refused with a typed reason and changes nothing. -struct DocumentEditRequest -{ - pdf::PDFAuthoringEdit edit; - pdf::PDFRevisionIdentity revision; -}; - -/// The outcome of one authoring edit. `applied` means the document advanced to -/// a new revision and `revision` names it; a refusal carries the typed reason -/// and leaves the document and its revision untouched. -struct DocumentEditOutcome -{ - bool applied = false; - QString typedError; - pdf::PDFAuthoringEditResult::Kind resolution = pdf::PDFAuthoringEditResult::Kind::Refused; - int affectedCount = 0; - pdf::PDFRevisionIdentity revision; -}; - /// One presentation-facing document lifecycle. /// /// The facade owns no PDF truth. pdf::PDFDocumentContext remains the document @@ -354,17 +291,6 @@ class DocumentFacade final : public QObject bool requestPlan(const QString& operationId, QString& error); void clearPlanIntent(); - /// Applies one direct authoring edit (ADR-014). - /// - /// The order is the contract: the fence is checked against the complete - /// `PDFRevisionIdentity` before anything else, then the document's - /// permission for that edit family, then the payload is parsed at this - /// boundary, and only then does a Core writer run. An accepted edit - /// replaces the document in the context — one new revision, one history - /// fence — and marks the document modified. A refused edit changes nothing - /// and returns its stable code in `typedError`. - DocumentDirectEditResult applyDirectEdit(const DocumentDirectEditRequest& request); - /// Begins a signature check for the current revision. One check at a time. /// The host runs Core's verification on a scheduler worker and admits the /// result through completeSignatureCheck/failSignatureCheck. @@ -398,20 +324,20 @@ class DocumentFacade final : public QObject /// the lifecycle. void markModified(); - /// Applies one interactive authoring edit (ADR-014). The fence is checked - /// before any mutation: a request begun against a revision that is no - /// longer current, or against a document whose modify permission is not - /// granted, is refused with a typed reason. An applied edit replaces the - /// document, which advances the revision and the cache generation. - DocumentEditOutcome applyAuthoringEdit(const DocumentEditRequest& request); - - /// Applies one typed direct edit — the single mutation gateway for - /// interactive authoring (ADR-014). The revision fence and the document's - /// modify-annotations permission are checked before anything is built, so a - /// refusal mutates nothing. An accepted edit replaces the document with the - /// new revision produced by the Core writer primitive and appends one - /// operation-history event and rollback point (#124's model); the outcome - /// reports the mutation and the provenance write separately. + /// Applies one direct-edit request — the single mutation gateway for + /// interactive authoring (ADR-014). Dispatches on `request.kind` to the + /// edit family's own helper, each of which keeps that family's fence, + /// permission and refusal codes. + /// + /// The order is the contract: the fence is checked against the complete + /// `PDFRevisionIdentity` before anything else, then the document's + /// permission for that edit family, then the payload is parsed at this + /// boundary, and only then does a Core writer run. An accepted edit + /// replaces the document in the context — one new revision — and marks the + /// document modified; a CreateAnnotation edit also appends one + /// operation-history event and rollback point (#124's model), reporting the + /// mutation and the provenance write separately. A refused edit changes + /// nothing and returns its stable code in `typedError`. DirectEditOutcome applyDirectEdit(const DirectEditRequest& request); CommandInvocationId open(const QString& path); @@ -479,6 +405,13 @@ class DocumentFacade final : public QObject /// authority, and one accepted edit is exactly one revision. void noteDirectMutation(); + /// One direct-edit family's application, dispatched from applyDirectEdit. + /// Each helper owns its family's fence order, permission check and refusal + /// codes: the unification is one shape at the gateway, not one policy. + DirectEditOutcome applyAnnotationEdit(const DirectEditRequest& request); + DirectEditOutcome applyOutlineEdit(const DirectEditRequest& request); + DirectEditOutcome applyDocumentEdit(const DirectEditRequest& request); + bool admitInspectionTransition(const DocumentInspectionToken& token, QString& error) const; void invalidateInspection(); bool admitSignatureCheckTransition(const DocumentSignatureCheckToken& token, QString& error) const; @@ -487,7 +420,7 @@ class DocumentFacade final : public QObject /// Whether the open document's security handler grants the permission one /// direct-edit family needs. A document that grants none of them is not an /// error state; it is a refusal with a typed reason. - bool directEditAllowed(DocumentDirectEditRequest::Kind kind) const; + bool directEditAllowed(DirectEditKind kind) const; /// The context is reached through the P4-S1 seam rather than held directly, /// so a destroyed context degrades to an invalid revision instead of a diff --git a/LoopLibInteraction/sources/documentfacadeedits.cpp b/LoopLibInteraction/sources/documentfacadeedits.cpp index baf98285..ad9c9aa8 100644 --- a/LoopLibInteraction/sources/documentfacadeedits.cpp +++ b/LoopLibInteraction/sources/documentfacadeedits.cpp @@ -23,13 +23,16 @@ #include "documentfacade.h" #include "pdfannotationauthoring.h" +#include "pdfauthoringedit.h" #include "pdfauthoringhistory.h" +#include "pdfdirectedit.h" #include "pdfdocumentwriter.h" #include "pdfresourcebudget.h" #include "pdfsecurityhandler.h" #include #include +#include namespace pdfinteraction { @@ -37,6 +40,61 @@ namespace pdfinteraction namespace { +constexpr const char* FieldParameter = "field"; +constexpr const char* ValueParameter = "value"; +constexpr const char* CheckedParameter = "checked"; +constexpr const char* NameParameter = "name"; +constexpr const char* DescriptionParameter = "description"; +constexpr const char* TitleParameter = "title"; +constexpr const char* AuthorParameter = "author"; +constexpr const char* SubjectParameter = "subject"; +constexpr const char* KeywordsParameter = "keywords"; +constexpr const char* CreatorParameter = "creator"; + +/// Reads one declared string parameter. An absent key is an absent value; a key +/// carrying another type is malformed, never coerced. +std::optional scalarString(const QVariantMap& scalars, const char* name, bool* malformed) +{ + const auto it = scalars.constFind(QLatin1String(name)); + if (it == scalars.constEnd()) + { + return std::nullopt; + } + if (it->typeId() != QMetaType::QString) + { + *malformed = true; + return std::nullopt; + } + return it->toString(); +} + +std::optional scalarBool(const QVariantMap& scalars, const char* name, bool* malformed) +{ + const auto it = scalars.constFind(QLatin1String(name)); + if (it == scalars.constEnd()) + { + return std::nullopt; + } + if (it->typeId() != QMetaType::Bool) + { + *malformed = true; + return std::nullopt; + } + return it->toBool(); +} + +bool requiredString(const QVariantMap& scalars, const char* name, QString* value, bool* malformed) +{ + const std::optional read = scalarString(scalars, name, malformed); + if (!read || read->isEmpty()) + { + *malformed = true; + return false; + } + *value = *read; + return true; +} + /// Serializes one revision for the operation-history artifact store. A /// revision that cannot be serialized cannot be recorded, and an unrecordable /// edit is refused before anything is mutated. @@ -70,6 +128,21 @@ const char* getDirectEditKindName(DirectEditKind kind) { case DirectEditKind::CreateAnnotation: return "create-annotation"; + + case DirectEditKind::EditOutline: + return "edit-outline"; + + case DirectEditKind::SetFormFieldValue: + return "set-form-field-value"; + + case DirectEditKind::AddAttachment: + return "add-attachment"; + + case DirectEditKind::RemoveAttachment: + return "remove-attachment"; + + case DirectEditKind::EditMetadata: + return "edit-metadata"; } return "invalid"; @@ -77,28 +150,58 @@ const char* getDirectEditKindName(DirectEditKind kind) bool parseDirectEditKind(const QString& name, DirectEditKind* kind) { - if (name == QLatin1String("create-annotation")) + static const QList> names = { + { QStringLiteral("create-annotation"), DirectEditKind::CreateAnnotation }, + { QStringLiteral("edit-outline"), DirectEditKind::EditOutline }, + { QStringLiteral("set-form-field-value"), DirectEditKind::SetFormFieldValue }, + { QStringLiteral("add-attachment"), DirectEditKind::AddAttachment }, + { QStringLiteral("remove-attachment"), DirectEditKind::RemoveAttachment }, + { QStringLiteral("edit-metadata"), DirectEditKind::EditMetadata }, + }; + for (const auto& entry : names) { - if (kind) + if (entry.first == name) { - *kind = DirectEditKind::CreateAnnotation; + if (kind) + { + *kind = entry.second; + } + return true; } - return true; } return false; } DirectEditOutcome DocumentFacade::applyDirectEdit(const DirectEditRequest& request) +{ + switch (request.kind) + { + case DirectEditKind::CreateAnnotation: + return applyAnnotationEdit(request); + + case DirectEditKind::EditOutline: + return applyOutlineEdit(request); + + case DirectEditKind::SetFormFieldValue: + case DirectEditKind::AddAttachment: + case DirectEditKind::RemoveAttachment: + case DirectEditKind::EditMetadata: + return applyDocumentEdit(request); + } + + // A kind outside the vocabulary cannot be dispatched; it is refused rather + // than handed to a family that would guess at the payload. + DirectEditOutcome outcome; + outcome.typedError = QStringLiteral("direct-edit/unsupported-kind"); + return outcome; +} + +DirectEditOutcome DocumentFacade::applyAnnotationEdit(const DirectEditRequest& request) { DirectEditOutcome outcome; outcome.revision = currentRevision(); - if (request.kind != DirectEditKind::CreateAnnotation) - { - outcome.typedError = QStringLiteral("direct-edit/unsupported-kind"); - return outcome; - } if (request.operatorIdentity.trimmed().isEmpty()) { outcome.typedError = QStringLiteral("direct-edit/missing-operator"); @@ -216,6 +319,235 @@ DirectEditOutcome DocumentFacade::applyDirectEdit(const DirectEditRequest& reque return outcome; } +DirectEditOutcome DocumentFacade::applyOutlineEdit(const DirectEditRequest& request) +{ + DirectEditOutcome outcome; + const auto refuse = [&outcome](const QString& typedError) + { + outcome.accepted = false; + outcome.typedError = typedError; + return outcome; + }; + + pdf::PDFDocumentContext* documentContext = context(); + if (m_state != DocumentState::Ready || !documentContext || !documentContext->getDocument()) + { + return refuse(QStringLiteral("edit/document-not-ready")); + } + + // The fence is checked before any mutation, so an edit begun against a + // revision the operator is no longer looking at cannot land on this one. + if (!m_revisionSource.isCurrent(request.revision)) + { + return refuse(QStringLiteral("edit/stale-revision")); + } + + if (!permissions().modify) + { + return refuse(QStringLiteral("edit/modify-permission-denied")); + } + + pdf::PDFAuthoringEditResult result; + try + { + result = pdf::PDFAuthoringEditWriter::apply(documentContext->getDocument(), request.authoring); + } + catch (const pdf::PDFResourceBudgetExceededException&) + { + return refuse(QStringLiteral("edit/resource-budget-exceeded")); + } + catch (const pdf::PDFException&) + { + return refuse(QStringLiteral("edit/refused-by-core")); + } + + if (!result.applied()) + { + return refuse(result.typedError.isEmpty() ? QStringLiteral("edit/refused") : result.typedError); + } + + // The writer produced a new document; replacing it advances the document + // revision and the cache generation, which is the revision this edit + // produced. The old document stays owned by nothing once the context drops + // it, exactly as a document replacement does. + documentContext->setDocument(result.document, result.flags); + noteAuthoringMutation(); + + outcome.accepted = true; + outcome.resolution = result.kind; + outcome.affectedCount = result.affectedCount; + outcome.revision = m_revisionSource.currentRevision(); + return outcome; +} + +DirectEditOutcome DocumentFacade::applyDocumentEdit(const DirectEditRequest& request) +{ + DirectEditOutcome outcome; + outcome.revision = currentRevision(); + + pdf::PDFDocumentContext* documentContext = context(); + const pdf::PDFDocument* document = documentContext ? documentContext->getDocument() : nullptr; + if (m_state != DocumentState::Ready || !document) + { + outcome.typedError = QStringLiteral("document/not-ready"); + return outcome; + } + + // The fence first. An edit begun against a superseded revision is refused + // before its payload is read and before any writer is reached. + if (!request.revision.isValid() || request.revision != currentRevision()) + { + outcome.typedError = QStringLiteral("document/stale-revision"); + return outcome; + } + + // One mutation at a time: a lifecycle operation in flight is measured + // against the revision this edit is about to supersede. + if (m_pendingInvocation != InvalidCommandInvocation || m_outputState == DocumentOutputState::Pending) + { + outcome.typedError = QStringLiteral("document/busy"); + return outcome; + } + + if (!directEditAllowed(request.kind)) + { + outcome.typedError = QStringLiteral("document/modify-permission-denied"); + return outcome; + } + + // Parse the scalar payload at this boundary, then hand it to the one Core + // writer for the family. Nothing below here reads the raw map again. + bool malformed = false; + pdf::DirectEditOutcome coreOutcome; + switch (request.kind) + { + case DirectEditKind::SetFormFieldValue: + { + pdf::FormFieldValueEdit edit; + const bool hasField = requiredString(request.scalars, FieldParameter, &edit.field, &malformed); + edit.text = scalarString(request.scalars, ValueParameter, &malformed); + edit.checked = scalarBool(request.scalars, CheckedParameter, &malformed); + if (!hasField || malformed || (!edit.text && !edit.checked)) + { + outcome.typedError = QStringLiteral("direct-edit/invalid-request"); + return outcome; + } + coreOutcome = pdf::PDFFormValueWriter::setFieldValue(document, edit); + break; + } + + case DirectEditKind::AddAttachment: + { + pdf::AttachmentEdit edit; + const bool hasName = requiredString(request.scalars, NameParameter, &edit.name, &malformed); + edit.description = scalarString(request.scalars, DescriptionParameter, &malformed).value_or(QString()); + edit.contents = request.contents; + if (!hasName || malformed || edit.contents.isEmpty()) + { + outcome.typedError = QStringLiteral("direct-edit/invalid-request"); + return outcome; + } + coreOutcome = pdf::PDFAttachmentWriter::add(document, edit); + break; + } + + case DirectEditKind::RemoveAttachment: + { + QString name; + const bool hasName = requiredString(request.scalars, NameParameter, &name, &malformed); + if (!hasName || malformed) + { + outcome.typedError = QStringLiteral("direct-edit/invalid-request"); + return outcome; + } + coreOutcome = pdf::PDFAttachmentWriter::remove(document, name); + break; + } + + case DirectEditKind::EditMetadata: + { + pdf::DocumentMetadataEdit edit; + edit.title = scalarString(request.scalars, TitleParameter, &malformed); + edit.author = scalarString(request.scalars, AuthorParameter, &malformed); + edit.subject = scalarString(request.scalars, SubjectParameter, &malformed); + edit.keywords = scalarString(request.scalars, KeywordsParameter, &malformed); + edit.creator = scalarString(request.scalars, CreatorParameter, &malformed); + if (malformed || (!edit.title && !edit.author && !edit.subject && !edit.keywords && !edit.creator)) + { + outcome.typedError = QStringLiteral("direct-edit/invalid-request"); + return outcome; + } + coreOutcome = pdf::PDFMetadataWriter::update(document, edit); + break; + } + + case DirectEditKind::CreateAnnotation: + case DirectEditKind::EditOutline: + // The dispatch routes these to their own helpers; a direct call is + // refused rather than handed to a document writer that would not + // understand the payload. + outcome.typedError = QStringLiteral("direct-edit/unsupported-kind"); + return outcome; + } + + if (!coreOutcome.ok()) + { + outcome.typedError = QString::fromLatin1(pdf::getDirectEditRefusalCode(coreOutcome.refusal)); + return outcome; + } + + // Accept the edit: the new document replaces the current one, which is what + // advances the revision exactly once. markModified() is deliberately not + // used here -- it would advance the revision a second time for one edit. + if (m_outputState == DocumentOutputState::Pending) + { + supersedePending(CommandTerminalState::Cancelled, QStringLiteral("document/superseded")); + } + documentContext->setDocument(coreOutcome.document); + if (!m_publishedKey.isEmpty()) + { + m_submitter->publishCurrentRevision(m_publishedKey, m_revisionSource.currentRevision()); + } + setOutputState(DocumentOutputState::None); + setFacets(m_facets | DocumentFacet::Dirty); + updateAvailability(); + + outcome.accepted = true; + outcome.revision = m_revisionSource.currentRevision(); + return outcome; +} + +bool DocumentFacade::directEditAllowed(DirectEditKind kind) const +{ + const auto* documentContext = context(); + const auto* document = m_state == DocumentState::Ready && documentContext ? documentContext->getDocument() : nullptr; + const auto* security = document ? document->getStorage().getSecurityHandler() : nullptr; + if (!security) + { + return false; + } + + using Permission = pdf::PDFSecurityHandler::Permission; + switch (kind) + { + case DirectEditKind::CreateAnnotation: + return security->isAllowed(Permission::ModifyInteractiveItems); + + case DirectEditKind::EditOutline: + return security->isAllowed(Permission::Modify); + + case DirectEditKind::SetFormFieldValue: + return security->isAllowed(Permission::ModifyFormFields); + + case DirectEditKind::AddAttachment: + case DirectEditKind::RemoveAttachment: + case DirectEditKind::EditMetadata: + return security->isAllowed(Permission::Modify); + } + + return false; +} + void DocumentFacade::noteDirectMutation() { // Same lifecycle bookkeeping as markModified(), minus the context revision diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index f0bd897f..d8a646d0 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -1800,14 +1800,15 @@ void DocumentFacadeTest::authoringEditAdvancesTheRevisionAndWritesTheOutline() const pdf::PDFRevisionIdentity before = harness.facade->currentRevision(); const QString documentKey = harness.context.getDocumentIdentity().documentId; - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 2; - request.edit.text = QStringLiteral("Appendix"); + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.authoring.pageIndex = 2; + request.authoring.text = QStringLiteral("Appendix"); request.revision = before; - const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); - QVERIFY2(outcome.applied, qPrintable(outcome.typedError)); + const pdfinteraction::DirectEditOutcome outcome = harness.facade->applyDirectEdit(request); + QVERIFY2(outcome.accepted, qPrintable(outcome.typedError)); QCOMPARE(outcome.resolution, pdf::PDFAuthoringEditResult::Kind::BookmarkAdded); QVERIFY(outcome.revision != before); QCOMPARE(outcome.revision, harness.facade->currentRevision()); @@ -1846,13 +1847,14 @@ void DocumentFacadeTest::authoringEditRefusesAStaleRevisionWithoutMutating() const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); const pdf::PDFRevisionIdentity revisionBefore = harness.facade->currentRevision(); - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 1; + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.authoring.pageIndex = 1; request.revision = displayed; - const pdfinteraction::DocumentEditOutcome outcome = harness.facade->applyAuthoringEdit(request); - QVERIFY(!outcome.applied); + const pdfinteraction::DirectEditOutcome outcome = harness.facade->applyDirectEdit(request); + QVERIFY(!outcome.accepted); QCOMPARE(outcome.typedError, QStringLiteral("edit/stale-revision")); // A refusal produces no revision at all. QVERIFY(!outcome.revision.isValid()); @@ -1872,13 +1874,14 @@ void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() { Harness harness; - pdfinteraction::DocumentEditRequest request; - request.edit.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; - request.edit.pageIndex = 0; + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.authoring.pageIndex = 0; request.revision = harness.facade->currentRevision(); - const pdfinteraction::DocumentEditOutcome noDocument = harness.facade->applyAuthoringEdit(request); - QVERIFY(!noDocument.applied); + const pdfinteraction::DirectEditOutcome noDocument = harness.facade->applyDirectEdit(request); + QVERIFY(!noDocument.accepted); QCOMPARE(noDocument.typedError, QStringLiteral("edit/document-not-ready")); // A document whose user permissions deny Modify refuses the edit with the @@ -1910,8 +1913,7 @@ void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() { *accepted = true; } - return QStringLiteral("fixture-user"); - }, true, false); + return QStringLiteral("fixture-user"); }, true, false); pdf::PDFDocument restricted = reader.readFromFile(path); QCOMPARE(reader.getReadingResult(), pdf::PDFDocumentReader::Result::OK); @@ -1923,8 +1925,8 @@ void DocumentFacadeTest::authoringEditRefusesWithoutAReadyDocumentOrPermission() request.revision = harness.facade->currentRevision(); const pdf::PDFDocumentPointer documentBefore = harness.context.getDocumentPointer(); - const pdfinteraction::DocumentEditOutcome denied = harness.facade->applyAuthoringEdit(request); - QVERIFY(!denied.applied); + const pdfinteraction::DirectEditOutcome denied = harness.facade->applyDirectEdit(request); + QVERIFY(!denied.accepted); QCOMPARE(denied.typedError, QStringLiteral("edit/modify-permission-denied")); QCOMPARE(harness.context.getDocumentPointer().data(), documentBefore.data()); } From afbd74da9c6f635fbae9a25631d89f9e0ede682e Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:36:40 -0700 Subject: [PATCH 08/11] fix(architecture): register the authoring test target and satisfy the proof lanes (#231 PR 2) Adds UnitTestsAuthoringEdit to the core module (paths + tests) so the catalog target has a proof lane, rewrites the evidence manifest against the touched subsystems' required lanes (core, interaction, quick, build_policy, governed-execution incl. the canonical-json-golden fixture and packaging workflows; the independent-validation stub is listed unresolved), and clang-formats pdfauthoringedit.h. --- LoopLibCore/sources/pdfauthoringedit.h | 20 +++++----- agent-policy.json | 2 + changes/gh-231-quick-mutations.evidence.yaml | 39 +++++++++++--------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/LoopLibCore/sources/pdfauthoringedit.h b/LoopLibCore/sources/pdfauthoringedit.h index c4a51cf8..645a272b 100644 --- a/LoopLibCore/sources/pdfauthoringedit.h +++ b/LoopLibCore/sources/pdfauthoringedit.h @@ -77,22 +77,22 @@ struct PDFAuthoringEdit { enum class Kind { - ToggleBookmark, ///< Add the page's bookmark, or remove it when one targets the page. + ToggleBookmark, ///< Add the page's bookmark, or remove it when one targets the page. GenerateBookmarks, ///< Add a bookmark for every page that has none. - ReplaceBookmarks, ///< Replace the outline with `bookmarks`. - AddUriLink, ///< Link annotation with a URI action. + ReplaceBookmarks, ///< Replace the outline with `bookmarks`. + AddUriLink, ///< Link annotation with a URI action. AddDestinationLink ///< Link annotation with a GoTo action of `destinationType`. }; Kind kind = Kind::ToggleBookmark; - QString text; ///< Bookmark title, or the URI of a URI link. - int pageIndex = -1; ///< Bookmark target page, or the link rectangle's page. - QRectF rectangle; ///< Link rectangle, page space. - int targetPageIndex = -1; ///< Link destination page. + QString text; ///< Bookmark title, or the URI of a URI link. + int pageIndex = -1; ///< Bookmark target page, or the link rectangle's page. + QRectF rectangle; ///< Link rectangle, page space. + int targetPageIndex = -1; ///< Link destination page. DestinationType destinationType = DestinationType::Fit; - QRectF targetRectangle; ///< FitR / XYZ destination rectangle, page space. + QRectF targetRectangle; ///< FitR / XYZ destination rectangle, page space. PDFReal zoom = std::numeric_limits::quiet_NaN(); ///< XYZ zoom; NaN inherits. - QList bookmarks; ///< ReplaceBookmarks payload. + QList bookmarks; ///< ReplaceBookmarks payload. }; /// How one edit resolved. `Refused` means no document was written and @@ -110,7 +110,7 @@ struct PDFAuthoringEditResult }; Kind kind = Kind::Refused; - QString typedError; ///< Set exactly when kind == Refused. + QString typedError; ///< Set exactly when kind == Refused. int affectedCount = 0; ///< Bookmarks written by Generated/Replaced. PDFDocumentPointer document; PDFModifiedDocument::ModificationFlags flags; diff --git a/agent-policy.json b/agent-policy.json index d84949ca..b38245f3 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -82,6 +82,7 @@ "core": { "paths": [ "LoopLibCore/**", + "UnitTests/tst_authoringedittest.cpp", "UnitTests/tst_bleedfixuptest.cpp", "UnitTests/tst_bleedmarginprobetest.cpp", "UnitTests/tst_bleedstresstest.cpp", @@ -137,6 +138,7 @@ "UnitTestsGovernedExecution", "UnitTestsHugeDocumentEnvelope", "UnitTestsApplicationIdentity", + "UnitTestsAuthoringEdit", "UnitTestsIdentitySeparation", "UnitTestsImageDecodeGuard", "UnitTestsImageOptimizer", diff --git a/changes/gh-231-quick-mutations.evidence.yaml b/changes/gh-231-quick-mutations.evidence.yaml index da7546aa..af028b1d 100644 --- a/changes/gh-231-quick-mutations.evidence.yaml +++ b/changes/gh-231-quick-mutations.evidence.yaml @@ -3,30 +3,35 @@ kind: evidence claims: - id: authoring-edit-gateway evidence: - - unit:UnitTestsAuthoringEdit - - unit:UnitTestsAnnotationAuthoring - - unit:UnitTestsDirectEdit - - unit:UnitTestsDocumentFacade - - unit:agent-policy:interaction - unit:agent-policy:core + - unit:agent-policy:interaction - id: quick-authoring-surfaces evidence: - - unit:UnitTestsEditorHost - - unit:UnitTestsShellWorkspace - - unit:UnitTestsShellKeyboard - - unit:UnitTestsProductOperatorLoop - unit:agent-policy:quick - - id: catalog-contract + - id: governed-execution-parity + evidence: + - unit:UnitTestsGovernedExecution + - unit:UnitTestsProductOperatorLoop + - unit:scripts/ci/test_check_governed_parity.py + - fixture:regression:canonical-json-golden + - architecture:agent-policy:core + - packaging:linux-build + - packaging:windows-build + - id: core-contracts + evidence: + - architecture:docs/generated/architecture-catalog.json + - security:scripts/ci/check_source_integrity.py + - differential:UnitTestsConversionOracle + - id: build-policy evidence: - - unit:scripts/ci/test_command_catalog.py - - architecture:scripts/verify-command-catalog.py + - unit:agent-policy:build_policy + - id: documentation-and-catalogs + evidence: + - architecture:scripts/generate-architecture-catalogs.py - id: agent-policy-contracts evidence: - unit:scripts/agent/test_architecture_contracts.py - architecture:scripts/agent/check-architecture.py - architecture:scripts/agent/generate-adapters.py - - architecture:scripts/generate-architecture-catalogs.py - - id: documentation-and-catalogs - evidence: - - unit:agent-policy:documentation -unresolved: [] +unresolved: + - core:scripts/ci/check_independent_validation_gate.py From 9d60302cbad2b82b4ee94af4f0117bc4ab0ec516 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:29:38 -0700 Subject: [PATCH 09/11] chore(generated): regenerate the phase5 widgets inventory for the new test targets (#231 PR 2) --- docs/generated/phase5-widgets-inventory.json | 86 +++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index cfec5f31..d362fb24 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -94,6 +94,8 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loop-preflight/tools/CMakeLists.txt", "tools/CanvasBenchmark/CMakeLists.txt", "tools/CodeGenerator/CMakeLists.txt", @@ -407,6 +409,7 @@ "UnitTestsActionList", "UnitTestsApplicationIdentity", "UnitTestsAuthorSettings", + "UnitTestsAuthoringEdit", "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", "UnitTestsBleedMarginProbe", @@ -417,6 +420,7 @@ "UnitTestsContentProcessorLimits", "UnitTestsConversionOracle", "UnitTestsDiagnostics", + "UnitTestsDirectEdit", "UnitTestsDocumentSearch", "UnitTestsDocumentSession", "UnitTestsEvidenceGraph", @@ -979,6 +983,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsAuthoringEdit", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsBenchmarkIdentity", "kind": "executable", @@ -1406,6 +1450,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsDirectEdit", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsDocumentSearch", "kind": "executable", @@ -3691,7 +3775,7 @@ } ], "counts": { - "targets": 84, + "targets": 86, "installed_in_profile": 5, "build_only_in_profile": 3, "widgets_surfaces": 4, From 826da2dac757062ce7c6cea8fee13d04432f327f Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:20 -0700 Subject: [PATCH 10/11] fix(core): give PDFBookmarkRecord an explicit equality for GCC The defaulted operator== makes GCC 13 instantiate the definition while QList's own equality is still forming (has_operator_equal probe), a hard error CI caught; the explicit declaration is portable and MSVC-identical. --- LoopLibCore/sources/pdfauthoringedit.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/LoopLibCore/sources/pdfauthoringedit.h b/LoopLibCore/sources/pdfauthoringedit.h index 645a272b..5b8ca35b 100644 --- a/LoopLibCore/sources/pdfauthoringedit.h +++ b/LoopLibCore/sources/pdfauthoringedit.h @@ -45,7 +45,14 @@ struct PDFBookmarkRecord int pageIndex = -1; QList children; - bool operator==(const PDFBookmarkRecord&) const = default; + /// Explicit rather than defaulted: a defaulted equality makes GCC 13's + /// `has_operator_equal` probe instantiate the definition while QList's own + /// equality is still being formed, which is a hard error there (MSVC + /// accepts it). The explicit declaration short-circuits the probe. + bool operator==(const PDFBookmarkRecord& other) const + { + return title == other.title && pageIndex == other.pageIndex && children == other.children; + } }; /// The bookmark interchange file of the direct-edit family (ADR-014). From 2035288ca1aaa0f7b3e113eb7749cf63365ed740 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:42:53 -0700 Subject: [PATCH 11/11] fix(tests): keep the parsed link annotation alive in the read-back helpers firstLinkOnPage/linkOnPage returned a raw pointer into a temporary or local PDFAnnotationPtr, so the annotation was freed before use; glibc's freed-chunk metadata clobbers the vtable and the next dynamic_cast segfaults (MSVC survived by heap luck). The helpers now return the owning pointer and call sites downcast explicitly. --- UnitTests/tst_authoringedittest.cpp | 16 +++++++++++----- UnitTests/tst_editorhosttest.cpp | 13 ++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/UnitTests/tst_authoringedittest.cpp b/UnitTests/tst_authoringedittest.cpp index 8bec2cf3..afc53089 100644 --- a/UnitTests/tst_authoringedittest.cpp +++ b/UnitTests/tst_authoringedittest.cpp @@ -89,13 +89,17 @@ std::vector annotationsOnPage(const pdf::PDFDocument& doc return annotations; } -const pdf::PDFLinkAnnotation* firstLinkOnPage(const pdf::PDFDocument& document, int pageIndex) +pdf::PDFAnnotationPtr firstLinkOnPage(const pdf::PDFDocument& document, int pageIndex) { for (const pdf::PDFAnnotationPtr& annotation : annotationsOnPage(document, pageIndex)) { - if (const auto* link = dynamic_cast(annotation.data())) + if (dynamic_cast(annotation.data())) { - return link; + // Return the owning pointer: the annotations list is a temporary, so + // a raw pointer into it dangles as soon as the loop ends (glibc's + // freed-chunk metadata then clobbers the vtable and the next + // dynamic_cast crashes; MSVC happened to survive). + return annotation; } } return nullptr; @@ -414,7 +418,8 @@ void AuthoringEditTest::destinationVariantWritesItsPdfDestinationType() const std::optional parsed = writeAndRead(*applied.document); QVERIFY(parsed.has_value()); - const pdf::PDFLinkAnnotation* link = firstLinkOnPage(*parsed, 0); + const pdf::PDFAnnotationPtr linkAnnotation = firstLinkOnPage(*parsed, 0); + const auto* link = dynamic_cast(linkAnnotation.data()); QVERIFY(link); QCOMPARE(link->getRectangle(), linkRectangle); @@ -482,7 +487,8 @@ void AuthoringEditTest::uriLinkWritesTheUriAction() const std::optional parsed = writeAndRead(*applied.document); QVERIFY(parsed.has_value()); - const pdf::PDFLinkAnnotation* link = firstLinkOnPage(*parsed, 0); + const pdf::PDFAnnotationPtr linkAnnotation = firstLinkOnPage(*parsed, 0); + const auto* link = dynamic_cast(linkAnnotation.data()); QVERIFY(link); QCOMPARE(link->getRectangle(), edit.rectangle); diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 817a15d0..a6912a54 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -126,7 +126,7 @@ void waitForSurfacesToSettle(EditorHost& host) QTRY_VERIFY_WITH_TIMEOUT(surfaces->counters().inFlight == 0, 15000); } -const pdf::PDFLinkAnnotation* linkOnPage(const pdf::PDFDocument* document, int pageIndex) +pdf::PDFAnnotationPtr linkOnPage(const pdf::PDFDocument* document, int pageIndex) { if (!document) { @@ -141,10 +141,12 @@ const pdf::PDFLinkAnnotation* linkOnPage(const pdf::PDFDocument* document, int p for (const pdf::PDFObjectReference& reference : page->getAnnotations()) { - const pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), reference); - if (const auto* link = dynamic_cast(annotation.data())) + pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), reference); + if (dynamic_cast(annotation.data())) { - return link; + // Return the owning pointer; the local parse result would free the + // annotation at return and hand the caller a dangling raw pointer. + return annotation; } } return nullptr; @@ -4393,7 +4395,8 @@ void EditorHostTest::hyperlinkCommandsWriteEveryDestinationVariant() // Read back through Core: the link annotation carries the destination the // command named, and the destination carries its own payload. const pdf::PDFDocumentPointer document = host.sessionForTest()->context().getDocumentPointer(); - const pdf::PDFLinkAnnotation* link = linkOnPage(document.data(), 0); + const pdf::PDFAnnotationPtr linkAnnotation = linkOnPage(document.data(), 0); + const auto* link = dynamic_cast(linkAnnotation.data()); QVERIFY(link); QCOMPARE(link->getRectangle(), QRectF(QPointF(10, 20), QPointF(60, 40)));