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
+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"