diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index b42a1b31..e28f9ef1 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" @@ -44,7 +45,9 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdirectedit.h" #include "pdfdocumentimageextractor.h" +#include "pdfannotationauthoring.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" @@ -85,6 +88,7 @@ #include #include +#include #include #include #include @@ -99,6 +103,56 @@ const QString QuitCommandId = QStringLiteral("actionQuit"); const QString MoveSelectionCommandId = QStringLiteral("actionMoveSelection"); const QString TranslatePageBoxOperationId = QStringLiteral("translate-page-box"); +/// The destination type each `actionCreateHyperlinkToThisPDF*` command writes +/// (issue #233). `XYZInheritZoom` is the XYZ destination with a null zoom, +/// which asks the viewer to keep its own zoom. +struct HyperlinkDestinationVariant +{ + const char* command; + pdf::DestinationType type; + bool inheritZoom; +}; + +const HyperlinkDestinationVariant HyperlinkDestinationVariants[] = { + { "actionCreateHyperlinkToThisPDFFit", pdf::DestinationType::Fit, false }, + { "actionCreateHyperlinkToThisPDFFitB", pdf::DestinationType::FitB, false }, + { "actionCreateHyperlinkToThisPDFFitBH", pdf::DestinationType::FitBH, false }, + { "actionCreateHyperlinkToThisPDFFitBV", pdf::DestinationType::FitBV, false }, + { "actionCreateHyperlinkToThisPDFFitH", pdf::DestinationType::FitH, false }, + { "actionCreateHyperlinkToThisPDFFitR", pdf::DestinationType::FitR, false }, + { "actionCreateHyperlinkToThisPDFFitV", pdf::DestinationType::FitV, false }, + { "actionCreateHyperlinkToThisPDFXYZ", pdf::DestinationType::XYZ, false }, + { "actionCreateHyperlinkToThisPDFXYZInheritZoom", pdf::DestinationType::XYZ, true }, +}; + +/// Reads a page-space rectangle from four scalar parameters, or nothing when +/// the surface supplied none. A partial rectangle is refused: a corner the +/// surface never gave is not a corner the host may invent. +std::optional rectangleFromParameters(const QVariantMap& parameters, + const QString& leftKey, + const QString& bottomKey, + const QString& rightKey, + const QString& topKey, + QString& typedError) +{ + const bool any = parameters.contains(leftKey) || parameters.contains(bottomKey) || + parameters.contains(rightKey) || parameters.contains(topKey); + if (!any) + { + return std::nullopt; + } + + if (!parameters.contains(leftKey) || !parameters.contains(bottomKey) || + !parameters.contains(rightKey) || !parameters.contains(topKey)) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return std::nullopt; + } + + return QRectF(QPointF(parameters.value(leftKey).toDouble(), parameters.value(bottomKey).toDouble()), + QPointF(parameters.value(rightKey).toDouble(), parameters.value(topKey).toDouble())); +} + QString actionListBindingsHash(const QJsonObject& bindings) { return QString::fromLatin1(QCryptographicHash::hash(QJsonDocument(bindings).toJson(QJsonDocument::Compact), @@ -755,6 +809,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; @@ -4462,10 +4540,26 @@ bool EditorHost::commandPermissionAllowed(const QString& commandId) const return permissions.copy; } if (commandId == MoveSelectionCommandId || commandId == QLatin1String("actionPageGeometry") || - commandId == QLatin1String("actionInsertPageNumbers") || commandId == QLatin1String("actionStickyNoteInsert")) + commandId == QLatin1String("actionInsertPageNumbers")) { return permissions.allowsCorrection(); } + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + if (commandId == entry.first) + { + return permissions.modifyAnnotations; + } + } + if (commandId == QLatin1String("actionSetFormFieldValue")) + { + return m_documentModel.canFillForms(); + } + if (commandId == QLatin1String("actionAddAttachment") || commandId == QLatin1String("actionRemoveAttachment") || + commandId == QLatin1String("actionEditDocumentMetadata")) + { + return m_documentModel.canModify(); + } return true; } @@ -5100,9 +5194,466 @@ 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)); + } + + + // ---- Annotation authoring (issue #234) --------------------------------- + // One handler per declared annotation command. The handler parses geometry + // at this boundary, submits the typed request to the facade's direct-edit + // gateway (ADR-014) and surfaces the gateway's typed refusal; it never + // touches the document itself. An accepted edit is a new revision, and the + // revision-changed path already refreshes the canvas and the annotation + // overlay, so no annotation state is cached here. + for (const auto& entry : pdfinteraction::annotationAuthoringCommandIds()) + { + const pdf::AnnotationAuthoringSubtype subtype = entry.second; + pdfinteraction::CommandCatalog::Handler authoring; + authoring.invoke = [this, subtype](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + QString refusal; + const std::optional parsed = + pdfinteraction::directEditRequestFromParameters(subtype, parameters, &refusal); + if (!parsed) + { + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, refusal); + bumpPresentation(); + return; + } + + pdfinteraction::DirectEditRequest request = *parsed; + request.revision = m_session->facade().currentRevision(); + request.operatorIdentity = QStringLiteral("Editor"); + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); + if (!outcome.accepted) + { + // A menu command that changed nothing must say why, in the same + // announcement channel every other operator-visible outcome uses. + announceDocumentState(tr("The annotation was not created: %1").arg(outcome.typedError)); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Failed, outcome.typedError); + } + else + { + announceDocumentState(tr("Annotation added.")); + // The annotation exists. A provenance failure is reported on the + // completed invocation instead of pretending the edit did not land. + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed, + outcome.historyAppended ? QString() : outcome.historyError); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(entry.first, std::move(authoring)); + } + + + // ---- Direct authoring edits (ADR-014, issue #242) ------------------------------ + // Form values, attachments and metadata are mutations through this catalog + // into Core: each handler maps its declared scalars into the facade's typed + // direct-edit request, and the facade checks the revision fence and the + // document's permission before any writer runs. A refusal finishes Failed + // with the facade's own stable code, so a stale, unauthorized, XFA or + // unsupported edit is reported rather than silently dropped. + auto bindDocumentDirectEdit = [this](const QString& id, pdfinteraction::DirectEditKind kind) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, kind](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) + { + const pdfinteraction::DirectEditOutcome result = submitDocumentDirectEdit(kind, parameters); + m_session->catalog().finishInvocation(invocation, + result.accepted ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + result.accepted ? QString() : result.typedError); + if (result.accepted) + { + announceDocumentState(tr("The document was edited. Save to publish the new revision.")); + // The document was replaced in place, so the surfaces the new + // revision needs are requested now rather than at the next + // viewport change. + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(result.typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindDocumentDirectEdit(QStringLiteral("actionSetFormFieldValue"), pdfinteraction::DirectEditKind::SetFormFieldValue); + bindDocumentDirectEdit(QStringLiteral("actionAddAttachment"), pdfinteraction::DirectEditKind::AddAttachment); + bindDocumentDirectEdit(QStringLiteral("actionRemoveAttachment"), pdfinteraction::DirectEditKind::RemoveAttachment); + bindDocumentDirectEdit(QStringLiteral("actionEditDocumentMetadata"), pdfinteraction::DirectEditKind::EditMetadata); + + // Send by e-mail hands the current published revision to the mail client and + // writes nothing. An unsaved document is refused with a typed reason: the + // bytes on disk are then a different revision than the one on screen. + pdfinteraction::CommandCatalog::Handler sendByMail; + sendByMail.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + QString typedError; + const bool handed = handOffPublishedRevisionToMail(&typedError); + m_session->catalog().finishInvocation(invocation, + handed ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + handed ? QString() : typedError); + if (!handed) + { + announceDocumentState(tr("The document was not handed to the mail client: %1").arg(typedError)); + } + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionSend_by_E_Mail"), std::move(sendByMail)); + refreshFeatureAvailability(); } +pdfinteraction::DirectEditOutcome EditorHost::submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters) +{ + pdfinteraction::DirectEditOutcome outcome; + if (!hasDocument()) + { + outcome.typedError = QStringLiteral("document/not-ready"); + return outcome; + } + + pdfinteraction::DirectEditRequest request; + request.kind = kind; + request.scalars = parameters; + // The caller names the revision the edit was begun against. It is parsed at + // this boundary, and an absent or malformed identity stays invalid, so the + // gateway refuses it as a stale fence instead of guessing which revision + // the caller meant. + request.revision = pdf::PDFRevisionIdentity::fromString(parameters.value(QStringLiteral("revision")).toString()); + + if (kind == pdfinteraction::DirectEditKind::AddAttachment) + { + // The command carries a path; the host reads the file so the mutation + // gateway never touches the filesystem. + constexpr qint64 maximumAttachmentBytes = 64ll * 1024 * 1024; + const QString source = parameters.value(QStringLiteral("path")).toString(); + QFile file(source); + if (source.isEmpty() || !file.open(QIODevice::ReadOnly)) + { + outcome.typedError = QStringLiteral("attachment/source-unreadable"); + return outcome; + } + if (file.size() > maximumAttachmentBytes) + { + outcome.typedError = QStringLiteral("attachment/source-too-large"); + return outcome; + } + request.contents = file.readAll(); + } + + return m_session->facade().applyDirectEdit(request); +} + +bool EditorHost::handOffPublishedRevisionToMail(QString* typedError) +{ + typedError->clear(); + if (!hasDocument()) + { + *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(); @@ -5159,9 +5710,249 @@ 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 + // export uses. + const bool canModify = ready && m_session->facade().permissions().modify; + for (const QString& id : { QStringLiteral("actionBookmarkPage"), + QStringLiteral("actionBookmarkAutoGenerate"), + QStringLiteral("actionBookmarkImport"), + QStringLiteral("actionCreateHyperlink"), + QStringLiteral("actionCreateHyperlinkToThisPDFFit"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitB"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitBV"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitH"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitR"), + QStringLiteral("actionCreateHyperlinkToThisPDFFitV"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZ"), + QStringLiteral("actionCreateHyperlinkToThisPDFXYZInheritZoom") }) + { + availability.insert(id, canModify); + } + availability.insert(QStringLiteral("actionBookmarkExport"), ready && m_documentModel.canCopy()); + for (const QString& id : { QStringLiteral("actionGotoNextBookmark"), QStringLiteral("actionGotoPreviousBookmark") }) + { + availability.insert(id, ready && m_documentModel.hasOutline()); + } + m_session->catalog().setEnabledBatch(availability); } +void EditorHost::runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters) +{ + QString typedError = buildError; + pdfinteraction::DirectEditOutcome outcome; + if (edit.has_value()) + { + outcome = submitAuthoringEdit(*edit, parameters, typedError); + } + + const bool applied = edit.has_value() && outcome.accepted; + if (applied) + { + announceDocumentState(authoringEditAnnouncement(*edit, outcome)); + } + else + { + announceDocumentState(tr("The edit was refused: %1").arg(typedError)); + } + + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : typedError); + bumpPresentation(); + refreshFeatureAvailability(); +} + +pdfinteraction::DirectEditOutcome EditorHost::submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError) +{ + const pdf::PDFRevisionIdentity current = m_session->facade().currentRevision(); + const QString displayed = parameters.value(QStringLiteral("revision")).toString(); + if (!displayed.isEmpty() && displayed != current.toString()) + { + // The surface acted on a revision the document has left; the edit is + // refused rather than rebased onto one the operator never saw. + typedError = QStringLiteral("edit/stale-revision"); + return {}; + } + + pdfinteraction::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring = edit; + request.revision = current; + const pdfinteraction::DirectEditOutcome outcome = m_session->facade().applyDirectEdit(request); + typedError = outcome.typedError; + return outcome; +} + +bool EditorHost::authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const +{ + const std::optional explicitRectangle = rectangleFromParameters(parameters, + QStringLiteral("left"), + QStringLiteral("bottom"), + QStringLiteral("right"), + QStringLiteral("top"), + typedError); + if (!typedError.isEmpty()) + { + return false; + } + + const QVariant page = parameters.value(QStringLiteral("page")); + if (explicitRectangle.has_value() || page.isValid()) + { + if (!explicitRectangle.has_value() || !page.isValid()) + { + typedError = QStringLiteral("hyperlink/incomplete-geometry"); + return false; + } + + *pageIndex = page.toInt(); + *rectangle = *explicitRectangle; + return true; + } + + // No explicit geometry: the current text selection is the shell's own + // "this region" gesture, already fenced to the revision it was taken from. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const std::optional& selection = interaction->state().textSelection(); + if (selection.has_value() && selection->isValid() && selection->available && !selection->geometry.isEmpty()) + { + QRectF bounds; + for (const QRectF& span : selection->geometry) + { + bounds = bounds.united(span); + } + + if (bounds.isValid() && !bounds.isEmpty()) + { + *pageIndex = selection->pageIndex; + *rectangle = bounds; + return true; + } + } + } + + typedError = QStringLiteral("hyperlink/link-rectangle-required"); + return false; +} + +bool EditorHost::goToAdjacentBookmark(int direction, QString& typedError) +{ + if (!hasDocument()) + { + typedError = QStringLiteral("bookmark/document-not-ready"); + return false; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + const QList pages = pdf::PDFAuthoringEditWriter::bookmarkPages(document.data()); + if (pages.isEmpty()) + { + typedError = QStringLiteral("bookmark/none"); + return false; + } + + // Outline order, not page order: the first bookmark after (or before) the + // current page, wrapping at the ends like the shell's historical bookmark + // cursor. The page the operator is already on is never its own destination. + const int current = currentPage(); + int target = -1; + if (direction > 0) + { + for (const int page : pages) + { + if (page > current) + { + target = page; + break; + } + } + if (target < 0) + { + target = pages.constFirst(); + } + } + else + { + for (auto it = pages.crbegin(); it != pages.crend(); ++it) + { + if (*it < current) + { + target = *it; + break; + } + } + if (target < 0) + { + target = pages.constLast(); + } + } + + goToPage(target); + return true; +} + +QString EditorHost::authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DirectEditOutcome& outcome) const +{ + switch (outcome.resolution) + { + case pdf::PDFAuthoringEditResult::Kind::BookmarkAdded: + return tr("Bookmark added for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarkRemoved: + return tr("Bookmark removed for page %1.").arg(edit.pageIndex + 1); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksGenerated: + return tr("Generated %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::BookmarksReplaced: + return tr("Imported %n bookmark(s).", "", outcome.affectedCount); + + case pdf::PDFAuthoringEditResult::Kind::LinkCreated: + return tr("Hyperlink created."); + + case pdf::PDFAuthoringEditResult::Kind::Refused: + break; + } + + return QString(); +} + void EditorHost::moveSearch(int direction) { const int count = m_documentModel.searchResults()->rowCount(); @@ -5979,6 +6770,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..fb7c98bf 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 @@ -169,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 @@ -355,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 @@ -617,6 +636,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 @@ -693,12 +724,25 @@ 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(); 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(); @@ -709,7 +753,45 @@ class EditorHost final : public QObject void registerFeatureHandlers(); void refreshFeatureAvailability(); bool commandPermissionAllowed(const QString& commandId) const; + /// Applies one document-family direct-edit command through the facade's + /// direct-edit gateway (ADR-014). The command's declared scalars become the + /// request's payload; for an attachment the host reads the source file, so + /// the gateway itself never touches the filesystem. The facade's typed + /// refusal is returned as it stands. + pdfinteraction::DirectEditOutcome submitDocumentDirectEdit(pdfinteraction::DirectEditKind kind, + const QVariantMap& parameters); + /// Hands the current published revision to the shell's mail client. Refuses + /// when there is no open document, when its source is not a file, or when it + /// has unsaved changes: the bytes on disk would then be a different revision + /// than the one on screen. Never writes and never opens anything itself. + bool handOffPublishedRevisionToMail(QString* typedError); bool requestMoveSelection(const QVariantMap& parameters); + + // ---- Bookmarks and hyperlinks (issue #233) ------------------------------------- + /// Runs one authoring command: submits the built edit through the facade + /// gateway and finishes the invocation with the outcome, announcing what + /// happened. `buildError` is the command's typed refusal when no edit could + /// be built. + void runAuthoringEdit(pdfinteraction::CommandInvocationId invocation, + const std::optional& edit, + const QString& buildError, + const QVariantMap& parameters); + /// The fence and the facade call behind every authoring command. `typedError` + /// is set on refusal. + pdfinteraction::DirectEditOutcome submitAuthoringEdit(const pdf::PDFAuthoringEdit& edit, + const QVariantMap& parameters, + QString& typedError); + /// The link rectangle and page a hyperlink command addresses: explicit + /// scalars when the surface supplied them, otherwise the current text + /// selection, which is the Quick shell's own "this region" gesture. + bool authoringLinkGeometry(const QVariantMap& parameters, int* pageIndex, QRectF* rectangle, + QString& typedError) const; + /// Moves to the next (1) or previous (-1) bookmark page in outline order, + /// wrapping at the ends like the shell's historical bookmark cursor. + bool goToAdjacentBookmark(int direction, QString& typedError); + /// The operator-facing sentence for one applied edit. + QString authoringEditAnnouncement(const pdf::PDFAuthoringEdit& edit, + const pdfinteraction::DirectEditOutcome& outcome) const; void moveSearch(int direction); bool moveFindingSelection(int direction); void refreshHitTestSources(); @@ -835,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 6a5d4499..bd66d6e0 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -28,6 +28,8 @@ add_library(LoopLibCore SHARED sources/pdfalgorithmlcs.h sources/pdfannotation.cpp sources/pdfannotation.h + sources/pdfannotationauthoring.cpp + sources/pdfannotationauthoring.h sources/pdfblendfunction.cpp sources/pdfblendfunction.h sources/pdfccittfaxdecoder.cpp @@ -136,6 +138,8 @@ add_library(LoopLibCore SHARED sources/pdfdocumenttextfloweditormodel.h sources/pdfdocumentwriter.cpp sources/pdfdocumentwriter.h + sources/pdfdirectedit.cpp + sources/pdfdirectedit.h sources/pdfwriteobjectvisitor.cpp sources/pdfwriteobjectvisitor_p.h sources/pdfexecutionpolicy.cpp @@ -166,6 +170,8 @@ add_library(LoopLibCore SHARED sources/pdfoptimizer.h sources/pdfoptionalcontent.cpp sources/pdfoptionalcontent.h + sources/pdfauthoringedit.cpp + sources/pdfauthoringedit.h sources/pdfoutline.cpp sources/pdfoutline.h sources/pdfpagenavigation.cpp @@ -286,6 +292,8 @@ add_library(LoopLibCore SHARED sources/pdfartifactidentity.cpp sources/pdfartifactstore.h sources/pdfartifactstore.cpp + sources/pdfauthoringhistory.h + sources/pdfauthoringhistory.cpp sources/pdfoperationhistory.h sources/pdfoperationhistory.cpp sources/pdfoperationhistorystore.h diff --git a/LoopLibCore/sources/pdfannotationauthoring.cpp b/LoopLibCore/sources/pdfannotationauthoring.cpp new file mode 100644 index 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/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..5b8ca35b --- /dev/null +++ b/LoopLibCore/sources/pdfauthoringedit.h @@ -0,0 +1,155 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFAUTHORINGEDIT_H +#define PDFAUTHORINGEDIT_H + +#include "pdfaction.h" +#include "pdfdocument.h" +#include "pdfglobal.h" + +#include +#include +#include + +#include +#include + +namespace pdf +{ + +/// One entry of the bookmark interchange file: a title and the 0-based page it +/// targets. `children` keeps the file's order, which is the outline order. +struct PDFBookmarkRecord +{ + QString title; + int pageIndex = -1; + QList children; + + /// Explicit rather than defaulted: a defaulted equality makes GCC 13's + /// `has_operator_equal` probe instantiate the definition while QList's own + /// equality is still being formed, which is a hard error there (MSVC + /// accepts it). The explicit declaration short-circuits the probe. + bool operator==(const PDFBookmarkRecord& other) const + { + return title == other.title && pageIndex == other.pageIndex && children == other.children; + } +}; + +/// The bookmark interchange file of the direct-edit family (ADR-014). +/// +/// Canonical JSON: `{"schema_version":1,"bookmarks":[{"title":…,"page":…, +/// "children":[…]}]}`. The format carries titles, page targets and nesting +/// only; outline state it cannot represent (structure elements, non-GoTo +/// actions) is not silently dropped by export — export refuses instead. +/// Parsing is total: malformed input returns a typed reason and no partial +/// tree. +class LOOPLIBCORESHARED_EXPORT PDFBookmarkFile +{ +public: + static std::optional fromJson(const QByteArray& payload, QString& typedError); + QByteArray toJson() const; + + /// Total records, including nested children. + int count() const; + + QList bookmarks; +}; + +/// What one authoring edit asks Core to write (ADR-014). +/// +/// All rectangles are in PDF page space (y-up); the writer normalizes inverted +/// corners and refuses degenerate ones. `zoom` uses a quiet NaN to write the +/// PDF `null` zoom, which is the "inherit the viewer's zoom" destination. +struct PDFAuthoringEdit +{ + enum class Kind + { + ToggleBookmark, ///< Add the page's bookmark, or remove it when one targets the page. + GenerateBookmarks, ///< Add a bookmark for every page that has none. + ReplaceBookmarks, ///< Replace the outline with `bookmarks`. + AddUriLink, ///< Link annotation with a URI action. + AddDestinationLink ///< Link annotation with a GoTo action of `destinationType`. + }; + + Kind kind = Kind::ToggleBookmark; + QString text; ///< Bookmark title, or the URI of a URI link. + int pageIndex = -1; ///< Bookmark target page, or the link rectangle's page. + QRectF rectangle; ///< Link rectangle, page space. + int targetPageIndex = -1; ///< Link destination page. + DestinationType destinationType = DestinationType::Fit; + QRectF targetRectangle; ///< FitR / XYZ destination rectangle, page space. + PDFReal zoom = std::numeric_limits::quiet_NaN(); ///< XYZ zoom; NaN inherits. + QList bookmarks; ///< ReplaceBookmarks payload. +}; + +/// How one edit resolved. `Refused` means no document was written and +/// `typedError` says why; every other kind carries the new document. +struct PDFAuthoringEditResult +{ + enum class Kind + { + Refused, + BookmarkAdded, + BookmarkRemoved, + BookmarksGenerated, + BookmarksReplaced, + LinkCreated + }; + + Kind kind = Kind::Refused; + QString typedError; ///< Set exactly when kind == Refused. + int affectedCount = 0; ///< Bookmarks written by Generated/Replaced. + PDFDocumentPointer document; + PDFModifiedDocument::ModificationFlags flags; + + bool applied() const { return kind != Kind::Refused; } +}; + +/// The Core writer behind the interaction layer's direct authoring edits +/// (ADR-014). Every edit builds a modified document through +/// PDFDocumentModifier/PDFDocumentBuilder; a refusal carries a typed reason and +/// leaves the input document untouched. +/// +/// An outline rewrite cannot represent state the builder's outline writer does +/// not write back (structure elements, actions that are neither GoTo nor +/// GoToDp), so edits that keep existing items refuse with +/// `bookmark/unsupported-outline-item` rather than dropping that state. +class LOOPLIBCORESHARED_EXPORT PDFAuthoringEditWriter +{ +public: + static PDFAuthoringEditResult apply(const PDFDocument* document, const PDFAuthoringEdit& edit); + + /// The document's outline as interchange records, in outline order. An + /// item without a resolvable page destination is refused with + /// `bookmark/unsupported-outline-item` rather than skipped. + static std::optional> readOutline(const PDFDocument* document, + QString& typedError); + + /// Flattened bookmark page targets in outline order, for bookmark + /// navigation. Items without a page target are not visited. + static QList bookmarkPages(const PDFDocument* document); +}; + +} // namespace pdf + +#endif // PDFAUTHORINGEDIT_H diff --git a/LoopLibCore/sources/pdfauthoringhistory.cpp b/LoopLibCore/sources/pdfauthoringhistory.cpp new file mode 100644 index 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/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/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..880e968d --- /dev/null +++ b/LoopLibInteraction/sources/directeditrequest.h @@ -0,0 +1,153 @@ +// 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 "pdfauthoringedit.h" +#include "pdfdocumentcontext.h" + +#include +#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, ///< 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); + +/// 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. +/// +/// 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 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 +/// 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; 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; +}; + +/// 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.cpp b/LoopLibInteraction/sources/documentfacade.cpp index 51f40675..f4866d1f 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()); diff --git a/LoopLibInteraction/sources/documentfacade.h b/LoopLibInteraction/sources/documentfacade.h index b36d4fe8..de8c7414 100644 --- a/LoopLibInteraction/sources/documentfacade.h +++ b/LoopLibInteraction/sources/documentfacade.h @@ -24,10 +24,12 @@ #define DOCUMENTFACADE_H #include "commandcatalog.h" +#include "directeditrequest.h" #include "documentcontextsource.h" #include "documentloader.h" #include "jobrelay.h" #include "jobsubmitter.h" +#include "pdfauthoringedit.h" #include "pdfpreflightverdict.h" #include "pdfsignaturehandler.h" @@ -35,6 +37,7 @@ #include #include #include +#include #include #include @@ -209,6 +212,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; } }; @@ -316,6 +324,22 @@ class DocumentFacade final : public QObject /// the lifecycle. void markModified(); + /// 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); /// Re-opens the current source. Replacement, not a refresh: the old identity @@ -369,12 +393,35 @@ 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); + + /// 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(); + + /// 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; 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(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 /// dangling read. diff --git a/LoopLibInteraction/sources/documentfacadeedits.cpp b/LoopLibInteraction/sources/documentfacadeedits.cpp new file mode 100644 index 00000000..ad9c9aa8 --- /dev/null +++ b/LoopLibInteraction/sources/documentfacadeedits.cpp @@ -0,0 +1,572 @@ +// 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 "pdfauthoringedit.h" +#include "pdfauthoringhistory.h" +#include "pdfdirectedit.h" +#include "pdfdocumentwriter.h" +#include "pdfresourcebudget.h" +#include "pdfsecurityhandler.h" + +#include +#include +#include + +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. +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"; + + 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"; +} + +bool parseDirectEditKind(const QString& name, DirectEditKind* kind) +{ + 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 (entry.first == name) + { + if (kind) + { + *kind = entry.second; + } + 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.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; +} + +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 + // 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/CMakeLists.txt b/UnitTests/CMakeLists.txt index 0da51db8..68154b11 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 ) @@ -843,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/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_authoringedittest.cpp b/UnitTests/tst_authoringedittest.cpp new file mode 100644 index 00000000..afc53089 --- /dev/null +++ b/UnitTests/tst_authoringedittest.cpp @@ -0,0 +1,591 @@ +// 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; +} + +pdf::PDFAnnotationPtr firstLinkOnPage(const pdf::PDFDocument& document, int pageIndex) +{ + for (const pdf::PDFAnnotationPtr& annotation : annotationsOnPage(document, pageIndex)) + { + if (dynamic_cast(annotation.data())) + { + // 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; +} + +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::PDFAnnotationPtr linkAnnotation = firstLinkOnPage(*parsed, 0); + const auto* link = dynamic_cast(linkAnnotation.data()); + 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::PDFAnnotationPtr linkAnnotation = firstLinkOnPage(*parsed, 0); + const auto* link = dynamic_cast(linkAnnotation.data()); + 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_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 9ef8391e..d8a646d0 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(); }; @@ -407,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); @@ -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, 94); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() @@ -1781,6 +1790,147 @@ 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::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::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()); + 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::DirectEditRequest request; + request.kind = pdfinteraction::DirectEditKind::EditOutline; + request.authoring.kind = pdf::PDFAuthoringEdit::Kind::ToggleBookmark; + request.authoring.pageIndex = 1; + request.revision = displayed; + + 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()); + + // 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::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::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 + // 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::DirectEditOutcome denied = harness.facade->applyDirectEdit(request); + QVERIFY(!denied.accepted); + 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..a6912a54 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -57,10 +57,17 @@ #include "quickdocumentmodel.h" #include "pdfapplicationidentity.h" +#include "pdfauthoringedit.h" +#include "pdfannotation.h" #include "pdfblockingthreadguard.h" +#include "pdfcatalog.h" +#include "pdfannotation.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentwriter.h" #include "pdfdocumentreader.h" +#include "pdfpage.h" +#include "pdfencoding.h" +#include "pdfform.h" #include "quickoutput.h" #include #include @@ -79,6 +86,83 @@ 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); +} + +pdf::PDFAnnotationPtr 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()) + { + pdf::PDFAnnotationPtr annotation = pdf::PDFAnnotation::parse(&document->getStorage(), reference); + if (dynamic_cast(annotation.data())) + { + // 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; +} + +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) { @@ -271,6 +355,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 @@ -326,6 +593,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(); @@ -346,11 +617,28 @@ 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(); + + // 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() { QStandardPaths::setTestModeEnabled(true); + qRegisterMetaType("pdfinteraction::CommandResult"); m_fixtureRecipes = std::make_unique(); QVERIFY(m_fixtureRecipes->isValid()); } @@ -2592,6 +2880,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(); @@ -3740,5 +4127,706 @@ 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::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))); + + 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)); +} + +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); + 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) #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/agent-policy.json b/agent-policy.json index 6a950133..b38245f3 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -82,12 +82,14 @@ "core": { "paths": [ "LoopLibCore/**", + "UnitTests/tst_authoringedittest.cpp", "UnitTests/tst_bleedfixuptest.cpp", "UnitTests/tst_bleedmarginprobetest.cpp", "UnitTests/tst_bleedstresstest.cpp", "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 +129,7 @@ "UnitTestsContentProcessorLimits", "UnitTestsConversionOracle", "UnitTestsDiagnostics", + "UnitTestsDirectEdit", "UnitTestsDocumentSession", "UnitTestsDocumentSearch", "UnitTestsEvidenceGraph", @@ -135,6 +138,7 @@ "UnitTestsGovernedExecution", "UnitTestsHugeDocumentEnvelope", "UnitTestsApplicationIdentity", + "UnitTestsAuthoringEdit", "UnitTestsIdentitySeparation", "UnitTestsImageDecodeGuard", "UnitTestsImageOptimizer", @@ -197,6 +201,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 +216,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/changes/gh-231-quick-mutations.evidence.yaml b/changes/gh-231-quick-mutations.evidence.yaml new file mode 100644 index 00000000..af028b1d --- /dev/null +++ b/changes/gh-231-quick-mutations.evidence.yaml @@ -0,0 +1,37 @@ +format_version: 1 +kind: evidence +claims: + - id: authoring-edit-gateway + evidence: + - unit:agent-policy:core + - unit:agent-policy:interaction + - id: quick-authoring-surfaces + evidence: + - unit:agent-policy:quick + - 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: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 +unresolved: + - core:scripts/ci/check_independent_validation_gate.py 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/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..5151ed05 --- /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 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 + 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 (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 + 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 diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 273af332..1b3d7d8a 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -399,7 +399,9 @@ "test_targets": [ "UnitTests", "UnitTestsActionList", + "UnitTestsAnnotationAuthoring", "UnitTestsApplicationIdentity", + "UnitTestsAuthoringEdit", "UnitTestsAuthorSettings", "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", @@ -413,6 +415,7 @@ "UnitTestsContentProcessorLimits", "UnitTestsConversionOracle", "UnitTestsDiagnostics", + "UnitTestsDirectEdit", "UnitTestsDocumentFacade", "UnitTestsDocumentSearch", "UnitTestsDocumentSession", 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, diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index 1daeaabc..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", @@ -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" }, @@ -272,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" }, @@ -285,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" }, @@ -298,10 +384,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 +433,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,62 +507,367 @@ "target": "Inspect", "command": { "label_key": "command.actionCreateHyperlinkToThisPDFFitB.label", - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" - }, - "menu_group": "Document" - }, - { - "id": "actionCreateHyperlinkToThisPDFFitBH", - "disposition": "KEEP", - "target": "Inspect", - "command": { - "label_key": "command.actionCreateHyperlinkToThisPDFFitBH.label", - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" - }, - "menu_group": "Document" - }, - { - "id": "actionCreateHyperlinkToThisPDFFitBV", - "disposition": "KEEP", - "target": "Inspect", - "command": { - "label_key": "command.actionCreateHyperlinkToThisPDFFitBV.label", - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" - }, - "menu_group": "Document" - }, - { - "id": "actionCreateHyperlinkToThisPDFFitH", - "disposition": "KEEP", - "target": "Inspect", - "command": { - "label_key": "command.actionCreateHyperlinkToThisPDFFitH.label", - "parameters": [], - "capability": "unclassified", - "cancellable": false, - "availability": "declared" - }, - "menu_group": "Document" - }, - { - "id": "actionCreateHyperlinkToThisPDFFitR", + "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": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionCreateHyperlinkToThisPDFFitBH", + "disposition": "KEEP", + "target": "Inspect", + "command": { + "label_key": "command.actionCreateHyperlinkToThisPDFFitBH.label", + "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": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionCreateHyperlinkToThisPDFFitBV", + "disposition": "KEEP", + "target": "Inspect", + "command": { + "label_key": "command.actionCreateHyperlinkToThisPDFFitBV.label", + "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": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionCreateHyperlinkToThisPDFFitH", + "disposition": "KEEP", + "target": "Inspect", + "command": { + "label_key": "command.actionCreateHyperlinkToThisPDFFitH.label", + "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": "implemented" + }, + "menu_group": "Document" + }, + { + "id": "actionCreateHyperlinkToThisPDFFitR", "disposition": "KEEP", "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 +877,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 +951,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 +1025,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" }, @@ -428,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" }, @@ -441,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" }, @@ -454,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" }, @@ -467,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" }, @@ -480,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" }, @@ -493,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" }, @@ -506,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" }, @@ -519,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" }, @@ -739,9 +1628,9 @@ "sequence": "Ctrl+." }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -787,9 +1676,9 @@ "sequence": "Ctrl+," }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -831,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" }, @@ -1408,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" }, @@ -1446,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" }, @@ -1459,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" }, @@ -1472,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" }, @@ -1485,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" }, @@ -1498,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" }, @@ -1511,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" }, @@ -1524,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" }, @@ -1628,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 7b9037a7..16a86e87 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -6,51 +6,16 @@ "unbound": [ "actionAutomaticDocumentRefresh", "actionBleedFixup", - "actionBookmarkAutoGenerate", - "actionBookmarkExport", - "actionBookmarkImport", - "actionBookmarkPage", "actionClearRecentFileHistory", "actionCreateBitonalDocument", - "actionCreateEllipse", - "actionCreateFreehandCurve", - "actionCreateHyperlink", - "actionCreateHyperlinkToThisPDFFit", - "actionCreateHyperlinkToThisPDFFitB", - "actionCreateHyperlinkToThisPDFFitBH", - "actionCreateHyperlinkToThisPDFFitBV", - "actionCreateHyperlinkToThisPDFFitH", - "actionCreateHyperlinkToThisPDFFitR", - "actionCreateHyperlinkToThisPDFFitV", - "actionCreateHyperlinkToThisPDFXYZ", - "actionCreateHyperlinkToThisPDFXYZInheritZoom", - "actionCreatePolygon", - "actionCreatePolyline", - "actionCreateRectangle", - "actionCreateStraightLine", - "actionCreateTextHighlight", - "actionCreateTextSquiggly", - "actionCreateTextStrikeout", - "actionCreateTextUnderline", "actionEncryption", "actionFirstPageOnRightSide", - "actionGotoNextBookmark", - "actionGotoPreviousBookmark", - "actionInlineText", "actionInsertPageNumbers", "actionOptimize", "actionOptimizeImages", "actionPageGeometry", "actionRemoveExternalLinks", "actionRender_to_Images", - "actionSanitize", - "actionSend_by_E_Mail", - "actionStickyNoteComment", - "actionStickyNoteHelp", - "actionStickyNoteInsert", - "actionStickyNoteKey", - "actionStickyNoteNewParagraph", - "actionStickyNoteNote", - "actionStickyNoteParagraph" + "actionSanitize" ] } diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index a221f150..d05b7110 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -112,7 +112,49 @@ "actionPrint", "actionExportDocument", "actionUndo", + "actionBookmarkAutoGenerate", + "actionBookmarkExport", + "actionBookmarkImport", + "actionBookmarkPage", + "actionCreateHyperlink", + "actionCreateHyperlinkToThisPDFFit", + "actionCreateHyperlinkToThisPDFFitB", + "actionCreateHyperlinkToThisPDFFitBH", + "actionCreateHyperlinkToThisPDFFitBV", + "actionCreateHyperlinkToThisPDFFitH", + "actionCreateHyperlinkToThisPDFFitR", + "actionCreateHyperlinkToThisPDFFitV", + "actionCreateHyperlinkToThisPDFXYZ", + "actionCreateHyperlinkToThisPDFXYZInheritZoom", + "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", + "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")