Add an optional label to pictures (#349)

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 <image>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG
This commit is contained in:
ispyisail
2026-09-27 16:12:51 +13:00
parent d7036ad807
commit 5120feb417
5 changed files with 149 additions and 5 deletions
+93 -3
View File
@@ -35,6 +35,7 @@
#include <QBuffer>
#include <QFileDialog>
#include <QFileInfo>
#include <QFontMetricsF>
#include <QGraphicsSceneContextMenuEvent>
#include <QImageWriter>
#include <QMenu>
@@ -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;
@@ -23,6 +23,7 @@
#include "../ui/imagetransparentcolordialog.h"
#include <QColor>
#include <QFont>
#include <QList>
#include <QStyleOptionGraphicsItem>
#include <QVector>
@@ -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
+24
View File
@@ -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());
}
/**
+1
View File
@@ -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
+16 -2
View File
@@ -152,14 +152,28 @@
</property>
</widget>
</item>
<item row="6" column="0" colspan="2">
<item row="6" column="0">
<widget class="QLabel" name="label_label">
<property name="text">
<string>Libellé</string>
</property>
</widget>
</item>
<item row="6" column="1" colspan="2">
<widget class="QLineEdit" name="m_label_le">
<property name="toolTip">
<string>Texte affiché sous l'image. Il suit l'image quand elle est déplacée, copiée ou tournée.</string>
</property>
</widget>
</item>
<item row="7" column="0" colspan="2">
<widget class="QCheckBox" name="m_lock_pos_cb">
<property name="text">
<string>Verrouiller la position</string>
</property>
</widget>
</item>
<item row="7" column="0">
<item row="8" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>