/* 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 "diagramimageitem.h" #include "../PropertiesEditor/propertieseditordialog.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../diagram.h" #include "../diagramview.h" #include "../qet.h" #include "../qetapp.h" #include "../qetdiagrameditor.h" #include "../qeticons.h" #include "../ui/imagepropertieswidget.h" #include "../ui/imagecropdialog.h" #include "../ui/imagetransparentcolordialog.h" #include "../utils/qetutils.h" #include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" #include #include #include #include #include #include #include #include #include /** @brief DiagramImageItem::DiagramImageItem Constructor without pixmap @param parent_item the parent graphics item */ DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item): QetGraphicsItem(parent_item) { setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); } /** @brief DiagramImageItem::DiagramImageItem Constructor with pixmap @param pixmap the pixmap to be draw @param parent_item the parent graphic item */ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *parent_item): QetGraphicsItem(parent_item), pixmap_(pixmap), m_base_pixmap(pixmap), m_crop_rect(pixmap.rect()) { // m_transform.toMatrix(), not QGraphicsItem::setRotation()/setScale(): // those are a single uniform scale() float, which is exactly why an // image could never break its own aspect ratio before this class // gained proper independent scaleX/scaleY. m_transform.pivot = boundingRect().center(); setTransform(m_transform.toMatrix()); setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); } /** @brief DiagramImageItem::~DiagramImageItem Destructor */ DiagramImageItem::~DiagramImageItem() { if (!m_handler_vector.isEmpty()) qDeleteAll(m_handler_vector); } /** @brief DiagramImageItem::paint Draw the pixmap. @param painter the Qpainter to use for draw the pixmap @param option the style option @param widget the QWidget where we draw the pixmap */ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { painter -> drawPixmap(pixmap_.rect(),pixmap_); Q_UNUSED(option); Q_UNUSED(widget); if (isSelected()) { painter -> save(); // Annulation des renderhints painter -> setRenderHint(QPainter::Antialiasing, false); painter -> setRenderHint(QPainter::TextAntialiasing, false); painter -> setRenderHint(QPainter::SmoothPixmapTransform, false); // Dessin du cadre de selection en noir à partir du boundingrect QPen t(Qt::black); t.setStyle(Qt::DashLine); painter -> setPen(t); painter -> drawRect(boundingRect()); painter -> restore(); } } /** @brief DiagramImageItem::editProperty Open the appropriate dialog to edit this image */ void DiagramImageItem::editProperty() { if (diagram() -> isReadOnly()) return; PropertiesEditorDialog dialog(new ImagePropertiesWidget(this), QApplication::activeWindow()); dialog.exec(); } /** @brief DiagramImageItem::setPixmap Set the new pixmap to be draw @param pixmap the new pixmap */ void DiagramImageItem::setPixmap(const QPixmap &pixmap) { // Only ever mattered once setPixmap() could be called on an // already-placed item with a differently-sized replacement (see // replaceImage()) -- previously this only ran from the constructor, // before the item existed in any scene, where there was nothing yet // to invalidate. Without this, a same-session replace to a // different-sized image would leave the scene's bounding-rect cache // stale, the exact class of bug already fixed multiple times this // session for shapes. // // Deliberately does NOT touch m_transform.pivot/pos() here, even // though a differently-sized pixmap generally means the old pivot // (a bounding-rect-relative point) no longer means the same thing: // crop() and replaceImage(), the only two callers that ever change // the pixmap's size, each have their own specific idea of what // should happen to position and pivot when they do (see their own // comments) -- a single generic rule here would fight with // whichever of them actually needs something more specific. prepareGeometryChange(); pixmap_ = pixmap; emit pixmapChanged(); } /** @brief DiagramImageItem::setScaleFactorX / setScaleFactorY / setRotationAngle Matching QetShapeItem's own setters for the identical fields -- same pattern, same reasoning: change the one field, rebuild the matrix, notify. No pivot or position compensation needed here, unlike setPivot() below, since neither scale nor rotation moves the pivot itself. */ void DiagramImageItem::setScaleFactorX(qreal factor) { if (qFuzzyCompare(m_transform.scaleX, factor)) return; prepareGeometryChange(); m_transform.scaleX = factor; setTransform(m_transform.toMatrix()); emit transformChanged(); } void DiagramImageItem::setScaleFactorY(qreal factor) { if (qFuzzyCompare(m_transform.scaleY, factor)) return; prepareGeometryChange(); m_transform.scaleY = factor; setTransform(m_transform.toMatrix()); emit transformChanged(); } void DiagramImageItem::setRotationAngle(qreal angle) { if (qFuzzyCompare(m_transform.rotation, angle)) return; prepareGeometryChange(); m_transform.rotation = angle; setTransform(m_transform.toMatrix()); emit transformChanged(); } void DiagramImageItem::setSkewX(qreal skew) { if (qFuzzyCompare(m_transform.skewX, skew)) return; prepareGeometryChange(); m_transform.skewX = skew; setTransform(m_transform.toMatrix()); emit transformChanged(); } void DiagramImageItem::setSkewY(qreal skew) { if (qFuzzyCompare(m_transform.skewY, skew)) return; prepareGeometryChange(); m_transform.skewY = skew; setTransform(m_transform.toMatrix()); emit transformChanged(); } /** @brief DiagramImageItem::setPivot Unlike scale/rotation, moving the pivot alone WOULD visibly shift the image on screen, since the pivot is baked directly into the transform matrix -- compensatedPositionForNewPivot() (already proven correct for QetShapeItem's identical need) solves for whatever pos() keeps the image exactly where it already was, so only the *handle* moves, not the picture underneath it. */ void DiagramImageItem::setPivot(const QPointF &newPivot) { if (m_transform.pivot == newPivot) return; const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, newPivot, m_transform.linearPart()); prepareGeometryChange(); m_transform.pivot = newPivot; // Verified numerically that the underlying math keeps the image // exactly in place when the pivot moves -- this guard is about a // DIFFERENT problem: setPos() and setTransform() are two separate // QGraphicsItem operations, each independently triggering // itemChange(), so without this, repositionHandles() (and, // visibly, a render) could happen using the new pos() but the // still-old transform (or vice versa) in the moment between the // two calls -- a real, if brief, wobble, not a calculation error. // QetShapeItem's own setPivot() already needed the identical fix // for the identical reason. m_deferHandleReposition = true; // QGraphicsObject::setPos(), not the plain setPos() this class // would otherwise inherit: QetGraphicsItem overrides setPos() to // snap to the diagram's grid, which is exactly right for a user // dragging the whole image around, but silently corrupts this // specific, exactly-computed compensation -- rounding it to the // nearest grid point defeats the entire point of computing an // exact value in the first place, and was the actual cause of the // "picture drifts slightly" symptom (invisible at scale 1 with no // rotation, since the compensation is a no-op there regardless). QGraphicsObject::setPos(newPos); setTransform(m_transform.toMatrix()); m_deferHandleReposition = false; repositionHandles(); emit transformChanged(); } /** @brief DiagramImageItem::setPivotRaw Sets m_transform.pivot directly, with NO position compensation at all -- deliberately, unlike setPivot() above. Exists solely for crop()'s own undo chain (see the Q_PROPERTY declaration's comment for why): crop() already computes the fully correct pos() itself, accounting for the crop, and pushes that as its own independent undo step, so compensating pos() *again* here would silently corrupt it back to a wrong value the moment this step replays. Never call this expecting the image to stay visually in place on its own -- it won't; the caller is responsible for that. */ void DiagramImageItem::setPivotRaw(const QPointF &newPivot) { if (m_transform.pivot == newPivot) return; prepareGeometryChange(); m_transform.pivot = newPivot; setTransform(m_transform.toMatrix()); repositionHandles(); emit transformChanged(); } /** @brief DiagramImageItem::resetPivotToBoundingRectCenter Used after a resize drag ends (see handlerMouseReleaseEvent()) -- NOT after crop() or replaceImage(), which each compute their own, more specific position handling instead (see setPixmap()'s comment for why a single generic rule doesn't fit both of those). */ void DiagramImageItem::resetPivotToBoundingRectCenter() { m_pivotIsCustom = false; setPivot(boundingRect().center()); } namespace { // Local-space (natural, unscaled pixmap size) position of each of // the 8 resize handles. Indices: 0=TL 1=TM 2=TR 3=ML 4=MR 5=BL // 6=BM 7=BR -- arbitrary but fixed, matched by oppositeHandleIndex() // and adjustsX()/adjustsY() below. QPointF handleNaturalPosition(int index, qreal w, qreal h) { switch (index) { case 0: return QPointF(0, 0); case 1: return QPointF(w / 2, 0); case 2: return QPointF(w, 0); case 3: return QPointF(0, h / 2); case 4: return QPointF(w, h / 2); case 5: return QPointF(0, h); case 6: return QPointF(w / 2, h); case 7: return QPointF(w, h); } return QPointF(); } int oppositeHandleIndex(int index) { static const int opposite[8] = {7, 6, 5, 4, 3, 2, 1, 0}; return opposite[index]; } // Corners adjust both axes; edge midpoints adjust only the axis // perpendicular to their own edge. bool adjustsX(int index) { return index != 1 && index != 6; } bool adjustsY(int index) { return index != 3 && index != 4; } } /** @brief DiagramImageItem::cornerPosition Matches QetShapeItem::cornerPoint()'s own slot convention exactly (0=TL 1=TR 2=BR 3=BL), so the rotate math ported from there (see dragRotateHandle()) lines up without needing its own re-derivation. */ QPointF DiagramImageItem::cornerPosition(int cornerIndex, qreal w, qreal h) { switch (cornerIndex & 3) { case 0: return QPointF(0, 0); case 1: return QPointF(w, 0); case 2: return QPointF(w, h); default: return QPointF(0, h); } } /** @brief DiagramImageItem::edgeMidpointPosition Matches QetShapeItem::edgeMidpoint()'s own slot convention exactly (0=N 1=E 2=S 3=W), for the same reason as cornerPosition() above. */ QPointF DiagramImageItem::edgeMidpointPosition(int edgeIndex, qreal w, qreal h) { switch (edgeIndex & 3) { case 0: return QPointF(w / 2, 0); case 1: return QPointF(w, h / 2); case 2: return QPointF(w / 2, h); default: return QPointF(0, h / 2); } } /** @brief DiagramImageItem::scaleOnlyOffset / scaleAndShearOffset Verbatim ports of QetShapeItem's own identically-named helpers (see their definitions there for the derivation) -- the offset of a local point from the pivot, with scale (and, for the second, shear too) applied but rotation deliberately left out. Used by dragRotateHandle()/dragSkewHandle() to solve for rotation/skew directly rather than through mapFromScene(), which would divide out the very value being solved for. */ QPointF DiagramImageItem::scaleOnlyOffset(const QPointF &localPoint) const { const QPointF offset = localPoint - m_transform.pivot; return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY); } QPointF DiagramImageItem::scaleAndShearOffset(const QPointF &localPoint) const { const QPointF scaled = scaleOnlyOffset(localPoint); const qreal kx = qTan(qDegreesToRadians(m_transform.skewX)); const qreal ky = qTan(qDegreesToRadians(m_transform.skewY)); return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x()); } /** @brief DiagramImageItem::handlePosition Local-space position of handle at vector index `index`, for whichever role it currently holds -- the single source both rebuildHandles() and repositionHandles() read from, so the two can never disagree about where a handle belongs. */ QPointF DiagramImageItem::handlePosition(int index) const { const qreal w = pixmap_.width(), h = pixmap_.height(); const HandleRole role = m_handleRoles.at(index); if (role == HandleRole::Resize) return handleNaturalPosition(index, w, h); if (role == HandleRole::Rotate) return cornerPosition(index, w, h); // Rotate handles are always the first 4 in RotateSkew mode, so index IS the corner slot if (role == HandleRole::SkewEdge) return edgeMidpointPosition(index - 4, w, h); // ...and SkewEdge the next 4, offset by those same 4 return m_transform.pivot; // Pivot } /** @brief DiagramImageItem::mousePressEvent Clicking an already-selected image cycles its handle mode, exactly matching QetShapeItem's own established convention for the same gesture -- deliberately kept consistent rather than inventing a different interaction just because this is a different class. */ void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { const bool wasAlreadySelected = isSelected(); event->ignore(); QetGraphicsItem::mousePressEvent(event); if (event->button() == Qt::LeftButton) { if (wasAlreadySelected && isSelected()) toggleHandleMode(); event->accept(); } } /** @brief DiagramImageItem::toggleHandleMode */ void DiagramImageItem::toggleHandleMode() { prepareGeometryChange(); m_handleMode = (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size; rebuildHandles(); refreshInteractionHints(); } /** @brief DiagramImageItem::nextHandleMode @return whichever mode a click would switch to from here -- the other one, since there are only two. */ DiagramImageItem::HandleMode DiagramImageItem::nextHandleMode() const { return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size; } /** @brief DiagramImageItem::handleModeLabel */ QString DiagramImageItem::handleModeLabel(HandleMode mode) { return (mode == HandleMode::Size) ? tr("redimensionner") : tr("pivoter/incliner"); } /** @brief DiagramImageItem::updateModeHint Keeps the tooltip in sync with what the next click would do -- called on selection change (so it appears/disappears with the handles themselves) and after every mode switch. Ported from QetShapeItem's identical method: deliberately short, since this is a tooltip, not documentation -- the fuller gesture/modifier reference lives in the status bar instead (see currentModeStatusHint(), hoverEnterEvent()). */ void DiagramImageItem::updateModeHint() { setToolTip(isSelected() ? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode())) : QString()); } /** @brief DiagramImageItem::refreshInteractionHints Keeps the tooltip text current, and -- if already being hovered -- immediately re-shows both the tooltip and the status bar hint rather than leaving them stuck on whatever was true before. Ported from QetShapeItem's identical method, for the identical reason: Qt only re-evaluates a tooltip, and this class only re-shows the status bar, when the cursor *moves* -- selecting an image (often clicked while the mouse was already sitting on it) or cycling handle modes (definitely clicked while sitting on it) both change what should be shown without the cursor moving at all. */ void DiagramImageItem::refreshInteractionHints() { updateModeHint(); if (!isHovered() || !isSelected()) return; showStatusHint(currentModeStatusHint()); } /** @brief DiagramImageItem::currentModeStatusHint One-line reference for whatever handles are visible right now, shown in the status bar while hovering a selected image's body -- the modifier keys in particular (Ctrl, Shift) have no other visible indication that they do anything at all. Also carries the same "next mode" information as the tooltip, since the status bar has room for the full picture in one place. */ QString DiagramImageItem::currentModeStatusHint() const { QString hint = (m_handleMode == HandleMode::Size) ? tr("Glisser un coin/bord : redimensionner (Ctrl = depuis le centre, Maj = conserver les proportions)") : tr("Glisser un coin : pivoter (Maj = par pas de 15°) ; glisser un bord : incliner (Maj = par pas de 15°) ; point rouge : déplacer le centre de rotation"); hint += tr(" -- %1 : mode %2").arg(tr("Cliquer"), handleModeLabel(nextHandleMode())); return hint; } /** @brief DiagramImageItem::hoverEnterEvent */ void DiagramImageItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { QetGraphicsItem::hoverEnterEvent(event); refreshInteractionHints(); } /** @brief DiagramImageItem::hoverLeaveEvent */ void DiagramImageItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) { QetGraphicsItem::hoverLeaveEvent(event); clearStatusHint(); } /** @brief DiagramImageItem::clearHandles */ void DiagramImageItem::clearHandles() { if (!m_handler_vector.isEmpty()) { qDeleteAll(m_handler_vector); m_handler_vector.clear(); } m_handleRoles.clear(); } /** @brief DiagramImageItem::rebuildHandles A deliberately small switch compared to QetShapeItem's own version: images have exactly one "shape" (a plain rectangle matching the pixmap's natural size), so there's no shape-type branching to do -- just the two handle modes themselves. */ /** @brief DiagramImageItem::colorForHandleRole / hintForHandleRole Single source of truth for both rebuildHandles() (which sets each handle's native tooltip) and sceneEventFilter()'s hover dispatch (which shows the identical text in the status bar) -- keeping these as two independently-maintained copies is exactly how they'd quietly drift apart over time, as already happened once between writing this pair. */ QColor DiagramImageItem::colorForHandleRole(HandleRole role) { switch (role) { case HandleRole::Resize: return Qt::blue; case HandleRole::Rotate: return Qt::darkGreen; case HandleRole::SkewEdge: return QColor(255, 140, 0); case HandleRole::Pivot: return Qt::red; } return Qt::blue; } QString DiagramImageItem::hintForHandleRole(HandleRole role) { switch (role) { case HandleRole::Resize: return tr("Glisser : redimensionner (Maj = conserver les proportions, Ctrl = depuis le centre)"); case HandleRole::Rotate: return tr("Glisser : pivoter (Maj = par pas de 15°)"); case HandleRole::SkewEdge: return tr("Glisser : incliner (Maj = par pas de 15°)"); case HandleRole::Pivot: return tr("Glisser : déplacer le centre de rotation"); } return QString(); } void DiagramImageItem::rebuildHandles() { clearHandles(); if (m_handleMode == HandleMode::Size) { for (int i = 0; i < 8; ++i) m_handleRoles << HandleRole::Resize; } else // RotateSkew: 4 corners (rotate), then 4 edges (skew), then 1 pivot -- handlePosition() relies on this exact order { for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::Rotate; for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::SkewEdge; m_handleRoles << HandleRole::Pivot; } if (m_handleRoles.isEmpty() || !scene()) return; QVector positions; for (int i = 0; i < m_handleRoles.size(); ++i) positions << handlePosition(i); m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this)); for (int i = 0; i < m_handler_vector.size(); ++i) { QetGraphicsHandlerItem *h = m_handler_vector.at(i); h->setZValue(zValue() + 1); h->setColor(colorForHandleRole(m_handleRoles.at(i))); h->setToolTip(hintForHandleRole(m_handleRoles.at(i))); h->setAcceptHoverEvents(true); scene()->addItem(h); h->installSceneEventFilter(this); } } /** @brief DiagramImageItem::repositionHandles Moves the *existing* handle items to match current geometry, without touching their identity -- safe to call on every frame of a live drag, matching QetShapeItem's own repositionHandles()/rebuildHandles() split for the identical reason. */ void DiagramImageItem::repositionHandles() { if (m_handler_vector.isEmpty()) return; if (m_handler_vector.size() != m_handleRoles.size()) { rebuildHandles(); return; } for (int i = 0; i < m_handleRoles.size(); ++i) m_handler_vector.at(i)->setPos(mapToScene(handlePosition(i))); } /** @brief DiagramImageItem::sceneEventFilter Dispatches handle interaction events -- structurally identical to QetShapeItem::sceneEventFilter(), reused as a pattern rather than shared as code, since the two classes' handle roles are different enough (no path/polygon/arc concepts here at all) that sharing the dispatcher itself would need an awkward, indirect abstraction for very little actual code saved. */ bool DiagramImageItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event) { if (watched->type() != QetGraphicsHandlerItem::Type) return false; QetGraphicsHandlerItem *qghi = qgraphicsitem_cast(watched); const int index = m_handler_vector.indexOf(qghi); if (index == -1) return false; if (event->type() == QEvent::GraphicsSceneMousePress) { handlerMousePressEvent(index, static_cast(event)->modifiers()); return true; } if (event->type() == QEvent::GraphicsSceneMouseMove) { handlerMouseMoveEvent(index, static_cast(event)); return true; } if (event->type() == QEvent::GraphicsSceneMouseRelease) { handlerMouseReleaseEvent(index); return true; } if (event->type() == QEvent::GraphicsSceneHoverEnter) { // On top of the tooltip Qt already shows natively from the // handle's own setToolTip() (see rebuildHandles()) -- returning // false leaves that native tooltip handling alone, matching // QetShapeItem's identical dual approach for its own handles. showStatusHint(hintForHandleRole(m_handleRoles.at(index))); return false; } if (event->type() == QEvent::GraphicsSceneHoverLeave) { clearStatusHint(); return false; } return false; } /** @brief DiagramImageItem::showStatusHint */ void DiagramImageItem::showStatusHint(const QString &text) const { if (text.isEmpty() || !diagram() || diagram()->views().isEmpty()) return; if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) editor->statusBar()->showMessage(text); } /** @brief DiagramImageItem::clearStatusHint */ void DiagramImageItem::clearStatusHint() const { if (!diagram() || diagram()->views().isEmpty()) return; if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) editor->statusBar()->clearMessage(); } /** @brief DiagramImageItem::handlerMousePressEvent For a Resize handle, temporarily repositions the pivot for the duration of this drag: to the OPPOSITE corner/edge by default (so that one stays fixed, the usual convention), or to the CENTER if Ctrl is held at the moment of the press (so the image grows symmetrically from its middle instead). Decided once here, not re-checked on every subsequent move -- mirroring the identical fix already made for shape creation, and for the identical reason: continuously re-checking made releasing the modifier mid-drag revert a decision that felt already made, rather than a deliberate choice made once at the start. Verified numerically before relying on this: with the pivot fixed at whichever reference point applies, solving for a new scale that puts the dragged handle at the mouse automatically keeps that reference point exactly where it was, with zero drift, across all 8 handles and a rotated, non-uniformly-scaled starting transform. compensatedPositionForNewPivot() is what keeps this repositioning itself invisible -- without it, temporarily moving the pivot would visibly jump the image the instant the drag starts. */ void DiagramImageItem::handlerMousePressEvent(int index, Qt::KeyboardModifiers mods) { m_vector_index = index; m_original_pos = pos(); m_original_transform = m_transform; if (m_handleRoles.at(index) == HandleRole::Resize) { m_resizeCenterAnchored = mods & Qt::ControlModifier; const QPointF referencePoint = m_resizeCenterAnchored ? QPointF(pixmap_.width() / 2.0, pixmap_.height() / 2.0) : handleNaturalPosition(oppositeHandleIndex(index), pixmap_.width(), pixmap_.height()); const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, referencePoint, m_transform.linearPart()); m_deferHandleReposition = true; m_transform.pivot = referencePoint; // QGraphicsObject::setPos(), not the plain setPos() -- see // setPivot()'s identical comment for why: QetGraphicsItem's // override snaps to the grid, which would corrupt this exact // compensation the same way it did there. QGraphicsObject::setPos(newPos); setTransform(m_transform.toMatrix()); m_deferHandleReposition = false; } } /** @brief DiagramImageItem::handlerMouseMoveEvent */ void DiagramImageItem::handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event) { const HandleRole role = m_handleRoles.at(index); if (role == HandleRole::Resize) dragResize(index, mapFromScene(event->scenePos()), event->modifiers()); else if (role == HandleRole::Rotate) dragRotateHandle(index, event->scenePos(), event->modifiers()); // index IS the corner slot (0-3): Rotate handles are always first else if (role == HandleRole::SkewEdge) dragSkewHandle(index - 4, event->scenePos(), event->modifiers()); // offset by the 4 preceding Rotate handles else if (role == HandleRole::Pivot) dragPivot(mapFromScene(event->scenePos())); } /** @brief DiagramImageItem::handlerMouseReleaseEvent Two responsibilities, in order: first, Resize's own pivot cleanup (the pivot was temporarily relocated to the opposite corner or center in handlerMousePressEvent() purely to make the drag math simple, and has to move back now that the drag is over, via resetPivotToBoundingRectCenter(), which itself handles the position compensation that reset needs) -- but only when the pivot isn't user-customized, matching dragPivot()'s own comment for why. Second -- and previously entirely missing -- building the actual undo command for whatever changed during this drag: every drag method above mutates m_transform directly and calls setTransform() immediately, live, with no undo tracking of its own at all, exactly like QetShapeItem's identical handlerMouseMoveEvent()/dragResize() pair. QetShapeItem's own handlerMouseReleaseEvent() is where that gets reconciled into a single undo step per role, comparing m_original_transform/m_original_pos (captured at press time) against the now-already-applied live values -- this is a direct port of that same pattern for this class's own, smaller role set. Pushing a command whose redo() sets a property to what it's already live at is intentional, not wasted: redo() runs once immediately, as a harmless no-op, so the stack has a correct entry for Ctrl+Z without the live drag needing any awareness of undo at all. For Pivot specifically, chaining "pos" before "rawPivot" -- not the compensating "pivot" -- means undoing simply restores both to their exact recorded values, without a second, redundant compensation needing to run and (correctly, but confusingly) arrive at the same place a longer way around. */ void DiagramImageItem::handlerMouseReleaseEvent(int index) { const HandleRole role = m_handleRoles.at(index); if (role == HandleRole::Resize && !m_pivotIsCustom) resetPivotToBoundingRectCenter(); if (diagram()) { QUndoCommand *undo = nullptr; switch (role) { case HandleRole::Resize: if (!qFuzzyCompare(m_transform.scaleX, m_original_transform.scaleX)) undo = new QPropertyUndoCommand(this, "scaleFactorX", m_original_transform.scaleX, m_transform.scaleX); if (!qFuzzyCompare(m_transform.scaleY, m_original_transform.scaleY)) { if (undo) new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY, undo); else undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY); } if (undo) undo->setText(tr("Redimensionner une image")); break; case HandleRole::Rotate: if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation)) { undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation); undo->setText(tr("Faire pivoter une image")); } break; case HandleRole::SkewEdge: if (!qFuzzyCompare(m_transform.skewX, m_original_transform.skewX)) undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX); else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY)) undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY); if (undo) undo->setText(tr("Incliner une image")); break; case HandleRole::Pivot: if (m_transform.pivot != m_original_transform.pivot) { undo = new QUndoCommand(tr("Déplacer le centre de rotation d'une image")); new QPropertyUndoCommand(this, "pos", m_original_pos, pos(), undo); new QPropertyUndoCommand(this, "rawPivot", m_original_transform.pivot, m_transform.pivot, undo); } break; } if (undo) { // Defensive fallback only -- every role above now sets its // own, distinct label directly (Resize/Rotate/SkewEdge used // to all fall through to this same generic text, making the // undo list unable to tell three completely different edits // apart); this only still matters if some future role is // ever added without setting one of its own. if (undo->text().isEmpty()) undo->setText(tr("Modifier une image")); // Every push here is one complete, finished gesture (press, // drag, release) -- never a continuation of an earlier one. // QPropertyUndoCommand::mergeWith() already treats any // command with children as never mergeable; a dummy child // guarantees that here regardless of which role produced // undo. Without this, a single-property change (Rotate and // SkewEdge always are; Resize sometimes is, when only one // axis actually changed) would silently coalesce into // whatever identically-labelled edit came right before it // -- even after a deselect/reselect proved they were two // separate actions, since the shared label alone is // otherwise indistinguishable from a genuine continuation. new QUndoCommand(undo); diagram()->undoStack().push(undo); } } m_vector_index = -1; } /** @brief DiagramImageItem::dragResize Verified numerically (all 8 handles, a rotated and non-uniformly scaled starting transform) before writing this: undoing only rotation from the target -- never scale, since that's the very value being solved for -- is what avoids the feedback-loop mistake a full mapFromScene()-based solve would fall into (mapFromScene() divides out the CURRENT scale, not the new one, since it doesn't know a new one is being computed at all). */ void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods) { const QPointF pivotScene = pos() + m_transform.pivot; QTransform undoRotation; undoRotation.rotate(-m_transform.rotation); const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene); const QPointF handleNatural = handleNaturalPosition(index, pixmap_.width(), pixmap_.height()); const qreal handleDx = handleNatural.x() - m_transform.pivot.x(); const qreal handleDy = handleNatural.y() - m_transform.pivot.y(); qreal newScaleX = m_transform.scaleX; qreal newScaleY = m_transform.scaleY; if (adjustsX(index) && qAbs(handleDx) > 1e-6) newScaleX = postScaleOffset.x() / handleDx; if (adjustsY(index) && qAbs(handleDy) > 1e-6) newScaleY = postScaleOffset.y() / handleDy; if (mods & Qt::ShiftModifier) { // Preserve the ORIGINAL aspect ratio (captured at press time in // m_original_transform), not force scaleX==scaleY -- an image // that wasn't square to begin with should stay proportional to // itself, not become literally square the moment Shift is held. const qreal originalRatio = (qFuzzyIsNull(m_original_transform.scaleY)) ? 1.0 : m_original_transform.scaleX / m_original_transform.scaleY; if (adjustsX(index) && !adjustsY(index)) newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio); else if (adjustsY(index) && !adjustsX(index)) newScaleX = newScaleY * originalRatio; else { // Corner handle: whichever axis moved further from its own // starting value, in relative terms, drives the other. const qreal relX = qAbs(newScaleX / m_original_transform.scaleX); const qreal relY = qAbs(newScaleY / m_original_transform.scaleY); if (relX > relY) newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio); else newScaleX = newScaleY * originalRatio; } } // A pixmap can't meaningfully render at zero or negative scale -- // clamped well above zero rather than letting it vanish or silently // flip (negative scale is a mirror, and mirroring is already its // own deliberate, explicit context-menu action, not something a // fast drag past the opposite edge should trigger by accident). const qreal MIN_SCALE = 0.02; if (newScaleX < MIN_SCALE) newScaleX = MIN_SCALE; if (newScaleY < MIN_SCALE) newScaleY = MIN_SCALE; m_transform.scaleX = newScaleX; m_transform.scaleY = newScaleY; setTransform(m_transform.toMatrix()); repositionHandles(); emit transformChanged(); } /** @brief DiagramImageItem::dragRotateHandle Verified numerically before writing this (all 4 corners, WITH nonzero skew already present -- scaleAndShearOffset() is what makes the reference direction correctly account for that): the handle ends up pointing exactly at the mouse's angle from the pivot, and the pivot itself never moves. Ported from QetShapeItem::dragRotateHandle() -- see that copy's own comment for why this is a plain assignment rather than a recurrence (computing the angle via mapFromScene() would divide out the current rotation and turn each frame's result into a feedback loop). */ void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) { const QPointF scenePivot = pos() + m_transform.pivot; const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x())); const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height())); const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x())); qreal angle = angleMouse - angleReference; if (mods & Qt::ShiftModifier) angle = qRound(angle / 15.0) * 15.0; m_transform.rotation = angle; setTransform(m_transform.toMatrix()); repositionHandles(); emit transformChanged(); } /** @brief DiagramImageItem::dragSkewHandle Verified numerically before writing this (all 4 edges, WITH nonzero rotation already present): recovers the exact skew angle that would put the edge handle at a given scene position, for both skewX (N/S edges) and skewY (E/W edges). Ported from QetShapeItem::dragSkewHandle() -- see that copy's own comment for the closed-form derivation (solved for the one skew component being dragged, holding everything else fixed, since going through mapFromScene() would divide out the very value being solved for). */ void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) { const QPointF pivot = m_transform.pivot; const QPointF Q = scaleOnlyOffset(edgeMidpointPosition(edgeIndex, pixmap_.width(), pixmap_.height())); const qreal rad = qDegreesToRadians(m_transform.rotation); const qreal c = qCos(rad), s = qSin(rad); const QPointF targetRel = scenePos - pos() - pivot; const QPointF M(targetRel.x() * c + targetRel.y() * s, -targetRel.x() * s + targetRel.y() * c); qreal degrees; if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX { if (qFuzzyIsNull(Q.y())) return; degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y())); if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; m_transform.skewX = degrees; } else // E/W edge -> skewY { if (qFuzzyIsNull(Q.x())) return; degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x())); if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; m_transform.skewY = degrees; } setTransform(m_transform.toMatrix()); repositionHandles(); emit transformChanged(); } /** @brief DiagramImageItem::dragPivot Marks the pivot as user-customized, the same way QetShapeItem's own pivot handle does -- so a later resize (see handlerMouseReleaseEvent()) doesn't silently snap it back to the bounding-rect center the instant the user deliberately moved it somewhere else. */ void DiagramImageItem::dragPivot(const QPointF &localPos) { m_pivotIsCustom = true; setPivot(localPos); repositionHandles(); } /** @brief DiagramImageItem::restoreAspectRatio Context-menu action, the direct answer to "restoration of aspect ratio" from the original wishlist: makes scaleY match scaleX, keeping the current width fixed. The natural aspect ratio is already fully accounted for by the pixmap's own width/height (a uniform scale, by definition, can never distort it) -- the distortion is entirely scaleX and scaleY disagreeing with each other, so undoing it is exactly "make them agree", not a computation involving pixmap_'s own dimensions at all. An earlier version of this multiplied by the natural height/width ratio a second time, which double-counted it and left the image still visibly distorted, just less obviously so. */ void DiagramImageItem::restoreAspectRatio() { if (!diagram() || diagram()->isReadOnly()) return; if (qFuzzyCompare(m_transform.scaleY, m_transform.scaleX)) return; auto *undo = new QPropertyUndoCommand(this, "scaleFactorY", m_transform.scaleY, m_transform.scaleX); undo->setText(tr("Restaurer les proportions d'une image")); diagram()->undoStack().push(undo); } /** @brief DiagramImageItem::saveImageAs Saves the currently displayed pixmap (crop and colour-keyed transparency already applied -- what the item actually looks like on the diagram, not the pristine original) to an arbitrary file on disk. */ void DiagramImageItem::saveImageAs() { saveImagePixmapAs(pixmap_, tr("Enregistrer l'image sous..."), !m_transparent_colors.isEmpty()); } /** @brief DiagramImageItem::saveOriginalImageAs Saves m_base_pixmap -- the true, pristine original, before any crop or colour-keyed transparency -- rather than the item's current, possibly-cropped-and-keyed display pixmap. The only way to recover the un-cropped, un-keyed source once either of those has actually been applied, short of undoing every edit back to the point it was first inserted or replaced. */ void DiagramImageItem::saveOriginalImageAs() { saveImagePixmapAs(m_base_pixmap, tr("Enregistrer l'image d'origine sous..."), false); } /** @brief DiagramImageItem::saveImagePixmapAs Shared by saveImageAs() and saveOriginalImageAs(): prompts for a destination, resolves whichever format was actually intended, warns before silently dropping transparency, and writes the file. A read-only export, not an edit: doesn't touch diagram()'s undo stack, and works even on a read-only diagram, unlike every other action in this item's context menu. @param pixmap the pixmap to save -- pixmap_ or m_base_pixmap @param dialogTitle distinguishes the two callers in the save dialog's own title bar @param hasTransparency whether `pixmap` has colour-keyed transparency worth warning about losing (never true for the pristine original, which predates any such keying) */ void DiagramImageItem::saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency) { QWidget *parentWidget = (diagram() && !diagram()->views().isEmpty()) ? diagram()->views().first() : nullptr; // Filter text -> extension. Built once and used both to construct // the dialog's filter list and, below, to resolve whichever one the // person actually had selected -- rather than only ever falling // back to a single hardcoded format regardless of their choice, // which is what this used to do (getSaveFileName() doesn't reliably // auto-append the selected filter's extension on every platform, // and the previous version's fallback ignored the selected filter // entirely, defaulting to PNG even when JPEG or BMP had been // explicitly chosen). const QList> filters = { {tr("Image PNG (*.png)"), QStringLiteral("png")}, {tr("Image JPEG (*.jpg *.jpeg)"), QStringLiteral("jpg")}, {tr("Image BMP (*.bmp)"), QStringLiteral("bmp")}, // SVG here always means a raster image wrapped in an SVG // container (an element embedding this same pixmap as // base64 PNG), never a true vector export -- this item only // ever holds raster data, even when originally inserted from an // SVG file, since that file was rasterized once at import time // and its vector information is already gone by the time this // runs. {tr("Image SVG (*.svg)"), QStringLiteral("svg")}, }; QStringList filterStrings; for (const auto &f : filters) filterStrings << f.first; filterStrings << tr("Tous les fichiers (*)"); QString selectedFilter; QString path = QFileDialog::getSaveFileName( parentWidget, dialogTitle, QString(), filterStrings.join(QLatin1String(";;")), &selectedFilter); if (path.isEmpty()) return; // Resolve the target format: prefer an extension already present // and actually writable, otherwise fall back to whichever filter // was selected in the dialog (not a fixed default), so the format // picked in the dropdown is the one that's actually honoured even // when the typed name carries no extension of its own. QString suffix = QFileInfo(path).suffix().toLower(); const bool suffixIsSvg = (suffix == QLatin1String("svg")); const bool suffixIsWritableRaster = QImageWriter::supportedImageFormats().contains(suffix.toUtf8()); if (!suffixIsSvg && !suffixIsWritableRaster) { QString fallbackExt = QStringLiteral("png"); for (const auto &f : filters) if (f.first == selectedFilter) { fallbackExt = f.second; break; } path += QLatin1Char('.') + fallbackExt; suffix = fallbackExt; } // JPEG and BMP have no usable alpha channel here -- Qt's writers for // both silently drop it. That alone wouldn't be too bad (the // warning already tells the person transparency won't survive), // except pixmap_ itself has already lost the true colour // information wherever it's transparent by this point: QPixmap // stores its data premultiplied internally, which zeroes out RGB // at alpha=0 the moment applyColorKey()'s QImage result gets // wrapped into a QPixmap -- confirmed directly, not assumed // (QPixmap::fromImage() on a white, alpha=0 pixel reliably comes // back black on toImage()). Saving pixmap_ as-is to a format with // no alpha channel doesn't fall back to the original picture, it // reveals that already-lost black. The fix below substitutes the // cropped base pixmap for the save in this specific case -- it has // the same, correct RGB values everywhere applyColorKey() left a // pixel opaque (colour-keying only ever changes alpha, never RGB), // so it's an exact reproduction of what the image looked like // before any colour was ever keyed out, not an approximation. const bool formatPreservesAlpha = (suffix == QLatin1String("png") || suffix == QLatin1String("svg")); QPixmap toSave = pixmap; if (hasTransparency && !formatPreservesAlpha) { if (QMessageBox::warning(parentWidget, tr("Transparence non conservée"), tr("Ce format ne prend pas en charge la transparence : l'image sera enregistrée " "telle qu'elle était avant l'application de la couleur transparente. Continuer ?"), QMessageBox::Yes | QMessageBox::Cancel) != QMessageBox::Yes) return; toSave = m_base_pixmap.copy(m_crop_rect); } bool ok; if (suffix == QLatin1String("svg")) ok = writeRasterAsSvg(toSave, path); else ok = toSave.save(path); if (!ok) { QMessageBox::warning(parentWidget, tr("Échec de l'enregistrement"), tr("Impossible d'enregistrer l'image à cet emplacement.")); } } /** @brief DiagramImageItem::writeRasterAsSvg Wraps `pixmap` as a base64-embedded PNG inside a minimal, valid SVG document -- the closest this can honestly offer to "save as SVG" given this item only ever holds raster data (see the note in saveImagePixmapAs()). Opens in any SVG viewer at the pixmap's own pixel size, but is not, and cannot be, a vector re-export. @param pixmap the raster image to embed @param path destination file path @return whether the file was written successfully */ bool DiagramImageItem::writeRasterAsSvg(const QPixmap &pixmap, const QString &path) { QByteArray pngData; QBuffer buffer(&pngData); buffer.open(QIODevice::WriteOnly); if (!pixmap.save(&buffer, "PNG")) return false; QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return false; QTextStream out(&file); out << "\n" << "\n" << " \n" << "\n"; return true; } /** @brief DiagramImageItem::itemChange */ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant &value) { if (change == ItemSelectedHasChanged) { if (value.toBool()) rebuildHandles(); else { prepareGeometryChange(); clearHandles(); m_handleMode = HandleMode::Size; } refreshInteractionHints(); } else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) { if (!m_deferHandleReposition) repositionHandles(); } else if (change == ItemSceneHasChanged) { if (!scene()) setSelected(false); } return QGraphicsItem::itemChange(change, value); } /** @brief DiagramImageItem::computeDisplayPixmap Re-derives what pixmap_ should be from first principles: crop the true original down to the chosen region, then colour-key whichever colours have been picked out of it, each at its own tolerance. Used whenever crop() or setTransparentColor() changes one of those two independently, so the other's effect is correctly re-applied on top rather than lost or compounded -- cropping after colours were already picked has to still show them keyed out; picking colours after a crop has to only ever consider what's still actually part of the image. @param base the true, uncropped original @param cropRect the region of base to keep, in base's own coordinates @param colors colours (each with its own tolerance) to key transparent within the cropped region */ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors) { const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect); if (colors.isEmpty()) return cropped; return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors)); } /** @brief DiagramImageItem::boundingRect the outer bounds of the item as a rectangle, if no pixmap are set, return a default QRectF @return a QRectF represent the bounding rectangle */ QRectF DiagramImageItem::boundingRect() const { if (!pixmap_.isNull()) { return (QRectF(pixmap_.rect())); } else { QRectF bound; return (bound); } } /** @brief DiagramImageItem::name @return the generic name of this item (picture) */ QString DiagramImageItem::name() const { return tr("une image"); } /** @brief DiagramImageItem::fromXml Load this image from xml element e @param e @return true if successfully loaded. */ bool DiagramImageItem::fromXml(const QDomElement &e) { if (e.tagName() != "image") { return (false); } QDomNode image_node = e.firstChild(); if (!image_node.isText()) { return (false); } //load xml image to QByteArray QByteArray array; array = QByteArray::fromBase64(e.text().toLatin1()); //Set QPixmap from the array QPixmap pixmap; pixmap.loadFromData(array); setPixmap(pixmap); // Falls back to treating the loaded result as its own base, with no // remembered crop or colours -- correct both for a genuinely plain // image and for a file saved before these features existed. // Overwritten below if the file actually does carry this // information. m_base_pixmap = pixmap; m_crop_rect = pixmap.rect(); m_transparent_colors.clear(); const QDomElement colorsElement = e.firstChildElement("transparent_colors"); bool hasColors = !colorsElement.isNull(); if (hasColors) { // Files saved before per-colour tolerance existed wrote a // single value on the wrapper element itself, shared by every // colour -- kept here purely as the fallback default for a // that doesn't carry its own attribute, which for // those old files is every one of them, exactly reproducing // what they used to do (one tolerance applied to all of them). const int wrapperTolerance = colorsElement.attribute("tolerance", "10").toInt(); for (const QDomElement &colorElement : QET::findInDomElement(colorsElement, "color")) { m_transparent_colors.append({ QColor(colorElement.attribute("r").toInt(), colorElement.attribute("g").toInt(), colorElement.attribute("b").toInt()), colorElement.attribute("tolerance", QString::number(wrapperTolerance)).toInt()}); } } const QDomElement cropElement = e.firstChildElement("crop"); bool hasCrop = !cropElement.isNull(); if (hasCrop) { m_crop_rect = QRect( cropElement.attribute("x").toInt(), cropElement.attribute("y").toInt(), cropElement.attribute("w").toInt(), cropElement.attribute("h").toInt()); } // Present, and only meaningful, whenever either of the above is -- // the base pixmap on its own, with no crop or colours to apply to // it, wouldn't mean anything. if (hasColors || hasCrop) { const QDomElement baseElement = e.firstChildElement("image_base"); if (!baseElement.isNull()) { const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1()); QPixmap basePixmap; if (basePixmap.loadFromData(baseArray)) m_base_pixmap = basePixmap; } // m_crop_rect may still refer to a saved file's base image, not // pixmap (used as a fallback above only when nothing better is // available) -- clamp it to whatever base actually ended up // loaded, so an inconsistent or hand-edited file can't produce // an out-of-bounds crop rect later. m_crop_rect = m_crop_rect.intersected(m_base_pixmap.rect()); if (m_crop_rect.isEmpty()) m_crop_rect = m_base_pixmap.rect(); } // Baseline: the plain "rotation"/"size" attributes, understood by // every version of this code, past and future -- size applied // uniformly to both axes, since a single float can't say otherwise. // Overwritten below if the file actually carries the richer // element (independent scaleX/scaleY, and/or a custom // pivot), the same two-tier fallback QetShapeItem's own fromXml() // already uses for its identical element. m_transform.rotation = e.attribute("rotation").toDouble(); m_transform.scaleX = e.attribute("size").toDouble(); m_transform.scaleY = m_transform.scaleX; m_transform.pivot = boundingRect().center(); m_pivotIsCustom = false; const QDomElement transformElement = e.firstChildElement("transform"); if (!transformElement.isNull()) { m_transform.rotation = transformElement.attribute("rotation", "0").toDouble(); m_transform.skewX = transformElement.attribute("skewX", "0").toDouble(); m_transform.skewY = transformElement.attribute("skewY", "0").toDouble(); m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble(); m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble(); m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(), transformElement.attribute("pivotY", "0").toDouble()); m_pivotIsCustom = true; } setTransform(m_transform.toMatrix()); //We directly call setPos from QGraphicsObject, because QetGraphicsItem will snap to grid QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble()); setZValue(e.attribute("z", QString::number(this->zValue())).toDouble()); is_movable_ = (e.attribute("is_movable").toInt()); return (true); } /** @param document Le document XML a utiliser @return L'element XML representant l'image */ QDomElement DiagramImageItem::toXml(QDomDocument &document) const { QDomElement result = document.createElement("image"); //write some attribute result.setAttribute("x", QString::number(pos().x())); result.setAttribute("y", QString::number(pos().y())); result.setAttribute("z", QString::number(this->zValue())); // m_transform.rotation/scaleX, not rotation()/scale(): those are // QGraphicsItem's own convenience properties, and this class no // longer ever calls their setters at all -- it uses setTransform() // directly now, the only way to represent independent scaleX/scaleY // at all, so rotation()/scale() would always read back their // defaults (0 and 1) regardless of the image's actual, visible // state. "size" keeps scaleX specifically (not some average of // scaleX/scaleY) as its best-effort value for older code that only // ever understood a single uniform scale -- the element // below is what any code that understands both axes independently // should prefer instead, exactly mirroring how QetShapeItem's own // otherwise-identical element already works. result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation))); result.setAttribute("size", QString::number(m_transform.scaleX)); result.setAttribute("is_movable", bool(is_movable_)); //write the pixmap in the xml element after he was been transformed to base64 QByteArray array; QBuffer buffer(&array); buffer.open(QIODevice::ReadWrite); pixmap_.save(&buffer, "PNG"); QDomText base64 = document.createTextNode(array.toBase64()); result.appendChild(base64); // Full-fidelity transform, only written when it actually carries // something the rotation/size attributes above can't already // express -- exactly the same convention QetShapeItem's own // identical element already uses, for the same reason: // this keeps a plain, never-resized-independently image's saved // file byte-for-byte unchanged. const bool hasRichTransform = !qFuzzyCompare(m_transform.scaleX, m_transform.scaleY) || !qFuzzyIsNull(m_transform.skewX) || !qFuzzyIsNull(m_transform.skewY) || m_pivotIsCustom; if (hasRichTransform) { QDomElement transformElement = document.createElement("transform"); transformElement.setAttribute("rotation", QString::number(m_transform.rotation)); transformElement.setAttribute("skewX", QString::number(m_transform.skewX)); transformElement.setAttribute("skewY", QString::number(m_transform.skewY)); transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX)); transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY)); transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x())); transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y())); result.appendChild(transformElement); } // Only written when there's actually something to remember -- a // plain image that's never been cropped or had transparency applied // shouldn't carry this extra weight at all. pixmap_ above already // reflects the current result on its own, so older code (or a file // that never used either feature) reads back exactly what it // always did; this is purely additional context for the dialogs' // own memory, letting them be reopened non-destructively. const bool hasCrop = (m_crop_rect != m_base_pixmap.rect()); const bool hasColors = !m_transparent_colors.isEmpty(); if (hasColors) { QDomElement colorsElement = document.createElement("transparent_colors"); // Best-effort fallback for an OLDER version of this same code // (from before per-colour tolerance existed) reading a file // saved by this one: uses the first colour's own tolerance as // a single, plausible value rather than some fixed default, // in case it ever needs to open a file like this. Newer code // (including this version) always prefers each 's own // attribute below over this one. colorsElement.setAttribute("tolerance", m_transparent_colors.first().tolerance); for (const auto &pc : m_transparent_colors) { QDomElement colorElement = document.createElement("color"); colorElement.setAttribute("r", pc.color.red()); colorElement.setAttribute("g", pc.color.green()); colorElement.setAttribute("b", pc.color.blue()); colorElement.setAttribute("tolerance", pc.tolerance); colorsElement.appendChild(colorElement); } result.appendChild(colorsElement); } if (hasCrop) { QDomElement cropElement = document.createElement("crop"); cropElement.setAttribute("x", m_crop_rect.x()); cropElement.setAttribute("y", m_crop_rect.y()); cropElement.setAttribute("w", m_crop_rect.width()); cropElement.setAttribute("h", m_crop_rect.height()); result.appendChild(cropElement); } if (hasCrop || hasColors) { QByteArray baseArray; QBuffer baseBuffer(&baseArray); baseBuffer.open(QIODevice::ReadWrite); m_base_pixmap.save(&baseBuffer, "PNG"); QDomElement baseElement = document.createElement("image_base"); baseElement.appendChild(document.createTextNode(baseArray.toBase64())); result.appendChild(baseElement); } return(result); } /** @brief DiagramImageItem::contextMenuEvent @param event */ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { if (!diagram()) { QetGraphicsItem::contextMenuEvent(event); return; } if (diagram()->selectedItems().isEmpty()) this->setSelected(true); if (isSelected() && scene()->selectedItems().size() == 1) { DiagramView *d_view = nullptr; for (QGraphicsView *view : diagram()->views()) { if (view->isActiveWindow()) { d_view = dynamic_cast(view); if (d_view) continue; } } if (d_view) { QScopedPointer menu(new QMenu()); QAction *replace = menu.data()->addAction(tr("Remplacer l'image...")); connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage); QAction *saveAs = menu.data()->addAction(tr("Enregistrer l'image sous...")); connect(saveAs, &QAction::triggered, this, &DiagramImageItem::saveImageAs); QAction *saveOriginalAs = menu.data()->addAction(tr("Enregistrer l'image d'origine sous...")); connect(saveOriginalAs, &QAction::triggered, this, &DiagramImageItem::saveOriginalImageAs); QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente...")); transparentColor->setIcon(QET::Icons::EditOpacity); connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor); QAction *cropAction = menu.data()->addAction(tr("Rogner...")); cropAction->setIcon(QET::Icons::TransformCrop); connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop); QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal")); mirrorH->setIcon(QET::Icons::ImageFlipHorizontal); QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical")); mirrorV->setIcon(QET::Icons::ImageFlipVertical); connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); }); connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); }); QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les proportions")); connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio); // menu.data()->addSeparator(); // QAction *properties = menu.data()->addAction(tr("Propriétés...")); // connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty); menu.data()->addSeparator(); menu.data()->addActions(d_view->contextMenuActions()); menu.data()->exec(event->screenPos()); event->accept(); return; } } QetGraphicsItem::contextMenuEvent(event); } /** @brief DiagramImageItem::replaceImage Context-menu action: swaps the underlying pixmap for one loaded from a new file, keeping position, rotation and scale untouched -- only pixmap_ changes, everything else about how this item sits on the diagram is left exactly as it was. Reuses the same file dialog filter and error handling as DiagramEventAddImage::openDialog(), so picking a replacement looks and behaves like picking a new image did when this item was first inserted. */ void DiagramImageItem::replaceImage() { if (!diagram() || diagram()->isReadOnly()) return; QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); const QString fileName = QFileDialog::getOpenFileName( parentWidget, tr("Selectionner une image..."), QETApp::pictureDir(), tr("Image Files (*.png *.jpg *.jpeg *.bmp *.svg)")); if (fileName.isEmpty()) return; QImage image(fileName); if (image.isNull()) { QMessageBox::critical(parentWidget, tr("Erreur"), tr("Impossible de charger l'image.")); return; } const QPixmap oldPixmap = pixmap_; const QPixmap newPixmap = QPixmap::fromImage(image); // A wholesale replacement, not an edit of the existing image -- the // new picture has nothing to do with whatever colours were picked // or region was cropped for the old one, so this starts that // memory fresh rather than carrying over choices that would no // longer make sense. m_base_pixmap = newPixmap; m_crop_rect = newPixmap.rect(); m_transparent_colors.clear(); auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(tr("Remplacer une image")); // Every call here is a separate, deliberate menu action with no // compound child of its own (unlike crop(), which always chains a // pos/rawPivot change and is naturally immune) -- two of them in a // row would carry the exact same object, property, and text, which // is indistinguishable from a legitimate merge to // QPropertyUndoCommand::mergeWith(). A dummy child (already treated // as "never merge" by that check) keeps each one its own, separate // undo step regardless. new QUndoCommand(undo); diagram()->undoStack().push(undo); } /** @brief DiagramImageItem::mirror Context-menu action: flips the pixmap itself, not a transform -- unlike QetShapeItem::mirror(), which has rotation/skew to contend with, there's no equivalent linear-transform decomposition needed here: the bitmap is flipped once, directly, and stays flipped regardless of whatever rotation is applied on top afterward. */ void DiagramImageItem::mirror(bool horizontal) { if (!diagram() || diagram()->isReadOnly()) return; const QPixmap oldPixmap = pixmap_; const QTransform flip = horizontal ? QTransform(-1, 0, 0, 1, 0, 0) : QTransform(1, 0, 0, -1, 0, 0); const QPixmap newPixmap = pixmap_.transformed(flip); // The base is flipped the same way, to stay in sync with pixmap_ -- // but the picked-colours list itself is left untouched: the actual // colour values don't change when the image is mirrored, only their // positions, so whatever was already keyed transparent should stay // remembered and still apply correctly to the flipped version. m_base_pixmap = m_base_pixmap.transformed(flip); // m_crop_rect, unlike the colour list, DOES need to change: it's // defined in terms of positions within the base, and those // positions just moved. Width/height and the other axis are // untouched -- only the axis being flipped needs its edge mirrored // (dimensions are unaffected by the flip, so it doesn't matter // whether this reads m_base_pixmap's size from before or after the // assignment above). if (horizontal) m_crop_rect = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(), m_crop_rect.top(), m_crop_rect.width(), m_crop_rect.height()); else m_crop_rect = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(), m_crop_rect.width(), m_crop_rect.height()); auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image")); // See replaceImage()'s identical comment: a separate, deliberate // action with no compound child of its own, so a dummy one is // needed to stop two consecutive same-direction mirrors (identical // object, property, and text) from silently merging into one. new QUndoCommand(undo); diagram()->undoStack().push(undo); } /** @brief DiagramImageItem::setTransparentColor Context-menu action: opens ImageTransparentColorDialog against m_base_pixmap (the pristine source), pre-populated with whatever colours and tolerance were remembered from a previous session -- both problems fixed together, since they had the same root cause: passing pixmap_ (the already colour-keyed result) as if it were the source, with nowhere to remember which colours produced it. Applies the result the same way replaceImage() and mirror() do, through the "pixmap" property, so undo/redo stays consistent across all three; m_base_pixmap itself is deliberately left untouched here, since this action only ever changes which colours are keyed out of it, not the source those colours are keyed out of. */ /** @brief DiagramImageItem::setTransparentColor Context-menu action: opens ImageTransparentColorDialog against the CROPPED base (m_base_pixmap.copy(m_crop_rect)), not the full, uncropped original -- picking a colour from a region that's already been permanently cropped away would be picking a colour that isn't even part of the image anymore. Pre-populated with whatever colours and tolerance were remembered from a previous session. Applies the result the same way replaceImage() and mirror() do, through the "pixmap" property, so undo/redo stays consistent across all three; m_base_pixmap and m_crop_rect are deliberately left untouched here, since this action only ever changes which colours are keyed out, never the source region they're keyed out of. */ void DiagramImageItem::setTransparentColor() { if (!diagram() || diagram()->isReadOnly()) return; QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); const QPixmap croppedBase = m_base_pixmap.copy(m_crop_rect); ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, parentWidget); if (dialog.exec() != QDialog::Accepted) return; m_transparent_colors = dialog.pickedColors(); const QPixmap oldPixmap = pixmap_; const QPixmap newPixmap = dialog.resultPixmap(); auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(tr("Définir une couleur transparente")); // See replaceImage()'s identical comment: a separate, deliberate // action with no compound child of its own, so a dummy one is // needed to stop two consecutive transparency edits (identical // object, property, and text) from silently merging into one. new QUndoCommand(undo); diagram()->undoStack().push(undo); } /** @brief DiagramImageItem::crop Context-menu action: opens ImageCropDialog against pixmap_ (the current, already colour-keyed display, so cropping is WYSIWYG against whatever is actually visible), then applies the chosen rectangle to both pixmap_ and m_base_pixmap together -- kept in sync the same way mirror() keeps them in sync, since cropping is a permanent, geometric change to the image's own content, unlike setTransparentColor()'s non-destructive colour keying. pos() also needs adjusting, not just pixmap_: setPixmap() (called via the "pixmap" undo command below) recomputes transformOriginPoint() from the new, smaller boundingRect(), but pos() itself is untouched by that -- without fixing it up here too, the surviving content would visually jump to wherever local (0,0) happens to land after shrinking, rather than staying exactly where it already was. Chained into one undo step together with the pixmap change, since undoing a crop has to restore both, or the restored (larger) image ends up in the wrong place. */ /** @brief DiagramImageItem::crop Context-menu action: opens ImageCropDialog against m_base_pixmap (the true, uncropped original), pre-populated with whatever crop rectangle was chosen in a previous session -- re-editable, not destructive: nothing about the original content is ever discarded, only which region of it is currently being shown, exactly the same principle setTransparentColor() already follows for its own choices. Recomputes pixmap_ via computeDisplayPixmap() so any already-picked transparent colours are correctly re-applied to the newly-cropped region, rather than lost (the crop dialog itself knows nothing about them). pos() also needs adjusting, not just pixmap_: setPixmap() (called via the "pixmap" undo command below) recomputes transformOriginPoint() from the new boundingRect(), but pos() itself is untouched by that -- without fixing it up here too, the surviving content would visually jump to wherever local (0,0) ends up after the crop rect changes, rather than staying exactly where it already was. This has to work whether this is the first crop ever applied or an adjustment of an existing one, so the position math is always done relative to the CURRENT crop rect (m_crop_rect, before it's updated below) -- when there's no previous crop, that's simply the whole base, which is what the very first version of this method assumed unconditionally. Chained into one undo step together with the pixmap change, since undoing a crop has to restore both, or the restored (larger) image ends up in the wrong place. */ void DiagramImageItem::crop() { if (!diagram() || diagram()->isReadOnly()) return; QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); ImageCropDialog dialog(m_base_pixmap, m_crop_rect, parentWidget); if (dialog.exec() != QDialog::Accepted) return; const QRect newCropRect = dialog.cropRect(); if (newCropRect.isEmpty() || newCropRect == m_crop_rect) return; // nothing actually changed // newCropRect is in m_base_pixmap's own coordinates; converting its // center into the CURRENT local space (pixmap_'s own coordinates, // i.e. relative to the OLD m_crop_rect's own top-left) before // mapping to the scene through the transform that's still active // right now, prior to anything below changing it. const QPointF newCropCenterInCurrentLocal = QRectF(newCropRect).center() - QPointF(m_crop_rect.topLeft()); const QPointF cropCenterScene = mapToScene(newCropCenterInCurrentLocal); const QPointF oldPos = pos(); const QPixmap oldPixmap = pixmap_; const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors); m_crop_rect = newCropRect; // boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by // reading the actual implementation, not assumed) -- so the new // origin point can be derived directly from newPixmap here. Unlike // before this class gained a proper transform, setPixmap() no // longer manages the pivot automatically at all (see its own // comment for why) -- crop() now has to set it explicitly itself, // chained into the same undo step as the pixmap and position // changes, since all three genuinely change together here. const QPointF oldPivot = m_transform.pivot; const QPointF newOriginPoint = QRectF(newPixmap.rect()).center(); const QPointF newPos = cropCenterScene - newOriginPoint; auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); undo->setText(tr("Rogner une image")); new QPropertyUndoCommand(this, "pos", oldPos, newPos, undo); new QPropertyUndoCommand(this, "rawPivot", oldPivot, newOriginPoint, undo); m_pivotIsCustom = false; diagram()->undoStack().push(undo); }