/* 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 . */ #include "imagetransparentcolordialog.h" #include #include #include #include #include #include #include #include #include #include namespace { // Fits the dialog comfortably on a normal screen regardless of the // source image's own resolution -- a photo straight off a phone // would otherwise make this dialog enormous. constexpr int MAX_DISPLAY_SIZE = 350; } /** @brief ClickableImageLabel::ClickableImageLabel @param sourceImage the full-resolution image to display and sample from @param parent */ ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *parent) : QLabel(parent), m_source(sourceImage) { const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width(); const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height(); m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one const QImage displayImage = (m_displayScale < 1.0) ? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation) : m_source; setPixmap(QPixmap::fromImage(displayImage)); setCursor(Qt::CrossCursor); setToolTip(tr("Cliquez pour choisir une couleur")); } /** @brief ClickableImageLabel::mousePressEvent Maps the click back to the original, full-resolution image before sampling -- see the class-level comment for why. @param event */ void ClickableImageLabel::mousePressEvent(QMouseEvent *event) { if (event->button() != Qt::LeftButton || pixmap().isNull()) return; // The label may be larger than its pixmap (layout stretching); the // pixmap itself is always drawn centered by Qt's default alignment, // so the click has to be re-based against the pixmap's own rect // within the label, not the label's own top-left. const QRect pixmapRect( (width() - pixmap().width()) / 2, (height() - pixmap().height()) / 2, pixmap().width(), pixmap().height()); if (!pixmapRect.contains(event->pos())) return; const QPoint withinPixmap = event->pos() - pixmapRect.topLeft(); QPoint originalPos(qRound(withinPixmap.x() / m_displayScale), qRound(withinPixmap.y() / m_displayScale)); originalPos.setX(qBound(0, originalPos.x(), m_source.width() - 1)); originalPos.setY(qBound(0, originalPos.y(), m_source.height() - 1)); emit colorPicked(m_source.pixelColor(originalPos)); } /** @brief ImageTransparentColorDialog::ImageTransparentColorDialog @param pixmap the image to pick a transparent colour from @param parent */ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap, const QList &existingColors, int existingTolerance, QWidget *parent) : QDialog(parent), m_sourceImage(basePixmap.toImage()), m_pickedColors(existingColors), m_tolerance(existingTolerance) { setWindowTitle(tr("Couleur transparente")); m_sourceLabel = new ClickableImageLabel(m_sourceImage, this); m_previewLabel = new QLabel(this); m_hintLabel = new QLabel(this); m_toleranceSlider = new QSlider(Qt::Horizontal, this); m_toleranceSlider->setRange(0, 100); m_toleranceSlider->setValue(m_tolerance); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); m_okButton = buttons->button(QDialogButtonBox::Ok); m_okButton->setEnabled(!m_pickedColors.isEmpty()); // nothing to apply until at least one colour has been picked auto *grid = new QGridLayout; grid->addWidget(new QLabel(tr("Image source")), 0, 0); grid->addWidget(new QLabel(tr("Aperçu")), 0, 1); grid->addWidget(m_sourceLabel, 1, 0); grid->addWidget(m_previewLabel, 1, 1); // An empty row to start with if existingColors is empty -- // rebuildSwatches() below populates it either way (including from // existingColors on the first call), and again as colours get // added or removed. m_swatchesLayout = new QHBoxLayout; auto *colorRow = new QHBoxLayout; colorRow->addWidget(m_hintLabel); colorRow->addStretch(); colorRow->addLayout(m_swatchesLayout); auto *toleranceRow = new QHBoxLayout; toleranceRow->addWidget(new QLabel(tr("Tolérance"))); toleranceRow->addWidget(m_toleranceSlider); auto *mainLayout = new QVBoxLayout(this); mainLayout->addLayout(grid); mainLayout->addLayout(colorRow); mainLayout->addLayout(toleranceRow); mainLayout->addWidget(buttons); connect(m_sourceLabel, &ClickableImageLabel::colorPicked, this, &ImageTransparentColorDialog::onColorPicked); connect(m_toleranceSlider, &QSlider::valueChanged, this, &ImageTransparentColorDialog::onToleranceChanged); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); rebuildSwatches(); // shows existingColors immediately, if any updatePreview(); // and the preview already reflects them too, rather than only appearing after the next pick } /** @brief ImageTransparentColorDialog::onColorPicked Adds to the set of picked colours rather than replacing the previous pick -- clicking a second, different pixel used to silently discard the first choice, with no way to work with more than one colour (a white background *and* a grey border, say) in the same pass. Skips an exact duplicate rather than adding a second, indistinguishable swatch for it. @param color the colour sampled from the source image */ void ImageTransparentColorDialog::onColorPicked(const QColor &color) { if (m_pickedColors.contains(color)) return; m_pickedColors.append(color); m_okButton->setEnabled(true); rebuildSwatches(); updatePreview(); } /** @brief ImageTransparentColorDialog::removeColor Removes one colour from the set -- the counterpart onColorPicked() was missing entirely before: picking the wrong pixel by mistake had no way to undo except cancelling the whole dialog and starting over. @param color the colour to remove */ void ImageTransparentColorDialog::removeColor(const QColor &color) { m_pickedColors.removeAll(color); m_okButton->setEnabled(!m_pickedColors.isEmpty()); rebuildSwatches(); updatePreview(); } /** @brief ImageTransparentColorDialog::rebuildSwatches Rebuilds the row of picked-colour swatches from scratch against the current m_pickedColors -- simpler and safer than trying to incrementally add/remove individual widgets in sync with the list, given the list only ever changes one colour at a time and is never large enough for a full rebuild to be a real cost. */ void ImageTransparentColorDialog::rebuildSwatches() { QLayoutItem *item; while ((item = m_swatchesLayout->takeAt(0)) != nullptr) { delete item->widget(); delete item; } for (const QColor &color : std::as_const(m_pickedColors)) { auto *swatch = new QPushButton(this); swatch->setFixedSize(24, 24); swatch->setStyleSheet(QStringLiteral("background-color: rgb(%1,%2,%3); border: 1px solid palette(mid);") .arg(color.red()).arg(color.green()).arg(color.blue())); swatch->setToolTip(tr("rgb(%1, %2, %3) -- cliquer pour retirer").arg(color.red()).arg(color.green()).arg(color.blue())); connect(swatch, &QPushButton::clicked, this, [this, color]() { removeColor(color); }); m_swatchesLayout->addWidget(swatch); } m_hintLabel->setText(m_pickedColors.isEmpty() ? tr("Cliquez sur l'image pour choisir une couleur") : tr("Cliquez sur l'image pour ajouter une couleur, ou sur une pastille pour la retirer")); } /** @brief ImageTransparentColorDialog::onToleranceChanged @param value the new tolerance, 0-100 */ void ImageTransparentColorDialog::onToleranceChanged(int value) { m_tolerance = value; if (!m_pickedColors.isEmpty()) updatePreview(); } /** @brief ImageTransparentColorDialog::updatePreview Recomputes the checkerboard-backed preview against the current set of picked colours and the shared tolerance. Always runs against m_sourceImage (the original, full-resolution image), not any already-keyed result -- so adjusting the tolerance, or adding or removing a colour, re-evaluates every picked colour from scratch each time rather than compounding successive passes. */ void ImageTransparentColorDialog::updatePreview() { const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance); m_previewLabel->setPixmap(onCheckerboard(keyed)); } /** @brief ImageTransparentColorDialog::resultPixmap @return the colour-keyed pixmap against every picked colour, or the original pixmap unchanged if none were ever picked (the Ok button stays disabled until at least one is, so this is mostly a defensive fallback). */ QPixmap ImageTransparentColorDialog::resultPixmap() const { if (m_pickedColors.isEmpty()) return QPixmap::fromImage(m_sourceImage); return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors, m_tolerance)); } /** @brief ImageTransparentColorDialog::applyColorKey Binary transparency within tolerance, not a smooth falloff: every pixel within `tolerance` (0-100, mapped onto the maximum possible RGB distance) of *any* of keyColors becomes fully transparent, everything else keeps its existing alpha untouched. Squared distance throughout, avoiding a sqrt per pixel; breaks out of the inner loop on the first matching colour, since further matches wouldn't change the outcome. @param source the image to key @param keyColors the colours to make transparent @param tolerance 0 (exact match only) to 100 (everything) @return the resulting image, always in Format_ARGB32 */ QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList &keyColors, int tolerance) { QImage result = source.convertToFormat(QImage::Format_ARGB32); if (keyColors.isEmpty()) return result; QVector keys; keys.reserve(keyColors.size()); for (const QColor &c : keyColors) keys.append(c.rgb()); const qint64 threshold = qint64(tolerance) * tolerance * 3 * 255 * 255 / (100 * 100); for (int y = 0; y < result.height(); ++y) { QRgb *line = reinterpret_cast(result.scanLine(y)); for (int x = 0; x < result.width(); ++x) { const QRgb px = line[x]; for (const QRgb &key : keys) { const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key); const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db; if (distSq <= threshold) { line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0); break; } } } } return result; } /** @brief ImageTransparentColorDialog::onCheckerboard Composites `image` over a light/dark checkerboard, the standard visual convention for showing where an image is transparent -- without this, a fully keyed-out region would just show whatever widget background happens to be behind it, easy to misread as "still opaque, just white" rather than "correctly transparent". @param image the (possibly partially transparent) image to composite @return a checkerboard-backed pixmap ready to display */ QPixmap ImageTransparentColorDialog::onCheckerboard(const QImage &image) { const int cell = 8; QPixmap board(image.size()); QPainter painter(&board); for (int y = 0; y < image.height(); y += cell) { for (int x = 0; x < image.width(); x += cell) { const bool dark = ((x / cell) + (y / cell)) % 2 == 0; painter.fillRect(x, y, cell, cell, dark ? QColor(200, 200, 200) : QColor(255, 255, 255)); } } painter.drawImage(0, 0, image); painter.end(); return board; }