From 3b6a65856dcfe31441b0bd26aeb31aa8ac820b07 Mon Sep 17 00:00:00 2001 From: Tushar Malpani Date: Sat, 12 Sep 2026 13:53:14 +0530 Subject: [PATCH 1/2] feat: optionally export screenshots at logical size --- CMakeLists.txt | 4 + README.md | 11 +++ docs/editing-model.md | 5 ++ src/editor.cpp | 8 +- src/main.cpp | 4 +- src/output-config.cpp | 3 + src/output-config.hpp | 4 +- src/output-image.cpp | 26 ++++++ src/output-image.hpp | 8 ++ tests/editor-smoke.cpp | 5 ++ tests/output-image-smoke.cpp | 159 +++++++++++++++++++++++++++++++++++ tests/output-image-smoke.hpp | 6 ++ 12 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 src/output-image.cpp create mode 100644 src/output-image.hpp create mode 100644 tests/output-image-smoke.cpp create mode 100644 tests/output-image-smoke.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cdac5e2..26a69bf8 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/overlay-chrome.cpp src/overlay-chrome.hpp src/eyedropper.cpp @@ -149,6 +151,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 src/cli-path.cpp src/recent-snaps.cpp src/recent-snaps.hpp diff --git a/README.md b/README.md index 68f1edec..88f089c7 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,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. @@ -294,6 +296,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 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 477a17aa..f42fa185 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -38,6 +38,11 @@ snapshot. Each of those calls `renderCapture` once, off the UI thread (see [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. + ## The two exceptions, and why they're still safe Two operations *do* need to touch real pixels before export, and both are diff --git a/src/editor.cpp b/src/editor.cpp index 1c0a9aaf..896448b1 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -6,6 +6,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" @@ -3424,9 +3425,10 @@ void CaptureEditor::finish(OutputMode mode) { backdrop, appSlug, mode]() { FinishResult result; result.mode = mode; - const QImage image = renderCapture(captureCopy, selection, annotations, - background, imageShadow, - canvasBoundary, backdrop); + const QImage image = prepareOutputImage( + renderCapture(captureCopy, selection, annotations, background, + imageShadow, canvasBoundary, backdrop), + captureCopy.monitor.scale); if (!image.isNull()) result.thumbnail = image.scaled(kRecentThumbEdge, kRecentThumbEdge, Qt::KeepAspectRatio, diff --git a/src/main.cpp b/src/main.cpp index ea146887..4f2d4758 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "editor.hpp" #include "instance-lock.hpp" #include "overlay-chrome.hpp" +#include "output-image.hpp" #include "pin.hpp" #include "recent-snaps.hpp" #include "startup-timing.hpp" @@ -377,7 +378,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 f70a1360..5cfc9b5d 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 611ef67f..72706f9b 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 00000000..65ffaa6d --- /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 00000000..1b0bf144 --- /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 02eb6f78..41aeedd6 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -2,6 +2,7 @@ */ #include "capture.hpp" #include "output-config.hpp" +#include "output-image-smoke.hpp" #include "cli-path.hpp" #include "clipboard-smoke.hpp" #include "cut-mapping-smoke.hpp" @@ -8992,6 +8993,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 00000000..1f1efb91 --- /dev/null +++ b/tests/output-image-smoke.cpp @@ -0,0 +1,159 @@ +/** @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 +#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", "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; + 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 QStringList 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; + } + } + return true; +} diff --git a/tests/output-image-smoke.hpp b/tests/output-image-smoke.hpp new file mode 100644 index 00000000..18980127 --- /dev/null +++ b/tests/output-image-smoke.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include + +class QApplication; +bool runOutputImageSmoke(QApplication &application, QString &error); From d3b976c4c4fb3fd0c6a019ca198c3e0e578a08c6 Mon Sep 17 00:00:00 2001 From: Tushar Malpani Date: Sat, 12 Sep 2026 14:02:22 +0530 Subject: [PATCH 2/2] fix: retain scroll capture scale for logical exports --- docs/editing-model.md | 3 ++ src/capture.cpp | 6 +++ src/capture.hpp | 6 +++ src/editor.cpp | 12 +++-- tests/output-image-smoke.cpp | 93 +++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/docs/editing-model.md b/docs/editing-model.md index f42fa185..ce8ca515 100644 --- a/docs/editing-model.md +++ b/docs/editing-model.md @@ -42,6 +42,9 @@ 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. +Stitched captures keep their original monitor scale separately in the working +log's `outputScale` field because their editing coordinates are native pixels. +This makes logical-size export consistent after reopening them from recents. ## The two exceptions, and why they're still safe diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..3759a55b 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1916,6 +1916,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()); @@ -1962,6 +1964,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()); @@ -2058,6 +2063,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 108b0b4e..1ca4997d 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -41,6 +41,9 @@ struct CaptureData { /** Logical size the native source image is presented at. */ QSize previewSize; QVector windows; + /** Original monitor scale for images edited in native-pixel coordinates + * (scroll stitches). Zero uses monitor.scale for ordinary captures. */ + qreal outputScale = 0.0; }; enum class BackgroundStyle { @@ -130,6 +133,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; }; diff --git a/src/editor.cpp b/src/editor.cpp index 896448b1..c02f5990 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -2439,6 +2439,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; @@ -2843,7 +2844,7 @@ void CaptureEditor::startSnapshotRender() { const QString path = snapshotPath_; const QString logPath = operationLogPath(path); const OperationLog log{ops_, opIndex_, nextAnnotationId_, nextMarker_, - pristineLogicalSize_}; + pristineLogicalSize_, capture_.outputScale}; const bool writeSource = !sourceWritten_ || !QFile::exists(path); snapshotWatcher_.setFuture(QtConcurrent::run( [source, path, logPath, log, writeSource] { @@ -3428,7 +3429,8 @@ void CaptureEditor::finish(OutputMode mode) { const QImage image = prepareOutputImage( renderCapture(captureCopy, selection, annotations, background, imageShadow, canvasBoundary, backdrop), - captureCopy.monitor.scale); + captureCopy.outputScale > 0.0 ? captureCopy.outputScale + : captureCopy.monitor.scale); if (!image.isNull()) result.thumbnail = image.scaled(kRecentThumbEdge, kRecentThumbEdge, Qt::KeepAspectRatio, @@ -5600,7 +5602,11 @@ void CaptureEditor::adoptStitched(const QImage &image) { } const bool veryLong = image.width() > stitch::kWidelyOpenableEdge || image.height() > stitch::kWidelyOpenableEdge; - adoptImage(image, OperationLog(), SelectTab::Scroll, + // Stitches are edited at 1:1 native pixels. Keep their origin scale only + // for optional logical-size exports, and persist it with the working log. + OperationLog log; + log.outputScale = liveMonitor_.scale; + adoptImage(image, log, SelectTab::Scroll, veryLong ? QStringLiteral("Very long capture (%1 × %2) · edits and " "saves here as usual, but many apps cannot " diff --git a/tests/output-image-smoke.cpp b/tests/output-image-smoke.cpp index 1f1efb91..b1f5ccd4 100644 --- a/tests/output-image-smoke.cpp +++ b/tests/output-image-smoke.cpp @@ -74,6 +74,37 @@ bool runOutputImageSmoke(QApplication &application, QString &error) { 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)}, @@ -140,8 +171,8 @@ bool runOutputImageSmoke(QApplication &application, QString &error) { Qt::IgnoreAspectRatio, Qt::SmoothTransformation); QTest::keyClick(&editor, Qt::Key_Return); editor.waitForExport(); - const QStringList files = QDir(savedDir).entryList({QStringLiteral("*.png")}, - QDir::Files); + 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"); @@ -155,5 +186,63 @@ bool runOutputImageSmoke(QApplication &application, QString &error) { return false; } } + // Scrolling captures keep native editing coordinates, but must remember the + // monitor scale for export, 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 != stitched.size() || + !editor.waitForSnapshot()) { + error = QStringLiteral("Stitched editing coordinates 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); + for (const bool reopen : {false, true}) { + const QString savedDir = directory.filePath( + QStringLiteral("scroll-%1-%2").arg(scale).arg(reopen)); + qputenv("OMASNAP_SCREENSHOT_DIR", savedDir.toUtf8()); + CaptureEditor reopened(restored, CaptureEditor::CaptureMode::File, + QuickOutputMode::None, log); + CaptureEditor &target = reopen ? reopened : editor; + 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; + } + } + } return true; }