From 034013ff67eb25f5cf6198fafe9f255ad0db58b6 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:01:05 +0200 Subject: [PATCH 1/3] Drop picture files on a folio to add them Picture files (png, jpg, bmp, svg) dragged from the file manager onto a folio are added there: the first centred on the drop point, the others cascaded from it, and one undo step removes them all. A picture larger than the visible part of the folio is scaled down to fit it. Files that cannot be used are listed once after the others have been placed, and a drop holding only other files (a .qet project) still reaches the main window, which opens it. The checks a picture must pass before it is embedded in the project move into ImageDrop::load and are now shared by the drop, the add image dialog and the script API: a regular file of at most 10 MB, and at most 64 megapixels, read from the header before any pixel is allocated. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/diagramevent/diagrameventaddimage.cpp | 6 +- sources/diagramview.cpp | 79 +++++++- sources/diagramview.h | 1 + sources/imagedrop.cpp | 103 +++++++++++ sources/imagedrop.h | 72 ++++++++ sources/scripting/qetscriptapi.cpp | 19 +- tests/qttest/CMakeLists.txt | 11 ++ tests/qttest/tst_imagedrop.cpp | 172 ++++++++++++++++++ 9 files changed, 447 insertions(+), 18 deletions(-) create mode 100644 sources/imagedrop.cpp create mode 100644 sources/imagedrop.h create mode 100644 tests/qttest/tst_imagedrop.cpp 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 fe586a03b..21e651701 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,63 @@ void DiagramView::handleTextDrop(QDropEvent *e) { iti, m_diagram, mapToScene(e->position().toPoint()))); } +/** + @brief DiagramView::handleImageFilesDrop + Add the picture files dropped from the file manager. The first one is + centred on the drop point and the others are cascaded from it; a + picture larger than the visible part of the folio is scaled down to + fit it. 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 QSizeF available = viewedSceneRect().size(); + + auto *undo = new QUndoCommand(); + QStringList refused; + int placed = 0; + 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); + continue; + } + + auto *item = new DiagramImageItem(QPixmap::fromImage(image)); + const qreal scale = ImageDrop::fitScale(item -> boundingRect().size(), available); + if (scale < 1.0) { + item -> setScaleFactorX(scale); + item -> setScaleFactorY(scale); + } + // Centre the picture, as transformed, on its drop point. + const QPointF centre = item -> mapRectToScene(item -> boundingRect()).center() - item -> pos(); + new AddGraphicsObjectCommand(item, m_diagram, + drop_pos + ImageDrop::cascadeOffset(placed) - centre, undo); + ++placed; + } + + 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..0c36f25f4 --- /dev/null +++ b/sources/imagedrop.cpp @@ -0,0 +1,103 @@ +/* + 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 + +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; +} + +qreal ImageDrop::fitScale(const QSizeF &size, const QSizeF &available) +{ + if (size.isEmpty() || available.isEmpty()) + return 1.0; + const qreal scale = std::min(available.width() * fitMargin / size.width(), + available.height() * fitMargin / size.height()); + return std::min(1.0, scale); +} + +QPointF ImageDrop::cascadeOffset(int index) +{ + return QPointF(cascadeStep * index, cascadeStep * index); +} diff --git a/sources/imagedrop.h b/sources/imagedrop.h new file mode 100644 index 000000000..9d7afaab7 --- /dev/null +++ b/sources/imagedrop.h @@ -0,0 +1,72 @@ +/* + 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 + +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; + /// Offset between the pictures of one drop, in scene units. + constexpr qreal cascadeStep = 20.0; + /// Share of the visible area a large dropped picture is fitted into. + constexpr qreal fitMargin = 0.9; + + /// 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); + + /** + Scale that fits a picture of @a size into fitMargin of @a available, + keeping its proportions; 1.0 when it already fits. Pictures are + never enlarged. + */ + qreal fitScale(const QSizeF &size, const QSizeF &available); + + /// Offset of the @a index-th picture of one drop from the first one. + QPointF cascadeOffset(int index); +} + +#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 b33b94130..b147f2131 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1079,6 +1079,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..514299639 --- /dev/null +++ b/tests/qttest/tst_imagedrop.cpp @@ -0,0 +1,172 @@ +/* + 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)); + } + + void largePicturesAreFittedIntoTheView_data() + { + QTest::addColumn("size"); + QTest::addColumn("available"); + QTest::addColumn("scale"); + QTest::newRow("small, kept") << QSizeF(100, 50) << QSizeF(1000, 800) << 1.0; + QTest::newRow("wide") << QSizeF(4000, 1000) << QSizeF(1000, 800) << 0.225; + QTest::newRow("tall") << QSizeF(500, 3200) << QSizeF(1000, 800) << 0.225; + QTest::newRow("exactly the margin") << QSizeF(900, 720) << QSizeF(1000, 800) << 1.0; + QTest::newRow("empty view") << QSizeF(4000, 1000) << QSizeF() << 1.0; + } + + void largePicturesAreFittedIntoTheView() + { + QFETCH(QSizeF, size); + QFETCH(QSizeF, available); + QFETCH(qreal, scale); + QCOMPARE(ImageDrop::fitScale(size, available), scale); + } + + void picturesOfOneDropCascade() + { + QCOMPARE(ImageDrop::cascadeOffset(0), QPointF(0, 0)); + QCOMPARE(ImageDrop::cascadeOffset(2), QPointF(2 * ImageDrop::cascadeStep, + 2 * ImageDrop::cascadeStep)); + } +}; + +QTEST_MAIN(tst_imagedrop) +#include "tst_imagedrop.moc" From c76bc5eb4e8921792dc4f098051527970b46b898 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:24:38 +0200 Subject: [PATCH 2/3] Fit dropped pictures into the folio frame, with a margin A dropped picture too large for the folio is now scaled to leave 20 % of the drawing area free on every side, instead of being fitted to the visible part of the view, and every dropped picture is kept inside the frame -- a scaled-down one also off that margin. Co-Authored-By: Claude Opus 5.5 --- sources/diagramview.cpp | 22 ++++++++------ sources/imagedrop.cpp | 30 +++++++++++++++++--- sources/imagedrop.h | 19 ++++++++++--- tests/qttest/tst_imagedrop.cpp | 52 +++++++++++++++++++++++++++------- 4 files changed, 96 insertions(+), 27 deletions(-) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 21e651701..7602a1ca8 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -430,9 +430,10 @@ void DiagramView::handleTextDrop(QDropEvent *e) { /** @brief DiagramView::handleImageFilesDrop Add the picture files dropped from the file manager. The first one is - centred on the drop point and the others are cascaded from it; a - picture larger than the visible part of the folio is scaled down to - fit it. One undo step removes them all. Files that cannot be used are + centred on the drop point and the others are cascaded from it. A + picture too large for the folio is scaled down to leave a free margin + of ImageDrop::frameMargin to the frame on every side, and every + picture is kept inside the frame. 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 */ @@ -443,7 +444,7 @@ void DiagramView::handleImageFilesDrop(QDropEvent *e) const QStringList files = ImageDrop::imageFiles(e -> mimeData()); const QPointF drop_pos = mapToScene(e -> position().toPoint()); - const QSizeF available = viewedSceneRect().size(); + const QRectF frame = m_diagram -> border_and_titleblock.insideBorderRect(); auto *undo = new QUndoCommand(); QStringList refused; @@ -458,15 +459,18 @@ void DiagramView::handleImageFilesDrop(QDropEvent *e) } auto *item = new DiagramImageItem(QPixmap::fromImage(image)); - const qreal scale = ImageDrop::fitScale(item -> boundingRect().size(), available); + const qreal scale = ImageDrop::fitScale(item -> boundingRect().size(), frame); if (scale < 1.0) { item -> setScaleFactorX(scale); item -> setScaleFactorY(scale); } - // Centre the picture, as transformed, on its drop point. - const QPointF centre = item -> mapRectToScene(item -> boundingRect()).center() - item -> pos(); - new AddGraphicsObjectCommand(item, m_diagram, - drop_pos + ImageDrop::cascadeOffset(placed) - centre, undo); + // Centre the picture, as transformed, on its drop point, then keep + // it inside the frame -- a scaled-down one also off the margin. + QRectF rect = item -> mapRectToScene(item -> boundingRect()); + const QPointF offset = rect.topLeft() - item -> pos(); + rect.moveCenter(drop_pos + ImageDrop::cascadeOffset(placed)); + rect = ImageDrop::keepInside(rect, scale < 1.0 ? ImageDrop::innerFrame(frame) : frame); + new AddGraphicsObjectCommand(item, m_diagram, rect.topLeft() - offset, undo); ++placed; } diff --git a/sources/imagedrop.cpp b/sources/imagedrop.cpp index 0c36f25f4..871936662 100644 --- a/sources/imagedrop.cpp +++ b/sources/imagedrop.cpp @@ -88,15 +88,37 @@ QImage ImageDrop::load(const QString &path, QString *error) return image; } -qreal ImageDrop::fitScale(const QSizeF &size, const QSizeF &available) +QRectF ImageDrop::innerFrame(const QRectF &frame) { - if (size.isEmpty() || available.isEmpty()) + 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(available.width() * fitMargin / size.width(), - available.height() * fitMargin / size.height()); + 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; +} + QPointF ImageDrop::cascadeOffset(int index) { return QPointF(cascadeStep * index, cascadeStep * index); diff --git a/sources/imagedrop.h b/sources/imagedrop.h index 9d7afaab7..431791d86 100644 --- a/sources/imagedrop.h +++ b/sources/imagedrop.h @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -38,8 +39,9 @@ namespace ImageDrop constexpr qint64 maxPixels = 64LL * 1024 * 1024; /// Offset between the pictures of one drop, in scene units. constexpr qreal cascadeStep = 20.0; - /// Share of the visible area a large dropped picture is fitted into. - constexpr qreal fitMargin = 0.9; + /// 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(); @@ -58,12 +60,21 @@ namespace ImageDrop */ 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 fitMargin of @a available, + 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 QSizeF &available); + 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); /// Offset of the @a index-th picture of one drop from the first one. QPointF cascadeOffset(int index); diff --git a/tests/qttest/tst_imagedrop.cpp b/tests/qttest/tst_imagedrop.cpp index 514299639..4c357950f 100644 --- a/tests/qttest/tst_imagedrop.cpp +++ b/tests/qttest/tst_imagedrop.cpp @@ -140,24 +140,56 @@ private slots: QVERIFY2(error.contains(reason), qPrintable(error)); } - void largePicturesAreFittedIntoTheView_data() + // 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("available"); QTest::addColumn("scale"); - QTest::newRow("small, kept") << QSizeF(100, 50) << QSizeF(1000, 800) << 1.0; - QTest::newRow("wide") << QSizeF(4000, 1000) << QSizeF(1000, 800) << 0.225; - QTest::newRow("tall") << QSizeF(500, 3200) << QSizeF(1000, 800) << 0.225; - QTest::newRow("exactly the margin") << QSizeF(900, 720) << QSizeF(1000, 800) << 1.0; - QTest::newRow("empty view") << QSizeF(4000, 1000) << QSizeF() << 1.0; + // 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 largePicturesAreFittedIntoTheView() + void largePicturesLeaveAMarginToTheFrame() { QFETCH(QSizeF, size); - QFETCH(QSizeF, available); QFETCH(qreal, scale); - QCOMPARE(ImageDrop::fitScale(size, available), 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); } void picturesOfOneDropCascade() From 360065036cb4d8c853f42421ca5fbbbe21d2d057 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 23:08:23 +0200 Subject: [PATCH 3/3] Spread several dropped pictures over the folio in a grid Pictures dropped together were cascaded 20 px apart from the drop point; once scaled to fit the folio they lay almost on top of each other. They are now laid out side by side in a roughly square grid over the folio's drawing area, inside the same 20 % margin, each shrunk into its cell with its proportions kept. A single dropped picture still lands on the drop point. Co-Authored-By: Claude Opus 5.5 --- sources/diagramview.cpp | 60 ++++++++++++++++++++++------------ sources/imagedrop.cpp | 24 ++++++++++++-- sources/imagedrop.h | 16 ++++++--- tests/qttest/tst_imagedrop.cpp | 58 +++++++++++++++++++++++++++++--- 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 7602a1ca8..ef7618a85 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -429,11 +429,12 @@ void DiagramView::handleTextDrop(QDropEvent *e) { /** @brief DiagramView::handleImageFilesDrop - Add the picture files dropped from the file manager. The first one is - centred on the drop point and the others are cascaded from it. A - picture too large for the folio is scaled down to leave a free margin - of ImageDrop::frameMargin to the frame on every side, and every - picture is kept inside the frame. One undo step removes them all. Files that cannot be used are + 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 */ @@ -446,32 +447,51 @@ void DiagramView::handleImageFilesDrop(QDropEvent *e) const QPointF drop_pos = mapToScene(e -> position().toPoint()); const QRectF frame = m_diagram -> border_and_titleblock.insideBorderRect(); - auto *undo = new QUndoCommand(); QStringList refused; - int placed = 0; + QList items; for (const QString &file : files) { QString error; const QImage image = ImageDrop::load(file, &error); - if (image.isNull()) { + if (image.isNull()) refused << QStringLiteral("%1 : %2").arg(QFileInfo(file).fileName(), error); - continue; - } + else + items << new DiagramImageItem(QPixmap::fromImage(image)); + } - auto *item = new DiagramImageItem(QPixmap::fromImage(image)); - const qreal scale = ImageDrop::fitScale(item -> boundingRect().size(), frame); + // 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); } - // Centre the picture, as transformed, on its drop point, then keep - // it inside the frame -- a scaled-down one also off the margin. - QRectF rect = item -> mapRectToScene(item -> boundingRect()); - const QPointF offset = rect.topLeft() - item -> pos(); - rect.moveCenter(drop_pos + ImageDrop::cascadeOffset(placed)); - rect = ImageDrop::keepInside(rect, scale < 1.0 ? ImageDrop::innerFrame(frame) : frame); - new AddGraphicsObjectCommand(item, m_diagram, rect.topLeft() - offset, undo); - ++placed; + const QPointF offset = item -> mapRectToScene(item -> boundingRect()).topLeft() - item -> pos(); + new AddGraphicsObjectCommand(item, m_diagram, targets.at(i).topLeft() - offset, undo); } if (placed) { diff --git a/sources/imagedrop.cpp b/sources/imagedrop.cpp index 871936662..06aa5ed85 100644 --- a/sources/imagedrop.cpp +++ b/sources/imagedrop.cpp @@ -24,6 +24,7 @@ #include #include +#include namespace { @@ -119,7 +120,26 @@ QRectF ImageDrop::keepInside(const QRectF &picture, const QRectF &area) return result; } -QPointF ImageDrop::cascadeOffset(int index) +QList ImageDrop::gridLayout(const QList &sizes, const QRectF &area) { - return QPointF(cascadeStep * index, cascadeStep * index); + 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 index 431791d86..22b944274 100644 --- a/sources/imagedrop.h +++ b/sources/imagedrop.h @@ -22,6 +22,7 @@ #include #include #include +#include #include class QMimeData; @@ -37,8 +38,9 @@ namespace ImageDrop constexpr qint64 maxFileBytes = 10LL * 1024 * 1024; /// Refuses decompression bombs before any pixel is allocated. constexpr qint64 maxPixels = 64LL * 1024 * 1024; - /// Offset between the pictures of one drop, in scene units. - constexpr qreal cascadeStep = 20.0; + /// 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; @@ -76,8 +78,14 @@ namespace ImageDrop */ QRectF keepInside(const QRectF &picture, const QRectF &area); - /// Offset of the @a index-th picture of one drop from the first one. - QPointF cascadeOffset(int index); + /** + 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/tests/qttest/tst_imagedrop.cpp b/tests/qttest/tst_imagedrop.cpp index 4c357950f..41945b203 100644 --- a/tests/qttest/tst_imagedrop.cpp +++ b/tests/qttest/tst_imagedrop.cpp @@ -192,11 +192,61 @@ private slots: QCOMPARE(ImageDrop::keepInside(picture, QRectF(0, 0, 1000, 800)), expected); } - void picturesOfOneDropCascade() + // Several pictures dropped together are spread side by side: a + // roughly square grid over the area, each shrunk into its cell. + void severalPicturesAreSpreadInAGrid_data() { - QCOMPARE(ImageDrop::cascadeOffset(0), QPointF(0, 0)); - QCOMPARE(ImageDrop::cascadeOffset(2), QPointF(2 * ImageDrop::cascadeStep, - 2 * ImageDrop::cascadeStep)); + 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()); } };