diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index a0e662306..643fe7df6 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -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 #include +#include #include +#include +#include +#include /** 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) { diff --git a/sources/diagramview.h b/sources/diagramview.h index 0550b01d9..9ae47fc12 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -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 *); diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index 63c5f98bb..4f9cad644 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -18,7 +18,9 @@ #include "qetpalette.h" #include +#include #include + #include 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(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); diff --git a/sources/qetpalette.h b/sources/qetpalette.h index 0df5bb3a8..2673bc6e5 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -22,6 +22,7 @@ #include #include +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 diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp index 4aea6d76d..2fd4d0684 100644 --- a/tests/qttest/tst_qetpalette.cpp +++ b/tests/qttest/tst_qetpalette.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include #include #include @@ -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 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