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 ); 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; QStringList splitStringForWrapping( const QString &stringToSplt ) const;
%Docstring %Docstring
Splits a string using the wrap char taking into account handling empty 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 textHeight = settings.fontHeightCharacterMM( symbolLabelFont, QChar( '0' ) );
double textDescent = settings.fontDescentMillimeters( symbolLabelFont ); 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 ); 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() ); context.appendScope( mMap->createExpressionContext().popScope() );
} }



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


scope->addVariable( QgsExpressionContextScope::StaticVariable( QStringLiteral( "legend_title" ), title(), true ) ); 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 ); QModelIndex idx = mLegendModel->node2index( nodeLayer );


//Let the user omit the layer title item by having an empty layer title string //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(); double y = point.y();


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


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


QStringList lines = mSettings.splitStringForWrapping( mLegendModel->data( idx, Qt::DisplayRole ).toString() ); QgsExpressionContext tempContext;
for ( QStringList::Iterator layerItemPart = lines.begin(); layerItemPart != lines.end(); ++layerItemPart )
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 ); y += mSettings.fontAscentMillimeters( layerFont );
if ( context && context->painter() ) if ( context && context->painter() )
Expand Down Expand Up @@ -617,8 +621,11 @@ QSizeF QgsLegendRenderer::drawGroupTitleInternal( QgsLayerTreeGroup *nodeGroup,


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


QStringList lines = mSettings.splitStringForWrapping( mLegendModel->data( idx, Qt::DisplayRole ).toString() ); QgsExpressionContext tempContext;
for ( QStringList::Iterator groupPart = lines.begin(); groupPart != lines.end(); ++groupPart )
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 ); y += mSettings.fontAscentMillimeters( groupFont );
if ( context && context->painter() ) 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 "qgslegendsettings.h"
#include "qgsexpressioncontext.h"
#include "qgsexpression.h"


#include <QPainter> #include <QPainter>


Expand All @@ -35,6 +37,12 @@ QgsLegendSettings::QgsLegendSettings()
rstyle( QgsLegendStyle::SymbolLabel ).rfont().setPointSizeF( 12.0 ); 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 QgsLegendSettings::splitStringForWrapping( const QString &stringToSplt ) const
{ {
QStringList list; 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" #include "qgslegendstyle.h"


class QgsExpressionContext;


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


// utility functions // 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 * Splits a string using the wrap char taking into account handling empty
* wrap char which means no wrapping * 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 <qgslayertreemodel.h>
#include <qgslayertreemodellegendnode.h> #include <qgslayertreemodellegendnode.h>
#include <qgslayertreeutils.h> #include <qgslayertreeutils.h>
#include "qgslegendsettings.h"


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


private: private:


Expand Down Expand Up @@ -707,6 +709,62 @@ void TestQgsLayerTree::testUtilsCountMapLayers()
QCOMPARE( QgsLayerTreeUtils::countMapLayerInTree( &root, vl ), 2 ); 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 ) QGSTEST_MAIN( TestQgsLayerTree )
#include "testqgslayertree.moc" #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.QtCore import QRectF
from qgis.PyQt.QtGui import QColor from qgis.PyQt.QtGui import QColor


from qgis.core import (QgsLayoutItemLegend, from qgis.core import (QgsPrintLayout,
QgsLayoutItemLegend,
QgsLayoutItemMap, QgsLayoutItemMap,
QgsLayout, QgsLayout,
QgsMapSettings, QgsMapSettings,
Expand All @@ -30,7 +31,10 @@
QgsLayoutItem, QgsLayoutItem,
QgsLayoutPoint, QgsLayoutPoint,
QgsLayoutSize, QgsLayoutSize,
QgsExpression) QgsExpression,
QgsMapLayerLegendUtils,
QgsLegendStyle,
QgsFontUtils)
from qgis.testing import (start_app, from qgis.testing import (start_app,
unittest unittest
) )
Expand Down Expand Up @@ -305,6 +309,64 @@ def testLegendScopeVariables(self):
exp6 = QgsExpression("@map_scale") exp6 = QgsExpression("@map_scale")
self.assertAlmostEqual(exp6.evaluate(expc2), 15000, 2) 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__': if __name__ == '__main__':
unittest.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.