New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7)
This commit is contained in:
1 parent
765e071c0c
commit
0616f9b006
12 files changed
+420
-7
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<QuickLookImages> 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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QByteArray>
|
||||
#include <QImage>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
|
||||
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> 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<bool()> &cancelled = {});
|
||||
|
||||
@@ -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 <QFileDialog>
|
||||
@@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::function<void(bool)
|
||||
// The store blocks on the disk: off the UI thread.
|
||||
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
|
||||
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());
|
||||
|
||||
@@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QStri
|
||||
{
|
||||
}
|
||||
|
||||
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
|
||||
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook)
|
||||
{
|
||||
validate(snapshot.manifest);
|
||||
std::vector<std::pair<QString, QByteArray>> 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";
|
||||
|
||||
@@ -97,9 +97,14 @@ private:
|
||||
ProjectError(Kind kind, std::optional<qint64> 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<QuickLookImages> &quickLook = std::nullopt);
|
||||
ProjectSnapshot load(const QString &path);
|
||||
}
|
||||
@@ -5,9 +5,13 @@
|
||||
#include <QBuffer>
|
||||
#include <QClipboard>
|
||||
#include <QGridLayout>
|
||||
#include <QActionGroup>
|
||||
#include <QImageReader>
|
||||
#include <QMenu>
|
||||
#include <QPainter>
|
||||
#include <QShortcut>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
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<std::vector<CanvasPreset>> 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> CanvasPreset::all = [] {
|
||||
std::vector<CanvasPreset> presets;
|
||||
for (const std::vector<CanvasPreset> &group : groups)
|
||||
presets.insert(presets.end(), group.begin(), group.end());
|
||||
return presets;
|
||||
}();
|
||||
|
||||
NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> 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::function<void(int, i
|
||||
auto *column = new QVBoxLayout(this);
|
||||
column->setContentsMargins(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<CanvasPreset> &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<QShortcut *> returns;
|
||||
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
|
||||
@@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
|
||||
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
|
||||
synchronize();
|
||||
validate();
|
||||
checkPreset();
|
||||
}
|
||||
|
||||
void NewCanvasSheet::checkPreset()
|
||||
{
|
||||
const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) {
|
||||
return QString::number(preset.width) == m_width->text() && 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()
|
||||
|
||||
@@ -4,8 +4,20 @@
|
||||
#include <QLineEdit>
|
||||
#include <QMimeData>
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
// 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<std::vector<CanvasPreset>> groups;
|
||||
static const std::vector<CanvasPreset> 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<void(int, int)> 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;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QClipboard>
|
||||
#include <QImageReader>
|
||||
#include <QImageWriter>
|
||||
#include <QMenu>
|
||||
#include <QtTest>
|
||||
|
||||
// The welcome sheet: sizes, its three ways on, the clipboard.
|
||||
@@ -16,6 +17,31 @@ struct Sheet {
|
||||
QLineEdit &height = *sheet.findChild<QLineEdit *>("heightInput");
|
||||
QLabel ¬e = *sheet.findChild<QLabel *>("canvasNote");
|
||||
QPushButton &create = *sheet.findChild<QPushButton *>("createCanvas");
|
||||
QToolButton &presets = *sheet.findChild<QToolButton *>("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<CanvasPreset> &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>{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"
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "DialogDesk.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include <QBuffer>
|
||||
#include <QColorSpace>
|
||||
#include <QImageReader>
|
||||
#include <QImageWriter>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
// 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"
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user