diff --git a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
index a12f5eb..e328461 100644
--- a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
+++ b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
@@ -4,7 +4,7 @@ import CoreLocation
import ForeFlightKML
enum KMLGenerator {
- static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> Data? {
+ static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> BuildResult {
let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo")
let centerCoordinate = Coordinate(latitude: center.latitude, longitude: center.longitude)
@@ -23,7 +23,7 @@ enum KMLGenerator {
radiusMeters: radiusMeters * 2,
style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3)))
- return try builder.buildKMZ()
+ return try builder.build(as: .kmz)
}
diff --git a/Example/ForeFlightKMLDemo/Views/ContentView.swift b/Example/ForeFlightKMLDemo/Views/ContentView.swift
index 80f2ab4..fda51f8 100644
--- a/Example/ForeFlightKMLDemo/Views/ContentView.swift
+++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import MapKit
+import ForeFlightKML
struct ContentView: View {
@State private var lastTapCoordinate: CLLocationCoordinate2D?
@@ -49,14 +50,14 @@ struct ContentView: View {
private func handleMapTap(_ coord: CLLocationCoordinate2D) {
lastTapCoordinate = coord
do {
- let kmz = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
+ let buildResult = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
- let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kmz")
-
- try kmz?.write(to: tmpURL)
+ let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).\(buildResult.fileExtension)")
+
+ try buildResult.data.write(to: tmpURL)
kmlToShareURL = tmpURL
showingShare = true
} catch {
diff --git a/Package.swift b/Package.swift
index 71dbf62..03bc596 100644
--- a/Package.swift
+++ b/Package.swift
@@ -28,6 +28,10 @@ let package = Package(
.testTarget(
name: "ForeFlightKMLTests",
dependencies: ["ForeFlightKML"]
+ ),
+ .testTarget(
+ name: "UserMapShapeTests",
+ dependencies: ["ForeFlightKML"]
)
]
)
diff --git a/README.md b/README.md
index 01277ac..eafd548 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ This package provides a small, focused API for composing KML/KMZ documents suita
## Example Output
Using the example given on the [ForeFlight website](https://foreflight.com/support/user-map-shapes/) the below is generated using this Framework.
-See `/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift`
+See `/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift`
@@ -32,6 +32,7 @@ import ForeFlightKML
import Geodesy (used for coordinates)
let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ")
+
builder.addLine(
name: "Runway 15-33",
coordinates: [Coordinate(latitude:, longitude:),Coordinate(latitude:, longitude:)],
@@ -45,8 +46,10 @@ builder.addLineCircle(
PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
)
-let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes.kml")
-try builder.build().write(to: url, atomically: true, encoding: .utf8)
+let buildResult = try builder.build(as: .kmz)
+
+let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes\(buildResult.fileExtension)")
+try buildResult.data.write(to: tmpURL)
presentShareSheet(with: url)
```
@@ -59,7 +62,7 @@ presentShareSheet(with: url)
`ForeFlightKMLBuilder` is the builder for the KML/KMZ document.
- Document name can be set on `init` or with `setDocumentName()`
- Elements can be manually added using `addPlacemark(_:)`
- - The output is accessed by: for KML `try builder.build()` or for KMZ: `try builder.buildKMZ()`
+ - The output is accessed by `try builder.build()`
### KMLBuilder Convenience Elements
- `addPoint` Add a point with style.
@@ -73,9 +76,16 @@ presentShareSheet(with: url)
- `addLabel` Add a text-only label placemark at a coordinate.
### ForeflightKMLBuilder Export formats
-- `kml String` via `builder.build()`
-- `kml Data` via `builder.kmlData()`
-- `kmz Data` via `builder.buildKMZ()`
+Type `BuildResult` contains:
+```
+ data: Data
+ fileExtension: String
+ mimetype: String
+```
+Specific data access:
+- `kml Data` via `builder.build(as: .kml)`
+- `kmz Data` via `builder.build(as: .kmz)`
+- `kml String` via `builder.kmlString()` note: this can be unsafe and should only be used for debugging
- KMZ (zipped KML) is required when using custom icons or using labelBadge (which uses a transparent .png under the hood).
### Underlying elements
@@ -96,7 +106,7 @@ Full public API surface is visible in the package sources.
## Demo & tests
-The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests.
+The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit and end to end example tests.
## Contributing
diff --git a/Sources/ForeFlightKML/Enums/OutputFormat.swift b/Sources/ForeFlightKML/Enums/OutputFormat.swift
new file mode 100644
index 0000000..5ead170
--- /dev/null
+++ b/Sources/ForeFlightKML/Enums/OutputFormat.swift
@@ -0,0 +1,4 @@
+public enum OutputFormat {
+ case kml
+ case kmz
+}
diff --git a/Sources/ForeFlightKML/Errors/BuildError.swift b/Sources/ForeFlightKML/Errors/BuildError.swift
new file mode 100644
index 0000000..967a09b
--- /dev/null
+++ b/Sources/ForeFlightKML/Errors/BuildError.swift
@@ -0,0 +1,19 @@
+enum BuildError: Error, Equatable {
+ case missingAssetsForKMZ
+ case unsupportedFeatureForKML
+ case emptyArchive
+ case internalError(underlying: Error)
+
+ static func == (lhs: BuildError, rhs: BuildError) -> Bool {
+ switch (lhs, rhs) {
+ case (.missingAssetsForKMZ, .missingAssetsForKMZ): return true
+ case (.unsupportedFeatureForKML, .unsupportedFeatureForKML): return true
+ case (.emptyArchive, .emptyArchive): return true
+ case (.internalError, .internalError):
+ // Consider any internalError equal regardless of underlying Error
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/Sources/ForeFlightKML/Errors/KMZExportError.swift b/Sources/ForeFlightKML/Errors/ExportError.swift
similarity index 71%
rename from Sources/ForeFlightKML/Errors/KMZExportError.swift
rename to Sources/ForeFlightKML/Errors/ExportError.swift
index ba3e86e..2fd7800 100644
--- a/Sources/ForeFlightKML/Errors/KMZExportError.swift
+++ b/Sources/ForeFlightKML/Errors/ExportError.swift
@@ -1,4 +1,4 @@
-public enum KMZExportError: Error {
+public enum ExportError: Error {
case kmzRequired
case missingLocalResource(String)
case archiveCreationFailed
diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift
index 445d6b2..4c2a830 100644
--- a/Sources/ForeFlightKML/ForeFlightKML.swift
+++ b/Sources/ForeFlightKML/ForeFlightKML.swift
@@ -4,7 +4,7 @@ import GeodesySpherical
/// A Builder for composing a KML Document (styles + placemarks).
/// Use this to create KML documents compatible with ForeFlight's User Map Shapes feature.
///
-public final class ForeFlightKMLBuilder {
+public final class ForeFlightKMLBuilder: Building {
/// Optional name for the `` element.
private var documentName: String?
/// Collection of placemarks added to this builder.
@@ -57,12 +57,30 @@ public final class ForeFlightKMLBuilder {
/// Generate the complete KML string for this document.
/// This method can be called multiple times - it doesn't modify the builder state.
/// - Returns: A complete KML document as a UTF-8 string ready for export to ForeFlight
- public func build() throws -> String {
- guard !requiresKMZ else {
- throw KMZExportError.kmzRequired
+ public func build(as format: OutputFormat = .kmz) throws -> BuildResult {
+ if format == .kml, requiresKMZ {
+ throw BuildError.unsupportedFeatureForKML
}
- return kmlString()
+ switch format {
+ case .kml:
+ let data = buildKML()
+ return BuildResult(
+ data: data,
+ fileExtension: "kml",
+ mimetype: "application/vnd.google-earth.kml+xml"
+ )
+
+ case .kmz:
+ guard let data = try buildKMZ() else {
+ throw BuildError.emptyArchive
+ }
+ return BuildResult(
+ data: data,
+ fileExtension: "kmz",
+ mimetype: "application/vnd.google-earth.kmz"
+ )
+ }
}
/// Produce the full KML string for this document.
@@ -91,7 +109,7 @@ public final class ForeFlightKMLBuilder {
/// Produce the KML document as `Data` using the given text encoding.
/// - Parameter encoding: The `String.Encoding` to use when converting the KML string into data.
/// - Returns: `Data` containing the encoded KML, or an empty `Data` if encoding fails.
- public func kmlData(encoding: String.Encoding = .utf8) -> Data {
+ public func buildKML(encoding: String.Encoding = .utf8) -> Data {
return kmlString().data(using: encoding) ?? Data()
}
diff --git a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
index 300dac6..46cacdc 100644
--- a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
+++ b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
@@ -6,7 +6,7 @@ public extension ForeFlightKMLBuilder {
/// Build a KMZ (ZIP) containing doc.kml and any required local assets.
func buildKMZ() throws -> Data? {
- let kmlData = kmlData()
+ let kmlData = buildKML()
let archive = try Archive(accessMode: .create)
@@ -38,7 +38,7 @@ private extension ForeFlightKMLBuilder {
let bundle = Bundle.module
guard let iconURL = bundle.url(forResource: "1x1", withExtension: "png") else {
- throw KMZExportError.missingLocalResource("1x1.png")
+ throw ExportError.missingLocalResource("1x1.png")
}
try archive.addEntry(
diff --git a/Sources/ForeFlightKML/Utils/DataFormat.swift b/Sources/ForeFlightKML/Utils/DataFormat.swift
new file mode 100644
index 0000000..bf80777
--- /dev/null
+++ b/Sources/ForeFlightKML/Utils/DataFormat.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+public struct BuildResult {
+ public let data: Data
+ public let fileExtension: String
+ public let mimetype: String
+}
diff --git a/Sources/ForeFlightKML/Utils/Protocols.swift b/Sources/ForeFlightKML/Utils/Protocols.swift
index 3ea966d..cfa6ff8 100644
--- a/Sources/ForeFlightKML/Utils/Protocols.swift
+++ b/Sources/ForeFlightKML/Utils/Protocols.swift
@@ -26,3 +26,7 @@ public protocol KMLSubStyle {
// These produce the inner element tags (..., ...).
func kmlString() -> String
}
+
+protocol Building {
+ func build(as format: OutputFormat) throws -> BuildResult
+}
diff --git a/Tests/ForeFlightKMLTests/Builder/ForeFlightKML+BuildTests.swift b/Tests/ForeFlightKMLTests/Builder/ForeFlightKML+BuildTests.swift
new file mode 100644
index 0000000..f95b2e6
--- /dev/null
+++ b/Tests/ForeFlightKMLTests/Builder/ForeFlightKML+BuildTests.swift
@@ -0,0 +1,92 @@
+import XCTest
+import GeodesySpherical
+@testable import ForeFlightKML
+
+final class ForeFlightKMLBuildTests: XCTestCase {
+
+ func test_build_asKMZ_returnsExpectedMetadataAndHasDocKML() throws {
+ let builder = ForeFlightKMLBuilder()
+ builder.addPoint(
+ name: "Normal",
+ coordinate: Coordinate(latitude: 51.0, longitude: -1.0),
+ altitude: 0,
+ style: PointStyle(icon: .custom(type: .square, color: .white, scale: 1.0))
+ )
+
+ let result = try builder.build(as: .kmz)
+ XCTAssertEqual(result.fileExtension, "kmz")
+ XCTAssertEqual(result.mimetype, "application/vnd.google-earth.kmz")
+ // Quick sanity check that archive contains doc.kml
+ let archive = try ArchiveDataVerifier.archive(from: result.data)
+ XCTAssertNotNil(archive["doc.kml"], "KMZ must contain doc.kml at root")
+ }
+
+ func test_build_asKML_whenNoKMZRequired_succeedsWithMetadata() throws {
+ let builder = ForeFlightKMLBuilder()
+ // Use a simple predefined icon that should not require local assets
+ builder.addPoint(
+ name: "Simple",
+ coordinate: Coordinate(latitude: 0, longitude: 0)
+ )
+
+ XCTAssertFalse(builder.requiresKMZ)
+ let result = try builder.build(as: .kml)
+ XCTAssertEqual(result.fileExtension, "kml")
+ XCTAssertEqual(result.mimetype, "application/vnd.google-earth.kml+xml")
+ // Resulting data should decode as UTF-8 and contain KML root
+ let kml = String(data: result.data, encoding: .utf8)
+ XCTAssertNotNil(kml)
+ XCTAssertTrue(kml?.contains("My Doc"))
+ }
+
+ func test_clear_resetsState_counts_and_requiresKMZ() {
+ let builder = ForeFlightKMLBuilder()
+ builder.addPoint(name: "p", coordinate: Coordinate(latitude: 0, longitude: 0))
+ builder.addLabel("Badge", coordinate: Coordinate(latitude: 1, longitude: 1), color: .warning)
+
+ XCTAssertGreaterThan(builder.placemarkCount, 0)
+ XCTAssertGreaterThan(builder.styleCount, 0)
+ XCTAssertTrue(builder.requiresKMZ)
+
+ builder.clear()
+
+ XCTAssertEqual(builder.placemarkCount, 0)
+ XCTAssertEqual(builder.styleCount, 0)
+ XCTAssertFalse(builder.requiresKMZ)
+ }
+}
+
+// MARK: - Local helper
+import ZIPFoundation
+private enum ArchiveDataVerifier {
+ static func archive(from data: Data) throws -> Archive {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("kmz")
+ try data.write(to: url)
+ return try Archive(url: url, accessMode: .read)
+ }
+}
diff --git a/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift b/Tests/ForeFlightKMLTests/Builder/ForeFlightKML+KMZTests.swift
similarity index 100%
rename from Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift
rename to Tests/ForeFlightKMLTests/Builder/ForeFlightKML+KMZTests.swift
diff --git a/Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift b/Tests/ForeFlightKMLTests/Builder/ForeFlightKMLTests.swift
similarity index 100%
rename from Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift
rename to Tests/ForeFlightKMLTests/Builder/ForeFlightKMLTests.swift
diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift
index a9b0e83..1f7ceea 100644
--- a/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift
+++ b/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift
@@ -97,4 +97,23 @@ final class PolygonTests: XCTestCase {
XCTAssertFalse(kml.contains(""))
}
+
+ func test_polygon_withInnerRings_producesInnerBoundary() {
+ let builder = ForeFlightKMLBuilder()
+ let outer = [
+ Coordinate(latitude: 0, longitude: 0),
+ Coordinate(latitude: 0, longitude: 1),
+ Coordinate(latitude: 1, longitude: 1)
+ ]
+ let hole = [
+ Coordinate(latitude: 0.2, longitude: 0.2),
+ Coordinate(latitude: 0.2, longitude: 0.8),
+ Coordinate(latitude: 0.8, longitude: 0.8)
+ ]
+
+ builder.addPolygon(name: "poly", outerRing: outer, innerRings: [hole])
+ let kml = builder.kmlString()
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains(""))
+ }
}
diff --git a/Tests/ForeFlightKMLTests/TestHelpers.swift b/Tests/ForeFlightKMLTests/Utils/TestHelpers.swift
similarity index 100%
rename from Tests/ForeFlightKMLTests/TestHelpers.swift
rename to Tests/ForeFlightKMLTests/Utils/TestHelpers.swift
diff --git a/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift b/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift
similarity index 99%
rename from Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift
rename to Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift
index e350347..7d9f4ed 100644
--- a/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift
+++ b/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift
@@ -341,7 +341,7 @@ final class UserMapShapesRecreationTests: XCTestCase {
)
)
- let kml = try builder.build()
+ let kml = builder.kmlString()
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent("FFIconTest_Generated.kml")
diff --git a/Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift b/Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift
similarity index 100%
rename from Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift
rename to Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift