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` Image -## 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("