@dunguel/expo-geo-parser 0.1.1 → 0.4.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.
@@ -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
- documentDepth > 0 && !documentMetaCaptured -> { documentName = text; documentMetaCaptured = true }
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
- documentDepth > 0 && documentDescription.isEmpty() -> documentDescription = text
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
- if (currentFeatureDescription.isNotEmpty()) properties["description"] = currentFeatureDescription
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 feature = mutableMapOf<String, Any?>(
249
- "type" to "Feature",
250
- "geometry" to geometry,
251
- "properties" to properties
252
- )
253
- currentFeatureId?.takeIf { it.isNotEmpty() }?.let { feature["id"] = it }
254
-
255
- featureBuffer.add(feature)
256
- if (featureBuffer.size >= batchSize) {
257
- onFeatures(featureBuffer.toList(), false)
258
- featureBuffer.clear()
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("&nbsp;", " ")
338
+ .replace("&amp;", "&")
339
+ .replace("&lt;", "<")
340
+ .replace("&gt;", ">")
341
+ .replace("&quot;", "\"")
342
+ .replace("&#39;", "'")
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+)?$")
@@ -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 documentDepth > 0 && !documentMetaCaptured { documentName = text; documentMetaCaptured = true }
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 documentDepth > 0 && documentDescription.isEmpty { documentDescription = text }
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
- let geometry: [String: Any]
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
- geometry = ["type": "GeometryCollection", "geometries": multiGeometries]
306
+ geometries = multiGeometries
239
307
  } else if let gType = currentGeometryType, let geom = buildGeometry(type: gType) {
240
- geometry = geom
308
+ geometries = [geom]
241
309
  } else {
242
310
  return
243
311
  }
244
312
 
245
- var properties: [String: Any] = [:]
246
- if !currentFeatureName.isEmpty { properties["name"] = currentFeatureName }
247
- if !currentFeatureDescription.isEmpty { properties["description"] = currentFeatureDescription }
248
- if !currentFeatureStyleUrl.isEmpty {
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
- var feature: [String: Any] = ["type": "Feature", "geometry": geometry, "properties": properties]
256
- if let id = currentFeatureId, !id.isEmpty { feature["id"] = id }
257
- features.append(feature)
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: "&nbsp;", with: " ")
366
+ .replacingOccurrences(of: "&amp;", with: "&")
367
+ .replacingOccurrences(of: "&lt;", with: "<")
368
+ .replacingOccurrences(of: "&gt;", with: ">")
369
+ .replacingOccurrences(of: "&quot;", with: "\"")
370
+ .replacingOccurrences(of: "&#39;", 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.1.1",
3
+ "version": "0.4.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",