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"