Skip to content

Commit

Permalink
[feature] Add support for word-spacing CSS in html labels
Browse files Browse the repository at this point in the history
...and other places HTML text formatting is accepted.

This allows use of CSS "word-spacing: 12" to increase the word
spacing in a section of HTML text. The word spacing is always
treated as being in point units.
  • Loading branch information
nyalldawson committed Sep 10, 2024
1 parent bda5dfd commit 9052463
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/core/textrenderer/qgstextcharacterformat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
Expand All @@ -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 )
Expand Down Expand Up @@ -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
Expand All @@ -222,3 +230,13 @@ void QgsTextCharacterFormat::setFontWeight( int fontWeight )
{
mFontWeight = fontWeight;
}

double QgsTextCharacterFormat::wordSpacing() const
{
return mWordSpacing;
}

void QgsTextCharacterFormat::setWordSpacing( double spacing )
{
mWordSpacing = spacing;
}
17 changes: 17 additions & 0 deletions src/core/textrenderer/qgstextcharacterformat.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/core/textrenderer/qgstextdocument.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
57 changes: 57 additions & 0 deletions tests/src/core/testqgslabelingengine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class TestQgsLabelingEngine : public QgsTest
void testCurvedLabelsHtmlFormatting();
void testCurvedPerimeterLabelsHtmlFormatting();
void testCurvedLabelsHtmlSuperSubscript();
void testCurvedLabelsHtmlWordSpacing();
void testPointLabelTabs();
void testPointLabelTabsHtml();
void testPointLabelHtmlFormatting();
Expand Down Expand Up @@ -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 <span style=\"word-spacing: 20.5\">wo space</span>'" );
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<QgsMapLayer *>() << 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
Expand Down
35 changes: 35 additions & 0 deletions tests/src/python/test_qgstextrenderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4015,6 +4015,41 @@ def testHtmlSuperSubscriptBufferShadow(self):
'<sub>sub</sub>N<sup>sup</sup>'],
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 <span style="word-spacing: 20.5">wo space</span>'],
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 <span style="word-spacing: 20.5px">wo space</span>'],
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 <span style="word-spacing: -20.5">wo space</span>'],
point=QPointF(10, 200))

def testTextRenderFormat(self):
format = QgsTextFormat()
format.setFont(getTestFont('bold'))
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 9052463

Please sign in to comment.