@dunguel/expo-geo-parser 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/expo/modules/geoparser/parsers/KMLParser.kt +122 -25
- package/build/ExpoGeoParser.types.d.ts +31 -6
- package/build/ExpoGeoParser.types.d.ts.map +1 -1
- package/build/ExpoGeoParser.types.js.map +1 -1
- package/ios/ExpoGeoParserModule.swift +3 -0
- package/ios/Parsers/KMLParser.swift +133 -18
- package/package.json +1 -1
- package/src/ExpoGeoParser.types.ts +49 -7
|
@@ -28,6 +28,9 @@ class KMLParser(
|
|
|
28
28
|
private var currentFeatureName = ""
|
|
29
29
|
private var currentFeatureDescription = ""
|
|
30
30
|
private var currentFeatureStyleUrl = ""
|
|
31
|
+
private var currentExtendedData = linkedMapOf<String, Any?>()
|
|
32
|
+
private var currentExtendedDataName = ""
|
|
33
|
+
private var inExtendedData = false
|
|
31
34
|
private var currentGeometryType: String? = null
|
|
32
35
|
|
|
33
36
|
private var pointCoord = listOf<Double>()
|
|
@@ -93,9 +96,14 @@ class KMLParser(
|
|
|
93
96
|
currentFeatureName = ""
|
|
94
97
|
currentFeatureDescription = ""
|
|
95
98
|
currentFeatureStyleUrl = ""
|
|
99
|
+
currentExtendedData = linkedMapOf()
|
|
100
|
+
currentExtendedDataName = ""
|
|
101
|
+
inExtendedData = false
|
|
96
102
|
currentGeometryType = null
|
|
97
103
|
multiGeometries = mutableListOf()
|
|
98
104
|
}
|
|
105
|
+
"ExtendedData" -> if (inPlacemark) inExtendedData = true
|
|
106
|
+
"Data", "SimpleData" -> if (inPlacemark && inExtendedData) currentExtendedDataName = attrs?.getValue("name") ?: ""
|
|
99
107
|
"Point" -> { currentGeometryType = "Point"; pointCoord = listOf() }
|
|
100
108
|
"LineString" -> { currentGeometryType = "LineString"; lineCoords = listOf() }
|
|
101
109
|
"LinearRing" -> currentRing = listOf()
|
|
@@ -117,18 +125,33 @@ class KMLParser(
|
|
|
117
125
|
override fun endElement(uri: String?, localName: String?, qName: String?) {
|
|
118
126
|
val name = stripped(localName ?: qName ?: "")
|
|
119
127
|
val text = textBuffer.toString().trim()
|
|
128
|
+
val parent = elementStack.dropLast(1).lastOrNull() ?: ""
|
|
120
129
|
textBuffer.clear()
|
|
121
130
|
if (elementStack.isNotEmpty()) elementStack.removeAt(elementStack.lastIndex)
|
|
122
131
|
|
|
123
132
|
when (name) {
|
|
124
133
|
"Document" -> documentDepth--
|
|
125
134
|
"name" -> when {
|
|
126
|
-
inPlacemark -> currentFeatureName = text
|
|
127
|
-
|
|
135
|
+
inPlacemark && parent == "Placemark" -> currentFeatureName = text
|
|
136
|
+
isCollectionContainer(parent) && !documentMetaCaptured -> {
|
|
137
|
+
documentName = text
|
|
138
|
+
documentMetaCaptured = true
|
|
139
|
+
}
|
|
128
140
|
}
|
|
129
141
|
"description" -> when {
|
|
130
|
-
inPlacemark -> currentFeatureDescription = text
|
|
131
|
-
|
|
142
|
+
inPlacemark && parent == "Placemark" -> currentFeatureDescription = text
|
|
143
|
+
isCollectionContainer(parent) && documentDescription.isEmpty() -> documentDescription = text
|
|
144
|
+
}
|
|
145
|
+
"ExtendedData" -> inExtendedData = false
|
|
146
|
+
"Data" -> currentExtendedDataName = ""
|
|
147
|
+
"value" -> if (inPlacemark && inExtendedData && currentExtendedDataName.isNotEmpty()) {
|
|
148
|
+
currentExtendedData[currentExtendedDataName] = parsePropertyValue(text)
|
|
149
|
+
}
|
|
150
|
+
"SimpleData" -> {
|
|
151
|
+
if (inPlacemark && inExtendedData && currentExtendedDataName.isNotEmpty()) {
|
|
152
|
+
currentExtendedData[currentExtendedDataName] = parsePropertyValue(text)
|
|
153
|
+
}
|
|
154
|
+
currentExtendedDataName = ""
|
|
132
155
|
}
|
|
133
156
|
"Style" -> { currentStyleId?.let { styles[it] = buildingStyle }; currentStyleId = null }
|
|
134
157
|
"StyleMap" -> { inStyleMap = false; currentStyleMapId = null }
|
|
@@ -158,7 +181,6 @@ class KMLParser(
|
|
|
158
181
|
"scale" -> if (inIconStyle) text.toDoubleOrNull()?.let { buildingStyle.iconScale = it }
|
|
159
182
|
"coordinates" -> {
|
|
160
183
|
val coords = parseCoordinates(text)
|
|
161
|
-
val parent = elementStack.lastOrNull() ?: ""
|
|
162
184
|
when (parent) {
|
|
163
185
|
"Point" -> pointCoord = coords.firstOrNull() ?: listOf()
|
|
164
186
|
"LineString" -> lineCoords = coords
|
|
@@ -195,6 +217,9 @@ class KMLParser(
|
|
|
195
217
|
return if (idx >= 0) name.substring(idx + 1) else name
|
|
196
218
|
}
|
|
197
219
|
|
|
220
|
+
private fun isCollectionContainer(name: String): Boolean =
|
|
221
|
+
name == "Document" || name == "Folder"
|
|
222
|
+
|
|
198
223
|
private fun kmlColorToCSS(kml: String): String {
|
|
199
224
|
val s = kml.trim().lowercase()
|
|
200
225
|
if (s.length != 8) return "#000000"
|
|
@@ -209,6 +234,16 @@ class KMLParser(
|
|
|
209
234
|
if (parts.size >= 2) parts else null
|
|
210
235
|
}
|
|
211
236
|
|
|
237
|
+
private fun parsePropertyValue(text: String): Any {
|
|
238
|
+
val trimmed = text.trim()
|
|
239
|
+
return when {
|
|
240
|
+
trimmed.equals("true", ignoreCase = true) -> true
|
|
241
|
+
trimmed.equals("false", ignoreCase = true) -> false
|
|
242
|
+
NUMERIC_SCALAR.matches(trimmed) -> trimmed.toDouble()
|
|
243
|
+
else -> trimmed
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
212
247
|
private fun buildGeometry(type: String): Map<String, Any?>? = when (type) {
|
|
213
248
|
"Point" -> if (pointCoord.isNotEmpty()) mapOf("type" to "Point", "coordinates" to pointCoord) else null
|
|
214
249
|
"LineString" -> if (lineCoords.isNotEmpty()) mapOf("type" to "LineString", "coordinates" to lineCoords) else null
|
|
@@ -227,17 +262,14 @@ class KMLParser(
|
|
|
227
262
|
}
|
|
228
263
|
|
|
229
264
|
private fun finalizeFeature() {
|
|
230
|
-
val geometry: Map<String, Any?> = if (currentGeometryType == "MultiGeometry") {
|
|
231
|
-
if (multiGeometries.isEmpty()) return
|
|
232
|
-
mapOf("type" to "GeometryCollection", "geometries" to multiGeometries.toList())
|
|
233
|
-
} else {
|
|
234
|
-
val gType = currentGeometryType ?: return
|
|
235
|
-
buildGeometry(gType) ?: return
|
|
236
|
-
}
|
|
237
|
-
|
|
238
265
|
val properties = mutableMapOf<String, Any?>()
|
|
239
266
|
if (currentFeatureName.isNotEmpty()) properties["name"] = currentFeatureName
|
|
240
|
-
|
|
267
|
+
properties.putAll(currentExtendedData)
|
|
268
|
+
if (currentFeatureDescription.isNotEmpty()) {
|
|
269
|
+
val extracted = extractDescriptionAttributes(currentFeatureDescription)
|
|
270
|
+
extracted.attributes.forEach { (key, value) -> properties.putIfAbsent(key, value) }
|
|
271
|
+
extracted.description?.let { properties["description"] = it }
|
|
272
|
+
}
|
|
241
273
|
if (currentFeatureStyleUrl.isNotEmpty()) {
|
|
242
274
|
properties["styleId"] = currentFeatureStyleUrl
|
|
243
275
|
resolveStyle(currentFeatureStyleUrl)?.takeIf { !it.isEmpty }?.let {
|
|
@@ -245,19 +277,76 @@ class KMLParser(
|
|
|
245
277
|
}
|
|
246
278
|
}
|
|
247
279
|
|
|
248
|
-
val
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
280
|
+
val geometries: List<Map<String, Any?>> = if (currentGeometryType == "MultiGeometry") {
|
|
281
|
+
if (multiGeometries.isEmpty()) return
|
|
282
|
+
multiGeometries.toList()
|
|
283
|
+
} else {
|
|
284
|
+
val gType = currentGeometryType ?: return
|
|
285
|
+
listOf(buildGeometry(gType) ?: return)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
geometries.forEachIndexed { index, geometry ->
|
|
289
|
+
val feature = mutableMapOf<String, Any?>(
|
|
290
|
+
"type" to "Feature",
|
|
291
|
+
"geometry" to geometry,
|
|
292
|
+
"properties" to properties
|
|
293
|
+
)
|
|
294
|
+
splitFeatureId(currentFeatureId, index, geometries.size)?.let { feature["id"] = it }
|
|
295
|
+
|
|
296
|
+
featureBuffer.add(feature)
|
|
297
|
+
if (featureBuffer.size >= batchSize) {
|
|
298
|
+
onFeatures(featureBuffer.toList(), false)
|
|
299
|
+
featureBuffer.clear()
|
|
300
|
+
}
|
|
259
301
|
}
|
|
260
302
|
}
|
|
303
|
+
|
|
304
|
+
private fun splitFeatureId(baseId: String?, index: Int, total: Int): String? {
|
|
305
|
+
if (baseId.isNullOrEmpty()) return null
|
|
306
|
+
if (total <= 1) return baseId
|
|
307
|
+
return "${baseId}_${index + 1}"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun extractDescriptionAttributes(description: String): ExtractedDescription {
|
|
311
|
+
val textDescription = stripHtml(description)
|
|
312
|
+
if (!description.contains("<table") || !description.contains("<th") || !description.contains("<td")) {
|
|
313
|
+
return ExtractedDescription(linkedMapOf(), textDescription.ifEmpty { null })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
val attributes = linkedMapOf<String, Any?>()
|
|
317
|
+
for (match in HTML_ATTRIBUTE_ROW.findAll(description)) {
|
|
318
|
+
val key = stripHtml(match.groupValues[1])
|
|
319
|
+
if (key.isEmpty()) continue
|
|
320
|
+
val value = stripHtml(match.groupValues[2])
|
|
321
|
+
attributes[key] = parsePropertyValue(value)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
val cleanedDescription =
|
|
325
|
+
if (textDescription.isNotEmpty() && !(attributes.isNotEmpty() && textDescription == "Attributes")) {
|
|
326
|
+
textDescription
|
|
327
|
+
} else {
|
|
328
|
+
null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return ExtractedDescription(attributes, cleanedDescription)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private fun stripHtml(value: String): String =
|
|
335
|
+
value
|
|
336
|
+
.replace(HTML_TAG, " ")
|
|
337
|
+
.replace(" ", " ")
|
|
338
|
+
.replace("&", "&")
|
|
339
|
+
.replace("<", "<")
|
|
340
|
+
.replace(">", ">")
|
|
341
|
+
.replace(""", "\"")
|
|
342
|
+
.replace("'", "'")
|
|
343
|
+
.replace(WHITESPACE, " ")
|
|
344
|
+
.trim()
|
|
345
|
+
|
|
346
|
+
private data class ExtractedDescription(
|
|
347
|
+
val attributes: LinkedHashMap<String, Any?>,
|
|
348
|
+
val description: String?
|
|
349
|
+
)
|
|
261
350
|
}
|
|
262
351
|
|
|
263
352
|
private data class StyleInfo(
|
|
@@ -281,3 +370,11 @@ private data class StyleInfo(
|
|
|
281
370
|
return d
|
|
282
371
|
}
|
|
283
372
|
}
|
|
373
|
+
|
|
374
|
+
private val HTML_TAG = Regex("<[^>]+>")
|
|
375
|
+
private val HTML_ATTRIBUTE_ROW = Regex(
|
|
376
|
+
"<th[^>]*>\\s*(.*?)\\s*</th>\\s*<td[^>]*>\\s*(.*?)\\s*</td>",
|
|
377
|
+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
|
378
|
+
)
|
|
379
|
+
private val WHITESPACE = Regex("\\s+")
|
|
380
|
+
private val NUMERIC_SCALAR = Regex("^-?(0|[1-9]\\d*)(\\.\\d+)?$")
|
|
@@ -6,17 +6,42 @@ export type File = {
|
|
|
6
6
|
type: GeoFileType;
|
|
7
7
|
uti?: string | null;
|
|
8
8
|
};
|
|
9
|
+
export type Position = number[];
|
|
10
|
+
export type Point = {
|
|
11
|
+
type: "Point";
|
|
12
|
+
coordinates: Position;
|
|
13
|
+
};
|
|
14
|
+
export type LineString = {
|
|
15
|
+
type: "LineString";
|
|
16
|
+
coordinates: Position[];
|
|
17
|
+
};
|
|
18
|
+
export type Polygon = {
|
|
19
|
+
type: "Polygon";
|
|
20
|
+
coordinates: Position[][];
|
|
21
|
+
};
|
|
22
|
+
export type MultiPoint = {
|
|
23
|
+
type: "MultiPoint";
|
|
24
|
+
coordinates: Position[];
|
|
25
|
+
};
|
|
26
|
+
export type MultiLineString = {
|
|
27
|
+
type: "MultiLineString";
|
|
28
|
+
coordinates: Position[][];
|
|
29
|
+
};
|
|
30
|
+
export type MultiPolygon = {
|
|
31
|
+
type: "MultiPolygon";
|
|
32
|
+
coordinates: Position[][][];
|
|
33
|
+
};
|
|
34
|
+
export type GeometryCollection = {
|
|
35
|
+
type: "GeometryCollection";
|
|
36
|
+
geometries: Geometry[];
|
|
37
|
+
};
|
|
38
|
+
export type Geometry = Point | LineString | Polygon | MultiPoint | MultiLineString | MultiPolygon | GeometryCollection;
|
|
9
39
|
export type Feature = {
|
|
10
40
|
type: "Feature";
|
|
11
|
-
id?: string
|
|
41
|
+
id?: string;
|
|
12
42
|
geometry: Geometry;
|
|
13
43
|
properties: Record<string, unknown>;
|
|
14
44
|
};
|
|
15
|
-
export type Geometry = {
|
|
16
|
-
type: string;
|
|
17
|
-
coordinates?: number[][];
|
|
18
|
-
geometries?: Geometry[];
|
|
19
|
-
};
|
|
20
45
|
export type FeatureCollection = {
|
|
21
46
|
type: "FeatureCollection";
|
|
22
47
|
name?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoGeoParser.types.d.ts","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAEjF,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,EAAE,
|
|
1
|
+
{"version":3,"file":"ExpoGeoParser.types.d.ts","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAEjF,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB,CAAC;AAIF,MAAM,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC;AAEhC,MAAM,MAAM,KAAK,GAAG;IAClB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,QAAQ,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,QAAQ,EAAE,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,WAAW,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,QAAQ,EAAE,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,iBAAiB,CAAC;IACxB,WAAW,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,WAAW,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,oBAAoB,CAAC;IAC3B,UAAU,EAAE,QAAQ,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAChB,KAAK,GACL,UAAU,GACV,OAAO,GACP,UAAU,GACV,eAAe,GACf,YAAY,GACZ,kBAAkB,CAAC;AAEvB,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,WAAW,CAAC;IACzB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoGeoParser.types.js","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"","sourcesContent":["export type GeoFileType = \"kml\" | \"kmz\" | \"zip\" | \"geojson\" | \"json\" | \"unknown\";\n\nexport type File = {\n uri: string;\n fileName?: string | null;\n extension?: string | null;\n type: GeoFileType;\n uti?: string | null;\n};\n\nexport type
|
|
1
|
+
{"version":3,"file":"ExpoGeoParser.types.js","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"","sourcesContent":["export type GeoFileType = \"kml\" | \"kmz\" | \"zip\" | \"geojson\" | \"json\" | \"unknown\";\n\nexport type File = {\n uri: string;\n fileName?: string | null;\n extension?: string | null;\n type: GeoFileType;\n uti?: string | null;\n};\n\n// GeoJSON-compliant geometry types (RFC 7946)\n\nexport type Position = number[]; // [longitude, latitude, ?altitude]\n\nexport type Point = {\n type: \"Point\";\n coordinates: Position;\n};\n\nexport type LineString = {\n type: \"LineString\";\n coordinates: Position[];\n};\n\nexport type Polygon = {\n type: \"Polygon\";\n coordinates: Position[][];\n};\n\nexport type MultiPoint = {\n type: \"MultiPoint\";\n coordinates: Position[];\n};\n\nexport type MultiLineString = {\n type: \"MultiLineString\";\n coordinates: Position[][];\n};\n\nexport type MultiPolygon = {\n type: \"MultiPolygon\";\n coordinates: Position[][][];\n};\n\nexport type GeometryCollection = {\n type: \"GeometryCollection\";\n geometries: Geometry[];\n};\n\nexport type Geometry =\n | Point\n | LineString\n | Polygon\n | MultiPoint\n | MultiLineString\n | MultiPolygon\n | GeometryCollection;\n\nexport type Feature = {\n type: \"Feature\";\n id?: string;\n geometry: Geometry;\n properties: Record<string, unknown>;\n};\n\nexport type FeatureCollection = {\n type: \"FeatureCollection\";\n name?: string;\n description?: string;\n sourceType?: GeoFileType;\n features: Feature[];\n};\n\nexport type ParseFeaturesEvent = {\n features: Feature[];\n isLast: boolean;\n};\n"]}
|
|
@@ -84,6 +84,9 @@ private extension ExpoGeoParserModule {
|
|
|
84
84
|
throw GeoParserError.invalidURI(uri)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
let accessing = url.startAccessingSecurityScopedResource()
|
|
88
|
+
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
|
89
|
+
|
|
87
90
|
let info = detectFileType(from: url)
|
|
88
91
|
|
|
89
92
|
if info.type == "kmz" || info.type == "zip" {
|
|
@@ -19,6 +19,9 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
19
19
|
private var currentFeatureName = ""
|
|
20
20
|
private var currentFeatureDescription = ""
|
|
21
21
|
private var currentFeatureStyleUrl = ""
|
|
22
|
+
private var currentExtendedData: [String: Any] = [:]
|
|
23
|
+
private var currentExtendedDataName = ""
|
|
24
|
+
private var inExtendedData = false
|
|
22
25
|
private var currentGeometryType: String?
|
|
23
26
|
|
|
24
27
|
private var pointCoord: [Double] = []
|
|
@@ -92,8 +95,15 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
92
95
|
currentFeatureName = ""
|
|
93
96
|
currentFeatureDescription = ""
|
|
94
97
|
currentFeatureStyleUrl = ""
|
|
98
|
+
currentExtendedData = [:]
|
|
99
|
+
currentExtendedDataName = ""
|
|
100
|
+
inExtendedData = false
|
|
95
101
|
currentGeometryType = nil
|
|
96
102
|
multiGeometries = []
|
|
103
|
+
case "ExtendedData":
|
|
104
|
+
if inPlacemark { inExtendedData = true }
|
|
105
|
+
case "Data", "SimpleData":
|
|
106
|
+
if inPlacemark && inExtendedData { currentExtendedDataName = attributeDict["name"] ?? "" }
|
|
97
107
|
case "Point": currentGeometryType = "Point"; pointCoord = []
|
|
98
108
|
case "LineString": currentGeometryType = "LineString"; lineCoords = []
|
|
99
109
|
case "LinearRing": currentRing = []
|
|
@@ -124,16 +134,30 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
124
134
|
) {
|
|
125
135
|
let name = stripped(elementName)
|
|
126
136
|
let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
137
|
+
let parent = elementStack.dropLast().last ?? ""
|
|
127
138
|
defer { textBuffer = ""; if !elementStack.isEmpty { elementStack.removeLast() } }
|
|
128
139
|
|
|
129
140
|
switch name {
|
|
130
141
|
case "Document": documentDepth -= 1
|
|
131
142
|
case "name":
|
|
132
|
-
if inPlacemark { currentFeatureName = text }
|
|
133
|
-
else if
|
|
143
|
+
if inPlacemark && parent == "Placemark" { currentFeatureName = text }
|
|
144
|
+
else if isCollectionContainer(parent) && !documentMetaCaptured { documentName = text; documentMetaCaptured = true }
|
|
134
145
|
case "description":
|
|
135
|
-
if inPlacemark { currentFeatureDescription = text }
|
|
136
|
-
else if
|
|
146
|
+
if inPlacemark && parent == "Placemark" { currentFeatureDescription = text }
|
|
147
|
+
else if isCollectionContainer(parent) && documentDescription.isEmpty { documentDescription = text }
|
|
148
|
+
case "ExtendedData":
|
|
149
|
+
inExtendedData = false
|
|
150
|
+
case "Data":
|
|
151
|
+
currentExtendedDataName = ""
|
|
152
|
+
case "value":
|
|
153
|
+
if inPlacemark && inExtendedData && !currentExtendedDataName.isEmpty {
|
|
154
|
+
currentExtendedData[currentExtendedDataName] = parsePropertyValue(text)
|
|
155
|
+
}
|
|
156
|
+
case "SimpleData":
|
|
157
|
+
if inPlacemark && inExtendedData && !currentExtendedDataName.isEmpty {
|
|
158
|
+
currentExtendedData[currentExtendedDataName] = parsePropertyValue(text)
|
|
159
|
+
}
|
|
160
|
+
currentExtendedDataName = ""
|
|
137
161
|
case "Style":
|
|
138
162
|
if let id = currentStyleId { styles[id] = buildingStyle }
|
|
139
163
|
currentStyleId = nil
|
|
@@ -161,7 +185,6 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
161
185
|
case "scale": if inIconStyle, let s = Double(text) { buildingStyle.iconScale = s }
|
|
162
186
|
case "coordinates":
|
|
163
187
|
let coords = parseCoordinates(text)
|
|
164
|
-
let parent = elementStack.dropLast().last ?? ""
|
|
165
188
|
switch parent {
|
|
166
189
|
case "Point": pointCoord = coords.first ?? []
|
|
167
190
|
case "LineString": lineCoords = coords
|
|
@@ -189,6 +212,10 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
189
212
|
return name
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
private func isCollectionContainer(_ name: String) -> Bool {
|
|
216
|
+
name == "Document" || name == "Folder"
|
|
217
|
+
}
|
|
218
|
+
|
|
192
219
|
private func kmlColorToCSS(_ kml: String) -> String {
|
|
193
220
|
let s = kml.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
194
221
|
guard s.count == 8 else { return "#000000" }
|
|
@@ -205,6 +232,24 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
205
232
|
}
|
|
206
233
|
}
|
|
207
234
|
|
|
235
|
+
private func parsePropertyValue(_ text: String) -> Any {
|
|
236
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
237
|
+
let lowercased = trimmed.lowercased()
|
|
238
|
+
|
|
239
|
+
if lowercased == "true" { return true }
|
|
240
|
+
if lowercased == "false" { return false }
|
|
241
|
+
if isNumericScalar(trimmed), let number = Double(trimmed) {
|
|
242
|
+
return number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return trimmed
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func isNumericScalar(_ text: String) -> Bool {
|
|
249
|
+
guard !text.isEmpty else { return false }
|
|
250
|
+
return text.range(of: #"^-?(0|[1-9]\d*)(\.\d+)?$"#, options: .regularExpression) != nil
|
|
251
|
+
}
|
|
252
|
+
|
|
208
253
|
private func buildGeometry(type: String) -> [String: Any]? {
|
|
209
254
|
switch type {
|
|
210
255
|
case "Point":
|
|
@@ -232,29 +277,99 @@ final class KMLParser: NSObject, XMLParserDelegate {
|
|
|
232
277
|
}
|
|
233
278
|
|
|
234
279
|
private func finalizeFeature() {
|
|
235
|
-
|
|
280
|
+
var properties: [String: Any] = [:]
|
|
281
|
+
if !currentFeatureName.isEmpty { properties["name"] = currentFeatureName }
|
|
282
|
+
for (key, value) in currentExtendedData {
|
|
283
|
+
properties[key] = value
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if !currentFeatureDescription.isEmpty {
|
|
287
|
+
let extracted = extractDescriptionAttributes(from: currentFeatureDescription)
|
|
288
|
+
for (key, value) in extracted.attributes where properties[key] == nil {
|
|
289
|
+
properties[key] = value
|
|
290
|
+
}
|
|
291
|
+
if let description = extracted.description {
|
|
292
|
+
properties["description"] = description
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if !currentFeatureStyleUrl.isEmpty {
|
|
297
|
+
properties["styleId"] = currentFeatureStyleUrl
|
|
298
|
+
if let style = resolveStyle(url: currentFeatureStyleUrl), !style.isEmpty {
|
|
299
|
+
properties["style"] = style.asDictionary()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let geometries: [[String: Any]]
|
|
236
304
|
if currentGeometryType == "MultiGeometry" {
|
|
237
305
|
guard !multiGeometries.isEmpty else { return }
|
|
238
|
-
|
|
306
|
+
geometries = multiGeometries
|
|
239
307
|
} else if let gType = currentGeometryType, let geom = buildGeometry(type: gType) {
|
|
240
|
-
|
|
308
|
+
geometries = [geom]
|
|
241
309
|
} else {
|
|
242
310
|
return
|
|
243
311
|
}
|
|
244
312
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
properties["styleId"] = currentFeatureStyleUrl
|
|
250
|
-
if let style = resolveStyle(url: currentFeatureStyleUrl), !style.isEmpty {
|
|
251
|
-
properties["style"] = style.asDictionary()
|
|
313
|
+
for (index, geometry) in geometries.enumerated() {
|
|
314
|
+
var feature: [String: Any] = ["type": "Feature", "geometry": geometry, "properties": properties]
|
|
315
|
+
if let id = splitFeatureId(baseId: currentFeatureId, index: index, total: geometries.count) {
|
|
316
|
+
feature["id"] = id
|
|
252
317
|
}
|
|
318
|
+
features.append(feature)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private func splitFeatureId(baseId: String?, index: Int, total: Int) -> String? {
|
|
323
|
+
guard let baseId, !baseId.isEmpty else { return nil }
|
|
324
|
+
guard total > 1 else { return baseId }
|
|
325
|
+
return "\(baseId)_\(index + 1)"
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private func extractDescriptionAttributes(from description: String) -> (attributes: [String: Any], description: String?) {
|
|
329
|
+
let textDescription = stripHTML(description)
|
|
330
|
+
guard description.contains("<table"), description.contains("<th"), description.contains("<td") else {
|
|
331
|
+
return ([:], textDescription.isEmpty ? nil : textDescription)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
guard let regex = try? NSRegularExpression(
|
|
335
|
+
pattern: #"<th[^>]*>\s*(.*?)\s*</th>\s*<td[^>]*>\s*(.*?)\s*</td>"#,
|
|
336
|
+
options: [.caseInsensitive, .dotMatchesLineSeparators]
|
|
337
|
+
) else {
|
|
338
|
+
return ([:], textDescription.isEmpty ? nil : textDescription)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let nsDescription = description as NSString
|
|
342
|
+
let matches = regex.matches(in: description, range: NSRange(location: 0, length: nsDescription.length))
|
|
343
|
+
|
|
344
|
+
var attributes: [String: Any] = [:]
|
|
345
|
+
for match in matches where match.numberOfRanges >= 3 {
|
|
346
|
+
let key = stripHTML(nsDescription.substring(with: match.range(at: 1)))
|
|
347
|
+
if key.isEmpty { continue }
|
|
348
|
+
let value = stripHTML(nsDescription.substring(with: match.range(at: 2)))
|
|
349
|
+
attributes[key] = parsePropertyValue(value)
|
|
253
350
|
}
|
|
254
351
|
|
|
255
|
-
|
|
256
|
-
if
|
|
257
|
-
|
|
352
|
+
let cleanedDescription: String?
|
|
353
|
+
if !textDescription.isEmpty && !(matches.isEmpty == false && textDescription == "Attributes") {
|
|
354
|
+
cleanedDescription = textDescription
|
|
355
|
+
} else {
|
|
356
|
+
cleanedDescription = nil
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return (attributes, cleanedDescription)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private func stripHTML(_ value: String) -> String {
|
|
363
|
+
value
|
|
364
|
+
.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
|
|
365
|
+
.replacingOccurrences(of: " ", with: " ")
|
|
366
|
+
.replacingOccurrences(of: "&", with: "&")
|
|
367
|
+
.replacingOccurrences(of: "<", with: "<")
|
|
368
|
+
.replacingOccurrences(of: ">", with: ">")
|
|
369
|
+
.replacingOccurrences(of: """, with: "\"")
|
|
370
|
+
.replacingOccurrences(of: "'", with: "'")
|
|
371
|
+
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
|
372
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
258
373
|
}
|
|
259
374
|
}
|
|
260
375
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dunguel/expo-geo-parser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A high-performance Expo native module for parsing geospatial files like KML, KMZ, and other formats, extracting polygons, markers, and spatial data efficiently on iOS and Android.",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -8,19 +8,61 @@ export type File = {
|
|
|
8
8
|
uti?: string | null;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
// GeoJSON-compliant geometry types (RFC 7946)
|
|
12
|
+
|
|
13
|
+
export type Position = number[]; // [longitude, latitude, ?altitude]
|
|
14
|
+
|
|
15
|
+
export type Point = {
|
|
16
|
+
type: "Point";
|
|
17
|
+
coordinates: Position;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LineString = {
|
|
21
|
+
type: "LineString";
|
|
22
|
+
coordinates: Position[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Polygon = {
|
|
26
|
+
type: "Polygon";
|
|
27
|
+
coordinates: Position[][];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MultiPoint = {
|
|
31
|
+
type: "MultiPoint";
|
|
32
|
+
coordinates: Position[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MultiLineString = {
|
|
36
|
+
type: "MultiLineString";
|
|
37
|
+
coordinates: Position[][];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type MultiPolygon = {
|
|
41
|
+
type: "MultiPolygon";
|
|
42
|
+
coordinates: Position[][][];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type GeometryCollection = {
|
|
46
|
+
type: "GeometryCollection";
|
|
47
|
+
geometries: Geometry[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type Geometry =
|
|
51
|
+
| Point
|
|
52
|
+
| LineString
|
|
53
|
+
| Polygon
|
|
54
|
+
| MultiPoint
|
|
55
|
+
| MultiLineString
|
|
56
|
+
| MultiPolygon
|
|
57
|
+
| GeometryCollection;
|
|
58
|
+
|
|
11
59
|
export type Feature = {
|
|
12
60
|
type: "Feature";
|
|
13
|
-
id?: string
|
|
61
|
+
id?: string;
|
|
14
62
|
geometry: Geometry;
|
|
15
63
|
properties: Record<string, unknown>;
|
|
16
64
|
};
|
|
17
65
|
|
|
18
|
-
export type Geometry = {
|
|
19
|
-
type: string;
|
|
20
|
-
coordinates?: number[][];
|
|
21
|
-
geometries?: Geometry[];
|
|
22
|
-
};
|
|
23
|
-
|
|
24
66
|
export type FeatureCollection = {
|
|
25
67
|
type: "FeatureCollection";
|
|
26
68
|
name?: string;
|