diff --git a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/commands/style/AddImage.kt b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/commands/style/AddImage.kt new file mode 100644 index 0000000..32bf6ed --- /dev/null +++ b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/commands/style/AddImage.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025, MapTiler + * All rights reserved. + * SPDX-License-Identifier: BSD 3-Clause + */ + +package com.maptiler.maptilersdk.commands.style + +import android.graphics.Bitmap +import com.maptiler.maptilersdk.bridge.JSString +import com.maptiler.maptilersdk.bridge.MTBridge +import com.maptiler.maptilersdk.bridge.MTCommand +import com.maptiler.maptilersdk.helpers.ImageHelper +import com.maptiler.maptilersdk.helpers.JsonConfig +import com.maptiler.maptilersdk.map.style.image.MTAddImageOptions +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +internal data class AddImage( + val identifier: String, + val image: Bitmap, + val options: MTAddImageOptions? = null, +) : MTCommand { + override val isPrimitiveReturnType: Boolean = false + + override fun toJS(): JSString { + val encoded = ImageHelper.encodeImageWithMime(image) + val dataUri = ImageHelper.getEncodedString(encoded) + val sanitizedIdentifier = identifier.replace("\\", "\\\\").replace("'", "\\'") + val optionsJson = + options?.let { + val surrogate = + AddImageOptionsSurrogate( + pixelRatio = it.pixelRatio, + sdf = it.sdf, + ) + JsonConfig.json.encodeToString(surrogate) + } + + val addImageCall = + if (optionsJson != null) { + "${MTBridge.MAP_OBJECT}.style.addImage('$sanitizedIdentifier', __mtImg, $optionsJson);" + } else { + "${MTBridge.MAP_OBJECT}.style.addImage('$sanitizedIdentifier', __mtImg);" + } + + return """ + (function() { + const __mtImg = new Image(); + __mtImg.src = '$dataUri'; + __mtImg.onload = function() { + $addImageCall + }; + })(); + """.trimIndent() + } +} + +@Serializable +private data class AddImageOptionsSurrogate( + val pixelRatio: Double? = null, + val sdf: Boolean? = null, +) diff --git a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/helpers/ImageHelper.kt b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/helpers/ImageHelper.kt index 19181b3..c7aed9e 100644 --- a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/helpers/ImageHelper.kt +++ b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/helpers/ImageHelper.kt @@ -38,6 +38,11 @@ internal object ImageHelper { return EncodedImage(base64 = base64, mimeType = mime) } + /** + * Returns a data URI for the given encoded image payload. + */ + fun getEncodedString(encodedImage: EncodedImage): String = "data:${encodedImage.mimeType};base64,${encodedImage.base64}" + /** * Deprecated: prefer [encodeImageWithMime]. Kept for tests/backward-compatibility. */ diff --git a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/MTStyle.kt b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/MTStyle.kt index de9e426..64f7288 100644 --- a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/MTStyle.kt +++ b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/MTStyle.kt @@ -6,10 +6,12 @@ package com.maptiler.maptilersdk.map.style +import android.graphics.Bitmap import com.maptiler.maptilersdk.annotations.MTMarker import com.maptiler.maptilersdk.annotations.MTTextPopup import com.maptiler.maptilersdk.bridge.MTBridge import com.maptiler.maptilersdk.bridge.MTError +import com.maptiler.maptilersdk.map.style.image.MTAddImageOptions import com.maptiler.maptilersdk.map.style.layer.MTLayer import com.maptiler.maptilersdk.map.style.source.MTSource import com.maptiler.maptilersdk.map.types.MTLanguage @@ -115,6 +117,19 @@ class MTStyle( */ override fun removeTextPopup(popup: MTTextPopup) = stylableWorker.removeTextPopup(popup) + /** + * Registers an image asset that can be referenced from the style. + * + * @param identifier Unique name for the image in the style registry. + * @param image Bitmap containing the image data. + * @param options Optional image configuration such as pixel ratio or SDF toggle. + */ + fun addImage( + identifier: String, + image: Bitmap, + options: MTAddImageOptions? = null, + ) = stylableWorker.addImage(identifier, image, options) + /** * Adds a layer to the map. * diff --git a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/image/MTAddImageOptions.kt b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/image/MTAddImageOptions.kt new file mode 100644 index 0000000..ebc59f7 --- /dev/null +++ b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/style/image/MTAddImageOptions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025, MapTiler + * All rights reserved. + * SPDX-License-Identifier: BSD 3-Clause + */ + +package com.maptiler.maptilersdk.map.style.image + +/** + * Options used when registering an image in the current map style. + * + * @param sdf Whether the image should be interpreted as a signed distance field. + * @param pixelRatio Override pixel ratio for the image. Must be greater than 0 when provided. + */ +data class MTAddImageOptions( + val sdf: Boolean? = null, + val pixelRatio: Double? = null, +) { + init { + if (pixelRatio != null && pixelRatio <= 0.0) { + throw IllegalArgumentException("pixelRatio must be greater than 0.0") + } + } +} diff --git a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/workers/stylable/StylableWorker.kt b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/workers/stylable/StylableWorker.kt index d472c3d..f0a5168 100644 --- a/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/workers/stylable/StylableWorker.kt +++ b/MapTilerSDK/src/main/java/com/maptiler/maptilersdk/map/workers/stylable/StylableWorker.kt @@ -6,6 +6,7 @@ package com.maptiler.maptilersdk.map.workers.stylable +import android.graphics.Bitmap import com.maptiler.maptilersdk.annotations.MTMarker import com.maptiler.maptilersdk.annotations.MTTextPopup import com.maptiler.maptilersdk.bridge.MTBridge @@ -15,6 +16,7 @@ import com.maptiler.maptilersdk.commands.annotations.AddTextPopup import com.maptiler.maptilersdk.commands.annotations.RemoveMarker import com.maptiler.maptilersdk.commands.annotations.RemoveTextPopup import com.maptiler.maptilersdk.commands.misc.AddLogoControl +import com.maptiler.maptilersdk.commands.style.AddImage import com.maptiler.maptilersdk.commands.style.AddLayer import com.maptiler.maptilersdk.commands.style.AddSource import com.maptiler.maptilersdk.commands.style.DisableHalo @@ -47,6 +49,7 @@ import com.maptiler.maptilersdk.map.options.MTHalo import com.maptiler.maptilersdk.map.options.MTSpace import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle import com.maptiler.maptilersdk.map.style.MTMapStyleVariant +import com.maptiler.maptilersdk.map.style.image.MTAddImageOptions import com.maptiler.maptilersdk.map.style.layer.MTLayer import com.maptiler.maptilersdk.map.style.source.MTSource import com.maptiler.maptilersdk.map.types.MTLanguage @@ -200,6 +203,18 @@ internal class StylableWorker( } } + fun addImage( + identifier: String, + image: Bitmap, + options: MTAddImageOptions? = null, + ) { + scope.launch { + bridge.execute( + AddImage(identifier, image, options), + ) + } + } + fun removeLayer(layer: MTLayer) { scope.launch { bridge.execute( diff --git a/MapTilerSDK/src/test/java/com/maptiler/maptilersdk/StyleAndCommandsTests.kt b/MapTilerSDK/src/test/java/com/maptiler/maptilersdk/StyleAndCommandsTests.kt index 1e8b01e..8482a44 100644 --- a/MapTilerSDK/src/test/java/com/maptiler/maptilersdk/StyleAndCommandsTests.kt +++ b/MapTilerSDK/src/test/java/com/maptiler/maptilersdk/StyleAndCommandsTests.kt @@ -8,6 +8,10 @@ package com.maptiler.maptilersdk import android.graphics.Bitmap import com.maptiler.maptilersdk.bridge.MTBridge +import com.maptiler.maptilersdk.bridge.MTBridgeReturnType +import com.maptiler.maptilersdk.bridge.MTCommand +import com.maptiler.maptilersdk.bridge.MTCommandExecutable +import com.maptiler.maptilersdk.commands.style.AddImage import com.maptiler.maptilersdk.commands.style.AddSource import com.maptiler.maptilersdk.commands.style.DisableTerrain import com.maptiler.maptilersdk.commands.style.EnableGlobeProjection @@ -18,6 +22,9 @@ import com.maptiler.maptilersdk.commands.style.SetDataToSource import com.maptiler.maptilersdk.commands.style.SetTilesToSource import com.maptiler.maptilersdk.helpers.EncodedImage import com.maptiler.maptilersdk.helpers.ImageHelper +import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle +import com.maptiler.maptilersdk.map.style.MTStyle +import com.maptiler.maptilersdk.map.style.image.MTAddImageOptions import com.maptiler.maptilersdk.map.style.layer.symbol.MTSymbolLayer import com.maptiler.maptilersdk.map.style.source.MTGeoJSONSource import com.maptiler.maptilersdk.map.style.source.MTVectorTileSource @@ -25,6 +32,8 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import org.junit.Assert.assertTrue import org.junit.Test import java.net.URL @@ -181,4 +190,45 @@ class StyleAndCommandsTests { assertTrue(js.contains("\"text-size\":12.0")) assertTrue(js.contains("\"filter\":[\"has\",\"point_count\"]")) } + + @Test fun addImageToJS_EncodesBitmapAndOptions() { + val bmp = mockk() + every { bmp.hasAlpha() } returns true + every { bmp.compress(any(), any(), any()) } returns true + + mockkObject(ImageHelper) + every { ImageHelper.encodeImageWithMime(any()) } returns EncodedImage("CCC", "image/png") + every { ImageHelper.getEncodedString(any()) } returns "data:image/png;base64,CCC" + + val options = MTAddImageOptions(sdf = true, pixelRatio = 2.0) + val js = + AddImage("poi-icon", bmp, options).toJS() + + assertTrue(js.contains("${MTBridge.MAP_OBJECT}.style.addImage('poi-icon'")) + assertTrue(js.contains("data:image/png;base64,CCC")) + assertTrue(js.contains("\"sdf\":true")) + assertTrue(js.contains("\"pixelRatio\":2.0")) + } + + @Test fun mtStyleAddImage_DelegatesToBridge() { + val recordedCommands = mutableListOf() + val bridge = + MTBridge( + object : MTCommandExecutable { + override suspend fun execute(command: MTCommand): MTBridgeReturnType { + recordedCommands.add(command) + return MTBridgeReturnType.Null + } + }, + ) + val style = MTStyle(MTMapReferenceStyle.STREETS) + style.initWorker(bridge, CoroutineScope(Dispatchers.Unconfined)) + + val bmp = mockk(relaxed = true) + + style.addImage("poi-icon", bmp) + + assertEquals(1, recordedCommands.size) + assertTrue(recordedCommands.first() is AddImage) + } }