diff --git a/CHANGELOG.md b/CHANGELOG.md index 7936cf4..e88e2e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ earlier development is recorded in the [commit history](https://github.com/omaco ### Added +- Optional logical-size Copy/Save output on scaled monitors with + `[output] logical_size = true`; editable sources and previews retain native pixels. + - Standard, Pointy, Curved, and Double arrow styles, with editable bends. - Text wrapping at the canvas edge or a dragged width, preserved when reopening a capture. diff --git a/CMakeLists.txt b/CMakeLists.txt index 20b3ccc..7d0afc6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,8 @@ add_library(omasnap-core STATIC src/recent-snaps.hpp src/output-config.cpp src/output-config.hpp + src/output-image.cpp + src/output-image.hpp src/pin-expiry.cpp src/pin-expiry.hpp src/overlay-chrome.cpp @@ -153,6 +155,8 @@ qt_add_executable(omasnap-smoke tests/cut-mapping-smoke.hpp tests/palette-config-smoke.cpp tests/palette-config-smoke.hpp + tests/output-image-smoke.cpp + tests/output-image-smoke.hpp tests/stroke-smoothing-smoke.cpp tests/stroke-smoothing-smoke.hpp src/cli-path.cpp diff --git a/README.md b/README.md index ff651fd..f86a718 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,8 @@ directory = ~/Pictures/Captures # Filename pattern, without extension (.png is appended). # Default: screenshot-{date}_{time}-{app} filename = screenshot-{date}_{time}-{app} +# Optional: Copy/Save at logical size on scaled monitors. Default: false. +logical_size = false [colors] # Up to eight preset colors for the palette, and the initial custom color. @@ -347,6 +349,15 @@ image = ~/Pictures/backdrops/desk.jpg default = custom ``` +Set `[output] logical_size = true` to downscale Copy/Save output by the +capture's monitor scale. For example, a 600-pixel-wide capture at 2x becomes +300 pixels wide. Fractional results round to the nearest pixel, with a minimum +of one pixel per dimension. The complete rendered image, including annotations +and backdrops, is resized once at export. This also applies to quick output, the initial clipboard copy accompanying +a preview, and scrolling captures; native-resolution output remains the default. Editing, +OCR, pins, and the recents shelf retain their original resolution. Ordinary +image files without stored capture scale are not resized. + Filename tokens: | Token | Expands to | diff --git a/docs/editing-model.md b/docs/editing-model.md index b3f7587..4d280b0 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -41,6 +41,13 @@ snapshot or returning edits to an existing pin. Each render runs off the UI thre [threading.md](threading.md)), and writes the result. Until one of those happens, everything remains a log entry you can undo. +Copy/Save can optionally downscale this completed raster to logical size +(`output/logical_size`). Resizing runs after rendering, including redaction, +on the output worker. It never changes the working source, operation log, +OCR input, or pinned image; native-resolution exports remain the default. +Working documents retain the original export scale in the log, so rounding +their logical presentation size does not change output size after reopening. + Opening a pin for annotation leaves its compositor window in place. A private copy retains the pristine source and operation log; `Esc` commits any text draft, dismisses the annotator, and updates the pin's rendered preview. Reopening reads diff --git a/src/capture.cpp b/src/capture.cpp index 8da983b..a45dd66 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1828,7 +1828,7 @@ QSize editorWindowSize(const QSize &preview, const QSize &available, } bool savePinnedSnapshot(const QImage &image, const QString &path, - const QSize &logicalSize, QString &error) { + const QSize &logicalSize, QString &error, qreal outputScale) { if (!saveTemporarySnapshot(image, path, error)) return false; // The snapshot holds device pixels; the sidecar records the logical size @@ -1837,6 +1837,7 @@ bool savePinnedSnapshot(const QImage &image, const QString &path, // image blown up. OperationLog sidecar; sidecar.previewSize = logicalSize; + sidecar.outputScale = outputScale; if (!logicalSize.isEmpty() && !saveOperationLog(operationLogPath(path), sidecar, error)) { QFile::remove(path); @@ -1848,14 +1849,15 @@ bool savePinnedSnapshot(const QImage &image, const QString &path, QString launchPinnedCapture( const QImage &image, const QSize &logicalSize, bool copy, PinLifetime lifetime, QString &error, - const std::function &launcher) { + const std::function &launcher, + qreal outputScale) { prunePinnedSnapshots(); const QString path = pinnedSnapshotPath(1); if (path.isEmpty()) { error = QStringLiteral("Could not create private runtime directory"); return {}; } - if (!savePinnedSnapshot(image, path, logicalSize, error)) + if (!savePinnedSnapshot(image, path, logicalSize, error, outputScale)) return {}; const auto cleanup = [&] { QFile::remove(path); @@ -2390,6 +2392,8 @@ bool saveOperationLog(const QString &path, const OperationLog &log, root.insert(QStringLiteral("index"), log.index); root.insert(QStringLiteral("nextId"), QString::number(log.nextId)); root.insert(QStringLiteral("nextMarker"), log.nextMarker); + if (std::isfinite(log.outputScale) && log.outputScale > 0.0) + root.insert(QStringLiteral("outputScale"), log.outputScale); if (log.previewSize.isValid()) { root.insert(QStringLiteral("previewWidth"), log.previewSize.width()); root.insert(QStringLiteral("previewHeight"), log.previewSize.height()); @@ -2436,6 +2440,9 @@ bool loadOperationLog(const QString &path, OperationLog &log, QString &error) { loaded.index = root.value(QStringLiteral("index")).toInt(); loaded.nextId = root.value(QStringLiteral("nextId")).toString().toULongLong(); loaded.nextMarker = root.value(QStringLiteral("nextMarker")).toInt(1); + const qreal outputScale = root.value(QStringLiteral("outputScale")).toDouble(); + if (std::isfinite(outputScale) && outputScale > 0.0) + loaded.outputScale = outputScale; loaded.previewSize = QSize(root.value(QStringLiteral("previewWidth")).toInt(), root.value(QStringLiteral("previewHeight")).toInt()); @@ -2532,6 +2539,7 @@ void describeFileCapture(CaptureData &capture, QImage image, capture = CaptureData(); capture.previewSize = image.size(); capture.monitor.scale = 1.0; + capture.outputScale = log.outputScale; if (log.previewSize.isValid() && !log.previewSize.isEmpty() && log.previewSize.width() <= image.width() && log.previewSize.height() <= image.height()) { diff --git a/src/capture.hpp b/src/capture.hpp index da0e745..911b624 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -44,6 +44,9 @@ struct CaptureData { /** Logical size the native source image is presented at. */ QSize previewSize; QVector windows; + /** Original export scale, before logical presentation dimensions rounded. + * Zero uses monitor.scale for freshly captured images. */ + qreal outputScale = 0.0; /** Loaded documents retain their exact pixel dimensions during rendering. */ bool preserveSourceResolution = false; }; @@ -146,6 +149,9 @@ struct OperationLog { /// monitor reopens at the same scale. Invalid when unknown. QSize previewSize; + /// Export scale override, independent of the operation coordinate space. + qreal outputScale = 0.0; + bool operator==(const OperationLog &) const = default; }; @@ -389,14 +395,16 @@ bool removeEditorHandoff(const QString &path, const QString &token); /** Saves a pinned snapshot plus a sidecar log recording the logical size, * so editing the pin later reopens at the captured scale. */ [[nodiscard]] bool savePinnedSnapshot(const QImage &image, const QString &path, - const QSize &logicalSize, QString &error); + const QSize &logicalSize, QString &error, + qreal outputScale = 0.0); /** Saves and launches a private pin, optionally copying the same PNG first. * Call on a worker: encoding, clipboard verification and process launch block. * Returns the owned snapshot path, or removes it on failure. */ [[nodiscard]] QString launchPinnedCapture( const QImage &image, const QSize &logicalSize, bool copy, PinLifetime lifetime, QString &error, - const std::function &launcher = {}); + const std::function &launcher = {}, + qreal outputScale = 0.0); [[nodiscard]] bool saveTemporarySnapshot(const QImage &image, QString path, QString &error, int quality = -1); [[nodiscard]] QString recognizeText(const QImage &image, QString &error); diff --git a/src/editor.cpp b/src/editor.cpp index aa31863..1d79eda 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -7,6 +7,7 @@ #include "icons.hpp" #include "eyedropper.hpp" #include "output-config.hpp" +#include "output-image.hpp" #include "overlay-chrome.hpp" #include "palette-config.hpp" #include "recent-snaps.hpp" @@ -2766,6 +2767,7 @@ bool CaptureEditor::restoreOperationLog(const QString &path, QString &error) { nextAnnotationId_ = std::max(log.nextId, 1); nextMarker_ = std::max(log.nextMarker, 1); replayLog(); + capture_.outputScale = log.outputScale; phase_ = Phase::Edit; scheduleSnapshot(); return true; @@ -3174,7 +3176,8 @@ void CaptureEditor::startSnapshotRender() { const QString path = snapshotPath_; const QString logPath = operationLogPath(path); const OperationLog log{ops_, opIndex_, nextAnnotationId_, nextMarker_, - pristineLogicalSize_}; + pristineLogicalSize_, capture_.outputScale > 0.0 + ? capture_.outputScale : capture_.monitor.scale}; const bool writeSource = !sourceWritten_ || !QFile::exists(path); snapshotWatcher_.setFuture(QtConcurrent::run( [source, path, logPath, log, writeSource] { @@ -3225,7 +3228,8 @@ void CaptureEditor::handOffEditor(bool toWindow) { // its files would block input and race later coalesced snapshot writes. const QImage source = pristineSource_; const OperationLog log{ops_, opIndex_, nextAnnotationId_, nextMarker_, - pristineLogicalSize_}; + pristineLogicalSize_, capture_.outputScale > 0.0 + ? capture_.outputScale : capture_.monitor.scale}; const QString program = QCoreApplication::applicationFilePath(); const auto launcher = processLauncher_; const auto pinDocument = pinDocument_; @@ -3317,7 +3321,9 @@ void CaptureEditor::pinSnapshot() { renderCapture(captureCopy, selection, annotations, background, imageShadow, canvasBoundary, backdrop); result.path = launchPinnedCapture(image, selection.size().toSize(), - false, PinLifetime::Persistent, result.error, launcher); + false, PinLifetime::Persistent, result.error, launcher, + captureCopy.outputScale > 0.0 + ? captureCopy.outputScale : captureCopy.monitor.scale); return result; })); } @@ -3480,7 +3486,8 @@ void CaptureEditor::dismissEditor() { const auto boundary = canvasBoundaryMode_; const QImage backdrop = customBackdrop_; const OperationLog log{ops_, opIndex_, nextAnnotationId_, nextMarker_, - pristineLogicalSize_}; + pristineLogicalSize_, capture_.outputScale > 0.0 + ? capture_.outputScale : capture_.monitor.scale}; auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, [this, watcher] { const QString error = watcher->result(); @@ -3502,7 +3509,7 @@ void CaptureEditor::dismissEditor() { // Commit the log last: its atomic replacement tells the pin that both // the preview and the editable document are ready to read. if (savePinnedSnapshot(image, document->previewPath(), - selection.size().toSize(), error)) + selection.size().toSize(), error, log.outputScale)) static_cast(saveOperationLog(operationLogPath(document->path()), log, error)); return error; })); @@ -3929,12 +3936,23 @@ void CaptureEditor::finish(OutputMode mode) { backdrop, appSlug, mode, launcher]() { FinishResult result; result.mode = mode; - const QImage image = renderCapture(captureCopy, selection, annotations, - background, imageShadow, - canvasBoundary, backdrop); + const QImage nativeImage = renderCapture(captureCopy, selection, annotations, + background, imageShadow, + canvasBoundary, backdrop); + const qreal outputScale = captureCopy.outputScale > 0.0 + ? captureCopy.outputScale : captureCopy.monitor.scale; + const QImage image = prepareOutputImage(nativeImage, outputScale); if (mode == OutputMode::CopyAndPreview) { - static_cast(launchPinnedCapture(image, selection.size().toSize(), - true, PinLifetime::Timed, result.error, launcher)); + // The clipboard follows output sizing, but the preview remains a native + // working image so opening its editor never starts with downsampled pixels. + const bool resized = image.size() != nativeImage.size(); + if (resized && !copyImageToClipboard(image, result.error)) + return result; + // With native output, reuse the pin's PNG for the clipboard as before. + if (launchPinnedCapture(nativeImage, selection.size().toSize(), + !resized, PinLifetime::Timed, result.error, launcher, + outputScale).isEmpty() && resized) + result.error.prepend(QStringLiteral("Screenshot copied, but ")); return result; } if (!image.isNull()) @@ -6193,9 +6211,10 @@ void CaptureEditor::adoptStitched(const QImage &image) { } const bool veryLong = image.width() > stitch::kWidelyOpenableEdge || image.height() > stitch::kWidelyOpenableEdge; - // Stitching produces native pixels; retain the monitor's logical size - // through the same document metadata used when reopening a pinned capture. + // Retain both the rounded logical presentation and the original export + // scale; odd native dimensions must still export identically after reopening. OperationLog log; + log.outputScale = liveMonitor_.scale; log.previewSize = (QSizeF(image.size()) / std::max(1.0, liveMonitor_.scale)).toSize(); adoptImage(image, std::move(log), CaptureMode::Scroll, veryLong diff --git a/src/main.cpp b/src/main.cpp index 29ef9e1..4b2d92d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "instance-lock.hpp" #include "output-config.hpp" #include "overlay-chrome.hpp" +#include "output-image.hpp" #include "pin.hpp" #include "pin-file.hpp" #include "recent-snaps.hpp" @@ -399,7 +400,8 @@ int main(int argc, char **argv) { : renderCapture(capture, QRectF(QPointF(), capture.previewSize), {}, BackgroundStyle::None); - if (!quickOutput(output, quickOutputMode, outputError)) { + if (!quickOutput(prepareOutputImage(output, capture.monitor.scale), + quickOutputMode, outputError)) { qCritical().noquote() << outputError; return 1; } diff --git a/src/output-config.cpp b/src/output-config.cpp index e5a0152..0f920e8 100644 --- a/src/output-config.cpp +++ b/src/output-config.cpp @@ -21,6 +21,9 @@ OutputConfig loadOutputConfig(const QString &filePath) { settings.value(QStringLiteral("output/filename")).toString().trimmed(); if (!filename.isEmpty()) config.filename = filename; + config.logicalSize = + settings.value(QStringLiteral("output/logical_size")).toString() + .trimmed().compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0; return config; } diff --git a/src/output-config.hpp b/src/output-config.hpp index 17ad4d9..dd03d06 100644 --- a/src/output-config.hpp +++ b/src/output-config.hpp @@ -11,9 +11,11 @@ struct OutputConfig { /** Filename pattern without extension. Tokens: `{date}` (yyyy-MM-dd), * `{time}` (HH-mm-ss), `{app}` (slug of the app under the selection). */ QString filename = QStringLiteral("screenshot-{date}_{time}-{app}"); + /** Copy/Save at logical size on scaled monitors; native pixels by default. */ + bool logicalSize = false; }; -/** Reads [output] directory and [output] filename. A missing file or key +/** Reads [output] directory, filename, and logical_size. A missing file or key * leaves the default untouched; `~` in directory expands to $HOME. */ [[nodiscard]] OutputConfig loadOutputConfig(const QString &filePath); diff --git a/src/output-image.cpp b/src/output-image.cpp new file mode 100644 index 0000000..65ffaa6 --- /dev/null +++ b/src/output-image.cpp @@ -0,0 +1,26 @@ +#include "output-image.hpp" +#include "output-config.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +QImage prepareOutputImage(const QImage &image, qreal scale) { + if (image.isNull() || !std::isfinite(scale) || scale <= 1.0 || + !loadOutputConfig(defaultConfigPath()).logicalSize) + return image; + + // Scale the completed canvas, including backdrops and annotations. Never + // resample source pixels before redaction has destroyed sensitive content. + const QSize size(std::max(1, qRound(image.width() / scale)), + std::max(1, qRound(image.height() / scale))); + QImage output = image.scaled(size, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + output.setDevicePixelRatio(1.0); + return output; +} diff --git a/src/output-image.hpp b/src/output-image.hpp new file mode 100644 index 0000000..1b0bf14 --- /dev/null +++ b/src/output-image.hpp @@ -0,0 +1,8 @@ +/** @fileoverview Final Copy/Save image sizing, after rendering and redaction. */ +#pragma once + +#include + +/** Reads output configuration and optionally downsizes a flattened image. + * Call on the output worker, never while painting or persisting source pixels. */ +[[nodiscard]] QImage prepareOutputImage(const QImage &image, qreal scale); diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index d0f74f4..600225a 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -4,6 +4,7 @@ */ #include "capture.hpp" #include "output-config.hpp" +#include "output-image-smoke.hpp" #include "overlay-chrome.hpp" #include "cli-path.hpp" #include "clipboard-smoke.hpp" @@ -11741,6 +11742,10 @@ int main(int argc, char **argv) { } QString instanceError; + if (!runOutputImageSmoke(application, instanceError)) { + qWarning().noquote() << instanceError; + return EXIT_FAILURE; + } if (!runInstanceLockSmoke(instanceError)) { qWarning().noquote() << instanceError; return 85; diff --git a/tests/output-image-smoke.cpp b/tests/output-image-smoke.cpp new file mode 100644 index 0000000..98c28da --- /dev/null +++ b/tests/output-image-smoke.cpp @@ -0,0 +1,309 @@ +/** @fileoverview Logical export sizing and real editor Copy/Save regression. */ +#include "output-image-smoke.hpp" + +#include "capture.hpp" +#include "editor.hpp" +#include "output-config.hpp" +#include "output-image.hpp" +#include "recent-snaps.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +bool writeFile(const QString &path, const QByteArray &contents) { + QFile file(path); + return file.open(QIODevice::WriteOnly) && + file.write(contents) == contents.size(); +} +} // namespace + +bool runOutputImageSmoke(QApplication &application, QString &error) { + error = QStringLiteral("Logical output smoke failed"); + const QTemporaryDir directory; + if (!directory.isValid()) + return false; + const std::array variables = {"XDG_CONFIG_HOME", "OMASNAP_SCREENSHOT_DIR", + "OMASNAP_TEST_OUTPUT_PNG", "OMASNAP_RECENT_DIR", "PATH"}; + std::array previous; + for (std::size_t i = 0; i < variables.size(); ++i) + previous[i] = qgetenv(variables[i]); + const auto restore = qScopeGuard([&] { + for (std::size_t i = 0; i < variables.size(); ++i) { + if (previous[i].isNull()) + qunsetenv(variables[i]); + else + qputenv(variables[i], previous[i]); + } + }); + qputenv("XDG_CONFIG_HOME", directory.path().toUtf8()); + if (!defaultConfigPath().startsWith(directory.path() + '/')) + return false; + QDir().mkpath(directory.filePath(QStringLiteral("omasnap"))); + QImage image(601, 303, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + image.setPixelColor(200, 100, Qt::red); + if (prepareOutputImage(image, 2.0) != image) + return false; + for (const QByteArray &value : {QByteArray("false"), QByteArray("invalid")}) { + if (!writeFile(defaultConfigPath(), "[output]\nlogical_size=" + value + '\n') || + loadOutputConfig(defaultConfigPath()).logicalSize || + prepareOutputImage(image, 2.0) != image) + return false; + } + if (!writeFile(defaultConfigPath(), "[output]\nlogical_size=true\n") || + !loadOutputConfig(defaultConfigPath()).logicalSize) + return false; + // Downsampling must never reintroduce source positions hidden by redaction. + // Mosaic intentionally keeps aggregate colors, so rearrange equal counts. + for (const RedactionStyle style : {RedactionStyle::Solid, + RedactionStyle::Pixelate}) { + CaptureData capture; + capture.monitor.scale = 1.5; + capture.previewSize = QSize(80, 60); + capture.source = QImage(120, 90, QImage::Format_RGB32); + capture.source.fill(Qt::white); + Annotation redact; + redact.kind = Annotation::Kind::Redaction; + redact.start = QPointF(10, 10); + redact.end = QPointF(30, 30); + redact.redactionStyle = style; + redact.redactionSeed = 42; + const QRectF selection(QPointF(), capture.previewSize); + for (int y = 20; y < 40; ++y) + for (int x = 20; x < 30; ++x) + capture.source.setPixelColor(x, y, Qt::red); + const QImage before = prepareOutputImage( + renderCapture(capture, selection, {redact}, BackgroundStyle::None), 1.5); + for (int y = 20; y < 40; ++y) + for (int x = 20; x < 40; ++x) + capture.source.setPixelColor(x, y, x < 30 ? Qt::white : Qt::red); + const QImage after = prepareOutputImage( + renderCapture(capture, selection, {redact}, BackgroundStyle::None), 1.5); + if (before.isNull() || before != after) { + error = QStringLiteral("Logical export leaked redacted source pixels"); + return false; + } + } + for (const auto &[scale, size] : + std::array{std::pair{1.0, QSize(601, 303)}, + std::pair{1.5, QSize(401, 202)}, + std::pair{2.0, QSize(301, 152)}}) { + const QImage output = prepareOutputImage(image, scale); + if (output.size() != size || output.pixelColor(0, 0).alpha() != 0) + return false; + } + for (const qreal scale : {0.0, -1.0, 0.75, + std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + if (prepareOutputImage(image, scale) != image) + return false; + } + QImage tiny(1, 1, QImage::Format_RGB32); + tiny.fill(Qt::red); + if (!prepareOutputImage({}, 2.0).isNull() || + prepareOutputImage(tiny, 2.0).size() != + QSize(1, 1)) + return false; + + // Exercise both destinations through the editor's actual asynchronous export. + // Stubs keep the test independent of the user's clipboard and notifications. + for (const auto &[name, script] : std::array{ + std::pair{"wl-copy", "#!/bin/sh\ncat > \"$OMASNAP_TEST_OUTPUT_PNG\"\n"}, + std::pair{"wl-paste", "#!/bin/sh\ncat \"$OMASNAP_TEST_OUTPUT_PNG\"\n"}, + std::pair{"omarchy-notification-send", "#!/bin/sh\nexit 0\n"}}) { + const QString path = directory.filePath(QString::fromLatin1(name)); + if (!writeFile(path, script) || + !QFile::setPermissions(path, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)) + return false; + } + qputenv("PATH", directory.path().toUtf8() + ':' + qgetenv("PATH")); + const QString clipboard = directory.filePath(QStringLiteral("clipboard.png")); + qputenv("OMASNAP_TEST_OUTPUT_PNG", clipboard.toUtf8()); + for (const qreal scale : {1.0, 1.5, 2.0}) { + const QString savedDir = directory.filePath(QString::number(scale)); + qputenv("OMASNAP_SCREENSHOT_DIR", savedDir.toUtf8()); + CaptureData capture; + capture.previewSize = QSize(400, 300); + capture.monitor.geometry = QRect(QPoint(), capture.previewSize); + capture.monitor.scale = scale; + capture.source = QImage(qRound(400 * scale), qRound(300 * scale), + QImage::Format_ARGB32_Premultiplied); + capture.source.fill(QColor(QStringLiteral("#345678"))); + capture.monitor.pixelSize = capture.source.size(); + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_R); + const QPoint start = editor.annotationPointToWidgetForTest(QPointF(80, 80)).toPoint(); + const QPoint end = editor.annotationPointToWidgetForTest(QPointF(180, 160)).toPoint(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove(&editor, end); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, end); + if (editor.currentAnnotationsForTest().size() != 1) + return false; + const QImage native = editor.renderCurrentOutput(); + const QImage expected = native.scaled( + qRound(native.width() / scale), qRound(native.height() / scale), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QTest::keyClick(&editor, Qt::Key_Return); + editor.waitForExport(); + const auto files = QDir(savedDir).entryList({QStringLiteral("*.png")}, + QDir::Files); + if (editor.isVisible() || files.size() != 1 || + editor.captureData().source != capture.source) { + error = QStringLiteral("Logical export failed or changed the working source"); + return false; + } + const QImage saved(QDir(savedDir).filePath(files.constFirst())); + if (saved.convertToFormat(QImage::Format_ARGB32) != + expected.convertToFormat(QImage::Format_ARGB32) || + saved != QImage(clipboard)) { + error = QStringLiteral("Logical Copy/Save pixels differ from rendered output"); + return false; + } + } + // Fresh captures downsize only the initial clipboard output. Their preview + // keeps native pixels and exact export scale for a later edit. + for (const bool scrolling : {false, true}) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.scale = 2.0; + capture.previewSize = {400, 300}; + capture.monitor.geometry = QRect(QPoint(), capture.previewSize); + capture.monitor.pixelSize = {800, 600}; + capture.source = QImage(800, 600, QImage::Format_RGB32); + capture.source.fill(Qt::cyan); + CaptureEditor editor(capture, scrolling ? CaptureEditor::CaptureMode::Scroll + : CaptureEditor::CaptureMode::Fullscreen, + QuickOutputMode::CopyAndPreview); + QString preview; + editor.setProcessLauncherForTest([&](const QString &, const QStringList &args) { + if (args.size() != 2 || args.first() != QStringLiteral("--preview")) + return false; + preview = args.last(); + return true; + }); + editor.resize(800, 600); + editor.show(); + QImage native = capture.source; + if (scrolling) { + native = QImage(603, 1203, QImage::Format_RGB32); + native.fill(Qt::cyan); + editor.adoptStitchedForTest(native); + } + editor.waitForExport(); + const auto cleanup = qScopeGuard([&] { + QFile::remove(preview); + QFile::remove(operationLogPath(preview)); + }); + OperationLog log; + if (preview.isEmpty() || editor.isVisible() || QImage(preview).size() != native.size() || + QImage(clipboard).size() != QSize(qRound(native.width() / 2.0), + qRound(native.height() / 2.0)) || + !loadOperationLog(operationLogPath(preview), log, error) || log.outputScale != 2.0) { + error = QStringLiteral("Logical clipboard output changed the native preview or lost its scale"); + return false; + } + } + // Scrolling captures retain logical editing coordinates and the original + // export scale, including when their working document reopens. + for (const qreal scale : {1.5, 2.0}) { + CaptureData monitor; + monitor.monitor.name = QStringLiteral("TEST"); + monitor.monitor.scale = scale; + monitor.previewSize = QSize(400, 300); + monitor.monitor.geometry = QRect(QPoint(), monitor.previewSize); + monitor.source = QImage(600, 450, QImage::Format_RGB32); + monitor.source.fill(Qt::white); + QImage stitched(603, 1203, QImage::Format_RGB32); + stitched.fill(QColor(QStringLiteral("#345678"))); + CaptureEditor editor(monitor, CaptureEditor::CaptureMode::File); + editor.resize(800, 600); + editor.show(); + if (!editor.waitForSnapshot()) + return false; + editor.adoptStitchedForTest(stitched); + if (editor.captureData().previewSize != (QSizeF(stitched.size()) / scale).toSize() || + !editor.waitForSnapshot()) { + error = QStringLiteral("Stitched logical presentation changed"); + return false; + } + OperationLog log; + if (!loadOperationLog(editor.workingLogPath(), log, error)) + return false; + const QImage source(editor.workingSourcePath()); + if (source != stitched || log.outputScale != scale) { + error = QStringLiteral("Stitched working document lost source pixels or export scale"); + return false; + } + CaptureData restored; + describeFileCapture(restored, source, log); + const QImage expected = stitched.scaled( + qRound(stitched.width() / scale), qRound(stitched.height() / scale), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const auto checkExport = [&](CaptureEditor &target, bool reopen) { + const QString savedDir = directory.filePath( + QStringLiteral("scroll-%1-%2").arg(scale).arg(reopen)); + qputenv("OMASNAP_SCREENSHOT_DIR", savedDir.toUtf8()); + qputenv("OMASNAP_RECENT_DIR", (savedDir + QStringLiteral("/recents")).toUtf8()); + target.resize(800, 600); + target.show(); + application.processEvents(); + QTest::keyClick(&target, Qt::Key_Return); + target.waitForExport(); + const auto files = QDir(savedDir).entryList({QStringLiteral("*.png")}, QDir::Files); + if (files.size() != 1 || target.isVisible() || + QImage(QDir(savedDir).filePath(files.constFirst())).convertToFormat(QImage::Format_RGB32) != + expected.convertToFormat(QImage::Format_RGB32)) { + error = QStringLiteral("Stitched export lost monitor scale (scale %1, reopen %2)") + .arg(scale).arg(reopen); + return false; + } + const auto recents = listRecentSnaps(); + OperationLog shelved; + if (recents.size() != 1 || + !loadOperationLog(recents.first().logPath, shelved, error) || + shelved.outputScale != scale || shelved.previewSize != log.previewSize || + QImage(recents.first().sourcePath).convertToFormat(QImage::Format_RGB32) != + stitched) { + error = QStringLiteral("Logical output changed recent source pixels or scale metadata"); + return false; + } + return true; + }; + if (!checkExport(editor, false)) + return false; + // Editors use one process-level working path. Construct the reopened one + // only after the first has finished shelving and relinquished that path. + CaptureEditor reopened(restored, CaptureEditor::CaptureMode::File, + QuickOutputMode::None, log); + if (!checkExport(reopened, true)) + return false; + } + return true; +} diff --git a/tests/output-image-smoke.hpp b/tests/output-image-smoke.hpp new file mode 100644 index 0000000..1898012 --- /dev/null +++ b/tests/output-image-smoke.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include + +class QApplication; +bool runOutputImageSmoke(QApplication &application, QString &error);