Draw the folio with inverted lightness on a dark palette

On a dark palette the folio stayed a white sheet with black ink, and
the white/gray toggle only darkened the sheet while the ink stayed
black. DiagramView now renders each repaint into an image and inverts
its lightness before blitting it: white becomes the palette's Base,
black becomes its Text, and colored conductors and elements keep their
hue. The document, printing and export are untouched; only the screen
rendering changes, and only while the palette is dark.

QET::Palette::invertLightness does the inversion in one integer pass
(adding 255 - max - min to the three channels inverts the HSL lightness
and keeps hue and saturation), then stretches the result between the
sheet and ink colors through three lookup tables. A 4K viewport costs
about 9 ms in a release build. QGraphicsView::render() skips the
selection rubber band, so the view draws it again after the inversion.

Tests in tst_qetpalette: invertLightnessMapsSheetAndInk,
invertedViewReadsOnDarkSheet, invertLightnessSpeed.
This commit is contained in:
Jeff Patterson
2026-09-19 12:47:10 -05:00
parent dfe56cddbd
commit 85dc638a42
5 changed files with 248 additions and 1 deletions
+81 -1
View File
@@ -29,6 +29,7 @@
#include "qetgraphicsitem/conductortextitem.h"
#include "qetgraphicsitem/independenttextitem.h"
#include "qeticons.h"
#include "qetpalette.h"
#include "titleblock/integrationmovetemplateshandler.h"
#include "ui/diagrampropertiesdialog.h"
#include "ui/multipastedialog.h"
@@ -39,8 +40,13 @@
#include "ElementsCollection/xmlelementcollection.h"
#include "NameList/nameslist.h"
#include "elementdialog.h"
#include <QApplication>
#include <QDropEvent>
#include <QPainter>
#include <QPointer>
#include <QStyleHintReturnMask>
#include <QStyleOptionRubberBand>
#include <QtMath>
/**
Constructeur
@@ -1081,6 +1087,77 @@ bool DiagramView::event(QEvent *e) {
return(QGraphicsView::event(e));
}
/**
@brief DiagramView::canvasIsInverted
@return true when the folio must be drawn with its lightness inverted,
i.e. when the application palette is dark. The document keeps its own
colors; only the screen rendering changes, so printing and exporting
stay black on white.
*/
bool DiagramView::canvasIsInverted() const
{
return QET::Palette::isDark(qApp->palette());
}
/**
@brief DiagramView::paintInverted
Render \a area of the viewport into an off-screen image, invert the
lightness of that image and blit it to the viewport. Inverting the
finished rendering turns the white sheet black and the black ink white
in one pass, and keeps the hue of colored conductors and elements.
@param area the part of the viewport to repaint, in viewport coordinates
*/
void DiagramView::paintInverted(const QRect &area)
{
const QRect rect = area.intersected(viewport()->rect());
if (rect.isEmpty())
return;
const qreal ratio = viewport()->devicePixelRatioF();
QImage buffer(qCeil(rect.width() * ratio), qCeil(rect.height() * ratio),
QImage::Format_RGB32);
buffer.setDevicePixelRatio(ratio);
QPainter buffer_painter(&buffer);
buffer_painter.setRenderHints(renderHints());
render(&buffer_painter, QRectF(QPointF(0, 0), QSizeF(rect.size())),
rect, Qt::IgnoreAspectRatio);
buffer_painter.end();
QET::Palette::invertLightness(buffer, palette().color(QPalette::Base),
palette().color(QPalette::Text));
QPainter painter(viewport());
painter.drawImage(rect.topLeft(), buffer);
drawRubberBand(painter);
}
/**
@brief DiagramView::drawRubberBand
Draw the selection rubber band the way QGraphicsView::paintEvent does.
Rendering the view into an off-screen image skips it, so it is drawn
here instead, after the inversion, and keeps the palette colors.
@param painter a painter on the viewport
*/
void DiagramView::drawRubberBand(QPainter &painter)
{
const QRect band = rubberBandRect();
if (band.isNull())
return;
QStyleOptionRubberBand option;
option.initFrom(viewport());
option.rect = band;
option.shape = QRubberBand::Rectangle;
QStyleHintReturnMask mask;
if (viewport()->style()->styleHint(QStyle::SH_RubberBand_Mask, &option,
viewport(), &mask))
painter.setClipRegion(mask.region, Qt::IntersectClip);
viewport()->style()->drawControl(QStyle::CE_RubberBand, &option,
&painter, viewport());
}
/**
@brief DiagramView::paintEvent
Reimplemented from QGraphicsView
@@ -1088,7 +1165,10 @@ bool DiagramView::event(QEvent *e) {
*/
void DiagramView::paintEvent(QPaintEvent *event)
{
QGraphicsView::paintEvent(event);
if (canvasIsInverted())
paintInverted(event->rect());
else
QGraphicsView::paintEvent(event);
if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
{
+4
View File
@@ -30,6 +30,7 @@ class QETDiagramEditor;
class DVEventInterface;
class QInputEvent;
class QGestureEvent;
class QPainter;
/**
This class provides a widget to render an electric diagram in an editable,
@@ -97,6 +98,9 @@ class DiagramView : public QGraphicsView
virtual bool selectedItemHasFocus();
private:
bool canvasIsInverted() const;
void paintInverted(const QRect &area);
void drawRubberBand(QPainter &painter);
void handleElementDrop(QDropEvent *);
void handleTitleBlockDrop(QDropEvent *);
void handleTextDrop(QDropEvent *);
+44
View File
@@ -18,7 +18,9 @@
#include "qetpalette.h"
#include <QColor>
#include <QImage>
#include <QStyle>
#include <cmath>
namespace {
@@ -75,6 +77,48 @@ bool QET::Palette::isDark(const QPalette &palette)
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128;
}
void QET::Palette::invertLightness(QImage &image, const QColor &sheet,
const QColor &ink)
{
if (image.format() != QImage::Format_RGB32)
image.convertTo(QImage::Format_RGB32);
// One table per channel maps the inverted value (0 = was white,
// 255 = was black) onto the sheet..ink span.
uchar red_of[256], green_of[256], blue_of[256];
for (int v = 0; v < 256; ++v) {
red_of[v] = uchar(sheet.red() + (ink.red() - sheet.red()) * v / 255);
green_of[v] = uchar(sheet.green() + (ink.green() - sheet.green()) * v / 255);
blue_of[v] = uchar(sheet.blue() + (ink.blue() - sheet.blue()) * v / 255);
}
/* Inverting the lightness of an HSL color while keeping its hue and
saturation leaves the distance between the highest and the lowest
channel unchanged, so it comes down to one offset per pixel:
c + 255 - max - min. The offset turns the highest channel into
255 - min and the lowest into 255 - max, so no channel can leave
the 0..255 range and no clamping is needed. The loop runs on every
repaint of a folio, hence the plain integer arithmetic. */
for (int y = 0; y < image.height(); ++y) {
quint32 *line = reinterpret_cast<quint32 *>(image.scanLine(y));
for (int x = 0, width = image.width(); x < width; ++x) {
const quint32 pixel = line[x];
const int red = (pixel >> 16) & 0xff;
const int green = (pixel >> 8) & 0xff;
const int blue = pixel & 0xff;
int highest = red > green ? red : green;
int lowest = red < green ? red : green;
if (blue > highest) highest = blue;
if (blue < lowest) lowest = blue;
const int offset = 255 - highest - lowest;
line[x] = 0xff000000u
| (quint32(red_of[red + offset]) << 16)
| (quint32(green_of[green + offset]) << 8)
| quint32(blue_of[blue + offset]);
}
}
}
double QET::Palette::contrastRatio(const QColor &a, const QColor &b)
{
double lighter = relativeLuminance(a);
+13
View File
@@ -22,6 +22,7 @@
#include <QPalette>
#include <QPixmap>
class QImage;
class QStyle;
/**
@@ -54,6 +55,18 @@ namespace QET {
*/
bool isDark(const QPalette &palette);
/**
Invert the lightness of every pixel of \a image, keeping its hue
and saturation, then stretch the result between two colors: pure
white becomes \a sheet, pure black becomes \a ink, and a red
line stays red, only lighter. Made for a rendering of a white
sheet that has to read on a dark palette, with sheet = Base and
ink = Text. The image must be opaque; an image in another format
is converted to RGB32 first.
*/
void invertLightness(QImage &image, const QColor &sheet = Qt::black,
const QColor &ink = Qt::white);
/**
WCAG 2 contrast ratio between two opaque colors, from 1 (equal)
to 21 (black on white). Normal text needs at least 4.5, large
+106
View File
@@ -19,6 +19,8 @@
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
@@ -70,6 +72,9 @@ class tst_qetpalette : public QObject
void invertedLightnessKeepsHueAndAlpha();
void elementPreviewReadsOnBothPalettes();
void previewDelegateAdaptsLineArtOnly();
void invertLightnessMapsSheetAndInk();
void invertedViewReadsOnDarkSheet();
void invertLightnessSpeed();
private:
static void addPaletteRows();
@@ -437,6 +442,107 @@ void tst_qetpalette::previewDelegateAdaptsLineArtOnly()
QVERIFY2(blue, "the colored icon lost its color");
}
/**
The lightness inversion sends white to the sheet color and black to
the ink color, lands a mid gray between the two, and keeps the hue of
a colored line. Without sheet and ink it is a plain inversion.
*/
void tst_qetpalette::invertLightnessMapsSheetAndInk()
{
const QColor sheet(30, 30, 30);
const QColor ink(220, 220, 220);
QImage image(4, 1, QImage::Format_ARGB32);
image.setPixelColor(0, 0, Qt::white);
image.setPixelColor(1, 0, Qt::black);
image.setPixelColor(2, 0, QColor(128, 128, 128));
image.setPixelColor(3, 0, QColor(200, 0, 0));
QImage plain = image;
QET::Palette::invertLightness(image, sheet, ink);
QCOMPARE(image.format(), QImage::Format_RGB32);
QCOMPARE(image.pixelColor(0, 0), sheet);
QCOMPARE(image.pixelColor(1, 0), ink);
const int middle = (sheet.red() + ink.red()) / 2;
QVERIFY(qAbs(image.pixelColor(2, 0).red() - middle) <= 2);
const QColor red = image.pixelColor(3, 0);
QCOMPARE(red.hslHue(), 0);
QVERIFY2(red.hslSaturationF() > 0.5, qPrintable(red.name()));
QVERIFY2(red.lightness() > QColor(200, 0, 0).lightness(), qPrintable(red.name()));
QET::Palette::invertLightness(plain);
QCOMPARE(plain.pixelColor(0, 0), QColor(Qt::black));
QCOMPARE(plain.pixelColor(1, 0), QColor(Qt::white));
QCOMPARE(plain.pixelColor(3, 0), QColor(255, 55, 55));
}
/**
A view rendered the way DiagramView does it on a dark palette: a part
of the viewport goes into an image, which is inverted between the
palette's Base and Text. The white sheet comes out as Base, black
lines read at text contrast, and a blue box is still blue.
*/
void tst_qetpalette::invertedViewReadsOnDarkSheet()
{
QGraphicsScene scene(0, 0, 200, 120);
scene.setBackgroundBrush(Qt::white);
scene.addLine(10, 60, 190, 60, QPen(Qt::black, 2));
scene.addRect(20, 20, 40, 20, QPen(Qt::NoPen), QBrush(QColor(30, 96, 176)));
QGraphicsView view(&scene);
view.setFrameShape(QFrame::NoFrame);
view.setAlignment(Qt::AlignLeft | Qt::AlignTop);
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.resize(200, 120);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
// A part of the viewport that is not at its origin, as a partial
// repaint after a scroll would be.
const QRect area(10, 10, 100, 60);
QImage buffer(area.size(), QImage::Format_RGB32);
QPainter painter(&buffer);
view.render(&painter, QRectF(QPointF(0, 0), QSizeF(area.size())), area);
painter.end();
const QPalette dark = QET::Palette::fusionDark();
const QColor base = dark.color(QPalette::Active, QPalette::Base);
const QColor text = dark.color(QPalette::Active, QPalette::Text);
QET::Palette::invertLightness(buffer, base, text);
QHash<QRgb, int> histogram;
for (int y = 0; y < buffer.height(); ++y)
for (int x = 0; x < buffer.width(); ++x)
++histogram[buffer.pixel(x, y)];
QRgb dominant = 0;
for (auto it = histogram.cbegin(); it != histogram.cend(); ++it)
if (it.value() > histogram.value(dominant)) dominant = it.key();
QCOMPARE(QColor(dominant), base);
const double contrast = inkContrast(buffer, buffer.rect());
QVERIFY2(contrast >= QET::Palette::contrastRatio(base, text) - 0.5,
qPrintable(QString("ink reads %1:1 on the dark sheet").arg(contrast)));
// The box at scene (20..60, 20..40) sits at (10..50, 10..30) in the buffer.
const QColor box = buffer.pixelColor(30, 20);
QVERIFY2(box.hslSaturationF() > 0.3 && box.blue() > box.red() + 60,
qPrintable(QString("the blue box became %1").arg(box.name())));
}
/**
The inversion runs on every repaint of the folio, so a 4K viewport
has to cost a few milliseconds. Reported, not asserted: the bound
depends on the build box.
*/
void tst_qetpalette::invertLightnessSpeed()
{
QImage image(3840, 2000, QImage::Format_RGB32);
image.fill(Qt::white);
QBENCHMARK {
QET::Palette::invertLightness(image, QColor(30, 30, 30), QColor(220, 220, 220));
}
}
int main(int argc, char **argv)
{
// Widgets are painted for real, on Qt's offscreen platform so the test