Preserve imported image colours by default in dark mode

This commit is contained in:
saschbe
2026-10-06 21:09:41 +02:00
parent 0708338bce
commit 8ff5fbac0c
14 changed files with 553 additions and 15 deletions
+16
View File
@@ -66,6 +66,22 @@ include(../../cmake/fetch_pugixml.cmake)
enable_testing()
add_executable(tst_darkimagelayers tst_darkimagelayers.cpp
${QET_DIR}/sources/palettegraphicsview.cpp ${QET_DIR}/sources/palettegraphicsview.h
${QET_DIR}/sources/qetpalette.cpp)
add_test(NAME tst_darkimagelayers COMMAND tst_darkimagelayers)
target_include_directories(tst_darkimagelayers PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_darkimagelayers PRIVATE Qt::Test Qt::Widgets)
find_package(Python3 QUIET COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja")
add_test(NAME tst_darkimageintegration
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_image_dark_probe.py
--build ${CMAKE_BINARY_DIR} --binary $<TARGET_FILE:qelectrotech>
--source ${CMAKE_CURRENT_SOURCE_DIR}/image_dark_integration_probe.cpp
--fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet)
set_tests_properties(tst_darkimageintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE)
endif()
add_executable(${PROJECT_NAME} tst_mytest.cpp)
add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME})
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Linked by run_image_dark_probe.py against the application's own objects.
#include <QApplication>
#include <QBuffer>
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDomDocument>
#include <QFile>
#include <QFontDatabase>
#include <QPrinter>
#include <QPushButton>
#include <QSettings>
#include <QSvgGenerator>
#include <QTemporaryDir>
#include <cstdio>
#include <stdexcept>
#include "../../sources/diagram.h"
#include "../../sources/qetproject.h"
#include "../../sources/qetresult.h"
#include "../../sources/qetmessagebox.h"
#include "../../sources/qetpalette.h"
#include "../../sources/palettegraphicsview.h"
#include "../../sources/qetgraphicsitem/diagramimageitem.h"
#include "../../sources/ui/imagepropertieswidget.h"
#include "../../sources/PropertiesEditor/propertieseditordialog.h"
static void check(bool value, const char *message) { if (!value) throw std::runtime_error(message); }
static QByteArray png(const QPixmap &pixmap) {
QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); pixmap.save(&buffer,"PNG"); return bytes;
}
static QByteArray read(const QString &path) { QFile file(path); check(file.open(QIODevice::ReadOnly),"read export"); return file.readAll(); }
static DiagramImageItem *findImage(Diagram *diagram) {
for (auto *item : diagram->items()) if (auto *image = dynamic_cast<DiagramImageItem *>(item)) return image;
return nullptr;
}
int main(int argc, char **argv)
{
QApplication app(argc,argv);
std::freopen(argv[2],"w",stdout); std::freopen(argv[2],"a",stderr);
QTemporaryDir settings;
QSettings::setDefaultFormat(QSettings::IniFormat);
QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,settings.path());
QCoreApplication::setOrganizationName("QETImageDarkRegression");
QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true);
QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf");
try {
const QString output=QString::fromLocal8Bit(argv[3]);
QETProject project(QString::fromLocal8Bit(argv[1])); check(project.state()==QETProject::Ok,"load fixture");
auto *diagram=project.diagrams().first();
QImage original(40,30,QImage::Format_ARGB32); original.fill(QColor(197,81,33));
for(int y=0;y<30;++y) for(int x=10;x<20;++x) original.setPixelColor(x,y,QColor(197,81,33,128));
for(int y=10;y<16;++y) for(int x=24;x<30;++x) original.setPixelColor(x,y,Qt::transparent);
QPixmap source=QPixmap::fromImage(original);
auto *image=new DiagramImageItem(source); diagram->addItem(image);
image->setPos(55,55); image->setLabel("Image test"); image->setRotationAngle(17); image->setScaleFactorX(1.3); image->setSkewX(8);
check(!image->adaptToDarkTheme(),"new image defaults to original colours");
QDomDocument xml; auto legacy=image->toXml(xml);
check(!legacy.hasAttribute("adapt_to_dark_theme"),"default does not add an attribute to old files");
const QRect crop(4,3,24,20);
legacy.firstChild().setNodeValue(QString::fromLatin1(png(source.copy(crop)).toBase64()));
auto base=xml.createElement("image_base"); base.appendChild(xml.createTextNode(QString::fromLatin1(png(source).toBase64()))); legacy.appendChild(base);
auto cropElement=xml.createElement("crop"); cropElement.setAttribute("x",4); cropElement.setAttribute("y",3); cropElement.setAttribute("w",24); cropElement.setAttribute("h",20); legacy.appendChild(cropElement);
image->setAdaptToDarkTheme(true); check(image->fromXml(legacy),"load cropped legacy image");
check(!image->adaptToDarkTheme(),"missing attribute resets an existing image to original colours");
check(image->pixmap().size()==crop.size(),"crop survives");
const QByteArray pristine=png(image->pixmap()); const auto transform=image->transform(); const auto bounds=image->boundingRect();
ImagePropertiesWidget widget(image); auto *box=widget.findChild<QCheckBox *>("m_adapt_to_dark_theme_cb");
check(box && !box->isChecked(),"unchecked properties default");
check(box->text()==QString::fromUtf8("Adapter l’image au thème sombre"),"checkbox wording");
check(box->toolTip()==QString::fromUtf8("Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés."),"tooltip wording");
box->setChecked(true); check(image->adaptToDarkTheme(),"preview"); widget.reset(); check(!image->adaptToDarkTheme(),"reset cancels preview");
diagram->undoStack().clear(); box->setChecked(true); widget.apply(); check(image->adaptToDarkTheme(),"apply");
diagram->undoStack().undo(); check(!image->adaptToDarkTheme()&&!box->isChecked(),"undo updates checkbox");
diagram->undoStack().redo(); check(image->adaptToDarkTheme()&&box->isChecked(),"redo updates checkbox");
widget.setLiveEdit(true); box->click(); check(!image->adaptToDarkTheme(),"live edit");
check(diagram->undoStack().count()==2,"deliberate toggles do not merge");
diagram->undoStack().undo(); check(image->adaptToDarkTheme(),"live undo");
diagram->undoStack().redo(); check(!image->adaptToDarkTheme(),"live redo");
box->click(); check(image->adaptToDarkTheme()&&diagram->undoStack().count()==3,"edit after undo has current baseline");
for (auto role : {QDialogButtonBox::Cancel,QDialogButtonBox::Reset,QDialogButtonBox::Apply}) {
image->setAdaptToDarkTheme(false);
auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor);
editor->findChild<QCheckBox *>("m_adapt_to_dark_theme_cb")->setChecked(true);
dialog.findChild<QDialogButtonBox *>()->button(role)->click();
check(image->adaptToDarkTheme()==(role==QDialogButtonBox::Apply),"actual dialog button semantics");
}
check(png(image->pixmap())==pristine && image->transform()==transform && image->boundingRect()==bounds && image->label()=="Image test","choice cannot modify pixels, transform, crop or caption");
image->setAdaptToDarkTheme(false);
{
auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor);
editor->findChild<QCheckBox *>("m_adapt_to_dark_theme_cb")->setChecked(true);
dialog.reject(); check(!image->adaptToDarkTheme(),"Escape/title-bar rejection cancels preview");
}
PaletteGraphicsView view(diagram); view.setSceneRect(0,0,180,180); view.setFrameShape(QFrame::NoFrame);
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.setAlignment(Qt::AlignLeft|Qt::AlignTop); view.resize(180,180); view.setAttribute(Qt::WA_DontShowOnScreen); view.show();
for (bool dark : {false,true}) for(bool adapt : {false,true}) {
const auto palette=dark?QET::Palette::fusionDark():QET::Palette::fusionLight(); QApplication::setPalette(palette);
image->setAdaptToDarkTheme(adapt); QApplication::processEvents(); const auto shown=view.viewport()->grab().toImage();
shown.save(output+QString("/screen-%1-%2.png").arg(dark).arg(adapt));
const auto point=view.mapFromScene(image->mapToScene(QPointF(2,2))); const auto ratio=shown.devicePixelRatio();
QImage expected(1,1,QImage::Format_RGB32); expected.fill(QColor(197,81,33));
if(dark&&adapt) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text));
check(shown.pixelColor(qRound(point.x()*ratio),qRound(point.y()*ratio))==expected.pixelColor(0,0),"actual rotated/cropped image display colour");
project.setFilePath(output+QString("/project-%1-%2.qet").arg(dark).arg(adapt)); check(project.write().isOk(),"project save");
QETProject reopened(project.filePath()); check(reopened.state()==QETProject::Ok,"project reopen");
auto *loaded=findImage(reopened.diagrams().first()); check(loaded && loaded->adaptToDarkTheme()==adapt,"per-image persisted choice");
check(png(loaded->pixmap())==pristine && loaded->transform()==transform && loaded->label()=="Image test","reopen keeps original image/crop/transform/caption");
}
QApplication::setPalette(QET::Palette::fusionDark()); QImage exports[2];
for(int i=0;i<2;++i) {
image->setAdaptToDarkTheme(i); exports[i]=QImage(180,180,QImage::Format_RGB32); exports[i].fill(Qt::white);
{QPainter painter(&exports[i]); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));}
exports[i].save(output+QString("/export-%1.png").arg(i));
QSvgGenerator svg; svg.setFileName(output+QString("/export-%1.svg").arg(i)); svg.setSize(QSize(180,180)); svg.setViewBox(QRect(0,0,180,180));
{QPainter painter(&svg); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));}
QPrinter printer; printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(output+QString("/print-%1.pdf").arg(i)); printer.setResolution(72);
{QPainter painter(&printer); check(painter.isActive(),"PDF print painter"); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));}
}
check(exports[0]==exports[1],"raster export unchanged"); check(read(output+"/export-0.svg")==read(output+"/export-1.svg"),"SVG export unchanged");
std::puts("PASS: image defaults, XML crop/alpha/transform/label, properties preview/apply/reset/cancel, undo/redo and live edit, four display modes, project save/reopen, raster/SVG export, PDF print generation");
} catch(const std::exception &error) {std::fprintf(stderr,"FAIL: %s\n",error.what());return 1;}
return 0;
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Link the real image/widget integration probe using an existing Ninja app build.
Reuses app objects without duplicating the app or introducing a core-library
refactor. No application source or existing executable is replaced. The
generated manifest, probe binary, reports and export fixtures stay in build/.
"""
import argparse
import pathlib
import re
import shutil
import subprocess
import sys
import zlib
parser = argparse.ArgumentParser()
parser.add_argument('--build', required=True)
parser.add_argument('--binary', required=True)
parser.add_argument('--source', required=True)
parser.add_argument('--fixture', required=True)
args = parser.parse_args()
build = pathlib.Path(args.build).resolve()
binary = pathlib.Path(args.binary).resolve()
source = pathlib.Path(args.source).resolve()
fixture = pathlib.Path(args.fixture).resolve()
manifest = (build / 'build.ninja').read_text(encoding='utf-8')
match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE)
if not match:
sys.exit('Cannot find the application main object in the Ninja build.')
main_object = match.group(1)
probe_object = main_object.replace('main.cpp.', 'image_dark_integration_probe.cpp.')
probe_binary = binary.with_name('image_dark_integration_probe' + binary.suffix)
def escape(path):
return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ')
main_source = source.parents[2] / 'sources' / 'main.cpp'
manifest = manifest.replace(main_object, probe_object)
manifest = manifest.replace(escape(main_source), escape(source))
if binary.suffix:
manifest = manifest.replace(binary.name, probe_binary.name)
else:
old_target = escape(binary.name if binary.parent == build else binary)
new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary)
manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':')
old_file = binary.name if binary.parent == build else binary.as_posix()
new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix()
manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$',
' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE)
probe_manifest = build / 'image-dark-probe.ninja'
probe_manifest.write_text(manifest, encoding='utf-8')
ninja = shutil.which('ninja')
if not ninja:
sys.exit('Ninja is required for the integration probe.')
target = probe_binary.name if probe_binary.parent == build else str(probe_binary)
subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True)
output = build / 'image-dark-integration'
output.mkdir(exist_ok=True)
report = output / 'results.txt'
result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90)
if report.exists():
print(report.read_text(encoding='utf-8', errors='replace'))
if result.returncode == 0:
def print_streams(path):
streams = re.findall(rb'\nstream\r?\n(.*?)\nendstream', path.read_bytes(), re.DOTALL)
decoded = []
for stream in streams:
try:
data = zlib.decompress(stream)
except zlib.error:
data = stream
# Qt assigns every PDF a fresh XMP DocumentID (and dates).
# Metadata is not printing content and must not be compared
# as if it were a graphics/image/font stream.
if b'<x:xmpmeta' not in data:
decoded.append(data)
return decoded
first = print_streams(output / 'print-0.pdf')
second = print_streams(output / 'print-1.pdf')
if not first or first != second:
sys.exit('PDF printing changed the decoded graphics/image/font streams.')
print('PASS: PDF print graphics/image/font streams identical for both choices.')
try:
import pymupdf
except ImportError:
print('PDF raster comparison not run: optional PyMuPDF is unavailable.')
else:
documents = [pymupdf.open(output / f'print-{i}.pdf') for i in range(2)]
if len(documents[0]) != len(documents[1]):
sys.exit('PDF page counts differ.')
for page in range(len(documents[0])):
pixels = [doc[page].get_pixmap(dpi=144, alpha=False) for doc in documents]
if (pixels[0].width, pixels[0].height, pixels[0].samples) != (pixels[1].width, pixels[1].height, pixels[1].samples):
sys.exit('Rasterized PDF print pages differ.')
for i, image in enumerate(pixels):
image.save(output / f'print-{i}-page-{page}.png')
print('PASS: PDF print pages rendered at 144 dpi are pixel-identical.')
sys.exit(result.returncode)
+129
View File
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
#include "darkimagerendering.h"
#include "palettegraphicsview.h"
#include "qetpalette.h"
class Raster : public QGraphicsItem {
public:
QPixmap pixmap;
bool adapt = false;
explicit Raster(QGraphicsItem *parent = nullptr) : QGraphicsItem(parent) {
QImage image(40, 30, QImage::Format_ARGB32);
image.fill(QColor(197, 81, 33));
for (int y = 0; y < 30; ++y)
for (int x = 10; x < 20; ++x) image.setPixelColor(x, y, QColor(197,81,33,128));
for (int y = 0; y < 30; ++y)
for (int x = 30; x < 40; ++x) image.setPixelColor(x, y, Qt::transparent);
pixmap = QPixmap::fromImage(image);
}
QRectF boundingRect() const override { return QRectF(0,0,40,40); }
void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override {
DarkImageRendering::paintPixmap(p, pixmap, adapt);
p->fillRect(QRectF(0,34,8,4), Qt::black); // caption/decorations remain adapted
}
};
class tst_darkimagelayers : public QObject {
Q_OBJECT
static QColor sample(const QImage &image, const PaletteGraphicsView &view, QPointF scene) {
const QPoint point = view.mapFromScene(scene);
const qreal dpr = image.devicePixelRatio();
return image.pixelColor(qRound(point.x()*dpr), qRound(point.y()*dpr));
}
static void setup(PaletteGraphicsView &view) {
view.setFrameShape(QFrame::NoFrame);
view.setAlignment(Qt::AlignLeft | Qt::AlignTop);
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.resize(100,80); view.show();
}
private slots:
void cleanup() {
QApplication::setPalette(QET::Palette::fusionLight());
PaletteGraphicsView::setCustomBackgroundColor(false);
}
void modes_data() {
QTest::addColumn<bool>("dark"); QTest::addColumn<bool>("adapt"); QTest::addColumn<bool>("custom");
QTest::newRow("light-original") << false << false << false;
QTest::newRow("light-adapt") << false << true << false;
QTest::newRow("dark-original") << true << false << false;
QTest::newRow("dark-adapt") << true << true << false;
QTest::newRow("light-custom-original") << false << false << true;
QTest::newRow("light-custom-adapt") << false << true << true;
QTest::newRow("dark-custom-original") << true << false << true;
QTest::newRow("dark-custom-adapt") << true << true << true;
}
void modes() {
QFETCH(bool,dark); QFETCH(bool,adapt); QFETCH(bool,custom);
PaletteGraphicsView::setCustomBackgroundColor(custom);
const QPalette palette = dark ? QET::Palette::fusionDark() : QET::Palette::fusionLight();
QApplication::setPalette(palette);
const QColor background = custom ? QColor(80,90,100) : QColor(Qt::white);
QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(background);
auto *raster = new Raster; raster->adapt = adapt; raster->setPos(10,10); scene.addItem(raster);
scene.addRect(32,10,5,20,QPen(Qt::NoPen),QBrush(Qt::black))->setZValue(1);
PaletteGraphicsView view(&scene); setup(view);
QVERIFY(QTest::qWaitForWindowExposed(&view));
const QImage shown = view.viewport()->grab().toImage();
QImage expected(100,80,QImage::Format_RGB32);
expected.fill(custom ? background : (dark && !adapt ? palette.color(QPalette::Base) : QColor(Qt::white)));
QPainter p(&expected);
if (dark && custom && adapt) {
QImage adapted = raster->pixmap.toImage();
QET::Palette::invertLightnessLayer(adapted,palette.color(QPalette::Base),palette.color(QPalette::Text));
p.drawImage(QPoint(10,10),adapted);
} else p.drawPixmap(QPoint(10,10),raster->pixmap);
p.fillRect(QRect(32,10,5,20), dark && !adapt && !custom ? palette.color(QPalette::Text) : QColor(Qt::black));
p.end();
if (dark && adapt && !custom) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text));
for (const QPoint point : {QPoint(15,15),QPoint(25,15),QPoint(45,15),QPoint(34,15)})
QCOMPARE(sample(shown,view,point), expected.pixelColor(point));
QCOMPARE(sample(shown,view,QPoint(13,45)), dark && !custom ? palette.color(QPalette::Text) : QColor(Qt::black));
}
void parentClippingRotationOpacityAndOverlap() {
const auto palette = QET::Palette::fusionDark(); QApplication::setPalette(palette);
QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white);
auto *parent = scene.addRect(0,0,25,40,QPen(Qt::NoPen),QBrush(Qt::NoBrush));
parent->setFlag(QGraphicsItem::ItemClipsChildrenToShape); parent->setPos(35,15); parent->setRotation(12);
auto *first = new Raster(parent); first->setOpacity(0.5);
auto *second = new Raster; second->setPos(36,30); scene.addItem(second); second->setZValue(2);
QImage blue(40,30,QImage::Format_ARGB32); blue.fill(QColor(20,80,210,128)); second->pixmap=QPixmap::fromImage(blue);
PaletteGraphicsView view(&scene); setup(view); QVERIFY(QTest::qWaitForWindowExposed(&view));
const auto shown = view.viewport()->grab().toImage();
// Original pixels blend once over the dark sheet, then the upper
// original image blends once over those pixels. No inverse transform.
QImage reference(1,1,QImage::Format_RGB32); reference.fill(palette.color(QPalette::Base));
QPainter p(&reference); p.setOpacity(0.5); p.fillRect(reference.rect(),QColor(197,81,33)); p.end();
QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,5))),reference.pixelColor(0,0));
QPainter upper(&reference); upper.fillRect(reference.rect(),QColor(20,80,210,128)); upper.end();
QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,20))),reference.pixelColor(0,0));
// Pixel in the child's transparent stripe / beyond the parent's clip.
QCOMPARE(sample(shown,view,first->mapToScene(QPointF(28,5))),palette.color(QPalette::Base));
first->setPos(3,0); scene.update(); QApplication::processEvents();
QVERIFY(!view.viewport()->grab().isNull());
}
void alphaMapping() {
QImage image(2,1,QImage::Format_ARGB32_Premultiplied);
image.setPixelColor(0,0,QColor(0,0,0,128)); image.setPixelColor(1,0,Qt::transparent);
QET::Palette::invertLightnessLayer(image,QColor(30,30,30),QColor(220,220,220));
QCOMPARE(image.pixelColor(0,0),QColor(220,220,220,128));
QCOMPARE(image.pixelColor(1,0).alpha(),0);
}
void exportUnaffected() {
QApplication::setPalette(QET::Palette::fusionDark());
QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white);
auto *raster=new Raster; raster->setPos(10,10); scene.addItem(raster);
QImage results[2];
for(int i=0;i<2;++i) {
raster->adapt=i; results[i]=QImage(100,80,QImage::Format_RGB32); results[i].fill(Qt::white);
QPainter p(&results[i]); scene.render(&p,QRectF(0,0,100,80),scene.sceneRect());
}
QCOMPARE(results[0],results[1]);
QCOMPARE(results[0].pixelColor(15,15),QColor(197,81,33));
}
};
QTEST_MAIN(tst_darkimagelayers)
#include "tst_darkimagelayers.moc"