diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index 4be682549..3d95b92aa 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -47,6 +47,10 @@ qt_add_qml_module(LoopEditor qml/CanvasPane.qml qml/PreflightPane.qml qml/InspectorPane.qml + qml/WorkspacePlaceholderPane.qml + qml/ShellToolBar.qml + qml/ShellMenuBar.qml + qml/MenuModel.qml ) target_link_libraries(LoopEditor @@ -78,6 +82,10 @@ if(WIN32 AND LOOP_LOOP_DISTRIBUTION) set(_loop_editor_win32_executable OFF) endif() +if(LOOP_LOOP_DISTRIBUTION) + target_compile_definitions(LoopEditor PRIVATE LOOP_LOOP_DISTRIBUTION_BUILD=1) +endif() + set_target_properties(LoopEditor PROPERTIES WIN32_EXECUTABLE ${_loop_editor_win32_executable} MACOSX_BUNDLE ON diff --git a/LoopEditor/app.qrc b/LoopEditor/app.qrc index 937ba9215..2449fb301 100644 --- a/LoopEditor/app.qrc +++ b/LoopEditor/app.qrc @@ -1,5 +1,6 @@ app-icon.svg + ../loop-preflight/profiles/loop-default.json diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index dbfaca51d..05cb97765 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -28,10 +28,21 @@ #include "interactionstate.h" #include "interactiontarget.h" #include "loopcanvasitem.h" +#include "loopstatevisual.h" +#include "looptokens.h" #include "pagesurfacecoordinator.h" #include "preflightcontroller.h" +#include "preflightclirun.h" +#include "preflightengine.h" +#include "preflightprofileresolver.h" #include "previewstatemodel.h" +#include "productionmodel.h" +#include "interactiontarget.h" + +#include "pdfdocumentsession.h" +#include "pdfsafefilewriter.h" +#include "pdfblockingthreadguard.h" #include "pdfpage.h" #include "pdftransparencyrenderer.h" @@ -39,19 +50,29 @@ #include #include #include +#include +#include +#include +#include #include +#include #include #include #include +#include +#include #include +#include +#include #include +#include +#include namespace { const QString QuitCommandId = QStringLiteral("actionQuit"); - int rotationToDegrees(pdf::PageRotation rotation) { switch (rotation) @@ -87,10 +108,68 @@ QString preflightStateToString(pdfinteraction::PreflightController::State state) return QStringLiteral("stale"); case pdfinteraction::PreflightController::State::Incomplete: return QStringLiteral("incomplete"); + case pdfinteraction::PreflightController::State::Error: + return QStringLiteral("error"); } return QStringLiteral("not-checked"); } +QString shellMenuGroupForAction(const QString& id, const QString& target) +{ + static const QStringList fileActions = { + QStringLiteral("actionOpen"), + QStringLiteral("actionClose"), + QStringLiteral("actionSave"), + QStringLiteral("actionSave_As"), + QStringLiteral("actionQuit"), + QStringLiteral("actionPrint"), + QStringLiteral("actionSendByEmail"), + QStringLiteral("actionRenderToImages"), + QStringLiteral("actionClearRecentFileHistory"), + QStringLiteral("actionAutomaticDocumentRefresh"), + }; + if (fileActions.contains(id)) + { + return QStringLiteral("File"); + } + + if (id.startsWith(QStringLiteral("actionCopy")) || id.startsWith(QStringLiteral("actionCut")) || + id.startsWith(QStringLiteral("actionPaste")) || id == QStringLiteral("actionUndo") || + id == QStringLiteral("actionRedo")) + { + return QStringLiteral("Edit"); + } + + if (id.startsWith(QStringLiteral("actionZoom")) || id.startsWith(QStringLiteral("actionFit")) || + id.startsWith(QStringLiteral("actionRotate")) || id.startsWith(QStringLiteral("actionPageLayout")) || + id.startsWith(QStringLiteral("actionGoTo")) || id.startsWith(QStringLiteral("actionFind")) || + id == QStringLiteral("actionFullscreenMode")) + { + return QStringLiteral("View"); + } + + if (id == QStringLiteral("actionAbout") || id == QStringLiteral("actionBecomeASponsor") || + id == QStringLiteral("actionGet_Source")) + { + return QStringLiteral("Help"); + } + + if (target == QStringLiteral("Preflight")) + { + return QStringLiteral("Preflight"); + } + if (target == QStringLiteral("Production") || target == QStringLiteral("Pages") || target == QStringLiteral("Fix")) + { + return QStringLiteral("Production"); + } + if (target == QStringLiteral("Document") || target == QStringLiteral("Inspect")) + { + return QStringLiteral("Document"); + } + + return QStringLiteral("Document"); +} + QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descriptor, bool enabled) { QVariantMap entry; @@ -98,6 +177,9 @@ QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descrip entry.insert(QStringLiteral("labelKey"), descriptor.labelKey); entry.insert(QStringLiteral("implemented"), descriptor.isImplemented()); entry.insert(QStringLiteral("enabled"), enabled); + entry.insert(QStringLiteral("target"), descriptor.target); + entry.insert(QStringLiteral("disposition"), descriptor.disposition); + entry.insert(QStringLiteral("menuGroup"), shellMenuGroupForAction(descriptor.id, descriptor.target)); QVariantMap shortcut; shortcut.insert(QStringLiteral("standardKey"), descriptor.shortcut.standardKey); @@ -110,11 +192,29 @@ QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descrip } // namespace +struct EditorHost::PreflightWorkerOutcome +{ + pdf::PreflightResult result; +}; + EditorHost::EditorHost(QObject* parent) : QObject(parent), m_session(std::make_unique(this)), m_preflight(&m_session->scheduler(), this) { + // Registers this constructing thread -- the one QML dispatches pointer + // and frame callbacks on -- as the thread blocking service adapters + // (PreflightEngine::run, and future OCR/AI/file-I/O adapters) must + // refuse to run on (issue #144). + pdf::PDFBlockingThreadGuard::registerInteractiveThread(); + + m_preflightProfileWatcher = new QFileSystemWatcher(this); + connect(m_preflightProfileWatcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString&) + { reloadPreflightProfiles(); }); + connect(m_preflightProfileWatcher, &QFileSystemWatcher::fileChanged, this, [this](const QString&) + { reloadPreflightProfiles(); }); + reloadPreflightProfiles(); + connectFacade(); connectViewport(); connectCatalog(); @@ -128,20 +228,46 @@ EditorHost::EditorHost(QObject* parent) : m_preflightOverlayBridge.setInteractionController(m_session->interaction()); connect(&m_preflight, &pdfinteraction::PreflightController::stateChanged, this, &EditorHost::bumpPresentation); + connect(&m_preflight, &pdfinteraction::PreflightController::progressChanged, this, &EditorHost::bumpPresentation); connect(m_preflight.findingsModel(), &pdfinteraction::PreflightFindingsModel::findingsReplaced, this, &EditorHost::refreshHitTestSources); connect(&m_preflight, &pdfinteraction::PreflightController::navigationRequested, this, &EditorHost::onPreflightNavigation); connect(&m_inspector, &pdfinteraction::InspectorModel::selectionChanged, this, &EditorHost::bumpPresentation); connect(&m_preview, &pdfinteraction::PreviewStateModel::stateChanged, this, &EditorHost::bumpPresentation); + connect(&m_production, &pdfinteraction::ProductionModel::stateChanged, this, &EditorHost::bumpPresentation); connect(&m_documentModel, &QuickDocumentModel::searchChanged, this, [this] { refreshFeatureAvailability(); bumpPresentation(); bumpCommandEpoch(); }); + + connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobQueued, this, [this](const pdf::PDFJobSnapshot& snapshot) + { + m_activeAsyncJobs.insert(snapshot.jobId, snapshot.kind); + refreshCanvasTrace(); }); + connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobProgress, this, [this](const pdf::PDFJobSnapshot& snapshot) + { m_preflight.updateProgress(snapshot.jobId, snapshot.documentRevision, snapshot.progress); }); + connect(&m_session->scheduler(), &pdf::PDFJobScheduler::jobFinished, this, [this](const pdf::PDFJobSnapshot& snapshot) + { + m_activeAsyncJobs.remove(snapshot.jobId); + finishPreflightJob(snapshot); + refreshCanvasTrace(); }); } EditorHost::~EditorHost() { + m_acceptPreflightResults = false; + cancelPreflight(); + QObject::disconnect(&m_session->scheduler(), nullptr, this, nullptr); unbindCanvas(); + + // The guard registration is global process state owned by the thread that + // built this host, so pair it with the host's lifetime: a host destroyed and + // recreated in one process must not leave a registration behind that keeps + // refusing synchronous blocking work on that thread. + if (pdf::PDFBlockingThreadGuard::isCurrentThreadInteractive()) + { + pdf::PDFBlockingThreadGuard::clearInteractiveThread(); + } } QString EditorHost::documentState() const @@ -232,6 +358,20 @@ void EditorHost::goToOutlinePage(int pageIndex) goToPage(pageIndex); } +void EditorHost::setWorkspace(LoopWorkspace workspace) +{ + if (workspace == m_workspace) + { + return; + } + + const LoopWorkspace previous = m_workspace; + m_workspace = workspace; + m_workspaceRequest = -1; + Q_EMIT workspaceChanged(previous, workspace); + bumpPresentation(); +} + void EditorHost::acknowledgeWorkspaceRequest() { if (m_workspaceRequest < 0) @@ -239,8 +379,44 @@ void EditorHost::acknowledgeWorkspaceRequest() return; } + const LoopWorkspace requested = static_cast(m_workspaceRequest); m_workspaceRequest = -1; - Q_EMIT presentationChanged(); + setWorkspace(requested); +} + +QString EditorHost::documentShellStatus() const +{ + return QString::fromLatin1( + pdfinteraction::getShellDocumentStatusName(m_session->facade().shellDocumentStatus())); +} + +QString EditorHost::productionStateName() const +{ + if (!hasDocument()) + { + return QStringLiteral("NOT_READY"); + } + + switch (m_session->facade().outputState()) + { + case pdfinteraction::DocumentOutputState::Pending: + return QStringLiteral("OPERATION_PENDING"); + case pdfinteraction::DocumentOutputState::Saved: + return QStringLiteral("OUTPUT_WRITTEN"); + case pdfinteraction::DocumentOutputState::None: + break; + } + + return pdfinteraction::ProductionModel::stateName(m_production.state()); +} + +bool EditorHost::allowDeveloperDiagnostics() const +{ +#ifdef LOOP_LOOP_DISTRIBUTION_BUILD + return false; +#else + return true; +#endif } void EditorHost::acknowledgeSearchPanel() @@ -259,6 +435,88 @@ QString EditorHost::preflightStateName() const return preflightStateToString(m_preflight.state()); } +QVariantMap EditorHost::preflightStateVisual() const +{ + const pdfquick::tokens::LoopStateVisual visual = pdfquick::tokens::resolvePreflightStateVisual(preflightStateName()); + + QVariantMap result; + result.insert(QStringLiteral("kind"), pdfquick::tokens::stateKindName(visual.kind)); + result.insert(QStringLiteral("colorRole"), pdfquick::tokens::colorRoleName(visual.colorRole)); + result.insert(QStringLiteral("icon"), pdfquick::tokens::stateIconName(visual.icon)); + result.insert(QStringLiteral("accessibleName"), visual.accessibleName); + return result; +} + +QColor EditorHost::preflightStateColor() const +{ + const pdfquick::tokens::LoopStateVisual visual = pdfquick::tokens::resolvePreflightStateVisual(preflightStateName()); + const pdfquick::tokens::LoopTheme theme = + highContrast() ? pdfquick::tokens::LoopTheme::HighContrast : pdfquick::tokens::LoopTheme::Dark; + return pdfquick::tokens::color(visual.colorRole, theme); +} + +QString EditorHost::preflightOperatorSummary() const +{ + return m_preflight.operatorSummary(); +} + +QVariantList EditorHost::preflightProfiles() const +{ + QVariantList profiles; + profiles.reserve(m_preflightProfiles.size()); + for (const PreflightProfileChoice& profile : m_preflightProfiles) + { + QVariantMap item; + item.insert(QStringLiteral("id"), profile.id); + item.insert(QStringLiteral("name"), profile.name); + item.insert(QStringLiteral("version"), profile.version); + item.insert(QStringLiteral("source"), profile.source.startsWith(QLatin1Char(':')) + ? tr("Bundled") + : tr("Local")); + item.insert(QStringLiteral("valid"), profile.valid); + item.insert(QStringLiteral("diagnostic"), profile.diagnostic); + profiles.append(item); + } + return profiles; +} + +QVariantList EditorHost::preflightVariables() const +{ + const auto it = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId; }); + if (it == m_preflightProfiles.cend()) + { + return {}; + } + + QVariantList variables; + const QStringList names = it->variables.keys(); + for (const QString& name : names) + { + const QJsonObject declaration = it->variables.value(name).toObject(); + QVariantMap item; + item.insert(QStringLiteral("name"), name); + item.insert(QStringLiteral("type"), declaration.value(QStringLiteral("type")).toString()); + item.insert(QStringLiteral("required"), declaration.value(QStringLiteral("required")).toBool()); + item.insert(QStringLiteral("description"), declaration.value(QStringLiteral("description")).toString()); + item.insert(QStringLiteral("value"), m_preflightBindings.contains(name) + ? m_preflightBindings.value(name).toVariant() + : declaration.value(QStringLiteral("default")).toVariant()); + if (declaration.contains(QStringLiteral("min"))) + item.insert(QStringLiteral("min"), declaration.value(QStringLiteral("min")).toVariant()); + if (declaration.contains(QStringLiteral("max"))) + item.insert(QStringLiteral("max"), declaration.value(QStringLiteral("max")).toVariant()); + variables.append(item); + } + return variables; +} + +QString EditorHost::selectedPreflightProfileId() const +{ + return m_selectedPreflightProfileId; +} + QString EditorHost::previewSummary() const { return m_preview.summary(); @@ -378,6 +636,293 @@ void EditorHost::announceDocumentState(const QString& message) QAccessible::updateAccessibility(&event); } +bool EditorHost::runPreflight() +{ + if (!hasDocument() || m_preflight.state() == pdfinteraction::PreflightController::State::Running || + !m_session->revisionSource()) + { + return false; + } + + const auto profileIt = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId; }); + if (profileIt == m_preflightProfiles.cend() || !profileIt->valid) + { + return false; + } + + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + if (!document) + { + return false; + } + + const QString documentKey = m_session->revisionSource()->documentKey(); + const QString documentRevision = m_session->facade().currentRevision().toString(); + const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + pdf::PDFJobSpec spec; + spec.jobId = jobId; + spec.kind = pdf::PDFJobKind::Preflight; + spec.priority = pdf::PDFJobPriority::Operator; + spec.documentKey = documentKey; + spec.documentRevision = documentRevision; + spec.operationId = QStringLiteral("preflight.%1").arg(profileIt->id); + spec.checkId = profileIt->name; + spec.progressModel = QStringLiteral("preflight-progress-v1"); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + m_preflight.beginRun(documentKey, + documentRevision, + profileIt->digest, + jobId); + auto outcome = std::make_shared(); + m_preflightOutcomes.insert(jobId, outcome); + const PreflightProfileChoice selectedProfile = *profileIt; + const QJsonObject bindings = m_preflightBindings; + const QByteArray sourceHash = m_session->context().getDocumentIdentity().sourceDataHash; + + const QString submittedId = m_session->scheduler().submit( + spec, + [document, outcome, selectedProfile, bindings, sourceHash](pdf::PDFJobContext& context) + { + if (context.isCancellationRequested()) + { + return; + } + + const pdf::PreflightProfileImportResult imported = pdf::importPreflightProfile(selectedProfile.profile, + selectedProfile.source); + if (!imported.ok) + { + throw std::runtime_error(imported.errorMessage.toStdString()); + } + const pdf::PreflightVariableBindResult bound = + pdf::bindPreflightProfileVariables(imported.profile, bindings); + if (!bound.ok) + { + throw std::runtime_error(bound.errorMessage.toStdString()); + } + pdf::PreflightProfileResolver resolver; + const pdf::PreflightResolvedProfile resolved = resolver.resolveExplicitProfile( + bound.profile, selectedProfile.name, + imported.identity.version.isEmpty() ? QStringLiteral("explicit") : imported.identity.version); + if (!resolved.ok) + { + throw std::runtime_error(resolved.errorMessage.toStdString()); + } + + pdf::PreflightProfileData profile; + QString profileError; + if (!pdf::PreflightEngine::parseProfile(bound.profile, profile, profileError)) + { + throw std::runtime_error(profileError.toStdString()); + } + profile.variableBindings = bound.bindings; + profile.fileDigest = imported.identity.digest; + profile.effectiveDigest = pdf::computeProfileDigest(bound.profile); + profile.profileIdentity = imported.identity.toJson(); + profile.profileIdentity.insert(QStringLiteral("effective_digest"), profile.effectiveDigest); + context.reportProgress(5); + + std::unique_ptr session( + pdf::PDFDocumentSession::createForInspection(document.data()), &pdf::PDFDocumentSession::destroy); + pdf::PreflightEngine engine(session.get()); + engine.setOperationControl(context.operationControl()); + context.reportProgress(15); + outcome->result = engine.run(profile); + pdf::finalizePreflightResult(outcome->result, sourceHash, resolved); + if (context.isCancellationRequested()) + { + return; + } + context.reportProgress(95); + context.setResultSummary(QStringLiteral("Preflight completed.")); + }); + if (submittedId != jobId) + { + m_preflightOutcomes.remove(jobId); + m_preflight.failRun(jobId, documentRevision, tr("Unable to submit preflight work.")); + return false; + } + + bumpPresentation(); + return true; +} + +bool EditorHost::cancelPreflight() +{ + return m_preflight.cancelRun(m_preflight.jobId()); +} + +bool EditorHost::selectPreflightProfile(const QString& id) +{ + const auto it = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [&id](const PreflightProfileChoice& profile) + { return profile.id == id; }); + if (it == m_preflightProfiles.cend() || !it->valid || id == m_selectedPreflightProfileId) + { + return false; + } + m_selectedPreflightProfileId = id; + m_preflightBindings = QJsonObject(); + m_preflight.markProfileStale(); + Q_EMIT preflightProfilesChanged(); + bumpPresentation(); + return true; +} + +bool EditorHost::setPreflightVariable(const QString& name, const QVariant& value) +{ + const auto it = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId; }); + if (it == m_preflightProfiles.cend() || !it->variables.contains(name)) + { + return false; + } + m_preflightBindings.insert(name, QJsonValue::fromVariant(value)); + m_preflight.markProfileStale(); + Q_EMIT preflightProfilesChanged(); + bumpPresentation(); + return true; +} + +void EditorHost::requestPreflightReportExport() +{ + if (m_preflight.hasResult()) + { + Q_EMIT preflightReportExportRequested(); + } +} + +bool EditorHost::exportPreflightReportFileUrl(const QUrl& url) +{ + if (!url.isValid() || !url.isLocalFile() || !m_preflight.hasResult()) + { + return false; + } + const QByteArray report = m_preflight.serializedReport(m_session->facade().source().path); + const pdf::PDFOperationResult result = pdf::PDFSafeFileWriter::writeData( + url.toLocalFile(), report, pdf::PDFSafeFileWriter::OverwritePolicy::Overwrite); + if (!result) + { + announceDocumentState(tr("Could not export the preflight report: %1").arg(result.getErrorMessage())); + return false; + } + announceDocumentState(tr("Preflight report exported.")); + return true; +} + +void EditorHost::reloadPreflightProfiles() +{ + const QString priorId = m_selectedPreflightProfileId; + QString priorDigest; + for (const PreflightProfileChoice& profile : std::as_const(m_preflightProfiles)) + { + if (profile.id == priorId) + { + priorDigest = profile.digest; + break; + } + } + + QList profiles; + const auto addProfile = [&profiles](const QString& source, const QByteArray& data) + { + PreflightProfileChoice choice; + choice.id = source; + choice.source = source; + QJsonParseError parseError; + const QJsonDocument parsed = QJsonDocument::fromJson(data, &parseError); + if (parseError.error != QJsonParseError::NoError || !parsed.isObject()) + { + choice.name = QFileInfo(source).completeBaseName(); + choice.diagnostic = QStringLiteral("Profile JSON is invalid."); + profiles.append(choice); + return; + } + const pdf::PreflightProfileImportResult imported = pdf::importPreflightProfile(parsed.object(), source); + choice.name = imported.profile.value(QStringLiteral("name")).toString(QFileInfo(source).completeBaseName()); + choice.version = imported.identity.version; + choice.digest = imported.identity.digest; + choice.profile = imported.profile; + choice.variables = imported.profile.value(QStringLiteral("variables")).toObject(); + choice.valid = imported.ok; + choice.diagnostic = imported.ok ? QString() : imported.errorMessage; + profiles.append(choice); + }; + + const QDir bundled(QStringLiteral(":/profiles")); + for (const QFileInfo& file : bundled.entryInfoList({ QStringLiteral("*.json") }, QDir::Files, QDir::Name)) + { + QFile input(file.filePath()); + if (input.open(QIODevice::ReadOnly)) + { + addProfile(file.filePath(), input.readAll()); + } + } + + const QString localDirectory = QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) + .filePath(QStringLiteral("profiles")); + const QDir local(localDirectory); + for (const QFileInfo& file : local.entryInfoList({ QStringLiteral("*.json") }, QDir::Files, QDir::Name)) + { + QFile input(file.absoluteFilePath()); + if (input.open(QIODevice::ReadOnly)) + { + addProfile(file.absoluteFilePath(), input.readAll()); + } + } + + m_preflightProfiles = std::move(profiles); + if (m_selectedPreflightProfileId.isEmpty() || + std::none_of(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId && profile.valid; })) + { + const auto valid = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [](const PreflightProfileChoice& profile) + { return profile.valid; }); + m_selectedPreflightProfileId = valid == m_preflightProfiles.cend() ? QString() : valid->id; + m_preflightBindings = QJsonObject(); + } + const auto current = std::find_if(m_preflightProfiles.cbegin(), m_preflightProfiles.cend(), + [this](const PreflightProfileChoice& profile) + { return profile.id == m_selectedPreflightProfileId; }); + if (!priorId.isEmpty() && (priorId != m_selectedPreflightProfileId || current == m_preflightProfiles.cend() || current->digest != priorDigest)) + { + m_preflight.markProfileStale(); + } + updatePreflightProfileWatch(); + Q_EMIT preflightProfilesChanged(); + bumpPresentation(); +} + +void EditorHost::updatePreflightProfileWatch() +{ + if (!m_preflightProfileWatcher) + { + return; + } + m_preflightProfileWatcher->removePaths(m_preflightProfileWatcher->directories()); + m_preflightProfileWatcher->removePaths(m_preflightProfileWatcher->files()); + const QString localDirectory = QDir(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)) + .filePath(QStringLiteral("profiles")); + if (QFileInfo::exists(localDirectory)) + { + m_preflightProfileWatcher->addPath(localDirectory); + } + for (const PreflightProfileChoice& profile : std::as_const(m_preflightProfiles)) + { + if (!profile.source.startsWith(QLatin1Char(':')) && QFileInfo::exists(profile.source)) + { + m_preflightProfileWatcher->addPath(profile.source); + } + } +} + QVariantList EditorHost::commandDescriptors() const { QVariantList descriptors; @@ -452,6 +997,13 @@ void EditorHost::cancelPendingOperation() void EditorHost::attachCanvas(QObject* canvasObject) { m_canvas = qobject_cast(canvasObject); + if (m_canvas) + { + m_canvas->ensureTraceRecorder(); + m_canvas->setAsyncWorkKindsProvider([this] + { return activeAsyncWorkKinds(); }); + refreshCanvasTrace(); + } if (m_documentBound) { bindCanvas(); @@ -461,6 +1013,10 @@ void EditorHost::attachCanvas(QObject* canvasObject) void EditorHost::detachCanvas() { unbindCanvas(); + if (m_canvas) + { + m_canvas->setAsyncWorkKindsProvider({}); + } m_canvas.clear(); } @@ -547,6 +1103,8 @@ void EditorHost::connectFacade() this, [this](const pdf::PDFRevisionIdentity&, const pdf::PDFRevisionIdentity&) { + cancelPreflight(); + syncRevisionModels(); if (m_documentBound) { m_documentModel.setDocument(&m_session->context()); @@ -570,6 +1128,7 @@ void EditorHost::connectFacade() connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) { syncDocumentLifecycle(); + syncProductionState(); bumpPresentation(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentReplaced, this, [this](quint64) @@ -596,6 +1155,10 @@ void EditorHost::connectViewport() void EditorHost::connectInteraction() { + connect(m_session->interaction(), + &pdfinteraction::InteractionController::selectionChanged, + this, + &EditorHost::onInteractionSelectionChanged); connect(m_session->interaction(), &pdfinteraction::InteractionController::dragCompleted, this, @@ -654,13 +1217,13 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionFind"), [this] { m_searchPanelVisible = true; - m_workspaceRequest = 0; }); + setWorkspace(LoopWorkspace::Document); }); bind(QStringLiteral("actionFindNext"), [this] { moveSearch(1); }); bind(QStringLiteral("actionFindPrevious"), [this] { moveSearch(-1); }); bind(QStringLiteral("actionProperties"), [this] - { m_workspaceRequest = 2; }); + { setWorkspace(LoopWorkspace::Inspect); }); refreshFeatureAvailability(); } @@ -739,6 +1302,7 @@ void EditorHost::onDocumentReady() m_documentBound = true; bindCanvas(); updateCanvasAccessibilitySummary(); + applyEmptyCanvasInspectorSelection(); announceDocumentState(tr("Document ready.")); } @@ -767,13 +1331,15 @@ void EditorHost::syncDocumentLifecycle() void EditorHost::onDocumentGone() { + cancelPreflight(); unbindCanvas(); m_session->clearDocumentView(); - m_preflight.findingsModel()->clear(); + m_preflight.clear(); m_inspector.clearSelection(); m_documentModel.clear(); m_searchRow = -1; m_preview.clear(); + m_production.clear(); m_session->hitTest()->clearSources(); m_documentBound = false; updateCanvasAccessibilitySummary(); @@ -804,6 +1370,73 @@ void EditorHost::unbindCanvas() m_canvas->bind(nullptr, nullptr, nullptr); } +QStringList EditorHost::activeAsyncWorkKinds() const +{ + QStringList kinds; + kinds.reserve(m_activeAsyncJobs.size()); + for (auto it = m_activeAsyncJobs.cbegin(); it != m_activeAsyncJobs.cend(); ++it) + { + kinds.append(QString::fromLatin1(pdf::getPDFJobKindName(it.value()))); + } + kinds.removeDuplicates(); + std::sort(kinds.begin(), kinds.end()); + return kinds; +} + +void EditorHost::acceptPreflightResult(const QString& jobId, + const QString& documentRevision, + const pdf::PreflightResult& result) +{ + if (m_acceptPreflightResults && m_preflight.acceptResult(jobId, documentRevision, result)) + { + refreshCanvasTrace(); + bumpPresentation(); + } +} + +void EditorHost::finishPreflightJob(const pdf::PDFJobSnapshot& snapshot) +{ + const std::shared_ptr outcome = m_preflightOutcomes.take(snapshot.jobId); + if (snapshot.jobId != m_preflight.jobId() || m_preflight.state() != pdfinteraction::PreflightController::State::Running) + { + return; + } + + switch (snapshot.status) + { + case pdf::PDFJobStatus::Succeeded: + if (outcome) + { + acceptPreflightResult(snapshot.jobId, snapshot.documentRevision, outcome->result); + } + else + { + m_preflight.failRun(snapshot.jobId, snapshot.documentRevision, tr("Preflight result was unavailable.")); + } + break; + case pdf::PDFJobStatus::Failed: + m_preflight.failRun(snapshot.jobId, snapshot.documentRevision, snapshot.errorMessage); + break; + case pdf::PDFJobStatus::Cancelled: + m_preflight.cancelRun(snapshot.jobId); + break; + case pdf::PDFJobStatus::Stale: + syncRevisionModels(); + break; + case pdf::PDFJobStatus::Queued: + case pdf::PDFJobStatus::Running: + break; + } +} + +void EditorHost::refreshCanvasTrace() +{ + if (m_canvas) + { + m_canvas->update(); + } +} + void EditorHost::syncRevisionModels() { if (!m_session->revisionSource()) @@ -816,6 +1449,7 @@ void EditorHost::syncRevisionModels() m_preflight.setCurrentRevision(documentKey, documentRevision); m_inspector.setCurrentRevision(documentKey, documentRevision); m_preview.setCurrentRevision(documentKey, documentRevision); + m_production.setCurrentRevision(documentKey, documentRevision); if (hasDocument()) { @@ -825,7 +1459,34 @@ void EditorHost::syncRevisionModels() tr("Production preview is approximate until proof mode is active."), tr("The current view uses the standard render path."), QString()); + syncProductionState(); + } +} + +void EditorHost::syncProductionState() +{ + if (!m_session->revisionSource() || !hasDocument()) + { + return; + } + + const QString documentKey = m_session->revisionSource()->documentKey(); + const QString documentRevision = m_session->facade().currentRevision().toString(); + pdfinteraction::ProductionModel::State state = pdfinteraction::ProductionModel::State::Ready; + if (m_session->facade().outputState() == pdfinteraction::DocumentOutputState::Pending) + { + state = pdfinteraction::ProductionModel::State::OperationPending; } + else if (m_session->facade().outputState() == pdfinteraction::DocumentOutputState::Saved) + { + state = pdfinteraction::ProductionModel::State::OutputWritten; + } + else if (m_preview.status() == pdfinteraction::PreviewStateModel::Status::Unavailable) + { + state = pdfinteraction::ProductionModel::State::NotReady; + } + + m_production.setState(documentKey, documentRevision, state); } void EditorHost::updateCanvasAccessibilitySummary() @@ -874,3 +1535,127 @@ void EditorHost::onDragCompleted(pdfinteraction::DragSession session) m_session->interaction()->refreshOverlay(); } } + +void EditorHost::onInteractionSelectionChanged(pdfinteraction::InteractionTarget target) +{ + applyInspectorSelection(target); + bumpPresentation(); +} + +void EditorHost::applyEmptyCanvasInspectorSelection() +{ + if (!m_session->revisionSource() || !hasDocument()) + { + m_inspector.clearSelection(); + return; + } + + pdfinteraction::InspectorModel::Selection selection; + selection.documentKey = m_session->revisionSource()->documentKey(); + selection.documentRevision = m_session->facade().currentRevision().toString(); + selection.selectionId = QStringLiteral("canvas"); + selection.title = tr("Document canvas"); + selection.kind = pdfinteraction::InspectorModel::SelectionKind::EmptyCanvas; + selection.properties = { + { QStringLiteral("document"), QStringLiteral("Document"), displayTitle() }, + { QStringLiteral("pages"), QStringLiteral("Pages"), QString::number(pageCount()) }, + { QStringLiteral("document-status"), QStringLiteral("Document status"), documentShellStatus() }, + { QStringLiteral("preflight"), QStringLiteral("Preflight"), preflightStateName() }, + { QStringLiteral("production"), QStringLiteral("Production"), productionStateName() }, + }; + m_inspector.setSelection(selection); +} + +void EditorHost::applyInspectorSelection(const pdfinteraction::InteractionTarget& target) +{ + if (!m_session->revisionSource() || !hasDocument()) + { + applyEmptyCanvasInspectorSelection(); + return; + } + + if (!target.isValid()) + { + applyEmptyCanvasInspectorSelection(); + return; + } + + const QString documentKey = m_session->revisionSource()->documentKey(); + const QString documentRevision = m_session->facade().currentRevision().toString(); + + if (target.kind == pdfinteraction::InteractionTargetKind::Finding) + { + m_inspector.setFindingSelection(*m_preflight.findingsModel(), target.id, documentRevision); + return; + } + + if (target.id.startsWith(QStringLiteral("image:"))) + { + pdfinteraction::InspectorModel::Selection selection; + selection.documentKey = documentKey; + selection.documentRevision = documentRevision; + selection.selectionId = target.id; + selection.title = tr("Image"); + selection.kind = pdfinteraction::InspectorModel::SelectionKind::Image; + selection.properties = { + { QStringLiteral("id"), QStringLiteral("Image"), target.id.mid(6) }, + { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("bounds"), QStringLiteral("Bounds"), + QStringLiteral("%1,%2 %3x%4") + .arg(QString::number(target.pageBounds.x()), + QString::number(target.pageBounds.y()), + QString::number(target.pageBounds.width()), + QString::number(target.pageBounds.height())) }, + { QStringLiteral("dpi"), QStringLiteral("Effective DPI"), tr("pending") }, + { QStringLiteral("colour-space"), QStringLiteral("Colour space"), tr("pending") }, + { QStringLiteral("compression"), QStringLiteral("Compression"), tr("pending") }, + { QStringLiteral("mask"), QStringLiteral("Mask"), tr("pending") }, + }; + m_inspector.setSelection(selection); + return; + } + + if (target.id.startsWith(QStringLiteral("separation:"))) + { + pdfinteraction::InspectorModel::Selection selection; + selection.documentKey = documentKey; + selection.documentRevision = documentRevision; + selection.selectionId = target.id; + selection.title = tr("Separation"); + selection.kind = pdfinteraction::InspectorModel::SelectionKind::Separation; + selection.properties = { + { QStringLiteral("name"), QStringLiteral("Ink"), target.id.mid(11) }, + { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("coverage"), QStringLiteral("Ink coverage"), tr("pending") }, + { QStringLiteral("kind"), QStringLiteral("Process / spot"), tr("pending") }, + }; + m_inspector.setSelection(selection); + return; + } + + if (target.kind == pdfinteraction::InteractionTargetKind::Page || + target.kind == pdfinteraction::InteractionTargetKind::PageBox) + { + pdfinteraction::InspectorModel::Selection selection; + selection.documentKey = documentKey; + selection.documentRevision = documentRevision; + selection.selectionId = target.id.isEmpty() ? QStringLiteral("page") : target.id; + selection.title = target.kind == pdfinteraction::InteractionTargetKind::PageBox + ? tr("Page box: %1").arg(target.id) + : tr("Page %1").arg(target.pageIndex + 1); + selection.kind = pdfinteraction::InspectorModel::SelectionKind::Page; + selection.properties = { + { QStringLiteral("page"), QStringLiteral("Page"), QString::number(target.pageIndex + 1) }, + { QStringLiteral("box"), QStringLiteral("Box"), target.id }, + { QStringLiteral("size"), QStringLiteral("Size"), + QStringLiteral("%1 x %2") + .arg(QString::number(target.pageBounds.width()), QString::number(target.pageBounds.height())) }, + { QStringLiteral("rotation"), QStringLiteral("Rotation"), QStringLiteral("%1°").arg(rotationDegrees()) }, + { QStringLiteral("ocg"), QStringLiteral("Optional content"), m_documentModel.hasOptionalContent() ? tr("present") : tr("none") }, + }; + m_inspector.setSelection(selection); + return; + } + + applyEmptyCanvasInspectorSelection(); +} diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 794e3747d..7b00f1b7c 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -35,6 +35,7 @@ #include "preflightcontroller.h" #include "preflightoverlaybridge.h" #include "previewstatemodel.h" +#include "productionmodel.h" #include "viewportcommandbridge.h" #include "viewportcontroller.h" @@ -45,7 +46,10 @@ #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" +#include #include +#include +#include #include #include #include @@ -91,6 +95,13 @@ class EditorHost final : public QObject Q_PROPERTY(QObject* documentModel READ documentModel CONSTANT) Q_PROPERTY(QObject* focusRestoration READ focusRestoration CONSTANT) Q_PROPERTY(QString preflightStateName READ preflightStateName NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap preflightStateVisual READ preflightStateVisual NOTIFY presentationChanged) + Q_PROPERTY(QColor preflightStateColor READ preflightStateColor NOTIFY presentationChanged) + Q_PROPERTY(QString preflightOperatorSummary READ preflightOperatorSummary NOTIFY presentationChanged) + Q_PROPERTY(QVariantList preflightProfiles READ preflightProfiles NOTIFY preflightProfilesChanged) + Q_PROPERTY(QVariantList preflightVariables READ preflightVariables NOTIFY preflightProfilesChanged) + Q_PROPERTY(QString selectedPreflightProfileId READ selectedPreflightProfileId NOTIFY preflightProfilesChanged) + Q_PROPERTY(bool hasPreflightReport READ hasPreflightReport NOTIFY presentationChanged) Q_PROPERTY(QString previewSummary READ previewSummary NOTIFY presentationChanged) Q_PROPERTY(QString inspectorTitle READ inspectorTitle NOTIFY presentationChanged) Q_PROPERTY(bool preferReducedMotion READ preferReducedMotion NOTIFY presentationChanged) @@ -101,8 +112,24 @@ class EditorHost final : public QObject Q_PROPERTY(bool searchPanelVisible READ searchPanelVisible NOTIFY presentationChanged) Q_PROPERTY(bool fullscreenRequested READ fullscreenRequested NOTIFY presentationChanged) Q_PROPERTY(int workspaceRequest READ workspaceRequest NOTIFY presentationChanged) + Q_PROPERTY(LoopWorkspace workspace READ workspace WRITE setWorkspace NOTIFY workspaceChanged) + Q_PROPERTY(QString documentShellStatus READ documentShellStatus NOTIFY presentationChanged) + Q_PROPERTY(QString productionStateName READ productionStateName NOTIFY presentationChanged) + Q_PROPERTY(bool allowDeveloperDiagnostics READ allowDeveloperDiagnostics CONSTANT) public: + enum LoopWorkspace + { + Document = 0, + Preflight = 1, + ProductionPreview = 2, + Pages = 3, + Inspect = 4, + Fix = 5, + Compare = 6 + }; + Q_ENUM(LoopWorkspace) + explicit EditorHost(QObject* parent = nullptr); ~EditorHost() override; @@ -129,6 +156,21 @@ class EditorHost final : public QObject FocusRestoration* focusRestoration() { return &m_focusRestoration; } QString preflightStateName() const; + + /// Canonical #194 treatment for the current document-level preflight state: the keys + /// `kind`, `colorRole`, `icon` and `accessibleName`, all computed by LoopLibQuick from Core's + /// own state name. QML renders it; it derives nothing and picks no roles. + QVariantMap preflightStateVisual() const; + + /// The `colorRole` above, resolved to a colour for the current theme. QML must never map a role + /// name to a colour itself. + QColor preflightStateColor() const; + + QString preflightOperatorSummary() const; + QVariantList preflightProfiles() const; + QVariantList preflightVariables() const; + QString selectedPreflightProfileId() const; + bool hasPreflightReport() const noexcept { return m_preflight.hasResult(); } QString previewSummary() const; QString inspectorTitle() const; bool preferReducedMotion() const; @@ -136,6 +178,10 @@ class EditorHost final : public QObject bool searchPanelVisible() const noexcept { return m_searchPanelVisible; } bool fullscreenRequested() const noexcept { return m_fullscreenRequested; } int workspaceRequest() const noexcept { return m_workspaceRequest; } + LoopWorkspace workspace() const noexcept { return m_workspace; } + QString documentShellStatus() const; + QString productionStateName() const; + bool allowDeveloperDiagnostics() const; /// Overprint render fidelity for the currently displayed page (issue #49). /// True (and pageFidelityReason empty) when the page has no overprint @@ -153,6 +199,12 @@ class EditorHost final : public QObject Q_INVOKABLE void selectFinding(const QString& findingId); Q_INVOKABLE void announceDocumentState(const QString& message); + Q_INVOKABLE bool runPreflight(); + Q_INVOKABLE bool cancelPreflight(); + Q_INVOKABLE bool selectPreflightProfile(const QString& id); + Q_INVOKABLE bool setPreflightVariable(const QString& name, const QVariant& value); + Q_INVOKABLE void requestPreflightReportExport(); + Q_INVOKABLE bool exportPreflightReportFileUrl(const QUrl& url); /// Toggles the current page between the fast approximate render and the /// authoritative overprint-accurate one. Re-renders only that page; @@ -160,6 +212,7 @@ class EditorHost final : public QObject Q_INVOKABLE void toggleCurrentPageFidelity(); Q_INVOKABLE void goToPage(int pageIndex); Q_INVOKABLE void goToOutlinePage(int pageIndex); + Q_INVOKABLE void setWorkspace(LoopWorkspace workspace); Q_INVOKABLE void acknowledgeWorkspaceRequest(); Q_INVOKABLE void acknowledgeSearchPanel(); @@ -198,6 +251,9 @@ class EditorHost final : public QObject signals: void presentationChanged(); void commandEpochChanged(); + void workspaceChanged(LoopWorkspace from, LoopWorkspace to); + void preflightProfilesChanged(); + void preflightReportExportRequested(); private: void connectFacade(); @@ -218,26 +274,60 @@ class EditorHost final : public QObject void syncDocumentLifecycle(); void bindCanvas(); void unbindCanvas(); + QStringList activeAsyncWorkKinds() const; + void acceptPreflightResult(const QString& jobId, + const QString& documentRevision, + const pdf::PreflightResult& result); + void finishPreflightJob(const pdf::PDFJobSnapshot& snapshot); + void refreshCanvasTrace(); + void reloadPreflightProfiles(); + void updatePreflightProfileWatch(); void syncRevisionModels(); void updateCanvasAccessibilitySummary(); void onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request); void onDragCompleted(pdfinteraction::DragSession session); + void onInteractionSelectionChanged(pdfinteraction::InteractionTarget target); + void syncProductionState(); + void applyInspectorSelection(const pdfinteraction::InteractionTarget& target); + void applyEmptyCanvasInspectorSelection(); std::unique_ptr m_session; pdfinteraction::PreflightController m_preflight; pdfinteraction::PreflightOverlayBridge m_preflightOverlayBridge; pdfinteraction::InspectorModel m_inspector; pdfinteraction::PreviewStateModel m_preview; + pdfinteraction::ProductionModel m_production; QuickDocumentModel m_documentModel; FocusRestoration m_focusRestoration; pdfinteraction::FindingListHitTestSource m_findingsHitTest; QPointer m_canvas; + QHash m_activeAsyncJobs; + struct PreflightWorkerOutcome; + QHash> m_preflightOutcomes; + struct PreflightProfileChoice + { + QString id; + QString name; + QString version; + QString source; + QString diagnostic; + QString digest; + QJsonObject profile; + QJsonObject variables; + bool valid = false; + }; + QList m_preflightProfiles; + QJsonObject m_preflightBindings; + QString m_selectedPreflightProfileId; + class QFileSystemWatcher* m_preflightProfileWatcher = nullptr; + bool m_acceptPreflightResults = true; int m_commandEpoch = 0; bool m_documentBound = false; bool m_searchPanelVisible = false; bool m_fullscreenRequested = false; int m_workspaceRequest = -1; + LoopWorkspace m_workspace = LoopWorkspace::Document; int m_searchRow = -1; }; diff --git a/LoopEditor/main.cpp b/LoopEditor/main.cpp index f487659fb..cb8a92cae 100644 --- a/LoopEditor/main.cpp +++ b/LoopEditor/main.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -254,6 +255,11 @@ int runQuickSmoke(QGuiApplication& application, EditorHost& host, const QString& engine.addImportPath(importPath); } engine.rootContext()->setContextProperty(QStringLiteral("editorHost"), &host); + qmlRegisterUncreatableType("Loop.Quick", + 1, + 0, + "EditorHost", + QStringLiteral("EditorHost is provided by the shell context")); QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, &application, [&application](QObject* object, const QUrl& url) { @@ -378,6 +384,11 @@ int main(int argc, char* argv[]) QQmlApplicationEngine engine; engine.rootContext()->setContextProperty(QStringLiteral("editorHost"), &host); + qmlRegisterUncreatableType("Loop.Quick", + 1, + 0, + "EditorHost", + QStringLiteral("EditorHost is provided by the shell context")); QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, &application, [&application](QObject* object, const QUrl& url) diff --git a/LoopEditor/qml/DocumentPane.qml b/LoopEditor/qml/DocumentPane.qml index f03119c9a..fd6193215 100644 --- a/LoopEditor/qml/DocumentPane.qml +++ b/LoopEditor/qml/DocumentPane.qml @@ -159,6 +159,14 @@ Item { host: root.host Accessible.name: qsTr("Document canvas pane") } + + InspectorPane { + id: inspectorPane + Layout.preferredWidth: 280 + Layout.fillHeight: true + host: root.host + Accessible.name: qsTr("Contextual inspector dock") + } } Connections { diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index f73397e69..1cd5bdedc 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -17,49 +17,8 @@ ApplicationWindow { height: 768 title: host && host.displayTitle.length > 0 ? host.displayTitle : qsTr("Loop") - property var commandMap: ({}) - property int commandEpoch: host ? host.commandEpoch : 0 - - function rebuildCommands() { - const next = {} - if (!host) { - commandMap = next - return - } - - const descriptors = host.commandDescriptors() - for (let index = 0; index < descriptors.length; ++index) { - const entry = descriptors[index] - next[entry.id] = entry - } - commandMap = next - } - - function commandEnabled(commandId) { - const entry = commandMap[commandId] - return !!entry && entry.enabled === true - } - - function invoke(commandId) { - if (!host) { - return - } - host.invokeCommand(commandId) - } - - function shortcutSequence(entry) { - if (!entry || !entry.id) { - return "" - } - return entry.shortcutText || "" - } - Connections { target: host - function onCommandEpochChanged() { - window.commandEpoch = host.commandEpoch - window.rebuildCommands() - } function onPresentationChanged() { if (host && host.displayTitle.length > 0) { window.title = host.displayTitle @@ -76,171 +35,9 @@ ApplicationWindow { } } - Component.onCompleted: rebuildCommands() - - menuBar: MenuBar { - Menu { - title: qsTr("&File") - Action { - text: qsTr("&Open…") - enabled: commandEnabled("actionOpen") - shortcut: shortcutSequence(commandMap["actionOpen"]) - onTriggered: { - if (host && host.focusRestoration) { - host.focusRestoration.remember(window.activeFocusItem) - } - openDialog.open() - } - } - Action { - text: qsTr("&Close") - enabled: commandEnabled("actionClose") - shortcut: shortcutSequence(commandMap["actionClose"]) - onTriggered: invoke("actionClose") - } - Action { - text: qsTr("&Save") - enabled: commandEnabled("actionSave") - shortcut: shortcutSequence(commandMap["actionSave"]) - onTriggered: invoke("actionSave") - } - Action { - text: qsTr("Save &As…") - enabled: commandEnabled("actionSave_As") - shortcut: shortcutSequence(commandMap["actionSave_As"]) - onTriggered: { - if (host && host.focusRestoration) { - host.focusRestoration.remember(window.activeFocusItem) - } - saveAsDialog.open() - } - } - MenuSeparator {} - Action { - text: qsTr("E&xit") - enabled: commandEnabled("actionQuit") - shortcut: shortcutSequence(commandMap["actionQuit"]) - onTriggered: invoke("actionQuit") - } - } - - Menu { - title: qsTr("&Navigate") - Action { - text: qsTr("Previous &Page") - enabled: commandEnabled("actionGoToPreviousPage") - shortcut: shortcutSequence(commandMap["actionGoToPreviousPage"]) - onTriggered: invoke("actionGoToPreviousPage") - } - Action { - text: qsTr("Next &Page") - enabled: commandEnabled("actionGoToNextPage") - shortcut: shortcutSequence(commandMap["actionGoToNextPage"]) - onTriggered: invoke("actionGoToNextPage") - } - Action { - text: qsTr("&First Page") - enabled: commandEnabled("actionGoToDocumentStart") - shortcut: shortcutSequence(commandMap["actionGoToDocumentStart"]) - onTriggered: invoke("actionGoToDocumentStart") - } - Action { - text: qsTr("&Last Page") - enabled: commandEnabled("actionGoToDocumentEnd") - shortcut: shortcutSequence(commandMap["actionGoToDocumentEnd"]) - onTriggered: invoke("actionGoToDocumentEnd") - } - } - - Menu { - title: qsTr("&View") - Action { - text: qsTr("&Find…") - enabled: commandEnabled("actionFind") - shortcut: shortcutSequence(commandMap["actionFind"]) - onTriggered: invoke("actionFind") - } - MenuSeparator {} - Action { - text: qsTr("Zoom &In") - enabled: commandEnabled("actionZoom_In") - shortcut: shortcutSequence(commandMap["actionZoom_In"]) - onTriggered: invoke("actionZoom_In") - } - Action { - text: qsTr("Zoom &Out") - enabled: commandEnabled("actionZoom_Out") - shortcut: shortcutSequence(commandMap["actionZoom_Out"]) - onTriggered: invoke("actionZoom_Out") - } - Action { - text: qsTr("&Fit Page") - enabled: commandEnabled("actionFitPage") - shortcut: shortcutSequence(commandMap["actionFitPage"]) - onTriggered: invoke("actionFitPage") - } - Action { - text: qsTr("Fit &Width") - enabled: commandEnabled("actionFitWidth") - shortcut: shortcutSequence(commandMap["actionFitWidth"]) - onTriggered: invoke("actionFitWidth") - } - Action { - text: qsTr("Fit &Height") - enabled: commandEnabled("actionFitHeight") - shortcut: shortcutSequence(commandMap["actionFitHeight"]) - onTriggered: invoke("actionFitHeight") - } - MenuSeparator {} - Action { - text: qsTr("Rotate &Left") - enabled: commandEnabled("actionRotateLeft") - shortcut: shortcutSequence(commandMap["actionRotateLeft"]) - onTriggered: invoke("actionRotateLeft") - } - Action { - text: qsTr("Rotate &Right") - enabled: commandEnabled("actionRotateRight") - shortcut: shortcutSequence(commandMap["actionRotateRight"]) - onTriggered: invoke("actionRotateRight") - } - MenuSeparator {} - Action { - text: qsTr("Continuous Layout") - enabled: commandEnabled("actionPageLayoutContinuous") - onTriggered: invoke("actionPageLayoutContinuous") - } - Action { - text: qsTr("Single Page Layout") - enabled: commandEnabled("actionPageLayoutSinglePage") - onTriggered: invoke("actionPageLayoutSinglePage") - } - Action { - text: qsTr("Two-Column Layout") - enabled: commandEnabled("actionPageLayoutTwoColumns") - onTriggered: invoke("actionPageLayoutTwoColumns") - } - Action { - text: qsTr("Two-Page Layout") - enabled: commandEnabled("actionPageLayoutTwoPages") - onTriggered: invoke("actionPageLayoutTwoPages") - } - Action { - text: qsTr("Fullscreen") - enabled: commandEnabled("actionFullscreenMode") - shortcut: shortcutSequence(commandMap["actionFullscreenMode"]) - onTriggered: invoke("actionFullscreenMode") - } - } - - Menu { - title: qsTr("&Document") - Action { - text: qsTr("&Properties") - enabled: commandEnabled("actionProperties") - onTriggered: invoke("actionProperties") - } - } + menuBar: ShellMenuBar { + host: window.host + window: window } FileDialog { @@ -270,6 +67,27 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: preflightReportDialog + title: qsTr("Export Preflight Report") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("JSON files (*.json)")] + onAccepted: { + if (host) { + host.exportPreflightReportFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + Connections { + target: host + function onPreflightReportExportRequested() { + preflightReportDialog.open() + } + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -331,16 +149,38 @@ ApplicationWindow { padding: 6 Accessible.role: Accessible.StatusBar - Accessible.name: qsTr("Page status") + Accessible.name: qsTr("Shell status") RowLayout { anchors.fill: parent - spacing: 12 + spacing: 16 Label { - text: host && host.hasDocument - ? qsTr("Page %1 / %2").arg(host.currentPage + 1).arg(host.pageCount) - : qsTr("No document") + text: host + ? qsTr("Document: %1").arg(host.documentShellStatus) + : qsTr("Document: NO_DOCUMENT") + Accessible.name: qsTr("Document status") + } + + Label { + text: host + ? qsTr("Production: %1").arg(host.productionStateName) + : qsTr("Production: NOT_READY") + Accessible.name: qsTr("Production status") + } + + Label { + text: host + ? qsTr("Preflight: %1").arg(host.preflightStateName) + : qsTr("Preflight: not-checked") + Accessible.name: qsTr("Preflight status") + } + + Item { Layout.fillWidth: true } + + Label { + visible: host && host.hasDocument + text: qsTr("Page %1 / %2").arg(host.currentPage + 1).arg(host.pageCount) Accessible.name: qsTr("Current page") } @@ -356,8 +196,6 @@ ApplicationWindow { Accessible.name: qsTr("Current rotation") } - Item { Layout.fillWidth: true } - Label { visible: host && host.documentState === "opening" text: qsTr("Opening…") diff --git a/LoopEditor/qml/MenuModel.qml b/LoopEditor/qml/MenuModel.qml new file mode 100644 index 000000000..89a14a6fa --- /dev/null +++ b/LoopEditor/qml/MenuModel.qml @@ -0,0 +1,81 @@ +import QtQuick + +QtObject { + id: root + + property var host: null + property var window: null + + readonly property var menuGroups: [ + "File", "Edit", "View", "Document", "Production", "Preflight", "Help", "Advanced" + ] + + function descriptorsForGroup(groupName) { + if (!host) { + return [] + } + + const descriptors = host.commandDescriptors() + const entries = [] + for (let index = 0; index < descriptors.length; ++index) { + const entry = descriptors[index] + const disposition = entry.disposition || "" + if (disposition === "HIDE" || disposition === "STOP-SHIPPING") { + continue + } + if (groupName === "Advanced") { + if (disposition !== "ADVANCED") { + continue + } + if (!host.allowDeveloperDiagnostics) { + continue + } + } else if (entry.menuGroup !== groupName) { + continue + } else if (disposition === "ADVANCED" && !host.allowDeveloperDiagnostics) { + continue + } + entries.push(entry) + } + return entries + } + + function labelForEntry(entry) { + if (!entry || !entry.labelKey) { + return entry && entry.id ? entry.id : "" + } + const key = entry.labelKey + const suffix = key.startsWith("command.") ? key.slice("command.".length) : key + const base = suffix.endsWith(".label") ? suffix.slice(0, -".label".length) : suffix + const words = base.replace(/^action/, "").replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2") + if (words.length === 0) { + return entry.id + } + return words.charAt(0).toUpperCase() + words.slice(1) + } + + function commandEnabled(commandId) { + return host ? host.isCommandEnabled(commandId) : false + } + + function invoke(commandId) { + if (!host) { + return + } + if (commandId === "actionOpen" && window && window.openDialog) { + if (host.focusRestoration) { + host.focusRestoration.remember(window.activeFocusItem) + } + window.openDialog.open() + return + } + if (commandId === "actionSave_As" && window && window.saveAsDialog) { + if (host.focusRestoration) { + host.focusRestoration.remember(window.activeFocusItem) + } + window.saveAsDialog.open() + return + } + host.invokeCommand(commandId) + } +} diff --git a/LoopEditor/qml/PreflightPane.qml b/LoopEditor/qml/PreflightPane.qml index 19547bf1f..68167ae7f 100644 --- a/LoopEditor/qml/PreflightPane.qml +++ b/LoopEditor/qml/PreflightPane.qml @@ -21,10 +21,104 @@ Pane { Label { Layout.fillWidth: true wrapMode: Text.WordWrap - text: host ? qsTr("Preflight status: %1").arg(host.preflightStateName) : "" + text: { + if (!host) + return "" + if (host.preflightOperatorSummary) + return host.preflightOperatorSummary + return qsTr("Preflight status: %1").arg(host.preflightStateName) + } Accessible.name: qsTr("Preflight status") } + ComboBox { + id: profileSelector + Layout.fillWidth: true + model: root.host ? root.host.preflightProfiles : [] + textRole: "name" + valueRole: "id" + enabled: root.host && root.host.preflightStateName !== "running" + currentIndex: { + if (!root.host) + return -1 + for (var i = 0; i < model.length; ++i) { + if (model[i].id === root.host.selectedPreflightProfileId) + return i + } + return -1 + } + Accessible.name: qsTr("Preflight profile") + Accessible.description: qsTr("Select a bundled or local validated preflight profile.") + onActivated: function(index) { + if (root.host && model[index]) + root.host.selectPreflightProfile(model[index].id) + } + } + + Repeater { + model: root.host ? root.host.preflightVariables : [] + delegate: RowLayout { + Layout.fillWidth: true + required property var modelData + + Label { + text: modelData.required ? qsTr("%1 (required)").arg(modelData.name) : modelData.name + Accessible.name: modelData.description.length > 0 ? modelData.description : text + } + CheckBox { + visible: modelData.type === "boolean" + checked: Boolean(modelData.value) + text: modelData.description + onToggled: if (root.host) root.host.setPreflightVariable(modelData.name, checked) + } + TextField { + Layout.fillWidth: true + visible: modelData.type !== "boolean" + text: modelData.value === undefined || modelData.value === null ? "" : String(modelData.value) + inputMethodHints: modelData.type === "string" ? Qt.ImhNone : Qt.ImhFormattedNumbersOnly + Accessible.name: qsTr("Preflight variable %1").arg(modelData.name) + onEditingFinished: if (root.host) root.host.setPreflightVariable(modelData.name, text) + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + text: qsTr("Run Preflight") + enabled: root.host && root.host.hasDocument && root.host.preflightStateName !== "running" + Accessible.name: qsTr("Run preflight") + Accessible.description: qsTr("Runs the selected validated preflight profile.") + onClicked: root.host.runPreflight() + } + + Button { + text: qsTr("Cancel") + enabled: root.host && root.host.preflightStateName === "running" + Accessible.name: qsTr("Cancel preflight") + Accessible.description: qsTr("Cancels the running preflight job.") + onClicked: root.host.cancelPreflight() + } + + Button { + text: qsTr("Export Report") + enabled: root.host && root.host.hasPreflightReport + Accessible.name: qsTr("Export preflight report") + Accessible.description: qsTr("Exports the retained normalized preflight report as JSON.") + onClicked: root.host.requestPreflightReportExport() + } + + ProgressBar { + Layout.fillWidth: true + from: 0 + to: 100 + value: root.host ? root.host.preflight.progress : 0 + enabled: root.host && root.host.preflightStateName === "running" + Accessible.name: qsTr("Preflight progress") + } + } + ListView { id: findingsView Layout.fillWidth: true @@ -40,10 +134,11 @@ Pane { delegate: ItemDelegate { width: findingsView.width - text: model.message + text: "%1 — %2".arg(model.severity).arg(model.message) highlighted: model.selected Accessible.name: model.message - Accessible.description: qsTr("Severity %1 on page %2").arg(model.severity).arg(model.page) + Accessible.description: qsTr("Severity %1, scope %2, page %3, object %4, check %5, evidence %6") + .arg(model.severity).arg(model.scope).arg(model.page).arg(model.objectId).arg(model.checkId).arg(model.evidenceIds.join(", ")) onClicked: { findingsView.currentIndex = index if (host) { diff --git a/LoopEditor/qml/ShellMenuBar.qml b/LoopEditor/qml/ShellMenuBar.qml new file mode 100644 index 000000000..fdb20fd90 --- /dev/null +++ b/LoopEditor/qml/ShellMenuBar.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Controls + +MenuBar { + id: root + + property var host: editorHost + property var window: null + + readonly property var menuModel: MenuModel { + host: root.host + window: root.window + } + + Repeater { + model: root.menuModel.menuGroups + + delegate: Menu { + required property string modelData + title: { + switch (modelData) { + case "File": return qsTr("&File") + case "Edit": return qsTr("&Edit") + case "View": return qsTr("&View") + case "Document": return qsTr("&Document") + case "Production": return qsTr("&Production") + case "Preflight": return qsTr("&Preflight") + case "Help": return qsTr("&Help") + case "Advanced": return qsTr("&Advanced") + default: return modelData + } + } + visible: modelData !== "Advanced" || (root.host && root.host.allowDeveloperDiagnostics) + + Instantiator { + model: root.menuModel.descriptorsForGroup(modelData) + + delegate: MenuItem { + required property var modelData + text: root.menuModel.labelForEntry(modelData) + enabled: root.menuModel.commandEnabled(modelData.id) + onTriggered: root.menuModel.invoke(modelData.id) + } + + onObjectAdded: function(index, object) { + parent.addItem(object) + } + onObjectRemoved: function(index, object) { + parent.removeItem(object) + } + } + } + } +} diff --git a/LoopEditor/qml/ShellToolBar.qml b/LoopEditor/qml/ShellToolBar.qml new file mode 100644 index 000000000..cac820df3 --- /dev/null +++ b/LoopEditor/qml/ShellToolBar.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolBar { + id: root + + property var host: editorHost + property var commandMap: ({}) + + function rebuildCommands() { + const next = {} + if (!host) { + commandMap = next + return + } + const descriptors = host.commandDescriptors() + for (let index = 0; index < descriptors.length; ++index) { + const entry = descriptors[index] + next[entry.id] = entry + } + commandMap = next + } + + function commandEnabled(commandId) { + const entry = commandMap[commandId] + return !!entry && entry.enabled === true + } + + function invoke(commandId) { + if (host) { + host.invokeCommand(commandId) + } + } + + function shortcutText(commandId) { + const entry = commandMap[commandId] + return entry ? (entry.shortcutText || "") : "" + } + + Component.onCompleted: rebuildCommands() + + Connections { + target: host + function onCommandEpochChanged() { + root.rebuildCommands() + } + } + + RowLayout { + anchors.fill: parent + spacing: 6 + + ToolButton { + text: qsTr("Open") + enabled: root.commandEnabled("actionOpen") + onClicked: root.invoke("actionOpen") + Accessible.name: qsTr("Open document") + } + ToolButton { + text: qsTr("Save") + enabled: root.commandEnabled("actionSave") + onClicked: root.invoke("actionSave") + Accessible.name: qsTr("Save document") + } + ToolButton { + text: qsTr("Export") + enabled: root.commandEnabled("actionSave_As") + onClicked: root.invoke("actionSave_As") + Accessible.name: qsTr("Export document") + } + ToolSeparator {} + ToolButton { + text: qsTr("Undo") + enabled: root.commandEnabled("actionUndo") + onClicked: root.invoke("actionUndo") + Accessible.name: qsTr("Undo") + } + ToolButton { + text: qsTr("Redo") + enabled: root.commandEnabled("actionRedo") + onClicked: root.invoke("actionRedo") + Accessible.name: qsTr("Redo") + } + ToolSeparator {} + ToolButton { + text: qsTr("Select") + checkable: true + checked: true + Accessible.name: qsTr("Select tool") + } + ToolButton { + text: qsTr("Hand") + checkable: true + Accessible.name: qsTr("Hand tool") + } + ToolSeparator {} + ToolButton { + text: qsTr("Zoom In") + enabled: root.commandEnabled("actionZoom_In") + onClicked: root.invoke("actionZoom_In") + Accessible.name: qsTr("Zoom in") + } + ToolButton { + text: qsTr("Zoom Out") + enabled: root.commandEnabled("actionZoom_Out") + onClicked: root.invoke("actionZoom_Out") + Accessible.name: qsTr("Zoom out") + } + ToolSeparator {} + ToolButton { + text: qsTr("Preflight") + enabled: root.host !== null + onClicked: if (root.host) root.host.setWorkspace(EditorHost.Preflight) + Accessible.name: qsTr("Open preflight workspace") + } + ToolButton { + text: qsTr("Preview") + enabled: root.host !== null + onClicked: if (root.host) root.host.setWorkspace(EditorHost.ProductionPreview) + Accessible.name: qsTr("Open production preview workspace") + } + + Item { Layout.fillWidth: true } + } +} diff --git a/LoopEditor/qml/Workspace.qml b/LoopEditor/qml/Workspace.qml index 9e300a655..c3017700b 100644 --- a/LoopEditor/qml/Workspace.qml +++ b/LoopEditor/qml/Workspace.qml @@ -10,78 +10,138 @@ Item { property var host: editorHost readonly property bool preferReducedMotion: host ? host.preferReducedMotion : false - RowLayout { + function workspaceIndex(workspaceValue) { + switch (workspaceValue) { + case EditorHost.Document: return 0 + case EditorHost.Preflight: return 1 + case EditorHost.ProductionPreview: return 2 + case EditorHost.Pages: return 3 + case EditorHost.Inspect: return 4 + case EditorHost.Fix: return 5 + case EditorHost.Compare: return 6 + default: return 0 + } + } + + function setWorkspaceFromRail(workspaceValue) { + if (!host || workspaceValue === EditorHost.Compare) { + return + } + host.setWorkspace(workspaceValue) + } + + ColumnLayout { anchors.fill: parent spacing: 0 - Pane { - id: workspaceRail - Layout.preferredWidth: 120 + ShellToolBar { + Layout.fillWidth: true + host: root.host + } + + RowLayout { + Layout.fillWidth: true Layout.fillHeight: true - padding: 8 - - focus: true - Accessible.role: Accessible.Grouping - Accessible.name: qsTr("Workspace rail") - - ColumnLayout { - anchors.fill: parent - spacing: 8 - - ToolButton { - id: documentButton - Layout.fillWidth: true - text: qsTr("Document") - checkable: true - checked: workspaceStack.currentIndex === 0 - onClicked: workspaceStack.currentIndex = 0 - Accessible.name: qsTr("Document workspace") - Accessible.role: Accessible.Button + spacing: 0 + + Pane { + id: workspaceRail + Layout.preferredWidth: 132 + Layout.fillHeight: true + padding: 8 + + focus: true + Accessible.role: Accessible.Grouping + Accessible.name: qsTr("Workspace rail") + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + Repeater { + model: [ + { label: qsTr("Document"), workspace: EditorHost.Document, enabled: true }, + { label: qsTr("Preflight"), workspace: EditorHost.Preflight, enabled: true }, + { label: qsTr("Production Preview"), workspace: EditorHost.ProductionPreview, enabled: true }, + { label: qsTr("Pages / Production"), workspace: EditorHost.Pages, enabled: true }, + { label: qsTr("Inspect"), workspace: EditorHost.Inspect, enabled: true }, + { label: qsTr("Fix"), workspace: EditorHost.Fix, enabled: true }, + { label: qsTr("Compare"), workspace: EditorHost.Compare, enabled: false } + ] + + delegate: ToolButton { + required property string label + required property int workspace + required property bool enabled + + Layout.fillWidth: true + text: label + checkable: true + enabled: enabled + checked: host && host.workspace === workspace + onClicked: root.setWorkspaceFromRail(workspace) + Accessible.name: workspace === EditorHost.Compare + ? qsTr("Compare workspace (product decision pending)") + : qsTr("%1 workspace").arg(label) + Accessible.description: workspace === EditorHost.Compare + ? qsTr("Compare is disabled until the product decision is approved.") + : "" + } + } + + Item { Layout.fillHeight: true } } + } + + StackLayout { + id: workspaceStack + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: host ? root.workspaceIndex(host.workspace) : 0 - ToolButton { - id: preflightButton - Layout.fillWidth: true - text: qsTr("Preflight") - checkable: true - checked: workspaceStack.currentIndex === 1 - onClicked: workspaceStack.currentIndex = 1 - Accessible.name: qsTr("Preflight workspace") - Accessible.role: Accessible.Button + DocumentPane { + id: documentPane + host: root.host } - ToolButton { - id: inspectorButton - Layout.fillWidth: true - text: qsTr("Inspector") - checkable: true - checked: workspaceStack.currentIndex === 2 - onClicked: workspaceStack.currentIndex = 2 - Accessible.name: qsTr("Inspector workspace") - Accessible.role: Accessible.Button + PreflightPane { + host: root.host } - Item { Layout.fillHeight: true } - } - } + WorkspacePlaceholderPane { + host: root.host + titleText: qsTr("Production Preview") + descriptionText: qsTr("Soft proofing and output preview will appear here.") + Accessible.name: qsTr("Production Preview workspace") + } - StackLayout { - id: workspaceStack - Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: 0 + WorkspacePlaceholderPane { + host: root.host + titleText: qsTr("Pages / Production") + descriptionText: qsTr("Page assembly and production geometry will appear here.") + Accessible.name: qsTr("Pages and Production workspace") + } - DocumentPane { - id: documentPane - host: root.host - } + WorkspacePlaceholderPane { + host: root.host + titleText: qsTr("Inspect") + descriptionText: qsTr("Dedicated inspection tools will appear here. Use the document inspector dock for contextual selection.") + Accessible.name: qsTr("Inspect workspace") + } - PreflightPane { - host: root.host - } + WorkspacePlaceholderPane { + host: root.host + titleText: qsTr("Fix") + descriptionText: qsTr("Bounded corrective operations will appear here.") + Accessible.name: qsTr("Fix workspace") + } - InspectorPane { - host: root.host + WorkspacePlaceholderPane { + host: root.host + titleText: qsTr("Compare") + descriptionText: qsTr("Compare remains deferred pending the product decision.") + Accessible.name: qsTr("Compare workspace placeholder") + } } } } @@ -90,9 +150,13 @@ Item { Connections { target: root.host + function onWorkspaceChanged() { + if (root.host) { + workspaceStack.currentIndex = root.workspaceIndex(root.host.workspace) + } + } function onPresentationChanged() { if (root.host && root.host.workspaceRequest >= 0) { - workspaceStack.currentIndex = root.host.workspaceRequest root.host.acknowledgeWorkspaceRequest() } } diff --git a/LoopEditor/qml/WorkspacePlaceholderPane.qml b/LoopEditor/qml/WorkspacePlaceholderPane.qml new file mode 100644 index 000000000..d2c9680c3 --- /dev/null +++ b/LoopEditor/qml/WorkspacePlaceholderPane.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Pane { + id: root + + property var host: editorHost + property string titleText: "" + property string descriptionText: "" + + padding: 24 + + Accessible.role: Accessible.Grouping + Accessible.name: titleText + + ColumnLayout { + anchors.fill: parent + spacing: 12 + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + font.bold: true + text: root.titleText + Accessible.name: root.titleText + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.descriptionText + Accessible.name: root.descriptionText + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: root.host && root.host.hasDocument + text: qsTr("Document: %1").arg(root.host.displayTitle) + } + + Item { Layout.fillHeight: true } + } +} diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index f459deca4..0025debcc 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -96,6 +96,8 @@ add_library(LoopLibCore SHARED sources/pdfprocessingbudget.h sources/pdfjobscheduler.cpp sources/pdfjobscheduler.h + sources/pdfblockingthreadguard.cpp + sources/pdfblockingthreadguard.h sources/pdfschemaversion.cpp sources/pdfschemaversion.h sources/pdfevidencegraph.cpp diff --git a/LoopLibCore/sources/pdfactionlist.cpp b/LoopLibCore/sources/pdfactionlist.cpp index d21633f6e..17101a16f 100644 --- a/LoopLibCore/sources/pdfactionlist.cpp +++ b/LoopLibCore/sources/pdfactionlist.cpp @@ -22,6 +22,8 @@ #include "pdfactionlist.h" +#include "pdfpreflightverdict.h" + #include #include #include @@ -41,9 +43,12 @@ QString failurePolicyName(PDFActionListFailurePolicy policy) { switch (policy) { - case PDFActionListFailurePolicy::Inherit: return QStringLiteral("inherit"); - case PDFActionListFailurePolicy::Stop: return QStringLiteral("stop"); - case PDFActionListFailurePolicy::Continue: return QStringLiteral("continue"); + case PDFActionListFailurePolicy::Inherit: + return QStringLiteral("inherit"); + case PDFActionListFailurePolicy::Stop: + return QStringLiteral("stop"); + case PDFActionListFailurePolicy::Continue: + return QStringLiteral("continue"); } return QStringLiteral("inherit"); } @@ -80,11 +85,16 @@ bool isJsonNumber(const QJsonValue& value) bool matchesType(const QJsonValue& value, const QString& type) { - if (type == QStringLiteral("object")) return value.isObject(); - if (type == QStringLiteral("array")) return value.isArray(); - if (type == QStringLiteral("string")) return value.isString(); - if (type == QStringLiteral("boolean")) return value.isBool(); - if (type == QStringLiteral("number")) return isJsonNumber(value); + if (type == QStringLiteral("object")) + return value.isObject(); + if (type == QStringLiteral("array")) + return value.isArray(); + if (type == QStringLiteral("string")) + return value.isString(); + if (type == QStringLiteral("boolean")) + return value.isBool(); + if (type == QStringLiteral("number")) + return isJsonNumber(value); if (type == QStringLiteral("integer")) { return isJsonNumber(value) && std::floor(value.toDouble()) == value.toDouble(); @@ -95,7 +105,8 @@ bool matchesType(const QJsonValue& value, const QString& type) bool valuesEqual(const QJsonValue& left, const QJsonValue& right) { return QJsonDocument(left.toObject()).toJson(QJsonDocument::Compact) == - QJsonDocument(right.toObject()).toJson(QJsonDocument::Compact) || left == right; + QJsonDocument(right.toObject()).toJson(QJsonDocument::Compact) || + left == right; } void appendError(QStringList* errors, const QString& error) @@ -107,9 +118,9 @@ void appendError(QStringList* errors, const QString& error) } bool validateValue(const QJsonValue& value, - const QJsonObject& schema, - const QString& path, - QStringList* errors) + const QJsonObject& schema, + const QString& path, + QStringList* errors) { const QString type = schema.value(QStringLiteral("type")).toString(); if (!type.isEmpty() && !matchesType(value, type)) @@ -301,8 +312,9 @@ PDFActionListFailurePolicy effectivePolicy(const PDFActionList& actionList, cons QString recipeHash(const PDFActionList& actionList) { return QString::fromLatin1(QCryptographicHash::hash( - QJsonDocument(actionList.toJson()).toJson(QJsonDocument::Compact), - QCryptographicHash::Sha256).toHex()); + QJsonDocument(actionList.toJson()).toJson(QJsonDocument::Compact), + QCryptographicHash::Sha256) + .toHex()); } void addDiagnostic(PDFActionListStepResult* step, const QString& code, const QString& message) @@ -310,8 +322,7 @@ void addDiagnostic(PDFActionListStepResult* step, const QString& code, const QSt step->diagnostics.append(QJsonObject{ { QStringLiteral("code"), code }, { QStringLiteral("severity"), QStringLiteral("error") }, - { QStringLiteral("message"), message } - }); + { QStringLiteral("message"), message } }); } void markRemaining(QVector* steps, int start, PDFActionListStepStatus status, const QString& code, const QString& message) @@ -326,18 +337,46 @@ void markRemaining(QVector* steps, int start, PDFAction } } -} // namespace +} // namespace + +void applyCanonicalPreflightVerdict(PDFActionListStepResult* step, const PreflightVerdict& verdict) +{ + if (!step) + { + return; + } + step->verdict = verdict.toJson(); + if (!verdict.isPass() && step->status == PDFActionListStepStatus::Succeeded) + { + step->status = PDFActionListStepStatus::Failed; + step->diagnostics.append(QJsonObject{ + { QStringLiteral("code"), QStringLiteral("action-list.postflight-verdict") }, + { QStringLiteral("severity"), QStringLiteral("error") }, + { QStringLiteral("message"), preflightVerdictOperatorSummary(verdict) } }); + } +} + +void applyCanonicalPreflightVerdict(PDFActionListStepResult* step, const PreflightResult& result) +{ + applyCanonicalPreflightVerdict(step, reducePreflightVerdict(result)); +} QString pdfActionListStepStatusName(PDFActionListStepStatus status) { switch (status) { - case PDFActionListStepStatus::Pending: return QStringLiteral("pending"); - case PDFActionListStepStatus::Running: return QStringLiteral("running"); - case PDFActionListStepStatus::Succeeded: return QStringLiteral("succeeded"); - case PDFActionListStepStatus::Skipped: return QStringLiteral("skipped"); - case PDFActionListStepStatus::Failed: return QStringLiteral("failed"); - case PDFActionListStepStatus::Cancelled: return QStringLiteral("cancelled"); + case PDFActionListStepStatus::Pending: + return QStringLiteral("pending"); + case PDFActionListStepStatus::Running: + return QStringLiteral("running"); + case PDFActionListStepStatus::Succeeded: + return QStringLiteral("succeeded"); + case PDFActionListStepStatus::Skipped: + return QStringLiteral("skipped"); + case PDFActionListStepStatus::Failed: + return QStringLiteral("failed"); + case PDFActionListStepStatus::Cancelled: + return QStringLiteral("cancelled"); } return QStringLiteral("failed"); } @@ -354,7 +393,8 @@ QJsonObject PDFActionListStep::toJson() const { QStringLiteral("operation"), operationId }, { QStringLiteral("params"), parameters } }; - if (!condition.isEmpty()) result.insert(QStringLiteral("when"), condition); + if (!condition.isEmpty()) + result.insert(QStringLiteral("when"), condition); if (failurePolicy != PDFActionListFailurePolicy::Inherit) { result.insert(QStringLiteral("onFailure"), failurePolicyName(failurePolicy)); @@ -427,6 +467,7 @@ QJsonObject PDFActionListStepResult::toJson() const { QStringLiteral("resolved_params"), resolvedParameters }, { QStringLiteral("plan"), plan }, { QStringLiteral("repair_result"), repairResult }, + { QStringLiteral("verdict"), verdict }, { QStringLiteral("diagnostics"), diagnostics }, { QStringLiteral("affected_scope"), affectedScope } }; @@ -465,7 +506,8 @@ PDFOperationResult PDFActionListExecutor::validate(const PDFActionList& actionLi const PDFActionListExecutionOptions& options, QStringList* errors) const { - if (errors) errors->clear(); + if (errors) + errors->clear(); bool valid = true; if (actionList.schema != schemaVersion()) { @@ -562,8 +604,7 @@ PDFOperationResult PDFActionListExecutor::plan(const PDFActionList& actionList, result->diagnostics.append(QJsonObject{ { QStringLiteral("code"), QStringLiteral("action-list.validation-failed") }, { QStringLiteral("severity"), QStringLiteral("error") }, - { QStringLiteral("message"), error } - }); + { QStringLiteral("message"), error } }); } result->durationMs = totalTimer.elapsed(); return validation; @@ -690,6 +731,14 @@ PDFOperationResult PDFActionListExecutor::execute(const PDFActionList& actionLis } stepResult.plan = currentPlan.toJson(); stepResult.repairResult = repairResult.toJson(); + if (!repairResult.verdict.isEmpty()) + { + applyCanonicalPreflightVerdict(&stepResult, preflightVerdictFromJson(repairResult.verdict)); + if (stepResult.status == PDFActionListStepStatus::Failed) + { + hadFailure = true; + } + } } stepResult.durationMs = stepTimer.elapsed(); statuses.insert(step.id, pdfActionListStepStatusName(stepResult.status)); @@ -713,4 +762,4 @@ PDFOperationResult PDFActionListExecutor::execute(const PDFActionList& actionLis return PDFOperationResult(true); } -} // namespace pdf +} // namespace pdf diff --git a/LoopLibCore/sources/pdfactionlist.h b/LoopLibCore/sources/pdfactionlist.h index 878d7c374..0225147a4 100644 --- a/LoopLibCore/sources/pdfactionlist.h +++ b/LoopLibCore/sources/pdfactionlist.h @@ -34,6 +34,9 @@ namespace pdf { +struct PreflightVerdict; +struct PreflightResult; + enum class PDFActionListStepStatus { Pending, @@ -86,12 +89,16 @@ struct LOOPLIBCORESHARED_EXPORT PDFActionListStepResult QJsonObject resolvedParameters; QJsonObject plan; QJsonObject repairResult; + QJsonObject verdict; QJsonArray diagnostics; QJsonArray affectedScope; QJsonObject toJson() const; }; +LOOPLIBCORESHARED_EXPORT void applyCanonicalPreflightVerdict(PDFActionListStepResult* step, const PreflightVerdict& verdict); +LOOPLIBCORESHARED_EXPORT void applyCanonicalPreflightVerdict(PDFActionListStepResult* step, const PreflightResult& result); + struct LOOPLIBCORESHARED_EXPORT PDFActionListExecutionResult { QString schema = QStringLiteral("loop-action-list-result/1"); @@ -145,6 +152,6 @@ class LOOPLIBCORESHARED_EXPORT PDFActionListExecutor const PDFRepairRegistry* m_registry = nullptr; }; -} // namespace pdf +} // namespace pdf -#endif // PDFACTIONLIST_H +#endif // PDFACTIONLIST_H diff --git a/LoopLibCore/sources/pdfblockingthreadguard.cpp b/LoopLibCore/sources/pdfblockingthreadguard.cpp new file mode 100644 index 000000000..b91be3811 --- /dev/null +++ b/LoopLibCore/sources/pdfblockingthreadguard.cpp @@ -0,0 +1,73 @@ +// 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 "pdfblockingthreadguard.h" + +#include +#include + +#include + +namespace pdf +{ + +namespace +{ + +std::atomic s_interactiveThread{ nullptr }; + +} // namespace + +void PDFBlockingThreadGuard::registerInteractiveThread() +{ + s_interactiveThread.store(QThread::currentThread(), std::memory_order_release); +} + +void PDFBlockingThreadGuard::clearInteractiveThread() +{ + s_interactiveThread.store(nullptr, std::memory_order_release); +} + +bool PDFBlockingThreadGuard::isInteractiveThreadRegistered() +{ + return s_interactiveThread.load(std::memory_order_acquire) != nullptr; +} + +bool PDFBlockingThreadGuard::isCurrentThreadInteractive() +{ + QThread* registered = s_interactiveThread.load(std::memory_order_acquire); + return registered != nullptr && registered == QThread::currentThread(); +} + +bool PDFBlockingThreadGuard::assertOffInteractiveThread(const char* serviceName) +{ + if (!isCurrentThreadInteractive()) + { + return true; + } + + qWarning("%s must not run on the interactive thread: it would block pointer handling and frame callbacks.", + serviceName ? serviceName : "A blocking service"); + return false; +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfblockingthreadguard.h b/LoopLibCore/sources/pdfblockingthreadguard.h new file mode 100644 index 000000000..72c4069b3 --- /dev/null +++ b/LoopLibCore/sources/pdfblockingthreadguard.h @@ -0,0 +1,67 @@ +// 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 PDFBLOCKINGTHREADGUARD_H +#define PDFBLOCKINGTHREADGUARD_H + +#include "pdfglobal.h" + +namespace pdf +{ + +/// Marks the thread that owns pointer handlers and frame callbacks so a +/// blocking service adapter (preflight, OCR, font enumeration, parsing, +/// filesystem/network I/O) can refuse to run there (issue #144). +/// +/// Registration is opt-in and global rather than per-object: a host with an +/// interactive canvas (Editor) registers once, early, from that thread. A +/// tool with no such thread (PdfTool, Fuzz, CLI tests) never registers, and +/// the guard is then a no-op that never blocks a caller: there is nothing to +/// protect. +class LOOPLIBCORESHARED_EXPORT PDFBlockingThreadGuard +{ +public: + /// Registers the calling thread as the one interactive input and frame + /// callbacks run on. + static void registerInteractiveThread(); + + /// Drops the registration. A guard with no registered thread never + /// reports a violation. + static void clearInteractiveThread(); + + static bool isInteractiveThreadRegistered(); + + /// True when called from the registered interactive thread. Always false + /// when no thread is registered. + static bool isCurrentThreadInteractive(); + + /// A blocking service adapter calls this once, before doing any work. + /// Returns true when it is safe to proceed. Returns false, and logs a + /// warning naming \p serviceName, when called from the registered + /// interactive thread -- the caller must not run the blocking work and + /// should fold this into its own typed error result instead. + static bool assertOffInteractiveThread(const char* serviceName); +}; + +} // namespace pdf + +#endif // PDFBLOCKINGTHREADGUARD_H diff --git a/LoopLibCore/sources/pdfcms.cpp b/LoopLibCore/sources/pdfcms.cpp index 8374e4a7b..8573b6469 100644 --- a/LoopLibCore/sources/pdfcms.cpp +++ b/LoopLibCore/sources/pdfcms.cpp @@ -23,6 +23,7 @@ #include "pdfcms.h" #include "pdfdocument.h" #include "pdfexecutionpolicy.h" +#include "pdfprocessingbudget.h" #include #include @@ -41,7 +42,7 @@ #ifdef LOOP_COMPILER_MSVC #pragma warning(push) -#pragma warning(disable:5033) +#pragma warning(disable : 5033) #endif #ifndef CMS_NO_REGISTER_KEYWORD #define CMS_NO_REGISTER_KEYWORD @@ -98,7 +99,7 @@ static QByteArray getWindowsColorProfileData(const QString& profileName) { std::wstring profileNameW = profileName.toStdWString(); - PROFILE profile = { }; + PROFILE profile = {}; profile.dwType = PROFILE_FILENAME; profile.pProfileData = profileNameW.data(); profile.cbDataSize = DWORD((profileNameW.size() + 1) * sizeof(wchar_t)); @@ -106,7 +107,7 @@ static QByteArray getWindowsColorProfileData(const QString& profileName) HPROFILE profileHandle = OpenColorProfileW(&profile, PROFILE_READ, FILE_SHARE_READ, OPEN_EXISTING); if (!profileHandle) { - return { }; + return {}; } DWORD profileSize = 0; @@ -114,7 +115,7 @@ static QByteArray getWindowsColorProfileData(const QString& profileName) if (!GetColorProfileFromHandle(profileHandle, nullptr, &profileSize) && GetLastError() != ERROR_INSUFFICIENT_BUFFER) { CloseColorProfile(profileHandle); - return { }; + return {}; } if (profileSize > 0) @@ -138,7 +139,7 @@ static HPROFILE openWindowsColorProfile(const QString& profileName) { std::wstring profileNameW = profileName.toStdWString(); - PROFILE profile = { }; + PROFILE profile = {}; profile.dwType = PROFILE_FILENAME; profile.pProfileData = profileNameW.data(); profile.cbDataSize = DWORD((profileNameW.size() + 1) * sizeof(wchar_t)); @@ -152,7 +153,7 @@ static PDFColorProfileIdentifier::Type getWindowsColorProfileType(HPROFILE profi constexpr DWORD spaceRgb = 0x52474220u; constexpr DWORD spaceCmyk = 0x434d594bu; - PROFILEHEADER header = { }; + PROFILEHEADER header = {}; if (!GetColorProfileHeader(profileHandle, &header)) { return PDFColorProfileIdentifier::Type::Invalid; @@ -180,7 +181,7 @@ static PDFColorProfileIdentifiers getInstalledWindowsColorProfiles() { PDFColorProfileIdentifiers result; - ENUMTYPEW enumRecord = { }; + ENUMTYPEW enumRecord = {}; enumRecord.dwSize = sizeof(ENUMTYPEW); enumRecord.dwVersion = ENUM_TYPE_VERSION; @@ -253,7 +254,7 @@ class PDFLittleCMS : public PDFCMS static int installCmsPlugins(); static cmsBool optimizePipeline(cmsPipeline** Lut, - cmsUInt32Number Intent, + cmsUInt32Number Intent, cmsUInt32Number* InputFormat, cmsUInt32Number* OutputFormat, cmsUInt32Number* dwFlags); @@ -556,7 +557,6 @@ bool PDFLittleCMS::transformColorSpace(const PDFCMS::ColorSpaceTransformParams& target(target), pixelCount(pixelCount) { - } const float* source = nullptr; @@ -695,7 +695,7 @@ QColor PDFLittleCMS::getColorFromDeviceGray(const PDFColor& color, RenderingInte Q_ASSERT(cmsGetTransformOutputFormat(transform) == TYPE_RGB_FLT); const float grayColor = color[0]; - std::array rgbOutputColor = { }; + std::array rgbOutputColor = {}; cmsDoTransform(transform, &grayColor, rgbOutputColor.data(), 1); return getColorFromOutputColor(rgbOutputColor); } @@ -722,7 +722,7 @@ QColor PDFLittleCMS::getColorFromDeviceRGB(const PDFColor& color, RenderingInten Q_ASSERT(cmsGetTransformOutputFormat(transform) == TYPE_RGB_FLT); std::array rgbInputColor = { color[0], color[1], color[2] }; - std::array rgbOutputColor = { }; + std::array rgbOutputColor = {}; cmsDoTransform(transform, rgbInputColor.data(), rgbOutputColor.data(), 1); return getColorFromOutputColor(rgbOutputColor); } @@ -749,7 +749,7 @@ QColor PDFLittleCMS::getColorFromDeviceCMYK(const PDFColor& color, RenderingInte Q_ASSERT(cmsGetTransformOutputFormat(transform) == TYPE_RGB_FLT); std::array cmykInputColor = { color[0] * 100.0f, color[1] * 100.0f, color[2] * 100.0f, color[3] * 100.0f }; - std::array rgbOutputColor = { }; + std::array rgbOutputColor = {}; cmsDoTransform(transform, cmykInputColor.data(), rgbOutputColor.data(), 1); return getColorFromOutputColor(rgbOutputColor); } @@ -777,7 +777,7 @@ QColor PDFLittleCMS::getColorFromXYZ(const PDFColor3& whitePoint, const PDFColor const PDFColorComponentMatrix_3x3 adaptationMatrix = PDFChromaticAdaptationXYZ::createWhitepointChromaticAdaptation(getDefaultXYZWhitepoint(), whitePoint, m_settings.colorAdaptationXYZ); const PDFColor3 xyzInputColor = adaptationMatrix * color; - std::array rgbOutputColor = { }; + std::array rgbOutputColor = {}; cmsDoTransform(transform, xyzInputColor.data(), rgbOutputColor.data(), 1); return getColorFromOutputColor(rgbOutputColor); } @@ -856,7 +856,7 @@ QColor PDFLittleCMS::getColorFromICC(const PDFColor& color, RenderingIntent rend return QColor(); } - std::array inputBuffer = { }; + std::array inputBuffer = {}; const cmsUInt32Number format = cmsGetTransformInputFormat(transform); const cmsUInt32Number channels = T_CHANNELS(format); const cmsUInt32Number colorSpace = T_COLORSPACE(format); @@ -868,7 +868,7 @@ QColor PDFLittleCMS::getColorFromICC(const PDFColor& color, RenderingIntent rend inputBuffer[i] = isCMYK ? color[i] * 100.0f : color[i]; } - std::array rgbOutputColor = { }; + std::array rgbOutputColor = {}; cmsDoTransform(transform, inputBuffer.data(), rgbOutputColor.data(), 1); return getColorFromOutputColor(rgbOutputColor); } @@ -916,7 +916,7 @@ void PDFLittleCMS::init() int PDFLittleCMS::installCmsPlugins() { - static cmsPluginOptimization optimizationPlugin = { }; + static cmsPluginOptimization optimizationPlugin = {}; optimizationPlugin.base.Magic = cmsPluginMagicNumber; optimizationPlugin.base.Type = cmsPluginOptimizationSig; optimizationPlugin.base.Next = nullptr; @@ -1027,14 +1027,16 @@ bool PDFLittleCMS::isSoftProofing() const cmsHPROFILE PDFLittleCMS::createProfile(const QString& id, const PDFColorProfileIdentifiers& profileDescriptors, bool preferOutputProfile) const { - auto it = std::find_if(profileDescriptors.cbegin(), profileDescriptors.cend(), [&id](const PDFColorProfileIdentifier& identifier) { return identifier.id == id; }); + auto it = std::find_if(profileDescriptors.cbegin(), profileDescriptors.cend(), [&id](const PDFColorProfileIdentifier& identifier) + { return identifier.id == id; }); if (preferOutputProfile && it != profileDescriptors.end()) { const PDFColorProfileIdentifier& identifier = *it; if (!identifier.isOutputIntentProfile) { // Find first output intent color profile - auto itOutputIntentColorProfile = std::find_if(profileDescriptors.cbegin(), profileDescriptors.cend(), [](const PDFColorProfileIdentifier& identifier) { return identifier.isOutputIntentProfile; }); + auto itOutputIntentColorProfile = std::find_if(profileDescriptors.cbegin(), profileDescriptors.cend(), [](const PDFColorProfileIdentifier& identifier) + { return identifier.isOutputIntentProfile; }); if (itOutputIntentColorProfile != profileDescriptors.end()) { it = itOutputIntentColorProfile; @@ -1049,7 +1051,7 @@ cmsHPROFILE PDFLittleCMS::createProfile(const QString& id, const PDFColorProfile { case PDFColorProfileIdentifier::Type::Gray: { - cmsCIExyY whitePoint{ }; + cmsCIExyY whitePoint{}; if (cmsWhitePointFromTemp(&whitePoint, identifier.temperature)) { cmsToneCurve* gammaCurve = cmsBuildGamma(cmsContext(), identifier.gamma); @@ -1069,7 +1071,7 @@ cmsHPROFILE PDFLittleCMS::createProfile(const QString& id, const PDFColorProfile case PDFColorProfileIdentifier::Type::RGB: { - cmsCIExyY whitePoint{ }; + cmsCIExyY whitePoint{}; if (cmsWhitePointFromTemp(&whitePoint, identifier.temperature)) { cmsCIExyYTRIPLE primaries; @@ -1422,8 +1424,8 @@ QString getInfoFromProfile(cmsHPROFILE profile, cmsInfoType infoType) QString country = QLocale::territoryToString(locale.territory()); QString language = QLocale::languageToString(locale.language()); - char countryCode[3] = { }; - char languageCode[3] = { }; + char countryCode[3] = {}; + char languageCode[3] = {}; if (country.size() == 2) { countryCode[0] = country[0].toLatin1(); @@ -1459,7 +1461,6 @@ QString getInfoFromProfile(cmsHPROFILE profile, cmsInfoType infoType) PDFCMSGeneric::PDFCMSGeneric(const PDFColorConvertor& colorConvertor) : m_colorConvertor(colorConvertor) { - } bool PDFCMSGeneric::isCompatible(const PDFCMSSettings& settings) const @@ -1619,7 +1620,6 @@ PDFCMSManager::PDFCMSManager(QObject* parent) : m_document(nullptr), m_mutex() { - } void PDFCMSManager::finalize() @@ -1715,6 +1715,11 @@ PDFCMSSettings PDFCMSManager::getDefaultSettings() const } void PDFCMSManager::setDocument(const PDFDocument* document) +{ + setDocument(document, nullptr); +} + +void PDFCMSManager::setDocument(const PDFDocument* document, PDFProcessingBudget* processingBudget) { std::optional> lock; lock.emplace(&m_mutex); @@ -1741,9 +1746,17 @@ void PDFCMSManager::setDocument(const PDFDocument* document) PDFObject outputProfileObject = m_document->getObject(outputIntent.getOutputProfile()); if (outputProfileObject.isStream()) { - content = m_document->getDecodedStream(outputProfileObject.getStream()); + content = m_document->getDecodedStream(outputProfileObject.getStream(), processingBudget); } } + catch (const PDFBudgetExceededException&) + { + // A budget failure is an incomplete operation, not a profile that + // failed to parse: it must reach the caller (see + // docs/RESOURCE_BUDGETS.md - "a budget failure is incomplete, + // never PASS"). + throw; + } catch (const PDFException&) { continue; @@ -1869,8 +1882,7 @@ PDFColorProfileIdentifiers PDFCMSManager::getGrayProfilesImpl() const { // Jakub Melka: We create gray profiles for temperature 5000K, 6500K and 9300K. // We also use linear gamma and gamma value 2.2. - PDFColorProfileIdentifiers result = - { + PDFColorProfileIdentifiers result = { PDFColorProfileIdentifier::createGray(tr("Gray D65, γ = 2.2"), "@GENERIC_Gray_D65_g22", 6500.0, 2.2), PDFColorProfileIdentifier::createGray(tr("Gray D50, γ = 2.2"), "@GENERIC_Gray_D50_g22", 5000.0, 2.2), PDFColorProfileIdentifier::createGray(tr("Gray D93, γ = 2.2"), "@GENERIC_Gray_D93_g22", 9300.0, 2.2), @@ -1890,8 +1902,7 @@ PDFColorProfileIdentifiers PDFCMSManager::getRGBProfilesImpl() const { // Jakub Melka: We create RGB profiles for common standards and also for // default standard sRGB. See https://en.wikipedia.org/wiki/Color_spaces_with_RGB_primaries. - PDFColorProfileIdentifiers result = - { + PDFColorProfileIdentifiers result = { PDFColorProfileIdentifier::createSRGB(), PDFColorProfileIdentifier::createRGB(tr("HDTV (ITU-R BT.709)"), "@GENERIC_RGB_HDTV", 6500, QPointF(0.64, 0.33), QPointF(0.30, 0.60), QPointF(0.15, 0.06), 20.0 / 9.0), PDFColorProfileIdentifier::createRGB(tr("Adobe RGB 1998"), "@GENERIC_RGB_Adobe1998", 6500, QPointF(0.64, 0.33), QPointF(0.30, 0.60), QPointF(0.15, 0.06), 563.0 / 256.0), @@ -2046,7 +2057,8 @@ PDFColorProfileIdentifiers PDFCMSManager::getFilteredExternalProfiles(PDFColorPr PDFColorProfileIdentifiers PDFCMSManager::getFilteredOutputIntentProfiles(PDFColorProfileIdentifier::Type type) const { PDFColorProfileIdentifiers result; - std::copy_if(m_outputIntentProfiles.cbegin(), m_outputIntentProfiles.cend(), std::back_inserter(result), [type](const PDFColorProfileIdentifier& identifier) { return identifier.type == type; }); + std::copy_if(m_outputIntentProfiles.cbegin(), m_outputIntentProfiles.cend(), std::back_inserter(result), [type](const PDFColorProfileIdentifier& identifier) + { return identifier.type == type; }); return result; } @@ -2134,24 +2146,23 @@ PDFColorComponentMatrix_3x3 PDFChromaticAdaptationXYZ::createWhitepointChromatic case pdf::PDFCMSSettings::ColorAdaptationXYZ::XYZScaling: matrix.makeDiagonal(std::array{ targetWhitePoint[0] / sourceWhitePoint[0], - targetWhitePoint[1] / sourceWhitePoint[1], - targetWhitePoint[2] / sourceWhitePoint[2] - }); + targetWhitePoint[1] / sourceWhitePoint[1], + targetWhitePoint[2] / sourceWhitePoint[2] }); break; case pdf::PDFCMSSettings::ColorAdaptationXYZ::CAT97: { // CAT97 matrix, as defined in https://en.wikipedia.org/wiki/LMS_color_space constexpr PDFColorComponentMatrix_3x3 cat97Matrix( - 0.8562f, 0.3372f, -0.1934f, - -0.8360f, 1.8327f, 0.0033f, - 0.0357f, -0.0469f, 1.0112f); + 0.8562f, 0.3372f, -0.1934f, + -0.8360f, 1.8327f, 0.0033f, + 0.0357f, -0.0469f, 1.0112f); // Inverse of CAT97 matrix (using wxMaxima to compute it) constexpr PDFColorComponentMatrix_3x3 inverseCat97Matrix( - 0.9873999149199271f, -0.1768250198556842f, 0.1894251049357571f, - 0.4504351090445315f, 0.464932897752711f, 0.08463199320275755f, - -0.01396832510725165f, 0.027806572501434f, 0.9861617526058175f); + 0.9873999149199271f, -0.1768250198556842f, 0.1894251049357571f, + 0.4504351090445315f, 0.464932897752711f, 0.08463199320275755f, + -0.01396832510725165f, 0.027806572501434f, 0.9861617526058175f); PDFColor3 adaptedTargetWhitePoint = cat97Matrix * targetWhitePoint; PDFColor3 adaptedSourceWhitePoint = cat97Matrix * sourceWhitePoint; @@ -2172,15 +2183,15 @@ PDFColorComponentMatrix_3x3 PDFChromaticAdaptationXYZ::createWhitepointChromatic { // CAT02 matrix, as defined in https://en.wikipedia.org/wiki/LMS_color_space constexpr PDFColorComponentMatrix_3x3 cat02Matrix( - 0.7328f, 0.4296f, -0.1624f, - -0.7036f, 1.6975f, 0.0061f, - 0.0030f, 0.0136f, 0.9834f); + 0.7328f, 0.4296f, -0.1624f, + -0.7036f, 1.6975f, 0.0061f, + 0.0030f, 0.0136f, 0.9834f); // Inverse of CAT02 matrix (using wxMaxima to compute it) constexpr PDFColorComponentMatrix_3x3 inverseCat02Matrix( - 1.096123820835514f, -0.2788690002182872f, 0.182745179382773f, - 0.4543690419753592f, 0.4735331543074117f, 0.0720978037172291f, - -0.009627608738429352f, -0.005698031216113419f, 1.015325639954543f); + 1.096123820835514f, -0.2788690002182872f, 0.182745179382773f, + 0.4543690419753592f, 0.4735331543074117f, 0.0720978037172291f, + -0.009627608738429352f, -0.005698031216113419f, 1.015325639954543f); PDFColor3 adaptedTargetWhitePoint = cat02Matrix * targetWhitePoint; PDFColor3 adaptedSourceWhitePoint = cat02Matrix * sourceWhitePoint; @@ -2201,15 +2212,15 @@ PDFColorComponentMatrix_3x3 PDFChromaticAdaptationXYZ::createWhitepointChromatic { // Bradford matrix, as defined in https://en.wikipedia.org/wiki/LMS_color_space constexpr PDFColorComponentMatrix_3x3 bradfordMatrix( - 0.8951f, 0.2264f, -0.1614f, - -0.7502f, 1.7135f, 0.0367f, - 0.0389f, -0.0685f, 1.0296f); + 0.8951f, 0.2264f, -0.1614f, + -0.7502f, 1.7135f, 0.0367f, + 0.0389f, -0.0685f, 1.0296f); // Inverse of bradford matrix (using wxMaxima to compute it) constexpr PDFColorComponentMatrix_3x3 inverseBradfordMatrix( - 1.004360519274085f, -0.1262294327613208f, 0.1619428982062721f, - 0.4399123264001572f, 0.527481594455384f, 0.05015858096782513f, - -0.008678739162151443f, 0.03986287311053728f, 0.9684695843590444f); + 1.004360519274085f, -0.1262294327613208f, 0.1619428982062721f, + 0.4399123264001572f, 0.527481594455384f, 0.05015858096782513f, + -0.008678739162151443f, 0.03986287311053728f, 0.9684695843590444f); PDFColor3 adaptedTargetWhitePoint = bradfordMatrix * targetWhitePoint; PDFColor3 adaptedSourceWhitePoint = bradfordMatrix * sourceWhitePoint; diff --git a/LoopLibCore/sources/pdfcms.h b/LoopLibCore/sources/pdfcms.h index 62ebbc1d0..43ed0934e 100644 --- a/LoopLibCore/sources/pdfcms.h +++ b/LoopLibCore/sources/pdfcms.h @@ -37,6 +37,8 @@ namespace pdf { +class PDFProcessingBudget; + /// This simple structure stores settings for color management system, and what /// color management system should be used. At default, two color management /// system are available - generic (which uses default imprecise color management), @@ -82,13 +84,13 @@ struct PDFCMSSettings bool isGamutChecking = false; bool isSoftProofing = false; bool isConsiderOutputIntent = true; - QColor outOfGamutColor = Qt::red; ///< Color, which marks out-of-gamut when soft-proofing is proceeded - QString outputCS; ///< Output (rendering) color space - QString deviceGray; ///< Identifiers for color space (device gray) - QString deviceRGB; ///< Identifiers for color space (device RGB) - QString deviceCMYK; ///< Identifiers for color space (device CMYK) - QString softProofingProfile; ///< Identifiers for soft proofing profile - QString profileDirectory; ///< Directory containing color profiles + QColor outOfGamutColor = Qt::red; ///< Color, which marks out-of-gamut when soft-proofing is proceeded + QString outputCS; ///< Output (rendering) color space + QString deviceGray; ///< Identifiers for color space (device gray) + QString deviceRGB; ///< Identifiers for color space (device RGB) + QString deviceCMYK; ///< Identifiers for color space (device CMYK) + QString softProofingProfile; ///< Identifiers for soft proofing profile + QString profileDirectory; ///< Directory containing color profiles // Postprocessing QColor foregroundColor = Qt::green; @@ -281,7 +283,7 @@ class LOOPLIBCORESHARED_EXPORT PDFCMSGeneric : public PDFCMS virtual QColor getColorFromDeviceRGB(const PDFColor& color, RenderingIntent intent, PDFRenderErrorReporter* reporter) const override; virtual QColor getColorFromDeviceCMYK(const PDFColor& color, RenderingIntent intent, PDFRenderErrorReporter* reporter) const override; virtual QColor getColorFromXYZ(const PDFColor3& whitePoint, const PDFColor3& color, RenderingIntent intent, PDFRenderErrorReporter* reporter) const override; - virtual QColor getColorFromICC(const PDFColor& color, RenderingIntent renderingIntent, const QByteArray& iccID, const QByteArray& iccData, PDFRenderErrorReporter* reporter) const override; + virtual QColor getColorFromICC(const PDFColor& color, RenderingIntent renderingIntent, const QByteArray& iccID, const QByteArray& iccData, PDFRenderErrorReporter* reporter) const override; virtual bool fillRGBBufferFromDeviceGray(const std::vector& colors, RenderingIntent intent, unsigned char* outputBuffer, PDFRenderErrorReporter* reporter) const override; virtual bool fillRGBBufferFromDeviceRGB(const std::vector& colors, RenderingIntent intent, unsigned char* outputBuffer, PDFRenderErrorReporter* reporter) const override; virtual bool fillRGBBufferFromDeviceCMYK(const std::vector& colors, RenderingIntent intent, unsigned char* outputBuffer, PDFRenderErrorReporter* reporter) const override; @@ -409,6 +411,13 @@ class LOOPLIBCORESHARED_EXPORT PDFCMSManager : public QObject /// \param document Document void setDocument(const PDFDocument* document); + /// Same as setDocument(), but decodes output-intent profiles under the given + /// operation budget so a hostile /DestOutputProfile cannot bypass the + /// cumulative and elapsed decoded-stream accounting. The budget is a + /// parameter rather than a member: PDFCMSManager is an exported class and + /// growing it would break the Core ABI the Editor plugins link against. + void setDocument(const PDFDocument* document, PDFProcessingBudget* processingBudget); + /// Get translated name for color management system /// \param system System static QString getSystemName(PDFCMSSettings::System system); @@ -475,4 +484,4 @@ class PDFChromaticAdaptationXYZ } // namespace pdf -#endif // PDFCMS_H +#endif // PDFCMS_H diff --git a/LoopLibCore/sources/pdfdiagnostics.cpp b/LoopLibCore/sources/pdfdiagnostics.cpp index f57069718..ac878f55e 100644 --- a/LoopLibCore/sources/pdfdiagnostics.cpp +++ b/LoopLibCore/sources/pdfdiagnostics.cpp @@ -138,18 +138,35 @@ QJsonObject buildSystemInfo(const QString& applicationId) return root; } +/// Longest plugin display string copied into a bundle. Plugin metadata is +/// author-supplied JSON that Loop does not size-validate at load time, so an +/// installed plugin with a multi-megabyte Description would otherwise bloat +/// every future support bundle. The cap is generous for a real display string +/// and the truncation is visible rather than silent. +constexpr int PLUGIN_FIELD_LENGTH_LIMIT = 2048; + +QString truncatePluginField(const QString& value) +{ + if (value.size() <= PLUGIN_FIELD_LENGTH_LIMIT) + { + return value; + } + + return value.left(PLUGIN_FIELD_LENGTH_LIMIT) + QStringLiteral("... "); +} + QJsonObject buildPlugins(const PDFPluginInfos& plugins) { QJsonArray array; for (const PDFPluginInfo& plugin : plugins) { QJsonObject entry; - entry[QStringLiteral("name")] = plugin.name; - entry[QStringLiteral("pluginId")] = plugin.pluginId; + entry[QStringLiteral("name")] = truncatePluginField(plugin.name); + entry[QStringLiteral("pluginId")] = truncatePluginField(plugin.pluginId); entry[QStringLiteral("abiVersion")] = static_cast(plugin.abiVersion); - entry[QStringLiteral("author")] = plugin.author; - entry[QStringLiteral("version")] = plugin.version; - entry[QStringLiteral("license")] = plugin.license; + entry[QStringLiteral("author")] = truncatePluginField(plugin.author); + entry[QStringLiteral("version")] = truncatePluginField(plugin.version); + entry[QStringLiteral("license")] = truncatePluginField(plugin.license); array.append(entry); } diff --git a/LoopLibCore/sources/pdfdocumentreader.cpp b/LoopLibCore/sources/pdfdocumentreader.cpp index 9f7f4e03d..1ef3dad2c 100644 --- a/LoopLibCore/sources/pdfdocumentreader.cpp +++ b/LoopLibCore/sources/pdfdocumentreader.cpp @@ -40,6 +40,19 @@ namespace pdf { +namespace +{ + +// Bounds for the dense object table built by damaged-document recovery. The +// table is indexed by object number, so its size is driven by the highest +// number a malformed document happens to declare rather than by how much was +// actually recovered. A document numbered more sparsely than this is not a +// recoverable document, it is a document asking for a large allocation. +constexpr PDFInteger DAMAGED_DOCUMENT_MAX_OBJECT_NUMBER_DENSITY = 64; +constexpr PDFInteger DAMAGED_DOCUMENT_MINIMUM_OBJECT_SLOTS = 4096; + +} // namespace + PDFDocumentReader::PDFDocumentReader(PDFProgress* progress, const std::function& getPasswordCallback, bool permissive, @@ -313,7 +326,8 @@ PDFObject PDFDocumentReader::getObjectFromXrefTable(PDFXRefTable* xrefTable, PDF PDFObject PDFDocumentReader::readDamagedTrailerDictionary() const { PDFObject object = PDFObject::createDictionary(std::make_shared(PDFDictionary())); - PDFParsingContext context([](PDFParsingContext*, PDFObjectReference){ return PDFObject(); }); + PDFParsingContext context([](PDFParsingContext*, PDFObjectReference) + { return PDFObject(); }); int offset = 0; while (offset < m_source.size()) @@ -350,7 +364,8 @@ PDFObject PDFDocumentReader::readDamagedTrailerDictionary() const PDFDocumentReader::Result PDFDocumentReader::processReferenceTableEntries(PDFXRefTable* xrefTable, const std::vector& occupiedEntries, PDFObjectStorage::PDFObjects& objects) { - auto objectFetcher = [this, xrefTable](PDFParsingContext* context, PDFObjectReference reference) { return getObjectFromXrefTable(xrefTable, context, reference); }; + auto objectFetcher = [this, xrefTable](PDFParsingContext* context, PDFObjectReference reference) + { return getObjectFromXrefTable(xrefTable, context, reference); }; auto processEntry = [this, &objectFetcher, &objects](const PDFXRefTable::Entry& entry) { Q_ASSERT(entry.type == PDFXRefTable::EntryType::Occupied); @@ -503,8 +518,9 @@ void PDFDocumentReader::processObjectStreams(PDFXRefTable* xrefTable, PDFObjectS objectStreams.insert(entry.objectStream); } - auto objectFetcher = [this, xrefTable](PDFParsingContext* context, PDFObjectReference reference) { return getObjectFromXrefTable(xrefTable, context, reference); }; - auto processObjectStream = [this, &objectFetcher, &objects, &objectStreamEntries] (const PDFObjectReference& objectStreamReference) + auto objectFetcher = [this, xrefTable](PDFParsingContext* context, PDFObjectReference reference) + { return getObjectFromXrefTable(xrefTable, context, reference); }; + auto processObjectStream = [this, &objectFetcher, &objects, &objectStreamEntries](const PDFObjectReference& objectStreamReference) { if (m_result != Result::OK) { @@ -603,7 +619,8 @@ void PDFDocumentReader::processObjectStreams(PDFXRefTable* xrefTable, PDFObjectS parser.seek(offset); PDFObject currentObject = parser.getObject(); - auto predicate = [objectNumber, objectStreamReference](const PDFXRefTable::Entry& entry) -> bool { return entry.reference.objectNumber == objectNumber && entry.objectStream == objectStreamReference; }; + auto predicate = [objectNumber, objectStreamReference](const PDFXRefTable::Entry& entry) -> bool + { return entry.reference.objectNumber == objectNumber && entry.objectStream == objectStreamReference; }; if (std::find_if(objectStreamEntries.cbegin(), objectStreamEntries.cend(), predicate) != objectStreamEntries.cend()) { QMutexLocker lock(&m_mutex); @@ -671,6 +688,34 @@ PDFDocument PDFDocumentReader::readFromBuffer(const QByteArray& buffer) throw PDFException(tr("Empty xref table.")); } + // The reader allocates a dense object table for every declared slot - + // including free and never-referenced ones - so the declared cardinality, + // not the number of occupied entries, is what must fit the document-model + // object budget. A sparse table otherwise turns a small file into a large + // allocation before a single object has been visited. + // + // The refusal is raised through the budget vocabulary the rest of the + // reader already uses (PDFBudgetExceededException, kind ObjectsVisited) + // rather than a bespoke message: it is the same budget a document whose + // object table grows past the limit trips while being read, so the failure + // stays attributable ("attempted N, limit L") and a budget failure still + // fails the read closed instead of being retried permissively. The detail + // is built directly because there is no bulk charge in the public budget + // API, and charging one object at a time up to the limit would make the + // refusal cost attacker-amplifiable work. + const std::uint64_t declaredObjectCount = xrefTable.getSize(); + const std::uint64_t maximalObjectTableSize = m_processingBudget.limits().maxObjectsVisited; + if (declaredObjectCount > maximalObjectTableSize) + { + PDFBudgetExceeded detail; + detail.kind = PDFBudgetKind::ObjectsVisited; + detail.pool = budgetPoolFor(detail.kind); + detail.limit = maximalObjectTableSize; + detail.attempted = declaredObjectCount; + detail.context = tr("PDF object table"); + throw PDFBudgetExceededException(std::move(detail)); + } + PDFObjectStorage::PDFObjects objects; objects.resize(xrefTable.getSize()); @@ -708,7 +753,7 @@ PDFDocument PDFDocumentReader::readFromBuffer(const QByteArray& buffer) m_warnings << m_errorMessage; return PDFDocument(); } - catch (const PDFException &parserException) + catch (const PDFException& parserException) { m_result = Result::Failed; m_errorMessage = parserException.getMessage(); @@ -884,7 +929,27 @@ PDFDocument PDFDocumentReader::readDamagedDocumentFromBuffer(const QByteArray& b if (!restoredObjects.empty()) { - objects.resize(restoredObjects.rbegin()->first.objectNumber + 1); + // The object table is dense: it is indexed by object number, so a + // single recovered object numbered 9999999 would allocate ten million + // entries. The per-reference check in restoreObjects() bounds an + // object number by the file size, which on a 50 MiB file still allows + // a table of fifty million entries. Bound the table by how many + // objects were actually recovered instead: real damaged documents are + // numbered densely, and a highest-number-to-recovered-count ratio far + // past that is a malformed document rather than a recoverable one. + const PDFInteger highestObjectNumber = restoredObjects.rbegin()->first.objectNumber; + const PDFInteger maximumObjectNumber = std::max( + DAMAGED_DOCUMENT_MINIMUM_OBJECT_SLOTS, + static_cast(restoredObjects.size()) * DAMAGED_DOCUMENT_MAX_OBJECT_NUMBER_DENSITY); + + if (highestObjectNumber > maximumObjectNumber) + { + throw PDFException(PDFTranslationContext::tr("Damaged document declares object number %1, but only %2 objects could be recovered; refusing to build an object table of that size.") + .arg(highestObjectNumber) + .arg(restoredObjects.size())); + } + + objects.resize(highestObjectNumber + 1); for (auto& objectItem : restoredObjects) { @@ -901,9 +966,16 @@ PDFDocument PDFDocumentReader::readDamagedDocumentFromBuffer(const QByteArray& b } PDFObjectStorage storage(std::move(objects), PDFObject(trailerDictionaryObject), qMove(m_securityHandler)); - return PDFDocument(std::move(storage), m_version, QByteArray()); + + // The recovered document corresponds to exactly these bytes, so it gets a + // real source digest like any other successful read. Returning an empty + // digest here silently disabled the "the file changed underneath us" guard + // in PDFDocumentWriter::writeIncremental for every permissively recovered + // document - the one class of document where appending to the wrong bytes + // is most likely. + return PDFDocument(std::move(storage), m_version, hash(buffer)); } - catch (const PDFException &parserException) + catch (const PDFException& parserException) { m_result = Result::Failed; m_warnings << parserException.getMessage(); diff --git a/LoopLibCore/sources/pdfdocumentsanitizer.cpp b/LoopLibCore/sources/pdfdocumentsanitizer.cpp index ea560e734..5e39fbc65 100644 --- a/LoopLibCore/sources/pdfdocumentsanitizer.cpp +++ b/LoopLibCore/sources/pdfdocumentsanitizer.cpp @@ -173,10 +173,10 @@ QByteArray PDFInvisibleTextSanitizerHelper::sanitizeInvisibleTextInContent(const if (operatorIDPosition != -1 && operatorEIPosition != -1) { PDFLexicalAnalyzer inlineImageLexicalAnalyzer(content.constBegin() + operatorBIPosition, content.constBegin() + operatorIDPosition); - PDFParser inlineImageParser([&inlineImageLexicalAnalyzer] { return inlineImageLexicalAnalyzer.fetch(); }); + PDFParser inlineImageParser([&inlineImageLexicalAnalyzer] + { return inlineImageLexicalAnalyzer.fetch(); }); - constexpr std::pair replacements[] = - { + constexpr std::pair replacements[] = { { "BPC", "BitsPerComponent" }, { "CS", "ColorSpace" }, { "D", "Decode" }, @@ -252,7 +252,9 @@ QByteArray PDFInvisibleTextSanitizerHelper::sanitizeInvisibleTextInContent(const { continue; } - stride = (stride + 7) / 8; + // The +7 above is the only rounding (see the inline-image + // length computation in pdfpagecontentprocessor.cpp). + stride = stride / 8; if (!pdfTryMultiply(stride, height, dataLengthProduct)) { continue; @@ -562,7 +564,6 @@ class PDFRemoveMetadataVisitor : public PDFUpdateObjectVisitor PDFUpdateObjectVisitor(storage), m_counter(counter) { - } virtual void visitDictionary(const PDFDictionary* dictionary) override; @@ -600,7 +601,6 @@ PDFDocumentSanitizer::PDFDocumentSanitizer(SanitizationFlag flags, QObject* pare QObject(parent), m_flags(flags) { - } void PDFDocumentSanitizer::sanitize() @@ -691,7 +691,7 @@ void PDFDocumentSanitizer::performSanitizeMetadata() { std::atomic counter = 0; - PDFObjectStorage::PDFObjects objects = m_storage.getObjects(); + PDFObjectStorage::PDFObjects objects = m_storage.getObjects(); auto processEntry = [this, &counter](PDFObjectStorage::Entry& entry) { PDFRemoveMetadataVisitor visitor(&m_storage, &counter); @@ -919,7 +919,7 @@ void PDFDocumentSanitizer::performSanitizeInvisibleText() } } -void PDFDocumentSanitizer::removeAnnotations(const std::function& filter, +void PDFDocumentSanitizer::removeAnnotations(const std::function& filter, QString message) { PDFDocumentBuilder builder(m_storage, PDFVersion(2, 0)); diff --git a/LoopLibCore/sources/pdfdocumentsession.cpp b/LoopLibCore/sources/pdfdocumentsession.cpp index aafdca460..14ec58cbe 100644 --- a/LoopLibCore/sources/pdfdocumentsession.cpp +++ b/LoopLibCore/sources/pdfdocumentsession.cpp @@ -735,7 +735,7 @@ void PDFDocumentSession::initializeRendering() m_optionalContentActivity = std::make_unique(m_document, OCUsage::Export, nullptr); m_cmsManager = std::make_unique(nullptr); - m_cmsManager->setDocument(m_document); + m_cmsManager->setDocument(m_document, m_processingBudget.get()); m_cms = m_cmsManager->getCurrentCMS(); m_fontCache = std::make_unique(DEFAULT_FONT_CACHE_LIMIT, DEFAULT_REALIZED_FONT_CACHE_LIMIT); diff --git a/LoopLibCore/sources/pdfdocumentwriter.cpp b/LoopLibCore/sources/pdfdocumentwriter.cpp index 78e3d0564..aa7f63e6e 100644 --- a/LoopLibCore/sources/pdfdocumentwriter.cpp +++ b/LoopLibCore/sources/pdfdocumentwriter.cpp @@ -252,7 +252,7 @@ PDFOperationResult PDFDocumentWriter::write(QIODevice* device, const PDFDocument PDFDictionary trailerDictionary = *document->getTrailerDictionary(); PDFDictionary newTrailerDictionary; - for (const char* entry : { "Size", "Root", "Encrypt", "Info", "ID"}) + for (const char* entry : { "Size", "Root", "Encrypt", "Info", "ID" }) { PDFObject object = trailerDictionary.get(entry); if (!object.isNull()) @@ -284,9 +284,18 @@ PDFOperationResult PDFDocumentWriter::write(QIODevice* device, const PDFDocument } PDFOperationResult PDFDocumentWriter::writeIncremental(const QString& fileName, - const PDFDocument* originalDocument, - const PDFDocument* document, - bool safeWrite) + const PDFDocument* originalDocument, + const PDFDocument* document, + bool safeWrite) +{ + return writeIncremental(fileName, originalDocument, document, safeWrite, nullptr); +} + +PDFOperationResult PDFDocumentWriter::writeIncremental(const QString& fileName, + const PDFDocument* originalDocument, + const PDFDocument* document, + bool safeWrite, + IncrementalWriteOutcome* outcome) { if (!originalDocument || !document) { @@ -311,14 +320,23 @@ PDFOperationResult PDFDocumentWriter::writeIncremental(const QString& fileName, return tr("File '%1' can't be opened for incremental save. %2").arg(fileName, targetFile.errorString()); } - const PDFOperationResult result = writeIncremental(&targetFile, originalData, originalDocument, document); - if (result && !targetFile.commit()) + // The nested write reports what it did on success, but a successful + // write is not a successful save: commit() can still fail. Hold the + // outcome locally and publish it only once the rename has landed. + IncrementalWriteOutcome nestedOutcome = IncrementalWriteOutcome::CopiedUnchanged; + const PDFOperationResult result = writeIncremental(&targetFile, originalData, originalDocument, document, &nestedOutcome); + if (!result) + { + targetFile.cancelWriting(); + return result; + } + if (!targetFile.commit()) { return tr("File '%1' can't be committed after incremental save. %2").arg(fileName, targetFile.errorString()); } - if (!result) + if (outcome) { - targetFile.cancelWriting(); + *outcome = nestedOutcome; } return result; } @@ -329,15 +347,29 @@ PDFOperationResult PDFDocumentWriter::writeIncremental(const QString& fileName, return tr("File '%1' can't be opened for incremental save. %2").arg(fileName, targetFile.errorString()); } - const PDFOperationResult result = writeIncremental(&targetFile, originalData, originalDocument, document); + IncrementalWriteOutcome nestedOutcome = IncrementalWriteOutcome::CopiedUnchanged; + const PDFOperationResult result = writeIncremental(&targetFile, originalData, originalDocument, document, &nestedOutcome); targetFile.close(); + if (result && outcome) + { + *outcome = nestedOutcome; + } return result; } PDFOperationResult PDFDocumentWriter::writeIncremental(QIODevice* device, - const QByteArray& originalData, - const PDFDocument* originalDocument, - const PDFDocument* document) + const QByteArray& originalData, + const PDFDocument* originalDocument, + const PDFDocument* document) +{ + return writeIncremental(device, originalData, originalDocument, document, nullptr); +} + +PDFOperationResult PDFDocumentWriter::writeIncremental(QIODevice* device, + const QByteArray& originalData, + const PDFDocument* originalDocument, + const PDFDocument* document, + IncrementalWriteOutcome* outcome) { if (!device || !device->isWritable() || !originalDocument || !document) { @@ -411,9 +443,20 @@ PDFOperationResult PDFDocumentWriter::writeIncremental(QIODevice* device, if (changedObjects.empty()) { - return device->write(originalData) == originalData.size() - ? PDFOperationResult(true) - : PDFOperationResult(tr("Failed to copy the original PDF bytes.")); + // Nothing changed, so there is nothing to append. The bytes are copied + // verbatim - which is the right output - but it is not an append, and a + // caller that asked for one is told so through \p outcome. + if (device->write(originalData) != originalData.size()) + { + return PDFOperationResult(tr("Failed to copy the original PDF bytes.")); + } + + if (outcome) + { + *outcome = IncrementalWriteOutcome::CopiedUnchanged; + } + + return PDFOperationResult(true); } if (device->write(originalData) != originalData.size()) @@ -516,12 +559,17 @@ PDFOperationResult PDFDocumentWriter::writeIncremental(QIODevice* device, writeCRLF(device); device->write("%%EOF"); + if (outcome) + { + *outcome = IncrementalWriteOutcome::Appended; + } + return true; } PDFDocumentWriter::WriteMode PDFDocumentWriter::getRecommendedWriteMode(const PDFDocument* sourceDocument, - bool requiresFullRewrite, - bool saveAsNewOutput) + bool requiresFullRewrite, + bool saveAsNewOutput) { return getRecommendedWriteMode(sourceDocument, requiresFullRewrite @@ -531,8 +579,8 @@ PDFDocumentWriter::WriteMode PDFDocumentWriter::getRecommendedWriteMode(const PD } PDFDocumentWriter::WriteMode PDFDocumentWriter::getRecommendedWriteMode(const PDFDocument* sourceDocument, - const PDFOperationSavePolicy& policy, - bool saveAsNewOutput) + const PDFOperationSavePolicy& policy, + bool saveAsNewOutput) { if (policy.mode != PDFSaveMode::IncrementalAppend || saveAsNewOutput || !sourceDocument) { @@ -626,7 +674,7 @@ qint64 getPreviousXrefOffset(const QByteArray& data) return ok ? result : -1; } -} // namespace +} // namespace class PDFSizeCounterIODevice : public QIODevice { @@ -634,7 +682,6 @@ class PDFSizeCounterIODevice : public QIODevice explicit PDFSizeCounterIODevice(QObject* parent) : QIODevice(parent) { - } virtual bool isSequential() const override; diff --git a/LoopLibCore/sources/pdfdocumentwriter.h b/LoopLibCore/sources/pdfdocumentwriter.h index 911aebeae..fcff360fe 100644 --- a/LoopLibCore/sources/pdfdocumentwriter.h +++ b/LoopLibCore/sources/pdfdocumentwriter.h @@ -50,6 +50,18 @@ class LOOPLIBCORESHARED_EXPORT PDFDocumentWriter Incremental }; + /// What an incremental save actually did. Every refusal to append is already + /// reported as a failed PDFOperationResult naming the reason, but one success + /// path is not an append at all: a document with no changed objects is + /// byte-copied. A caller that asked for an incremental save specifically to + /// preserve `Prev`/signature coverage needs to be able to tell those apart, + /// so writeIncremental reports the outcome on request. + enum class IncrementalWriteOutcome + { + Appended, ///< Changed objects plus a new xref section were appended + CopiedUnchanged ///< Nothing changed; the original bytes were copied verbatim + }; + explicit inline PDFDocumentWriter(PDFProgress* progress, const PDFOperationControl* operationControl = nullptr) : m_operationControl(operationControl) @@ -83,18 +95,38 @@ class LOOPLIBCORESHARED_EXPORT PDFDocumentWriter /// Appends an incremental update to an existing PDF. The original bytes /// are copied unchanged and only changed objects plus a new xref/trailer /// section are appended. + /// + /// This is the original four-argument entry point. It is kept as a real + /// exported overload (not a defaulted parameter on the reporting one) so + /// existing binaries keep resolving the same mangled symbol, and so a + /// four-argument call is not ambiguous. PDFOperationResult writeIncremental(const QString& fileName, - const PDFDocument* originalDocument, - const PDFDocument* document, - bool safeWrite); + const PDFDocument* originalDocument, + const PDFDocument* document, + bool safeWrite); + + /// As above, and reports through \p outcome what the save actually did. + /// \p outcome is set only on success. + PDFOperationResult writeIncremental(const QString& fileName, + const PDFDocument* originalDocument, + const PDFDocument* document, + bool safeWrite, + IncrementalWriteOutcome* outcome); + + /// Writes an incremental update using the supplied original bytes. Kept for + /// the same binary-compatibility reason as the four-argument file overload. + PDFOperationResult writeIncremental(QIODevice* device, + const QByteArray& originalData, + const PDFDocument* originalDocument, + const PDFDocument* document); - /// Writes an incremental update using the supplied original bytes. This - /// overload is useful for callers that already hold the source buffer and - /// for byte-preservation tests. + /// As above, and reports through \p outcome what the save actually did. + /// \p outcome is set only on success. PDFOperationResult writeIncremental(QIODevice* device, - const QByteArray& originalData, - const PDFDocument* originalDocument, - const PDFDocument* document); + const QByteArray& originalData, + const PDFDocument* originalDocument, + const PDFDocument* document, + IncrementalWriteOutcome* outcome); /// Chooses the default save mode for an existing document. Save As and /// destructive operations must pass the corresponding opt-out flags. @@ -139,4 +171,4 @@ class LOOPLIBCORESHARED_EXPORT PDFDocumentWriter } // namespace pdf -#endif // PDFDOCUMENTWRITER_H +#endif // PDFDOCUMENTWRITER_H diff --git a/LoopLibCore/sources/pdffilenamesanitizer.cpp b/LoopLibCore/sources/pdffilenamesanitizer.cpp index fe93a8324..d3578c47e 100644 --- a/LoopLibCore/sources/pdffilenamesanitizer.cpp +++ b/LoopLibCore/sources/pdffilenamesanitizer.cpp @@ -89,17 +89,32 @@ QString PDFFilenameSanitizer::sanitize(const QString& rawFilename, const QString bool PDFFilenameSanitizer::isPathContained(const QString& resolvedPath, const QString& targetDirectory) { - const QString canonicalTarget = QDir(targetDirectory).canonicalPath(); + // A target that does not exist yet cannot be canonicalized, but callers + // legitimately validate a planned output before creating its directory. Fall + // back to the cleaned absolute path in that case: traversal is still caught, + // because cleanPath() resolves "..", and there is no symlink to resolve in a + // directory that does not exist. Note that the *file* side keeps its + // stricter rule below - a name whose parent is a symlink is not treated as + // contained even when it resolves inside the target. + QString canonicalTarget = QDir(targetDirectory).canonicalPath(); if (canonicalTarget.isEmpty()) { - // Target directory does not exist — cannot verify containment - return false; + if (targetDirectory.isEmpty()) + { + return false; + } + + canonicalTarget = QDir::cleanPath(QDir(targetDirectory).absolutePath()); + if (canonicalTarget.isEmpty()) + { + return false; + } } const QString canonicalFilePath = QFileInfo(resolvedPath).canonicalFilePath(); const QString canonicalFile = canonicalFilePath.isEmpty() - ? QDir::cleanPath(QFileInfo(resolvedPath).absoluteFilePath()) - : canonicalFilePath; + ? QDir::cleanPath(QFileInfo(resolvedPath).absoluteFilePath()) + : canonicalFilePath; // The file path must start with the target directory path followed by a separator if (canonicalFile == canonicalTarget) @@ -107,8 +122,7 @@ bool PDFFilenameSanitizer::isPathContained(const QString& resolvedPath, const QS return false; } - return canonicalFile.startsWith(canonicalTarget + QLatin1Char('/')) - || canonicalFile.startsWith(canonicalTarget + QLatin1Char('\\')); + return canonicalFile.startsWith(canonicalTarget + QLatin1Char('/')) || canonicalFile.startsWith(canonicalTarget + QLatin1Char('\\')); } } // namespace pdf diff --git a/LoopLibCore/sources/pdffont.cpp b/LoopLibCore/sources/pdffont.cpp index dcd967a18..e1aff6460 100644 --- a/LoopLibCore/sources/pdffont.cpp +++ b/LoopLibCore/sources/pdffont.cpp @@ -77,8 +77,7 @@ struct PDF_Default_CJK_Font const char* name = nullptr; }; -static constexpr std::array S_DEFAULT_CJK_FONTS = -{ +static constexpr std::array S_DEFAULT_CJK_FONTS = { PDF_Default_CJK_Font{ ECjkDefaultFontType::AdobeGB, true, "KaiTi_GB2312" }, PDF_Default_CJK_Font{ ECjkDefaultFontType::AdobeGB, true, "Song" }, PDF_Default_CJK_Font{ ECjkDefaultFontType::AdobeGB, false, "Heiti" }, @@ -111,15 +110,14 @@ struct PDF_Font_Replacement const char* replaceFont; }; -static constexpr std::array S_FONT_REPLACEMENTS -{ - PDF_Font_Replacement{"Futura", "Calibri"}, - PDF_Font_Replacement{"Utopia-Bold", "Georgia"}, - PDF_Font_Replacement{"Utopia-BoldItalic", "Georgia"}, - PDF_Font_Replacement{"Utopia-Italic", "Georgia"}, - PDF_Font_Replacement{"Utopia-Semibold", "Georgia"}, - PDF_Font_Replacement{"Utopia-SemiboldItalic", "Georgia"}, - PDF_Font_Replacement{"Utopia", "Georgia"} +static constexpr std::array S_FONT_REPLACEMENTS{ + PDF_Font_Replacement{ "Futura", "Calibri" }, + PDF_Font_Replacement{ "Utopia-Bold", "Georgia" }, + PDF_Font_Replacement{ "Utopia-BoldItalic", "Georgia" }, + PDF_Font_Replacement{ "Utopia-Italic", "Georgia" }, + PDF_Font_Replacement{ "Utopia-Semibold", "Georgia" }, + PDF_Font_Replacement{ "Utopia-SemiboldItalic", "Georgia" }, + PDF_Font_Replacement{ "Utopia", "Georgia" } }; static bool isMicrosoftSymbolCharmap(FT_CharMap charMap) @@ -137,7 +135,7 @@ static bool isUnicodeCharmap(FT_CharMap charMap) return charMap && charMap->encoding == FT_ENCODING_UNICODE; } -template +template static bool hasCharmap(FT_Face face, Predicate predicate) { for (FT_Int i = 0; i < face->num_charmaps; ++i) @@ -151,7 +149,7 @@ static bool hasCharmap(FT_Face face, Predicate predicate) return false; } -template +template static FT_CharMap selectCharmap(FT_Face face, Predicate predicate) { for (FT_Int i = 0; i < face->num_charmaps; ++i) @@ -184,7 +182,8 @@ static FT_CharMap selectSymbolicTrueTypeCMap(FT_Face face) return charMap; } - return selectCharmap(face, [](FT_CharMap charMap) { return !isUnicodeCharmap(charMap); }); + return selectCharmap(face, [](FT_CharMap charMap) + { return !isUnicodeCharmap(charMap); }); } static FT_UInt getSymbolicTrueTypeGlyphIndex(FT_Face face, FT_CharMap charMap, FT_ULong characterCode) @@ -213,7 +212,7 @@ struct SystemFontData }; #if defined(Q_OS_WIN) -template +template static void releaseComObject(T*& object) { if (object) @@ -268,7 +267,6 @@ static bool matchesDirectWriteFontName(const QString& fontName, const QString& c class PDFSystemFontInfoStorage { public: - /// Returns instance of storage static const PDFSystemFontInfoStorage* getInstance(); @@ -649,33 +647,37 @@ SystemFontData PDFSystemFontInfoStorage::loadFontImpl(const FontDescriptor* desc } constexpr const std::array, 9> weights{ - std::pair{100, FC_WEIGHT_EXTRALIGHT}, - std::pair{200, FC_WEIGHT_LIGHT}, - std::pair{300, FC_WEIGHT_BOOK}, - std::pair{400, FC_WEIGHT_NORMAL}, - std::pair{500, FC_WEIGHT_MEDIUM}, - std::pair{600, FC_WEIGHT_DEMIBOLD}, - std::pair{700, FC_WEIGHT_BOLD}, - std::pair{800, FC_WEIGHT_EXTRABOLD}, - std::pair{900, FC_WEIGHT_EXTRABOLD}}; - auto wit = std::lower_bound(weights.cbegin(), weights.cend(), descriptor->fontWeight, [](const std::pair& data, PDFReal key) { return data.first < key; }); + std::pair{ 100, FC_WEIGHT_EXTRALIGHT }, + std::pair{ 200, FC_WEIGHT_LIGHT }, + std::pair{ 300, FC_WEIGHT_BOOK }, + std::pair{ 400, FC_WEIGHT_NORMAL }, + std::pair{ 500, FC_WEIGHT_MEDIUM }, + std::pair{ 600, FC_WEIGHT_DEMIBOLD }, + std::pair{ 700, FC_WEIGHT_BOLD }, + std::pair{ 800, FC_WEIGHT_EXTRABOLD }, + std::pair{ 900, FC_WEIGHT_EXTRABOLD } + }; + auto wit = std::lower_bound(weights.cbegin(), weights.cend(), descriptor->fontWeight, [](const std::pair& data, PDFReal key) + { return data.first < key; }); if (wit != weights.cend()) { checkFontConfigError(FcPatternAddInteger(p, FC_WEIGHT, wit->second)); } constexpr const std::array, 9> stretches{ - std::pair{QFont::UltraCondensed, FC_WIDTH_ULTRACONDENSED}, - std::pair{QFont::ExtraCondensed, FC_WIDTH_EXTRACONDENSED}, - std::pair{QFont::Condensed, FC_WIDTH_CONDENSED}, - std::pair{QFont::SemiCondensed, FC_WIDTH_SEMICONDENSED}, - std::pair{QFont::Unstretched, FC_WIDTH_NORMAL}, - std::pair{QFont::SemiExpanded, FC_WIDTH_SEMIEXPANDED}, - std::pair{QFont::Expanded, FC_WIDTH_EXPANDED}, - std::pair{QFont::ExtraExpanded, FC_WIDTH_EXTRAEXPANDED}, - std::pair{QFont::UltraExpanded, FC_WIDTH_ULTRAEXPANDED}}; - - auto sit = std::find_if(stretches.cbegin(), stretches.cend(), [&](const std::pair& item) { return item.first == descriptor->fontStretch; }); + std::pair{ QFont::UltraCondensed, FC_WIDTH_ULTRACONDENSED }, + std::pair{ QFont::ExtraCondensed, FC_WIDTH_EXTRACONDENSED }, + std::pair{ QFont::Condensed, FC_WIDTH_CONDENSED }, + std::pair{ QFont::SemiCondensed, FC_WIDTH_SEMICONDENSED }, + std::pair{ QFont::Unstretched, FC_WIDTH_NORMAL }, + std::pair{ QFont::SemiExpanded, FC_WIDTH_SEMIEXPANDED }, + std::pair{ QFont::Expanded, FC_WIDTH_EXPANDED }, + std::pair{ QFont::ExtraExpanded, FC_WIDTH_EXTRAEXPANDED }, + std::pair{ QFont::UltraExpanded, FC_WIDTH_ULTRAEXPANDED } + }; + + auto sit = std::find_if(stretches.cbegin(), stretches.cend(), [&](const std::pair& item) + { return item.first == descriptor->fontStretch; }); if (sit != stretches.cend()) { checkFontConfigError(FcPatternAddInteger(p, FC_WIDTH, sit->second)); @@ -691,7 +693,7 @@ SystemFontData PDFSystemFontInfoStorage::loadFontImpl(const FontDescriptor* desc if (FcPatternGetString(match, FC_FILE, 0, &s) == FcResultMatch) { QFile f(QString::fromUtf8(reinterpret_cast(s))); - if ( f.open(QIODevice::ReadOnly) ) + if (f.open(QIODevice::ReadOnly)) { result.data = f.readAll(); f.close(); @@ -1023,7 +1025,6 @@ PDFFont::PDFFont(CIDSystemInfo CIDSystemInfo, QByteArray fontId, FontDescriptor m_fontDescriptor(qMove(fontDescriptor)), m_fontId(qMove(fontId)) { - } class IRealizedFontImpl @@ -1055,7 +1056,11 @@ class IRealizedFontImpl class PDFRealizedType3FontImpl : public IRealizedFontImpl { public: - explicit PDFRealizedType3FontImpl(PDFFontPointer parentFont, PDFReal pixelSize) : m_pixelSize(pixelSize), m_parentFont(parentFont) { } + explicit PDFRealizedType3FontImpl(PDFFontPointer parentFont, PDFReal pixelSize) : + m_pixelSize(pixelSize), + m_parentFont(parentFont) + { + } virtual ~PDFRealizedType3FontImpl() override = default; PDFReal getPixelSize() const { return m_pixelSize; } @@ -1161,7 +1166,6 @@ PDFRealizedFontImpl::PDFRealizedFontImpl() : m_isEmbedded(false), m_isVertical(false) { - } PDFRealizedFontImpl::~PDFRealizedFontImpl() @@ -1423,13 +1427,13 @@ void PDFRealizedFontImpl::dumpFontToTreeItem(ITreeFactory* treeFactory) const QString yesString = PDFTranslationContext::tr("Yes"); QString noString = PDFTranslationContext::tr("No"); - treeFactory->addItem( { PDFTranslationContext::tr("Glyph count"), QString::number(m_face->num_glyphs) }); - treeFactory->addItem( { PDFTranslationContext::tr("Is CID keyed"), (m_face->face_flags & FT_FACE_FLAG_CID_KEYED) ? yesString : noString }); - treeFactory->addItem( { PDFTranslationContext::tr("Is bold"), (m_face->style_flags & FT_STYLE_FLAG_BOLD) ? yesString : noString }); - treeFactory->addItem( { PDFTranslationContext::tr("Is italics"), (m_face->style_flags & FT_STYLE_FLAG_ITALIC) ? yesString : noString }); - treeFactory->addItem( { PDFTranslationContext::tr("Has vertical writing system"), (m_face->face_flags & FT_FACE_FLAG_VERTICAL) ? yesString : noString }); - treeFactory->addItem( { PDFTranslationContext::tr("Has SFNT storage scheme"), (m_face->face_flags & FT_FACE_FLAG_SFNT) ? yesString : noString }); - treeFactory->addItem( { PDFTranslationContext::tr("Has glyph names"), (m_face->face_flags & FT_FACE_FLAG_GLYPH_NAMES) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Glyph count"), QString::number(m_face->num_glyphs) }); + treeFactory->addItem({ PDFTranslationContext::tr("Is CID keyed"), (m_face->face_flags & FT_FACE_FLAG_CID_KEYED) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Is bold"), (m_face->style_flags & FT_STYLE_FLAG_BOLD) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Is italics"), (m_face->style_flags & FT_STYLE_FLAG_ITALIC) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Has vertical writing system"), (m_face->face_flags & FT_FACE_FLAG_VERTICAL) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Has SFNT storage scheme"), (m_face->face_flags & FT_FACE_FLAG_SFNT) ? yesString : noString }); + treeFactory->addItem({ PDFTranslationContext::tr("Has glyph names"), (m_face->face_flags & FT_FACE_FLAG_GLYPH_NAMES) ? yesString : noString }); if (m_face->num_charmaps > 0) { @@ -1591,7 +1595,7 @@ bool PDFRealizedFontImpl::canRenderGlyphIndex(GID glyphIndex, QChar character) c if (m_face && FT_Has_PS_Glyph_Names(m_face)) { - char glyphName[128] = { }; + char glyphName[128] = {}; if (!FT_Get_Glyph_Name(m_face, glyphIndex, glyphName, static_cast(std::size(glyphName)))) { return qstrcmp(glyphName, ".notdef") != 0; @@ -1679,7 +1683,7 @@ PDFRealizedFontPointer PDFRealizedFont::createRealizedFont(PDFFontPointer font, Q_ASSERT(!impl->m_embeddedFontData.isEmpty()); PDFRealizedFontImpl::checkFreeTypeError(FT_New_Memory_Face(impl->m_library, reinterpret_cast(impl->m_embeddedFontData.constData()), impl->m_embeddedFontData.size(), 0, &impl->m_face)); - FT_Select_Charmap(impl->m_face, FT_ENCODING_UNICODE); // We try to select unicode encoding, but if it fails, we don't do anything (use glyph indices instead) + FT_Select_Charmap(impl->m_face, FT_ENCODING_UNICODE); // We try to select unicode encoding, but if it fails, we don't do anything (use glyph indices instead) PDFRealizedFontImpl::checkFreeTypeError(FT_Set_Pixel_Sizes(impl->m_face, 0, qRound(pixelSize * PDFRealizedFontImpl::PIXEL_SIZE_MULTIPLIER))); impl->m_isVertical = cmap ? cmap->isVertical() : false; impl->m_isEmbedded = true; @@ -1709,7 +1713,7 @@ PDFRealizedFontPointer PDFRealizedFont::createRealizedFont(PDFFontPointer font, throw PDFException(PDFTranslationContext::tr("System font '%1' is too large to be loaded by FreeType.").arg(QString::fromLatin1(descriptor->fontName))); } PDFRealizedFontImpl::checkFreeTypeError(FT_New_Memory_Face(impl->m_library, reinterpret_cast(impl->m_systemFontData.constData()), static_cast(impl->m_systemFontData.size()), impl->m_systemFontFaceIndex, &impl->m_face)); - FT_Select_Charmap(impl->m_face, FT_ENCODING_UNICODE); // We try to select unicode encoding, but if it fails, we don't do anything (use glyph indices instead) + FT_Select_Charmap(impl->m_face, FT_ENCODING_UNICODE); // We try to select unicode encoding, but if it fails, we don't do anything (use glyph indices instead) PDFRealizedFontImpl::checkFreeTypeError(FT_Set_Pixel_Sizes(impl->m_face, 0, qRound(pixelSize * PDFRealizedFontImpl::PIXEL_SIZE_MULTIPLIER))); impl->m_isVertical = cmap ? cmap->isVertical() : false; impl->m_isEmbedded = false; @@ -1851,7 +1855,7 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c std::pair{ "Type0", FontType::Type0 }, std::pair{ "Type1", FontType::Type1 }, std::pair{ "TrueType", FontType::TrueType }, - std::pair{ "Type3", FontType::Type3}, + std::pair{ "Type3", FontType::Type3 }, std::pair{ "MMType1", FontType::MMType1 } }; @@ -1921,11 +1925,11 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c // After the encoding is obtained, try to extract glyph indices for embedded font. PDFEncoding::Encoding encoding = PDFEncoding::Encoding::Invalid; - encoding::EncodingTable simpleFontEncodingTable = { }; - encoding::EncodingTable simpleFontToUnicodeTable = { }; + encoding::EncodingTable simpleFontEncodingTable = {}; + encoding::EncodingTable simpleFontToUnicodeTable = {}; bool hasToUnicode = false; - GlyphIndices glyphIndexArray = { }; - GlyphNames glyphNameArray = { }; + GlyphIndices glyphIndexArray = {}; + GlyphNames glyphNameArray = {}; switch (fontType) { case FontType::Type1: @@ -1933,8 +1937,8 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c case FontType::TrueType: { bool hasDifferences = false; - encoding::EncodingTable differences = { }; - GlyphNames differenceGlyphNames = { }; + encoding::EncodingTable differences = {}; + GlyphNames differenceGlyphNames = {}; bool useEmbeddedBuiltInEncoding = false; if (fontDictionary->hasKey("Encoding")) @@ -2108,8 +2112,8 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c for (size_t i = 0; i < standardEncoding.size(); ++i) { if (differenceGlyphNames[i].isEmpty() && - (simpleFontEncodingTable[i].isNull() || simpleFontEncodingTable[i] == QChar(QChar::SpecialCharacter::ReplacementCharacter)) && - (!standardEncoding[i].isNull() && standardEncoding[i] != QChar(QChar::SpecialCharacter::ReplacementCharacter))) + (simpleFontEncodingTable[i].isNull() || simpleFontEncodingTable[i] == QChar(QChar::SpecialCharacter::ReplacementCharacter)) && + (!standardEncoding[i].isNull() && standardEncoding[i] != QChar(QChar::SpecialCharacter::ReplacementCharacter))) { simpleFontEncodingTable[i] = standardEncoding[i]; } @@ -2163,7 +2167,7 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c glyphIndexArray[iTable] = glyphIndex; // Set mapping to unicode - char buffer[128] = { }; + char buffer[128] = {}; if (!FT_Get_Glyph_Name(face, glyphIndex, buffer, static_cast(std::size(buffer)))) { QByteArray byteArrayBuffer(buffer); @@ -2362,7 +2366,7 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c // Read default advance PDFReal dw = fontLoader.readNumberFromDictionary(descendantFontDictionary, "DW", 1000.0); - std::array dw2 = { }; + std::array dw2 = {}; fontLoader.readNumberArrayFromDictionary(descendantFontDictionary, "DW2", dw2.begin(), dw2.end()); PDFReal defaultWidth = descendantFontDictionary->hasKey("DW") ? dw : dw2.back(); @@ -2370,51 +2374,51 @@ PDFFontPointer PDFFont::createFont(const PDFObject& object, QByteArray fontId, c std::unordered_map advances; if (descendantFontDictionary->hasKey("W")) { - const PDFObject& wArrayObject = document->getObject(descendantFontDictionary->get("W")); - if (wArrayObject.isArray()) - { - const PDFArray* wArray = wArrayObject.getArray(); - const size_t size = wArray->getCount(); - - static constexpr CID MAX_W_ARRAY_RANGE = 1'000'000; - for (size_t i = 0; i < size;) - { - CID startCID = fontLoader.readInteger(wArray->getItem(i++), 0); - if (i >= size) - { - break; - } - const PDFObject& arrayOrCID = document->getObject(wArray->getItem(i++)); + const PDFObject& wArrayObject = document->getObject(descendantFontDictionary->get("W")); + if (wArrayObject.isArray()) + { + const PDFArray* wArray = wArrayObject.getArray(); + const size_t size = wArray->getCount(); - if (arrayOrCID.isInt()) - { - if (i >= size) - { - break; - } - CID endCID = arrayOrCID.getInteger(); - if (endCID < startCID || static_cast(endCID - startCID) > MAX_W_ARRAY_RANGE) - { - continue; - } - PDFReal width = fontLoader.readInteger(wArray->getItem(i++), 0); - for (CID currentCID = startCID; currentCID <= endCID; ++currentCID) - { - advances[currentCID] = width; - } - } - else if (arrayOrCID.isArray()) - { - const PDFArray* widthArray = arrayOrCID.getArray(); - const size_t widthArraySize = widthArray->getCount(); - for (size_t widthArrayIndex = 0; widthArrayIndex < widthArraySize; ++widthArrayIndex) - { - PDFReal width = fontLoader.readNumber(widthArray->getItem(widthArrayIndex), 0); - advances[startCID + static_cast(widthArrayIndex)] = width; - } - } - } - } + static constexpr CID MAX_W_ARRAY_RANGE = 1'000'000; + for (size_t i = 0; i < size;) + { + CID startCID = fontLoader.readInteger(wArray->getItem(i++), 0); + if (i >= size) + { + break; + } + const PDFObject& arrayOrCID = document->getObject(wArray->getItem(i++)); + + if (arrayOrCID.isInt()) + { + if (i >= size) + { + break; + } + CID endCID = arrayOrCID.getInteger(); + if (endCID < startCID || static_cast(endCID - startCID) > MAX_W_ARRAY_RANGE) + { + continue; + } + PDFReal width = fontLoader.readInteger(wArray->getItem(i++), 0); + for (CID currentCID = startCID; currentCID <= endCID; ++currentCID) + { + advances[currentCID] = width; + } + } + else if (arrayOrCID.isArray()) + { + const PDFArray* widthArray = arrayOrCID.getArray(); + const size_t widthArraySize = widthArray->getCount(); + for (size_t widthArrayIndex = 0; widthArrayIndex < widthArraySize; ++widthArrayIndex) + { + PDFReal width = fontLoader.readNumber(widthArray->getItem(widthArrayIndex), 0); + advances[startCID + static_cast(widthArrayIndex)] = width; + } + } + } + } } PDFFontCMap toUnicodeCMap; @@ -2583,7 +2587,6 @@ PDFSimpleFont::PDFSimpleFont(CIDSystemInfo cidSystemInfo, m_glyphNames(qMove(glyphNames)), m_standardFontType(standardFontType) { - } QChar PDFSimpleFont::getUnicode(CID cid) const @@ -2658,39 +2661,39 @@ void PDFSimpleFont::dumpFontToTreeItem(ITreeFactory* treeFactory) const QString encodingTypeString; switch (m_encodingType) { - case PDFEncoding::Encoding::Standard: + case PDFEncoding::Encoding::Standard: encodingTypeString = PDFTranslationContext::tr("Standard"); break; - case PDFEncoding::Encoding::MacRoman: + case PDFEncoding::Encoding::MacRoman: encodingTypeString = PDFTranslationContext::tr("Mac Roman"); break; - case PDFEncoding::Encoding::WinAnsi: + case PDFEncoding::Encoding::WinAnsi: encodingTypeString = PDFTranslationContext::tr("Win Ansi"); break; - case PDFEncoding::Encoding::PDFDoc: + case PDFEncoding::Encoding::PDFDoc: encodingTypeString = PDFTranslationContext::tr("PDF Doc"); break; - case PDFEncoding::Encoding::MacExpert: + case PDFEncoding::Encoding::MacExpert: encodingTypeString = PDFTranslationContext::tr("Mac Expert"); break; - case PDFEncoding::Encoding::Symbol: + case PDFEncoding::Encoding::Symbol: encodingTypeString = PDFTranslationContext::tr("Symbol"); break; - case PDFEncoding::Encoding::ZapfDingbats: + case PDFEncoding::Encoding::ZapfDingbats: encodingTypeString = PDFTranslationContext::tr("Zapf Dingbats"); break; - case PDFEncoding::Encoding::MacOsRoman: + case PDFEncoding::Encoding::MacOsRoman: encodingTypeString = PDFTranslationContext::tr("Mac OS Roman"); break; - case PDFEncoding::Encoding::Custom: + case PDFEncoding::Encoding::Custom: encodingTypeString = PDFTranslationContext::tr("Custom"); break; @@ -2723,7 +2726,6 @@ PDFType1Font::PDFType1Font(FontType fontType, PDFSimpleFont(qMove(cidSystemInfo), qMove(fontId), qMove(fontDescriptor), qMove(name), qMove(baseFont), firstChar, lastChar, qMove(widths), encodingType, encoding, toUnicode, hasToUnicode, standardFontType, glyphIndices, qMove(glyphNames)), m_fontType(fontType) { - } FontType PDFType1Font::getFontType() const @@ -2863,15 +2865,15 @@ const PDFFontCache::TextDrawingFontInfo* PDFFontCache::getFontForTextDrawing(con substituteFont.setWeight(QFont::Weight(qBound(1, int(descriptor->fontWeight), 1000))); substituteFont.setStretch(descriptor->fontStretch); substituteFont.setItalic(descriptor->isItalic()); - substituteFont.setStyleHint(descriptor->isFixedPitch() ? QFont::Monospace : - descriptor->isSerif() ? QFont::Serif : QFont::SansSerif); + substituteFont.setStyleHint(descriptor->isFixedPitch() ? QFont::Monospace : descriptor->isSerif() ? QFont::Serif + : QFont::SansSerif); info.font = substituteFont; info.isUsable = true; if (reporter) { reporter->reportRenderErrorOnce(RenderErrorType::Warning, - PDFTranslationContext::tr("Font '%1' is not embedded, using substitute font for real text drawing.").arg(QString::fromLatin1(descriptor->fontName))); + PDFTranslationContext::tr("Font '%1' is not embedded, using substitute font for real text drawing.").arg(QString::fromLatin1(descriptor->fontName))); } } } @@ -3018,7 +3020,7 @@ PDFFontCMap PDFFontCMap::createFromName(const QByteArray& name) PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) { Entries entries; - entries.reserve(1024); // Arbitrary number, we have enough memory, better than perform reallocation each time + entries.reserve(1024); // Arbitrary number, we have enough memory, better than perform reallocation each time std::vector additionalMappings; PDFLexicalAnalyzer parser(data.constBegin(), data.constEnd()); @@ -3036,7 +3038,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) continue; } - auto fetchCode = [] (const PDFLexicalAnalyzer::Token& currentToken) -> std::pair + auto fetchCode = [](const PDFLexicalAnalyzer::Token& currentToken) -> std::pair { if (currentToken.type == PDFLexicalAnalyzer::TokenType::String) { @@ -3054,7 +3056,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) throw PDFException(PDFTranslationContext::tr("Can't fetch code from CMap definition.")); }; - auto fetchCID = [] (const PDFLexicalAnalyzer::Token& currentToken) -> CID + auto fetchCID = [](const PDFLexicalAnalyzer::Token& currentToken) -> CID { if (currentToken.type == PDFLexicalAnalyzer::TokenType::Integer) { @@ -3084,6 +3086,21 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) return 0; }; + // A truncated CMap is malformed input, not an empty one. PDFLexicalAnalyzer + // returns EndOfFile for every fetch past the end of the buffer, so a range + // operator whose terminator is missing would spin here forever and grow + // `entries` until the process is killed (hostile /ToUnicode stream). Every + // fetch inside a range operator therefore fails closed on EndOfFile. + auto fetchRequired = [&parser](const char* operatorName) + { + PDFLexicalAnalyzer::Token fetchedToken = parser.fetch(); + if (fetchedToken.type == PDFLexicalAnalyzer::TokenType::EndOfFile) + { + throw PDFException(PDFTranslationContext::tr("CMap operator '%1' is not terminated.").arg(QString::fromLatin1(operatorName))); + } + return fetchedToken; + }; + if (token.type == PDFLexicalAnalyzer::TokenType::Command) { QByteArray command = token.data.toByteArray(); @@ -3102,16 +3119,16 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) { while (true) { - PDFLexicalAnalyzer::Token token1 = parser.fetch(); + PDFLexicalAnalyzer::Token token1 = fetchRequired("beginbfrange"); if (token1.type == PDFLexicalAnalyzer::TokenType::Command && - token1.data.toByteArray() == "endbfrange") + token1.data.toByteArray() == "endbfrange") { break; } - PDFLexicalAnalyzer::Token token2 = parser.fetch(); - PDFLexicalAnalyzer::Token token3 = parser.fetch(); + PDFLexicalAnalyzer::Token token2 = fetchRequired("beginbfrange"); + PDFLexicalAnalyzer::Token token3 = fetchRequired("beginbfrange"); std::pair from = fetchCode(token1); std::pair to = fetchCode(token2); @@ -3122,7 +3139,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) while (true) { - PDFLexicalAnalyzer::Token arrayToken = parser.fetch(); + PDFLexicalAnalyzer::Token arrayToken = fetchRequired("beginbfrange"); // Do we have end of array? if (arrayToken.type == PDFLexicalAnalyzer::TokenType::ArrayEnd) @@ -3146,7 +3163,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) { while (true) { - PDFLexicalAnalyzer::Token token1 = parser.fetch(); + PDFLexicalAnalyzer::Token token1 = fetchRequired("begincidrange"); if (token1.type == PDFLexicalAnalyzer::TokenType::Command && token1.data.toByteArray() == "endcidrange") @@ -3154,8 +3171,8 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) break; } - PDFLexicalAnalyzer::Token token2 = parser.fetch(); - PDFLexicalAnalyzer::Token token3 = parser.fetch(); + PDFLexicalAnalyzer::Token token2 = fetchRequired("begincidrange"); + PDFLexicalAnalyzer::Token token3 = fetchRequired("begincidrange"); std::pair from = fetchCode(token1); std::pair to = fetchCode(token2); @@ -3168,7 +3185,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) { while (true) { - PDFLexicalAnalyzer::Token token1 = parser.fetch(); + PDFLexicalAnalyzer::Token token1 = fetchRequired("begincidchar"); if (token1.type == PDFLexicalAnalyzer::TokenType::Command && token1.data.toByteArray() == "endcidchar") @@ -3176,7 +3193,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) break; } - PDFLexicalAnalyzer::Token token2 = parser.fetch(); + PDFLexicalAnalyzer::Token token2 = fetchRequired("begincidchar"); std::pair code = fetchCode(token1); CID cid = fetchCID(token2); @@ -3188,7 +3205,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) { while (true) { - PDFLexicalAnalyzer::Token token1 = parser.fetch(); + PDFLexicalAnalyzer::Token token1 = fetchRequired("beginbfchar"); if (token1.type == PDFLexicalAnalyzer::TokenType::Command && token1.data.toByteArray() == "endbfchar") @@ -3196,7 +3213,7 @@ PDFFontCMap PDFFontCMap::createFromData(const QByteArray& data) break; } - PDFLexicalAnalyzer::Token token2 = parser.fetch(); + PDFLexicalAnalyzer::Token token2 = fetchRequired("beginbfchar"); std::pair code = fetchCode(token1); CID cid = fetchUnicode(token2); @@ -3343,7 +3360,8 @@ std::vector PDFFontCMap::interpretWithCode(const QByteA ++scannedBytes; // Find suitable mapping - auto it = std::find_if(m_entries.cbegin(), m_entries.cend(), [value, scannedBytes](const Entry& entry) { return entry.from <= value && entry.to >= value && entry.byteCount == scannedBytes; }); + auto it = std::find_if(m_entries.cbegin(), m_entries.cend(), [value, scannedBytes](const Entry& entry) + { return entry.from <= value && entry.to >= value && entry.byteCount == scannedBytes; }); if (it != m_entries.cend()) { const Entry& entry = *it; @@ -3404,10 +3422,10 @@ QChar PDFFontCMap::getToUnicode(CID cid, unsigned int byteCount) const { if (isValid()) { - auto it = std::find_if(m_entries.cbegin(), m_entries.cend(), [cid, byteCount](const Entry& entry) { + auto it = std::find_if(m_entries.cbegin(), m_entries.cend(), [cid, byteCount](const Entry& entry) + { const bool byteCountMatches = byteCount == 0 || entry.byteCount == byteCount; - return byteCountMatches && entry.from <= cid && entry.to >= cid; - }); + return byteCountMatches && entry.from <= cid && entry.to >= cid; }); if (it != m_entries.cend()) { const Entry& entry = *it; @@ -3469,7 +3487,8 @@ void PDFFontCMap::enumerate(const std::function= code && entry.byteCount == byteCount; }); + return std::any_of(m_entries.cbegin(), m_entries.cend(), [code, byteCount](const Entry& entry) + { return entry.from <= code && entry.to >= code && entry.byteCount == byteCount; }); } PDFFontCMap::PDFFontCMap(Entries&& entries, bool vertical, bool unicodeEncoded) : @@ -3478,7 +3497,8 @@ PDFFontCMap::PDFFontCMap(Entries&& entries, bool vertical, bool unicodeEncoded) m_vertical(vertical), m_unicodeEncoded(unicodeEncoded) { - m_maxKeyLength = std::accumulate(m_entries.cbegin(), m_entries.cend(), 0, [](unsigned int a, const Entry& b) { return qMax(a, b.byteCount); }); + m_maxKeyLength = std::accumulate(m_entries.cbegin(), m_entries.cend(), 0, [](unsigned int a, const Entry& b) + { return qMax(a, b.byteCount); }); } PDFFontCMap::Entries PDFFontCMap::optimize(const PDFFontCMap::Entries& entries) @@ -3565,7 +3585,6 @@ bool PDFFontCMapRepository::loadFromFile(const QString& fileName) PDFFontCMapRepository::PDFFontCMapRepository() { - } PDFReal PDFType0Font::getGlyphAdvance(CID cid) const @@ -3611,7 +3630,7 @@ void PDFType0Font::buildEncodeMap() const // the ToUnicode character is still produced by the forward pass. const unsigned int maxKeyLength = m_cmap.getMaxKeyLength(); m_toUnicode.enumerate([&, this](unsigned int code, unsigned int byteCount, CID unicodeValue) - { + { if (unicodeValue == 0) { return; @@ -3635,8 +3654,7 @@ void PDFType0Font::buildEncodeMap() const return; } - m_encodeMap.emplace(codePoint, serializeCode(code, byteCount)); - }); + m_encodeMap.emplace(codePoint, serializeCode(code, byteCount)); }); // Pass 2: for unicode encoded predefined CMaps of non-embedded fonts, the forward // pass falls back to interpreting the character code directly as unicode, but only @@ -3644,7 +3662,7 @@ void PDFType0Font::buildEncodeMap() const if (!m_fontDescriptor.isEmbedded() && m_cmap.isUnicodeEncoded()) { m_cmap.enumerate([&, this](unsigned int code, unsigned int byteCount, CID) - { + { if (code == 0 || code > 0xFFFF) { return; @@ -3661,8 +3679,7 @@ void PDFType0Font::buildEncodeMap() const return; } - m_encodeMap.emplace(codePoint, serializeCode(code, byteCount)); - }); + m_encodeMap.emplace(codePoint, serializeCode(code, byteCount)); }); } } @@ -3684,7 +3701,6 @@ PDFType3Font::PDFType3Font(FontDescriptor fontDescriptor, m_resources(resources), m_toUnicode(qMove(toUnicode)) { - } FontType PDFType3Font::getFontType() const diff --git a/LoopLibCore/sources/pdffunction.cpp b/LoopLibCore/sources/pdffunction.cpp index 0317c24a0..030414fa8 100644 --- a/LoopLibCore/sources/pdffunction.cpp +++ b/LoopLibCore/sources/pdffunction.cpp @@ -39,13 +39,19 @@ namespace pdf { +// PDFSampledFunction materialises a 2^m hypercube offset table in its constructor +// and a 2^m sample buffer on every apply() call. The specification permits up to 32 +// input variables, but 2^32 four-byte offsets is a denial of service, and a real +// sampled function uses a handful of dimensions at most. This ceiling also makes the +// dimension count safe to shift and to index m_size with. +constexpr uint32_t SAMPLED_FUNCTION_MAXIMUM_DIMENSIONS = 20; + PDFFunction::PDFFunction(uint32_t m, uint32_t n, std::vector&& domain, std::vector&& range) : m_m(m), m_n(n), m_domain(std::move(domain)), m_range(std::move(range)) { - } PDFFunctionPtr PDFFunction::createFunction(const PDFDocument* document, const PDFObject& object) @@ -108,7 +114,8 @@ PDFFunctionPtr PDFFunction::createFunctionImpl(const PDFDocument* document, cons std::vector encode = loader.readNumberArrayFromDictionary(dictionary, "Encode"); std::vector decode = loader.readNumberArrayFromDictionary(dictionary, "Decode"); - if (size.empty() || !std::all_of(size.cbegin(), size.cend(), [](PDFInteger size) { return size >= 1; })) + if (size.empty() || !std::all_of(size.cbegin(), size.cend(), [](PDFInteger size) + { return size >= 1; })) { throw PDFException(PDFParsingContext::tr("Sampled function has invalid sample size.")); } @@ -118,6 +125,11 @@ PDFFunctionPtr PDFFunction::createFunctionImpl(const PDFDocument* document, cons throw PDFException(PDFParsingContext::tr("Sampled function has invalid count of bits per sample.")); } + if (size.size() > SAMPLED_FUNCTION_MAXIMUM_DIMENSIONS) + { + throw PDFException(PDFParsingContext::tr("Sampled function has invalid sample size.")); + } + if (encode.empty()) { // Construct default array according to the PDF 1.7 specification @@ -142,7 +154,12 @@ PDFFunctionPtr PDFFunction::createFunctionImpl(const PDFDocument* document, cons throw PDFException(PDFParsingContext::tr("Sampled function hasn't any output.")); } - if (domain.size() != encode.size()) + // The PDF 1.7 specification defines Encode as 2 x m numbers and Decode + // as 2 x n numbers, and PDFSampledFunction indexes m_domain/m_encoder as + // 2 x m and m_range/m_decoder as 2 x n. Comparing Domain.size() with + // Encode.size() alone accepted arrays shorter than 2 x m, which the + // constructor only rejects with Q_ASSERT - i.e. not in a release build. + if (domain.size() != 2 * m || encode.size() != 2 * m) { throw PDFException(PDFParsingContext::tr("Sampled function has invalid encode array.")); } @@ -168,6 +185,20 @@ PDFFunctionPtr PDFFunction::createFunctionImpl(const PDFDocument* document, cons { throw PDFException(PDFParsingContext::tr("Sampled function has invalid sample size.")); } + + // The stream is the only source of samples, and each one costs + // `bitsPerSample` bits of it, so a declared count larger than the + // stream can carry must fail here - before resize() reserves memory + // proportional to a hostile number. This accepts exactly the documents + // the read loop below would have accepted (it throws "Not enough + // samples" at the same bit count); it only fails earlier. + const uint64_t availableSampleBits = static_cast(streamData.size()) * 8; + const uint64_t requiredSampleBits = static_cast(sampleCount) * static_cast(bitsPerSample); + if (requiredSampleBits > availableSampleBits) + { + throw PDFException(PDFParsingContext::tr("Sampled function declares more samples than its stream contains (%1 samples need %2 bits, the stream has %3).").arg(sampleCount).arg(requiredSampleBits).arg(availableSampleBits)); + } + std::vector samples; samples.resize(sampleCount, 0.0); @@ -201,12 +232,8 @@ PDFFunctionPtr PDFFunction::createFunctionImpl(const PDFDocument* document, cons } std::vector sizeAsUint; - std::transform(size.cbegin(), size.cend(), std::back_inserter(sizeAsUint), [](PDFInteger integer) { return static_cast(integer); }); - - if (m > 30) - { - throw PDFException(PDFParsingContext::tr("Sampled function has invalid sample size.")); - } + std::transform(size.cbegin(), size.cend(), std::back_inserter(sizeAsUint), [](PDFInteger integer) + { return static_cast(integer); }); return std::make_shared(static_cast(m), static_cast(n), std::move(domain), std::move(range), std::move(sizeAsUint), std::move(samples), std::move(encode), std::move(decode), sampleMaxValue, loader.readIntegerFromDictionary(dictionary, "Order", 1)); } @@ -599,7 +626,6 @@ PDFStitchingFunction::PDFStitchingFunction(uint32_t m, uint32_t n, PDFStitchingFunction::~PDFStitchingFunction() { - } PDFFunction::FunctionResult PDFStitchingFunction::apply(const_iterator x_1, @@ -624,7 +650,8 @@ PDFFunction::FunctionResult PDFStitchingFunction::apply(const_iterator x_1, // First search for partial function, which defines our range. Use algorithm // similar to the std::lower_bound. - auto it = std::lower_bound(m_partialFunctions.cbegin(), m_partialFunctions.cend(), x, [](const auto& partialFunction, PDFReal value) { return partialFunction.bound1 < value; }); + auto it = std::lower_bound(m_partialFunctions.cbegin(), m_partialFunctions.cend(), x, [](const auto& partialFunction, PDFReal value) + { return partialFunction.bound1 < value; }); if (it == m_partialFunctions.cend()) { --it; @@ -650,7 +677,6 @@ PDFFunction::FunctionResult PDFStitchingFunction::apply(const_iterator x_1, PDFIdentityFunction::PDFIdentityFunction() : PDFFunction(0, 0, std::vector(), std::vector()) { - } PDFFunction::FunctionResult PDFIdentityFunction::apply(const_iterator x_1, @@ -678,10 +704,26 @@ class PDFPostScriptFunctionStack using OperandObject = PDFPostScriptFunction::OperandObject; using InstructionPointer = PDFPostScriptFunction::InstructionPointer; - inline void pushReal(PDFReal value) { m_stack.push_back(OperandObject::createReal(value)); checkOverflow(); } - inline void pushInteger(PDFInteger value) { m_stack.push_back(OperandObject::createInteger(value)); checkOverflow(); } - inline void pushBoolean(bool value) { m_stack.push_back(OperandObject::createBoolean(value)); checkOverflow(); } - inline void pushInstructionPointer(InstructionPointer value) { m_stack.push_back(OperandObject::createInstructionPointer(value)); checkOverflow(); } + inline void pushReal(PDFReal value) + { + m_stack.push_back(OperandObject::createReal(value)); + checkOverflow(); + } + inline void pushInteger(PDFInteger value) + { + m_stack.push_back(OperandObject::createInteger(value)); + checkOverflow(); + } + inline void pushBoolean(bool value) + { + m_stack.push_back(OperandObject::createBoolean(value)); + checkOverflow(); + } + inline void pushInstructionPointer(InstructionPointer value) + { + m_stack.push_back(OperandObject::createInstructionPointer(value)); + checkOverflow(); + } /// Returns true, if integer operation should be performed instead of operation with real values. /// (two top elements are integer). @@ -712,13 +754,25 @@ class PDFPostScriptFunctionStack PDFReal popNumber(); /// Returns true, if current value is real - bool isReal() const { checkUnderflow(); return m_stack.back().type == PDFPostScriptFunction::OperandType::Real; } + bool isReal() const + { + checkUnderflow(); + return m_stack.back().type == PDFPostScriptFunction::OperandType::Real; + } /// Returns true, if current value is integer - bool isInteger() const { checkUnderflow(); return m_stack.back().type == PDFPostScriptFunction::OperandType::Integer; } + bool isInteger() const + { + checkUnderflow(); + return m_stack.back().type == PDFPostScriptFunction::OperandType::Integer; + } /// Pops the current value - inline void pop() { checkUnderflow(); m_stack.pop_back(); } + inline void pop() + { + checkUnderflow(); + m_stack.pop_back(); + } /// Exchange the two top elements void exch(); @@ -740,7 +794,11 @@ class PDFPostScriptFunctionStack void roll(PDFInteger n, PDFInteger j); /// Pushes the operand onto the stack - void push(const OperandObject& operand) { m_stack.push_back(operand); checkOverflow(); } + void push(const OperandObject& operand) + { + m_stack.push_back(operand); + checkOverflow(); + } /// Returns true, if stack is empty bool empty() const { return m_stack.empty(); } @@ -774,14 +832,13 @@ class PDFPostScriptFunctionExecutor m_program(program), m_stack(stack) { - } /// Executes the postscript program void execute(); private: - template typename Comparator> + template