14.22: effects, text and shapes outlast size changes and adjustments

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 12:03:49 -04:00
1 parent bafb07978a
commit e8b74c6e47
17 files changed
+226 -38

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
#include "Document/CanvasSize.h"
#include "Document/ImageTrim.h"
#include "IO/CanvasResizer.h"
#include "IO/ImageResizer.h"
#include "SessionFixtures.h"
#include <QtTest>
// Swift 1.4.9's: effects, live text and shapes outlast size changes.
class LayerEffectsSurviveTests : public QObject {
Q_OBJECT
private slots:
void canvasSizeKeepsEffectsTextAndShapes();
void cropKeepsEffectsTextAndShapes();
void trimKeepsEffectsTextAndShapes();
void resolutionOnlyImageSizeKeepsEffectsTextAndShapes();
void imageSizeKeepsEffectsScaledWithTheImage();
void aStretchScalesByTheFactorsMeanWithinRange();
};
namespace {
LayerEffects effects()
{
LayerEffects made;
made.stroke = StrokeEffect{.size = 4};
made.shadow = ShadowEffect{.distance = 20, .blur = 10};
made.outerGlow = OuterGlowEffect{.size = 8};
return made;
}
// What a layer carries beyond its pixels.
struct Extras {
std::optional<LayerEffects> effects;
std::optional<LayerTextStyle> text;
std::optional<LayerShapeStyle> shape;
friend bool operator==(const Extras &, const Extras &) = default;
};
std::vector<Extras> extras(const EditorSession &session, const std::vector<QUuid> &ids)
{
std::vector<Extras> found;
for (const QUuid id : ids) {
const ImageLayer layer = layerWith(session, id);
found.push_back({layer.effects, layer.liveText() ? std::optional(layer.liveText()->style) : std::nullopt,
layer.liveShape() ? std::optional(layer.liveShape()->style) : std::nullopt});
}
return found;
}
// A clear canvas holding text and a shape, with effects.
std::unique_ptr<EditorSession> textAndShape(std::vector<QUuid> &ids)
{
auto session = std::make_unique<EditorSession>();
session->createDocument(200, 120);
session->selectTool(NavigationTool::type);
session->beginText(QPointF(30, 60));
TextDraft draft = session->textDraft().value();
draft.style.content = QStringLiteral("Text");
if (!session->applyText(draft))
throw std::runtime_error("no text");
const QUuid text = session->activeLayerID().value();
session->selectTool(NavigationTool::shape);
session->beginShape(QPointF(120, 30));
session->dragShape(QPointF(170, 80), false, false);
session->finishShape();
const QUuid shape = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, text).effects = record(snapshot, shape).effects = effects(); });
ids = {text, shape};
const std::vector<Extras> before = extras(*session, ids);
if (shape == text || !before[0].text || !before[1].shape)
throw std::runtime_error("no live text or shape");
return session;
}
}
void LayerEffectsSurviveTests::canvasSizeKeepsEffectsTextAndShapes()
{
std::vector<QUuid> ids;
const auto session = textAndShape(ids);
const std::vector<Extras> before = extras(*session, ids);
session->applyDocumentSize(CanvasResizer::resize(session->projectSnapshot().value(), CanvasSizeOptions{.width = 260, .height = 160, .anchor = 4}),
QStringLiteral("Canvas Size"));
QCOMPARE(session->document().value().width, 260);
QVERIFY(extras(*session, ids) == before);
}
void LayerEffectsSurviveTests::cropKeepsEffectsTextAndShapes()
{
std::vector<QUuid> ids;
const auto session = textAndShape(ids);
const std::vector<Extras> before = extras(*session, ids);
session->selectTool(NavigationTool::crop);
session->setCropRect(QRectF(10, 10, 180, 100));
bool done = false;
session->commitCrop([&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session->document().value().width, 180);
QVERIFY(extras(*session, ids) == before);
}
void LayerEffectsSurviveTests::trimKeepsEffectsTextAndShapes()
{
std::vector<QUuid> ids;
const auto session = textAndShape(ids);
const std::vector<Extras> before = extras(*session, ids);
session->applyDocumentSize(ImageTrim::trim(session->projectSnapshot().value(), TrimOptions()).value(), QStringLiteral("Trim"));
QVERIFY(session->document().value().width < 200);
QVERIFY(extras(*session, ids) == before);
}
void LayerEffectsSurviveTests::resolutionOnlyImageSizeKeepsEffectsTextAndShapes()
{
std::vector<QUuid> ids;
const auto session = textAndShape(ids);
const std::vector<Extras> before = extras(*session, ids);
session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), ImageSizeOptions{.width = 200, .height = 120, .resolution = 300}));
QCOMPARE(session->document().value().resolution, 300.0);
QVERIFY(extras(*session, ids) == before);
}
// Resampled pixels take their effects with them, as Scale Styles.
void LayerEffectsSurviveTests::imageSizeKeepsEffectsScaledWithTheImage()
{
std::vector<QUuid> ids;
const auto session = textAndShape(ids);
session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), ImageSizeOptions{.width = 100, .height = 60, .resolution = 72}));
QCOMPARE(session->document().value().width, 100);
for (const Extras &kept : extras(*session, ids)) {
QVERIFY(!kept.text && !kept.shape);
const LayerEffects scaled = kept.effects.value();
QVERIFY(scaled.stroke.value().size == 2 && scaled.outerGlow.value().size == 4);
QVERIFY(scaled.shadow.value().distance == 10 && scaled.shadow.value().blur == 5 && scaled.shadow.value().angle == effects().shadow.value().angle);
}
}
void LayerEffectsSurviveTests::aStretchScalesByTheFactorsMeanWithinRange()
{
LayerEffects made;
made.stroke = StrokeEffect{.size = 300};
made.shadow = ShadowEffect{.distance = 3000, .blur = 300, .opacity = 0.4};
made.innerShadow = InnerShadowEffect{.distance = 10, .blur = 4};
made.innerGlow = InnerGlowEffect{.size = 6};
made.colorOverlay = ColorOverlayEffect{.opacity = 0.3};
const LayerEffects doubled = made.scaled(2);
// Each held to the range the effect accepts.
QVERIFY(doubled.stroke.value().size == 500 && doubled.shadow.value().distance == 5000 && doubled.shadow.value().blur == 500);
QVERIFY(doubled.innerShadow.value().distance == 20 && doubled.innerShadow.value().blur == 8 && doubled.innerGlow.value().size == 12);
QVERIFY(doubled.shadow.value().opacity == 0.4 && doubled.colorOverlay == made.colorOverlay && !doubled.outerGlow && doubled.isValid());
// Stretched 4 by 1: the mean factor is 2.
EditorSession session;
session.createDocument(20, 20);
QImage pixels(20, 20, QImage::Format_RGBA8888_Premultiplied);
pixels.fill(Qt::red);
session.insert(ImportedImage(pixels, pixels, QStringLiteral("Red")));
const QUuid id = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).effects = effects(); });
session.applyImageSize(ImageResizer::resize(session.projectSnapshot().value(), ImageSizeOptions{.width = 80, .height = 20, .resolution = 72}));
QVERIFY(layerWith(session, id).effects == effects().scaled(2));
QCOMPARE(layerWith(session, id).effects.value().stroke.value().size, 8.0);
}
QTEST_MAIN(LayerEffectsSurviveTests)
#include "LayerEffectsSurviveTests.moc"
+15 -12
View File
@@ -75,7 +75,7 @@ private slots:
void decodingRefusesWhatSwiftRefuses();
void effectsSaveLoadAndExportWithTheirLayer();
void duplicatesAndBrushCommitsKeepThem();
void rebuildsCopiesAndResizesDropThem();
void rebuildsAndResizesKeepThem();
};
void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
@@ -308,28 +308,28 @@ void LayerEffectsTests::duplicatesAndBrushCommitsKeepThem()
QVERIFY(session->activeLayer().value().effects == every());
}
void LayerEffectsTests::rebuildsCopiesAndResizesDropThem()
// Swift 1.4.9 (`94e90bd`): every rebuild keeps them.
void LayerEffectsTests::rebuildsAndResizesKeepThem()
{
// Swift rebuilds these layers from lists that leave effects out.
const auto dropped = [](const std::function<void(EditorSession &)> &edit, const char *name) {
const auto kept = [](const std::function<void(EditorSession &)> &edit, const char *name) {
const std::unique_ptr<EditorSession> session = withEffects();
edit(*session);
return session->history.undoName() == QString::fromLatin1(name) && !session->activeLayer().value().effects;
return session->history.undoName() == QString::fromLatin1(name) && session->activeLayer().value().effects == every();
};
QVERIFY(dropped([](EditorSession &session) { QVERIFY(awaited([&](std::function<void()> done) { session.invertPixels(std::move(done)); })); }, "Invert"));
QVERIFY(dropped([](EditorSession &session) {
QVERIFY(kept([](EditorSession &session) { QVERIFY(awaited([&](std::function<void()> done) { session.invertPixels(std::move(done)); })); }, "Invert"));
QVERIFY(kept([](EditorSession &session) {
session.beginLevels();
LevelsSettings settings;
settings.ranges[0].outputWhite = 100;
session.updateLevels(settings, false);
QVERIFY(awaited([&](std::function<void()> done) { session.commitLevels(std::move(done)); }));
}, "Levels"));
QVERIFY(dropped([](EditorSession &session) {
QVERIFY(kept([](EditorSession &session) {
session.beginHueSaturation();
session.updateHueSaturation(HueSaturationSettings(90), false);
QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); }));
}, "Hue/Saturation"));
QVERIFY(dropped([](EditorSession &session) {
QVERIFY(kept([](EditorSession &session) {
session.applySelection(rectPath(QRectF(2, 2, 6, 4)), SelectionMode::replace, QStringLiteral("Select"));
QVERIFY(awaited([&](std::function<void()> done) { session.beginSelectionTransform(std::move(done)); }));
LayerTransform draft = session.transformEdit().value().draft;
@@ -337,10 +337,13 @@ void LayerEffectsTests::rebuildsCopiesAndResizesDropThem()
session.previewTransform(draft);
session.commitTransform();
}, "Transform Selection"));
// Both resizers rebuild their records without them, as Swift's.
// The canvas resizer carries them; the image resizer scales them.
const ProjectSnapshot snapshot = withEffects()->projectSnapshot().value();
QVERIFY(!ImageResizer::resize(snapshot, ImageSizeOptions{.width = 40, .height = 20, .resolution = 72}).manifest.layers[0].effects);
QVERIFY(!CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = 30, .height = 12}).manifest.layers[0].effects);
QVERIFY(CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = 30, .height = 12}).manifest.layers[0].effects == every());
LayerEffects doubled = every();
doubled.stroke->size = 14, doubled.shadow->distance = 24, doubled.shadow->blur = 12;
doubled.innerShadow->distance = 6, doubled.innerShadow->blur = 4, doubled.outerGlow->size = 66, doubled.innerGlow->size = 28;
QVERIFY(ImageResizer::resize(snapshot, ImageSizeOptions{.width = 40, .height = 20, .resolution = 72}).manifest.layers[0].effects == doubled);
}
QTEST_GUILESS_MAIN(LayerEffectsTests)
+5 -1
View File
@@ -379,9 +379,13 @@ void ShapeLayerTests::resizersKeepOrDropTheShape()
QCOMPARE(canvas.manifest.layers[1].shape, snapshot.manifest.layers[1].shape);
const ProjectSnapshot image = ImageResizer::resize(snapshot, {.width = 200, .height = 160, .resolution = 72});
QVERIFY(!image.manifest.layers[1].shape);
// Swift's rebuild after a resize leaves every shape out.
// Since 1.4.9 the rebuild keeps the shape live.
session->applyDocumentSize(canvas, QStringLiteral("Canvas Size"));
QCOMPARE(session->history.undoName(), QString("Canvas Size"));
QVERIFY(session->document().value().layers[1].liveShape());
QCOMPARE(session->document().value().layers[1].shape.value().style, snapshot.manifest.layers[1].shape.value());
// Image Size resamples it into pixels.
session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), {.width = 240, .height = 180, .resolution = 72}));
QVERIFY(!session->document().value().layers[1].shape);
}
+1
View File
@@ -267,6 +267,7 @@ compositor_test(AdjustmentLayerTests)
compositor_test(AdjustmentEditorTests)
compositor_test(AdjustmentPanelTests)
compositor_test(LayerEffectsTests)
compositor_test(LayerEffectsSurviveTests)
compositor_test(OuterGlowTests)
compositor_test(InnerGlowTests)
compositor_test(ColorOverlayTests)
+4 -1
View File
@@ -230,8 +230,11 @@ void TypeStoreTests::resizersKeepOrDropTheText()
const ProjectSnapshot canvas = CanvasResizer::resize(snapshot, {.width = 500, .height = 400});
QCOMPARE(canvas.manifest.layers[1].text, snapshot.manifest.layers[1].text);
QVERIFY(!ImageResizer::resize(snapshot, {.width = 800, .height = 600, .resolution = 72}).manifest.layers[1].text);
// Swift's rebuild after a resize leaves the text out.
// Since 1.4.9 the rebuild keeps the text live.
session->applyDocumentSize(canvas, QStringLiteral("Canvas Size"));
QVERIFY(session->document().value().layers[1].liveText());
QCOMPARE(session->document().value().layers[1].text.value().style, snapshot.manifest.layers[1].text.value());
session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), {.width = 1000, .height = 800, .resolution = 72}));
QVERIFY(!session->document().value().layers[1].text);
}