From 0616f9b00649e608c2fc9588c1fac3d8caf32c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 15:42:12 -0400 Subject: [PATCH] New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7) --- docs/writing-comp-files.md | 2 + src/IO/ImageExporter.cpp | 33 ++++++++ src/IO/ImageExporter.h | 3 + src/IO/ProjectController.cpp | 3 +- src/IO/ProjectStore.cpp | 9 +- src/IO/ProjectStore.h | 7 +- src/UI/NewCanvasSheet.cpp | 99 +++++++++++++++++++++- src/UI/NewCanvasSheet.h | 15 ++++ tests/NewCanvasSheetTests.cpp | 107 ++++++++++++++++++++++++ tests/ProjectReplaceTests.cpp | 4 + tests/QuickLookPreviewTests.cpp | 144 ++++++++++++++++++++++++++++++++ tests/Tests.cmake | 1 + 12 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 tests/QuickLookPreviewTests.cpp diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md index 57527ce..3f0632f 100644 --- a/docs/writing-comp-files.md +++ b/docs/writing-comp-files.md @@ -83,6 +83,8 @@ To change an existing layer, keep its `id` and overwrite its PNG, then rewrite t Remove images you no longer reference once the manifest no longer lists them. +A project OmaPhoto saved also has a `QuickLook` folder (`Preview.jpg`), which Finder shows as its Space-bar preview on a Mac. When you change a project, delete that folder, so Finder doesn't preview an out-of-date picture; OmaPhoto writes it again the next time it saves. + ## What the open app does - It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one. diff --git a/src/IO/ImageExporter.cpp b/src/IO/ImageExporter.cpp index 311e1dd..bfb2700 100644 --- a/src/IO/ImageExporter.cpp +++ b/src/IO/ImageExporter.cpp @@ -45,6 +45,24 @@ QByteArray encode(QImage image, const char *format, int quality, double resoluti throw ExportError(ExportError::Kind::encode); return data; } + +QByteArray scaledJPEG(const QImage &image, double longSide) +try { + const double scale = std::min(1.0, longSide / std::max(image.width(), image.height())); + const QSize size(std::max(1, int(std::round(image.width() * scale))), std::max(1, int(std::round(image.height() * scale)))); + const QImage scaled = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flattened(size, QImage::Format_RGB888); + if (scaled.isNull() || flattened.isNull()) + throw ExportError(ExportError::Kind::render); + flattened.fill(Qt::white); + { + QPainter painter(&flattened); + painter.drawImage(QRectF(flattened.rect()), scaled); + } + return encode(flattened, "jpeg", 80, 72); +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} } ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {} @@ -118,6 +136,21 @@ try { throw ExportError(ExportError::Kind::render); } +std::optional ImageExporter::quickLookImages(const ProjectSnapshot &snapshot) +{ + if (snapshot.manifest.width * snapshot.manifest.height > 50'000'000) { + qCInfo(lcIO) << "no Quick Look preview for" << snapshot.manifest.width << "x" << snapshot.manifest.height; + return std::nullopt; + } + try { + return QuickLookImages{scaledJPEG(render(snapshot).image, 1024)}; + } catch (const std::runtime_error &error) { + // Swift's try? saves without one; the log says why. + qCWarning(lcIO) << "no Quick Look preview:" << error.what(); + return std::nullopt; + } +} + QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot) { const ExportRaster raster = render(snapshot); diff --git a/src/IO/ImageExporter.h b/src/IO/ImageExporter.h index 330b04e..3976b15 100644 --- a/src/IO/ImageExporter.h +++ b/src/IO/ImageExporter.h @@ -3,6 +3,7 @@ #include #include #include +#include #include class ExportError : public std::runtime_error { @@ -39,6 +40,8 @@ struct JPEGResult { namespace ImageExporter { ExportRaster render(const ProjectSnapshot &snapshot); +// Flattened on white, 1024 a side; none past 50 megapixels. +std::optional quickLookImages(const ProjectSnapshot &snapshot); QByteArray pngData(const ProjectSnapshot &snapshot); // Stops at Swift's three points once `cancelled` says so. JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function &cancelled = {}); diff --git a/src/IO/ProjectController.cpp b/src/IO/ProjectController.cpp index be27a4f..e8731af 100644 --- a/src/IO/ProjectController.cpp +++ b/src/IO/ProjectController.cpp @@ -1,6 +1,7 @@ #include "IO/ProjectController.h" #include "IO/RecentProjects.h" #include "Document/ProjectWorkspace.h" +#include "IO/ImageExporter.h" #include "IO/ProjectStore.h" #include "Logging.h" #include @@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::functionsetFuture(QtConcurrent::run([snapshot, destination]() -> std::optional { try { - ProjectStore::save(snapshot, destination); + ProjectStore::save(snapshot, destination, ImageExporter::quickLookImages(snapshot)); return std::nullopt; } catch (const std::runtime_error &error) { return QString::fromUtf8(error.what()); diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp index 2fb358c..39a6ffa 100644 --- a/src/IO/ProjectStore.cpp +++ b/src/IO/ProjectStore.cpp @@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional version, const QStri { } -void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path) +void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional &quickLook) { validate(snapshot.manifest); std::vector> files; @@ -323,6 +323,13 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path) for (const auto &[filename, data] : files) writeFile(staged + QLatin1String("/images/") + filename, data); syncDirectory(staged + QLatin1String("/images")); + // Quick Look's Space-bar preview; loading ignores it. + if (quickLook) { + if (!QDir().mkdir(staged + QLatin1String("/QuickLook"))) + fail(QStringLiteral("could not create"), staged + QLatin1String("/QuickLook"), QStringLiteral("the folder cannot be made")); + writeFile(staged + QLatin1String("/QuickLook/Preview.jpg"), quickLook->preview); + syncDirectory(staged + QLatin1String("/QuickLook")); + } syncDirectory(staged); replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep); qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images"; diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h index d7dd8ec..2dfa53c 100644 --- a/src/IO/ProjectStore.h +++ b/src/IO/ProjectStore.h @@ -97,9 +97,14 @@ private: ProjectError(Kind kind, std::optional version, const QString &description); }; +// Swift's, in ImageExporter.swift; here, as the exporter includes the store. +struct QuickLookImages { + QByteArray preview; +}; + // A .comp project: a directory holding manifest.json and images/. namespace ProjectStore { // Replaces the project in one step or leaves it untouched. -void save(const ProjectSnapshot &snapshot, const QString &path); +void save(const ProjectSnapshot &snapshot, const QString &path, const std::optional &quickLook = std::nullopt); ProjectSnapshot load(const QString &path); } diff --git a/src/UI/NewCanvasSheet.cpp b/src/UI/NewCanvasSheet.cpp index 00b9312..51bdf0a 100644 --- a/src/UI/NewCanvasSheet.cpp +++ b/src/UI/NewCanvasSheet.cpp @@ -5,9 +5,13 @@ #include #include #include +#include #include +#include +#include #include #include +#include namespace { QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) @@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C label->setForegroundRole(role); return label; } + +// Swift's More button: three dots drawn exactly, flush right. +class MoreButton : public QToolButton { +public: + using QToolButton::QToolButton; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(palette().color(QPalette::WindowText)); + const double dot = 2.5, top = (height() - 5 * dot) / 2; + for (int index = 0; index < 3; ++index) + painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot)); + } +}; } +const std::vector> CanvasPreset::groups = { + { + {QStringLiteral("4K"), 3840, 2160}, + {QStringLiteral("1440p"), 2560, 1440}, + {QStringLiteral("1080p"), 1920, 1080}, + }, + { + {QStringLiteral("iPhone 18 Pro"), 1206, 2622}, + {QStringLiteral("iPhone 18 Pro Max"), 1320, 2868}, + {QStringLiteral("MacBook Pro 14\""), 3024, 1964}, + {QStringLiteral("MacBook Pro 16\""), 3456, 2234}, + {QStringLiteral("Studio Display"), 5120, 2880}, + }, + { + {QStringLiteral("Instagram Square"), 1080, 1080}, + {QStringLiteral("Instagram Portrait"), 1080, 1350}, + {QStringLiteral("Instagram Story"), 1080, 1920}, + {QStringLiteral("YouTube Thumb"), 1080, 608}, + }, +}; + +const std::vector CanvasPreset::all = [] { + std::vector presets; + for (const std::vector &group : groups) + presets.insert(presets.end(), group.begin(), group.end()); + return presets; +}(); + NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function onCreate, std::function onOpen, QWidget *parent) : QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)), m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), - m_create(new QPushButton(QStringLiteral("Create canvas"), this)) + m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this)) { // Swift's fields stretch the sheet to its full 500 points. setFixedWidth(500); @@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::functionsetContentsMargins(28, 28, 28, 28); column->setSpacing(24); - auto *heading = new QVBoxLayout; - heading->setSpacing(6); + auto *heading = new QHBoxLayout; heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this)); - heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this)); + heading->addStretch(1); + heading->addWidget(m_presets); column->addLayout(heading); column->addLayout(fields); column->addWidget(m_note); column->addLayout(buttons); + m_presets->setObjectName(QStringLiteral("presetSizes")); + m_presets->setFixedSize(28, 28); + m_presets->setPopupMode(QToolButton::InstantPopup); + m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats")); + m_presets->setAccessibleName(QStringLiteral("Preset sizes")); + auto *menu = new QMenu(m_presets); + auto *choices = new QActionGroup(menu); + // Custom changes nothing; the match stays checked. + QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom"))); + custom->setCheckable(true); + custom->setData(-1); + connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset); + int index = 0; + for (const std::vector &group : CanvasPreset::groups) { + menu->addSeparator(); + for (const CanvasPreset &preset : group) { + QAction *action = choices->addAction(menu->addAction(preset.title)); + action->setCheckable(true); + action->setData(index++); + connect(action, &QAction::triggered, this, [this, preset] { + m_width->setText(QString::number(preset.width)); + m_height->setText(QString::number(preset.height)); + }); + } + } + m_presets->setMenu(menu); + connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); + connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset); + connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset); // Return anywhere on the sheet is the default action. std::vector returns; for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter}) @@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::functiontext() && QString::number(preset.height) == m_height->text(); + }); + const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin())); + for (QAction *action : m_presets->menu()->actions()) + if (action->data() == index) + action->setChecked(true); } void NewCanvasSheet::synchronize() diff --git a/src/UI/NewCanvasSheet.h b/src/UI/NewCanvasSheet.h index 55ef78c..a147908 100644 --- a/src/UI/NewCanvasSheet.h +++ b/src/UI/NewCanvasSheet.h @@ -4,8 +4,20 @@ #include #include #include +#include #include #include +#include + +// New Canvas sizes in pixels, upright as held. +struct CanvasPreset { + QString title; + int width; + int height; + // Resolutions, Apple screens, then social formats; the menu divides them. + static const std::vector> groups; + static const std::vector all; +}; // The welcome: a new canvas, a project or an image. class NewCanvasSheet : public QWidget { @@ -23,6 +35,8 @@ private: void synchronize(); void validate(); void create(); + // Checks the preset the fields match, else Custom. + void checkPreset(); EditorSession &m_session; const std::function m_onCreate; @@ -30,5 +44,6 @@ private: QLineEdit *const m_height; QLabel *const m_note; QPushButton *const m_create; + QToolButton *const m_presets; bool m_suggestedClipboardSize = false; }; diff --git a/tests/NewCanvasSheetTests.cpp b/tests/NewCanvasSheetTests.cpp index 531f93b..8233c4f 100644 --- a/tests/NewCanvasSheetTests.cpp +++ b/tests/NewCanvasSheetTests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include // The welcome sheet: sizes, its three ways on, the clipboard. @@ -16,6 +17,31 @@ struct Sheet { QLineEdit &height = *sheet.findChild("heightInput"); QLabel ¬e = *sheet.findChild("canvasNote"); QPushButton &create = *sheet.findChild("createCanvas"); + QToolButton &presets = *sheet.findChild("presetSizes"); + + // The menu's entries, a separator as "-". + QStringList entries() const + { + QStringList titles; + for (const QAction *action : presets.menu()->actions()) + titles << (action->isSeparator() ? QStringLiteral("-") : action->text()); + return titles; + } + QString checked() const + { + QStringList titles; + for (const QAction *action : presets.menu()->actions()) + if (action->isChecked()) + titles << action->text(); + return titles.join(','); + } + QAction &entry(const QString &title) const + { + for (QAction *action : presets.menu()->actions()) + if (action->text() == title) + return *action; + throw std::runtime_error("no such preset"); + } }; QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone) @@ -42,6 +68,9 @@ private slots: void aCopiedPicturesSizeIsSuggestedOnce(); void theFirstTabSkipsTheClipboardOnce(); void clipboardSizesComeFromHeadersAlone(); + void presetsAreSwiftsGroups(); + void aPresetFillsTheFieldsAndTheMatchIsChecked(); + void theMoreButtonDrawsThreeDotsAndOpensTheMenu(); }; void NewCanvasSheetTests::cleanup() @@ -227,5 +256,83 @@ void NewCanvasSheetTests::clipboardSizesComeFromHeadersAlone() QCOMPARE(NewCanvasSheet::clipboardDimensions(&turned), std::optional(QSize(50, 30))); } +void NewCanvasSheetTests::presetsAreSwiftsGroups() +{ + QStringList sizes; + for (const std::vector &group : CanvasPreset::groups) { + for (const CanvasPreset &preset : group) + sizes << QStringLiteral("%1 %2x%3").arg(preset.title).arg(preset.width).arg(preset.height); + sizes << "-"; + } + QCOMPARE(sizes, (QStringList{"4K 3840x2160", "1440p 2560x1440", "1080p 1920x1080", "-", "iPhone 18 Pro 1206x2622", "iPhone 18 Pro Max 1320x2868", + "MacBook Pro 14\" 3024x1964", "MacBook Pro 16\" 3456x2234", "Studio Display 5120x2880", "-", "Instagram Square 1080x1080", + "Instagram Portrait 1080x1350", "Instagram Story 1080x1920", "YouTube Thumb 1080x608", "-"})); + QCOMPARE(CanvasPreset::all.size(), size_t(12)); + QCOMPARE(CanvasPreset::all.back().title, QString("YouTube Thumb")); + Sheet fixture; + QCOMPARE(fixture.entries(), (QStringList{"Custom", "-", "4K", "1440p", "1080p", "-", "iPhone 18 Pro", "iPhone 18 Pro Max", "MacBook Pro 14\"", + "MacBook Pro 16\"", "Studio Display", "-", "Instagram Square", "Instagram Portrait", "Instagram Story", + "YouTube Thumb"})); + QCOMPARE(fixture.presets.toolTip(), QString("Preset sizes for screens and common formats")); + QCOMPARE(fixture.presets.accessibleName(), QString("Preset sizes")); + QCOMPARE(fixture.presets.size(), QSize(28, 28)); + QCOMPARE(fixture.presets.popupMode(), QToolButton::InstantPopup); +} + +void NewCanvasSheetTests::aPresetFillsTheFieldsAndTheMatchIsChecked() +{ + Sheet fixture; + QCOMPARE(fixture.checked(), QString("1080p")); + fixture.entry("Instagram Story").trigger(); + QCOMPARE(fixture.width.text(), QString("1080")); + QCOMPARE(fixture.height.text(), QString("1920")); + QCOMPARE(fixture.checked(), QString("Instagram Story")); + fixture.entry("Studio Display").trigger(); + QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("5120x2880")); + QCOMPARE(fixture.checked(), QString("Studio Display")); + // A size no preset has is Custom, matched as text. + fixture.height.setText("2881"); + QCOMPARE(fixture.checked(), QString("Custom")); + fixture.height.setText("2880"); + QCOMPARE(fixture.checked(), QString("Studio Display")); + fixture.width.setText("05120"); + QCOMPARE(fixture.checked(), QString("Custom")); + fixture.width.setText("1080"); + fixture.height.setText("608"); + QCOMPARE(fixture.checked(), QString("YouTube Thumb")); + // Custom fills nothing in. + fixture.entry("Custom").trigger(); + QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("1080x608")); + QCOMPARE(fixture.checked(), QString("YouTube Thumb")); + fixture.entry("4K").trigger(); + QCOMPARE(fixture.checked(), QString("4K")); + QVERIFY(fixture.create.isEnabled()); + QTest::mouseClick(&fixture.create, Qt::LeftButton); + QCOMPARE(fixture.created, (QList{QSize(3840, 2160)})); +} + +void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + // Flush with the content's right edge, 28 points in. + QCOMPARE(fixture.presets.geometry().right() + 1, 500 - 28); + const QImage image = fixture.presets.grab().toImage(); + const QColor ground = image.pixelColor(2, 2), ink = fixture.presets.palette().color(QPalette::WindowText); + // Dots centred at x 26.75, y 9, 14 and 19. + for (const int y : {9, 14, 19}) + QVERIFY2(std::abs(qGray(image.pixel(26, y)) - qGray(ink.rgb())) < std::abs(qGray(image.pixel(26, y)) - qGray(ground.rgb())), qPrintable(QString::number(y))); + for (const QPoint clear : {QPoint(26, 11), QPoint(26, 16), QPoint(26, 5), QPoint(26, 23), QPoint(22, 14), QPoint(14, 14)}) + QCOMPARE(image.pixelColor(clear), ground); + bool shown = false; + QTimer::singleShot(0, fixture.presets.menu(), [&] { + shown = fixture.presets.menu()->isVisible(); + fixture.presets.menu()->close(); + }); + QTest::mouseClick(&fixture.presets, Qt::LeftButton); + QVERIFY(shown); +} + QTEST_MAIN(NewCanvasSheetTests) #include "NewCanvasSheetTests.moc" diff --git a/tests/ProjectReplaceTests.cpp b/tests/ProjectReplaceTests.cpp index c5bdd4c..da2a0ff 100644 --- a/tests/ProjectReplaceTests.cpp +++ b/tests/ProjectReplaceTests.cpp @@ -119,6 +119,10 @@ void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap() ProjectStore::save(twoLayers(), path); QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"})); QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"}); + // The preview and its folder, before the project's folder. + faults.trace.clear(); + ProjectStore::save(twoLayers(), path, QuickLookImages{"preview"}); + QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync file", "sync folder", "sync folder", "exchange", "sync folder"})); } void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data() diff --git a/tests/QuickLookPreviewTests.cpp b/tests/QuickLookPreviewTests.cpp new file mode 100644 index 0000000..4a4854b --- /dev/null +++ b/tests/QuickLookPreviewTests.cpp @@ -0,0 +1,144 @@ +#include "DialogDesk.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectController.h" +#include "ProjectFixtures.h" +#include +#include +#include +#include +#include +#include + +// The project's QuickLook/Preview.jpg: written on save, ignored on load. +namespace { +const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden; + +// One layer stretched over a canvas of this size. +ProjectSnapshot stretched(int width, int height, const QImage &pixels) +{ + const QUuid id = QUuid::createUuid(); + const ProjectLayerRecord layer{.id = id, .name = "Stretched", .isVisible = true, + .transform = {.origin = {0, 0}, .size = {double(width), double(height)}}, .imageFile = uuidString(id) + ".png"}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = id, .layers = {layer}}, + .images = {{id, asset(pixels, "source")}}}; +} + +QImage decoded(const QByteArray &data) +{ + QImage image = QImage::fromData(data, "jpeg"); + if (image.isNull()) + throw std::runtime_error("the preview is no JPEG"); + return image; +} + +QSize previewSize(int width, int height) +{ + QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied); + empty.fill(0); + return decoded(ImageExporter::quickLookImages(stretched(width, height, empty)).value().preview).size(); +} +} + +class QuickLookPreviewTests : public QObject { + Q_OBJECT +private slots: + void theStoreWritesThePreviewAndLoadingIgnoresIt(); + void thePreviewIsTheImageOnWhiteAtQualityEighty(); + void theLongSideIsAt1024AndNothingGrows(); + void pastFiftyMegapixelsThereIsNone(); + void aCanvasThatCannotRenderSavesWithoutOne(); + void everySaveWritesIt(); +}; + +void QuickLookPreviewTests::theStoreWritesThePreviewAndLoadingIgnoresIt() +{ + QTemporaryDir root; + const QString path = root.filePath("Previewed.comp"); + const ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, path, QuickLookImages{"not even a JPEG"}); + QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json", "QuickLook"})); + QCOMPARE(QDir(path + "/QuickLook").entryList(everything), QStringList{"Preview.jpg"}); + QCOMPARE(contents(path + "/QuickLook/Preview.jpg"), QByteArray("not even a JPEG")); + // Its bytes are never read. + QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded()); + // Saved without one, the project has none. + ProjectStore::save(snapshot, path); + QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json"})); + QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded()); +} + +void QuickLookPreviewTests::thePreviewIsTheImageOnWhiteAtQualityEighty() +{ + // Half opaque red, half clear, over 2048 by 1024. + const ProjectSnapshot snapshot = stretched(2048, 1024, halfRed()); + const QByteArray preview = ImageExporter::quickLookImages(snapshot).value().preview; + const QImage image = decoded(preview); + QCOMPARE(image.size(), QSize(1024, 512)); + QVERIFY(qAbs(image.pixelColor(200, 256).red() - 255) <= 3 && image.pixelColor(200, 256).green() <= 3); + QCOMPARE(image.pixelColor(800, 256), QColor(Qt::white)); + // Byte for byte: smooth scaling, white, quality 80, 72 dpi. + const QImage scaled = ImageExporter::render(snapshot).image.scaled(1024, 512, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flattened(1024, 512, QImage::Format_RGB888); + flattened.fill(Qt::white); + QPainter(&flattened).drawImage(QRectF(flattened.rect()), scaled); + flattened.setDotsPerMeterX(2835); + flattened.setDotsPerMeterY(2835); + flattened.setColorSpace(QColorSpace::SRgb); + QByteArray expected; + QBuffer buffer(&expected); + QImageWriter writer(&buffer, "jpeg"); + writer.setQuality(80); + QVERIFY(writer.write(flattened)); + QCOMPARE(preview, expected); +} + +void QuickLookPreviewTests::theLongSideIsAt1024AndNothingGrows() +{ + QCOMPARE(previewSize(300, 200), QSize(300, 200)); + QCOMPARE(previewSize(1024, 7), QSize(1024, 7)); + QCOMPARE(previewSize(1025, 1025), QSize(1024, 1024)); + // 1001 × 1024 / 3000 is 341.67: rounded, not cut. + QCOMPARE(previewSize(3000, 1001), QSize(1024, 342)); + QCOMPARE(previewSize(10, 5000), QSize(2, 1024)); + // A sliver keeps a pixel. + QCOMPARE(previewSize(1, 3000), QSize(1, 1024)); + QCOMPARE(previewSize(3000, 1), QSize(1024, 1)); +} + +void QuickLookPreviewTests::pastFiftyMegapixelsThereIsNone() +{ + QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied); + empty.fill(0); + QVERIFY(!ImageExporter::quickLookImages(stretched(7072, 7071, empty))); + QCOMPARE(decoded(ImageExporter::quickLookImages(stretched(10'000, 5'000, empty)).value().preview).size(), QSize(1024, 512)); +} + +void QuickLookPreviewTests::aCanvasThatCannotRenderSavesWithoutOne() +{ + ProjectSnapshot snapshot = twoLayers(); + snapshot.images.clear(); + QTest::ignoreMessage(QtWarningMsg, "no Quick Look preview: An image inside the project is missing or damaged. The current document has not been replaced."); + QVERIFY(!ImageExporter::quickLookImages(snapshot)); +} + +void QuickLookPreviewTests::everySaveWritesIt() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(8, 6); + QImage image(8, 6, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::blue); + session.insert(ImportedImage(image, QImage(), "Blue")); + ProjectController controller(session); + DialogDesk desk; + desk.replies = {folder.filePath("Saved")}; + bool saved = false; + controller.save(false, [&](bool done) { saved = done; }); + QTRY_VERIFY(saved); + const QImage preview = decoded(contents(folder.filePath("Saved.comp/QuickLook/Preview.jpg"))); + QCOMPARE(preview.size(), QSize(8, 6)); + QVERIFY(preview.pixelColor(4, 3).blue() >= 250 && preview.pixelColor(4, 3).red() <= 5); +} + +QTEST_MAIN(QuickLookPreviewTests) +#include "QuickLookPreviewTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..ea6e3a5 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -203,6 +203,7 @@ compositor_test(KeyboardZoomTests) add_dependencies(ProjectWorkspaceViewTests omaphoto) compositor_test(ProjectSessionTests) compositor_test(ProjectTests) +compositor_test(QuickLookPreviewTests) compositor_test(RasterSnapshotSessionTests) compositor_test(RasterSnapshotTests) compositor_test(SessionGateTests)