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.name().map(str::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, ParsedLayer};
292
293    use super::*;
294
295    fn first_layer01<'a, 'b>(layers: &'b [ParsedLayer<'a>]) -> &'b ParsedLayer01<'a> {
296        let ParsedLayer::Tag01(l) = &layers[0] else {
297            panic!("first layer should be 0x01")
298        };
299        l
300    }
301
302    fn geom_to_wkb(
303        geom: &GeometryValues,
304        index: usize,
305        xf: Option<TileTransform>,
306    ) -> MltResult<Vec<u8>> {
307        geom32_to_wkb(&geom.to_geojson(index)?, xf)
308    }
309
310    #[test]
311    fn tile_transform_rejects_zoom_above_30() {
312        let result = TileTransform::from_zxy(31, 0, 0, 4096, false);
313        assert!(result.is_err(), "z=31 should be rejected");
314
315        let result = TileTransform::from_zxy(30, 0, 0, 4096, false);
316        assert!(result.is_ok(), "z=30 should be accepted");
317
318        let result = TileTransform::from_zxy(0, 0, 0, 4096, false);
319        assert!(result.is_ok(), "z=0 should be accepted");
320    }
321
322    #[test]
323    fn tile_transform_zoom_zero_covers_world() {
324        let xf = TileTransform::from_zxy(0, 0, 0, 4096, false).unwrap();
325
326        let circumference = 2.0 * PI * 6_378_137.0;
327        let half = circumference / 2.0;
328
329        assert!(
330            (xf.x_origin + half).abs() < 1.0,
331            "x_origin at z=0 should be -half_circumference"
332        );
333        assert!(
334            (xf.y_origin - half).abs() < 1.0,
335            "y_origin at z=0 should be +half_circumference"
336        );
337
338        let tile_scale = circumference / 4096.0;
339        assert!(
340            (xf.x_scale - tile_scale).abs() < 1e-6,
341            "x_scale should equal circumference / extent"
342        );
343        assert!(
344            (xf.y_scale + tile_scale).abs() < 1e-6,
345            "y_scale should be negative (flipped)"
346        );
347    }
348
349    #[test]
350    fn tile_transform_apply_maps_origin_and_extent() {
351        let xf = TileTransform::from_zxy(0, 0, 0, 4096, false).unwrap();
352
353        let origin = xf.apply([0, 0]);
354        assert!(
355            (origin[0] - xf.x_origin).abs() < 1e-6,
356            "apply([0,0]).x should equal x_origin"
357        );
358        assert!(
359            (origin[1] - xf.y_origin).abs() < 1e-6,
360            "apply([0,0]).y should equal y_origin"
361        );
362
363        let far_corner = xf.apply([4096, 4096]);
364        let circumference = 2.0 * PI * 6_378_137.0;
365        let half = circumference / 2.0;
366        assert!(
367            (far_corner[0] - half).abs() < 1.0,
368            "apply([4096,4096]).x should reach +half"
369        );
370        assert!(
371            (far_corner[1] + half).abs() < 1.0,
372            "apply([4096,4096]).y should reach -half"
373        );
374    }
375
376    #[test]
377    fn tile_transform_tms_vs_xyz() {
378        let xyz = TileTransform::from_zxy(1, 0, 0, 4096, false).unwrap();
379        let tms = TileTransform::from_zxy(1, 0, 1, 4096, true).unwrap();
380
381        assert!(
382            (xyz.x_origin - tms.x_origin).abs() < 1e-6,
383            "same tile via TMS and XYZ should produce same x_origin"
384        );
385        assert!(
386            (xyz.y_origin - tms.y_origin).abs() < 1e-6,
387            "same tile via TMS and XYZ should produce same y_origin"
388        );
389    }
390
391    #[test]
392    fn fixture_parse_and_feature_collection() {
393        let fixture_path = "../../test/synthetic/0x01/point.mlt";
394        let data = fs::read(fixture_path)
395            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
396
397        let layers = Parser::default()
398            .parse_layers(&data)
399            .expect("parse_layers should succeed");
400        let mut dec = Decoder::default();
401        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
402
403        assert!(!decoded.is_empty(), "should parse at least one layer");
404        let l = first_layer01(&decoded);
405        assert!(!l.name().is_empty(), "layer name should be non-empty");
406
407        let fc = FeatureCollection::from_layers(decoded).expect("FeatureCollection should succeed");
408        assert!(
409            !fc.features.is_empty(),
410            "feature collection should have features"
411        );
412    }
413
414    #[test]
415    fn fixture_geom_to_wkb_produces_valid_output() {
416        let fixture_path = "../../test/synthetic/0x01/poly.mlt";
417        let data = fs::read(fixture_path)
418            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
419
420        let layers = Parser::default()
421            .parse_layers(&data)
422            .expect("parse_layers should succeed");
423        let mut dec = Decoder::default();
424        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
425
426        let l = first_layer01(&decoded);
427        let geom = l.geometry_values();
428
429        let wkb = geom_to_wkb(geom, 0, None).expect("geom_to_wkb should succeed");
430        assert!(
431            wkb.len() >= 5,
432            "WKB must be at least 5 bytes (byte order + type)"
433        );
434        assert_eq!(wkb[0], 0x01, "WKB byte order should be little-endian");
435        let wkb_type = u32::from_le_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]);
436        assert_eq!(
437            wkb_type, 3,
438            "polygon fixture should produce WKB type 3 (Polygon)"
439        );
440    }
441
442    #[test]
443    fn fixture_geom_to_wkb_with_transform() {
444        let fixture_path = "../../test/synthetic/0x01/point.mlt";
445        let data = fs::read(fixture_path)
446            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
447
448        let layers = Parser::default()
449            .parse_layers(&data)
450            .expect("parse_layers should succeed");
451        let mut dec = Decoder::default();
452        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
453
454        let l = first_layer01(&decoded);
455        let geom = l.geometry_values();
456
457        let xf = TileTransform::from_zxy(0, 0, 0, l.extent().get(), false).unwrap();
458
459        let wkb_raw = geom_to_wkb(geom, 0, None).expect("raw wkb should succeed");
460        let wkb_xf = geom_to_wkb(geom, 0, Some(xf)).expect("transformed wkb should succeed");
461
462        assert_eq!(
463            wkb_raw.len(),
464            wkb_xf.len(),
465            "raw and transformed WKB should have the same length"
466        );
467        assert_ne!(
468            wkb_raw, wkb_xf,
469            "transformed WKB should differ from raw (unless coordinates are trivially 0)"
470        );
471    }
472
473    #[test]
474    fn fixture_line_produces_wkb_linestring() {
475        let fixture_path = "../../test/synthetic/0x01/line.mlt";
476        let data = fs::read(fixture_path)
477            .unwrap_or_else(|e| panic!("failed to read fixture {fixture_path}: {e}"));
478
479        let layers = Parser::default()
480            .parse_layers(&data)
481            .expect("parse_layers should succeed");
482        let mut dec = Decoder::default();
483        let decoded = dec.decode_all(layers).expect("decode_all should succeed");
484
485        let l = first_layer01(&decoded);
486        let geom = l.geometry_values();
487
488        let wkb = geom_to_wkb(geom, 0, None).expect("geom_to_wkb should succeed");
489        assert!(wkb.len() >= 5);
490        let wkb_type = u32::from_le_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]);
491        assert_eq!(
492            wkb_type, 2,
493            "line fixture should produce WKB type 2 (LineString)"
494        );
495    }
496}