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` Image @@ -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