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 <noreply@anthropic.com>
This commit is contained in:
Beat Hangartner
2026-10-04 19:01:05 +02:00
parent e89c69719a
commit 034013ff67
9 changed files with 447 additions and 18 deletions
+2
View File
@@ -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
@@ -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;
}
+76 -3
View File
@@ -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 <QApplication>
#include <QDropEvent>
#include <QMessageBox>
#include <QPainter>
#include <QPointer>
#include <QSet>
@@ -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
*/
+1
View File
@@ -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;
+103
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "imagedrop.h"
#include <QCoreApplication>
#include <QFileInfo>
#include <QImageReader>
#include <QMimeData>
#include <QUrl>
#include <algorithm>
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);
}
+72
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef IMAGEDROP_H
#define IMAGEDROP_H
#include <QImage>
#include <QPointF>
#include <QSizeF>
#include <QStringList>
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
+6 -13
View File
@@ -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<Diagram *> 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;
}
+11
View File
@@ -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
+172
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "imagedrop.h"
#include <QtEndian>
#include <QtTest>
#include <QImage>
#include <QMimeData>
#include <QTemporaryDir>
#include <QUrl>
/**
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<QUrl> 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<quint32>(54, b.data() + 2);
qToLittleEndian<quint32>(54, b.data() + 10);
qToLittleEndian<quint32>(40, b.data() + 14);
qToLittleEndian<qint32>(width, b.data() + 18);
qToLittleEndian<qint32>(height, b.data() + 22);
qToLittleEndian<quint16>(1, b.data() + 26);
qToLittleEndian<quint16>(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"), "<project/>");
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<QMimeData> 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<QMimeData> 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<QString>("file");
QTest::addColumn<QString>("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<QSizeF>("size");
QTest::addColumn<QSizeF>("available");
QTest::addColumn<qreal>("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"