Skip to content
Permalink
Browse files

[FEATURE][layouts] Allow expressions to be embedded inside legend ite…

…m text

This feature allows for expressions to be embedded directly inside
legend item text (e.g. group, subgroup and item text). The expressions
are evaluated at render time, with full knowledge of the legend's
expression context (so can utilise variables from the layout/layout item/
etc)

There's no UI for this yet (that will come in 3.8), but expressions are
entered using the standard [% 1 + 2 %] format.

E.g. a legend item text of

    My layer (rendered at 1:[% @map_scale %])

will show in the output as

    My layer (renderer at 1:1000)
  • Loading branch information
nyalldawson committed Jan 18, 2019
1 parent b3d5f27 commit d70b60d48fb85d65dd89f9b564fbbba9b4b2f72c
@@ -198,6 +198,16 @@ The ``scale`` value indicates the scale denominator, e.g. 1000.0 for a 1:1000 ma
void setDpi( int dpi );



QStringList evaluateItemText( const QString &stringToSplt, const QgsExpressionContext &context ) const;
%Docstring
Returns the actual text to render for a legend item, split into separate lines.

:param ctx: Context for rendering - may be null if only doing layout without actual rendering

.. versionadded:: 3.6
%End

QStringList splitStringForWrapping( const QString &stringToSplt ) const;
%Docstring
Splits a string using the wrap char taking into account handling empty
@@ -92,7 +92,9 @@ QSizeF QgsLayerTreeModelLegendNode::drawSymbolText( const QgsLegendSettings &set
double textHeight = settings.fontHeightCharacterMM( symbolLabelFont, QChar( '0' ) );
double textDescent = settings.fontDescentMillimeters( symbolLabelFont );

const QStringList lines = settings.splitStringForWrapping( data( Qt::DisplayRole ).toString() );
QgsExpressionContext tempContext;

const QStringList lines = settings.evaluateItemText( data( Qt::DisplayRole ).toString(), ctx && ctx->context ? ctx->context->expressionContext() : tempContext );

labelSize.rheight() = lines.count() * textHeight + ( lines.count() - 1 ) * ( settings.lineSpacing() + textDescent );

@@ -831,7 +831,6 @@ QgsExpressionContext QgsLayoutItemLegend::createExpressionContext() const
context.appendScope( mMap->createExpressionContext().popScope() );
}


QgsExpressionContextScope *scope = new QgsExpressionContextScope( tr( "Legend Settings" ) );

scope->addVariable( QgsExpressionContextScope::StaticVariable( QStringLiteral( "legend_title" ), title(), true ) );
@@ -566,7 +566,8 @@ QSizeF QgsLegendRenderer::drawLayerTitleInternal( QgsLayerTreeLayer *nodeLayer,
QModelIndex idx = mLegendModel->node2index( nodeLayer );

//Let the user omit the layer title item by having an empty layer title string
if ( mLegendModel->data( idx, Qt::DisplayRole ).toString().isEmpty() ) return size;
if ( mLegendModel->data( idx, Qt::DisplayRole ).toString().isEmpty() )
return size;

double y = point.y();

@@ -577,8 +578,11 @@ QSizeF QgsLegendRenderer::drawLayerTitleInternal( QgsLayerTreeLayer *nodeLayer,

QFont layerFont = mSettings.style( nodeLegendStyle( nodeLayer ) ).font();

QStringList lines = mSettings.splitStringForWrapping( mLegendModel->data( idx, Qt::DisplayRole ).toString() );
for ( QStringList::Iterator layerItemPart = lines.begin(); layerItemPart != lines.end(); ++layerItemPart )
QgsExpressionContext tempContext;

const QStringList lines = mSettings.evaluateItemText( mLegendModel->data( idx, Qt::DisplayRole ).toString(),
context ? context->expressionContext() : tempContext );
for ( QStringList::ConstIterator layerItemPart = lines.constBegin(); layerItemPart != lines.constEnd(); ++layerItemPart )
{
y += mSettings.fontAscentMillimeters( layerFont );
if ( context && context->painter() )
@@ -617,8 +621,11 @@ QSizeF QgsLegendRenderer::drawGroupTitleInternal( QgsLayerTreeGroup *nodeGroup,

QFont groupFont = mSettings.style( nodeLegendStyle( nodeGroup ) ).font();

QStringList lines = mSettings.splitStringForWrapping( mLegendModel->data( idx, Qt::DisplayRole ).toString() );
for ( QStringList::Iterator groupPart = lines.begin(); groupPart != lines.end(); ++groupPart )
QgsExpressionContext tempContext;

const QStringList lines = mSettings.evaluateItemText( mLegendModel->data( idx, Qt::DisplayRole ).toString(),
context ? context->expressionContext() : tempContext );
for ( QStringList::ConstIterator groupPart = lines.constBegin(); groupPart != lines.constEnd(); ++groupPart )
{
y += mSettings.fontAscentMillimeters( groupFont );
if ( context && context->painter() )
@@ -14,6 +14,8 @@
***************************************************************************/

#include "qgslegendsettings.h"
#include "qgsexpressioncontext.h"
#include "qgsexpression.h"

#include <QPainter>

@@ -35,6 +37,12 @@ QgsLegendSettings::QgsLegendSettings()
rstyle( QgsLegendStyle::SymbolLabel ).rfont().setPointSizeF( 12.0 );
}

QStringList QgsLegendSettings::evaluateItemText( const QString &stringToSplt, const QgsExpressionContext &context ) const
{
const QString textToRender = QgsExpression::replaceExpressionText( stringToSplt, &context );
return splitStringForWrapping( textToRender );
}

QStringList QgsLegendSettings::splitStringForWrapping( const QString &stringToSplt ) const
{
QStringList list;
@@ -25,6 +25,7 @@ class QRectF;

#include "qgslegendstyle.h"

class QgsExpressionContext;

/**
* \ingroup core
@@ -182,6 +183,20 @@ class CORE_EXPORT QgsLegendSettings

// utility functions

/**
* Splits a string using the wrap char taking into account handling empty
* wrap char which means no wrapping
*/

/**
* Returns the actual text to render for a legend item, split into separate lines.
*
* \param ctx Context for rendering - may be null if only doing layout without actual rendering
*
* \since QGIS 3.6
*/
QStringList evaluateItemText( const QString &stringToSplt, const QgsExpressionContext &context ) const;

/**
* Splits a string using the wrap char taking into account handling empty
* wrap char which means no wrapping
@@ -27,6 +27,7 @@
#include <qgslayertreemodel.h>
#include <qgslayertreemodellegendnode.h>
#include <qgslayertreeutils.h>
#include "qgslegendsettings.h"

class TestQgsLayerTree : public QObject
{
@@ -53,6 +54,7 @@ class TestQgsLayerTree : public QObject
void testFindGroups();
void testUtilsCollectMapLayers();
void testUtilsCountMapLayers();
void testSymbolText();

private:

@@ -707,6 +709,62 @@ void TestQgsLayerTree::testUtilsCountMapLayers()
QCOMPARE( QgsLayerTreeUtils::countMapLayerInTree( &root, vl ), 2 );
}

void TestQgsLayerTree::testSymbolText()
{
//new memory layer
QgsVectorLayer *vl = new QgsVectorLayer( QStringLiteral( "Point?field=col1:integer" ), QStringLiteral( "vl" ), QStringLiteral( "memory" ) );
QVERIFY( vl->isValid() );

QgsProject project;
project.addMapLayer( vl );

//create a categorized renderer for layer
QgsCategorizedSymbolRenderer *renderer = new QgsCategorizedSymbolRenderer();
renderer->setClassAttribute( QStringLiteral( "col1" ) );
renderer->setSourceSymbol( QgsSymbol::defaultSymbol( QgsWkbTypes::PointGeometry ) );
renderer->addCategory( QgsRendererCategory( "a", QgsSymbol::defaultSymbol( QgsWkbTypes::PointGeometry ), QStringLiteral( "a [% 1 + 2 %]" ) ) );
renderer->addCategory( QgsRendererCategory( "b", QgsSymbol::defaultSymbol( QgsWkbTypes::PointGeometry ), QStringLiteral( "b,c" ) ) );
renderer->addCategory( QgsRendererCategory( "c", QgsSymbol::defaultSymbol( QgsWkbTypes::PointGeometry ), QStringLiteral( "c" ) ) );
vl->setRenderer( renderer );

//create legend with symbology nodes for categorized renderer
QgsLayerTree *root = new QgsLayerTree();
QgsLayerTreeLayer *n = new QgsLayerTreeLayer( vl );
root->addChildNode( n );
QgsLayerTreeModel *m = new QgsLayerTreeModel( root, nullptr );
m->refreshLayerLegend( n );

QList<QgsLayerTreeModelLegendNode *> nodes = m->layerLegendNodes( n );
QCOMPARE( nodes.length(), 3 );

QgsLegendSettings settings;
settings.setWrapChar( QStringLiteral( "," ) );
QCOMPARE( nodes.at( 0 )->data( Qt::DisplayRole ).toString(), QStringLiteral( "a [% 1 + 2 %]" ) );
QCOMPARE( nodes.at( 1 )->data( Qt::DisplayRole ).toString(), QStringLiteral( "b,c" ) );
QCOMPARE( nodes.at( 2 )->data( Qt::DisplayRole ).toString(), QStringLiteral( "c" ) );
nodes.at( 2 )->setUserLabel( QStringLiteral( "[% 2+3 %] x [% 3+4 %]" ) );
QCOMPARE( nodes.at( 2 )->data( Qt::DisplayRole ).toString(), QStringLiteral( "[% 2+3 %] x [% 3+4 %]" ) );

QgsExpressionContext context;
QCOMPARE( settings.evaluateItemText( nodes.at( 0 )->data( Qt::DisplayRole ).toString(), context ), QStringList() << QStringLiteral( "a 3" ) );
QCOMPARE( settings.evaluateItemText( nodes.at( 1 )->data( Qt::DisplayRole ).toString(), context ), QStringList() << QStringLiteral( "b" ) << QStringLiteral( "c" ) );
QCOMPARE( settings.evaluateItemText( nodes.at( 2 )->data( Qt::DisplayRole ).toString(), context ), QStringList() << QStringLiteral( "5 x 7" ) );

// split string should happen after expression evaluation
QgsExpressionContextScope *scope = new QgsExpressionContextScope();
scope->setVariable( QStringLiteral( "bbbb" ), QStringLiteral( "aaaa,bbbb,cccc" ) );
context.appendScope( scope );
nodes.at( 2 )->setUserLabel( QStringLiteral( "[% @bbbb %],[% 3+4 %]" ) );
QCOMPARE( settings.evaluateItemText( nodes.at( 2 )->data( Qt::DisplayRole ).toString(), context ), QStringList() << QStringLiteral( "aaaa" )
<< QStringLiteral( "bbbb" )
<< QStringLiteral( "cccc" )
<< QStringLiteral( "7" ) );

//cleanup
delete m;
delete root;
}


QGSTEST_MAIN( TestQgsLayerTree )
#include "testqgslayertree.moc"
@@ -15,7 +15,8 @@
from qgis.PyQt.QtCore import QRectF
from qgis.PyQt.QtGui import QColor

from qgis.core import (QgsLayoutItemLegend,
from qgis.core import (QgsPrintLayout,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayout,
QgsMapSettings,
@@ -30,7 +31,10 @@
QgsLayoutItem,
QgsLayoutPoint,
QgsLayoutSize,
QgsExpression)
QgsExpression,
QgsMapLayerLegendUtils,
QgsLegendStyle,
QgsFontUtils)
from qgis.testing import (start_app,
unittest
)
@@ -305,6 +309,64 @@ def testLegendScopeVariables(self):
exp6 = QgsExpression("@map_scale")
self.assertAlmostEqual(exp6.evaluate(expc2), 15000, 2)

def testExpressionInText(self):
"""Test expressions embedded in legend node text"""

point_path = os.path.join(TEST_DATA_DIR, 'points.shp')
point_layer = QgsVectorLayer(point_path, 'points', 'ogr')

layout = QgsPrintLayout(QgsProject.instance())
layout.setName('LAYOUT')
layout.initializeDefaults()

map = QgsLayoutItemMap(layout)
map.attemptSetSceneRect(QRectF(20, 20, 80, 80))
map.setFrameEnabled(True)
map.setLayers([point_layer])
layout.addLayoutItem(map)
map.setExtent(point_layer.extent())

legend = QgsLayoutItemLegend(layout)
legend.setTitle("Legend")
legend.attemptSetSceneRect(QRectF(120, 20, 100, 100))
legend.setFrameEnabled(True)
legend.setFrameStrokeWidth(QgsLayoutMeasurement(2))
legend.setBackgroundColor(QColor(200, 200, 200))
legend.setTitle('')
legend.setLegendFilterByMapEnabled(False)
legend.setStyleFont(QgsLegendStyle.Title, QgsFontUtils.getStandardTestFont('Bold', 16))
legend.setStyleFont(QgsLegendStyle.Group, QgsFontUtils.getStandardTestFont('Bold', 16))
legend.setStyleFont(QgsLegendStyle.Subgroup, QgsFontUtils.getStandardTestFont('Bold', 16))
legend.setStyleFont(QgsLegendStyle.Symbol, QgsFontUtils.getStandardTestFont('Bold', 16))
legend.setStyleFont(QgsLegendStyle.SymbolLabel, QgsFontUtils.getStandardTestFont('Bold', 16))

# disable auto resizing
legend.setResizeToContents(False)
legend.setAutoUpdateModel(False)

QgsProject.instance().addMapLayers([point_layer])
s = QgsMapSettings()
s.setLayers([point_layer])

group = legend.model().rootGroup().addGroup("Group [% 1 + 5 %] [% @layout_name %]")
layer_tree_layer = group.addLayer(point_layer)
layer_tree_layer.setCustomProperty("legend/title-label", 'bbbb [% 1+2 %] xx [% @layout_name %]')
QgsMapLayerLegendUtils.setLegendNodeUserLabel(layer_tree_layer, 0, 'xxxx')
legend.model().refreshLayerLegend(layer_tree_layer)
legend.model().layerLegendNodes(layer_tree_layer)[0].setUserLabel('bbbb [% 1+2 %] xx [% @layout_name %]')

layout.addLayoutItem(legend)
legend.setLinkedMap(map)

map.setExtent(QgsRectangle(-102.51, 41.16, -102.36, 41.30))

checker = QgsLayoutChecker(
'composer_legend_expressions', layout)
checker.setControlPathPrefix("composer_legend")
result, message = checker.testLayout()
self.assertTrue(result, message)

QgsProject.instance().removeMapLayers([point_layer.id()])

if __name__ == '__main__':
unittest.main()
Binary file not shown.

0 comments on commit d70b60d

Please sign in to comment.
You can’t perform that action at this time.