Skip to content

PMTiles

The pmtiles module reads PMTiles v3 archives from a caller-provided byte range source and writes PMTiles v3 archives to an append-only byte sink.

Details can be found in the API reference.

commonMain {
dependencies {
implementation("org.maplibre.spatialk:pmtiles:0.8.0")
}
}

This module implements PMTiles v3 reading and writing.

It does not provide filesystem or HTTP sources/sinks, or parse, construct, or render tile payload formats such as MVT, raster images, or MLT.

Uncompressed data is supported on all platforms. Gzip decompression and compression are built in on JVM, native, and web targets where the runtime provides DecompressionStream and CompressionStream; brotli, zstd, and other compression codes require custom codecs.

PmTilesArchive reads through ByteRangeSource, so callers can back archives with files, network requests, memory, or any other source that can return exact byte ranges.

fun ByteString.asByteRangeSource(): ByteRangeSource =
object : ByteRangeSource {
override suspend fun size(): ULong = this@asByteRangeSource.size.toULong()
override suspend fun read(range: ByteRange): ByteString {
val start = range.offset.toInt()
val length = range.length.toInt()
return this@asByteRangeSource.substring(start, start + length)
}
}

PmTiles.write writes to ByteSink, an append-only suspending output. Use this for files, network uploads, multipart object storage, or any other destination that accepts bytes in order.

class InMemoryByteSink : ByteSink {
private val chunks = mutableListOf<ByteString>()
var isFlushed = false
private set
var isClosed = false
private set
val bytes: ByteString
get() =
buildByteString(chunks.sumOf { it.size }) {
chunks.forEach { append(it.toByteArray()) }
}
override suspend fun write(bytes: ByteString) {
chunks += bytes
}
override suspend fun flush() {
isFlushed = true
}
override suspend fun close() {
isClosed = true
}
}

Open an archive with PmTiles.open, then read header fields, metadata, tile ranges, or tile payload bytes.

PmTiles.open(source).use { archive ->
val header = archive.header
val metadata = archive.metadata()
val tile = archive.readStoredTile(z = 0, x = 0, y = 0)
val tileRange = archive.findTileRange(z = 0, x = 0, y = 0)
}

Use readStoredTile to read tile payload bytes as stored in the archive, or readDecompressedTile to decompress supported tile payloads.

PmTiles.open(source).use { archive ->
val tile = archive.readDecompressedTile(z = 0, x = 0, y = 0)
}

Use readStoredTiles to read a batch of tile payloads with coalesced source ranges.

PmTiles.open(source).use { archive ->
val coords =
listOf(
TileCoord(z = 1, x = 0, y = 0),
TileCoord(z = 1, x = 0, y = 1),
)
val results = archive.readStoredTiles(coords)
}

Writer tile inputs are explicit: use stored tile input when payload bytes are already in the archive’s tile-compression format, or uncompressed tile input when a registered compressor should encode them before storage.

val tile =
ArchiveWriteTile.stored(
coord = TileCoord(z = 0, x = 0, y = 0),
payload = ByteString(0x89.toByte(), 0x50, 0x4e, 0x47),
)
val config = ArchiveWriteConfig.build {
tileType = TileTypeCodes.Png
metadataJson = """{"name":"demo"}"""
}
val archiveBytes =
PmTiles.writeToByteString(
tiles = listOf(tile),
config = config,
)

Spatial-K includes platform defaults for uncompressed data and gzip where available. Register other compression codecs, such as brotli or zstd, through ArchiveOpenOptions.

val options = ArchiveOpenOptions.build {
decompressor(CompressionCodes.Brotli) { bytes, limits ->
val decoded = decodeBrotli(bytes)
if (decoded.size.toULong() > limits.maxDecompressedBytes) {
throw PmTilesException(
PmTilesErrorCodes.LimitExceeded,
"Decoded output exceeds ${limits.maxDecompressedBytes} bytes.",
)
}
decoded
}
}
PmTiles.open(source, options).use { archive ->
val tile = archive.readDecompressedTile(z = 0, x = 0, y = 0)
}

Register writer-side compressors through ArchiveWriteOptions. The same compressor registry is used for internal sections and for tile payloads supplied as uncompressed input.

val options = ArchiveWriteOptions.build {
internalCompression = CompressionCodes.Brotli
compressor(CompressionCodes.Brotli) { bytes, limits ->
val encoded = encodeBrotli(bytes)
if (encoded.size.toULong() > limits.maxCompressedBytes) {
throw PmTilesException(
PmTilesErrorCodes.LimitExceeded,
"Encoded output exceeds ${limits.maxCompressedBytes} bytes.",
)
}
encoded
}
}
val archiveBytes =
PmTiles.writeToByteString(
tiles =
listOf(
ArchiveWriteTile.stored(
coord = TileCoord(z = 0, x = 0, y = 0),
payload = ByteString(1, 2, 3),
)
),
options = options,
)

Archives open in strict mode by default. ValidationMode.Lenient preserves recoverable issues as warnings, which can be inspected after opening or after metadata and tile lookups.

val options = ArchiveOpenOptions.build { validationMode = ValidationMode.Lenient }
PmTiles.open(source, options).use { archive ->
val warnings = archive.warnings
}