Skip to content

Commit 15dd295

Browse files
committed
Fix Capitalize First Letter fails with curved labels (fix #14875)
Instead of using QFont's inbuilt capitalization support, which applies only on rendering and accordingly fails for curved labels which are drawn one character at a time, we now manually capitalize label text while registering features. The capitalize first method from Qt was reimplemented in QgsStringUtils (together with what I expect is better handling of unicode characters over the Qt method). This change also makes it possible to implement other capitalization methods not directly supported by Qt
1 parent 0658640 commit 15dd295

File tree

6 files changed

+227
-34
lines changed

6 files changed

+227
-34
lines changed

python/core/qgsstringutils.sip

+19
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ class QgsStringUtils
122122
#include <qgsstringutils.h>
123123
%End
124124
public:
125+
126+
127+
//! Capitalization options
128+
enum Capitalization
129+
{
130+
MixedCase, //!< Mixed case, ie no change
131+
AllUppercase, //!< Convert all characters to uppercase
132+
AllLowercase, //!< Convert all characters to lowercase
133+
ForceFirstLetterToCapital, //!< Convert just the first letter of each word to uppercase, leave the rest untouched
134+
};
135+
136+
/** Converts a string by applying capitalization rules to the string.
137+
* @param string input string
138+
* @param capitalization capitalization type to apply
139+
* @return capitalized string
140+
* @note added in QGIS 3.0
141+
*/
142+
static QString capitalize( const QString& string, Capitalization capitalization );
143+
125144
/** Returns the Levenshtein edit distance between two strings. This equates to the minimum
126145
* number of character edits (insertions, deletions or substitutions) required to change
127146
* one string to another.

src/core/qgspallabeling.cpp

+36-34
Original file line numberDiff line numberDiff line change
@@ -2302,6 +2302,7 @@ void QgsPalLayerSettings::registerFeature( QgsFeature& f, QgsRenderContext &cont
23022302

23032303
// calculate rest of font attributes and store any data defined values
23042304
// this is done here for later use in making label backgrounds part of collision management (when implemented)
2305+
labelFont.setCapitalization( QFont::MixedCase ); // reset this - we don't use QFont's handling as it breaks with curved labels
23052306
parseTextStyle( labelFont, fontunits, context );
23062307
parseTextFormatting( context );
23072308
parseTextBuffer( context );
@@ -2340,6 +2341,41 @@ void QgsPalLayerSettings::registerFeature( QgsFeature& f, QgsRenderContext &cont
23402341
labelText = substitutions.process( labelText );
23412342
}
23422343

2344+
// apply capitalization
2345+
QgsStringUtils::Capitalization capitalization = QgsStringUtils::MixedCase;
2346+
// maintain API - capitalization may have been set in textFont
2347+
if ( textFont.capitalization() != QFont::MixedCase )
2348+
{
2349+
capitalization = static_cast< QgsStringUtils::Capitalization >( textFont.capitalization() );
2350+
}
2351+
// data defined font capitalization?
2352+
if ( dataDefinedEvaluate( QgsPalLayerSettings::FontCase, exprVal, &context.expressionContext() ) )
2353+
{
2354+
QString fcase = exprVal.toString().trimmed();
2355+
QgsDebugMsgLevel( QString( "exprVal FontCase:%1" ).arg( fcase ), 4 );
2356+
2357+
if ( !fcase.isEmpty() )
2358+
{
2359+
if ( fcase.compare( "NoChange", Qt::CaseInsensitive ) == 0 )
2360+
{
2361+
capitalization = QgsStringUtils::MixedCase;
2362+
}
2363+
else if ( fcase.compare( "Upper", Qt::CaseInsensitive ) == 0 )
2364+
{
2365+
capitalization = QgsStringUtils::AllUppercase;
2366+
}
2367+
else if ( fcase.compare( "Lower", Qt::CaseInsensitive ) == 0 )
2368+
{
2369+
capitalization = QgsStringUtils::AllLowercase;
2370+
}
2371+
else if ( fcase.compare( "Capitalize", Qt::CaseInsensitive ) == 0 )
2372+
{
2373+
capitalization = QgsStringUtils::ForceFirstLetterToCapital;
2374+
}
2375+
}
2376+
}
2377+
labelText = QgsStringUtils::capitalize( labelText, capitalization );
2378+
23432379
// data defined format numbers?
23442380
bool formatnum = formatNumbers;
23452381
if ( dataDefinedEvaluate( QgsPalLayerSettings::NumFormat, exprVal, &context.expressionContext(), formatNumbers ) )
@@ -3358,7 +3394,6 @@ void QgsPalLayerSettings::parseTextStyle( QFont& labelFont,
33583394
// copy over existing font settings
33593395
//newFont = newFont.resolve( labelFont ); // should work, but let's be sure what's being copied
33603396
newFont.setPixelSize( labelFont.pixelSize() );
3361-
newFont.setCapitalization( labelFont.capitalization() );
33623397
newFont.setUnderline( labelFont.underline() );
33633398
newFont.setStrikeOut( labelFont.strikeOut() );
33643399
newFont.setWordSpacing( labelFont.wordSpacing() );
@@ -3395,39 +3430,6 @@ void QgsPalLayerSettings::parseTextStyle( QFont& labelFont,
33953430
}
33963431
labelFont.setLetterSpacing( QFont::AbsoluteSpacing, scaleToPixelContext( letterspace, context, fontunits, false, fontSizeMapUnitScale ) );
33973432

3398-
// data defined font capitalization?
3399-
QFont::Capitalization fontcaps = labelFont.capitalization();
3400-
if ( dataDefinedEvaluate( QgsPalLayerSettings::FontCase, exprVal, &context.expressionContext() ) )
3401-
{
3402-
QString fcase = exprVal.toString().trimmed();
3403-
QgsDebugMsgLevel( QString( "exprVal FontCase:%1" ).arg( fcase ), 4 );
3404-
3405-
if ( !fcase.isEmpty() )
3406-
{
3407-
if ( fcase.compare( "NoChange", Qt::CaseInsensitive ) == 0 )
3408-
{
3409-
fontcaps = QFont::MixedCase;
3410-
}
3411-
else if ( fcase.compare( "Upper", Qt::CaseInsensitive ) == 0 )
3412-
{
3413-
fontcaps = QFont::AllUppercase;
3414-
}
3415-
else if ( fcase.compare( "Lower", Qt::CaseInsensitive ) == 0 )
3416-
{
3417-
fontcaps = QFont::AllLowercase;
3418-
}
3419-
else if ( fcase.compare( "Capitalize", Qt::CaseInsensitive ) == 0 )
3420-
{
3421-
fontcaps = QFont::Capitalize;
3422-
}
3423-
3424-
if ( fontcaps != labelFont.capitalization() )
3425-
{
3426-
labelFont.setCapitalization( fontcaps );
3427-
}
3428-
}
3429-
}
3430-
34313433
// data defined strikeout font style?
34323434
if ( dataDefinedEvaluate( QgsPalLayerSettings::Strikeout, exprVal, &context.expressionContext(), labelFont.strikeOut() ) )
34333435
{

src/core/qgsstringutils.cpp

+49
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,55 @@
1717
#include <QVector>
1818
#include <QRegExp>
1919
#include <QTextDocument> // for Qt::escape
20+
#include <QStringList>
21+
#include <QTextBoundaryFinder>
22+
23+
QString QgsStringUtils::capitalize( const QString& string, QgsStringUtils::Capitalization capitalization )
24+
{
25+
if ( string.isEmpty() )
26+
return QString();
27+
28+
switch ( capitalization )
29+
{
30+
case MixedCase:
31+
return string;
32+
33+
case AllUppercase:
34+
return string.toUpper();
35+
36+
case AllLowercase:
37+
return string.toLower();
38+
39+
case ForceFirstLetterToCapital:
40+
{
41+
QString temp = string;
42+
43+
QTextBoundaryFinder wordSplitter( QTextBoundaryFinder::Word, string.constData(), string.length(), 0, 0 );
44+
QTextBoundaryFinder letterSplitter( QTextBoundaryFinder::Grapheme, string.constData(), string.length(), 0, 0 );
45+
46+
wordSplitter.setPosition( 0 );
47+
bool first = true;
48+
#if QT_VERSION >= 0x050000
49+
while (( first && wordSplitter.boundaryReasons() & QTextBoundaryFinder::StartOfItem )
50+
|| wordSplitter.toNextBoundary() >= 0 )
51+
#else
52+
while (( first && wordSplitter.boundaryReasons() & QTextBoundaryFinder::StartWord )
53+
|| wordSplitter.toNextBoundary() >= 0 )
54+
#endif
55+
{
56+
first = false;
57+
letterSplitter.setPosition( wordSplitter.position() );
58+
letterSplitter.toNextBoundary();
59+
QString substr = string.mid( wordSplitter.position(), letterSplitter.position() - wordSplitter.position() );
60+
temp.replace( wordSplitter.position(), substr.length(), substr.toUpper() );
61+
}
62+
return temp;
63+
}
64+
65+
}
66+
// no warnings
67+
return string;
68+
}
2069

2170
int QgsStringUtils::levenshteinDistance( const QString& string1, const QString& string2, bool caseSensitive )
2271
{

src/core/qgsstringutils.h

+18
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <QRegExp>
1818
#include <QList>
1919
#include <QDomDocument>
20+
#include <QFont> // for enum values
2021
#include "qgis.h"
2122

2223
#ifndef QGSSTRINGUTILS_H
@@ -166,6 +167,23 @@ class CORE_EXPORT QgsStringUtils
166167
{
167168
public:
168169

170+
//! Capitalization options
171+
enum Capitalization
172+
{
173+
MixedCase = QFont::MixedCase, //!< Mixed case, ie no change
174+
AllUppercase = QFont::AllUppercase, //!< Convert all characters to uppercase
175+
AllLowercase = QFont::AllLowercase, //!< Convert all characters to lowercase
176+
ForceFirstLetterToCapital = QFont::Capitalize, //!< Convert just the first letter of each word to uppercase, leave the rest untouched
177+
};
178+
179+
/** Converts a string by applying capitalization rules to the string.
180+
* @param string input string
181+
* @param capitalization capitalization type to apply
182+
* @return capitalized string
183+
* @note added in QGIS 3.0
184+
*/
185+
static QString capitalize( const QString& string, Capitalization capitalization );
186+
169187
/** Returns the Levenshtein edit distance between two strings. This equates to the minimum
170188
* number of character edits (insertions, deletions or substitutions) required to change
171189
* one string to another.

tests/src/core/testqgslabelingengine.cpp

+61
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class TestQgsLabelingEngine : public QObject
4444
void zOrder(); //test that labels are stacked correctly
4545
void testEncodeDecodePositionOrder();
4646
void testSubstitutions();
47+
void testCapitalization();
4748

4849
private:
4950
QgsVectorLayer* vl;
@@ -52,6 +53,7 @@ class TestQgsLabelingEngine : public QObject
5253

5354
void setDefaultLabelParams( QgsVectorLayer* layer );
5455
bool imageCheck( const QString& testName, QImage &image, int mismatchCount );
56+
5557
};
5658

5759
void TestQgsLabelingEngine::initTestCase()
@@ -454,6 +456,65 @@ void TestQgsLabelingEngine::testSubstitutions()
454456
QCOMPARE( provider2->mLabels.at( 0 )->labelText(), QString( "bb label" ) );
455457
}
456458

459+
void TestQgsLabelingEngine::testCapitalization()
460+
{
461+
QgsFeature f( vl->fields(), 1 );
462+
f.setGeometry( QgsGeometry::fromPoint( QgsPoint( 1, 2 ) ) );
463+
464+
// make a fake render context
465+
QSize size( 640, 480 );
466+
QgsMapSettings mapSettings;
467+
mapSettings.setOutputSize( size );
468+
mapSettings.setExtent( vl->extent() );
469+
mapSettings.setLayers( QStringList() << vl->id() );
470+
mapSettings.setOutputDpi( 96 );
471+
QgsRenderContext context = QgsRenderContext::fromMapSettings( mapSettings );
472+
QStringList attributes;
473+
QgsLabelingEngine engine;
474+
engine.setMapSettings( mapSettings );
475+
476+
// no change
477+
QgsPalLayerSettings settings;
478+
QFont font = settings.textFont;
479+
font.setCapitalization( QFont::MixedCase );
480+
settings.textFont = font;
481+
settings.fieldName = QString( "'a teSt LABEL'" );
482+
settings.isExpression = true;
483+
484+
QgsVectorLayerLabelProvider* provider = new QgsVectorLayerLabelProvider( vl, "test", true, &settings );
485+
engine.addProvider( provider );
486+
provider->prepare( context, attributes );
487+
provider->registerFeature( f, context );
488+
QCOMPARE( provider->mLabels.at( 0 )->labelText(), QString( "a teSt LABEL" ) );
489+
490+
//uppercase
491+
font.setCapitalization( QFont::AllUppercase );
492+
settings.textFont = font;
493+
QgsVectorLayerLabelProvider* provider2 = new QgsVectorLayerLabelProvider( vl, "test2", true, &settings );
494+
engine.addProvider( provider2 );
495+
provider2->prepare( context, attributes );
496+
provider2->registerFeature( f, context );
497+
QCOMPARE( provider2->mLabels.at( 0 )->labelText(), QString( "A TEST LABEL" ) );
498+
499+
//lowercase
500+
font.setCapitalization( QFont::AllLowercase );
501+
settings.textFont = font;
502+
QgsVectorLayerLabelProvider* provider3 = new QgsVectorLayerLabelProvider( vl, "test3", true, &settings );
503+
engine.addProvider( provider3 );
504+
provider3->prepare( context, attributes );
505+
provider3->registerFeature( f, context );
506+
QCOMPARE( provider3->mLabels.at( 0 )->labelText(), QString( "a test label" ) );
507+
508+
//first letter uppercase
509+
font.setCapitalization( QFont::Capitalize );
510+
settings.textFont = font;
511+
QgsVectorLayerLabelProvider* provider4 = new QgsVectorLayerLabelProvider( vl, "test4", true, &settings );
512+
engine.addProvider( provider4 );
513+
provider4->prepare( context, attributes );
514+
provider4->registerFeature( f, context );
515+
QCOMPARE( provider4->mLabels.at( 0 )->labelText(), QString( "A TeSt LABEL" ) );
516+
}
517+
457518
bool TestQgsLabelingEngine::imageCheck( const QString& testName, QImage &image, int mismatchCount )
458519
{
459520
//draw background

tests/src/python/test_qgsstringutils.py

+44
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,49 @@ def testSaveRestore(self):
133133
self.assertEqual(c2.replacements(), c.replacements())
134134

135135

136+
class PyQgsStringUtils(unittest.TestCase):
137+
138+
def testMixed(self):
139+
""" test mixed capitalization - ie, no change! """
140+
self.assertFalse(QgsStringUtils.capitalize(None, QgsStringUtils.MixedCase))
141+
self.assertEqual(QgsStringUtils.capitalize('', QgsStringUtils.MixedCase), '')
142+
self.assertEqual(QgsStringUtils.capitalize('testing 123', QgsStringUtils.MixedCase), 'testing 123')
143+
self.assertEqual(QgsStringUtils.capitalize(' tESTinG 123 ', QgsStringUtils.MixedCase), ' tESTinG 123 ')
144+
self.assertEqual(QgsStringUtils.capitalize(' TESTING ABC', QgsStringUtils.MixedCase), ' TESTING ABC')
145+
146+
def testUpperCase(self):
147+
""" test uppercase """
148+
self.assertFalse(QgsStringUtils.capitalize(None, QgsStringUtils.AllUppercase))
149+
self.assertEqual(QgsStringUtils.capitalize('', QgsStringUtils.AllUppercase), '')
150+
self.assertEqual(QgsStringUtils.capitalize('testing 123', QgsStringUtils.AllUppercase), 'TESTING 123')
151+
self.assertEqual(QgsStringUtils.capitalize(' tESTinG abc ', QgsStringUtils.AllUppercase), ' TESTING ABC ')
152+
self.assertEqual(QgsStringUtils.capitalize(' TESTING ABC', QgsStringUtils.AllUppercase), ' TESTING ABC')
153+
154+
def testLowerCase(self):
155+
""" test lowercase """
156+
self.assertFalse(QgsStringUtils.capitalize(None, QgsStringUtils.AllLowercase))
157+
self.assertEqual(QgsStringUtils.capitalize('', QgsStringUtils.AllLowercase), '')
158+
self.assertEqual(QgsStringUtils.capitalize('testing 123', QgsStringUtils.AllLowercase), 'testing 123')
159+
self.assertEqual(QgsStringUtils.capitalize(' tESTinG abc ', QgsStringUtils.AllLowercase),
160+
' testing abc ')
161+
self.assertEqual(QgsStringUtils.capitalize(' TESTING ABC', QgsStringUtils.AllLowercase), ' testing abc')
162+
163+
def testCapitalizeFirst(self):
164+
""" test capitalize first """
165+
self.assertFalse(QgsStringUtils.capitalize(None, QgsStringUtils.ForceFirstLetterToCapital))
166+
self.assertEqual(QgsStringUtils.capitalize('', QgsStringUtils.ForceFirstLetterToCapital), '')
167+
self.assertEqual(QgsStringUtils.capitalize('testing 123', QgsStringUtils.ForceFirstLetterToCapital), 'Testing 123')
168+
self.assertEqual(QgsStringUtils.capitalize('testing', QgsStringUtils.ForceFirstLetterToCapital),
169+
'Testing')
170+
self.assertEqual(QgsStringUtils.capitalize('Testing', QgsStringUtils.ForceFirstLetterToCapital),
171+
'Testing')
172+
self.assertEqual(QgsStringUtils.capitalize('TESTING', QgsStringUtils.ForceFirstLetterToCapital),
173+
'TESTING')
174+
self.assertEqual(QgsStringUtils.capitalize(' tESTinG abc ', QgsStringUtils.ForceFirstLetterToCapital),
175+
' TESTinG Abc ')
176+
self.assertEqual(QgsStringUtils.capitalize(' TESTING ABC', QgsStringUtils.ForceFirstLetterToCapital), ' TESTING ABC')
177+
self.assertEqual(QgsStringUtils.capitalize(' testing abc', QgsStringUtils.ForceFirstLetterToCapital),
178+
' Testing Abc')
179+
136180
if __name__ == '__main__':
137181
unittest.main()

0 commit comments

Comments
 (0)