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.
Installation
Section titled “Installation”commonMain { dependencies { implementation("org.maplibre.spatialk:pmtiles:0.8.0") }}dependencies { implementation("org.maplibre.spatialk:pmtiles-jvm:0.8.0")}Download SpatialKPmtiles.swiftpm.zip from the matching
GitHub release,
extract it, then add the extracted SpatialKPmtiles directory as a local Swift package in Xcode
or SwiftPM.
import SpatialKPmtilesSpecification coverage
Section titled “Specification coverage”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.
Byte range sources
Section titled “Byte range sources”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) } }final class DataByteRangeSource: ByteRangeDataSource { private let data: Data
init(data: Data) { self.data = data }
func size() async throws -> UInt64 { UInt64(data.count) }
func read(offset: UInt64, length: UInt64) async throws -> Data { let start = Int(offset) let end = start + Int(length) return data.subdata(in: start..<end) }}Byte sinks
Section titled “Byte sinks”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 }}final class InMemoryByteSink: ByteDataSink { private(set) var data = Data() private(set) var isFlushed = false private(set) var isClosed = false
func write(data: Data) async throws { self.data.append(data) }
func flush() async throws { isFlushed = true }
func close() async throws { isClosed = true }}Opening archives
Section titled “Opening archives”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)}let archive = try await PmTiles.open(source: source)defer { archive.close() }
let header = archive.headerlet metadata = try await archive.metadata()let tile = try await archive.readStoredTile(z: 0, x: 0, y: 0)let payload = tile?.payloadlet tileRange = try await 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)}let archive = try await PmTiles.open(source: source)defer { archive.close() }
let tile = try await 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)}let archive = try await PmTiles.open(source: source)defer { archive.close() }
let coords = [ try TileCoord(z: 1, x: 0, y: 0), try TileCoord(z: 1, x: 0, y: 1),]let results = try await archive.readStoredTiles( coords: coords )Writing archives
Section titled “Writing archives”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, )let tile = ArchiveWriteTile.stored( coord: try TileCoord(z: 0, x: 0, y: 0), data: Data([0x89, 0x50, 0x4E, 0x47]) )let config = ArchiveWriteConfig.build { config in config.setTileType(.png) config.metadataJson = #"{"name":"demo"}"# }
let archiveData = try await PmTiles.writeToData( tiles: [tile], config: config )Decompressors
Section titled “Decompressors”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)}final class BrotliDecompressor: DataDecompressor { func decompress( data: Data, limits: DecompressionLimits ) async throws -> Data { let decoded = decodeBrotli(data) if UInt64(decoded.count) > limits.maxDecompressedBytes { throw PmTilesException( code: PmTilesErrorCode.limitExceeded, message: "Decoded output exceeds \(limits.maxDecompressedBytes) bytes." ).asError() } return decoded }}
let options = ArchiveOpenOptions.build { options in options.decompressor(.brotli, BrotliDecompressor()) }
let archive = try await PmTiles.open(source: source, options: options)defer { archive.close() }
let tile = try await archive.readDecompressedTile(z: 0, x: 0, y: 0)Compressors
Section titled “Compressors”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, )final class PassthroughCompressor: DataCompressor { func compress( data: Data, limits: CompressionLimits ) async throws -> Data { if UInt64(data.count) > limits.maxCompressedBytes { throw PmTilesException( code: PmTilesErrorCode.limitExceeded, message: "Encoded output exceeds \(limits.maxCompressedBytes) bytes." ).asError() } return data }}
let options = ArchiveWriteOptions.build { options in options.setInternalCompression(.brotli) options.compressor(.brotli, PassthroughCompressor()) }
let archiveData = try await PmTiles.writeToData( tiles: [ ArchiveWriteTile.stored( coord: try TileCoord(z: 0, x: 0, y: 0), data: Data([1, 2, 3]) ) ], options: options )Validation
Section titled “Validation”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}let archive = try await PmTiles.open( source: source, options: ArchiveOpenOptions.build { options in options.validationMode = ValidationMode.lenient } )defer { archive.close() }
let warnings = archive.warnings