OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,400 @@
|
||||
#include "AddressSpaceLimit.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/LayerMask.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ImageResizer.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include <QBuffer>
|
||||
#include <QImageReader>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
namespace {
|
||||
// A 64x32 layer, red left, clear right, filling its document.
|
||||
ProjectSnapshot imported()
|
||||
{
|
||||
const QUuid id = QUuid::createUuid();
|
||||
const ProjectLayerRecord record{.id = id, .name = "fixture", .isVisible = true, .transform = {.origin = {0, 0}, .size = {64, 32}},
|
||||
.imageFile = uuidString(id) + ".png"};
|
||||
return {.manifest = {.documentID = QUuid::createUuid(), .width = 64, .height = 32, .activeLayerID = id, .layers = {record}},
|
||||
.images = {{id, asset(halfRed(), "fixture")}}};
|
||||
}
|
||||
|
||||
std::optional<ProjectError::Kind> resizeError(const ProjectSnapshot &snapshot, const ImageSizeOptions &options)
|
||||
{
|
||||
return projectError([&] { ImageResizer::resize(snapshot, options); });
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSizeTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void resizePreservesLayerIdentityAndUndoRestoresSource();
|
||||
void resolutionOnlyRetainsPixelsAndSurvivesSaveAndExport();
|
||||
void rotatedHiddenLayerScalesInDocumentAxesAndInvalidSizeIsRejected();
|
||||
void limitsAreJudgedAtTheirEdges();
|
||||
void budgetsCountEveryLayer();
|
||||
void missingPixelsAreRefused();
|
||||
void masksScaleWithTheirLayers();
|
||||
void solidAndPlacedMasksKeepTheirPixels();
|
||||
void recordsKeepEveryField_data();
|
||||
void recordsKeepEveryField();
|
||||
void thumbnailsTruncate_data();
|
||||
void thumbnailsTruncate();
|
||||
void aSurfaceThatCannotBeAllocatedThrows();
|
||||
};
|
||||
|
||||
void ImageSizeTests::resizePreservesLayerIdentityAndUndoRestoresSource()
|
||||
{
|
||||
const ProjectSnapshot input = imported();
|
||||
const QUuid id = input.manifest.layers[0].id;
|
||||
QTest::ignoreMessage(QtInfoMsg, "resized 64 x 32 to 128 x 96");
|
||||
const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 300, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(result.manifest.width, qint64(128));
|
||||
QCOMPARE(result.manifest.height, qint64(96));
|
||||
QCOMPARE(result.manifest.resolution, std::optional(300.0));
|
||||
QCOMPARE(result.manifest.documentID, input.manifest.documentID);
|
||||
QCOMPARE(result.manifest.activeLayerID, std::optional(id));
|
||||
const ProjectLayerRecord &layer = result.manifest.layers[0];
|
||||
QCOMPARE(layer.id, id);
|
||||
QCOMPARE(layer.transform, (LayerTransform{.origin = {0, 0}, .size = {128, 96}, .sampling = LayerSampling::nearest}));
|
||||
const ImportedImage &scaled = result.images.at(id);
|
||||
QCOMPARE(scaled.name, QString("fixture"));
|
||||
QCOMPARE(scaled.size(), QSize(128, 96));
|
||||
QCOMPARE(scaled.image().format(), QImage::Format_RGBA8888_Premultiplied);
|
||||
// Nearest keeps the edge hard: red to 63, clear after.
|
||||
QCOMPARE(scaled.image().pixelColor(0, 0), QColor(255, 0, 0));
|
||||
QCOMPARE(scaled.image().pixelColor(63, 95), QColor(255, 0, 0));
|
||||
QCOMPARE(scaled.image().pixelColor(64, 0).alpha(), 0);
|
||||
QCOMPARE(scaled.image().pixelColor(127, 0).alpha(), 0);
|
||||
// The source is untouched.
|
||||
QCOMPARE(input.images.at(id).size(), QSize(64, 32));
|
||||
// In the session: one step; undo gives the source back.
|
||||
EditorSession session;
|
||||
session.installProject(input, "fixture.comp");
|
||||
const std::optional<CanvasDocument> original = session.document();
|
||||
session.applyImageSize(result);
|
||||
QCOMPARE(session.document().value().size(), QSizeF(128, 96));
|
||||
QCOMPARE(session.document().value().resolution, 300.0);
|
||||
QCOMPARE(session.activeLayerID(), std::optional(id));
|
||||
QCOMPARE(session.document().value().layers.front().asset.value().size(), QSize(128, 96));
|
||||
QCOMPARE(session.history.undoName(), QString("Image Size"));
|
||||
session.undo();
|
||||
QCOMPARE(session.document(), original);
|
||||
QVERIFY(session.document().value().layers.front().asset.value().identity() == input.images.at(id).identity());
|
||||
session.redo();
|
||||
QCOMPARE(session.document().value().resolution, 300.0);
|
||||
// One side alone is a resize too.
|
||||
QCOMPARE(ImageResizer::resize(input, {.width = 128, .height = 32, .resolution = 72}).images.at(id).size(), QSize(128, 32));
|
||||
QCOMPARE(ImageResizer::resize(input, {.width = 64, .height = 48, .resolution = 72}).images.at(id).size(), QSize(64, 48));
|
||||
}
|
||||
|
||||
void ImageSizeTests::resolutionOnlyRetainsPixelsAndSurvivesSaveAndExport()
|
||||
{
|
||||
ProjectSnapshot before = imported();
|
||||
before.manifest.layers[0].transform = {.origin = {3.5, -2}, .size = {40, 20}, .rotation = 30, .flipX = true, .sampling = LayerSampling::smooth};
|
||||
const QUuid id = before.manifest.layers[0].id;
|
||||
before.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||||
before.masks.insert({id, LayerMask::assetFrom(gray(64, 32, 200))});
|
||||
const ProjectSnapshot resized = ImageResizer::resize(before, {.width = 64, .height = 32, .resolution = 300});
|
||||
QCOMPARE(resized.manifest.layers[0].transform, before.manifest.layers[0].transform);
|
||||
QCOMPARE(resized.images.at(id).identity(), before.images.at(id).identity());
|
||||
// The mask and its record come through untouched too.
|
||||
QCOMPARE(resized.masks.at(id).identity(), before.masks.at(id).identity());
|
||||
QCOMPARE(resized.manifest.layers[0].maskFile, before.manifest.layers[0].maskFile);
|
||||
QCOMPARE(resized.manifest.resolution, std::optional(300.0));
|
||||
// In the session a new resolution alone is one step.
|
||||
EditorSession session;
|
||||
session.installProject(before, "fixture.comp");
|
||||
QCOMPARE(session.document().value().resolution, 72.0);
|
||||
session.applyImageSize(resized);
|
||||
QCOMPARE(session.document().value().resolution, 300.0);
|
||||
QCOMPARE(session.document().value().size(), QSizeF(64, 32));
|
||||
QCOMPARE(session.history.undoCount(), 1);
|
||||
session.undo();
|
||||
QCOMPARE(session.document().value().resolution, 72.0);
|
||||
QTemporaryDir folder;
|
||||
ProjectStore::save(resized, folder.filePath("Size.comp"));
|
||||
const ProjectSnapshot loaded = ProjectStore::load(folder.filePath("Size.comp"));
|
||||
QCOMPARE(loaded.manifest.resolution, std::optional(300.0));
|
||||
QByteArray png = ImageExporter::pngData(loaded);
|
||||
QBuffer buffer(&png);
|
||||
QImageReader reader(&buffer);
|
||||
// 300 pixels an inch is 11,811 dots a metre.
|
||||
QCOMPARE(reader.read().dotsPerMeterX(), 11'811);
|
||||
}
|
||||
|
||||
void ImageSizeTests::rotatedHiddenLayerScalesInDocumentAxesAndInvalidSizeIsRejected()
|
||||
{
|
||||
ProjectSnapshot input = imported();
|
||||
ProjectLayerRecord &record = input.manifest.layers[0];
|
||||
record.isVisible = false;
|
||||
record.transform = {.origin = {-16, 4}, .size = {64, 32}, .rotation = 90};
|
||||
const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
const ProjectLayerRecord &output = result.manifest.layers[0];
|
||||
QVERIFY(!output.isVisible);
|
||||
// Turned: 32x64 at (0, -12); Swift allows a pixel.
|
||||
QCOMPARE(output.transform.rotation, 0.0);
|
||||
QVERIFY(qAbs(output.transform.size.width() - 64) <= 1 && qAbs(output.transform.size.height() - 192) <= 1);
|
||||
QVERIFY(qAbs(output.transform.origin.x()) <= 1 && qAbs(output.transform.origin.y() + 36) <= 1);
|
||||
const QImage image = result.images.at(record.id).image();
|
||||
QCOMPARE(QSizeF(image.size()), output.transform.size);
|
||||
// A quarter turn clockwise: the red half is on top.
|
||||
QCOMPARE(image.pixelColor(32, 10), QColor(255, 0, 0));
|
||||
QCOMPARE(image.pixelColor(32, 90), QColor(255, 0, 0));
|
||||
QCOMPARE(image.pixelColor(32, 102).alpha(), 0);
|
||||
// Off the right angles the box is exact: floor, ceil.
|
||||
record.transform = {.origin = {10, 10}, .size = {40, 20}, .rotation = 30};
|
||||
const ProjectSnapshot leaning = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72});
|
||||
QCOMPARE(leaning.manifest.layers[0].transform, (LayerTransform{.origin = {15, 4}, .size = {90, 112}}));
|
||||
QCOMPARE(resizeError(input, {.width = 30'000, .height = 30'000, .resolution = 72}), std::optional(ProjectError::Kind::tooLarge));
|
||||
}
|
||||
|
||||
void ImageSizeTests::limitsAreJudgedAtTheirEdges()
|
||||
{
|
||||
ProjectSnapshot input = imported();
|
||||
const auto tooLarge = std::optional(ProjectError::Kind::tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 0, .height = 10, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge);
|
||||
for (const double wild : {0.99, 9600.01, std::nan(""), double(INFINITY)})
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = wild}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = 1}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 10, .resolution = 9600}), std::nullopt);
|
||||
// An empty document scales to the limits for nothing.
|
||||
input.manifest.layers.clear();
|
||||
input.images.clear();
|
||||
// No layer's own rule stands in for the document's here.
|
||||
QCOMPARE(resizeError(input, {.width = 0, .height = 10, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 0, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 30'001, .height = 10, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10, .height = 30'001, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'001, .resolution = 72}), tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 30'000, .height = 1, .resolution = 72}), std::nullopt);
|
||||
QCOMPARE(resizeError(input, {.width = 1, .height = 30'000, .resolution = 72}), std::nullopt);
|
||||
// The same size skips every other rule: only resolution changes.
|
||||
ProjectSnapshot wide = imported();
|
||||
wide.manifest.width = 20'000;
|
||||
wide.manifest.height = 20'000;
|
||||
QCOMPARE(ImageResizer::resize(wide, {.width = 20'000, .height = 20'000, .resolution = 144}).manifest.resolution, std::optional(144.0));
|
||||
}
|
||||
|
||||
void ImageSizeTests::budgetsCountEveryLayer()
|
||||
{
|
||||
// Scaled a hundredfold, the small layer takes a megapixel.
|
||||
ProjectSnapshot input = imported();
|
||||
input.manifest.width = 100;
|
||||
input.manifest.height = 100;
|
||||
ProjectLayerRecord small = input.manifest.layers[0];
|
||||
small.transform = {.origin = {0, 0}, .size = {10, 10}};
|
||||
ProjectLayerRecord whole = small;
|
||||
whole.id = QUuid::createUuid();
|
||||
whole.imageFile = uuidString(whole.id) + ".png";
|
||||
whole.transform = {.origin = {0, 0}, .size = {100, 100}};
|
||||
input.images.insert({whole.id, asset(halfRed(), "whole")});
|
||||
input.manifest.layers = {small, whole};
|
||||
const auto tooLarge = std::optional(ProjectError::Kind::tooLarge);
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
// A megapixel and 99 more fill the budget exactly.
|
||||
input.manifest.layers[1].transform.size = {99, 100};
|
||||
const ProjectSnapshot full = ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(full.images.at(whole.id).size(), QSize(9'900, 10'000));
|
||||
input.manifest.layers[1].transform.size = {99.01, 100};
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
// Masks have their own budget, counted the same way.
|
||||
input.manifest.layers = {small, whole};
|
||||
for (ProjectLayerRecord &layer : input.manifest.layers) {
|
||||
layer.imageFile = std::nullopt;
|
||||
layer.maskFile = uuidString(layer.id) + ".mask.png";
|
||||
input.masks.insert({layer.id, LayerMask::assetFrom(gray(4, 4, 200))});
|
||||
}
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
input.manifest.layers[1].transform.size = {99, 100};
|
||||
QCOMPARE(ImageResizer::resize(input, {.width = 10'000, .height = 10'000, .resolution = 72, .sampling = LayerSampling::nearest})
|
||||
.masks.at(whole.id).size(), QSize(9'900, 10'000));
|
||||
input.manifest.layers[1].transform.size = {99.01, 100};
|
||||
QCOMPARE(resizeError(input, {.width = 10'000, .height = 10'000, .resolution = 72}), tooLarge);
|
||||
// A side past 30,000 is refused though the pixels fit.
|
||||
ProjectSnapshot strip = imported();
|
||||
strip.manifest.layers[0].transform = {.origin = {0, 0}, .size = {64, 1}};
|
||||
strip.manifest.layers.push_back(strip.manifest.layers[0]);
|
||||
QCOMPARE(resizeError(strip, {.width = 30'000, .height = 32, .resolution = 72}), std::nullopt);
|
||||
strip.manifest.layers[0].transform.size = {64.01, 1};
|
||||
QCOMPARE(resizeError(strip, {.width = 30'000, .height = 32, .resolution = 72}), tooLarge);
|
||||
// 300 times 100.003 is 30,000.9: one pixel past, either way.
|
||||
ProjectSnapshot edge = imported();
|
||||
edge.manifest.width = 100;
|
||||
edge.manifest.height = 100;
|
||||
edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {100.003, 1}};
|
||||
QCOMPARE(resizeError(edge, {.width = 30'000, .height = 100, .resolution = 72}), tooLarge);
|
||||
edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {1, 100.003}};
|
||||
QCOMPARE(resizeError(edge, {.width = 100, .height = 30'000, .resolution = 72}), tooLarge);
|
||||
edge.manifest.layers[0].transform = {.origin = {0, 0}, .size = {1, 100}};
|
||||
QCOMPARE(resizeError(edge, {.width = 100, .height = 30'000, .resolution = 72}), std::nullopt);
|
||||
}
|
||||
|
||||
void ImageSizeTests::missingPixelsAreRefused()
|
||||
{
|
||||
ProjectSnapshot input = imported();
|
||||
input.images.clear();
|
||||
QCOMPARE(resizeError(input, {.width = 128, .height = 64, .resolution = 72}), std::optional(ProjectError::Kind::missingImage));
|
||||
input = imported();
|
||||
input.manifest.layers[0].maskFile = "mask.png";
|
||||
QCOMPARE(resizeError(input, {.width = 128, .height = 64, .resolution = 72}), std::optional(ProjectError::Kind::missingImage));
|
||||
// A layer scaled out of reach fails its own rule.
|
||||
input = imported();
|
||||
input.manifest.layers[0].transform.origin = {900'000, 0};
|
||||
QCOMPARE(resizeError(input, {.width = 128, .height = 32, .resolution = 72}), std::optional(ProjectError::Kind::tooLarge));
|
||||
}
|
||||
|
||||
void ImageSizeTests::masksScaleWithTheirLayers()
|
||||
{
|
||||
ProjectSnapshot input = imported();
|
||||
const QUuid id = input.manifest.layers[0].id;
|
||||
input.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||||
// White on the left half, 100 on the right.
|
||||
QImage mask = gray(64, 32, 100);
|
||||
for (int y = 0; y < 32; ++y)
|
||||
std::fill_n(mask.scanLine(y), 32, uchar(255));
|
||||
input.masks.insert({id, LayerMask::assetFrom(mask)});
|
||||
const ProjectSnapshot result = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
const QImage scaled = result.masks.at(id).image();
|
||||
QCOMPARE(scaled.format(), QImage::Format_Grayscale8);
|
||||
QCOMPARE(scaled.size(), QSize(128, 96));
|
||||
QCOMPARE(int(scaled.constScanLine(0)[0]), 255);
|
||||
QCOMPARE(int(scaled.constScanLine(95)[63]), 255);
|
||||
QCOMPARE(int(scaled.constScanLine(0)[64]), 100);
|
||||
QCOMPARE(int(scaled.constScanLine(95)[127]), 100);
|
||||
QCOMPARE(result.manifest.layers[0].maskFile, input.manifest.layers[0].maskFile);
|
||||
// Turned, the box's corners hold no mask: coverage zero.
|
||||
input.manifest.layers[0].transform.rotation = 45;
|
||||
const QImage turned = ImageResizer::resize(input, {.width = 128, .height = 64, .resolution = 72}).masks.at(id).image();
|
||||
QCOMPARE(int(turned.constScanLine(0)[0]), 0);
|
||||
QCOMPARE(int(turned.constScanLine(turned.height() / 2)[turned.width() / 2 - 8]), 255);
|
||||
}
|
||||
|
||||
void ImageSizeTests::solidAndPlacedMasksKeepTheirPixels()
|
||||
{
|
||||
ProjectSnapshot input = imported();
|
||||
const QUuid id = input.manifest.layers[0].id;
|
||||
input.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||||
input.masks.insert({id, LayerMask::solid(false).asset});
|
||||
const ProjectSnapshot solid = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72});
|
||||
QCOMPARE(solid.masks.at(id).identity(), input.masks.at(id).identity());
|
||||
// A mask placed apart keeps its pixels; its placement scales.
|
||||
input.masks.clear();
|
||||
input.masks.insert({id, LayerMask::assetFrom(gray(30, 15, 200))});
|
||||
input.manifest.layers[0].maskPlacement = LayerTransform{.origin = {10, 4}, .size = {30, 15}, .flipX = true};
|
||||
const ProjectSnapshot placed = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72});
|
||||
QCOMPARE(placed.masks.at(id).identity(), input.masks.at(id).identity());
|
||||
const LayerTransform placement = placed.manifest.layers[0].maskPlacement.value();
|
||||
QCOMPARE(placement.origin, QPointF(20, 12));
|
||||
QCOMPARE(placement.size, QSizeF(60, 45));
|
||||
QVERIFY(placement.flipX);
|
||||
}
|
||||
|
||||
void ImageSizeTests::recordsKeepEveryField_data()
|
||||
{
|
||||
QTest::addColumn<bool>("enabled");
|
||||
QTest::addColumn<bool>("linked");
|
||||
QTest::newRow("a disabled, linked mask") << false << true;
|
||||
QTest::newRow("an enabled, unlinked mask") << true << false;
|
||||
}
|
||||
|
||||
void ImageSizeTests::recordsKeepEveryField()
|
||||
{
|
||||
QFETCH(bool, enabled);
|
||||
QFETCH(bool, linked);
|
||||
ProjectSnapshot input = imported();
|
||||
const QUuid folder = QUuid::createUuid(), source = QUuid::createUuid();
|
||||
ProjectLayerRecord &layer = input.manifest.layers[0];
|
||||
layer.parentID = folder;
|
||||
layer.opacity = 0.25;
|
||||
layer.blendMode = LayerBlendMode::overlay;
|
||||
layer.maskFile = "mask.png";
|
||||
layer.maskEnabled = enabled;
|
||||
layer.maskSourceID = source;
|
||||
layer.maskLinked = linked;
|
||||
input.masks.insert({layer.id, LayerMask::solid(true).asset});
|
||||
const std::optional<QString> imageFile = layer.imageFile;
|
||||
// A folder has no pixels; its box still scales.
|
||||
input.manifest.layers.push_back({.id = folder, .name = "Folder", .isVisible = true, .transform = {.origin = {8, 4}, .size = {16, 8}},
|
||||
.imageFile = std::nullopt, .isGroup = true});
|
||||
const ProjectSnapshot output = ImageResizer::resize(input, {.width = 128, .height = 96, .resolution = 72});
|
||||
const ProjectLayerRecord &kept = output.manifest.layers[0];
|
||||
QCOMPARE(kept.name, QString("fixture"));
|
||||
QCOMPARE(kept.imageFile, imageFile);
|
||||
QCOMPARE(kept.parentID, std::optional(folder));
|
||||
QCOMPARE(kept.isGroup, std::nullopt);
|
||||
QCOMPARE(kept.opacity, std::optional(0.25));
|
||||
QCOMPARE(kept.blendMode, std::optional(LayerBlendMode::overlay));
|
||||
QCOMPARE(kept.maskFile, std::optional(QString("mask.png")));
|
||||
QCOMPARE(kept.maskEnabled, std::optional(enabled));
|
||||
QCOMPARE(kept.maskSourceID, std::optional(source));
|
||||
QCOMPARE(kept.maskLinked, std::optional(linked));
|
||||
QCOMPARE(kept.maskPlacement, std::nullopt);
|
||||
const ProjectLayerRecord &group = output.manifest.layers[1];
|
||||
QCOMPARE(group.isGroup, std::optional(true));
|
||||
QCOMPARE(group.transform, (LayerTransform{.origin = {16, 12}, .size = {32, 24}}));
|
||||
QCOMPARE(int(output.images.size()), 1);
|
||||
}
|
||||
|
||||
void ImageSizeTests::thumbnailsTruncate_data()
|
||||
{
|
||||
QTest::addColumn<QSize>("size");
|
||||
QTest::addColumn<QSize>("thumbnail");
|
||||
QTest::newRow("small layers keep their size") << QSize(96, 40) << QSize(96, 40);
|
||||
QTest::newRow("smaller still, never enlarged") << QSize(48, 20) << QSize(48, 20);
|
||||
QTest::newRow("28.8 loses its fraction") << QSize(100, 30) << QSize(96, 28);
|
||||
QTest::newRow("tall") << QSize(30, 100) << QSize(28, 96);
|
||||
QTest::newRow("a strip stays one pixel high") << QSize(2400, 1) << QSize(96, 1);
|
||||
}
|
||||
|
||||
void ImageSizeTests::thumbnailsTruncate()
|
||||
{
|
||||
QFETCH(QSize, size);
|
||||
QFETCH(QSize, thumbnail);
|
||||
const ProjectSnapshot input = imported();
|
||||
const ProjectSnapshot output = ImageResizer::resize(input, {.width = size.width(), .height = size.height(), .resolution = 72});
|
||||
QCOMPARE(output.images.begin()->second.thumbnail.size(), thumbnail);
|
||||
// Inside the red half; halved edges fade, as on macOS.
|
||||
QCOMPARE(output.images.begin()->second.thumbnail.pixelColor(thumbnail.width() / 4, thumbnail.height() / 2), QColor(255, 0, 0));
|
||||
}
|
||||
|
||||
void ImageSizeTests::aSurfaceThatCannotBeAllocatedThrows()
|
||||
{
|
||||
const ProjectSnapshot input = imported();
|
||||
std::optional<ExportError::Kind> kind;
|
||||
{
|
||||
// 9000 x 9000 asks for 324 MB.
|
||||
const AddressSpaceLimit limit(200ll * 1024 * 1024);
|
||||
try {
|
||||
ImageResizer::resize(input, {.width = 9000, .height = 9000, .resolution = 72});
|
||||
} catch (const ExportError &error) {
|
||||
kind = error.kind;
|
||||
}
|
||||
}
|
||||
QCOMPARE(kind, std::optional(ExportError::Kind::render));
|
||||
// A mask's surface fails the same way.
|
||||
ProjectSnapshot masked = imported();
|
||||
masked.manifest.layers[0].imageFile = std::nullopt;
|
||||
masked.manifest.layers[0].maskFile = "mask.png";
|
||||
masked.masks.insert({masked.manifest.layers[0].id, LayerMask::assetFrom(gray(64, 32, 200))});
|
||||
masked.images.clear();
|
||||
kind.reset();
|
||||
{
|
||||
// 9000 x 9000 gray asks for 81 MB.
|
||||
const AddressSpaceLimit limit(40ll * 1024 * 1024);
|
||||
try {
|
||||
ImageResizer::resize(masked, {.width = 9000, .height = 9000, .resolution = 72});
|
||||
} catch (const ExportError &error) {
|
||||
kind = error.kind;
|
||||
}
|
||||
}
|
||||
QCOMPARE(kind, std::optional(ExportError::Kind::render));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ImageSizeTests)
|
||||
#include "ImageSizeTests.moc"
|
||||
Reference in new issue
Block a user