From 50a4122a00cfd3e1868bb77a3fa201f1cab46a82 Mon Sep 17 00:00:00 2001 From: Dan Leedham Date: Wed, 17 Dec 2025 14:35:50 +0000 Subject: [PATCH 1/6] Allow adding label without icon --- .../ForeFlightKMLDemo/KML/KMLGenerator.swift | 4 +- .../ForeFlightKMLDemo/Views/ContentView.swift | 15 ++-- Package.resolved | 9 ++ Package.swift | 9 +- README.md | 12 +++ .../ForeFlightKML/Errors/KMZExportError.swift | 5 ++ .../ForeFlightKML+Convenience.swift | 31 +++++++ Sources/ForeFlightKML/ForeFlightKML.swift | 5 ++ Sources/ForeFlightKML/ForeflightKML+KMZ.swift | 50 +++++++++++ .../ForeFlightKML/Models/StyleManager.swift | 7 ++ Sources/ForeFlightKML/Resources/1x1.png | Bin 0 -> 95 bytes .../Styles/Geometry/PointStyle.swift | 15 ++++ Sources/ForeFlightKML/Styles/IconStyle.swift | 15 ++++ Sources/ForeFlightKML/Styles/Style.swift | 6 ++ Sources/ForeFlightKML/Utils/Protocols.swift | 6 ++ .../ForeFlightKML+KMZTests.swift | 80 ++++++++++++++++++ .../ModelTests/AltitudeTests.swift | 2 - .../StyleTests/Geometry/PointStyleTests.swift | 33 +++++++- .../StyleTests/IconStyle+HiddenTests.swift | 44 ++++++++++ .../StyleTests/IconStyleTests.swift | 15 ++++ .../UserMapShapesSampleFullTest.swift | 4 +- .../UserMapShapesSampleIndividualTests.swift | 1 - 22 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 Sources/ForeFlightKML/Errors/KMZExportError.swift create mode 100644 Sources/ForeFlightKML/ForeflightKML+KMZ.swift create mode 100644 Sources/ForeFlightKML/Resources/1x1.png create mode 100644 Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift create mode 100644 Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift 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..80dc333 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,18 @@ Full public API surface is visible in the package sources; the README examples s - **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`. + +### ForeFlight point label color (important) + +For Point placemarks: +- LabelStyle.color is ignored +- Label badge color is driven by IconStyle.color + +To create a label-only point with a colored badge: +``` + builder.addLabel("Text", coordinate: .init(...), color: KMLColor) +``` + --- ## Demo & tests 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..f0110e8 100644 --- a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift +++ b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift @@ -291,4 +291,35 @@ extension ForeFlightKMLBuilder { let placemark = Placemark(name: name, geometry: segment, style: style) return addPlacemark(placemark) } + + @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) + ) + } + + @discardableResult + public func addLabel( + _ text: String, + latitude: Double, + longitude: Double, + altitude: Double? = nil, + color: KMLColor = .white + ) -> Self { + addLabel( + text, + coordinate: Coordinate(latitude: latitude, longitude: longitude), + altitude: altitude, + color: color + ) + } } diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift index 5df0777..3ec83e7 100644 --- a/Sources/ForeFlightKML/ForeFlightKML.swift +++ b/Sources/ForeFlightKML/ForeFlightKML.swift @@ -46,6 +46,11 @@ public final class ForeFlightKMLBuilder { } return self } + + /// True if this document must be exported as KMZ to render correctly. + var requiresKMZ: Bool { + styleManager.requiresKMZ + } // MARK: - Build Methods 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..s01R$Gz9%CSj!PC{xWt~$(697H@6ZHT9 literal 0 HcmV?d00001 diff --git a/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift b/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift index eb969e2..e648e6f 100644 --- a/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift +++ b/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift @@ -24,6 +24,10 @@ public struct PointStyle: KMLStyle { public func id() -> String { return styleId } + + public var requiresKMZ: Bool { + icon.requiresKMZ + } public func kmlString() -> String { var components: [String] = [] @@ -36,3 +40,14 @@ public struct PointStyle: KMLStyle { return components.joined(separator: "\n") } } + +public extension PointStyle { + /// A point style that renders only the placemark name as a label. + static func labelBadge(color: KMLColor = .white, id: String? = nil) -> PointStyle { + PointStyle( + icon: .transparentLocalPng(tint: color), + label: nil, // ForeFlight ignores LabelStyle for point badges + id: id + ) + } +} diff --git a/Sources/ForeFlightKML/Styles/IconStyle.swift b/Sources/ForeFlightKML/Styles/IconStyle.swift index 629b84f..9da0ed2 100644 --- a/Sources/ForeFlightKML/Styles/IconStyle.swift +++ b/Sources/ForeFlightKML/Styles/IconStyle.swift @@ -60,6 +60,15 @@ public struct IconStyle: KMLSubStyle { func iconHref() -> String { return iconHrefValue } + + /// True if this icon references a local resource and therefore + /// requires KMZ packaging to render correctly in ForeFlight. + var requiresKMZ: Bool { + // Local icon references (e.g. "1x1.png") must be packaged in a KMZ. + // Remote URLs (http/https) are fine in plain KML. + !iconHrefValue.hasPrefix("http://") && + !iconHrefValue.hasPrefix("https://") + } public func kmlString() -> String { var lines: [String] = [] @@ -127,3 +136,9 @@ public enum DefinedIconColor: String { case pink = "pink" case red = "red" } + +public extension IconStyle { + static func transparentLocalPng(tint color: KMLColor = .white, scale: Double? = 1.0) -> IconStyle { + IconStyle(href: "1x1.png", color: color, scale: scale) + } +} diff --git a/Sources/ForeFlightKML/Styles/Style.swift b/Sources/ForeFlightKML/Styles/Style.swift index 16a9d07..18660d8 100644 --- a/Sources/ForeFlightKML/Styles/Style.swift +++ b/Sources/ForeFlightKML/Styles/Style.swift @@ -19,6 +19,12 @@ internal struct Style: KMLStyle { public func id() -> String { return styleId } + + var requiresKMZ: Bool { + subStyles.contains { + ($0 as? IconStyle)?.requiresKMZ == true + } + } /// Create a new complete style. /// - Parameters: diff --git a/Sources/ForeFlightKML/Utils/Protocols.swift b/Sources/ForeFlightKML/Utils/Protocols.swift index a131adb..3ea966d 100644 --- a/Sources/ForeFlightKML/Utils/Protocols.swift +++ b/Sources/ForeFlightKML/Utils/Protocols.swift @@ -12,6 +12,12 @@ public protocol KMLStyle { // Top-level style that must provide an id and full KML (usually a ) 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..266434b --- /dev/null +++ b/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift @@ -0,0 +1,80 @@ +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/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..88752f2 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,35 @@ 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() { + let builder = ForeFlightKMLBuilder(documentName: "Test") + builder.addLabel("Badge", coordinate: Coordinate(latitude: 51.0, longitude: -1.0), color: .warning) + + let kml = builder.build() + + XCTAssertTrue(kml.contains("1x1.png")) + XCTAssertTrue(kml.contains("")) + XCTAssertTrue(kml.contains(""), "Label badge must emit IconStyle color (drives ForeFlight badge)") + } + + 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..315ad74 --- /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() { + let builder = ForeFlightKMLBuilder() + + builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning) + let kml = builder.build() + + XCTAssertTrue(kml.contains("")) + XCTAssertTrue(kml.contains("")) } - func test_builderAddLabel_emitsPlacemarkNameAndHiddenIconScale() { + func test_builderAddLabel_emitsPlacemarkNameAndHiddenIconScale() throws { let builder = ForeFlightKMLBuilder() builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning) - let kml = builder.build() + let kml = try builder.build() XCTAssertTrue(kml.contains("")) XCTAssertTrue(kml.contains("