diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml
index 9bd1d56..6bf9087 100644
--- a/.github/workflows/swiftlint.yml
+++ b/.github/workflows/swiftlint.yml
@@ -13,9 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- - name: GitHub Action for SwiftLint
- uses: norio-nomura/action-swiftlint@3.2.1
- name: GitHub Action for SwiftLint with --strict
uses: norio-nomura/action-swiftlint@3.2.1
with:
- args: --strict
\ No newline at end of file
+ args: --fix --quiet
\ No newline at end of file
diff --git a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
index 9cba4d1..d022d9e 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) -> String {
+ static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> Data? {
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 builder.build()
+ return try builder.buildKMZ()
}
static func polygonCoordinatesForMap(center: CLLocationCoordinate2D, radiusMeters: Double) -> [CLLocationCoordinate2D] {
diff --git a/Example/ForeFlightKMLDemo/Views/ContentView.swift b/Example/ForeFlightKMLDemo/Views/ContentView.swift
index a18b871..c5574ab 100644
--- a/Example/ForeFlightKMLDemo/Views/ContentView.swift
+++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift
@@ -48,19 +48,20 @@ struct ContentView: View {
private func handleMapTap(_ coord: CLLocationCoordinate2D) {
lastTapCoordinate = coord
+
+ do {
+ let kmz = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
- let kml = KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
+ let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kmz")
- let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kml")
- do {
- try kml.data(using: .utf8)?.write(to: tmpURL)
+ try kmz?.write(to: tmpURL)
kmlToShareURL = tmpURL
showingShare = true
} catch {
- print("Failed to write KML: \(error)")
+ print("Failed to write KMZ: \(error)")
kmlToShareURL = nil
}
}
diff --git a/Package.resolved b/Package.resolved
index b312d27..a95a72e 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -8,6 +8,15 @@
"revision" : "c72d7ea459c6eee4d041272c61f84df61d850091",
"version" : "0.2.2"
}
+ },
+ {
+ "identity" : "zipfoundation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/weichsel/ZIPFoundation.git",
+ "state" : {
+ "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
+ "version" : "0.9.20"
+ }
}
],
"version" : 2
diff --git a/Package.swift b/Package.swift
index 14f7748..71dbf62 100644
--- a/Package.swift
+++ b/Package.swift
@@ -11,13 +11,18 @@ let package = Package(
)
],
dependencies: [
- .package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2"))
+ .package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2")),
+ .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0")
],
targets: [
.target(
name: "ForeFlightKML",
dependencies: [
- .product(name: "Geodesy", package: "Geodesy")
+ .product(name: "Geodesy", package: "Geodesy"),
+ .product(name: "ZIPFoundation", package: "ZIPFoundation")
+ ],
+ resources: [
+ .process("Resources")
]
),
.testTarget(
diff --git a/README.md b/README.md
index 099d969..ba05f9c 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
This package provides a small, focused API for composing KML 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
@@ -13,15 +12,11 @@ This package provides a small, focused API for composing KML documents suitable
- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` document string.
- Lightweight — no UI code.
----
-
## Install
-
1. In Xcode: **File › Add Packages...**
2. Enter the repository URL.
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.
@@ -30,27 +25,23 @@ See `/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift`
-## Minimal Quick Start
+## Quick Start
```swift
import ForeFlightKML
-import Geodesy (we use this for Coordinate and relative positioniong)
+import Geodesy (used for coordinates)
-// Example: Airport Traffic Pattern
let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ")
-
-// Runway centerline
builder.addLine(
name: "Runway 15-33",
coordinates: [Coordinate(latitude:, longitude:),Coordinate(latitude:, longitude:)],
style: LineStyle(color: .black)
)
-// Traffic Warning Area
builder.addLineCircle(
name: "Airport ATZ",
center: airportCenter,
- radiusMeters: 4630, // 2.5 nautical mile ATZ
+ radiusMeters: 4630,
PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
)
@@ -60,34 +51,59 @@ presentShareSheet(with: url)
```
> **Note**: ForeFlight supports importing KML/KMZ files via the iOS share sheet. See ForeFlight's docs for exact import behavior.
----
-## API quick reference (important types)
-- `ForeFlightKMLBuilder` — builder for the KML document. Methods: `addPlacemark(_:)`, `kmlString()`.
+
+## API Reference
+
+### KMLBuidler
+ `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 complete KML string is accessed by `builder.build()`
+
+### 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).
+ - `addLineSegment` Add an arc segment line geometry.
+ - `addPolygon` Add a polygon with outer boundary and optional holes.
+ - `addPolygonCircle` Add a polygon with outer boundary and optional holes.
+ - `addPolygonSegment` Add a filled segment polygon (pie slice).
+ - `addPolygonAnnularSegment` Add a filled annular (ring) segment polygon.
+ - `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()`
+- 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`, `LineSegment` (segment of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSegment` (filled segment) `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; the README examples show common usage patterns.
-
----
+Full public API surface is visible in the package sources.
## 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.
- **Units**: Distances (e.g. `LineCircle.radius`) are in **meters**.
- **Angles/bearings**: bearings (for arc & circle generation) are interpreted in degrees (0..360). The bearing convention is clockwise from north.
- **Altitude**: When you provide altitudes, the `AltitudeMode` is emitted (defaults to `.absolute` in most geometries).
- **Styles**: `Style` generates a stable `id` when provided; otherwise a UUID-based id is generated. `ForeFlightKMLBuilder` will automatically register styles added via `Placemark`.
----
-## Demo & tests
+To create a label-only point with a colored badge:
+```
+ builder.addLabel("Text", coordinate: .init(...), color: KMLColor?)
+```
-The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests. Before publishing, ensure the example builds and the tests run in CI.
+## Demo & tests
----
+The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests.
## Contributing
diff --git a/Sources/ForeFlightKML/Errors/KMZExportError.swift b/Sources/ForeFlightKML/Errors/KMZExportError.swift
new file mode 100644
index 0000000..ba3e86e
--- /dev/null
+++ b/Sources/ForeFlightKML/Errors/KMZExportError.swift
@@ -0,0 +1,5 @@
+public enum KMZExportError: Error {
+ case kmzRequired
+ case missingLocalResource(String)
+ case archiveCreationFailed
+}
diff --git a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
index 715a8c6..0be0591 100644
--- a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
+++ b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
@@ -291,4 +291,29 @@ extension ForeFlightKMLBuilder {
let placemark = Placemark(name: name, geometry: segment, style: style)
return addPlacemark(placemark)
}
+
+ /// Add a text-only label placemark at a coordinate.
+ /// This uses a transparent 1×1 icon to enable ForeFlight’s “badge” rendering, with the badge color driven by `IconStyle.color`.
+ /// - Important: Because this relies on a local icon asset, the output **must be exported as KMZ** (not plain KML) for the label to render correctly.
+ /// - Parameters:
+ /// - text: Label text displayed in ForeFlight
+ /// - coordinate: Geographic coordinate
+ /// - altitude: Altitude in meters (optional)
+ /// - color: Badge/background color for the label (default: white)
+ /// - Returns: Self for method chaining
+ @discardableResult
+ public func addLabel(
+ _ text: String,
+ coordinate: Coordinate,
+ altitude: Double? = nil,
+ color: KMLColor = .white
+ ) -> Self {
+ addPoint(
+ name: text,
+ coordinate: coordinate,
+ altitude: altitude,
+ style: .labelBadge(color: color)
+ )
+ }
+
}
diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift
index 5df0777..445d6b2 100644
--- a/Sources/ForeFlightKML/ForeFlightKML.swift
+++ b/Sources/ForeFlightKML/ForeFlightKML.swift
@@ -47,12 +47,21 @@ public final class ForeFlightKMLBuilder {
return self
}
+ /// True if this document must be exported as KMZ to render correctly.
+ public var requiresKMZ: Bool {
+ styleManager.requiresKMZ
+ }
+
// MARK: - Build Methods
/// 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() -> String {
+ public func build() throws -> String {
+ guard !requiresKMZ else {
+ throw KMZExportError.kmzRequired
+ }
+
return kmlString()
}
diff --git a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
new file mode 100644
index 0000000..300dac6
--- /dev/null
+++ b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
@@ -0,0 +1,50 @@
+import Foundation
+import ZIPFoundation
+
+public extension ForeFlightKMLBuilder {
+
+ /// Build a KMZ (ZIP) containing doc.kml and any required local assets.
+ func buildKMZ() throws -> Data? {
+
+ let kmlData = kmlData()
+
+ let archive = try Archive(accessMode: .create)
+
+ try archive.addEntry(
+ with: "doc.kml",
+ type: .file,
+ uncompressedSize: Int64(kmlData.count),
+ compressionMethod: .deflate,
+ provider: { position, size in
+ let start = Int(position)
+ guard start < kmlData.count else { return Data() }
+ let end = min(start + size, kmlData.count)
+ return kmlData.subdata(in: start.. String {
var components: [String] = []
components.append(")
func id() -> String
func kmlString() -> String
+ // Whether this style requires KMZ packaging (e.g. local icon assets).
+ var requiresKMZ: Bool { get }
+}
+
+public extension KMLStyle {
+ var requiresKMZ: Bool { false }
}
/// Represents a style *sub-element* like `` or ``.
diff --git a/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift b/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift
new file mode 100644
index 0000000..15ace04
--- /dev/null
+++ b/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift
@@ -0,0 +1,78 @@
+import XCTest
+@testable import ForeFlightKML
+import GeodesySpherical
+import ZIPFoundation
+
+final class KMZPackagingTests: XCTestCase {
+
+ func test_buildKMZ_alwaysContainsDocKML() 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 kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNotNil(archive["doc.kml"], "KMZ must contain doc.kml at root")
+ }
+
+ func test_buildKMZ_doesNotInclude1x1png_whenNotRequired() 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))
+ )
+
+ XCTAssertFalse(builder.requiresKMZ)
+
+ let kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNil(archive["1x1.png"], "KMZ should not include 1x1.png unless required")
+ }
+
+ func test_buildKMZ_includes1x1png_whenRequired() throws {
+ let builder = ForeFlightKMLBuilder()
+ builder.addLabel("Badge", coordinate: Coordinate(latitude: 51.0, longitude: -1.0), color: .warning)
+ XCTAssertTrue(builder.requiresKMZ)
+
+ let kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNotNil(archive["1x1.png"], "KMZ must include 1x1.png when label badges are used")
+ }
+
+ // MARK: - Helpers
+
+ private func makeArchive(from kmzData: Data) throws -> Archive {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("kmz")
+
+ try kmzData.write(to: url)
+ return try Archive(url: url, accessMode: .read)
+ }
+
+ func test_builderAddLabel() {
+ let builder = ForeFlightKMLBuilder()
+
+ builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning)
+ do {
+ let kmz = try builder.buildKMZ()
+
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("test.kmz")
+
+ try kmz!.write(to: url)
+ print("KMZ written to:", url)
+ } catch {
+ XCTFail("Unable to build KMZ: \(error)")
+ }
+ }
+}
diff --git a/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift
index c7ea25d..80a5c09 100644
--- a/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift
+++ b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift
@@ -170,7 +170,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
)
}
- let kml = builder.build()
+ let kml = try builder.build()
XCTAssertTrue(kml.contains(""))
let placemarkCount = kml.components(separatedBy: "").count - 1
diff --git a/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift b/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
index 077d225..845a476 100644
--- a/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
+++ b/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
@@ -22,9 +22,7 @@ final class AltitudeSupportTests: XCTestCase {
func testPointWithAltitudeNoMode() {
let point = Point(Coordinate(latitude: 1, longitude: -1), altitude: 0)
-
let kml = point.kmlString()
- print(kml)
XCTAssertTrue(kml.contains("-1.0,1.0,0.0"))
XCTAssertFalse(kml.contains(""))
}
diff --git a/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift b/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
index cff7162..7683582 100644
--- a/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
+++ b/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
@@ -1,5 +1,5 @@
import XCTest
-
+import GeodesySpherical
@testable import ForeFlightKML
final class PointStylesTests: XCTestCase {
@@ -46,4 +46,42 @@ final class PointStylesTests: XCTestCase {
XCTAssertNotEqual(style1.id(), style2.id())
}
+
+ func test_pointStyle_requiresKMZ_followsIconStyle() {
+ let s1 = PointStyle(icon: .transparentLocalPng(tint: .white))
+ XCTAssertTrue(s1.requiresKMZ)
+
+ let s2 = PointStyle(icon: .custom(type: .square, color: .white))
+ XCTAssertFalse(s2.requiresKMZ)
+ }
+
+ func test_labelBadge_requiresKMZ_true() {
+ let s = PointStyle.labelBadge(color: .warning)
+ XCTAssertTrue(s.requiresKMZ)
+ }
+
+ func test_addLabel_emitsTransparentHref_andIconColor() throws {
+ let builder = ForeFlightKMLBuilder(documentName: "Test")
+ builder.addLabel("Badge", coordinate: Coordinate(latitude: 51.0, longitude: -1.0), color: .warning)
+ let kml = builder.kmlString()
+
+ XCTAssertTrue(kml.contains("1x1.png"))
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains(""), "Label badge must emit IconStyle color (drives ForeFlight badge)")
+
+ do {
+ _ = try builder.buildKMZ()
+ } catch {
+ XCTFail("buildKMZ() threw: \(error)")
+ return
+ }
+
+ }
+
+ func test_labelBadge_doesNotEmitLabelStyle() {
+ let style = PointStyle.labelBadge(color: .warning, id: "fixed")
+ let xml = style.kmlString()
+
+ XCTAssertFalse(xml.contains(""), "labelBadge should omit LabelStyle (ForeFlight ignores it)")
+ }
}
diff --git a/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift b/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift
new file mode 100644
index 0000000..8b0f889
--- /dev/null
+++ b/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift
@@ -0,0 +1,44 @@
+import XCTest
+@testable import ForeFlightKML
+import GeodesySpherical
+
+final class LabelOnlyTests: XCTestCase {
+
+ func test_iconStyleTransparentLocalPng_emitsLocalHref() {
+ let kml = IconStyle.transparentLocalPng().kmlString()
+
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(
+ kml.contains("1x1.png"),
+ "Label-only icon must reference bundled transparent PNG"
+ )
+ XCTAssertTrue(kml.contains(""))
+ }
+
+ func test_pointStyleLabelOnly_emitsStyle() {
+ let style = PointStyle.labelBadge(color: .white)
+ let kml = style.kmlString()
+
+ XCTAssertTrue(kml.contains(""))
+ }
+
+ func test_builderAddLabel_emitsPlacemarkNameAndHiddenIconScale() throws {
+ let builder = ForeFlightKMLBuilder()
+
+ builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning)
+ let kml = try builder.build()
+
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains("