Skip to main content

mlt_py/
lib.rs

1mod encode;
2mod feature;
3mod tile_transform;
4
5use std::iter::once;
6use std::ops::Deref;
7
8use mlt_core::geo_types::{Geometry, LineString, Polygon};
9use mlt_core::geojson::FeatureCollection;
10use mlt_core::{
11    Decoder, GeometryType, Layer, LendingIterator, MltError, MltResult, ParsedLayer01, Parser,
12    PropValueRef,
13};
14use pyo3::exceptions::PyValueError;
15use pyo3::prelude::*;
16use pyo3::types::{PyBytes, PyDict};
17use pyo3_stub_gen::define_stub_info_gatherer;
18use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyfunction, gen_stub_pymethods};
19use tile_transform::TileTransform;
20
21use crate::feature::MltFeature;
22
23#[expect(clippy::needless_pass_by_value, reason = "helper util")]
24fn mlt_err(e: MltError) -> PyErr {
25    PyValueError::new_err(format!("MLT decode error: {e}"))
26}
27
28/// A decoded MLT layer containing features.
29#[gen_stub_pyclass]
30#[pyclass]
31struct MltLayer {
32    #[pyo3(get)]
33    name: String,
34    #[pyo3(get)]
35    extent: u32,
36    #[pyo3(get)]
37    features: Vec<Py<MltFeature>>,
38}
39
40#[gen_stub_pymethods]
41#[pymethods]
42impl MltLayer {
43    fn __repr__(&self) -> String {
44        format!(
45            "MltLayer(name={:?}, extent={}, features=<{} features>)",
46            self.name,
47            self.extent,
48            self.features.len()
49        )
50    }
51}
52
53fn push_coord_raw(buf: &mut Vec<u8>, coord: [i32; 2]) {
54    buf.extend_from_slice(&f64::from(coord[0]).to_le_bytes());
55    buf.extend_from_slice(&f64::from(coord[1]).to_le_bytes());
56}
57
58fn push_coord_xform(buf: &mut Vec<u8>, coord: [i32; 2], xf: TileTransform) {
59    let [x, y] = xf.apply(coord);
60    buf.extend_from_slice(&x.to_le_bytes());
61    buf.extend_from_slice(&y.to_le_bytes());
62}
63
64fn push_coord(buf: &mut Vec<u8>, coord: [i32; 2], xf: Option<TileTransform>) {
65    match xf {
66        Some(xf) => push_coord_xform(buf, coord, xf),
67        None => push_coord_raw(buf, coord),
68    }
69}
70
71fn push_u32(buf: &mut Vec<u8>, v: u32) {
72    buf.extend_from_slice(&v.to_le_bytes());
73}
74
75fn push_rings(
76    buf: &mut Vec<u8>,
77    rings: impl IntoIterator<Item = impl Deref<Target = LineString<i32>>>,
78    xf: Option<TileTransform>,
79) -> MltResult<()> {
80    for ring in rings {
81        let len = u32::try_from(ring.0.len()).map_err(|_| MltError::IntegerOverflow)?;
82        push_u32(buf, len);
83        for c in &ring.0 {
84            push_coord(buf, (*c).into(), xf);
85        }
86    }
87    Ok(())
88}
89
90fn push_linestring(
91    buf: &mut Vec<u8>,
92    line: impl Deref<Target = LineString<i32>>,
93    xf: Option<TileTransform>,
94) -> MltResult<()> {
95    buf.push(0x01);
96    push_u32(buf, 2);
97    push_rings(buf, once(line), xf)?;
98    Ok(())
99}
100
101fn push_polygon(
102    buf: &mut Vec<u8>,
103    poly: &Polygon<i32>,
104    xf: Option<TileTransform>,
105) -> MltResult<()> {
106    buf.push(0x01);
107    push_u32(buf, 3);
108    let len = u32::try_from(poly.interiors().len() + 1).map_err(|_| MltError::IntegerOverflow)?;
109    push_u32(buf, len);
110    push_rings(buf, once(poly.exterior()).chain(poly.interiors()), xf)?;
111    Ok(())
112}
113
114fn geom32_to_wkb(geom: &Geometry<i32>, xf: Option<TileTransform>) -> MltResult<Vec<u8>> {
115    let mut buf = Vec::with_capacity(128);
116    match geom {
117        Geometry::<i32>::Point(c) => {
118            buf.push(0x01);
119            push_u32(&mut buf, 1);
120            push_coord(&mut buf, (*c).into(), xf);
121        }
122        Geometry::<i32>::LineString(coords) => push_linestring(&mut buf, coords, xf)?,
123        Geometry::<i32>::Polygon(poly) => push_polygon(&mut buf, poly, xf)?,
124        Geometry::<i32>::MultiPoint(coords) => {
125            buf.push(0x01);
126            push_u32(&mut buf, 4);
127            let len = u32::try_from(coords.0.len()).map_err(|_| MltError::IntegerOverflow)?;
128            push_u32(&mut buf, len);
129            for c in &coords.0 {
130                buf.push(0x01);
131                push_u32(&mut buf, 1);
132                push_coord(&mut buf, (*c).into(), xf);
133            }
134        }
135        Geometry::<i32>::MultiLineString(lines) => {
136            buf.push(0x01);
137            push_u32(&mut buf, 5);
138            let len = u32::try_from(lines.0.len()).map_err(|_| MltError::IntegerOverflow)?;
139            push_u32(&mut buf, len);
140            for line in &lines.0 {
141                push_linestring(&mut buf, line, xf)?;
142            }
143        }
144        Geometry::<i32>::MultiPolygon(polygons) => {
145            buf.push(0x01);
146            push_u32(&mut buf, 6);
147            let len = u32::try_from(polygons.0.len()).map_err(|_| MltError::IntegerOverflow)?;
148            push_u32(&mut buf, len);
149            for polygon in &polygons.0 {
150                push_polygon(&mut buf, polygon, xf)?;
151            }
152        }
153        Geometry::Line(_)
154        | Geometry::GeometryCollection(_)
155        | Geometry::Rect(_)
156        | Geometry::Triangle(_) => {
157            return Err(MltError::NotImplemented("unsupported geometry type"));
158        }
159    }
160    Ok(buf)
161}
162
163fn prop_value_to_py(py: Python<'_>, v: PropValueRef<'_>) -> Py<PyAny> {
164    match v {
165        PropValueRef::Bool(b) => b.into_pyobject(py).unwrap().to_owned().into_any().unbind(),
166        PropValueRef::I8(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
167        PropValueRef::U8(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
168        PropValueRef::I32(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
169        PropValueRef::U32(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
170        PropValueRef::I64(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
171        PropValueRef::U64(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
172        PropValueRef::F32(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
173        PropValueRef::F64(n) => n.into_pyobject(py).unwrap().into_any().unbind(),
174        PropValueRef::Str(s) => s.into_pyobject(py).unwrap().into_any().unbind(),
175    }
176}
177
178fn build_features(
179    py: Python<'_>,
180    layer: &ParsedLayer01<'_>,
181    xf: Option<TileTransform>,
182) -> PyResult<Vec<Py<MltFeature>>> {
183    let mut features = Vec::new();
184    let mut feat_iter = layer.iter_features();
185    while let Some(feat_result) = feat_iter.next() {
186        let feat = feat_result.map_err(mlt_err)?;
187        let geometry_type = GeometryType::try_from(feat.geometry())
188            .map_or_else(|()| "Unknown".to_string(), |gt| gt.to_string());
189        let wkb_bytes = geom32_to_wkb(feat.geometry(), xf).map_err(mlt_err)?;
190        let wkb = PyBytes::new(py, &wkb_bytes).unbind();
191        let prop_dict = PyDict::new(py);
192        for p in feat.iter_properties() {
193            prop_dict.set_item(p.name().to_string(), prop_value_to_py(py, p.value()))?;
194        }
195        let feature = MltFeature::new(feat.id(), geometry_type, wkb, prop_dict.unbind());
196        features.push(Py::new(py, feature)?);
197    }
198    Ok(features)
199}
200
201/// Decode an MLT binary blob into a list of `MltLayer` objects.
202///
203/// If `z`, `x`, `y` are provided, tile-local coordinates are transformed
204/// to EPSG:3857 (Web Mercator) meters. Without them, raw tile coordinates
205/// are preserved.
206///
207/// `tms`: when True (the default), treat `y` as TMS convention (y=0 at south,
208/// used by `OpenMapTiles` / `MBTiles`). Set to False for XYZ / slippy-map tiles
209/// (y=0 at north, e.g. OSM raster tiles).
210#[gen_stub_pyfunction]
211#[pyfunction]
212#[pyo3(signature = (data, z=None, x=None, y=None, tms=true))]
213fn decode_mlt(
214    py: Python<'_>,
215    #[gen_stub(override_type(type_repr = "bytes"))] data: &[u8],
216    z: Option<u32>,
217    x: Option<u32>,
218    y: Option<u32>,
219    tms: bool,
220) -> PyResult<Vec<MltLayer>> {
221    let mut dec = Decoder::default();
222    let mut result = Vec::new();
223    for lazy_layer in Parser::default().parse_layers(data).map_err(mlt_err)? {
224        let Layer::Tag01(layer01) = lazy_layer else {
225            return Err(PyValueError::new_err(
226                "unsupported layer tag (expected 0x01)",
227            ));
228        };
229        let decoded = layer01.decode_all(&mut dec).map_err(mlt_err)?;
230        let extent = decoded.extent().get();
231        let xf = match (z, x, y) {
232            (Some(z), Some(x), Some(y)) => Some(TileTransform::from_zxy(z, x, y, extent, tms)?),
233            _ => None,
234        };
235        result.push(MltLayer {
236            name: decoded.name().to_string(),
237            extent,
238            features: build_features(py, &decoded, xf)?,
239        });
240    }
241
242    Ok(result)
243}
244
245/// Decode an MLT binary blob and return `GeoJSON` as a string.
246#[gen_stub_pyfunction]
247#[pyfunction]
248fn decode_mlt_to_geojson(
249    #[gen_stub(override_type(type_repr = "bytes"))] data: &[u8],
250) -> PyResult<String> {
251    let mut dec = Decoder::default();
252    let layers = dec
253        .decode_all(Parser::default().parse_layers(data).map_err(mlt_err)?)
254        .map_err(mlt_err)?;
255    let fc = FeatureCollection::from_layers(layers).map_err(mlt_err)?;
256    serde_json::to_string(&fc).map_err(|e| PyValueError::new_err(format!("JSON error: {e}")))
257}
258
259/// Return a list of layer names without fully decoding.
260#[gen_stub_pyfunction]
261#[pyfunction]
262fn list_layers(
263    #[gen_stub(override_type(type_repr = "bytes"))] data: &[u8],
264) -> PyResult<Vec<String>> {
265    let layers = Parser::default().parse_layers(data).map_err(mlt_err)?;
266    Ok(layers
267        .iter()
268        .filter_map(|l| l.as_layer01().map(|l| l.name().to_string()))
269        .collect())
270}
271
272#[pymodule]
273fn maplibre_tiles(m: &Bound<'_, PyModule>) -> PyResult<()> {
274    m.add_function(wrap_pyfunction!(decode_mlt, m)?)?;
275    m.add_function(wrap_pyfunction!(decode_mlt_to_geojson, m)?)?;
276    m.add_function(wrap_pyfunction!(list_layers, m)?)?;
277    m.add_function(wrap_pyfunction!(encode::geojson::encode_geojson, m)?)?;
278    m.add_function(wrap_pyfunction!(encode::mvt::encode_mvt, m)?)?;
279    m.add_class::<MltLayer>()?;
280    m.add_class::<MltFeature>()?;
281    Ok(())
282}
283
284define_stub_info_gatherer!(stub_info);
285
286#[cfg(test)]
287mod tests {
288    use std::f64::consts::PI;
289    use std::fs;
290
291    use mlt_core::{Decoder, GeometryValues};
292
293    use super::*;
294
295    fn geom_to_wkb(
296        geom: &GeometryValues,
297        index: usize,
298        xf: Option<TileTransform>,
299    ) -> MltResult<Vec<u8>> {
300        geom32_to_wkb(&geom.to_geojson(index)?, xf)
301    }
302
303    #[test]
304    fn tile_transform_rejects_zoom_above_30() {
305        let result = TileTransform::from_zxy(31, 0, 0, 4096, false);
306        assert!(result.is_err(), "z=31 should be rejected");
307
308        let result = TileTransform::from_zxy(30, 0, 0, 4096, false);
309        assert!(result.is_ok(), "z=30 should be accepted");
310
311        let result = TileTransform::from_zxy(0, 0, 0, 4096, false);
312        assert!(result.is_ok(), "z=0 should be accepted");
313    }
314
315    #[test]
316    fn tile_transform_zoom_zero_covers_world() {
317        let xf = TileTransform::from_zxy(0, 0, 0, 4096, false).unwrap();
318
319        let circumference = 2.0 * PI * 6_378_137.0;
320        let half = circumference / 2.0;
321
322        assert!(
323            (xf.x_origin + half).abs() < 1.0,
324            "x_origin at z=0 should be -half_circumference"
325        );
326        assert!(
327            (xf.y_origin - half).abs() < 1.0,
328            "y_origin at z=0 should be +half_circumference"
329        );
330
331        let tile_scale = circumference / 4096.0;
332        assert!(
333            (xf.x_scale - tile_scale).abs() < 1e-6,
334            "x_scale should equal circumference / extent"
335        );
336        assert!(
337            (xf.y_scale + tile_scale).abs() < 1e-6,
338            "y_scale should be negative (flipped)"
339        );
340    }
341
342    #[test]
343    fn tile_transform_apply_maps_origin_and_extent() {
344        let xf = TileTransform::from_zxy(0, 0, 0, 4096, false).unwrap();
345
346        let origin = xf.apply([0, 0]);
347        assert!(
348            (origin[0] - xf.x_origin).abs() < 1e-6,
349            "apply([0,0]).x should equal x_origin"
350        );
351        assert!(
352            (origin[1] - xf.y_origin).abs() < 1e-6,
353            "apply([0,0]).y should equal y_origin"
354        );
355
356        let far_corner = xf.apply([4096, 4096]);
357        let circumference = 2.0 * PI * 6_378_137.0;
358        let half = circumference / 2.0;
359        assert!(
360            (far_corner[0] - half).abs() < 1.0,
361            "apply([4096,4096]).x should reach +half"
362        );
363        assert!(
364            (far_corner[1] + half).abs() < 1.0,
365            "apply([4096,4096]).y should reach -half"
366        );
367    }
368
369    #[test]
370    fn tile_transform_tms_vs_xyz() {
371        let xyz = TileTransform::from_zxy(1, 0, 0, 4096, false).unwrap();
372        let tms = TileTransform::from_zxy(1, 0, 1, 4096, true).unwrap();
373
374        assert!(
375            (xyz.x_origin - tms.x_origin).abs() < 1e-6,
376            "same tile via TMS and XYZ should produce same x_origin"
377        );
378        assert!(
379            (xyz.y_origin - tms.y_origin).abs() < 1e-6,
380            "same tile via TMS and XYZ should produce same y_origin"
381        );
382    }
383
384    #[test]
385    fn fixture_parse_and_feature_collection() {
386        let fixture_path = "../../test/synthetic/0x01/point.mlt";
387        let data = fs::read(fixture_path)
388            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
389
390        let layers = Parser::default()
391            .parse_layers(&data)
392            .expect("parse_layers should succeed");
393        let mut dec = Decoder::default();
394        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
395
396        assert!(!decoded.is_empty(), "should parse at least one layer");
397        let l = decoded[0].as_layer01().expect("first layer should be v0.1");
398        assert!(!l.name().is_empty(), "layer name should be non-empty");
399
400        let fc = FeatureCollection::from_layers(decoded).expect("FeatureCollection should succeed");
401        assert!(
402            !fc.features.is_empty(),
403            "feature collection should have features"
404        );
405    }
406
407    #[test]
408    fn fixture_geom_to_wkb_produces_valid_output() {
409        let fixture_path = "../../test/synthetic/0x01/poly.mlt";
410        let data = fs::read(fixture_path)
411            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
412
413        let layers = Parser::default()
414            .parse_layers(&data)
415            .expect("parse_layers should succeed");
416        let mut dec = Decoder::default();
417        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
418
419        let l = decoded[0].as_layer01().expect("first layer should be v0.1");
420        let geom = l.geometry_values();
421
422        let wkb = geom_to_wkb(geom, 0, None).expect("geom_to_wkb should succeed");
423        assert!(
424            wkb.len() >= 5,
425            "WKB must be at least 5 bytes (byte order + type)"
426        );
427        assert_eq!(wkb[0], 0x01, "WKB byte order should be little-endian");
428        let wkb_type = u32::from_le_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]);
429        assert_eq!(
430            wkb_type, 3,
431            "polygon fixture should produce WKB type 3 (Polygon)"
432        );
433    }
434
435    #[test]
436    fn fixture_geom_to_wkb_with_transform() {
437        let fixture_path = "../../test/synthetic/0x01/point.mlt";
438        let data = fs::read(fixture_path)
439            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
440
441        let layers = Parser::default()
442            .parse_layers(&data)
443            .expect("parse_layers should succeed");
444        let mut dec = Decoder::default();
445        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
446
447        let l = decoded[0].as_layer01().expect("first layer should be v0.1");
448        let geom = l.geometry_values();
449
450        let xf = TileTransform::from_zxy(0, 0, 0, l.extent().get(), false).unwrap();
451
452        let wkb_raw = geom_to_wkb(geom, 0, None).expect("raw wkb should succeed");
453        let wkb_xf = geom_to_wkb(geom, 0, Some(xf)).expect("transformed wkb should succeed");
454
455        assert_eq!(
456            wkb_raw.len(),
457            wkb_xf.len(),
458            "raw and transformed WKB should have the same length"
459        );
460        assert_ne!(
461            wkb_raw, wkb_xf,
462            "transformed WKB should differ from raw (unless coordinates are trivially 0)"
463        );
464    }
465
466    #[test]
467    fn fixture_line_produces_wkb_linestring() {
468        let fixture_path = "../../test/synthetic/0x01/line.mlt";
469        let data = fs::read(fixture_path)
470            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
471
472        let layers = Parser::default()
473            .parse_layers(&data)
474            .expect("parse_layers should succeed");
475        let mut dec = Decoder::default();
476        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
477
478        let l = decoded[0].as_layer01().expect("first layer should be v0.1");
479        let geom = l.geometry_values();
480
481        let wkb = geom_to_wkb(geom, 0, None).expect("geom_to_wkb should succeed");
482        assert!(wkb.len() >= 5);
483        let wkb_type = u32::from_le_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]);
484        assert_eq!(
485            wkb_type, 2,
486            "line fixture should produce WKB type 2 (LineString)"
487        );
488    }
489}