Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

}

Expand Down
9 changes: 5 additions & 4 deletions Example/ForeFlightKMLDemo/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import MapKit
import ForeFlightKML

struct ContentView: View {
@State private var lastTapCoordinate: CLLocationCoordinate2D?
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ let package = Package(
.testTarget(
name: "ForeFlightKMLTests",
dependencies: ["ForeFlightKML"]
),
.testTarget(
name: "UserMapShapeTests",
dependencies: ["ForeFlightKML"]
)
]
)
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

<img width="615" height="770" alt="Image" src="/docs/example-output.png" />

Expand All @@ -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:)],
Expand All @@ -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)
```

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Sources/ForeFlightKML/Enums/OutputFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
public enum OutputFormat {
case kml
case kmz
}
19 changes: 19 additions & 0 deletions Sources/ForeFlightKML/Errors/BuildError.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
public enum KMZExportError: Error {
public enum ExportError: Error {
case kmzRequired
case missingLocalResource(String)
case archiveCreationFailed
Expand Down
30 changes: 24 additions & 6 deletions Sources/ForeFlightKML/ForeFlightKML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Document>` element.
private var documentName: String?
/// Collection of placemarks added to this builder.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/ForeFlightKML/ForeflightKML+KMZ.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions Sources/ForeFlightKML/Utils/DataFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

public struct BuildResult {
public let data: Data
public let fileExtension: String
public let mimetype: String
}
4 changes: 4 additions & 0 deletions Sources/ForeFlightKML/Utils/Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ public protocol KMLSubStyle {
// These produce the inner element tags (<LineStyle>...</LineStyle>, <PolyStyle>...</PolyStyle>).
func kmlString() -> String
}

protocol Building {
func build(as format: OutputFormat) throws -> BuildResult
}
92 changes: 92 additions & 0 deletions Tests/ForeFlightKMLTests/Builder/ForeFlightKML+BuildTests.swift
Original file line number Diff line number Diff line change
@@ -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("<kml ") == true)
}

func test_build_asKML_whenKMZRequired_throwsUnsupportedFeature() {
let builder = ForeFlightKMLBuilder()
// addLabel uses a local 1x1.png badge and therefore requires KMZ
builder.addLabel("Badge", coordinate: Coordinate(latitude: 1, longitude: 1), color: .warning)
XCTAssertTrue(builder.requiresKMZ)

XCTAssertThrowsError(try builder.build(as: .kml)) { error in
guard let buildError = error as? BuildError else {
return XCTFail("Expected BuildError.unsupportedFeatureForKML, got: \(error)")
}
XCTAssertEqual(buildError, .unsupportedFeatureForKML)
}
}

func test_setDocumentName_reflectedInOutput() {
let builder = ForeFlightKMLBuilder()
builder.setDocumentName("My Doc")
builder.addPoint(name: "p", coordinate: Coordinate(latitude: 0, longitude: 0))

let kml = builder.kmlString()
XCTAssertTrue(kml.contains("<name>My Doc</name>"))
}

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)
}
}
19 changes: 19 additions & 0 deletions Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,23 @@ final class PolygonTests: XCTestCase {

XCTAssertFalse(kml.contains("<altitudeMode>"))
}

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("<innerBoundaryIs>"))
XCTAssertTrue(kml.contains("<outerBoundaryIs>"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down