From 74bba4516d3e9030c6b81083e35608aab7d6315c Mon Sep 17 00:00:00 2001 From: Dan Leedham Date: Sat, 20 Dec 2025 13:28:50 +0000 Subject: [PATCH 1/2] Developer defines OutputFormat (#10) * Developers choose output format * Move builder protocol * Rename to Output Format * Add new tests * Lint * Update Readme --- .../ForeFlightKMLDemo/KML/KMLGenerator.swift | 4 +- .../ForeFlightKMLDemo/Views/ContentView.swift | 9 +- Package.swift | 4 + README.md | 26 ++++-- .../ForeFlightKML/Enums/OutputFormat.swift | 4 + Sources/ForeFlightKML/Errors/BuildError.swift | 19 ++++ ...KMZExportError.swift => ExportError.swift} | 2 +- Sources/ForeFlightKML/ForeFlightKML.swift | 30 ++++-- Sources/ForeFlightKML/ForeflightKML+KMZ.swift | 4 +- Sources/ForeFlightKML/Utils/DataFormat.swift | 7 ++ Sources/ForeFlightKML/Utils/Protocols.swift | 4 + .../Builder/ForeFlightKML+BuildTests.swift | 92 +++++++++++++++++++ .../ForeFlightKML+KMZTests.swift | 0 .../{ => Builder}/ForeFlightKMLTests.swift | 0 .../CoreElementTests/PolygonTests.swift | 19 ++++ .../{ => Utils}/TestHelpers.swift | 0 .../UserMapShapesSampleFullTest.swift | 2 +- .../UserMapShapesSampleIndividualTests.swift | 0 18 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 Sources/ForeFlightKML/Enums/OutputFormat.swift create mode 100644 Sources/ForeFlightKML/Errors/BuildError.swift rename Sources/ForeFlightKML/Errors/{KMZExportError.swift => ExportError.swift} (71%) create mode 100644 Sources/ForeFlightKML/Utils/DataFormat.swift create mode 100644 Tests/ForeFlightKMLTests/Builder/ForeFlightKML+BuildTests.swift rename Tests/ForeFlightKMLTests/{ => Builder}/ForeFlightKML+KMZTests.swift (100%) rename Tests/ForeFlightKMLTests/{ => Builder}/ForeFlightKMLTests.swift (100%) rename Tests/ForeFlightKMLTests/{ => Utils}/TestHelpers.swift (100%) rename Tests/{ForeFlightKMLTests => UserMapShapeTests}/UserMapShapesSampleFullTest.swift (99%) rename Tests/{ForeFlightKMLTests => UserMapShapeTests}/UserMapShapesSampleIndividualTests.swift (100%) 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 From e17f59a1b76f860390aba2755cc5a931cece72e1 Mon Sep 17 00:00:00 2001 From: Dan Leedham Date: Sun, 8 Feb 2026 14:55:47 +0000 Subject: [PATCH 2/2] Performance refactor (#12) * Performance Refactors - Replace string array with mutable String buffer - For circles, use cheaper radians conversion arithmetic - Enable off MainActor use - buildKMZ() calls kmlString() directly, rather than through buildKML - No compression for small documents - Consistent coordinate precision - Codable IconStyle * Developer defined coordinate precision - Enable coordinate precision - Update Example Project - Update Readme --- .../project.pbxproj | 31 +---- .../xcshareddata/swiftpm/Package.resolved | 18 +-- .../ForeFlightKMLDemo/KML/KMLGenerator.swift | 13 +- .../Sharing/ActivityViewController.swift | 12 -- .../ForeFlightKMLDemo/Views/ContentView.swift | 43 ++---- README.md | 80 ++++++++---- .../CoreElements/LineString.swift | 10 ++ .../ForeFlightKML/CoreElements/Point.swift | 33 +++-- .../ForeFlightKML/CoreElements/Polygon.swift | 31 +++-- Sources/ForeFlightKML/ForeFlightKML.swift | 44 +++++-- Sources/ForeFlightKML/ForeflightKML+KMZ.swift | 11 +- .../Geometry/Circle/PolygonCircle.swift | 8 ++ .../Segment/PolygonAnnularSector.swift | 8 ++ .../Geometry/Segment/PolygonSector.swift | 8 ++ .../Geometry/Shared/CircleGeometry.swift | 11 +- .../Models/CoordinateContainer.swift | 43 +++--- Sources/ForeFlightKML/Models/Placemark.swift | 26 ++-- .../ForeFlightKML/Models/StyleManager.swift | 33 +++-- Sources/ForeFlightKML/Styles/IconStyle.swift | 35 ++++- Sources/ForeFlightKML/Styles/Style.swift | 18 ++- Sources/ForeFlightKML/Utils/Geodesy+KML.swift | 63 ++++++++- Sources/ForeFlightKML/Utils/Protocols.swift | 30 +++++ .../CoreElementTests/LinearRingTests.swift | 12 +- .../CoreElementTests/PointTests.swift | 4 +- .../Utils/CoordinateFormattingTests.swift | 122 ++++++++++++++++++ .../UserMapShapesSampleIndividualTests.swift | 2 +- 26 files changed, 540 insertions(+), 209 deletions(-) delete mode 100644 Example/ForeFlightKMLDemo/Sharing/ActivityViewController.swift create mode 100644 Tests/ForeFlightKMLTests/Utils/CoordinateFormattingTests.swift diff --git a/Example/ForeFlightKMLDemo.xcodeproj/project.pbxproj b/Example/ForeFlightKMLDemo.xcodeproj/project.pbxproj index 7282430..5b9d22b 100644 --- a/Example/ForeFlightKMLDemo.xcodeproj/project.pbxproj +++ b/Example/ForeFlightKMLDemo.xcodeproj/project.pbxproj @@ -7,8 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - E9421CEB2E7A0A4200FD39E1 /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */; }; - E94E2EDA2E9E8699006CBDB6 /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */; }; E9D33C1E2EAFD4F70066F05A /* ForeFlightKML in Frameworks */ = {isa = PBXBuildFile; productRef = E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */; }; /* End PBXBuildFile section */ @@ -29,8 +27,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E9421CEB2E7A0A4200FD39E1 /* ForeFlightKML in Frameworks */, - E94E2EDA2E9E8699006CBDB6 /* ForeFlightKML in Frameworks */, E9D33C1E2EAFD4F70066F05A /* ForeFlightKML in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -74,8 +70,6 @@ ); name = ForeFlightKMLDemo; packageProductDependencies = ( - E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */, - E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */, E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */, ); productName = ForeFlightKMLDemo; @@ -107,7 +101,7 @@ mainGroup = E9421CAA2E7A09EA00FD39E1; minimizedProjectReferenceProxies = 1; packageReferences = ( - E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */, + E9D33C1C2EAFD4F70066F05A /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = E9421CB42E7A09EA00FD39E1 /* Products */; @@ -360,29 +354,16 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Haelix-Code/ForeFlightKML/"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.2.1; - }; +/* Begin XCLocalSwiftPackageReference section */ + E9D33C1C2EAFD4F70066F05A /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ".."; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - E9421CEA2E7A0A4200FD39E1 /* ForeFlightKML */ = { - isa = XCSwiftPackageProductDependency; - productName = ForeFlightKML; - }; - E94E2ED92E9E8699006CBDB6 /* ForeFlightKML */ = { - isa = XCSwiftPackageProductDependency; - productName = ForeFlightKML; - }; E9D33C1D2EAFD4F70066F05A /* ForeFlightKML */ = { isa = XCSwiftPackageProductDependency; - package = E9D33C1C2EAFD4F70066F05A /* XCRemoteSwiftPackageReference "ForeFlightKML" */; productName = ForeFlightKML; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1bd35e1..36f24aa 100644 --- a/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,22 +1,22 @@ { - "originHash" : "ff5c50a2d6953969dff659b425b0f2ab77cdc9af95fe7e2298a655036ff7a3db", + "originHash" : "9cb73a7eb66a94e9ff3b48c0d33c79d885ba74172d84e80b923fabe67536dcb9", "pins" : [ { - "identity" : "foreflightkml", + "identity" : "geodesy", "kind" : "remoteSourceControl", - "location" : "https://github.com/Haelix-Code/ForeFlightKML/", + "location" : "https://github.com/florianreinhart/Geodesy", "state" : { - "revision" : "25a2e22beae7c4a3ee0c0082e8da42c8f1e87eda", - "version" : "0.3.0" + "revision" : "c72d7ea459c6eee4d041272c61f84df61d850091", + "version" : "0.2.2" } }, { - "identity" : "geodesy", + "identity" : "zipfoundation", "kind" : "remoteSourceControl", - "location" : "https://github.com/florianreinhart/Geodesy", + "location" : "https://github.com/weichsel/ZIPFoundation.git", "state" : { - "revision" : "c72d7ea459c6eee4d041272c61f84df61d850091", - "version" : "0.2.2" + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" } } ], diff --git a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift index e328461..573307a 100644 --- a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift +++ b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift @@ -4,7 +4,8 @@ import CoreLocation import ForeFlightKML enum KMLGenerator { - static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> BuildResult { + /// Builds a KML file and writes it to a temp directory, returning the URL. + static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> URL { let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo") let centerCoordinate = Coordinate(latitude: center.latitude, longitude: center.longitude) @@ -23,8 +24,16 @@ enum KMLGenerator { radiusMeters: radiusMeters * 2, style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))) - return try builder.build(as: .kmz) + let result = try builder.build(as: .kml) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(dateFormatter.string(from: Date())).\(result.fileExtension)") + + try result.data.write(to: tmpURL, options: [.atomic]) + return tmpURL } static func polygonCoordinatesForMap(center: CLLocationCoordinate2D, radiusMeters: Double) -> [CLLocationCoordinate2D] { diff --git a/Example/ForeFlightKMLDemo/Sharing/ActivityViewController.swift b/Example/ForeFlightKMLDemo/Sharing/ActivityViewController.swift deleted file mode 100644 index 9471f10..0000000 --- a/Example/ForeFlightKMLDemo/Sharing/ActivityViewController.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI -import UIKit - -struct ActivityViewController: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/Example/ForeFlightKMLDemo/Views/ContentView.swift b/Example/ForeFlightKMLDemo/Views/ContentView.swift index fda51f8..1d21227 100644 --- a/Example/ForeFlightKMLDemo/Views/ContentView.swift +++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift @@ -1,11 +1,9 @@ import SwiftUI import MapKit -import ForeFlightKML struct ContentView: View { @State private var lastTapCoordinate: CLLocationCoordinate2D? @State private var kmlToShareURL: URL? - @State private var showingShare = false private let defaultRadiusMeters: Double = 500.0 @@ -26,49 +24,36 @@ struct ContentView: View { Spacer() HStack { Spacer() - Button(action: shareIfAvailable) { + if let url = kmlToShareURL { + ShareLink(item: url) { + Image(systemName: "square.and.arrow.up") + .font(.title2) + .padding() + .background(Color(.systemBackground).opacity(0.9)) + .clipShape(Circle()) + } + .padding() + } else { Image(systemName: "square.and.arrow.up") .font(.title2) .padding() .background(Color(.systemBackground).opacity(0.9)) .clipShape(Circle()) + .opacity(0.4) + .padding() } - .disabled(kmlToShareURL == nil) - .padding() } } } - .sheet(isPresented: $showingShare) { - if let url = kmlToShareURL { - ActivityViewController(activityItems: [url]) - } else { - Text("No KML available") - } - } } private func handleMapTap(_ coord: CLLocationCoordinate2D) { lastTapCoordinate = coord do { - 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())).\(buildResult.fileExtension)") - - try buildResult.data.write(to: tmpURL) - kmlToShareURL = tmpURL - showingShare = true + kmlToShareURL = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters) } catch { - print("Failed to write KMZ: \(error)") + print("Failed to generate KML: \(error)") kmlToShareURL = nil } } - - private func shareIfAvailable() { - if kmlToShareURL != nil { - showingShare = true - } - } } diff --git a/README.md b/README.md index eafd548..ce0dc48 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # ForeFlightKML -> Swift framework to build KML files in Jeppesen ForeFlight friendly format. +> Swift framework to build KML files in Jeppesen ForeFlight friendly format. This package provides a small, focused API for composing KML/KMZ documents suitable for importing into ForeFlight as **User Map Shapes (KML)**. It intentionally avoids UI concerns — it gives you `String` KML output (or bytes) which your app can write to disk and share using the standard iOS share sheet. - + ## Quick highlights - Compose `Placemark`s with `Point`, `LineString`, `Polygon` and derived geometry helpers (circles, arc sectors, etc.). - Create reusable styles (`Style`, `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`) and assign them to placemarks. -- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` or `kmz` document . +- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` or `kmz` document. +- Thread-safe — `ForeFlightKMLBuilder` is `Sendable`, so KML generation can run off the main thread. - Lightweight — no UI code. ## Install @@ -18,8 +19,8 @@ This package provides a small, focused API for composing KML/KMZ documents suita 3. Choose the `ForeFlightKML` package product and add it to your app target. -## Example Output -Using the example given on the [ForeFlight website](https://foreflight.com/support/user-map-shapes/) the below is generated using this Framework. +## 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/UserMapShapeTests/UserMapShapesSampleFullTest.swift` @@ -29,27 +30,27 @@ See `/Tests/UserMapShapeTests/UserMapShapesSampleFullTest.swift` ```swift import ForeFlightKML -import Geodesy (used for coordinates) +import GeodesySpherical let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ") builder.addLine( - name: "Runway 15-33", - coordinates: [Coordinate(latitude:, longitude:),Coordinate(latitude:, longitude:)], - style: LineStyle(color: .black) + name: "Runway 15-33", + coordinates: [Coordinate(latitude:, longitude:), Coordinate(latitude:, longitude:)], + style: PathStyle(color: .black) ) -builder.addLineCircle( - name: "Airport ATZ", - center: Coordinate(latitude:, longitude:), +builder.addPolygonCircle( + name: "Airport ATZ", + center: Coordinate(latitude:, longitude:), radiusMeters: 4630, - PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3)) + style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3)) ) let buildResult = try builder.build(as: .kmz) -let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes\(buildResult.fileExtension)") -try buildResult.data.write(to: tmpURL) +let url = FileManager.default.temporaryDirectory.appendingPathComponent("shapes.\(buildResult.fileExtension)") +try buildResult.data.write(to: url) presentShareSheet(with: url) ``` @@ -58,44 +59,71 @@ presentShareSheet(with: url) ## API Reference -### KMLBuidler - `ForeFlightKMLBuilder` is the builder for the KML/KMZ document. - - Document name can be set on `init` or with `setDocumentName()` +### KMLBuilder + `ForeFlightKMLBuilder` is the builder for the KML/KMZ document. + - Document name can be set on `init` or with `setDocumentName()` + - Coordinate precision can be configured with `setCoordinatePrecision(_:)` (default 8, see below) - Elements can be manually added using `addPlacemark(_:)` - The output is accessed by `try builder.build()` +### Coordinate Precision + +By default coordinates are written with up to 8 decimal places, with trailing zeros trimmed for cleaner output: + +| Value | Output | +|-------|--------| +| `2.0` | `2.0` | +| `51.750188` | `51.750188` | +| `51.12345678` | `51.12345678` | + +You can customise the precision (1–15) via the builder: + +```swift +let builder = ForeFlightKMLBuilder(documentName: "Low-res demo") + .setCoordinatePrecision(4) // max 4dp, trailing zeros trimmed +``` + +The `Coordinate.kmlString(precision:)` method also accepts a precision parameter for standalone use. + ### KMLBuilder Convenience Elements - `addPoint` Add a point with style. - `addLine` Add a line connecting multiple coordinates. - `addLineCircle` Add a circular line (approximated by line segments). - `addLineSector` Add an arc sector line geometry. - `addPolygon` Add a polygon with outer boundary and optional holes. - - `addPolygonCircle` Add a polygon with outer boundary and optional holes. + - `addPolygonCircle` Add a filled circular polygon. - `addPolygonSector` Add a filled sector polygon (pie slice). - `addPolygonAnnularSector` Add a filled annular (ring) sector polygon. - `addLabel` Add a text-only label placemark at a coordinate. -### ForeflightKMLBuilder Export formats -Type `BuildResult` contains: -``` +### ForeFlightKMLBuilder Export formats +Type `BuildResult` contains: +``` data: Data fileExtension: String mimetype: String ``` -Specific data access: +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). +- KMZ (zipped KML) is required when using custom icons or using labelBadge (which uses a transparent .png under the hood). ### Underlying elements - `Placemark` — a Feature containing a geometry (must implement `KMLElement`). Optionally attach a `KMLStyle`. -- Geometry types: `Point`, `Line`, `LineCircle`, `LineSector` (sector of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSector` (filled sector) `LinearRing`. +- Geometry types: `Point`, `Line`, `LineCircle`, `LineSector` (sector of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSector` (filled sector), `PolygonAnnularSector`, `LinearRing`. - `Style` and substyles: `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`. - `KMLColor` — helper to create the aabbggrr color values used by KML. Full public API surface is visible in the package sources. +## Performance + +- **Buffer-based generation** — KML is built using a single mutable `String` buffer rather than array concatenation, avoiding intermediate allocations. +- **Sendable** — `ForeFlightKMLBuilder` conforms to `Sendable`, allowing KML/KMZ generation to run on a background thread for a responsive UI. +- **Smart compression** — small documents (< 100 KB) skip DEFLATE compression in KMZ output, reducing overhead. +- **Efficient geometry** — circle point generation uses direct arithmetic rather than `Measurement` conversions. + ## Notes, conventions and gotchas - **Coordinates order**: KML requires `longitude,latitude[,altitude]`. The public API accepts `Coordinate(latitude:..., longitude:...)` (from `Geodesy`) and the framework emits coordinates in the KML `lon,lat[,alt]` order. @@ -106,7 +134,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 and end to end example 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/CoreElements/LineString.swift b/Sources/ForeFlightKML/CoreElements/LineString.swift index c01b3b7..069a689 100644 --- a/Sources/ForeFlightKML/CoreElements/LineString.swift +++ b/Sources/ForeFlightKML/CoreElements/LineString.swift @@ -36,6 +36,16 @@ public protocol LineLike: KMLElement, AltitudeSupport { } extension LineLike { + public func write(to buffer: inout String) { + write(to: &buffer, precision: kDefaultCoordinatePrecision) + } + + public func write(to buffer: inout String, precision: Int) { + let lineString = LineString( + coordinates: coordinates, altitude: altitude, tessellate: tessellate) + lineString.write(to: &buffer, precision: precision) + } + public func kmlString() -> String { let lineString = LineString( coordinates: coordinates, altitude: altitude, tessellate: tessellate) diff --git a/Sources/ForeFlightKML/CoreElements/Point.swift b/Sources/ForeFlightKML/CoreElements/Point.swift index a3e214c..e121bb0 100644 --- a/Sources/ForeFlightKML/CoreElements/Point.swift +++ b/Sources/ForeFlightKML/CoreElements/Point.swift @@ -24,21 +24,34 @@ public struct Point: KMLElement, AltitudeSupport { self.altitudeMode = altitudeMode } - public func kmlString() -> String { - let coordinate3D = Coordinate3D(coordinate, altitude: altitude) - var kmlComponents: [String] = [] + public func write(to buffer: inout String) { + write(to: &buffer, precision: kDefaultCoordinatePrecision) + } - kmlComponents.append("") - kmlComponents.append("1") + public func write(to buffer: inout String, precision: Int) { + buffer.append("\n") + buffer.append("1\n") - // Only emit altitude mode if we have altitude values if shouldEmitAltitudeMode(hasAltitude: altitude != nil) { - kmlComponents.append(altitudeModeTag()) + buffer.append(altitudeModeTag()) + buffer.append("\n") } - kmlComponents.append("\(coordinate3D.kmlString())") - kmlComponents.append("") + buffer.append("") + buffer.append(formatCoordinate(coordinate.longitude, precision: precision)) + buffer.append(",") + buffer.append(formatCoordinate(coordinate.latitude, precision: precision)) + if let alt = altitude { + buffer.append(",") + buffer.append(formatCoordinate(alt, precision: 1)) + } + buffer.append("\n") + buffer.append("\n") + } - return kmlComponents.joined(separator: "\n") + public func kmlString() -> String { + var buffer = String() + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/CoreElements/Polygon.swift b/Sources/ForeFlightKML/CoreElements/Polygon.swift index 5709082..d726590 100644 --- a/Sources/ForeFlightKML/CoreElements/Polygon.swift +++ b/Sources/ForeFlightKML/CoreElements/Polygon.swift @@ -25,27 +25,38 @@ public struct Polygon: KMLElement, AltitudeSupport { self.altitudeMode = altitudeMode } - public func kmlString() -> String { - var kmlComponents: [String] = [] - kmlComponents.append("") + public func write(to buffer: inout String) { + write(to: &buffer, precision: kDefaultCoordinatePrecision) + } + + public func write(to buffer: inout String, precision: Int) { + buffer.append("\n") if let tessellate = tessellate { - kmlComponents.append("\(tessellate ? 1 : 0)") + buffer.append("\(tessellate ? 1 : 0)\n") } - // Add altitude mode if any ring has altitude let allRings = [outer] + inner let hasAnyAltitude = allRings.contains { $0.altitude != nil } if shouldEmitAltitudeMode(hasAltitude: hasAnyAltitude) { - kmlComponents.append(altitudeModeTag()) + buffer.append(altitudeModeTag()) + buffer.append("\n") } - kmlComponents.append("\n" + outer.kmlString() + "") + buffer.append("\n") + outer.write(to: &buffer, precision: precision) + buffer.append("\n") for ring in inner { - kmlComponents.append("\n" + ring.kmlString() + "") + buffer.append("\n") + ring.write(to: &buffer, precision: precision) + buffer.append("\n") } - kmlComponents.append("") + buffer.append("\n") + } - return kmlComponents.joined(separator: "\n") + public func kmlString() -> String { + var buffer = String() + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift index 4c2a830..0e6409a 100644 --- a/Sources/ForeFlightKML/ForeFlightKML.swift +++ b/Sources/ForeFlightKML/ForeFlightKML.swift @@ -4,9 +4,15 @@ 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: Building { +/// - Note: Marked `@unchecked Sendable` because instances are intended +/// to be created, populated, and built within a single scope (not shared across threads). +/// This allows callers to use the builder from any actor/thread context. +public final class ForeFlightKMLBuilder: Building, @unchecked Sendable { /// Optional name for the `` element. private var documentName: String? + /// Maximum decimal places for coordinate values. Trailing zeros are trimmed, + /// so `2.0` stays as `"2.0"` rather than `"2.00000000"`. Default is 8. + public var coordinatePrecision: Int = kDefaultCoordinatePrecision /// Collection of placemarks added to this builder. private var placemarks: [Placemark] = [] /// Manages styles and deduplication @@ -35,6 +41,16 @@ public final class ForeFlightKMLBuilder: Building { return self } + /// Set the maximum decimal places for coordinate values. + /// Trailing zeros are always trimmed (e.g. `2.0` not `2.00000000`). + /// - Parameter precision: Maximum decimal places (default 8, clamped to 1...15) + /// - Returns: Self for method chaining + @discardableResult + public func setCoordinatePrecision(_ precision: Int) -> Self { + self.coordinatePrecision = max(1, min(15, precision)) + return self + } + /// Add a placemark to the builder. The placemark's style (if present) will be registered. /// - Parameter placemark: Placemark to add. /// - Returns: Self for method chaining @@ -86,24 +102,28 @@ public final class ForeFlightKMLBuilder: Building { /// Produce the full KML string for this document. /// - Returns: A UTF-8 `String` containing the KML document. internal func kmlString() -> String { - var documentComponents: [String] = [] - documentComponents.append("") + // Pre-allocate a reasonable buffer size to avoid repeated reallocations + var buffer = String() + buffer.reserveCapacity(placemarks.count * 500 + 1000) + + buffer.append("\n") let ns = namespaces.map { "\($0.key)=\"\($0.value)\"" }.joined(separator: " ") - documentComponents.append("") - documentComponents.append("") + buffer.append("\n") + buffer.append("\n") if let name = documentName { - documentComponents.append("\(escapeForKML(name))") + buffer.append("\(escapeForKML(name))\n") } - let stylesXML = styleManager.kmlString() - if !stylesXML.isEmpty { documentComponents.append(stylesXML) } + styleManager.write(to: &buffer) - for placemark in placemarks { documentComponents.append(placemark.kmlString()) } + for placemark in placemarks { + placemark.write(to: &buffer, precision: coordinatePrecision) + } - documentComponents.append("") - documentComponents.append("") + buffer.append("\n") + buffer.append("") - return documentComponents.joined(separator: "\n") + return buffer } /// Produce the KML document as `Data` using the given text encoding. diff --git a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift index 46cacdc..2f47c96 100644 --- a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift +++ b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift @@ -6,15 +6,22 @@ public extension ForeFlightKMLBuilder { /// Build a KMZ (ZIP) containing doc.kml and any required local assets. func buildKMZ() throws -> Data? { - let kmlData = buildKML() + // Build KML string directly, then convert to UTF-8 data once + let kmlString = kmlString() + guard let kmlData = kmlString.data(using: .utf8) else { + return nil + } let archive = try Archive(accessMode: .create) + // Use .none compression for small documents (< 100KB) to avoid DEFLATE overhead + let compressionMethod: CompressionMethod = kmlData.count > 100_000 ? .deflate : .none + try archive.addEntry( with: "doc.kml", type: .file, uncompressedSize: Int64(kmlData.count), - compressionMethod: .deflate, + compressionMethod: compressionMethod, provider: { position, size in let start = Int(position) guard start < kmlData.count else { return Data() } diff --git a/Sources/ForeFlightKML/Geometry/Circle/PolygonCircle.swift b/Sources/ForeFlightKML/Geometry/Circle/PolygonCircle.swift index d5edf66..34a4322 100644 --- a/Sources/ForeFlightKML/Geometry/Circle/PolygonCircle.swift +++ b/Sources/ForeFlightKML/Geometry/Circle/PolygonCircle.swift @@ -26,6 +26,14 @@ public struct PolygonCircle: KMLElement, AltitudeSupport { self.polygon = Polygon(outer: ring, altitudeMode: altitudeMode, tessellate: tessellate) } + public func write(to buffer: inout String) { + polygon.write(to: &buffer) + } + + public func write(to buffer: inout String, precision: Int) { + polygon.write(to: &buffer, precision: precision) + } + public func kmlString() -> String { return polygon.kmlString() } diff --git a/Sources/ForeFlightKML/Geometry/Segment/PolygonAnnularSector.swift b/Sources/ForeFlightKML/Geometry/Segment/PolygonAnnularSector.swift index a40d801..70dbb68 100644 --- a/Sources/ForeFlightKML/Geometry/Segment/PolygonAnnularSector.swift +++ b/Sources/ForeFlightKML/Geometry/Segment/PolygonAnnularSector.swift @@ -28,6 +28,14 @@ public struct PolygonAnnularSector: KMLElement, AltitudeSupport { self.polygon = Polygon(outer: ring, altitudeMode: altitudeMode, tessellate: tessellate) } + public func write(to buffer: inout String) { + polygon.write(to: &buffer) + } + + public func write(to buffer: inout String, precision: Int) { + polygon.write(to: &buffer, precision: precision) + } + public func kmlString() -> String { return polygon.kmlString() } diff --git a/Sources/ForeFlightKML/Geometry/Segment/PolygonSector.swift b/Sources/ForeFlightKML/Geometry/Segment/PolygonSector.swift index c1b5136..72c021e 100644 --- a/Sources/ForeFlightKML/Geometry/Segment/PolygonSector.swift +++ b/Sources/ForeFlightKML/Geometry/Segment/PolygonSector.swift @@ -23,6 +23,14 @@ public struct PolygonSector: KMLElement, AltitudeSupport { self.polygon = Polygon(outer: ring, altitudeMode: altitudeMode, tessellate: tessellate) } + public func write(to buffer: inout String) { + polygon.write(to: &buffer) + } + + public func write(to buffer: inout String, precision: Int) { + polygon.write(to: &buffer, precision: precision) + } + public func kmlString() -> String { return polygon.kmlString() } diff --git a/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift b/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift index e923ec3..68b46cb 100644 --- a/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift +++ b/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift @@ -1,15 +1,18 @@ -import Foundation import GeodesySpherical internal enum CircleGeometry { + + private static let radiansToDegrees = 180.0 / Double.pi + static func generateCirclePoints( center: Coordinate, radius: Double, numberOfPoints: Int ) -> [Coordinate] { var circlePoints: [Coordinate] = [] + circlePoints.reserveCapacity(numberOfPoints + 2) + + let angleStep = 360.0 / Double(numberOfPoints) for i in 0...numberOfPoints { - let bearingRadians = Double(i) * (2.0 * Double.pi) / Double(numberOfPoints) - let bearingDegrees = Measurement(value: bearingRadians, unit: UnitAngle.radians) - .converted(to: .degrees).value + let bearingDegrees = Double(i) * angleStep let endPoint = center.destination(with: radius, bearing: bearingDegrees) circlePoints.append( Coordinate(latitude: endPoint.latitude, longitude: endPoint.longitude)) diff --git a/Sources/ForeFlightKML/Models/CoordinateContainer.swift b/Sources/ForeFlightKML/Models/CoordinateContainer.swift index f5e382a..06d2cd4 100644 --- a/Sources/ForeFlightKML/Models/CoordinateContainer.swift +++ b/Sources/ForeFlightKML/Models/CoordinateContainer.swift @@ -17,35 +17,40 @@ extension CoordinateContainer { /// Default tessellate behavior is to not even include the tag public var tessellate: Bool? { nil } - /// Generate KML string representation for coordinate-based geometries. - /// This provides a standard implementation that handles coordinates, altitude, and tessellation. - /// - Returns: Complete KML element string - public func kmlString() -> String { - precondition(!coordinates.isEmpty, "\(Self.elementName) must have at least one coordinate") - - let coords3D = coordinates.map { Coordinate3D($0, altitude: altitude) } + /// Write KML directly into a mutable string buffer, avoiding intermediate allocations. + public func write(to buffer: inout String) { + write(to: &buffer, precision: kDefaultCoordinatePrecision) + } - var kmlComponents: [String] = [] + public func write(to buffer: inout String, precision: Int) { + precondition(!coordinates.isEmpty, "\(Self.elementName) must have at least one coordinate") - kmlComponents.append("<\(Self.elementName)>") + buffer.append("<\(Self.elementName)>\n") if let tessellate = tessellate { - kmlComponents.append("\(tessellate ? 1 : 0)") + buffer.append("\(tessellate ? 1 : 0)\n") } - // Add altitude mode if altitude is specified if shouldEmitAltitudeMode(hasAltitude: altitude != nil) { - kmlComponents.append(altitudeModeTag()) + buffer.append(altitudeModeTag()) + buffer.append("\n") } - // Add coordinates - kmlComponents.append("") - for coord in coords3D { - kmlComponents.append(coord.kmlString()) + buffer.append("\n") + for coord in coordinates { + coord.writeKML(to: &buffer, altitude: altitude, precision: precision) } - kmlComponents.append("") - kmlComponents.append("\n") + buffer.append("\n") + buffer.append("\n\n") + } - return kmlComponents.joined(separator: "\n") + /// Generate KML string representation for coordinate-based geometries. + /// This provides a standard implementation that handles coordinates, altitude, and tessellation. + /// - Returns: Complete KML element string + public func kmlString() -> String { + var buffer = String() + buffer.reserveCapacity(coordinates.count * 30 + 100) + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/Models/Placemark.swift b/Sources/ForeFlightKML/Models/Placemark.swift index fd45d1c..ff1032c 100644 --- a/Sources/ForeFlightKML/Models/Placemark.swift +++ b/Sources/ForeFlightKML/Models/Placemark.swift @@ -43,15 +43,25 @@ public struct Placemark { self.init(name: name, geometry: geometry, style: style) } - public func kmlString() -> String { - var kmlComponents: [String] = [] + public func write(to buffer: inout String) { + write(to: &buffer, precision: kDefaultCoordinatePrecision) + } - kmlComponents.append("") - if let name = name { kmlComponents.append("\(escapeForKML(name))") } - if let su = styleUrl { kmlComponents.append("#\(su)") } - kmlComponents.append(geometry.kmlString()) - kmlComponents.append("") + public func write(to buffer: inout String, precision: Int) { + buffer.append("\n") + if let name = name { + buffer.append("\(escapeForKML(name))\n") + } + if let su = styleUrl { + buffer.append("#\(su)\n") + } + geometry.write(to: &buffer, precision: precision) + buffer.append("\n") + } - return kmlComponents.joined(separator: "\n") + public func kmlString() -> String { + var buffer = String() + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/Models/StyleManager.swift b/Sources/ForeFlightKML/Models/StyleManager.swift index fb3935a..1d83a42 100644 --- a/Sources/ForeFlightKML/Models/StyleManager.swift +++ b/Sources/ForeFlightKML/Models/StyleManager.swift @@ -89,27 +89,26 @@ internal class StyleManager { // MARK: - KML Generation - /// Generate the KML string for all registered styles. - /// Only includes styles that have been marked as referenced. - /// - Parameters: - /// - includeUnreferenced: If true, includes all registered styles even if not referenced (default: false) - /// - Returns: KML string containing all style definitions - public func kmlString() -> String { - let stylesToOutput: [KMLStyle] - - stylesToOutput = styles.compactMap { styleId, style in + /// Write all referenced styles directly into a buffer. + public func write(to buffer: inout String) { + let stylesToOutput = styles.compactMap { styleId, style in referencedStyleIds.contains(styleId) ? style : nil } - guard !stylesToOutput.isEmpty else { - return "" - } + guard !stylesToOutput.isEmpty else { return } - // Sort by ID for consistent output let sortedStyles = stylesToOutput.sorted { $0.id() < $1.id() } + for style in sortedStyles { + style.write(to: &buffer) + buffer.append("\n") + } + } - return - sortedStyles - .map { $0.kmlString() } - .joined(separator: "\n") + /// Generate the KML string for all registered styles. + /// Only includes styles that have been marked as referenced. + /// - Returns: KML string containing all style definitions + public func kmlString() -> String { + var buffer = String() + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/Styles/IconStyle.swift b/Sources/ForeFlightKML/Styles/IconStyle.swift index 85df0fc..720821a 100644 --- a/Sources/ForeFlightKML/Styles/IconStyle.swift +++ b/Sources/ForeFlightKML/Styles/IconStyle.swift @@ -93,7 +93,7 @@ public struct IconStyle: KMLSubStyle { /// Icon shapes that work with Google's predefined color system. /// These are the "paddle" style icons that only support fixed colors. -public enum PredefinedIconType: String { +public enum PredefinedIconType: String, Codable { case pushpin case circle case square @@ -102,7 +102,7 @@ public enum PredefinedIconType: String { /// Icon shapes that support custom colors. /// These are the "shapes" style icons that can be any color via KML's color tag. -public enum CustomIconType { +public enum CustomIconType: Codable { case opendiamond case triangle case forbidden @@ -122,11 +122,40 @@ public enum CustomIconType { case .placemarkcircle: return "placemark_circle" } } + + private static let hrefToCaseMap: [String: CustomIconType] = [ + "open-diamond": .opendiamond, + "triangle": .triangle, + "forbidden": .forbidden, + "target": .target, + "square": .square, + "placemark_square": .placemarksquare, + "placemark_circle": .placemarkcircle, + ] + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(href) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + guard let value = CustomIconType.hrefToCaseMap[raw] else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unknown CustomIconType href: \(raw)" + ) + ) + } + self = value + } } /// Predefined icon colors available for paddle-style icons. /// These are the standard colors provided by Google's KML icon set. -public enum DefinedIconColor: String { +public enum DefinedIconColor: String, Codable { case purple = "purple" case white = "wht" case green = "grn" diff --git a/Sources/ForeFlightKML/Styles/Style.swift b/Sources/ForeFlightKML/Styles/Style.swift index a8604c5..2dba926 100644 --- a/Sources/ForeFlightKML/Styles/Style.swift +++ b/Sources/ForeFlightKML/Styles/Style.swift @@ -35,14 +35,18 @@ internal struct Style: KMLStyle { self.styleId = styleId } - public func kmlString() -> String { - var kmlComponents: [String] = [] - kmlComponents.append("") - return kmlComponents.joined(separator: "\n") + buffer.append("") + } + + public func kmlString() -> String { + var buffer = String() + write(to: &buffer) + return buffer } } diff --git a/Sources/ForeFlightKML/Utils/Geodesy+KML.swift b/Sources/ForeFlightKML/Utils/Geodesy+KML.swift index de7d19e..352c0ea 100644 --- a/Sources/ForeFlightKML/Utils/Geodesy+KML.swift +++ b/Sources/ForeFlightKML/Utils/Geodesy+KML.swift @@ -1,3 +1,4 @@ +import Foundation import GeodesySpherical // KML uses 3D geographic coordinates: longitude, latitude and altitude, in that order. @@ -5,9 +6,58 @@ import GeodesySpherical // as defined by the World Geodetic System of 1984 (WGS-84). // The vertical component (altitude) is measured in meters from the WGS84 EGM96 Geoid vertical datum. +/// Default coordinate precision when none is specified. +internal let kDefaultCoordinatePrecision = 8 + +/// Format a coordinate value to the given precision, trimming trailing zeros +/// but always keeping at least one decimal place. +/// +/// Examples (precision 8): +/// `formatCoordinate(2.0, precision: 8)` → `"2.0"` +/// `formatCoordinate(51.123, precision: 8)` → `"51.123"` +/// `formatCoordinate(-1.58156600, precision: 8)` → `"-1.581566"` +/// +/// Examples (precision 4): +/// `formatCoordinate(51.12345678, precision: 4)` → `"51.1235"` +/// `formatCoordinate(2.0, precision: 4)` → `"2.0"` +/// +internal func formatCoordinate(_ value: Double, precision: Int) -> String { + let formatted = String(format: "%.\(precision)f", value) + + // Find the decimal point + guard let dotIndex = formatted.firstIndex(of: ".") else { + return formatted + ".0" + } + + // Trim trailing zeros, but keep at least one digit after the decimal + let minimumEnd = formatted.index(dotIndex, offsetBy: 2) // keeps "X.Y" at minimum + var end = formatted.endIndex + while end > minimumEnd && formatted[formatted.index(before: end)] == "0" { + end = formatted.index(before: end) + } + + return String(formatted[formatted.startIndex.. String { - return "\(self.longitude),\(self.latitude)" + /// Format coordinate as KML `longitude,latitude` string with given precision. + /// Trailing zeros are trimmed (e.g. `2.0` not `2.00000000`). + public func kmlString(precision: Int = 8) -> String { + let lon = formatCoordinate(self.longitude, precision: precision) + let lat = formatCoordinate(self.latitude, precision: precision) + return "\(lon),\(lat)" + } + + /// Write coordinate in KML format directly into a buffer, with optional altitude. + internal func writeKML(to buffer: inout String, altitude: Double? = nil, precision: Int = kDefaultCoordinatePrecision) { + buffer.append(formatCoordinate(self.longitude, precision: precision)) + buffer.append(",") + buffer.append(formatCoordinate(self.latitude, precision: precision)) + if let alt = altitude { + buffer.append(",") + buffer.append(formatCoordinate(alt, precision: 1)) + } + buffer.append("\n") } } @@ -33,11 +83,14 @@ internal struct Coordinate3D: Hashable { public var latitude: GeodesySpherical.Degrees { coordinate.latitude } public var longitude: GeodesySpherical.Degrees { coordinate.longitude } - public func kmlString() -> String { + public func kmlString(precision: Int = kDefaultCoordinatePrecision) -> String { + let lon = formatCoordinate(self.longitude, precision: precision) + let lat = formatCoordinate(self.latitude, precision: precision) if let altitude = self.altitude { - return "\(self.longitude),\(self.latitude),\(altitude)" + let alt = formatCoordinate(altitude, precision: 1) + return "\(lon),\(lat),\(alt)" } else { - return "\(self.longitude),\(self.latitude)" + return "\(lon),\(lat)" } } } diff --git a/Sources/ForeFlightKML/Utils/Protocols.swift b/Sources/ForeFlightKML/Utils/Protocols.swift index cfa6ff8..379ca57 100644 --- a/Sources/ForeFlightKML/Utils/Protocols.swift +++ b/Sources/ForeFlightKML/Utils/Protocols.swift @@ -4,6 +4,23 @@ /// Implementers must produce valid KML (as a `String`) for that element. public protocol KMLElement { func kmlString() -> String + /// Write KML directly into a mutable string buffer for better performance. + /// Default implementation falls back to `kmlString()`. + func write(to buffer: inout String) + /// Write KML with coordinate precision control. + /// - Parameters: + /// - buffer: The string buffer to append to + /// - precision: Maximum decimal places for coordinates (trailing zeros are trimmed) + func write(to buffer: inout String, precision: Int) +} + +public extension KMLElement { + func write(to buffer: inout String) { + buffer.append(kmlString()) + } + func write(to buffer: inout String, precision: Int) { + write(to: &buffer) + } } /// Represents a top-level KML `Style` that has an id and a KML body. @@ -12,12 +29,17 @@ public protocol KMLStyle { // Top-level style that must provide an id and full KML (usually a ) func id() -> String func kmlString() -> String + /// Write style KML directly into a mutable string buffer for better performance. + func write(to buffer: inout String) // Whether this style requires KMZ packaging (e.g. local icon assets). var requiresKMZ: Bool { get } } public extension KMLStyle { var requiresKMZ: Bool { false } + func write(to buffer: inout String) { + buffer.append(kmlString()) + } } /// Represents a style *sub-element* like `` or ``. @@ -25,6 +47,14 @@ public extension KMLStyle { public protocol KMLSubStyle { // These produce the inner element tags (..., ...). func kmlString() -> String + /// Write sub-style KML directly into a mutable string buffer for better performance. + func write(to buffer: inout String) +} + +public extension KMLSubStyle { + func write(to buffer: inout String) { + buffer.append(kmlString()) + } } protocol Building { diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift index 4eba622..f7cd999 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift @@ -26,12 +26,12 @@ final class LinearRingTests: XCTestCase { """ - -100.1097399038377,31.57870338920791,0.0 - -100.1165273813259,30.28600960074139,0.0 - -98.96485321080908,30.49650491542987,0.0 - -98.95965359046227,30.92214152160733,0.0 - -99.09548335615463,31.45369338953584,0.0 - -100.1097399038377,31.57870338920791,0.0 + -100.1097399,31.57870339,0.0 + -100.11652738,30.2860096,0.0 + -98.96485321,30.49650492,0.0 + -98.95965359,30.92214152,0.0 + -99.09548336,31.45369339,0.0 + -100.1097399,31.57870339,0.0 """)) diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift index 6c727dc..c7fa454 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift @@ -45,7 +45,7 @@ final class PointTests: XCTestCase { let kml = point.kmlString() XCTAssertTrue(kml.contains("")) - XCTAssertTrue(kml.contains("-77.036572,38.898311,17.88")) + XCTAssertTrue(kml.contains("-77.036572,38.898311,17.9")) XCTAssertTrue(kml.contains("")) } @@ -66,6 +66,6 @@ final class PointTests: XCTestCase { XCTAssertEqual(altitudeMode, "absolute") let coordinates = try XMLTestHelper.getTextContent(elementName: "coordinates", from: xml) - XCTAssertEqual(coordinates, "-77.036572,38.898311,17.88") + XCTAssertEqual(coordinates, "-77.036572,38.898311,17.9") } } diff --git a/Tests/ForeFlightKMLTests/Utils/CoordinateFormattingTests.swift b/Tests/ForeFlightKMLTests/Utils/CoordinateFormattingTests.swift new file mode 100644 index 0000000..aaa7a55 --- /dev/null +++ b/Tests/ForeFlightKMLTests/Utils/CoordinateFormattingTests.swift @@ -0,0 +1,122 @@ +import XCTest +import GeodesySpherical +@testable import ForeFlightKML + +final class CoordinateFormattingTests: XCTestCase { + + // MARK: - formatCoordinate trailing zero trimming + + func testFormatCoordinate_roundValue_keepsOneDp() { + XCTAssertEqual(formatCoordinate(2.0, precision: 8), "2.0") + XCTAssertEqual(formatCoordinate(0.0, precision: 8), "0.0") + XCTAssertEqual(formatCoordinate(-1.0, precision: 8), "-1.0") + XCTAssertEqual(formatCoordinate(180.0, precision: 8), "180.0") + } + + func testFormatCoordinate_trimsTrailingZeros() { + XCTAssertEqual(formatCoordinate(51.123, precision: 8), "51.123") + XCTAssertEqual(formatCoordinate(-1.581566, precision: 8), "-1.581566") + XCTAssertEqual(formatCoordinate(0.1, precision: 8), "0.1") + XCTAssertEqual(formatCoordinate(10.5, precision: 8), "10.5") + } + + func testFormatCoordinate_fullPrecision_noTrimming() { + XCTAssertEqual(formatCoordinate(51.12345678, precision: 8), "51.12345678") + XCTAssertEqual(formatCoordinate(-0.00000001, precision: 8), "-0.00000001") + } + + func testFormatCoordinate_respectsPrecisionParameter() { + // Precision 4 rounds appropriately + XCTAssertEqual(formatCoordinate(51.12345678, precision: 4), "51.1235") + XCTAssertEqual(formatCoordinate(2.0, precision: 4), "2.0") + XCTAssertEqual(formatCoordinate(1.5000, precision: 4), "1.5") + + // Precision 2 + XCTAssertEqual(formatCoordinate(51.12345678, precision: 2), "51.12") + XCTAssertEqual(formatCoordinate(3.0, precision: 2), "3.0") + + // Precision 1 + XCTAssertEqual(formatCoordinate(51.12345678, precision: 1), "51.1") + XCTAssertEqual(formatCoordinate(7.0, precision: 1), "7.0") + } + + func testFormatCoordinate_negativeValues() { + XCTAssertEqual(formatCoordinate(-77.036572, precision: 8), "-77.036572") + XCTAssertEqual(formatCoordinate(-180.0, precision: 8), "-180.0") + } + + // MARK: - Coordinate.kmlString with precision + + func testCoordinateKmlString_defaultPrecision() { + let coord = Coordinate(latitude: 2.0, longitude: -1.0) + XCTAssertEqual(coord.kmlString(), "-1.0,2.0") + } + + func testCoordinateKmlString_customPrecision() { + let coord = Coordinate(latitude: 51.12345678, longitude: -1.58156634) + XCTAssertEqual(coord.kmlString(precision: 4), "-1.5816,51.1235") + XCTAssertEqual(coord.kmlString(precision: 2), "-1.58,51.12") + } + + // MARK: - Builder coordinatePrecision + + func testBuilder_defaultPrecision_is8() { + let builder = ForeFlightKMLBuilder() + XCTAssertEqual(builder.coordinatePrecision, 8) + } + + func testBuilder_setCoordinatePrecision_chaining() { + let builder = ForeFlightKMLBuilder() + .setCoordinatePrecision(4) + XCTAssertEqual(builder.coordinatePrecision, 4) + } + + func testBuilder_setCoordinatePrecision_clampsRange() { + let builder = ForeFlightKMLBuilder() + builder.setCoordinatePrecision(0) + XCTAssertEqual(builder.coordinatePrecision, 1) + + builder.setCoordinatePrecision(20) + XCTAssertEqual(builder.coordinatePrecision, 15) + } + + func testBuilder_precisionAffectsOutput() { + let builder = ForeFlightKMLBuilder(documentName: "Test") + builder.setCoordinatePrecision(4) + + builder.addPoint( + name: "TestPoint", + latitude: 51.12345678, + longitude: -1.58156634, + altitude: 0, + style: PointStyle(icon: .predefined(type: .pushpin, color: .white)) + ) + + let result = try! builder.build(as: .kml) + let kml = String(data: result.data, encoding: .utf8)! + + // With precision 4, coordinates should be trimmed to 4dp max + XCTAssertTrue(kml.contains("-1.5816,51.1235"), "Expected 4dp coordinates in output. Got: \(kml)") + // Should NOT contain 8dp coordinates + XCTAssertFalse(kml.contains("-1.58156634"), "Should not have 8dp coordinates with precision 4") + } + + func testBuilder_defaultPrecision_trimsTrailingZeros() { + let builder = ForeFlightKMLBuilder(documentName: "Test") + + builder.addPoint( + name: "Origin", + latitude: 0.0, + longitude: 0.0, + altitude: 0, + style: PointStyle(icon: .predefined(type: .pushpin, color: .white)) + ) + + let result = try! builder.build(as: .kml) + let kml = String(data: result.data, encoding: .utf8)! + + // Should be "0.0,0.0" not "0.00000000,0.00000000" + XCTAssertTrue(kml.contains("0.0,0.0"), "Expected trimmed coordinates. Got: \(kml)") + XCTAssertFalse(kml.contains("0.00000000"), "Should not have trailing zeros") + } +} diff --git a/Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift b/Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift index 7bf522c..20196d1 100644 --- a/Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift +++ b/Tests/UserMapShapeTests/UserMapShapesSampleIndividualTests.swift @@ -22,7 +22,7 @@ final class ExampleKMLRecreationTests: XCTestCase { let kml = builder.kmlString() XCTAssertTrue(kml.contains("ypin")) XCTAssertTrue(kml.contains("ylw-pushpin.png")) - XCTAssertTrue(kml.contains("-102.6009416726494,33.13980174601483,0")) + XCTAssertTrue(kml.contains("-102.60094167,33.13980175,0.0")) } func testBluePushpin() {