From 1c2916fc04fa0abfec5244ee8558433d0c7c2ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Bujnovsk=C3=BD?= <2659269+miakh@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:29:20 +0200 Subject: [PATCH 1/2] Handle GeoJSON features with empty coordinates --- platform/darwin/src/MLNShape.mm | 89 ++++++++++++++++++++- platform/darwin/test/MLNShapeSourceTests.mm | 46 +++++++++++ platform/ios/CHANGELOG.md | 1 + 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/platform/darwin/src/MLNShape.mm b/platform/darwin/src/MLNShape.mm index ad90132dbb68..d8e660ab0a9b 100644 --- a/platform/darwin/src/MLNShape.mm +++ b/platform/darwin/src/MLNShape.mm @@ -11,6 +11,72 @@ return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude; } +static BOOL MLNIsGeoJSONGeometryType(NSString *type) { + return [type isEqualToString:@"Point"] || [type isEqualToString:@"MultiPoint"] || + [type isEqualToString:@"LineString"] || [type isEqualToString:@"MultiLineString"] || + [type isEqualToString:@"Polygon"] || [type isEqualToString:@"MultiPolygon"]; +} + +static id MLNGeoJSONObjectByReplacingEmptyCoordinates(id object, BOOL *changed) { + if ([object isKindOfClass:[NSArray class]]) { + NSArray *array = object; + NSMutableArray *updatedArray = nil; + for (NSUInteger index = 0; index < array.count; index++) { + id updatedObject = MLNGeoJSONObjectByReplacingEmptyCoordinates(array[index], changed); + if (updatedObject != array[index]) { + if (!updatedArray) { + updatedArray = [array mutableCopy]; + } + updatedArray[index] = updatedObject; + } + } + return updatedArray ?: object; + } + + if (![object isKindOfClass:[NSDictionary class]]) { + return object; + } + + NSDictionary *dictionary = object; + NSString *type = dictionary[@"type"]; + if (![type isKindOfClass:[NSString class]]) { + return object; + } + id coordinates = dictionary[@"coordinates"]; + if (MLNIsGeoJSONGeometryType(type) && [coordinates isKindOfClass:[NSArray class]] && + [coordinates count] == 0) { + *changed = YES; + return [NSNull null]; + } + + NSString *childKey = nil; + if ([type isEqualToString:@"Feature"]) { + childKey = @"geometry"; + } else if ([type isEqualToString:@"FeatureCollection"]) { + childKey = @"features"; + } else if ([type isEqualToString:@"GeometryCollection"]) { + childKey = @"geometries"; + } + + if (!childKey) { + return object; + } + + id child = dictionary[childKey]; + if (!child) { + return object; + } + + id updatedChild = MLNGeoJSONObjectByReplacingEmptyCoordinates(child, changed); + if (updatedChild == child) { + return object; + } + + NSMutableDictionary *updatedDictionary = [dictionary mutableCopy]; + updatedDictionary[childKey] = updatedChild; + return updatedDictionary; +} + @implementation MLNShape + (nullable MLNShape *)shapeWithData:(NSData *)data @@ -28,11 +94,32 @@ + (nullable MLNShape *)shapeWithData:(NSData *)data const auto geojson = mapbox::geojson::parse(string.UTF8String); return MLNShapeFromGeoJSON(geojson); } catch (std::runtime_error &err) { + NSString *failureReason = @(err.what()); + NSData *utf8Data = [string dataUsingEncoding:NSUTF8StringEncoding]; + id jsonObject = [NSJSONSerialization JSONObjectWithData:utf8Data options:0 error:nil]; + BOOL changed = NO; + id updatedJSONObject = MLNGeoJSONObjectByReplacingEmptyCoordinates(jsonObject, &changed); + if (changed) { + if ([updatedJSONObject isKindOfClass:[NSNull class]]) { + updatedJSONObject = @{@"type" : @"GeometryCollection", @"geometries" : @[]}; + } + NSData *updatedData = [NSJSONSerialization dataWithJSONObject:updatedJSONObject + options:NSJSONWritingFragmentsAllowed + error:nil]; + NSString *updatedString = [[NSString alloc] initWithData:updatedData + encoding:NSUTF8StringEncoding]; + try { + const auto geojson = mapbox::geojson::parse(updatedString.UTF8String); + return MLNShapeFromGeoJSON(geojson); + } catch (std::runtime_error &updatedError) { + failureReason = @(updatedError.what()); + } + } if (outError) { *outError = [NSError errorWithDomain:MLNErrorDomain code:MLNErrorCodeUnknown userInfo:@{ - NSLocalizedFailureReasonErrorKey : @(err.what()), + NSLocalizedFailureReasonErrorKey : failureReason, }]; } return nil; diff --git a/platform/darwin/test/MLNShapeSourceTests.mm b/platform/darwin/test/MLNShapeSourceTests.mm index cb2f11f9ee6b..79680d5063ee 100644 --- a/platform/darwin/test/MLNShapeSourceTests.mm +++ b/platform/darwin/test/MLNShapeSourceTests.mm @@ -91,6 +91,52 @@ - (void)testMLNShapeSourceWithDataMultipleFeatures { XCTAssertTrue([collection.shapes.firstObject isMemberOfClass:[MLNPolylineFeature class]]); } +- (void)testMLNShapeSourceWithEmptyGeometries { + NSString *geoJSON = @"{\"type\":\"FeatureCollection\",\"features\":[" + "{\"type\":\"Feature\",\"properties\":{},\"geometry\":null}," + "{\"type\":\"Feature\",\"properties\":{},\"geometry\":{\"type\":\"Point\"," + "\"coordinates\":[]}}," + "{\"type\":\"Feature\",\"properties\":{\"name\":\"Café\"}," + "\"geometry\":{\"type\":\"Point\"," + "\"coordinates\":[0,0]}}]}"; + NSData *data = [geoJSON dataUsingEncoding:NSISOLatin1StringEncoding]; + NSError *error = nil; + MLNShapeCollectionFeature *collection = + (MLNShapeCollectionFeature *)[MLNShape shapeWithData:data + encoding:NSISOLatin1StringEncoding + error:&error]; + + XCTAssertNil(error); + XCTAssertEqual(collection.shapes.count, 3UL); + XCTAssertTrue([collection.shapes[0] isKindOfClass:[MLNEmptyFeature class]]); + XCTAssertTrue([collection.shapes[1] isKindOfClass:[MLNEmptyFeature class]]); + XCTAssertTrue([collection.shapes[2] isKindOfClass:[MLNPointFeature class]]); + XCTAssertEqualObjects([collection.shapes[2] attributeForKey:@"name"], @"Café"); + + MLNShapeSource *source; + XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" + shape:collection + options:nil]); + XCTAssertNotNil(source.shape); +} + +- (void)testMLNShapeSourceWithEmptyTopLevelGeometry { + NSData *data = + [@"{\"type\":\"Point\",\"coordinates\":[]}" dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + MLNShape *shape = [MLNShape shapeWithData:data encoding:NSUTF8StringEncoding error:&error]; + + XCTAssertNil(error); + XCTAssertTrue([shape isKindOfClass:[MLNShapeCollection class]]); + XCTAssertEqual(((MLNShapeCollection *)shape).shapes.count, 0UL); + + MLNShapeSource *source; + XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" + shape:shape + options:nil]); + XCTAssertNotNil(source.shape); +} + - (void)testMLNShapeSourceWithSingleGeometry { NSData *data = [@"{\"type\": \"Point\", \"coordinates\": [0, 0]}" dataUsingEncoding:NSUTF8StringEncoding]; diff --git a/platform/ios/CHANGELOG.md b/platform/ios/CHANGELOG.md index 4dfda1595571..594a989862af 100644 --- a/platform/ios/CHANGELOG.md +++ b/platform/ios/CHANGELOG.md @@ -4,6 +4,7 @@ MapLibre welcomes participation and contributions from everyone. Please read [`M ## 6.29.0 +- iOS: Accept GeoJSON features with empty coordinates. - fix(core): accept alpha in hsl colors ([#4435](https://github.com/maplibre/maplibre-native/pull/4435)). - fix(core): repaint feature-state-driven symbol paint properties ([#4445](https://github.com/maplibre/maplibre-native/pull/4445)). - Make string expressions operate on unicode ([#4344](https://github.com/maplibre/maplibre-native/pull/4344)). From 1f2ab2de44a191e6bce7992fa4b70fa79ee546f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Bujnovsk=C3=BD?= <2659269+miakh@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:17:59 +0200 Subject: [PATCH 2/2] Narrow empty GeoJSON handling to features --- platform/darwin/src/MLNShape.mm | 124 ++++++++++---------- platform/darwin/test/MLNShapeSourceTests.mm | 66 +++++++---- platform/ios/CHANGELOG.md | 2 +- platform/macos/CHANGELOG.md | 1 + 4 files changed, 104 insertions(+), 89 deletions(-) diff --git a/platform/darwin/src/MLNShape.mm b/platform/darwin/src/MLNShape.mm index d8e660ab0a9b..ee75f3240493 100644 --- a/platform/darwin/src/MLNShape.mm +++ b/platform/darwin/src/MLNShape.mm @@ -17,64 +17,78 @@ static BOOL MLNIsGeoJSONGeometryType(NSString *type) { [type isEqualToString:@"Polygon"] || [type isEqualToString:@"MultiPolygon"]; } -static id MLNGeoJSONObjectByReplacingEmptyCoordinates(id object, BOOL *changed) { - if ([object isKindOfClass:[NSArray class]]) { - NSArray *array = object; - NSMutableArray *updatedArray = nil; - for (NSUInteger index = 0; index < array.count; index++) { - id updatedObject = MLNGeoJSONObjectByReplacingEmptyCoordinates(array[index], changed); - if (updatedObject != array[index]) { - if (!updatedArray) { - updatedArray = [array mutableCopy]; - } - updatedArray[index] = updatedObject; - } - } - return updatedArray ?: object; - } +static NSRegularExpression *MLNEmptyArrayRegularExpression(void) { + static NSRegularExpression *regularExpression; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + regularExpression = [NSRegularExpression regularExpressionWithPattern:@"\\[\\s*\\]" + options:0 + error:nil]; + }); + return regularExpression; +} - if (![object isKindOfClass:[NSDictionary class]]) { - return object; +static BOOL MLNReplaceEmptyFeatureCoordinates(id object) { + if (![object isKindOfClass:[NSMutableDictionary class]]) { + return NO; } - NSDictionary *dictionary = object; + NSMutableDictionary *dictionary = object; NSString *type = dictionary[@"type"]; if (![type isKindOfClass:[NSString class]]) { - return object; + return NO; + } + if ([type isEqualToString:@"Feature"]) { + id geometry = dictionary[@"geometry"]; + if (![geometry isKindOfClass:[NSDictionary class]]) { + return NO; + } + + NSString *geometryType = geometry[@"type"]; + id coordinates = geometry[@"coordinates"]; + if (![geometryType isKindOfClass:[NSString class]] || !MLNIsGeoJSONGeometryType(geometryType) || + ![coordinates isKindOfClass:[NSArray class]] || [coordinates count] != 0) { + return NO; + } + + dictionary[@"geometry"] = [NSNull null]; + return YES; } - id coordinates = dictionary[@"coordinates"]; - if (MLNIsGeoJSONGeometryType(type) && [coordinates isKindOfClass:[NSArray class]] && - [coordinates count] == 0) { - *changed = YES; - return [NSNull null]; + + if (![type isEqualToString:@"FeatureCollection"]) { + return NO; } - NSString *childKey = nil; - if ([type isEqualToString:@"Feature"]) { - childKey = @"geometry"; - } else if ([type isEqualToString:@"FeatureCollection"]) { - childKey = @"features"; - } else if ([type isEqualToString:@"GeometryCollection"]) { - childKey = @"geometries"; + NSArray *features = dictionary[@"features"]; + if (![features isKindOfClass:[NSArray class]]) { + return NO; } - if (!childKey) { - return object; + BOOL changed = NO; + for (id feature in features) { + changed |= MLNReplaceEmptyFeatureCoordinates(feature); } + return changed; +} - id child = dictionary[childKey]; - if (!child) { - return object; +static NSString *MLNGeoJSONStringByReplacingEmptyFeatureCoordinates(NSString *string) { + NSRange range = NSMakeRange(0, string.length); + if (![MLNEmptyArrayRegularExpression() firstMatchInString:string options:0 range:range]) { + return string; } - id updatedChild = MLNGeoJSONObjectByReplacingEmptyCoordinates(child, changed); - if (updatedChild == child) { - return object; + NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; + id jsonObject = [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingMutableContainers + error:nil]; + if (!MLNReplaceEmptyFeatureCoordinates(jsonObject)) { + return string; } - NSMutableDictionary *updatedDictionary = [dictionary mutableCopy]; - updatedDictionary[childKey] = updatedChild; - return updatedDictionary; + NSData *updatedData = [NSJSONSerialization dataWithJSONObject:jsonObject options:0 error:nil]; + NSString *updatedString = [[NSString alloc] initWithData:updatedData + encoding:NSUTF8StringEncoding]; + return updatedString ?: string; } @implementation MLNShape @@ -90,36 +104,16 @@ + (nullable MLNShape *)shapeWithData:(NSData *)data return nil; } + NSString *normalizedString = MLNGeoJSONStringByReplacingEmptyFeatureCoordinates(string); try { - const auto geojson = mapbox::geojson::parse(string.UTF8String); + const auto geojson = mapbox::geojson::parse(normalizedString.UTF8String); return MLNShapeFromGeoJSON(geojson); } catch (std::runtime_error &err) { - NSString *failureReason = @(err.what()); - NSData *utf8Data = [string dataUsingEncoding:NSUTF8StringEncoding]; - id jsonObject = [NSJSONSerialization JSONObjectWithData:utf8Data options:0 error:nil]; - BOOL changed = NO; - id updatedJSONObject = MLNGeoJSONObjectByReplacingEmptyCoordinates(jsonObject, &changed); - if (changed) { - if ([updatedJSONObject isKindOfClass:[NSNull class]]) { - updatedJSONObject = @{@"type" : @"GeometryCollection", @"geometries" : @[]}; - } - NSData *updatedData = [NSJSONSerialization dataWithJSONObject:updatedJSONObject - options:NSJSONWritingFragmentsAllowed - error:nil]; - NSString *updatedString = [[NSString alloc] initWithData:updatedData - encoding:NSUTF8StringEncoding]; - try { - const auto geojson = mapbox::geojson::parse(updatedString.UTF8String); - return MLNShapeFromGeoJSON(geojson); - } catch (std::runtime_error &updatedError) { - failureReason = @(updatedError.what()); - } - } if (outError) { *outError = [NSError errorWithDomain:MLNErrorDomain code:MLNErrorCodeUnknown userInfo:@{ - NSLocalizedFailureReasonErrorKey : failureReason, + NSLocalizedFailureReasonErrorKey : @(err.what()), }]; } return nil; diff --git a/platform/darwin/test/MLNShapeSourceTests.mm b/platform/darwin/test/MLNShapeSourceTests.mm index 79680d5063ee..0ae99863eb9f 100644 --- a/platform/darwin/test/MLNShapeSourceTests.mm +++ b/platform/darwin/test/MLNShapeSourceTests.mm @@ -91,9 +91,47 @@ - (void)testMLNShapeSourceWithDataMultipleFeatures { XCTAssertTrue([collection.shapes.firstObject isMemberOfClass:[MLNPolylineFeature class]]); } -- (void)testMLNShapeSourceWithEmptyGeometries { +- (void)testMLNShapeSourceWithNullGeometry { + NSData *data = [@"{\"type\":\"Feature\",\"properties\":{},\"geometry\":null}" + dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + MLNShape *shape = [MLNShape shapeWithData:data encoding:NSUTF8StringEncoding error:&error]; + + XCTAssertNil(error); + XCTAssertTrue([shape isKindOfClass:[MLNEmptyFeature class]]); + + MLNShapeSource *source; + XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" + shape:shape + options:nil]); + XCTAssertNotNil(source.shape); +} + +- (void)testMLNShapeSourceWithEmptyFeatureCoordinates { + NSArray *geometryTypes = + @[ @"Point", @"MultiPoint", @"LineString", @"MultiLineString", @"Polygon", @"MultiPolygon" ]; + for (NSString *geometryType in geometryTypes) { + NSString *geoJSON = + [NSString stringWithFormat:@"{\"type\":\"Feature\",\"properties\":{},\"geometry\":{" + @"\"type\":\"%@\",\"coordinates\":[ ]}}", + geometryType]; + NSData *data = [geoJSON dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + MLNShape *shape = [MLNShape shapeWithData:data encoding:NSUTF8StringEncoding error:&error]; + + XCTAssertNil(error); + XCTAssertTrue([shape isKindOfClass:[MLNEmptyFeature class]]); + + MLNShapeSource *source; + XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" + shape:shape + options:nil]); + XCTAssertNotNil(source.shape); + } +} + +- (void)testMLNShapeSourceWithEmptyFeatureAndValidSibling { NSString *geoJSON = @"{\"type\":\"FeatureCollection\",\"features\":[" - "{\"type\":\"Feature\",\"properties\":{},\"geometry\":null}," "{\"type\":\"Feature\",\"properties\":{},\"geometry\":{\"type\":\"Point\"," "\"coordinates\":[]}}," "{\"type\":\"Feature\",\"properties\":{\"name\":\"Café\"}," @@ -107,11 +145,10 @@ - (void)testMLNShapeSourceWithEmptyGeometries { error:&error]; XCTAssertNil(error); - XCTAssertEqual(collection.shapes.count, 3UL); + XCTAssertEqual(collection.shapes.count, 2UL); XCTAssertTrue([collection.shapes[0] isKindOfClass:[MLNEmptyFeature class]]); - XCTAssertTrue([collection.shapes[1] isKindOfClass:[MLNEmptyFeature class]]); - XCTAssertTrue([collection.shapes[2] isKindOfClass:[MLNPointFeature class]]); - XCTAssertEqualObjects([collection.shapes[2] attributeForKey:@"name"], @"Café"); + XCTAssertTrue([collection.shapes[1] isKindOfClass:[MLNPointFeature class]]); + XCTAssertEqualObjects([collection.shapes[1] attributeForKey:@"name"], @"Café"); MLNShapeSource *source; XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" @@ -120,23 +157,6 @@ - (void)testMLNShapeSourceWithEmptyGeometries { XCTAssertNotNil(source.shape); } -- (void)testMLNShapeSourceWithEmptyTopLevelGeometry { - NSData *data = - [@"{\"type\":\"Point\",\"coordinates\":[]}" dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error = nil; - MLNShape *shape = [MLNShape shapeWithData:data encoding:NSUTF8StringEncoding error:&error]; - - XCTAssertNil(error); - XCTAssertTrue([shape isKindOfClass:[MLNShapeCollection class]]); - XCTAssertEqual(((MLNShapeCollection *)shape).shapes.count, 0UL); - - MLNShapeSource *source; - XCTAssertNoThrow(source = [[MLNShapeSource alloc] initWithIdentifier:@"source-id" - shape:shape - options:nil]); - XCTAssertNotNil(source.shape); -} - - (void)testMLNShapeSourceWithSingleGeometry { NSData *data = [@"{\"type\": \"Point\", \"coordinates\": [0, 0]}" dataUsingEncoding:NSUTF8StringEncoding]; diff --git a/platform/ios/CHANGELOG.md b/platform/ios/CHANGELOG.md index 594a989862af..1762ffd350d0 100644 --- a/platform/ios/CHANGELOG.md +++ b/platform/ios/CHANGELOG.md @@ -4,7 +4,7 @@ MapLibre welcomes participation and contributions from everyone. Please read [`M ## 6.29.0 -- iOS: Accept GeoJSON features with empty coordinates. +- iOS: Accept GeoJSON features with empty coordinate arrays. - fix(core): accept alpha in hsl colors ([#4435](https://github.com/maplibre/maplibre-native/pull/4435)). - fix(core): repaint feature-state-driven symbol paint properties ([#4445](https://github.com/maplibre/maplibre-native/pull/4445)). - Make string expressions operate on unicode ([#4344](https://github.com/maplibre/maplibre-native/pull/4344)). diff --git a/platform/macos/CHANGELOG.md b/platform/macos/CHANGELOG.md index 09b9d38b4443..08cda882179c 100644 --- a/platform/macos/CHANGELOG.md +++ b/platform/macos/CHANGELOG.md @@ -4,6 +4,7 @@ MapLibre welcomes participation and contributions from everyone. Please read [`C ## 6.27.0 +- Accept GeoJSON features with empty coordinate arrays. - First automated native macOS XCFramework release ([#4088](https://github.com/maplibre/maplibre-native/issues/4088)). - Add Bazel `MapLibre.dynamic` and `MapLibre.static` xcframework targets for macOS ([#4113](https://github.com/maplibre/maplibre-native/pull/4113)). - Fix Xcode 26 build compatibility ([#4226](https://github.com/maplibre/maplibre-native/pull/4226)).