diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index d899f6779..50e917912 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -216,6 +216,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/diagramposition.h ${QET_DIR}/sources/diagramview.cpp ${QET_DIR}/sources/diagramview.h + ${QET_DIR}/sources/imagedrop.cpp + ${QET_DIR}/sources/imagedrop.h ${QET_DIR}/sources/elementdialog.cpp ${QET_DIR}/sources/elementdialog.h ${QET_DIR}/sources/elementprovider.cpp diff --git a/sources/diagramevent/diagrameventaddimage.cpp b/sources/diagramevent/diagrameventaddimage.cpp index e0d2dad00..0eb44c622 100644 --- a/sources/diagramevent/diagrameventaddimage.cpp +++ b/sources/diagramevent/diagrameventaddimage.cpp @@ -17,6 +17,7 @@ */ #include "diagrameventaddimage.h" +#include "../imagedrop.h" #include "../qetapp.h" #include "../qetdiagrameditor.h" @@ -341,10 +342,11 @@ void DiagramEventAddImage::openDialog() if (fileName.isEmpty()) return; - QImage image(fileName); + QString error; + const QImage image = ImageDrop::load(fileName, &error); if(image.isNull()) { - QMessageBox::critical(m_diagram->views().isEmpty()? nullptr : m_diagram->views().first(), QObject::tr("Error"), QObject::tr("Unable to load the image.")); + QMessageBox::critical(m_diagram->views().isEmpty()? nullptr : m_diagram->views().first(), QObject::tr("Error"), QObject::tr("Unable to load the image.") + "\n" + error); return; } diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 092cd9ea5..5bb94d784 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -30,6 +30,7 @@ #include "qetdiagrameditor.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductortextitem.h" +#include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/independenttextitem.h" #include "qeticons.h" #include "qetpalette.h" @@ -42,6 +43,7 @@ #include "diagram.h" #include "foliogrid.h" #include "diagramcontexttoolbar.h" +#include "imagedrop.h" #include "diagramgestureoverlay.h" #include "gesturesettings.h" #include "shortcutbarsettings.h" @@ -55,6 +57,7 @@ #include "qetversion.h" #include #include +#include #include #include #include @@ -222,7 +225,14 @@ DiagramView::~DiagramView() @param e le QDragEnterEvent correspondant au drag'n drop tente */ void DiagramView::dragEnterEvent(QDragEnterEvent *e) { - if (e -> mimeData() -> hasFormat("application/x-qet-element-uri")) { + // Picture files from the file manager are checked first: a file drag + // also carries its path as text, which would otherwise become a text. + // Any other file (a .qet project...) is left to the main window. + if (!ImageDrop::imageFiles(e -> mimeData()).isEmpty()) { + e -> acceptProposedAction(); + } else if (ImageDrop::hasOnlyOtherUrls(e -> mimeData())) { + e -> ignore(); + } else if (e -> mimeData() -> hasFormat("application/x-qet-element-uri")) { e -> acceptProposedAction(); } else if (e -> mimeData() -> hasFormat("application/x-qet-titleblock-uri")) { e -> acceptProposedAction(); @@ -238,7 +248,9 @@ void DiagramView::dragEnterEvent(QDragEnterEvent *e) { @param e le QDragMoveEvent correspondant au drag'n drop tente */ void DiagramView::dragMoveEvent(QDragMoveEvent *e) { - if (e -> mimeData() -> hasFormat("text/plain")) e -> acceptProposedAction(); + if (!ImageDrop::imageFiles(e -> mimeData()).isEmpty()) e -> acceptProposedAction(); + else if (ImageDrop::hasOnlyOtherUrls(e -> mimeData())) e -> ignore(); + else if (e -> mimeData() -> hasFormat("text/plain")) e -> acceptProposedAction(); else e-> ignore(); } @@ -248,7 +260,11 @@ void DiagramView::dragMoveEvent(QDragMoveEvent *e) { */ void DiagramView::dropEvent(QDropEvent *e) { - if (e -> mimeData() -> hasFormat("application/x-qet-element-uri")) { + if (!ImageDrop::imageFiles(e -> mimeData()).isEmpty()) { + handleImageFilesDrop(e); + } else if (ImageDrop::hasOnlyOtherUrls(e -> mimeData())) { + e -> ignore(); + } else if (e -> mimeData() -> hasFormat("application/x-qet-element-uri")) { handleElementDrop(e); } else if (e -> mimeData() -> hasFormat("application/x-qet-titleblock-uri")) { handleTitleBlockDrop(e); @@ -411,6 +427,87 @@ void DiagramView::handleTextDrop(QDropEvent *e) { iti, m_diagram, mapToScene(e->position().toPoint()))); } +/** + @brief DiagramView::handleImageFilesDrop + Add the picture files dropped from the file manager. A single one is + centred on the drop point and kept inside the frame; a picture too + large for the folio is scaled down to leave a free margin of + ImageDrop::frameMargin to the frame on every side. Several pictures + are spread side by side in a grid over that same area + (ImageDrop::gridLayout). One undo step removes them all. Files that cannot be used are + listed once, after the others have been placed. + @param e the QDropEvent describing the current drag'n drop +*/ +void DiagramView::handleImageFilesDrop(QDropEvent *e) +{ + if (m_diagram -> isReadOnly()) return; + e -> acceptProposedAction(); + + const QStringList files = ImageDrop::imageFiles(e -> mimeData()); + const QPointF drop_pos = mapToScene(e -> position().toPoint()); + const QRectF frame = m_diagram -> border_and_titleblock.insideBorderRect(); + + QStringList refused; + QList items; + for (const QString &file : files) + { + QString error; + const QImage image = ImageDrop::load(file, &error); + if (image.isNull()) + refused << QStringLiteral("%1 : %2").arg(QFileInfo(file).fileName(), error); + else + items << new DiagramImageItem(QPixmap::fromImage(image)); + } + + // Where each picture goes, as it will look on the folio: one picture + // is centred on the drop point and fitted to the frame, several are + // spread over the frame in a grid. + QList targets; + if (items.size() == 1) + { + const QSizeF size = items.first() -> mapRectToScene(items.first() -> boundingRect()).size(); + const qreal scale = ImageDrop::fitScale(size, frame); + QRectF r(QPointF(), size * scale); + r.moveCenter(drop_pos); + targets << ImageDrop::keepInside(r, scale < 1.0 ? ImageDrop::innerFrame(frame) : frame); + } + else + { + QList sizes; + for (DiagramImageItem *item : items) + sizes << item -> mapRectToScene(item -> boundingRect()).size(); + targets = ImageDrop::gridLayout(sizes, ImageDrop::innerFrame(frame)); + } + + auto *undo = new QUndoCommand(); + const int placed = int(items.size()); + for (int i = 0 ; i < placed ; ++i) + { + DiagramImageItem *item = items.at(i); + const QRectF natural = item -> mapRectToScene(item -> boundingRect()); + const qreal scale = natural.width() > 0 ? targets.at(i).width() / natural.width() : 1.0; + if (scale < 1.0) { + item -> setScaleFactorX(scale); + item -> setScaleFactorY(scale); + } + const QPointF offset = item -> mapRectToScene(item -> boundingRect()).topLeft() - item -> pos(); + new AddGraphicsObjectCommand(item, m_diagram, targets.at(i).topLeft() - offset, undo); + } + + if (placed) { + undo -> setText(placed == 1 ? tr("Add an image") + : tr("Add %n image(s)", nullptr, placed)); + m_diagram -> undoStack().push(undo); + } else { + delete undo; + } + + if (!refused.isEmpty()) + QMessageBox::warning(this, tr("Images not added"), + tr("These files could not be added:") + "\n\n" + + refused.join("\n")); +} + /** Set the Diagram in visualisation mode */ diff --git a/sources/diagramview.h b/sources/diagramview.h index e7a99827d..0c4d02d02 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -145,6 +145,7 @@ class DiagramView : public PaletteGraphicsView void updateFolioReportMenu(); void handleTitleBlockDrop(QDropEvent *); void handleTextDrop(QDropEvent *); + void handleImageFilesDrop(QDropEvent *); void scrollOnMovement(QKeyEvent *); bool gestureEvent(QGestureEvent *event); QRectF viewedSceneRect() const; diff --git a/sources/imagedrop.cpp b/sources/imagedrop.cpp new file mode 100644 index 000000000..06aa5ed85 --- /dev/null +++ b/sources/imagedrop.cpp @@ -0,0 +1,145 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "imagedrop.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ + bool isImageFile(const QUrl &url) + { + if (!url.isLocalFile()) + return false; + const QString suffix = QFileInfo(url.toLocalFile()).suffix().toLower(); + return ImageDrop::supportedSuffixes().contains(suffix); + } + + QString tr(const char *text) + { + return QCoreApplication::translate("ImageDrop", text); + } +} + +QStringList ImageDrop::supportedSuffixes() +{ + return {QStringLiteral("png"), QStringLiteral("jpg"), QStringLiteral("jpeg"), + QStringLiteral("bmp"), QStringLiteral("svg")}; +} + +QStringList ImageDrop::imageFiles(const QMimeData *mime) +{ + QStringList files; + if (!mime || !mime->hasUrls()) + return files; + for (const QUrl &url : mime->urls()) + if (isImageFile(url)) + files << url.toLocalFile(); + return files; +} + +bool ImageDrop::hasOnlyOtherUrls(const QMimeData *mime) +{ + return mime && mime->hasUrls() && imageFiles(mime).isEmpty(); +} + +QImage ImageDrop::load(const QString &path, QString *error) +{ + auto fail = [error](const QString &message) { + if (error) + *error = message; + return QImage(); + }; + + const QFileInfo info(path); + if (!info.isFile()) + return fail(tr("not a file")); + if (info.size() > maxFileBytes) + return fail(tr("the file is larger than 10 MB")); + + QImageReader reader(path); + const QSize size = reader.size(); + if (size.isValid() && qint64(size.width()) * size.height() > maxPixels) + return fail(tr("the image has too many pixels")); + + const QImage image = reader.read(); + if (image.isNull()) + return fail(tr("unable to read the image")); + return image; +} + +QRectF ImageDrop::innerFrame(const QRectF &frame) +{ + return frame.adjusted(frame.width() * frameMargin, frame.height() * frameMargin, + -frame.width() * frameMargin, -frame.height() * frameMargin); +} + +qreal ImageDrop::fitScale(const QSizeF &size, const QRectF &frame) +{ + const QRectF inner = innerFrame(frame); + if (size.isEmpty() || inner.isEmpty()) + return 1.0; + const qreal scale = std::min(inner.width() / size.width(), + inner.height() / size.height()); + return std::min(1.0, scale); +} + +QRectF ImageDrop::keepInside(const QRectF &picture, const QRectF &area) +{ + if (area.isEmpty()) + return picture; + auto axis = [](qreal start, qreal length, qreal area_start, qreal area_length) { + if (length >= area_length) + return area_start + (area_length - length) / 2; + return std::clamp(start, area_start, area_start + area_length - length); + }; + QRectF result = picture; + result.moveLeft(axis(picture.left(), picture.width(), area.left(), area.width())); + result.moveTop(axis(picture.top(), picture.height(), area.top(), area.height())); + return result; +} + +QList ImageDrop::gridLayout(const QList &sizes, const QRectF &area) +{ + QList rects; + const int count = int(sizes.size()); + if (count == 0) + return rects; + const int columns = int(std::ceil(std::sqrt(qreal(count)))); + const int rows = (count + columns - 1) / columns; + const QSizeF cell(area.width() / columns, area.height() / rows); + const QSizeF room = cell * (1.0 - gridGap); + for (int i = 0; i < count; ++i) + { + const QSizeF size = sizes.at(i); + qreal scale = 1.0; + if (!size.isEmpty() && !room.isEmpty()) + scale = std::min({1.0, room.width() / size.width(), room.height() / size.height()}); + QRectF r(QPointF(), size * scale); + r.moveCenter(area.topLeft() + QPointF(cell.width() * (i % columns + 0.5), + cell.height() * (i / columns + 0.5))); + rects << r; + } + return rects; +} diff --git a/sources/imagedrop.h b/sources/imagedrop.h new file mode 100644 index 000000000..22b944274 --- /dev/null +++ b/sources/imagedrop.h @@ -0,0 +1,91 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef IMAGEDROP_H +#define IMAGEDROP_H + +#include +#include +#include +#include +#include +#include + +class QMimeData; + +/** + Picture files dropped on a folio: which files qualify, how they are + loaded, and where they are placed. Kept free of any QElectroTech class + so that it can be tested on its own. +*/ +namespace ImageDrop +{ + /// Pictures are embedded in the project, so larger files are refused. + constexpr qint64 maxFileBytes = 10LL * 1024 * 1024; + /// Refuses decompression bombs before any pixel is allocated. + constexpr qint64 maxPixels = 64LL * 1024 * 1024; + /// Space left between the pictures of one drop laid out in a grid, as + /// a share of a grid cell. + constexpr qreal gridGap = 0.05; + /// Free margin left between a scaled-down picture and the folio frame, + /// as a share of the frame on each side. + constexpr qreal frameMargin = 0.2; + + /// The suffixes the "add image" file dialog offers, lower case. + QStringList supportedSuffixes(); + + /// The local picture files among the URLs of @a mime, in drop order. + QStringList imageFiles(const QMimeData *mime); + + /// True when @a mime carries URLs and none of them is a picture file: + /// the drop is meant for someone else (a .qet project, an element). + bool hasOnlyOtherUrls(const QMimeData *mime); + + /** + Load the picture at @a path, checking that it is a regular file of + at most maxFileBytes and maxPixels. On failure the image is null + and @a error, if given, says why. + */ + QImage load(const QString &path, QString *error = nullptr); + + /// @a frame shrunk by frameMargin on every side. + QRectF innerFrame(const QRectF &frame); + + /** + Scale that fits a picture of @a size into innerFrame(@a frame), + keeping its proportions; 1.0 when it already fits. Pictures are + never enlarged. + */ + qreal fitScale(const QSizeF &size, const QRectF &frame); + + /** + @a picture moved by the least amount that puts it inside @a area; + centred on @a area along an axis where it is larger. + */ + QRectF keepInside(const QRectF &picture, const QRectF &area); + + /** + Lay out pictures of @a sizes, dropped together, side by side in a + grid filling @a area: as many columns as needed for a roughly + square grid, every picture shrunk -- never enlarged -- into its + cell, proportions kept, and centred in it. + @return where each picture goes, in the order of @a sizes + */ + QList gridLayout(const QList &sizes, const QRectF &area); +} + +#endif // IMAGEDROP_H diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index ac52e0f81..eec89c0e5 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -29,6 +29,7 @@ #include "../diagramcontent.h" #include "../diagramview.h" #include "../factory/elementfactory.h" +#include "../imagedrop.h" #include "../factory/qetgraphicstablefactory.h" #include "../factory/ui/addtabledialog.h" #include "../qet.h" @@ -3437,20 +3438,12 @@ int QetScriptApi::addImage(int folioIndex, const QString &filePath, double x, do const QList diagrams = m_project->diagrams(); if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; - const QFileInfo info(filePath); - if (!info.isFile()) { - log(QStringLiteral("qet.addImage: '%1' is not a file").arg(filePath)); - return -1; - } - constexpr qint64 max_bytes = 10LL * 1024 * 1024; - if (info.size() > max_bytes) { - log(QStringLiteral("qet.addImage: '%1' is %2 bytes; images are embedded in the project, " - "so files over 10 MB are refused").arg(filePath).arg(info.size())); - return -1; - } - const QImage image(filePath); + // Same checks as a picture dropped on a folio: a regular file of at + // most 10 MB (it is embedded in the project) and a bounded pixel count. + QString error; + const QImage image = ImageDrop::load(filePath, &error); if (image.isNull()) { - log(QStringLiteral("qet.addImage: '%1' could not be read as an image").arg(filePath)); + log(QStringLiteral("qet.addImage: '%1': %2").arg(filePath, error)); return -1; } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 85c9b7aa4..066b99144 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1105,6 +1105,17 @@ add_test(NAME tst_borderpropertiesxml COMMAND tst_borderpropertiesxml) target_include_directories(tst_borderpropertiesxml PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_borderpropertiesxml PRIVATE Qt::Test Qt::Xml) +# Picture files dropped on a folio: which files are taken, the size and +# pixel limits before a picture is embedded, fitting into the view and the +# cascade of several pictures. +add_executable( + tst_imagedrop + tst_imagedrop.cpp + ${QET_DIR}/sources/imagedrop.cpp) +add_test(NAME tst_imagedrop COMMAND tst_imagedrop) +target_include_directories(tst_imagedrop PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_imagedrop PRIVATE Qt::Test Qt::Gui) + # Qt 6.4.2 writes the attributes of an element in an order that changes from # one process to the next, so a project saved twice by two runs of the binary # differs in bytes only (seen on projects without any numbering, too). A fixed diff --git a/tests/qttest/tst_imagedrop.cpp b/tests/qttest/tst_imagedrop.cpp new file mode 100644 index 000000000..41945b203 --- /dev/null +++ b/tests/qttest/tst_imagedrop.cpp @@ -0,0 +1,254 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "imagedrop.h" + +#include +#include + +#include +#include +#include +#include + +/** + Picture files dropped on a folio: which files are taken, the limits a + file must meet before it is embedded in the project, and where the + pictures of one drop land. +*/ +class tst_imagedrop : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + QString path(const QString &name) const { return m_dir.filePath(name); } + + static QMimeData *urls(const QStringList &paths) + { + auto *mime = new QMimeData(); + QList list; + for (const QString &p : paths) + list << (p.startsWith("http") ? QUrl(p) : QUrl::fromLocalFile(p)); + mime->setUrls(list); + return mime; + } + + // A BMP header announcing width x height pixels, without the pixels. + static QByteArray bmpHeader(qint32 width, qint32 height) + { + QByteArray b(54, '\0'); + b[0] = 'B'; b[1] = 'M'; + qToLittleEndian(54, b.data() + 2); + qToLittleEndian(54, b.data() + 10); + qToLittleEndian(40, b.data() + 14); + qToLittleEndian(width, b.data() + 18); + qToLittleEndian(height, b.data() + 22); + qToLittleEndian(1, b.data() + 26); + qToLittleEndian(24, b.data() + 28); + return b; + } + + static void write(const QString &p, const QByteArray &data) + { + QFile f(p); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write(data); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QImage img(40, 20, QImage::Format_ARGB32); + img.fill(Qt::red); + QVERIFY(img.save(path("a.png"))); + QVERIFY(img.save(path("b.JPG"), "JPG")); + write(path("plan.qet"), ""); + write(path("fake.png"), "this is not a picture"); + write(path("bomb.bmp"), bmpHeader(10000, 10000)); + QFile big(path("big.png")); + QVERIFY(big.open(QIODevice::WriteOnly)); + QVERIFY(big.resize(ImageDrop::maxFileBytes + 1)); + } + + // Only local files with a picture suffix are taken, in drop order and + // whatever the case of the suffix. + void picturesAreTakenInOrder() + { + std::unique_ptr mime(urls({path("b.JPG"), path("plan.qet"), path("a.png"), + "https://example.org/remote.png"})); + QCOMPARE(ImageDrop::imageFiles(mime.get()), QStringList({path("b.JPG"), path("a.png")})); + QVERIFY(!ImageDrop::hasOnlyOtherUrls(mime.get())); + } + + // A drop holding only a project is left to the main window, which + // opens it; plain text is not a URL drop at all. + void otherFilesAreLeftAlone() + { + std::unique_ptr project(urls({path("plan.qet")})); + QVERIFY(ImageDrop::imageFiles(project.get()).isEmpty()); + QVERIFY(ImageDrop::hasOnlyOtherUrls(project.get())); + + QMimeData text; + text.setText("K1"); + QVERIFY(!ImageDrop::hasOnlyOtherUrls(&text)); + QVERIFY(ImageDrop::imageFiles(&text).isEmpty()); + QVERIFY(ImageDrop::imageFiles(nullptr).isEmpty()); + } + + void validPictureLoads() + { + QString error; + const QImage image = ImageDrop::load(path("a.png"), &error); + QCOMPARE(image.size(), QSize(40, 20)); + QVERIFY(error.isEmpty()); + } + + void unusableFilesAreRefusedWithAReason_data() + { + QTest::addColumn("file"); + QTest::addColumn("reason"); + QTest::newRow("missing") << path("missing.png") << "file"; + QTest::newRow("directory") << m_dir.path() << "file"; + QTest::newRow("not a picture") << path("fake.png") << "read"; + QTest::newRow("over 10 MB") << path("big.png") << "10 MB"; + // refused on its header, before the 300 MB of pixels are allocated + QTest::newRow("too many pixels") << path("bomb.bmp") << "pixels"; + } + + void unusableFilesAreRefusedWithAReason() + { + QFETCH(QString, file); + QFETCH(QString, reason); + QString error; + QVERIFY(ImageDrop::load(file, &error).isNull()); + QVERIFY2(error.contains(reason), qPrintable(error)); + } + + // A picture too large for the folio is scaled down until it leaves 20 % + // of the frame free on every side, i.e. into 60 % of its width and + // height; a picture that already fits keeps its size. + void largePicturesLeaveAMarginToTheFrame_data() + { + QTest::addColumn("size"); + QTest::addColumn("scale"); + // frame: 1000 x 800, so the inner frame is 600 x 480 + QTest::newRow("small, kept") << QSizeF(100, 50) << 1.0; + QTest::newRow("exactly the inner frame") << QSizeF(600, 480) << 1.0; + QTest::newRow("wide") << QSizeF(3000, 1000) << 0.2; + QTest::newRow("tall photo") << QSizeF(3024, 4032) << 480.0 / 4032; + } + + void largePicturesLeaveAMarginToTheFrame() + { + QFETCH(QSizeF, size); + QFETCH(qreal, scale); + const QRectF frame(50, 30, 1000, 800); + QCOMPARE(ImageDrop::innerFrame(frame), QRectF(250, 190, 600, 480)); + QCOMPARE(ImageDrop::fitScale(size, frame), scale); + const QSizeF scaled = size * ImageDrop::fitScale(size, frame); + QVERIFY(scaled.width() <= 600.0001 && scaled.height() <= 480.0001); + } + + void noFrameMeansNoScaling() + { + QCOMPARE(ImageDrop::fitScale(QSizeF(4000, 3000), QRectF()), 1.0); + } + + // A picture dropped near an edge is moved back inside, by the least + // amount; one larger than the area is centred on it. + void picturesAreKeptInsideTheArea_data() + { + QTest::addColumn("picture"); + QTest::addColumn("expected"); + const QRectF inside(300, 300, 100, 50); + QTest::newRow("already inside") << inside << inside; + QTest::newRow("over the top edge") << QRectF(300, -20, 100, 50) << QRectF(300, 0, 100, 50); + QTest::newRow("past the right and bottom") << QRectF(980, 790, 100, 50) + << QRectF(900, 750, 100, 50); + QTest::newRow("wider than the area") << QRectF(-50, 100, 1200, 50) + << QRectF(-100, 100, 1200, 50); + } + + void picturesAreKeptInsideTheArea() + { + QFETCH(QRectF, picture); + QFETCH(QRectF, expected); + QCOMPARE(ImageDrop::keepInside(picture, QRectF(0, 0, 1000, 800)), expected); + } + + // Several pictures dropped together are spread side by side: a + // roughly square grid over the area, each shrunk into its cell. + void severalPicturesAreSpreadInAGrid_data() + { + QTest::addColumn("count"); + QTest::addColumn("columns"); + QTest::addColumn("rows"); + QTest::newRow("2") << 2 << 2 << 1; + QTest::newRow("3") << 3 << 2 << 2; + QTest::newRow("4") << 4 << 2 << 2; + QTest::newRow("5") << 5 << 3 << 2; + QTest::newRow("9") << 9 << 3 << 3; + } + + void severalPicturesAreSpreadInAGrid() + { + QFETCH(int, count); + QFETCH(int, columns); + QFETCH(int, rows); + const QRectF area(100, 50, 600, 480); + const QList sizes(count, QSizeF(3024, 4032)); + const QList rects = ImageDrop::gridLayout(sizes, area); + QCOMPARE(rects.size(), count); + + const QSizeF cell(area.width() / columns, area.height() / rows); + for (int i = 0 ; i < count ; ++i) + { + const QRectF r = rects.at(i); + // inside the area, proportions kept + QVERIFY(area.contains(r)); + QVERIFY(qAbs(r.width() / r.height() - 3024.0 / 4032.0) < 1e-6); + // centred in its own cell, row by row + const QPointF centre = area.topLeft() + QPointF(cell.width() * (i % columns + 0.5), + cell.height() * (i / columns + 0.5)); + QVERIFY(qAbs(r.center().x() - centre.x()) < 1e-6); + QVERIFY(qAbs(r.center().y() - centre.y()) < 1e-6); + // and overlapping no other + for (int j = i + 1 ; j < count ; ++j) + QVERIFY2(!r.intersects(rects.at(j)), qPrintable(QString("%1 and %2").arg(i).arg(j))); + } + } + + void smallPicturesAreNotEnlargedInTheGrid() + { + const QList rects = ImageDrop::gridLayout({QSizeF(40, 20), QSizeF(30, 30)}, + QRectF(0, 0, 600, 480)); + QCOMPARE(rects.at(0).size(), QSizeF(40, 20)); + QCOMPARE(rects.at(1).size(), QSizeF(30, 30)); + QCOMPARE(rects.at(0).center(), QPointF(150, 240)); + QCOMPARE(rects.at(1).center(), QPointF(450, 240)); + } + + void emptyDropHasNoLayout() + { + QVERIFY(ImageDrop::gridLayout({}, QRectF(0, 0, 600, 480)).isEmpty()); + } +}; + +QTEST_MAIN(tst_imagedrop) +#include "tst_imagedrop.moc"