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)
  • Loading branch information
nyalldawson committed Jan 18, 2019
1 parent b3d5f27 commit d70b60d
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 9 deletions.
10 changes: 10 additions & 0 deletions python/core/auto_generated/qgslegendsettings.sip.in
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/core/layertree/qgslayertreemodellegendnode.cpp
Expand Up @@ -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 );

Expand Down
1 change: 0 additions & 1 deletion src/core/layout/qgslayoutitemlegend.cpp
Expand Up @@ -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 ) );
Expand Down
17 changes: 12 additions & 5 deletions src/core/qgslegendrenderer.cpp
Expand Up @@ -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();

Expand All @@ -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() )
Expand Down Expand Up @@ -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() )
Expand Down
8 changes: 8 additions & 0 deletions src/core/qgslegendsettings.cpp
Expand Up @@ -14,6 +14,8 @@
***************************************************************************/

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

#include <QPainter>

Expand All @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/core/qgslegendsettings.h
Expand Up @@ -25,6 +25,7 @@ class QRectF;

#include "qgslegendstyle.h"

class QgsExpressionContext;

/**
* \ingroup core
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/src/core/testqgslayertree.cpp
Expand Up @@ -27,6 +27,7 @@
#include <qgslayertreemodel.h>
#include <qgslayertreemodellegendnode.h>
#include <qgslayertreeutils.h>
#include "qgslegendsettings.h"

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

private:

Expand Down Expand Up @@ -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
Expand Up @@ -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,
Expand All @@ -30,7 +31,10 @@
QgsLayoutItem,
QgsLayoutPoint,
QgsLayoutSize,
QgsExpression)
QgsExpression,
QgsMapLayerLegendUtils,
QgsLegendStyle,
QgsFontUtils)
from qgis.testing import (start_app,
unittest
)
Expand Down Expand Up @@ -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()
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.