DavMelchi commited on
Commit
54690fe
·
1 Parent(s): e36a82b

Add KML to TAB converter

Browse files
app.py CHANGED
@@ -203,6 +203,11 @@ if check_password():
203
  title="Site & Sector KML Creator",
204
  icon=":material/map:",
205
  ),
 
 
 
 
 
206
  st.Page(
207
  "apps/clustering.py",
208
  title="Automatic Site Clustering",
 
203
  title="Site & Sector KML Creator",
204
  icon=":material/map:",
205
  ),
206
+ st.Page(
207
+ "apps/kml_to_tab_converter.py",
208
+ title="KML to TAB Converter",
209
+ icon=":material/conversion_path:",
210
+ ),
211
  st.Page(
212
  "apps/clustering.py",
213
  title="Automatic Site Clustering",
apps/kml_to_tab_converter.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ import streamlit as st
5
+
6
+ from utils.kml_to_tab import KmlToTabError, convert_kml_to_tab_zip
7
+
8
+
9
+ st.title(":material/conversion_path: KML to TAB Converter")
10
+
11
+ st.markdown(
12
+ "Upload a KML file and convert it to native MapInfo TAB tables. "
13
+ "The result is downloaded as a ZIP because each TAB table is made of "
14
+ "multiple files (`.tab`, `.dat`, `.map`, `.id`)."
15
+ )
16
+
17
+
18
+ def _timestamp() -> str:
19
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
20
+
21
+
22
+ def _find_sample_kml() -> Path | None:
23
+ for folder in (Path("samples"), Path("data2") / "sample"):
24
+ if not folder.exists():
25
+ continue
26
+ sample_files = sorted(folder.glob("*.kml"), key=lambda path: path.stat().st_mtime)
27
+ if sample_files:
28
+ return sample_files[-1]
29
+ return None
30
+
31
+
32
+ sample_kml = _find_sample_kml()
33
+ if sample_kml is not None:
34
+ with sample_kml.open("rb") as fh:
35
+ st.download_button(
36
+ "Download sample KML",
37
+ data=fh.read(),
38
+ file_name=sample_kml.name,
39
+ mime="application/vnd.google-earth.kml+xml",
40
+ )
41
+
42
+ uploaded_file = st.file_uploader("Upload KML file", type=["kml"])
43
+
44
+ if uploaded_file is None:
45
+ st.info("Upload a KML file to generate the MapInfo TAB ZIP.")
46
+ st.stop()
47
+
48
+ if st.button("Convert to TAB", type="primary"):
49
+ st.session_state.pop("kml_to_tab_result", None)
50
+ st.session_state.pop("kml_to_tab_source", None)
51
+ try:
52
+ with st.spinner("Converting KML to MapInfo TAB..."):
53
+ result = convert_kml_to_tab_zip(uploaded_file.getvalue(), uploaded_file.name)
54
+ st.session_state["kml_to_tab_result"] = result
55
+ st.session_state["kml_to_tab_source"] = Path(uploaded_file.name).stem
56
+ st.success("TAB export generated.")
57
+ except KmlToTabError as exc:
58
+ st.error(str(exc))
59
+ except Exception as exc: # pragma: no cover - UI safety net
60
+ st.error(f"Unexpected conversion error: {exc}")
61
+
62
+ result = st.session_state.get("kml_to_tab_result")
63
+ source_stem = st.session_state.get("kml_to_tab_source", "kml_to_tab")
64
+
65
+ if result:
66
+ st.subheader("Generated layers")
67
+ st.dataframe(
68
+ [
69
+ {
70
+ "Layer": layer.name,
71
+ "Geometry": layer.geometry_type,
72
+ "Features": layer.feature_count,
73
+ }
74
+ for layer in result.layers
75
+ ],
76
+ use_container_width=True,
77
+ hide_index=True,
78
+ )
79
+
80
+ with st.expander("Files in ZIP"):
81
+ st.write(result.files)
82
+
83
+ st.download_button(
84
+ "Download TAB ZIP",
85
+ data=result.zip_bytes,
86
+ file_name=f"{source_stem}_tab_{_timestamp()}.zip",
87
+ mime="application/zip",
88
+ )
requirements.txt CHANGED
Binary files a/requirements.txt and b/requirements.txt differ
 
tests/test_kml_to_tab.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import zipfile
2
+ from io import BytesIO
3
+
4
+ import pytest
5
+
6
+ from utils.kml_to_tab import KmlToTabError, convert_kml_to_tab_zip
7
+
8
+
9
+ MIXED_KML = b"""<?xml version="1.0" encoding="UTF-8"?>
10
+ <kml xmlns="http://www.opengis.net/kml/2.2">
11
+ <Document>
12
+ <Placemark>
13
+ <name>Site A</name>
14
+ <description>Point feature</description>
15
+ <Point>
16
+ <coordinates>50.606051,26.261322,0</coordinates>
17
+ </Point>
18
+ </Placemark>
19
+ <Placemark>
20
+ <name>Sector A</name>
21
+ <description>Polygon feature</description>
22
+ <Polygon>
23
+ <outerBoundaryIs>
24
+ <LinearRing>
25
+ <coordinates>
26
+ 50.606051,26.261322,0
27
+ 50.607051,26.261322,0
28
+ 50.607051,26.262322,0
29
+ 50.606051,26.261322,0
30
+ </coordinates>
31
+ </LinearRing>
32
+ </outerBoundaryIs>
33
+ </Polygon>
34
+ </Placemark>
35
+ </Document>
36
+ </kml>
37
+ """
38
+
39
+
40
+ def test_convert_kml_to_tab_zip_splits_mixed_geometries():
41
+ result = convert_kml_to_tab_zip(MIXED_KML, "mixed sample.kml")
42
+
43
+ with zipfile.ZipFile(BytesIO(result.zip_bytes)) as zf:
44
+ names = sorted(zf.namelist())
45
+
46
+ assert "mixed_sample_point.tab" in names
47
+ assert "mixed_sample_point.dat" in names
48
+ assert "mixed_sample_point.map" in names
49
+ assert "mixed_sample_point.id" in names
50
+ assert "mixed_sample_polygon.tab" in names
51
+ assert "mixed_sample_polygon.dat" in names
52
+ assert "mixed_sample_polygon.map" in names
53
+ assert "mixed_sample_polygon.id" in names
54
+ assert [(layer.geometry_type, layer.feature_count) for layer in result.layers] == [
55
+ ("Point", 1),
56
+ ("Polygon", 1),
57
+ ]
58
+
59
+
60
+ def test_convert_kml_to_tab_zip_rejects_empty_input():
61
+ with pytest.raises(KmlToTabError, match="vide"):
62
+ convert_kml_to_tab_zip(b"", "empty.kml")
utils/kml_to_tab.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import re
5
+ import struct
6
+ import tempfile
7
+ import zipfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ class KmlToTabError(RuntimeError):
13
+ """Raised when a KML file cannot be converted to MapInfo TAB."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TabLayerSummary:
18
+ name: str
19
+ geometry_type: str
20
+ feature_count: int
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TabConversionResult:
25
+ zip_bytes: bytes
26
+ layers: list[TabLayerSummary]
27
+ files: list[str]
28
+
29
+
30
+ _WKB_GEOMETRY_TYPES = {
31
+ 1: "Point",
32
+ 2: "LineString",
33
+ 3: "Polygon",
34
+ 4: "MultiPoint",
35
+ 5: "MultiLineString",
36
+ 6: "MultiPolygon",
37
+ 7: "GeometryCollection",
38
+ }
39
+
40
+
41
+ def convert_kml_to_tab_zip(kml_bytes: bytes, source_name: str) -> TabConversionResult:
42
+ """Convert one KML file to native MapInfo TAB tables packed into a ZIP."""
43
+
44
+ if not kml_bytes:
45
+ raise KmlToTabError("Le fichier KML est vide.")
46
+
47
+ pyogrio = _import_pyogrio()
48
+ base_name = _safe_stem(source_name)
49
+
50
+ with tempfile.TemporaryDirectory(prefix="kml_to_tab_") as tmp_dir_name:
51
+ tmp_dir = Path(tmp_dir_name)
52
+ input_path = tmp_dir / f"{base_name}.kml"
53
+ output_dir = tmp_dir / "tab_output"
54
+ output_dir.mkdir()
55
+ input_path.write_bytes(kml_bytes)
56
+
57
+ try:
58
+ metadata, _fids, geometry, field_data = pyogrio.raw.read(
59
+ input_path,
60
+ force_2d=True,
61
+ )
62
+ except Exception as exc: # pragma: no cover - exact GDAL errors vary
63
+ raise KmlToTabError(f"Lecture du KML impossible: {exc}") from exc
64
+
65
+ if geometry is None or len(geometry) == 0:
66
+ raise KmlToTabError("Aucune geometrie exploitable trouvee dans le KML.")
67
+
68
+ fields = metadata.get("fields")
69
+ crs = metadata.get("crs") or "EPSG:4326"
70
+ geometry_groups = _group_geometry_indexes(geometry)
71
+ layers: list[TabLayerSummary] = []
72
+
73
+ for geometry_type, indexes in geometry_groups.items():
74
+ layer_name = _layer_name(base_name, geometry_type, len(geometry_groups))
75
+ output_path = output_dir / f"{layer_name}.tab"
76
+ layer_geometry = geometry[indexes]
77
+ layer_field_data = [column[indexes] for column in field_data]
78
+
79
+ try:
80
+ pyogrio.raw.write(
81
+ output_path,
82
+ layer_geometry,
83
+ layer_field_data,
84
+ fields,
85
+ driver="MapInfo File",
86
+ geometry_type=geometry_type,
87
+ crs=crs,
88
+ encoding=metadata.get("encoding") or "UTF-8",
89
+ )
90
+ except Exception as exc: # pragma: no cover - exact GDAL errors vary
91
+ raise KmlToTabError(
92
+ f"Ecriture du TAB impossible pour la couche {layer_name}: {exc}"
93
+ ) from exc
94
+
95
+ layers.append(
96
+ TabLayerSummary(
97
+ name=layer_name,
98
+ geometry_type=geometry_type,
99
+ feature_count=len(indexes),
100
+ )
101
+ )
102
+
103
+ files = sorted(path.name for path in output_dir.iterdir() if path.is_file())
104
+ return TabConversionResult(
105
+ zip_bytes=_zip_directory(output_dir),
106
+ layers=layers,
107
+ files=files,
108
+ )
109
+
110
+
111
+ def _import_pyogrio():
112
+ try:
113
+ import pyogrio
114
+ except ImportError as exc: # pragma: no cover - depends on environment
115
+ raise KmlToTabError(
116
+ "La conversion TAB requiert pyogrio/GDAL. "
117
+ "Installe les dependances avec `pip install -r requirements.txt`."
118
+ ) from exc
119
+ return pyogrio
120
+
121
+
122
+ def _safe_stem(filename: str) -> str:
123
+ stem = Path(filename or "converted").stem
124
+ stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_")
125
+ return (stem or "converted")[:60]
126
+
127
+
128
+ def _layer_name(base_name: str, geometry_type: str, group_count: int) -> str:
129
+ if group_count == 1:
130
+ return base_name
131
+ suffix = re.sub(r"(?<!^)(?=[A-Z])", "_", geometry_type).lower()
132
+ return f"{base_name}_{suffix}"[:80]
133
+
134
+
135
+ def _group_geometry_indexes(geometry) -> dict[str, list[int]]:
136
+ groups: dict[str, list[int]] = {}
137
+
138
+ for index, wkb in enumerate(geometry):
139
+ geometry_type = _wkb_geometry_type(wkb)
140
+ groups.setdefault(geometry_type, []).append(index)
141
+
142
+ unsupported = sorted(
143
+ geometry_type
144
+ for geometry_type in groups
145
+ if geometry_type == "GeometryCollection"
146
+ )
147
+ if unsupported:
148
+ raise KmlToTabError(
149
+ "Les GeometryCollection ne sont pas supportees pour l'export TAB."
150
+ )
151
+
152
+ return groups
153
+
154
+
155
+ def _wkb_geometry_type(wkb: bytes) -> str:
156
+ if not wkb or len(wkb) < 5:
157
+ raise KmlToTabError("Geometrie WKB invalide dans le KML.")
158
+
159
+ endian = "<" if wkb[0] == 1 else ">"
160
+ raw_code = struct.unpack(f"{endian}I", wkb[1:5])[0]
161
+ base_code = raw_code & 0xFF
162
+ geometry_type = _WKB_GEOMETRY_TYPES.get(base_code)
163
+
164
+ if geometry_type is None:
165
+ raise KmlToTabError(f"Type de geometrie non supporte: WKB {raw_code}.")
166
+
167
+ return geometry_type
168
+
169
+
170
+ def _zip_directory(directory: Path) -> bytes:
171
+ zip_buffer = io.BytesIO()
172
+ with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
173
+ for path in sorted(directory.iterdir()):
174
+ if path.is_file():
175
+ zf.write(path, arcname=path.name)
176
+ return zip_buffer.getvalue()