From 09c5478ad212447dfbf300ab78c28a04d051471f Mon Sep 17 00:00:00 2001 From: Mathieu Pellerin Date: Fri, 6 Jun 2025 11:21:47 +0700 Subject: [PATCH] [vector tiles] Fix sprites not loaded from the OSM shortbread styles --- .../qgsmapboxglstyleconverter.sip.in | 37 ++++-- .../qgsmapboxglstyleconverter.sip.in | 37 ++++-- .../vectortile/qgsmapboxglstyleconverter.cpp | 58 ++++++--- .../vectortile/qgsmapboxglstyleconverter.h | 30 +++-- src/core/vectortile/qgsvectortileutils.cpp | 115 ++++++++++++------ tests/src/python/test_qgsmapboxglconverter.py | 49 ++++++++ 6 files changed, 239 insertions(+), 87 deletions(-) diff --git a/python/PyQt6/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in b/python/PyQt6/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in index b78d0f90e26..f4e4dbe0c6f 100644 --- a/python/PyQt6/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in +++ b/python/PyQt6/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in @@ -90,39 +90,54 @@ sizes when converting styles. .. seealso:: :py:func:`pixelSizeConversionFactor` %End - QImage spriteImage() const; + QStringList spriteCategories() const; %Docstring -Returns the sprite image to use during conversion, or an invalid image -if this is not set. +Returns the list of sprite categories to use during conversion, or an +empty list of none is set. + +.. seealso:: :py:func:`spriteDefinitions` + +.. seealso:: :py:func:`spriteImage` + +.. versionadded:: 3.44 +%End + + QImage spriteImage( const QString &category = QString() ) const; +%Docstring +Returns the sprite image for a given ``category`` to use during +conversion, or an invalid image if this is not set. + +.. seealso:: :py:func:`spriteCategories` .. seealso:: :py:func:`spriteDefinitions` .. seealso:: :py:func:`setSprites` %End - QVariantMap spriteDefinitions() const; + QVariantMap spriteDefinitions( const QString &category = QString() ) const; %Docstring -Returns the sprite definitions to use during conversion. +Returns the sprite definitions for a given ``category`` to use during +conversion. .. seealso:: :py:func:`spriteImage` .. seealso:: :py:func:`setSprites` %End - void setSprites( const QImage &image, const QVariantMap &definitions ); + void setSprites( const QImage &image, const QVariantMap &definitions, const QString &category = QString() ); %Docstring -Sets the sprite ``image`` and ``definitions`` JSON to use during -conversion. +Sets the sprite ``image`` and ``definitions`` JSON for a given +``category`` to use during conversion. .. seealso:: :py:func:`spriteImage` .. seealso:: :py:func:`spriteDefinitions` %End - void setSprites( const QImage &image, const QString &definitions ); + void setSprites( const QImage &image, const QString &definitions, const QString &category = QString() ); %Docstring -Sets the sprite ``image`` and ``definitions`` JSON string to use during -conversion. +Sets the sprite ``image`` and ``definitions`` JSON string for a given +``category`` to use during conversion. .. seealso:: :py:func:`spriteImage` diff --git a/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in b/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in index 4818a190d05..d2662f12cba 100644 --- a/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in +++ b/python/core/auto_generated/vectortile/qgsmapboxglstyleconverter.sip.in @@ -90,39 +90,54 @@ sizes when converting styles. .. seealso:: :py:func:`pixelSizeConversionFactor` %End - QImage spriteImage() const; + QStringList spriteCategories() const; %Docstring -Returns the sprite image to use during conversion, or an invalid image -if this is not set. +Returns the list of sprite categories to use during conversion, or an +empty list of none is set. + +.. seealso:: :py:func:`spriteDefinitions` + +.. seealso:: :py:func:`spriteImage` + +.. versionadded:: 3.44 +%End + + QImage spriteImage( const QString &category = QString() ) const; +%Docstring +Returns the sprite image for a given ``category`` to use during +conversion, or an invalid image if this is not set. + +.. seealso:: :py:func:`spriteCategories` .. seealso:: :py:func:`spriteDefinitions` .. seealso:: :py:func:`setSprites` %End - QVariantMap spriteDefinitions() const; + QVariantMap spriteDefinitions( const QString &category = QString() ) const; %Docstring -Returns the sprite definitions to use during conversion. +Returns the sprite definitions for a given ``category`` to use during +conversion. .. seealso:: :py:func:`spriteImage` .. seealso:: :py:func:`setSprites` %End - void setSprites( const QImage &image, const QVariantMap &definitions ); + void setSprites( const QImage &image, const QVariantMap &definitions, const QString &category = QString() ); %Docstring -Sets the sprite ``image`` and ``definitions`` JSON to use during -conversion. +Sets the sprite ``image`` and ``definitions`` JSON for a given +``category`` to use during conversion. .. seealso:: :py:func:`spriteImage` .. seealso:: :py:func:`spriteDefinitions` %End - void setSprites( const QImage &image, const QString &definitions ); + void setSprites( const QImage &image, const QString &definitions, const QString &category = QString() ); %Docstring -Sets the sprite ``image`` and ``definitions`` JSON string to use during -conversion. +Sets the sprite ``image`` and ``definitions`` JSON string for a given +``category`` to use during conversion. .. seealso:: :py:func:`spriteImage` diff --git a/src/core/vectortile/qgsmapboxglstyleconverter.cpp b/src/core/vectortile/qgsmapboxglstyleconverter.cpp index bd3a9ac4f91..5b5f0570925 100644 --- a/src/core/vectortile/qgsmapboxglstyleconverter.cpp +++ b/src/core/vectortile/qgsmapboxglstyleconverter.cpp @@ -3524,23 +3524,46 @@ QString QgsMapBoxGlStyleConverter::parseExpression( const QVariantList &expressi QImage QgsMapBoxGlStyleConverter::retrieveSprite( const QString &name, QgsMapBoxGlStyleConversionContext &context, QSize &spriteSize ) { - if ( context.spriteImage().isNull() ) + QImage spriteImage; + QString category; + QString actualName = name; + const int categorySeparator = name.indexOf( ':' ); + if ( categorySeparator > 0 ) + { + category = name.left( categorySeparator ); + if ( context.spriteCategories().contains( category ) ) + { + actualName = name.mid( categorySeparator + 1 ); + spriteImage = context.spriteImage( category ); + } + else + { + category.clear(); + } + } + + if ( category.isEmpty() ) + { + spriteImage = context.spriteImage(); + } + + if ( spriteImage.isNull() ) { context.pushWarning( QObject::tr( "%1: Could not retrieve sprite '%2'" ).arg( context.layerId(), name ) ); return QImage(); } - const QVariantMap spriteDefinition = context.spriteDefinitions().value( name ).toMap(); + const QVariantMap spriteDefinition = context.spriteDefinitions( category ).value( actualName ).toMap(); if ( spriteDefinition.size() == 0 ) { context.pushWarning( QObject::tr( "%1: Could not retrieve sprite '%2'" ).arg( context.layerId(), name ) ); return QImage(); } - const QImage sprite = context.spriteImage().copy( spriteDefinition.value( QStringLiteral( "x" ) ).toInt(), - spriteDefinition.value( QStringLiteral( "y" ) ).toInt(), - spriteDefinition.value( QStringLiteral( "width" ) ).toInt(), - spriteDefinition.value( QStringLiteral( "height" ) ).toInt() ); + const QImage sprite = spriteImage.copy( spriteDefinition.value( QStringLiteral( "x" ) ).toInt(), + spriteDefinition.value( QStringLiteral( "y" ) ).toInt(), + spriteDefinition.value( QStringLiteral( "width" ) ).toInt(), + spriteDefinition.value( QStringLiteral( "height" ) ).toInt() ); if ( sprite.isNull() ) { context.pushWarning( QObject::tr( "%1: Could not retrieve sprite '%2'" ).arg( context.layerId(), name ) ); @@ -4091,25 +4114,30 @@ void QgsMapBoxGlStyleConversionContext::setPixelSizeConversionFactor( double siz mSizeConversionFactor = sizeConversionFactor; } -QImage QgsMapBoxGlStyleConversionContext::spriteImage() const +QStringList QgsMapBoxGlStyleConversionContext::spriteCategories() const { - return mSpriteImage; + return mSpriteImage.keys(); } -QVariantMap QgsMapBoxGlStyleConversionContext::spriteDefinitions() const +QImage QgsMapBoxGlStyleConversionContext::spriteImage( const QString &category ) const { - return mSpriteDefinitions; + return mSpriteImage.contains( category ) ? mSpriteImage[category] : QImage(); } -void QgsMapBoxGlStyleConversionContext::setSprites( const QImage &image, const QVariantMap &definitions ) +QVariantMap QgsMapBoxGlStyleConversionContext::spriteDefinitions( const QString &category ) const { - mSpriteImage = image; - mSpriteDefinitions = definitions; + return mSpriteDefinitions.contains( category ) ? mSpriteDefinitions[category] : QVariantMap(); } -void QgsMapBoxGlStyleConversionContext::setSprites( const QImage &image, const QString &definitions ) +void QgsMapBoxGlStyleConversionContext::setSprites( const QImage &image, const QVariantMap &definitions, const QString &category ) { - setSprites( image, QgsJsonUtils::parseJson( definitions ).toMap() ); + mSpriteImage[category] = image; + mSpriteDefinitions[category] = definitions; +} + +void QgsMapBoxGlStyleConversionContext::setSprites( const QImage &image, const QString &definitions, const QString &category ) +{ + setSprites( image, QgsJsonUtils::parseJson( definitions ).toMap(), category ); } QString QgsMapBoxGlStyleConversionContext::layerId() const diff --git a/src/core/vectortile/qgsmapboxglstyleconverter.h b/src/core/vectortile/qgsmapboxglstyleconverter.h index 81345e89014..d6763e71776 100644 --- a/src/core/vectortile/qgsmapboxglstyleconverter.h +++ b/src/core/vectortile/qgsmapboxglstyleconverter.h @@ -103,36 +103,46 @@ class CORE_EXPORT QgsMapBoxGlStyleConversionContext void setPixelSizeConversionFactor( double sizeConversionFactor ); /** - * Returns the sprite image to use during conversion, or an invalid image if this is not set. + * Returns the list of sprite categories to use during conversion, or an empty list of none is set. * * \see spriteDefinitions() + * \see spriteImage() + * \since QGIS 3.44 + */ + QStringList spriteCategories() const; + + /** + * Returns the sprite image for a given \a category to use during conversion, or an invalid image if this is not set. + * + * \see spriteCategories() + * \see spriteDefinitions() * \see setSprites() */ - QImage spriteImage() const; + QImage spriteImage( const QString &category = QString() ) const; /** - * Returns the sprite definitions to use during conversion. + * Returns the sprite definitions for a given \a category to use during conversion. * * \see spriteImage() * \see setSprites() */ - QVariantMap spriteDefinitions() const; + QVariantMap spriteDefinitions( const QString &category = QString() ) const; /** - * Sets the sprite \a image and \a definitions JSON to use during conversion. + * Sets the sprite \a image and \a definitions JSON for a given \a category to use during conversion. * * \see spriteImage() * \see spriteDefinitions() */ - void setSprites( const QImage &image, const QVariantMap &definitions ); + void setSprites( const QImage &image, const QVariantMap &definitions, const QString &category = QString() ); /** - * Sets the sprite \a image and \a definitions JSON string to use during conversion. + * Sets the sprite \a image and \a definitions JSON string for a given \a category to use during conversion. * * \see spriteImage() * \see spriteDefinitions() */ - void setSprites( const QImage &image, const QString &definitions ); + void setSprites( const QImage &image, const QString &definitions, const QString &category = QString() ); /** * Returns the layer ID of the layer currently being converted. @@ -158,8 +168,8 @@ class CORE_EXPORT QgsMapBoxGlStyleConversionContext double mSizeConversionFactor = 1.0; - QImage mSpriteImage; - QVariantMap mSpriteDefinitions; + QMap mSpriteImage; + QMap mSpriteDefinitions; }; diff --git a/src/core/vectortile/qgsvectortileutils.cpp b/src/core/vectortile/qgsvectortileutils.cpp index 1a7949a99a1..08b9c9d98ef 100644 --- a/src/core/vectortile/qgsvectortileutils.cpp +++ b/src/core/vectortile/qgsvectortileutils.cpp @@ -320,66 +320,101 @@ void QgsVectorTileUtils::sortTilesByDistanceFromCenter( QVector &til void QgsVectorTileUtils::loadSprites( const QVariantMap &styleDefinition, QgsMapBoxGlStyleConversionContext &context, const QString &styleUrl ) { - if ( styleDefinition.contains( QStringLiteral( "sprite" ) ) && ( context.spriteDefinitions().empty() || context.spriteImage().isNull() ) ) + if ( styleDefinition.contains( QStringLiteral( "sprite" ) ) && ( context.spriteCategories().isEmpty() ) ) { - // retrieve sprite definition - QString spriteUriBase; - if ( styleDefinition.value( QStringLiteral( "sprite" ) ).toString().startsWith( QLatin1String( "http" ) ) ) + auto prepareSpriteUrl = []( const QString & sprite, const QString & styleUrl ) { - spriteUriBase = styleDefinition.value( QStringLiteral( "sprite" ) ).toString(); + if ( sprite.startsWith( QLatin1String( "http" ) ) ) + { + return sprite; + } + else if ( sprite.startsWith( QLatin1String( "/" ) ) ) + { + const QUrl url( styleUrl ); + return QStringLiteral( "%1://%2%3" ).arg( url.scheme(), url.host(), sprite ); + } + + return QStringLiteral( "%1/%2" ).arg( styleUrl, sprite ); + }; + + // retrieve sprite definition + QMap sprites; + const QVariant spriteVariant = styleDefinition.value( QStringLiteral( "sprite" ) ); + if ( spriteVariant.userType() == QMetaType::Type::QVariantList ) + { + const QVariantList spriteList = spriteVariant.toList(); + for ( const QVariant &spriteItem : spriteList ) + { + QVariantMap spriteMap = spriteItem.toMap(); + if ( spriteMap.contains( QStringLiteral( "id" ) ) && spriteMap.contains( "url" ) ) + { + sprites[spriteMap.value( QStringLiteral( "id" ) ).toString()] = prepareSpriteUrl( spriteMap.value( QStringLiteral( "url" ) ).toString(), styleUrl ); + } + } } else { - spriteUriBase = styleUrl + '/' + styleDefinition.value( QStringLiteral( "sprite" ) ).toString(); + sprites[""] = prepareSpriteUrl( spriteVariant.toString(), styleUrl ); } - for ( int resolution = 2; resolution > 0; resolution-- ) + if ( sprites.isEmpty() ) { - QUrl spriteUrl = QUrl( spriteUriBase ); - spriteUrl.setPath( spriteUrl.path() + QStringLiteral( "%1.json" ).arg( resolution > 1 ? QStringLiteral( "@%1x" ).arg( resolution ) : QString() ) ); - QNetworkRequest request = QNetworkRequest( spriteUrl ); - QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsVectorTileLayer" ) ) - QgsBlockingNetworkRequest networkRequest; - switch ( networkRequest.get( request ) ) - { - case QgsBlockingNetworkRequest::NoError: - { - const QgsNetworkReplyContent content = networkRequest.reply(); - const QVariantMap spriteDefinition = QgsJsonUtils::parseJson( content.content() ).toMap(); + return; + } - // retrieve sprite images - QUrl spriteUrl = QUrl( spriteUriBase ); - spriteUrl.setPath( spriteUrl.path() + QStringLiteral( "%1.png" ).arg( resolution > 1 ? QStringLiteral( "@%1x" ).arg( resolution ) : QString() ) ); - QNetworkRequest request = QNetworkRequest( spriteUrl ); - QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsVectorTileLayer" ) ) - QgsBlockingNetworkRequest networkRequest; - switch ( networkRequest.get( request ) ) + QMap::const_iterator spritesIterator = sprites.constBegin(); + while ( spritesIterator != sprites.end() ) + { + for ( int resolution = 2; resolution > 0; resolution-- ) + { + QUrl spriteUrl = QUrl( spritesIterator.value() ); + spriteUrl.setPath( spriteUrl.path() + QStringLiteral( "%1.json" ).arg( resolution > 1 ? QStringLiteral( "@%1x" ).arg( resolution ) : QString() ) ); + QNetworkRequest request = QNetworkRequest( spriteUrl ); + QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsVectorTileLayer" ) ) + QgsBlockingNetworkRequest networkRequest; + switch ( networkRequest.get( request ) ) + { + case QgsBlockingNetworkRequest::NoError: { - case QgsBlockingNetworkRequest::NoError: + const QgsNetworkReplyContent content = networkRequest.reply(); + const QVariantMap spriteDefinition = QgsJsonUtils::parseJson( content.content() ).toMap(); + + // retrieve sprite images + QUrl spriteUrl = QUrl( spritesIterator.value() ); + spriteUrl.setPath( spriteUrl.path() + QStringLiteral( "%1.png" ).arg( resolution > 1 ? QStringLiteral( "@%1x" ).arg( resolution ) : QString() ) ); + QNetworkRequest request = QNetworkRequest( spriteUrl ); + QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsVectorTileLayer" ) ) + QgsBlockingNetworkRequest networkRequest; + switch ( networkRequest.get( request ) ) { - const QgsNetworkReplyContent imageContent = networkRequest.reply(); - const QImage spriteImage( QImage::fromData( imageContent.content() ) ); - context.setSprites( spriteImage, spriteDefinition ); - break; + case QgsBlockingNetworkRequest::NoError: + { + const QgsNetworkReplyContent imageContent = networkRequest.reply(); + const QImage spriteImage( QImage::fromData( imageContent.content() ) ); + context.setSprites( spriteImage, spriteDefinition, spritesIterator.key() ); + break; + } + + case QgsBlockingNetworkRequest::NetworkError: + case QgsBlockingNetworkRequest::TimeoutError: + case QgsBlockingNetworkRequest::ServerExceptionError: + break; } - case QgsBlockingNetworkRequest::NetworkError: - case QgsBlockingNetworkRequest::TimeoutError: - case QgsBlockingNetworkRequest::ServerExceptionError: - break; + break; } - break; + case QgsBlockingNetworkRequest::NetworkError: + case QgsBlockingNetworkRequest::TimeoutError: + case QgsBlockingNetworkRequest::ServerExceptionError: + break; } - case QgsBlockingNetworkRequest::NetworkError: - case QgsBlockingNetworkRequest::TimeoutError: - case QgsBlockingNetworkRequest::ServerExceptionError: + if ( !context.spriteDefinitions().isEmpty() ) break; } - if ( !context.spriteDefinitions().isEmpty() ) - break; + ++spritesIterator; } } } diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py index 0e9ab826709..61aab8faea8 100644 --- a/tests/src/python/test_qgsmapboxglconverter.py +++ b/tests/src/python/test_qgsmapboxglconverter.py @@ -2735,6 +2735,55 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): 'CASE WHEN "lake_depth" IS NOT NULL THEN 24 WHEN length(to_string("ele")) IS 3 THEN 18 ELSE 24 END', ) + def testRetrieveSpriteWithCategory(self): + context = QgsMapBoxGlStyleConversionContext() + sprite_image_file = ( + f"{TEST_DATA_DIR}/vector_tile/sprites/swisstopo-sprite@2x.png" + ) + with open(sprite_image_file, "rb") as f: + sprite_image = QImage() + sprite_image.loadFromData(f.read()) + sprite_definition_file = ( + f"{TEST_DATA_DIR}/vector_tile/sprites/swisstopo-sprite@2x.json" + ) + with open(sprite_definition_file) as f: + sprite_definition = json.load(f) + context.setSprites(sprite_image, sprite_definition, "basics") + self.assertEqual(context.spriteCategories(), ["basics"]) + + # swisstopo - lightbasemap - sinkhole + icon_image = [ + "match", + ["get", "class"], + "sinkhole", + "basics:arrow_brown", + ["sinkhole_rock", "sinkhole_scree"], + "basics:arrow_grey", + ["sinkhole_ice", "sinkhole_water"], + "basics:arrow_blue", + "", + ] + sprite, size, sprite_property, sprite_size_property = ( + QgsMapBoxGlStyleConverter.retrieveSpriteAsBase64WithProperties( + icon_image, context + ) + ) + + def strip_base64(s): + pos = 0 + while True: + pos = s.find("'base64:", pos) + if pos < 0: + break + end_pos = s.find("'", pos + 1) + assert end_pos > pos + s = s[0:pos] + "'base64:[snip]'" + s[end_pos + 1 :] + pos += len("'base64:") + return s + + expected = "CASE WHEN \"class\" IN ('sinkhole') THEN 'base64:[snip]' WHEN \"class\" IN ('sinkhole_rock','sinkhole_scree') THEN 'base64:[snip]' WHEN \"class\" IN ('sinkhole_ice','sinkhole_water') THEN 'base64:[snip]' ELSE '' END" + self.assertEqual(strip_base64(sprite_property), expected) + if __name__ == "__main__": unittest.main()