Skip to content

Commit

Permalink
[feature] Support img tags in HTML label text
Browse files Browse the repository at this point in the history
Allows use of img tags in HTML label content. The following logic
is applied:

- Image path is set via the src="xxx" attribute. Local, HTTP, and
base64 encoded paths are permitted
- Any image format readable by QGIS can be used
- Image sizes can be specified via the width="##" and height="##"
attributes. If width or height is not specified it will automatically
be calculated from the original image size
- If width or height are specified, they are considered to be
in POINTS
- The css width/height settings are NOT respected (this is a Qt
limitation)
- Images are not supported for curved text labels
- Images are placed inline only, floating images are not supported

Sponsored by City of Freiburg im Breisgau
  • Loading branch information
nyalldawson committed Sep 17, 2024
1 parent edcd18f commit 3d66bb8
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Returns the vertical offset from a text block's baseline which should be applied
to the fragment at the specified index within that block.

.. versionadded:: 3.30
%End

double fragmentFixedHeight( int blockIndex, int fragmentIndex, Qgis::TextLayoutMode mode ) const;
%Docstring
Returns the fixed height of the fragment at the specified block and fragment index, or -1 if the fragment does not have a fixed height.

.. versionadded:: 3.40
%End

double verticalOrientationXOffset( int blockIndex ) const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Returns the vertical offset from a text block's baseline which should be applied
to the fragment at the specified index within that block.

.. versionadded:: 3.30
%End

double fragmentFixedHeight( int blockIndex, int fragmentIndex, Qgis::TextLayoutMode mode ) const;
%Docstring
Returns the fixed height of the fragment at the specified block and fragment index, or -1 if the fragment does not have a fixed height.

.. versionadded:: 3.40
%End

double verticalOrientationXOffset( int blockIndex ) const;
Expand Down
117 changes: 94 additions & 23 deletions src/core/textrenderer/qgstextdocumentmetrics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
#include "qgstextdocument.h"
#include "qgsrendercontext.h"
#include "qgstextrenderer.h"
#include "qgsapplication.h"
#include "qgsimagecache.h"

#include <QFontMetricsF>

Expand Down Expand Up @@ -83,19 +85,23 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
const int fragmentSize = block.size();

double maxBlockAscent = 0;
double maxBlockAscentForTextFragments = 0;
double maxBlockDescent = 0;
double maxLineSpacing = 0;
double maxBlockLeading = 0;
double maxBlockMaxWidth = 0;
double maxBlockCapHeight = 0;
double maxBlockFixedItemHeight = 0;

QList< double > fragmentVerticalOffsets;
fragmentVerticalOffsets.reserve( fragmentSize );

QList< QFont > fragmentFonts;
fragmentFonts.reserve( fragmentSize );
QList< double >fragmentHorizontalAdvance;
QList< double > fragmentHorizontalAdvance;
fragmentHorizontalAdvance.reserve( fragmentSize );
QList< double > fragmentFixedHeights;
fragmentFixedHeights.reserve( fragmentSize );

QFont previousNonSuperSubScriptFont;

Expand All @@ -115,6 +121,7 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo

fragmentVerticalOffsets << 0;
fragmentHorizontalAdvance << fragmentWidth;
fragmentFixedHeights << -1;
fragmentFonts << QFont();
}
else
Expand Down Expand Up @@ -190,34 +197,89 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
}
fragmentVerticalOffsets << fragmentVerticalOffset;

const double fragmentWidth = fm.horizontalAdvance( fragment.text() ) / scaleFactor;
// calculate width of fragment
double fragmentWidth = 0;
if ( fragment.isImage() )
{
double imageHeight = 0;
double imageWidth = 0;
if ( ( qgsDoubleNear( fragmentFormat.imageSize().width(), 0 ) || fragmentFormat.imageSize().width() < 0 )
&& ( qgsDoubleNear( fragmentFormat.imageSize().height(), 0 ) || fragmentFormat.imageSize().height() < 0 ) )
{
// use original image size
const QSize imageSize = QgsApplication::imageCache()->originalSize( fragmentFormat.imagePath(), context.flags() & Qgis::RenderContextFlag::RenderBlocking );
// TODO: maybe there's more optimal logic we could use here, but for now we assume 96dpi image resolution...
const QSizeF originalSizeMmAt96Dpi = imageSize / 3.7795275590551185;
const double pixelsPerMm = context.scaleFactor();
imageWidth = originalSizeMmAt96Dpi.width() * pixelsPerMm;
imageHeight = originalSizeMmAt96Dpi.height() * pixelsPerMm;
}
else if ( ( qgsDoubleNear( fragmentFormat.imageSize().width(), 0 ) || fragmentFormat.imageSize().width() < 0 ) )
{
// height specified, calculate width
const QSize originalImageSize = QgsApplication::imageCache()->originalSize( fragmentFormat.imagePath(), context.flags() & Qgis::RenderContextFlag::RenderBlocking );
imageHeight = context.convertToPainterUnits( fragmentFormat.imageSize().height(), Qgis::RenderUnit::Points );
imageWidth = originalImageSize.width() * imageHeight / originalImageSize.height();
}
else if ( ( qgsDoubleNear( fragmentFormat.imageSize().height(), 0 ) || fragmentFormat.imageSize().height() < 0 ) )
{
// width specified, calculate height
const QSize originalImageSize = QgsApplication::imageCache()->originalSize( fragmentFormat.imagePath(), context.flags() & Qgis::RenderContextFlag::RenderBlocking );
imageWidth = context.convertToPainterUnits( fragmentFormat.imageSize().width(), Qgis::RenderUnit::Points );
imageHeight = originalImageSize.height() * imageWidth / originalImageSize.width();
}
else
{
imageWidth = context.convertToPainterUnits( fragmentFormat.imageSize().width(), Qgis::RenderUnit::Points );
imageHeight = context.convertToPainterUnits( fragmentFormat.imageSize().height(), Qgis::RenderUnit::Points );
}

fragmentHorizontalAdvance << fragmentWidth;
fragmentWidth = imageWidth;

const double fragmentHeightUsingAscentDescent = ( fm.ascent() + fm.descent() ) / scaleFactor;
const double fragmentHeightUsingLineSpacing = fm.lineSpacing() / scaleFactor;
// we consider the whole image as ascent, and descent as 0
blockHeightUsingAscentDescent = std::max( blockHeightUsingAscentDescent, imageHeight + fm.descent() / scaleFactor );
blockHeightUsingLineSpacing = std::max( blockHeightUsingLineSpacing, imageHeight + fm.leading() );

blockWidth += fragmentWidth;
blockXMax += fragmentWidth;
blockHeightUsingAscentDescent = std::max( blockHeightUsingAscentDescent, fragmentHeightUsingAscentDescent );
maxBlockAscent = std::max( maxBlockAscent, imageHeight );
maxBlockCapHeight = std::max( maxBlockCapHeight, imageHeight );
maxLineSpacing = std::max( maxLineSpacing, imageHeight + fm.leading() / scaleFactor );
maxBlockLeading = std::max( maxBlockLeading, fm.leading() / scaleFactor );
maxBlockMaxWidth = std::max( maxBlockMaxWidth, imageWidth );
maxBlockFixedItemHeight = std::max( maxBlockFixedItemHeight, imageHeight );
fragmentFixedHeights << imageHeight;
}
else
{
fragmentWidth = fm.horizontalAdvance( fragment.text() ) / scaleFactor;

blockHeightUsingLineSpacing = std::max( blockHeightUsingLineSpacing, fragmentHeightUsingLineSpacing );
maxBlockAscent = std::max( maxBlockAscent, fm.ascent() / scaleFactor );
const double fragmentHeightUsingAscentDescent = ( fm.ascent() + fm.descent() ) / scaleFactor;
const double fragmentHeightUsingLineSpacing = fm.lineSpacing() / scaleFactor;
blockHeightUsingAscentDescent = std::max( blockHeightUsingAscentDescent, fragmentHeightUsingAscentDescent );

maxBlockCapHeight = std::max( maxBlockCapHeight, fm.capHeight() / scaleFactor );
blockHeightUsingLineSpacing = std::max( blockHeightUsingLineSpacing, fragmentHeightUsingLineSpacing );
maxBlockAscent = std::max( maxBlockAscent, fm.ascent() / scaleFactor );
maxBlockAscentForTextFragments = std::max( maxBlockAscentForTextFragments, fm.ascent() / scaleFactor );

blockHeightUsingAscentAccountingForVerticalOffset = std::max( std::max( maxBlockAscent, fragmentHeightForVerticallyOffsetText ), blockHeightUsingAscentAccountingForVerticalOffset );
maxBlockCapHeight = std::max( maxBlockCapHeight, fm.capHeight() / scaleFactor );

maxBlockDescent = std::max( maxBlockDescent, fm.descent() / scaleFactor );
maxBlockMaxWidth = std::max( maxBlockMaxWidth, fm.maxWidth() / scaleFactor );
maxBlockDescent = std::max( maxBlockDescent, fm.descent() / scaleFactor );
maxBlockMaxWidth = std::max( maxBlockMaxWidth, fm.maxWidth() / scaleFactor );

if ( ( fm.lineSpacing() / scaleFactor ) > maxLineSpacing )
{
maxLineSpacing = fm.lineSpacing() / scaleFactor;
maxBlockLeading = fm.leading() / scaleFactor;
}
fragmentFixedHeights << -1;
}

blockYMaxAdjustLabel = std::max( blockYMaxAdjustLabel, fragmentYMaxAdjust );
blockHeightUsingAscentAccountingForVerticalOffset = std::max( std::max( maxBlockAscent, fragmentHeightForVerticallyOffsetText ), blockHeightUsingAscentAccountingForVerticalOffset );

if ( ( fm.lineSpacing() / scaleFactor ) > maxLineSpacing )
{
maxLineSpacing = fm.lineSpacing() / scaleFactor;
maxBlockLeading = fm.leading() / scaleFactor;
}
fragmentHorizontalAdvance << fragmentWidth;

blockWidth += fragmentWidth;
blockXMax += fragmentWidth;

fragmentFonts << updatedFont;

Expand All @@ -233,7 +295,7 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
{
// same logic as used in QgsTextRenderer. (?!!)
// needed to move bottom of text's descender to within bottom edge of label
res.mFirstLineAscentOffset = 0.25 * maxBlockAscent; // descent() is not enough
res.mFirstLineAscentOffset = 0.25 * maxBlockAscentForTextFragments; // descent() is not enough
res.mLastLineAscentOffset = res.mFirstLineAscentOffset;
res.mFirstLineCapHeight = maxBlockCapHeight;
const double lineHeight = ( maxBlockAscent + maxBlockDescent ); // ignore +1 for baseline
Expand Down Expand Up @@ -262,8 +324,11 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
}
else
{
const double thisLineHeightUsingAscentDescent = format.lineHeightUnit() == Qgis::RenderUnit::Percentage ? ( format.lineHeight() * ( maxBlockAscent + maxBlockDescent ) ) : lineHeightPainterUnits;
const double thisLineHeightUsingLineSpacing = format.lineHeightUnit() == Qgis::RenderUnit::Percentage ? ( format.lineHeight() * maxLineSpacing ) : lineHeightPainterUnits;
double thisLineHeightUsingAscentDescent = format.lineHeightUnit() == Qgis::RenderUnit::Percentage ? ( format.lineHeight() * ( maxBlockAscent + maxBlockDescent ) ) : lineHeightPainterUnits;
double thisLineHeightUsingLineSpacing = format.lineHeightUnit() == Qgis::RenderUnit::Percentage ? ( format.lineHeight() * maxLineSpacing ) : lineHeightPainterUnits;

thisLineHeightUsingAscentDescent = std::max( thisLineHeightUsingAscentDescent, maxBlockFixedItemHeight );
thisLineHeightUsingLineSpacing = std::max( thisLineHeightUsingLineSpacing, maxBlockFixedItemHeight );

currentLabelBaseline += thisLineHeightUsingAscentDescent;
currentRectBaseline += thisLineHeightUsingLineSpacing;
Expand All @@ -278,7 +343,7 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
heightCapHeightMode += thisLineHeightUsingLineSpacing;
heightAscentMode += thisLineHeightUsingLineSpacing;
if ( blockIndex == blockSize - 1 )
res.mLastLineAscentOffset = 0.25 * maxBlockAscent;
res.mLastLineAscentOffset = 0.25 * maxBlockAscentForTextFragments;
}

if ( blockIndex == blockSize - 1 )
Expand All @@ -305,6 +370,7 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo
res.mBlockMaxDescent << maxBlockDescent;
res.mBlockMaxCharacterWidth << maxBlockMaxWidth;
res.mFragmentVerticalOffsetsLabelMode << fragmentVerticalOffsets;
res.mFragmentFixedHeights << fragmentFixedHeights;
res.mFragmentVerticalOffsetsRectMode << fragmentVerticalOffsets;
res.mFragmentVerticalOffsetsPointMode << fragmentVerticalOffsets;
res.mFragmentHorizontalAdvance << fragmentHorizontalAdvance;
Expand Down Expand Up @@ -477,6 +543,11 @@ double QgsTextDocumentMetrics::fragmentVerticalOffset( int blockIndex, int fragm
BUILTIN_UNREACHABLE
}

double QgsTextDocumentMetrics::fragmentFixedHeight( int blockIndex, int fragmentIndex, Qgis::TextLayoutMode ) const
{
return mFragmentFixedHeights.value( blockIndex ).value( fragmentIndex );
}

double QgsTextDocumentMetrics::verticalOrientationXOffset( int blockIndex ) const
{
return mVerticalOrientationXOffsets.value( blockIndex );
Expand Down
8 changes: 8 additions & 0 deletions src/core/textrenderer/qgstextdocumentmetrics.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ class CORE_EXPORT QgsTextDocumentMetrics
*/
double fragmentVerticalOffset( int blockIndex, int fragmentIndex, Qgis::TextLayoutMode mode ) const;

/**
* Returns the fixed height of the fragment at the specified block and fragment index, or -1 if the fragment does not have a fixed height.
*
* \since QGIS 3.40
*/
double fragmentFixedHeight( int blockIndex, int fragmentIndex, Qgis::TextLayoutMode mode ) const;

/**
* Returns the vertical orientation x offset for the specified block.
*/
Expand Down Expand Up @@ -160,6 +167,7 @@ class CORE_EXPORT QgsTextDocumentMetrics
QList< double > mBaselineOffsetsAscentBased;

QList< QList< double > > mFragmentHorizontalAdvance;
QList< QList< double > > mFragmentFixedHeights;

QList< QList< double > > mFragmentVerticalOffsetsLabelMode;
QList< QList< double > > mFragmentVerticalOffsetsPointMode;
Expand Down
26 changes: 22 additions & 4 deletions src/core/textrenderer/qgstextrenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
#include "qgstextrendererutils.h"
#include "qgsgeos.h"
#include "qgspainting.h"
#include "qgsapplication.h"
#include "qgsimagecache.h"
#include <optional>

#include <QTextBoundaryFinder>
Expand Down Expand Up @@ -603,7 +605,7 @@ double QgsTextRenderer::drawBuffer( QgsRenderContext &context, const QgsTextRend
{
QFont fragmentFont = metrics.fragmentFont( component.blockIndex, fragmentIndex );

if ( !fragment.isWhitespace() )
if ( !fragment.isWhitespace() && !fragment.isImage() )
{
if ( component.extraWordSpacing || component.extraLetterSpacing )
applyExtraSpacingForLineJustification( fragmentFont, component.extraWordSpacing, component.extraLetterSpacing );
Expand Down Expand Up @@ -771,7 +773,7 @@ void QgsTextRenderer::drawMask( QgsRenderContext &context, const QgsTextRenderer
int fragmentIndex = 0;
for ( const QgsTextFragment &fragment : component.block )
{
if ( !fragment.isWhitespace() )
if ( !fragment.isWhitespace() && !fragment.isImage() )
{
const QFont fragmentFont = metrics.fragmentFont( component.blockIndex, fragmentIndex );

Expand Down Expand Up @@ -1880,7 +1882,7 @@ void QgsTextRenderer::drawTextInternalHorizontal( QgsRenderContext &context, con
for ( const QgsTextFragment &fragment : block )
{
// draw text, QPainterPath method
if ( !fragment.isWhitespace() )
if ( !fragment.isWhitespace() && !fragment.isImage() )
{
QPainterPath path;
path.setFillRule( Qt::WindingFill );
Expand All @@ -1899,6 +1901,22 @@ void QgsTextRenderer::drawTextInternalHorizontal( QgsRenderContext &context, con
textp.setBrush( textColor );
textp.drawPath( path );
}
else if ( fragment.isImage() )
{
bool fitsInCache = false;
const double imageWidth = metrics.fragmentHorizontalAdvance( blockIndex, fragmentIndex, mode ) * fontScale;
const double imageHeight = metrics.fragmentFixedHeight( blockIndex, fragmentIndex, mode ) * fontScale;

const QImage image = QgsApplication::imageCache()->pathAsImage( fragment.characterFormat().imagePath(),
QSize( static_cast< int >( std::round( imageWidth ) ),
static_cast< int >( std::round( imageHeight ) ) ),
false,
1, fitsInCache, context.flags() & Qgis::RenderContextFlag::RenderBlocking );
const double imageBaseline = metrics.fragmentVerticalOffset( blockIndex, fragmentIndex, mode );
const double yOffset = imageBaseline - image.height();
if ( !image.isNull() )
textp.drawImage( QPointF( xOffset, yOffset ), image );
}

xOffset += metrics.fragmentHorizontalAdvance( blockIndex, fragmentIndex, mode ) * fontScale;
fragmentIndex ++;
Expand Down Expand Up @@ -1940,7 +1958,7 @@ void QgsTextRenderer::drawTextInternalHorizontal( QgsRenderContext &context, con
int fragmentIndex = 0;
for ( const QgsTextFragment &fragment : block )
{
if ( !fragment.isWhitespace() )
if ( !fragment.isWhitespace() && !fragment.isImage() )
{
QFont fragmentFont = metrics.fragmentFont( blockIndex, fragmentIndex );

Expand Down
54 changes: 54 additions & 0 deletions tests/src/core/testqgslabelingengine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class TestQgsLabelingEngine : public QgsTest
void testPointLabelTabsHtml();
void testPointLabelHtmlFormatting();
void testPointLabelHtmlFormattingDataDefinedSize();
void testPointLabelHtmlImages();
void testCurvedLabelsWithTinySegments();
void testCurvedLabelCorrectLinePlacement();
void testCurvedLabelNegativeDistance();
Expand Down Expand Up @@ -1900,6 +1901,59 @@ void TestQgsLabelingEngine::testPointLabelHtmlFormattingDataDefinedSize()
QVERIFY( imageCheck( QStringLiteral( "label_point_html_rendering" ), img, 20 ) );
}

void TestQgsLabelingEngine::testPointLabelHtmlImages()
{
// test point label rendering with HTML images
QgsPalLayerSettings settings;
setDefaultLabelParams( settings );

QgsTextFormat format = settings.format();
format.setSize( 20 );
format.setColor( QColor( 0, 0, 0 ) );
format.setAllowHtmlFormatting( true );
settings.setFormat( format );

settings.fieldName = QStringLiteral( "'<i>test</i> <img src=\"%1/small_sample_image.png\" width=\"80\" height=\"200\"> <b style=\"font-size: 30pt\">HTML</b>'" ).arg( TEST_DATA_DIR );
settings.isExpression = true;
settings.placement = Qgis::LabelPlacement::OverPoint;
settings.labelPerPart = false;

std::unique_ptr< QgsVectorLayer> vl2( new QgsVectorLayer( QStringLiteral( "Point?crs=epsg:3946&field=id:integer" ), QStringLiteral( "vl" ), QStringLiteral( "memory" ) ) );
vl2->setRenderer( new QgsNullSymbolRenderer() );

QgsFeature f;
f.setAttributes( QgsAttributes() << 1 );
const QgsGeometry refGeom = QgsGeometry::fromWkt( QStringLiteral( "LineString (190000 5000010, 190100 5000000, 190200 5000000)" ) );
f.setGeometry( refGeom.centroid() );
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( refGeom.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( "html_images" ), img, 20 ) );
}

void TestQgsLabelingEngine::testCurvedLabelsHtmlSuperSubscript()
{
// test line label rendering with HTML formatting
Expand Down
Loading

0 comments on commit 3d66bb8

Please sign in to comment.