diff --git a/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in b/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
index 6f2fc88ef776..0f70b3e1c3f4 100644
--- a/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
+++ b/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
@@ -140,6 +140,24 @@ and should be inherited.
.. seealso:: :py:func:`fontWeight`
.. versionadded:: 3.28
+%End
+
+ double wordSpacing() const;
+%Docstring
+Returns the font word spacing, in points, or NaN if word spacing is not set and should be inherited.
+
+.. seealso:: :py:func:`setWordSpacing`
+
+.. versionadded:: 3.40
+%End
+
+ void setWordSpacing( double spacing );
+%Docstring
+Sets the font word ``spacing``, in points, or NaN if word spacing is not set and should be inherited.
+
+.. seealso:: :py:func:`wordSpacing`
+
+.. versionadded:: 3.40
%End
BooleanValue italic() const;
diff --git a/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in b/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
index 6f2fc88ef776..0f70b3e1c3f4 100644
--- a/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
+++ b/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in
@@ -140,6 +140,24 @@ and should be inherited.
.. seealso:: :py:func:`fontWeight`
.. versionadded:: 3.28
+%End
+
+ double wordSpacing() const;
+%Docstring
+Returns the font word spacing, in points, or NaN if word spacing is not set and should be inherited.
+
+.. seealso:: :py:func:`setWordSpacing`
+
+.. versionadded:: 3.40
+%End
+
+ void setWordSpacing( double spacing );
+%Docstring
+Sets the font word ``spacing``, in points, or NaN if word spacing is not set and should be inherited.
+
+.. seealso:: :py:func:`wordSpacing`
+
+.. versionadded:: 3.40
%End
BooleanValue italic() const;
diff --git a/src/core/textrenderer/qgstextcharacterformat.cpp b/src/core/textrenderer/qgstextcharacterformat.cpp
index 58e24bc664ea..08ef0944ffa4 100644
--- a/src/core/textrenderer/qgstextcharacterformat.cpp
+++ b/src/core/textrenderer/qgstextcharacterformat.cpp
@@ -48,6 +48,7 @@ QgsTextCharacterFormat::QgsTextCharacterFormat( const QTextCharFormat &format )
, mStyleName( format.font().styleName() )
, mItalic( format.hasProperty( QTextFormat::FontItalic ) ? ( format.fontItalic() ? BooleanValue::SetTrue : BooleanValue::SetFalse ) : BooleanValue::NotSet )
, mFontPointSize( format.hasProperty( QTextFormat::FontPointSize ) ? format.fontPointSize() : - 1 )
+ , mWordSpacing( format.hasProperty( QTextFormat::FontWordSpacing ) ? format.fontWordSpacing() : std::numeric_limits< double >::quiet_NaN() )
, mStrikethrough( format.hasProperty( QTextFormat::FontStrikeOut ) ? ( format.fontStrikeOut() ? BooleanValue::SetTrue : BooleanValue::SetFalse ) : BooleanValue::NotSet )
, mUnderline( format.hasProperty( QTextFormat::FontUnderline ) ? ( format.fontUnderline() ? BooleanValue::SetTrue : BooleanValue::SetFalse ) : BooleanValue::NotSet )
, mOverline( format.hasProperty( QTextFormat::FontOverline ) ? ( format.fontOverline() ? BooleanValue::SetTrue : BooleanValue::SetFalse ) : BooleanValue::NotSet )
@@ -72,6 +73,8 @@ void QgsTextCharacterFormat::overrideWith( const QgsTextCharacterFormat &other )
mTextColor = other.mTextColor;
if ( mFontPointSize == -1 && other.mFontPointSize != -1 )
mFontPointSize = other.mFontPointSize;
+ if ( std::isnan( mWordSpacing ) )
+ mWordSpacing = other.mWordSpacing;
if ( mFontFamily.isEmpty() && !other.mFontFamily.isEmpty() )
mFontFamily = other.mFontFamily;
if ( mStrikethrough == BooleanValue::NotSet && other.mStrikethrough != BooleanValue::NotSet )
@@ -201,6 +204,11 @@ void QgsTextCharacterFormat::updateFontForFormat( QFont &font, const QgsRenderCo
font.setOverline( mOverline == QgsTextCharacterFormat::BooleanValue::SetTrue );
if ( mStrikethrough != QgsTextCharacterFormat::BooleanValue::NotSet )
font.setStrikeOut( mStrikethrough == QgsTextCharacterFormat::BooleanValue::SetTrue );
+
+ if ( !std::isnan( mWordSpacing ) )
+ {
+ font.setWordSpacing( scaleFactor * context.convertToPainterUnits( mWordSpacing, Qgis::RenderUnit::Points ) );
+ }
}
QgsTextCharacterFormat::BooleanValue QgsTextCharacterFormat::italic() const
@@ -222,3 +230,13 @@ void QgsTextCharacterFormat::setFontWeight( int fontWeight )
{
mFontWeight = fontWeight;
}
+
+double QgsTextCharacterFormat::wordSpacing() const
+{
+ return mWordSpacing;
+}
+
+void QgsTextCharacterFormat::setWordSpacing( double spacing )
+{
+ mWordSpacing = spacing;
+}
diff --git a/src/core/textrenderer/qgstextcharacterformat.h b/src/core/textrenderer/qgstextcharacterformat.h
index 139201ff21b6..60addfabe2b4 100644
--- a/src/core/textrenderer/qgstextcharacterformat.h
+++ b/src/core/textrenderer/qgstextcharacterformat.h
@@ -149,6 +149,22 @@ class CORE_EXPORT QgsTextCharacterFormat
*/
void setFontWeight( int fontWeight );
+ /**
+ * Returns the font word spacing, in points, or NaN if word spacing is not set and should be inherited.
+ *
+ * \see setWordSpacing()
+ * \since QGIS 3.40
+ */
+ double wordSpacing() const;
+
+ /**
+ * Sets the font word \a spacing, in points, or NaN if word spacing is not set and should be inherited.
+ *
+ * \see wordSpacing()
+ * \since QGIS 3.40
+ */
+ void setWordSpacing( double spacing );
+
/**
* Returns whether the format has italic enabled.
*
@@ -274,6 +290,7 @@ class CORE_EXPORT QgsTextCharacterFormat
BooleanValue mItalic = BooleanValue::NotSet;
double mFontPointSize = -1;
QString mFontFamily;
+ double mWordSpacing = std::numeric_limits< double >::quiet_NaN();
bool mHasVerticalAlignSet = false;
Qgis::TextCharacterVerticalAlignment mVerticalAlign = Qgis::TextCharacterVerticalAlignment::Normal;
diff --git a/src/core/textrenderer/qgstextdocument.cpp b/src/core/textrenderer/qgstextdocument.cpp
index efd4c8b5cd94..6517442db8d6 100644
--- a/src/core/textrenderer/qgstextdocument.cpp
+++ b/src/core/textrenderer/qgstextdocument.cpp
@@ -73,6 +73,13 @@ QgsTextDocument QgsTextDocument::fromHtml( const QStringList &lines )
// handle these markers as tab characters in the parsed HTML document.
line.replace( QString( '\t' ), QStringLiteral( TAB_REPLACEMENT_MARKER ) );
+ // cheat a little. Qt css requires word-spacing to have the "px" suffix. But we don't treat word spacing
+ // as pixels, because that doesn't scale well with different dpi render targets! So let's instead use just instead treat the suffix as
+ // optional, and ignore ANY unit suffix the user has put, and then replace it with "px" so that Qt's css parsing engine can process it
+ // correctly...
+ const thread_local QRegularExpression sRxWordSpacingFix( QStringLiteral( "word-spacing:\\s*(-?\\d+(?:\\.\\d+)?)([a-zA-Z]*)" ) );
+ line.replace( sRxWordSpacingFix, QStringLiteral( "word-spacing: \\1px" ) );
+
sourceDoc.setHtml( line );
QTextBlock sourceBlock = sourceDoc.firstBlock();
diff --git a/tests/src/core/testqgslabelingengine.cpp b/tests/src/core/testqgslabelingengine.cpp
index 35030030b7a8..c179e8899e21 100644
--- a/tests/src/core/testqgslabelingengine.cpp
+++ b/tests/src/core/testqgslabelingengine.cpp
@@ -74,6 +74,7 @@ class TestQgsLabelingEngine : public QgsTest
void testCurvedLabelsHtmlFormatting();
void testCurvedPerimeterLabelsHtmlFormatting();
void testCurvedLabelsHtmlSuperSubscript();
+ void testCurvedLabelsHtmlWordSpacing();
void testPointLabelTabs();
void testPointLabelTabsHtml();
void testPointLabelHtmlFormatting();
@@ -1955,6 +1956,62 @@ void TestQgsLabelingEngine::testCurvedLabelsHtmlSuperSubscript()
QVERIFY( imageCheck( QStringLiteral( "label_curved_html_supersubscript" ), img, 20 ) );
}
+void TestQgsLabelingEngine::testCurvedLabelsHtmlWordSpacing()
+{
+ // test line label rendering with HTML formatting
+ QgsPalLayerSettings settings;
+ setDefaultLabelParams( settings );
+
+ QgsTextFormat format = settings.format();
+ format.setSize( 30 );
+ format.setColor( QColor( 0, 0, 0 ) );
+ format.setAllowHtmlFormatting( true );
+ settings.setFormat( format );
+
+ settings.fieldName = QStringLiteral( "'test of wo space'" );
+ settings.isExpression = true;
+ settings.placement = Qgis::LabelPlacement::Curved;
+ settings.labelPerPart = false;
+ settings.lineSettings().setLineAnchorPercent( 0.5 );
+ settings.lineSettings().setAnchorType( QgsLabelLineSettings::AnchorType::Strict );
+ settings.lineSettings().setPlacementFlags( Qgis::LabelLinePlacementFlag::AboveLine | Qgis::LabelLinePlacementFlag::MapOrientation );
+ settings.lineSettings().setAnchorTextPoint( QgsLabelLineSettings::AnchorTextPoint::CenterOfText );
+
+ std::unique_ptr< QgsVectorLayer> vl2( new QgsVectorLayer( QStringLiteral( "LineString?crs=epsg:3946&field=id:integer" ), QStringLiteral( "vl" ), QStringLiteral( "memory" ) ) );
+ vl2->setRenderer( new QgsNullSymbolRenderer() );
+
+ QgsFeature f;
+ f.setAttributes( QgsAttributes() << 1 );
+ f.setGeometry( QgsGeometry::fromWkt( QStringLiteral( "LineString (190000 5000010, 190100 5000000, 190200 5000000)" ) ) );
+ QVERIFY( vl2->dataProvider()->addFeature( f ) );
+
+ vl2->setLabeling( new QgsVectorLayerSimpleLabeling( settings ) ); // TODO: this should not be necessary!
+ vl2->setLabelsEnabled( true );
+
+ // make a fake render context
+ const QSize size( 640, 480 );
+ QgsMapSettings mapSettings;
+ mapSettings.setLabelingEngineSettings( createLabelEngineSettings() );
+ mapSettings.setDestinationCrs( vl2->crs() );
+
+ mapSettings.setOutputSize( size );
+ mapSettings.setExtent( f.geometry().boundingBox() );
+ mapSettings.setLayers( QList() << vl2.get() );
+ mapSettings.setOutputDpi( 96 );
+
+ QgsLabelingEngineSettings engineSettings = mapSettings.labelingEngineSettings();
+ engineSettings.setFlag( Qgis::LabelingFlag::UsePartialCandidates, false );
+ engineSettings.setFlag( Qgis::LabelingFlag::DrawCandidates, true );
+ mapSettings.setLabelingEngineSettings( engineSettings );
+
+ QgsMapRendererSequentialJob job( mapSettings );
+ job.start();
+ job.waitForFinished();
+
+ QImage img = job.renderedImage();
+ QVERIFY( imageCheck( QStringLiteral( "curved_html_wordspacing" ), img, 20 ) );
+}
+
void TestQgsLabelingEngine::testCurvedLabelsHtmlFormatting()
{
// test line label rendering with HTML formatting
diff --git a/tests/src/python/test_qgstextrenderer.py b/tests/src/python/test_qgstextrenderer.py
index 87be77cf4bbc..55579b7ffbc2 100644
--- a/tests/src/python/test_qgstextrenderer.py
+++ b/tests/src/python/test_qgstextrenderer.py
@@ -4015,6 +4015,41 @@ def testHtmlSuperSubscriptBufferShadow(self):
'subNsup'],
point=QPointF(50, 200))
+ def testHtmlWordSpacing(self):
+ format = QgsTextFormat()
+ format.setFont(getTestFont('bold'))
+ format.setSize(30)
+ format.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
+ format.setColor(QColor(255, 0, 0))
+ format.setAllowHtmlFormatting(True)
+ assert self.checkRenderPoint(format, 'html_word_spacing', None, text=[
+ 'test of wo space'],
+ point=QPointF(10, 200))
+
+ def testHtmlWordSpacingPx(self):
+ format = QgsTextFormat()
+ format.setFont(getTestFont('bold'))
+ format.setSize(30)
+ format.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
+ format.setColor(QColor(255, 0, 0))
+ format.setAllowHtmlFormatting(True)
+ # unit should be ignored, we always treat it as pt as pixels don't
+ # scale
+ assert self.checkRenderPoint(format, 'html_word_spacing', None, text=[
+ 'test of wo space'],
+ point=QPointF(10, 200))
+
+ def testHtmlWordSpacingNegative(self):
+ format = QgsTextFormat()
+ format.setFont(getTestFont('bold'))
+ format.setSize(30)
+ format.setSizeUnit(QgsUnitTypes.RenderUnit.RenderPoints)
+ format.setColor(QColor(255, 0, 0))
+ format.setAllowHtmlFormatting(True)
+ assert self.checkRenderPoint(format, 'html_word_spacing_negative', None, text=[
+ 'test of wo space'],
+ point=QPointF(10, 200))
+
def testTextRenderFormat(self):
format = QgsTextFormat()
format.setFont(getTestFont('bold'))
diff --git a/tests/testdata/control_images/labelingengine/expected_curved_html_wordspacing/expected_curved_html_wordspacing.png b/tests/testdata/control_images/labelingengine/expected_curved_html_wordspacing/expected_curved_html_wordspacing.png
new file mode 100644
index 000000000000..98022f18add0
Binary files /dev/null and b/tests/testdata/control_images/labelingengine/expected_curved_html_wordspacing/expected_curved_html_wordspacing.png differ
diff --git a/tests/testdata/control_images/text_renderer/html_word_spacing/html_word_spacing.png b/tests/testdata/control_images/text_renderer/html_word_spacing/html_word_spacing.png
new file mode 100644
index 000000000000..5967d43b558c
Binary files /dev/null and b/tests/testdata/control_images/text_renderer/html_word_spacing/html_word_spacing.png differ
diff --git a/tests/testdata/control_images/text_renderer/html_word_spacing_negative/html_word_spacing_negative.png b/tests/testdata/control_images/text_renderer/html_word_spacing_negative/html_word_spacing_negative.png
new file mode 100644
index 000000000000..baf5abe35e90
Binary files /dev/null and b/tests/testdata/control_images/text_renderer/html_word_spacing_negative/html_word_spacing_negative.png differ