Skip to content

Commit

Permalink
[FEATURE][layouts] Allow expressions to be embedded inside legend ite…
Browse files Browse the repository at this point in the history
…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)
nyalldawson committed Jan 18, 2019
1 parent b3d5f27 commit d70b60d
Showing 9 changed files with 170 additions and 9 deletions.
10 changes: 10 additions & 0 deletions python/core/auto_generated/qgslegendsettings.sip.in
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion src/core/layertree/qgslayertreemodellegendnode.cpp
Original file line number Diff line number Diff line change
@@ -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 );

1 change: 0 additions & 1 deletion src/core/layout/qgslayoutitemlegend.cpp
Original file line number Diff line number Diff line change
@@ -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 ) );
17 changes: 12 additions & 5 deletions src/core/qgslegendrenderer.cpp
Original file line number Diff line number Diff line change
@@ -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() )
8 changes: 8 additions & 0 deletions src/core/qgslegendsettings.cpp
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions src/core/qgslegendsettings.h
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions tests/src/core/testqgslayertree.cpp
Original file line number Diff line number Diff line change
@@ -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"
66 changes: 64 additions & 2 deletions tests/src/python/test_qgslayoutlegend.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit d70b60d

Please sign in to comment.