From 5120feb41700bbfc439b25cd8e331e1422eec41f Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 16:12:51 +1300 Subject: [PATCH] Add an optional label to pictures (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picture can now carry a caption, set from its properties panel ("Libellé"). It is drawn centred under the picture at the folio's normal text size whatever the picture's scale, turns with it, and moves, copies and prints with it because the picture itself paints it. Clicking the caption selects the picture. Saved as a "label" attribute on , written only when non-empty: a project without labels saves byte-for-byte as before, and older versions open a labelled project and simply ignore the caption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/qetgraphicsitem/diagramimageitem.cpp | 96 +++++++++++++++++++- sources/qetgraphicsitem/diagramimageitem.h | 15 +++ sources/ui/imagepropertieswidget.cpp | 24 +++++ sources/ui/imagepropertieswidget.h | 1 + sources/ui/imagepropertieswidget.ui | 18 +++- 5 files changed, 149 insertions(+), 5 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 25f2fc164..8e3b58c71 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -69,7 +70,7 @@ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *paren // 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(); + m_transform.pivot = imageRect().center(); setTransform(m_transform.toMatrix()); setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); @@ -110,6 +111,45 @@ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter -> drawRect(boundingRect()); painter -> restore(); } + + if (!m_label.isEmpty()) + { + // Undo the picture's own scale so the caption keeps the size of + // every other text on the folio whether the picture is shown at + // 5 % or 500 %. Rotation and skew are applied after scale in + // ShapeTransform::toMatrix(), so the caption still turns with it. + const QRectF r = labelRect(); + painter -> save(); + painter -> translate(r.topLeft()); + painter -> scale(1.0 / m_label_scale.x(), 1.0 / m_label_scale.y()); + painter -> setFont(m_label_font); + painter -> setPen(Qt::black); + painter -> drawText(QRectF(QPointF(0, 0), m_label_size), Qt::AlignCenter, m_label); + painter -> restore(); + } +} + +/** + @brief DiagramImageItem::setLabel + Set the caption drawn under the picture. An empty string removes it. + @param label +*/ +void DiagramImageItem::setLabel(const QString &label) +{ + if (label == m_label) + return; + + prepareGeometryChange(); + m_label = label; + // Font looked up here, not in paint(): diagramTextsFont() reads + // QSettings, far too slow to do on every repaint. + if (!m_label.isEmpty()) + m_label_font = QETApp::diagramTextsFont(); + m_label_size = m_label.isEmpty() + ? QSizeF() + : QFontMetricsF(m_label_font).size(0, m_label); + updateLabelScale(); + emit labelChanged(); } /** @@ -278,7 +318,7 @@ void DiagramImageItem::setPivotRaw(const QPointF &newPivot) void DiagramImageItem::resetPivotToBoundingRectCenter() { m_pivotIsCustom = false; - setPivot(boundingRect().center()); + setPivot(imageRect().center()); } namespace { @@ -1263,6 +1303,11 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant } refreshInteractionHints(); } + else if (change == ItemTransformChange && !m_label.isEmpty()) + { + prepareGeometryChange(); + updateLabelScale(); + } else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) { if (!m_deferHandleReposition) @@ -1308,6 +1353,18 @@ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect @return a QRectF represent the bounding rectangle */ QRectF DiagramImageItem::boundingRect() const +{ + if (m_label.isEmpty()) + return imageRect(); + return imageRect().united(labelRect()); +} + +/** + @brief DiagramImageItem::imageRect + @return the picture's own rectangle, without its label. Handles, the + pivot and every other piece of geometry work on this one. +*/ +QRectF DiagramImageItem::imageRect() const { if (!pixmap_.isNull()) { return (QRectF(pixmap_.rect())); @@ -1317,6 +1374,34 @@ QRectF DiagramImageItem::boundingRect() const } } +/** + @brief DiagramImageItem::labelRect + @return the label's rectangle in item coordinates, centred just under + the picture. Divided by the picture's scale, because paint() draws + the label unscaled. +*/ +QRectF DiagramImageItem::labelRect() const +{ + const QRectF image = imageRect(); + const qreal w = m_label_size.width() / m_label_scale.x(); + const qreal h = m_label_size.height() / m_label_scale.y(); + const qreal gap = 2.0 / m_label_scale.y(); + return QRectF(image.center().x() - w / 2.0, image.bottom() + gap, w, h); +} + +/** + @brief DiagramImageItem::updateLabelScale + The label rect depends on the picture's scale, so boundingRect() + changes with it. m_transform is already updated by the time the item + transform changes, so the scale is cached here and only refreshed + after prepareGeometryChange() has recorded the old rect. +*/ +void DiagramImageItem::updateLabelScale() +{ + const auto safe = [](qreal v) { return qFuzzyIsNull(v) ? 1.0 : qAbs(v); }; + m_label_scale = QPointF(safe(m_transform.scaleX), safe(m_transform.scaleY)); +} + /** @brief DiagramImageItem::name @return the generic name of this item (picture) @@ -1426,7 +1511,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) 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_transform.pivot = imageRect().center(); m_pivotIsCustom = false; const QDomElement transformElement = e.firstChildElement("transform"); @@ -1447,6 +1532,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) 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()); + setLabel(e.attribute("label")); return (true); } @@ -1477,6 +1563,10 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const 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_)); + // An attribute, not a child element: fromXml() of every earlier + // version requires the base64 text to be the first child. + if (!m_label.isEmpty()) + result.setAttribute("label", m_label); //write the pixmap in the xml element after he was been transformed to base64 QByteArray array; diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index afdbaf7b5..ac8d7c9db 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -23,6 +23,7 @@ #include "../ui/imagetransparentcolordialog.h" #include +#include #include #include #include @@ -46,6 +47,7 @@ class DiagramImageItem : public QetGraphicsItem { Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged) Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged) Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged) + Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) // A second, deliberately non-compensating property on the SAME // underlying value -- setPivot() (above) intentionally adjusts // pos() to keep the image visually in place, which is exactly @@ -123,10 +125,13 @@ class DiagramImageItem : public QetGraphicsItem { QPointF pivot() const { return m_transform.pivot; } void setPivot(const QPointF &pivot); void setPivotRaw(const QPointF &pivot); + QString label() const { return m_label; } + void setLabel(const QString &label); signals: void pixmapChanged(); void transformChanged(); + void labelChanged(); protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; @@ -175,6 +180,9 @@ class DiagramImageItem : public QetGraphicsItem { static QString hintForHandleRole(HandleRole role); void showStatusHint(const QString &text) const; void clearStatusHint() const; + QRectF imageRect() const; + QRectF labelRect() const; + void updateLabelScale(); protected: QPixmap pixmap_; @@ -212,6 +220,13 @@ class DiagramImageItem : public QetGraphicsItem { QPointF m_original_pos; // scene position at the start of a resize/rotate/pivot drag, for Escape-to-cancel ShapeTransform m_original_transform; bool m_deferHandleReposition = false; // see setPivot()'s comment + // Optional caption drawn centred under the picture (issue #349). + // Empty by default, and then neither saved nor painted, so a picture + // without one costs exactly what it did before. + QString m_label; + QFont m_label_font; + QSizeF m_label_size; // in scene units, measured once in setLabel() + QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation }; #endif diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index b2cccca2b..3b0931437 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -64,16 +64,21 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) this->setEnabled(true); if (m_image == image) return; if (m_image) + { disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + } m_image = image; connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); m_scaleX = m_image->scaleFactorX(); m_scaleY = m_image->scaleFactorY(); m_rotation = m_image->rotationAngle(); m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); + m_label = m_image->label(); updateUi(); } @@ -101,6 +106,7 @@ void ImagePropertiesWidget::apply() m_rotation = m_image->rotationAngle(); m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); + m_label = m_image->label(); } /** @@ -116,6 +122,7 @@ void ImagePropertiesWidget::reset() m_image->setRotationAngle(m_rotation); m_image->setSkewX(m_skewX); m_image->setSkewY(m_skewY); + m_image->setLabel(m_label); m_image->setMovable(m_movable); updateUi(); } @@ -138,6 +145,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) connect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); } else { @@ -146,6 +154,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) disconnect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); } return true; @@ -191,6 +200,19 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const chain("skewX", m_skewX, newSkewX, tr("Modifier l'inclinaison d'une image")); chain("skewY", m_skewY, newSkewY, tr("Modifier l'inclinaison d'une image")); + // Not through chain(): a string cannot be animated. + const QString newLabel = ui->m_label_le->text(); + if (newLabel != m_label) + { + if (undo) + new QPropertyUndoCommand(m_image, "label", m_label, newLabel, undo); + else + { + undo = new QPropertyUndoCommand(m_image, "label", m_label, newLabel); + undo->setText(tr("Modifier le libellé d'une image")); + } + } + return undo; } @@ -221,6 +243,8 @@ void ImagePropertiesWidget::updateUi() ui->m_skew_x_sb->setValue(m_image->skewX()); ui->m_skew_y_sb->setValue(m_image->skewY()); ui->m_lock_pos_cb->setChecked(!m_image->isMovable()); + if (ui->m_label_le->text() != m_image->label()) + ui->m_label_le->setText(m_image->label()); } /** diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index 438b5e3b8..c692d7892 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -70,6 +70,7 @@ class ImagePropertiesWidget : public PropertiesEditorWidget qreal m_rotation; qreal m_skewX; qreal m_skewY; + QString m_label; // Guards the width/height spinboxes' mutual updates when // "Conserver les proportions" is checked, so setting one // programmatically in response to the other doesn't re-trigger diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index 36ff7ddf3..4c9954cd6 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -152,14 +152,28 @@ - + + + + Libellé + + + + + + + Texte affiché sous l'image. Il suit l'image quand elle est déplacée, copiée ou tournée. + + + + Verrouiller la position - + Qt::Vertical