From bae0d40368f714031c62e7102a3f7b6ec3e991ad Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 13 Sep 2024 11:28:57 +1000 Subject: [PATCH] [feature] Support img tags in HTML label text 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 --- .../qgstextcharacterformat.sip.in | 56 +++++++++ .../qgstextdocumentmetrics.sip.in | 7 ++ .../textrenderer/qgstextfragment.sip.in | 15 ++- .../qgstextcharacterformat.sip.in | 56 +++++++++ .../qgstextdocumentmetrics.sip.in | 7 ++ .../textrenderer/qgstextfragment.sip.in | 15 ++- src/core/qgsabstractcontentcache_p.h | 11 ++ src/core/qgsimagecache.cpp | 22 +++- .../textrenderer/qgstextcharacterformat.cpp | 26 ++++ .../textrenderer/qgstextcharacterformat.h | 52 ++++++++ src/core/textrenderer/qgstextdocument.cpp | 3 +- .../textrenderer/qgstextdocumentmetrics.cpp | 111 ++++++++++++++---- .../textrenderer/qgstextdocumentmetrics.h | 8 ++ src/core/textrenderer/qgstextfragment.cpp | 14 ++- src/core/textrenderer/qgstextfragment.h | 16 ++- src/core/textrenderer/qgstextrenderer.cpp | 26 +++- .../src/python/test_qgstextcharacterformat.py | 8 ++ tests/src/python/test_qgstextdocument.py | 18 ++- 18 files changed, 434 insertions(+), 37 deletions(-) diff --git a/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in b/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in index 09c8b16b3d21..824e9dd09fdd 100644 --- a/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in +++ b/python/PyQt6/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in @@ -262,6 +262,62 @@ Returns whether the format has overline enabled. Sets whether the format has overline ``enabled``. .. seealso:: :py:func:`overline` +%End + + QString imagePath() const; +%Docstring +Returns the path to the image to render, if the format applies to a document image fragment. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`imageSize` + +.. seealso:: :py:func:`setImagePath` + +.. versionadded:: 3.40 +%End + + void setImagePath( const QString &path ); +%Docstring +Sets the ``path`` to the image to render, if the format applies to a document image fragment. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`setImageSize` + +.. seealso:: :py:func:`imagePath` + +.. versionadded:: 3.40 +%End + + QSizeF imageSize() const; +%Docstring +Returns the image size, if the format applies to a document image fragment. + +The image size is always considered to be in :py:class:`Qgis`.RenderUnit.Points. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`imagePath` + +.. seealso:: :py:func:`setImageSize` + +.. versionadded:: 3.40 +%End + + void setImageSize( const QSizeF &size ); +%Docstring +Sets the image ``size``, if the format applies to a document image fragment. + +The image size is always considered to be in :py:class:`Qgis`.RenderUnit.Points. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`setImagePath` + +.. seealso:: :py:func:`imageSize` + +.. versionadded:: 3.40 %End bool hasVerticalAlignmentSet() const; diff --git a/python/PyQt6/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in b/python/PyQt6/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in index d7eea5efd7f6..17d851755b35 100644 --- a/python/PyQt6/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in +++ b/python/PyQt6/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in @@ -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; diff --git a/python/PyQt6/core/auto_generated/textrenderer/qgstextfragment.sip.in b/python/PyQt6/core/auto_generated/textrenderer/qgstextfragment.sip.in index 680257c1732e..4ba19e9f6db3 100644 --- a/python/PyQt6/core/auto_generated/textrenderer/qgstextfragment.sip.in +++ b/python/PyQt6/core/auto_generated/textrenderer/qgstextfragment.sip.in @@ -12,7 +12,13 @@ class QgsTextFragment { %Docstring(signature="appended") -Stores a fragment of text along with formatting overrides to be used when rendering the fragment. +Stores a fragment of document along with formatting overrides to be used when rendering the fragment. + +Text fragments consist of either a block of text or another atomic component of a document (such as an image). + +Each fragment has an associated :py:func:`~characterFormat`, which specifies the text formatting overrides +to use when rendering the fragment. Additionally, the :py:func:`~characterFormat` may contain properties +for other fragment types, such as image paths and sizes for image fragments. .. warning:: @@ -77,6 +83,13 @@ Returns the character formatting for the fragment. Sets the character ``format`` for the fragment. .. seealso:: :py:func:`characterFormat` +%End + + bool isImage() const; +%Docstring +Returns ``True`` if the fragment represents an image. + +.. versionadded:: 3.40 %End double horizontalAdvance( const QFont &font, const QgsRenderContext &context, bool fontHasBeenUpdatedForFragment = false, double scaleFactor = 1.0 ) const; diff --git a/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in b/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in index 09c8b16b3d21..824e9dd09fdd 100644 --- a/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in +++ b/python/core/auto_generated/textrenderer/qgstextcharacterformat.sip.in @@ -262,6 +262,62 @@ Returns whether the format has overline enabled. Sets whether the format has overline ``enabled``. .. seealso:: :py:func:`overline` +%End + + QString imagePath() const; +%Docstring +Returns the path to the image to render, if the format applies to a document image fragment. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`imageSize` + +.. seealso:: :py:func:`setImagePath` + +.. versionadded:: 3.40 +%End + + void setImagePath( const QString &path ); +%Docstring +Sets the ``path`` to the image to render, if the format applies to a document image fragment. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`setImageSize` + +.. seealso:: :py:func:`imagePath` + +.. versionadded:: 3.40 +%End + + QSizeF imageSize() const; +%Docstring +Returns the image size, if the format applies to a document image fragment. + +The image size is always considered to be in :py:class:`Qgis`.RenderUnit.Points. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`imagePath` + +.. seealso:: :py:func:`setImageSize` + +.. versionadded:: 3.40 +%End + + void setImageSize( const QSizeF &size ); +%Docstring +Sets the image ``size``, if the format applies to a document image fragment. + +The image size is always considered to be in :py:class:`Qgis`.RenderUnit.Points. + +.. seealso:: :py:func:`QgsTextFragment.isImage` + +.. seealso:: :py:func:`setImagePath` + +.. seealso:: :py:func:`imageSize` + +.. versionadded:: 3.40 %End bool hasVerticalAlignmentSet() const; diff --git a/python/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in b/python/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in index d7eea5efd7f6..17d851755b35 100644 --- a/python/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in +++ b/python/core/auto_generated/textrenderer/qgstextdocumentmetrics.sip.in @@ -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; diff --git a/python/core/auto_generated/textrenderer/qgstextfragment.sip.in b/python/core/auto_generated/textrenderer/qgstextfragment.sip.in index 680257c1732e..4ba19e9f6db3 100644 --- a/python/core/auto_generated/textrenderer/qgstextfragment.sip.in +++ b/python/core/auto_generated/textrenderer/qgstextfragment.sip.in @@ -12,7 +12,13 @@ class QgsTextFragment { %Docstring(signature="appended") -Stores a fragment of text along with formatting overrides to be used when rendering the fragment. +Stores a fragment of document along with formatting overrides to be used when rendering the fragment. + +Text fragments consist of either a block of text or another atomic component of a document (such as an image). + +Each fragment has an associated :py:func:`~characterFormat`, which specifies the text formatting overrides +to use when rendering the fragment. Additionally, the :py:func:`~characterFormat` may contain properties +for other fragment types, such as image paths and sizes for image fragments. .. warning:: @@ -77,6 +83,13 @@ Returns the character formatting for the fragment. Sets the character ``format`` for the fragment. .. seealso:: :py:func:`characterFormat` +%End + + bool isImage() const; +%Docstring +Returns ``True`` if the fragment represents an image. + +.. versionadded:: 3.40 %End double horizontalAdvance( const QFont &font, const QgsRenderContext &context, bool fontHasBeenUpdatedForFragment = false, double scaleFactor = 1.0 ) const; diff --git a/src/core/qgsabstractcontentcache_p.h b/src/core/qgsabstractcontentcache_p.h index e6c424591b84..d74cbfc43c49 100644 --- a/src/core/qgsabstractcontentcache_p.h +++ b/src/core/qgsabstractcontentcache_p.h @@ -20,6 +20,7 @@ #include "qgsabstractcontentcache.h" #include "qgssetrequestinitiator_p.h" +#include template QByteArray QgsAbstractContentCache::getContent( const QString &path, const QByteArray &missingContent, const QByteArray &fetchingContent, bool blocking ) const @@ -44,6 +45,16 @@ QByteArray QgsAbstractContentCache::getContent( const QString &path, const QB const QByteArray base64 = path.mid( 7 ).toLocal8Bit(); // strip 'base64:' prefix return QByteArray::fromBase64( base64, QByteArray::OmitTrailingEquals ); } + else + { + const thread_local QRegularExpression rx( QStringLiteral( "^data:image/.*;base64,(.*)$" ) ); + const QRegularExpressionMatch base64Match = rx.match( path ); + if ( base64Match.hasMatch() ) + { + const QByteArray base64 = base64Match.captured( 1 ).toLocal8Bit(); // strip prefix + return QByteArray::fromBase64( base64, QByteArray::OmitTrailingEquals ); + } + } // maybe it's a url... if ( !path.contains( QLatin1String( "://" ) ) ) // otherwise short, relative SVG paths might be considered URLs diff --git a/src/core/qgsimagecache.cpp b/src/core/qgsimagecache.cpp index 1477d2d081a8..6dfe55bd8c2b 100644 --- a/src/core/qgsimagecache.cpp +++ b/src/core/qgsimagecache.cpp @@ -162,6 +162,13 @@ QImage QgsImageCache::pathAsImagePrivate( const QString &f, const QSize size, co fitsInCache = true; + const thread_local QRegularExpression rx( QStringLiteral( "^data:image/.*;base64,(.*)$" ), QRegularExpression::DotMatchesEverythingOption ); + const QRegularExpressionMatch base64Match = rx.match( file ); + if ( base64Match.hasMatch() ) + { + file = QStringLiteral( "base64:%1" ).arg( base64Match.captured( 1 ) ); + } + QgsImageCacheEntry *currentEntry = findExistingEntry( new QgsImageCacheEntry( file, size, keepAspectRatio, opacity, targetDpi, frameNumber ) ); QImage result; @@ -206,13 +213,22 @@ QImage QgsImageCache::pathAsImagePrivate( const QString &f, const QSize size, co return result; } +bool isBase64Path( const QString &path ) +{ + if ( path.startsWith( QLatin1String( "base64:" ) ) ) + return true; + + const thread_local QRegularExpression rx( QStringLiteral( "^data:image/.*;base64," ), QRegularExpression::DotMatchesEverythingOption ); + return rx.match( path ).hasMatch(); +} + QSize QgsImageCache::originalSize( const QString &path, bool blocking ) const { if ( path.isEmpty() ) return QSize(); // direct read if path is a file -- maybe more efficient than going the bytearray route? (untested!) - if ( !path.startsWith( QLatin1String( "base64:" ) ) && QFile::exists( path ) ) + if ( !isBase64Path( path ) && QFile::exists( path ) ) { const QImageReader reader( path ); if ( reader.size().isValid() ) @@ -298,7 +314,7 @@ void QgsImageCache::prepareAnimation( const QString &path ) std::unique_ptr< QImageReader > reader; std::unique_ptr< QBuffer > buffer; - if ( !path.startsWith( QLatin1String( "base64:" ) ) && QFile::exists( path ) ) + if ( !isBase64Path( path ) && QFile::exists( path ) ) { const QString basePart = QFileInfo( path ).baseName(); int id = 1; @@ -355,7 +371,7 @@ QImage QgsImageCache::renderImage( const QString &path, QSize size, const bool k isBroken = false; // direct read if path is a file -- maybe more efficient than going the bytearray route? (untested!) - if ( !path.startsWith( QLatin1String( "base64:" ) ) && QFile::exists( path ) ) + if ( !isBase64Path( path ) && QFile::exists( path ) ) { QImageReader reader( path ); reader.setAutoTransform( true ); diff --git a/src/core/textrenderer/qgstextcharacterformat.cpp b/src/core/textrenderer/qgstextcharacterformat.cpp index 21cf800f39cf..1fe0ce1a3002 100644 --- a/src/core/textrenderer/qgstextcharacterformat.cpp +++ b/src/core/textrenderer/qgstextcharacterformat.cpp @@ -65,6 +65,12 @@ QgsTextCharacterFormat::QgsTextCharacterFormat( const QTextCharFormat &format ) if ( !families.isEmpty() ) mFontFamily = families.at( 0 ); } + if ( format.isImageFormat() ) + { + const QTextImageFormat imageFormat = format.toImageFormat(); + mImagePath = imageFormat.name(); + mImageSize = QSizeF( imageFormat.width(), imageFormat.height() ); + } } void QgsTextCharacterFormat::overrideWith( const QgsTextCharacterFormat &other ) @@ -168,6 +174,26 @@ void QgsTextCharacterFormat::setOverline( QgsTextCharacterFormat::BooleanValue e mOverline = enabled; } +QString QgsTextCharacterFormat::imagePath() const +{ + return mImagePath; +} + +void QgsTextCharacterFormat::setImagePath( const QString &path ) +{ + mImagePath = path; +} + +QSizeF QgsTextCharacterFormat::imageSize() const +{ + return mImageSize; +} + +void QgsTextCharacterFormat::setImageSize( const QSizeF &size ) +{ + mImageSize = size; +} + void QgsTextCharacterFormat::updateFontForFormat( QFont &font, const QgsRenderContext &context, const double scaleFactor ) const { // important -- MUST set family first diff --git a/src/core/textrenderer/qgstextcharacterformat.h b/src/core/textrenderer/qgstextcharacterformat.h index e3368504cfeb..8b6b79b5cce3 100644 --- a/src/core/textrenderer/qgstextcharacterformat.h +++ b/src/core/textrenderer/qgstextcharacterformat.h @@ -22,6 +22,7 @@ #include #include +#include class QTextCharFormat; class QgsRenderContext; @@ -253,6 +254,54 @@ class CORE_EXPORT QgsTextCharacterFormat */ void setOverline( BooleanValue enabled ); + /** + * Returns the path to the image to render, if the format applies to a document image fragment. + * + * \see QgsTextFragment::isImage() + * \see imageSize() + * \see setImagePath() + * + * \since QGIS 3.40 + */ + QString imagePath() const; + + /** + * Sets the \a path to the image to render, if the format applies to a document image fragment. + * + * \see QgsTextFragment::isImage() + * \see setImageSize() + * \see imagePath() + * + * \since QGIS 3.40 + */ + void setImagePath( const QString &path ); + + /** + * Returns the image size, if the format applies to a document image fragment. + * + * The image size is always considered to be in Qgis::RenderUnit::Points. + * + * \see QgsTextFragment::isImage() + * \see imagePath() + * \see setImageSize() + * + * \since QGIS 3.40 + */ + QSizeF imageSize() const; + + /** + * Sets the image \a size, if the format applies to a document image fragment. + * + * The image size is always considered to be in Qgis::RenderUnit::Points. + * + * \see QgsTextFragment::isImage() + * \see setImagePath() + * \see imageSize() + * + * \since QGIS 3.40 + */ + void setImageSize( const QSizeF &size ); + /** * Returns TRUE if the format has an explicit vertical alignment set. * @@ -326,6 +375,9 @@ class CORE_EXPORT QgsTextCharacterFormat bool mHasVerticalAlignSet = false; Qgis::TextCharacterVerticalAlignment mVerticalAlign = Qgis::TextCharacterVerticalAlignment::Normal; + QString mImagePath; + QSizeF mImageSize; + BooleanValue mStrikethrough = BooleanValue::NotSet; BooleanValue mUnderline = BooleanValue::NotSet; BooleanValue mOverline = BooleanValue::NotSet; diff --git a/src/core/textrenderer/qgstextdocument.cpp b/src/core/textrenderer/qgstextdocument.cpp index e17f8a0f4289..1b39c89ca16f 100644 --- a/src/core/textrenderer/qgstextdocument.cpp +++ b/src/core/textrenderer/qgstextdocument.cpp @@ -82,7 +82,7 @@ QgsTextDocument QgsTextDocument::fromHtml( const QStringList &lines ) sourceDoc.setHtml( line ); - QTextBlock sourceBlock = sourceDoc.firstBlock(); + QTextBlock sourceBlock = sourceDoc.begin(); while ( true ) { @@ -221,6 +221,7 @@ QgsTextDocument QgsTextDocument::fromHtml( const QStringList &lines ) } else { + QgsTextFragment tmpFragment( fragment ); QgsTextCharacterFormat newFormat { tmpFragment.characterFormat() }; newFormat.overrideWith( blockFormat ); diff --git a/src/core/textrenderer/qgstextdocumentmetrics.cpp b/src/core/textrenderer/qgstextdocumentmetrics.cpp index 466feeb72a08..573024f75a19 100644 --- a/src/core/textrenderer/qgstextdocumentmetrics.cpp +++ b/src/core/textrenderer/qgstextdocumentmetrics.cpp @@ -21,6 +21,8 @@ #include "qgstextdocument.h" #include "qgsrendercontext.h" #include "qgstextrenderer.h" +#include "qgsapplication.h" +#include "qgsimagecache.h" #include @@ -88,14 +90,17 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo 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; @@ -115,6 +120,7 @@ QgsTextDocumentMetrics QgsTextDocumentMetrics::calculateMetrics( const QgsTextDo fragmentVerticalOffsets << 0; fragmentHorizontalAdvance << fragmentWidth; + fragmentFixedHeights << -1; fragmentFonts << QFont(); } else @@ -190,34 +196,88 @@ 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 ); + 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 ); - 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; @@ -262,8 +322,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; @@ -305,6 +368,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; @@ -477,6 +541,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 ); diff --git a/src/core/textrenderer/qgstextdocumentmetrics.h b/src/core/textrenderer/qgstextdocumentmetrics.h index 1a182e84551c..f5c10f1bf69b 100644 --- a/src/core/textrenderer/qgstextdocumentmetrics.h +++ b/src/core/textrenderer/qgstextdocumentmetrics.h @@ -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. */ @@ -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; diff --git a/src/core/textrenderer/qgstextfragment.cpp b/src/core/textrenderer/qgstextfragment.cpp index 4bfd0bca9979..f6105bbbe001 100644 --- a/src/core/textrenderer/qgstextfragment.cpp +++ b/src/core/textrenderer/qgstextfragment.cpp @@ -19,15 +19,16 @@ #include "qgsstringutils.h" QgsTextFragment::QgsTextFragment( const QString &text, const QgsTextCharacterFormat &format ) - : mText( text ) + : mText( text != QStringLiteral( "\ufffc" ) ? text : QString() ) + , mIsImage( text == QStringLiteral( "\ufffc" ) ) , mCharFormat( format ) {} QgsTextFragment::QgsTextFragment( const QTextFragment &fragment ) - : mText( fragment.text() ) - , mCharFormat( QgsTextCharacterFormat( fragment.charFormat() ) ) + : mText( fragment.text() != QStringLiteral( "\ufffc" ) ? fragment.text() : QString() ) + , mIsImage( fragment.text() == QStringLiteral( "\ufffc" ) ) + , mCharFormat( fragment.charFormat() ) { - } QString QgsTextFragment::text() const @@ -45,6 +46,11 @@ void QgsTextFragment::setCharacterFormat( const QgsTextCharacterFormat &charForm mCharFormat = charFormat; } +bool QgsTextFragment::isImage() const +{ + return mIsImage; +} + double QgsTextFragment::horizontalAdvance( const QFont &font, const QgsRenderContext &context, bool fontHasBeenUpdatedForFragment, double scaleFactor ) const { if ( fontHasBeenUpdatedForFragment ) diff --git a/src/core/textrenderer/qgstextfragment.h b/src/core/textrenderer/qgstextfragment.h index 4e16db741a07..a17f88f0e367 100644 --- a/src/core/textrenderer/qgstextfragment.h +++ b/src/core/textrenderer/qgstextfragment.h @@ -26,7 +26,13 @@ class QTextFragment; /** * \class QgsTextFragment * \ingroup core - * \brief Stores a fragment of text along with formatting overrides to be used when rendering the fragment. + * \brief Stores a fragment of document along with formatting overrides to be used when rendering the fragment. + * + * Text fragments consist of either a block of text or another atomic component of a document (such as an image). + * + * Each fragment has an associated characterFormat(), which specifies the text formatting overrides + * to use when rendering the fragment. Additionally, the characterFormat() may contain properties + * for other fragment types, such as image paths and sizes for image fragments. * * \warning This API is not considered stable and may change in future QGIS versions. * @@ -89,6 +95,13 @@ class CORE_EXPORT QgsTextFragment */ void setCharacterFormat( const QgsTextCharacterFormat &format ); + /** + * Returns TRUE if the fragment represents an image. + * + * \since QGIS 3.40 + */ + bool isImage() const; + /** * Returns the horizontal advance associated with this fragment, when rendered using * the specified base \a font within the specified render \a context. @@ -113,6 +126,7 @@ class CORE_EXPORT QgsTextFragment private: QString mText; + bool mIsImage = false; QgsTextCharacterFormat mCharFormat; }; diff --git a/src/core/textrenderer/qgstextrenderer.cpp b/src/core/textrenderer/qgstextrenderer.cpp index ab05f28a7db8..1043fcfcaa2e 100644 --- a/src/core/textrenderer/qgstextrenderer.cpp +++ b/src/core/textrenderer/qgstextrenderer.cpp @@ -30,6 +30,8 @@ #include "qgstextrendererutils.h" #include "qgsgeos.h" #include "qgspainting.h" +#include "qgsapplication.h" +#include "qgsimagecache.h" #include #include @@ -598,7 +600,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 ); @@ -766,7 +768,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 ); @@ -1875,7 +1877,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 ); @@ -1894,6 +1896,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 ++; @@ -1935,7 +1953,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 ); diff --git a/tests/src/python/test_qgstextcharacterformat.py b/tests/src/python/test_qgstextcharacterformat.py index fb5756a23bc7..c5c34e5d1f3d 100644 --- a/tests/src/python/test_qgstextcharacterformat.py +++ b/tests/src/python/test_qgstextcharacterformat.py @@ -11,6 +11,7 @@ __date__ = '12/05/2020' __copyright__ = 'Copyright 2020, The QGIS Project' +from qgis.PyQt.QtCore import QSizeF from qgis.PyQt.QtGui import QColor from qgis.core import ( Qgis, @@ -64,6 +65,13 @@ def testGettersSetters(self): format.setVerticalAlignment(Qgis.TextCharacterVerticalAlignment.SuperScript) self.assertEqual(format.verticalAlignment(), Qgis.TextCharacterVerticalAlignment.SuperScript) + self.assertFalse(format.imagePath()) + self.assertEqual(format.imageSize(), QSizeF()) + format.setImagePath('my.jpg') + format.setImageSize(QSizeF(40, 60)) + self.assertEqual(format.imagePath(), 'my.jpg') + self.assertEqual(format.imageSize(), QSizeF(40, 60)) + def testUpdateFont(self): context = QgsRenderContext() font = QgsFontUtils.getStandardTestFont() diff --git a/tests/src/python/test_qgstextdocument.py b/tests/src/python/test_qgstextdocument.py index dd22b62e34e3..89ee20f898e4 100644 --- a/tests/src/python/test_qgstextdocument.py +++ b/tests/src/python/test_qgstextdocument.py @@ -11,7 +11,7 @@ __date__ = '12/05/2020' __copyright__ = 'Copyright 2020, The QGIS Project' -from qgis.PyQt.QtCore import QT_VERSION_STR +from qgis.PyQt.QtCore import QT_VERSION_STR, QSizeF from qgis.core import ( Qgis, QgsFontUtils, @@ -222,6 +222,22 @@ def testFromHtmlVerticalAlignment(self): self.assertTrue(doc[2][1].characterFormat().hasVerticalAlignmentSet()) self.assertEqual(doc[2][1].characterFormat().verticalAlignment(), Qgis.TextCharacterVerticalAlignment.SubScript) + def testImage(self): + doc = QgsTextDocument.fromHtml([ + 'abcextra']) + self.assertEqual(len(doc), 1) + self.assertEqual(len(doc[0]), 3) + self.assertEqual(doc[0][0].text(), 'abc') + self.assertFalse(doc[0][0].isImage()) + self.assertFalse(doc[0][0].characterFormat().imagePath()) + self.assertTrue(doc[0][1].isImage()) + self.assertFalse(doc[0][1].text()) + self.assertEqual(doc[0][1].characterFormat().imagePath(), 'qgis.jpg') + self.assertEqual(doc[0][1].characterFormat().imageSize(), QSizeF(40, 60)) + self.assertEqual(doc[0][2].text(), 'extra') + self.assertFalse(doc[0][2].isImage()) + self.assertTrue(doc[0][2].characterFormat().italic()) + def testAppend(self): doc = QgsTextDocument() self.assertEqual(len(doc), 0)