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..931f892 100644
--- a/Example/ForeFlightKMLDemo/Views/ContentView.swift
+++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift
@@ -5,7 +5,6 @@ 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 +25,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("\(Self.elementName)>\n")
+ buffer.append("\n")
+ buffer.append("\(Self.elementName)>\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..8783e78 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,18 @@ 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 {
+ func build(as format: OutputFormat) throws -> BuildResult
}
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() {