#include "DialogDesk.h" #include "Document/ImageTrim.h" #include "IO/ProjectController.h" #include "UI/TrimSheet.h" #include #include #include #include #include #include #include // Swift's ImageTrimTests, then the sheet, the controller and the menu. namespace { struct Colour { int r, g, b, a; }; // Premultiplied RGBA, painted pixel by pixel. QImage painted(int width, int height, const std::function &painter) { QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); for (int y = 0; y < height; ++y) { uchar *row = image.scanLine(y); for (int x = 0; x < width; ++x) { const Colour colour = painter(x, y); row[x * 4] = uchar(colour.r), row[x * 4 + 1] = uchar(colour.g), row[x * 4 + 2] = uchar(colour.b), row[x * 4 + 3] = uchar(colour.a); } } return image; } TrimOptions edges(TrimBasedOn basedOn, bool top, bool bottom, bool left, bool right) { return TrimOptions{.basedOn = basedOn, .top = top, .bottom = bottom, .left = left, .right = right}; } struct Desk { EditorSession session; ProjectController controller{session}; QWidget window; bool done = false; Desk() { session.createDocument(100, 100); session.insert(ImportedImage(painted(40, 40, [](int, int) { return Colour{200, 50, 50, 255}; }), QImage(), "Square"), QPointF(40, 50)); window.resize(600, 400); window.show(); controller.window = &window; } QDialog &dialog() { auto *shown = window.findChild(); if (!shown) throw std::runtime_error("no sheet shown"); return *shown; } std::function finished() { done = false; return [this] { done = true; }; } }; } class ImageTrimTests : public QObject { Q_OBJECT private slots: void calculateTrimRectTransparentPixels(); void calculateTrimRectTopLeftPixelColor(); void calculateTrimRectBottomRightPixelColor(); void uniformImageReturnsNil(); void trimImageDirectly(); void sessionTrimIntegrationAndUndo(); void toleranceAndAlphaDecideAMatch(); void theSheetSaysSwiftsWordsAndReturnsItsChoice(); void theControllerTrimsThroughTheSheetAsOneStep(); void nothingToTrimOrACancelChangesNothing(); void aTrimThatFailsIsExplained(); }; void ImageTrimTests::calculateTrimRectTransparentPixels() { // Content x 2..<17, y 4..<15, clear around it. const QImage image = painted(20, 20, [](int x, int y) { return x >= 2 && x < 17 && y >= 4 && y < 15 ? Colour{255, 0, 0, 255} : Colour{0, 0, 0, 0}; }); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, true, true)), std::optional(QRect(2, 4, 15, 11))); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, false, false)), std::optional(QRect(0, 4, 20, 11))); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, true, true)), std::optional(QRect(2, 0, 15, 20))); // One edge alone, each. QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, true, false, false)), std::optional(QRect(0, 0, 20, 15))); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, true)), std::optional(QRect(0, 0, 17, 20))); // No edge chosen trims nothing. QVERIFY(!edges(TrimBasedOn::transparentPixels, false, false, false, false).trimsAny()); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, false)), std::nullopt); } void ImageTrimTests::calculateTrimRectTopLeftPixelColor() { // A blue border of three around yellow. const QImage image = painted(16, 16, [](int x, int y) { return x >= 3 && x < 13 && y >= 3 && y < 13 ? Colour{255, 255, 0, 255} : Colour{0, 0, 255, 255}; }); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(3, 3, 10, 10))); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, false, false, false)), std::optional(QRect(0, 3, 16, 13))); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, false, false, true, false)), std::optional(QRect(3, 0, 13, 16))); // A staircase: the widest reach comes from middle rows. const QImage stairs = painted(8, 3, [](int x, int y) { const int from = std::array{1, 5, 3}[size_t(y)]; return x >= from && x < from + 2 ? Colour{255, 255, 255, 255} : Colour{0, 0, 0, 255}; }); QCOMPARE(ImageTrim::calculateTrimRect(stairs, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(1, 0, 6, 3))); } void ImageTrimTests::calculateTrimRectBottomRightPixelColor() { // Magenta along the bottom and right four, cyan elsewhere. const QImage image = painted(16, 16, [](int x, int y) { return x >= 12 || y >= 12 ? Colour{255, 0, 255, 255} : Colour{0, 255, 255, 255}; }); QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(0, 0, 12, 12))); // Matches count from a row's ends alone. const QImage holed = painted(8, 3, [](int x, int y) { return y == 1 && (x == 1 || x == 6) ? Colour{9, 9, 9, 255} : Colour{0, 0, 0, 255}; }); QCOMPARE(ImageTrim::calculateTrimRect(holed, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(1, 1, 6, 1))); } void ImageTrimTests::uniformImageReturnsNil() { const QImage solid = painted(8, 8, [](int, int) { return Colour{100, 150, 200, 255}; }); QCOMPARE(ImageTrim::calculateTrimRect(solid, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::nullopt); const QImage clear = painted(8, 8, [](int, int) { return Colour{0, 0, 0, 0}; }); QCOMPARE(ImageTrim::calculateTrimRect(clear, TrimOptions()), std::nullopt); } // Swift's trimImage, uncalled in the app: the rect it crops. void ImageTrimTests::trimImageDirectly() { const QImage image = painted(10, 10, [](int x, int y) { return x >= 2 && x < 8 && y >= 2 && y < 8 ? Colour{255, 128, 0, 255} : Colour{0, 0, 0, 0}; }); QCOMPARE(ImageTrim::calculateTrimRect(image, TrimOptions()), std::optional(QRect(2, 2, 6, 6))); } // Swift's session trim, uncalled: the controller's two steps. void ImageTrimTests::sessionTrimIntegrationAndUndo() { Desk desk; EditorSession &session = desk.session; QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30)); const std::optional trimmed = ImageTrim::trim(session.projectSnapshot().value(), TrimOptions()); session.applyDocumentSize(trimmed.value(), QStringLiteral("Trim")); QVERIFY(session.document().value().width == 40 && session.document().value().height == 40); QCOMPARE(session.activeLayer().value().origin(), QPointF(0, 0)); QCOMPARE(session.history.undoName(), QString("Trim")); session.undo(); QVERIFY(session.document().value().width == 100 && session.document().value().height == 100); QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30)); // A canvas with nothing to cut comes back whole. session.createDocument(4, 4); session.insert(ImportedImage(painted(4, 4, [](int, int) { return Colour{1, 2, 3, 255}; }), QImage(), "Full")); // The snapshot comes back whole, effects and all. session.setEffects(LayerEffects{.stroke = StrokeEffect()}); const ProjectSnapshot full = session.projectSnapshot().value(); QVERIFY(full.manifest.layers[0].effects); QVERIFY(ImageTrim::trim(full, TrimOptions()).value().manifest.encoded() == full.manifest.encoded()); // A clear canvas has nothing left. session.createDocument(4, 4); QCOMPARE(ImageTrim::trim(session.projectSnapshot().value(), TrimOptions()).has_value(), false); } void ImageTrimTests::toleranceAndAlphaDecideAMatch() { // A frame just off the corner, then a half-clear one. const QImage image = painted(6, 6, [](int x, int y) { if (x == 0 && y == 0) return Colour{10, 10, 10, 255}; if (x == 0 || y == 0) return Colour{12, 9, 10, 255}; if (x == 1 || y == 1) return Colour{10, 10, 10, 128}; return Colour{200, 0, 0, 255}; }); TrimOptions options = edges(TrimBasedOn::topLeftPixelColor, true, false, true, false); QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(0, 0, 6, 6))); options.tolerance = 2; QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(1, 1, 5, 5))); // Alpha is a channel like the others. options.tolerance = 127; QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(2, 2, 4, 4))); QCOMPARE(rawValue(TrimBasedOn::transparentPixels), QString("Transparent Pixels")); QCOMPARE(rawValue(TrimBasedOn::topLeftPixelColor), QString("Top Left Pixel Color")); QCOMPARE(rawValue(TrimBasedOn::bottomRightPixelColor), QString("Bottom Right Pixel Color")); QVERIFY(TrimOptions() == edges(TrimBasedOn::transparentPixels, true, true, true, true) && TrimOptions().tolerance == 0); } void ImageTrimTests::theSheetSaysSwiftsWordsAndReturnsItsChoice() { std::optional> answer; TrimSheet sheet([&answer](std::optional options) { answer = options; }); sheet.show(); QCOMPARE(sheet.width(), 320); QStringList words; for (const QLabel *label : sheet.findChildren()) words << label->text(); QCOMPARE(words, (QStringList{"Trim", "Based On", "Trim Away"})); QCOMPARE(sheet.findChildren().at(0)->font().pixelSize(), 17); QVERIFY(sheet.findChildren().at(0)->font().bold() && sheet.findChildren().at(1)->font().weight() == QFont::DemiBold); // Swift's defaults: transparent pixels, every edge. for (const TrimBasedOn option : allTrimBasedOn) { auto *radio = sheet.findChild(QStringLiteral("trimBasedOn%1").arg(int(option))); QVERIFY(radio && radio->text() == rawValue(option) && radio->isChecked() == (option == TrimBasedOn::transparentPixels)); } auto &ok = *sheet.findChild("trimOK"); QVERIFY(ok.isDefault() && ok.isEnabled() && !sheet.findChild("trimCancel")->autoDefault()); for (const char *edge : {"trimTop", "trimBottom", "trimLeft", "trimRight"}) QVERIFY(sheet.findChild(edge)->isChecked()); // OK rests only while every edge is off. for (const char *edge : {"trimTop", "trimBottom", "trimLeft"}) sheet.findChild(edge)->click(); QVERIFY(ok.isEnabled()); sheet.findChild("trimRight")->click(); QVERIFY(!ok.isEnabled()); sheet.findChild("trimBottom")->click(); sheet.findChild("trimBasedOn2")->click(); ok.click(); QVERIFY(answer.value() == edges(TrimBasedOn::bottomRightPixelColor, false, true, false, false)); sheet.findChild("trimCancel")->click(); QVERIFY(answer && !answer->has_value()); } void ImageTrimTests::theControllerTrimsThroughTheSheetAsOneStep() { Desk desk; const int steps = desk.session.history.undoCount(); desk.controller.trim(desk.finished()); QVERIFY(desk.session.isProjectBusy()); QCOMPARE(desk.dialog().windowTitle(), QString("Trim")); QVERIFY(desk.dialog().isVisible() && desk.dialog().windowModality() == Qt::WindowModal); QVERIFY(desk.dialog().findChild()); // Inside the dialog Cancel still defaults to nothing. QVERIFY(!desk.dialog().findChild("trimCancel")->autoDefault()); // Return is OK: the canvas shrinks to the square. QTest::keyClick(&desk.dialog(), Qt::Key_Return); QTRY_VERIFY(desk.done); QVERIFY(desk.session.document().value().width == 40 && desk.session.document().value().height == 40); QCOMPARE(desk.session.history.undoCount(), steps + 1); QCOMPARE(desk.session.history.undoName(), QString("Trim")); QVERIFY(!desk.session.isProjectBusy()); QTRY_VERIFY(!desk.window.findChild()); } void ImageTrimTests::nothingToTrimOrACancelChangesNothing() { Desk desk; const CanvasDocument before = desk.session.document().value(); const int steps = desk.session.history.undoCount(); desk.controller.trim(desk.finished()); QTest::keyClick(&desk.dialog(), Qt::Key_Escape); QTRY_VERIFY(desk.done); QVERIFY(desk.session.document().value() == before && desk.session.history.undoCount() == steps && !desk.session.isProjectBusy()); // Everything matches the corner: no alert, no step. DialogDesk alerts; desk.session.createDocument(8, 8); desk.session.insert(ImportedImage(painted(8, 8, [](int, int) { return Colour{5, 5, 5, 255}; }), QImage(), "Flat")); const CanvasDocument flat = desk.session.document().value(); const int flatSteps = desk.session.history.undoCount(); desk.controller.trim(desk.finished()); desk.dialog().findChild("trimBasedOn1")->click(); desk.dialog().findChild("trimOK")->click(); QTRY_VERIFY(desk.done); QVERIFY(desk.session.document().value() == flat && desk.session.history.undoCount() == flatSteps); QVERIFY(alerts.seen.isEmpty() && !desk.session.isProjectBusy()); // Busy, or without a window, no sheet opens. desk.session.setIsProjectBusy(true); desk.controller.trim(desk.finished()); QTRY_VERIFY(desk.done); QVERIFY(!desk.window.findChild()); desk.session.setIsProjectBusy(false); desk.controller.window = nullptr; desk.controller.trim(desk.finished()); QTRY_VERIFY(desk.done); QVERIFY(!desk.session.isProjectBusy()); } void ImageTrimTests::aTrimThatFailsIsExplained() { Desk desk; // Past the render's one surface, the trim cannot look. desk.session.createDocument(20'001, 10'000); const CanvasDocument before = desk.session.document().value(); DialogDesk alerts; alerts.replies = {"OK"}; alerts.note = [&] { return QStringLiteral("busy %1").arg(desk.session.isProjectBusy()); }; desk.controller.trim(desk.finished()); desk.dialog().findChild("trimOK")->click(); QTRY_VERIFY(desk.done); QCOMPARE(alerts.seen, (QStringList{QStringLiteral("alert|2|Couldn’t trim image|%1|OK|busy 1") .arg(QString::fromUtf8(ExportError(ExportError::Kind::tooLarge).what()))})); QVERIFY(desk.session.document().value() == before && !desk.session.isProjectBusy()); } QTEST_MAIN(ImageTrimTests) #include "ImageTrimTests.moc"