Skip to content

Commit daf359e

Browse files
evil159claudegithub-actions[bot]
committed
[maps-ios] Fix PointAnnotation silent-fail for SF Symbol images (MAPSIOS-2182) (#12415)
## Summary `PointAnnotation.image` and `StyleManager.addImage(_:id:sdf:contentInsets:)` silently failed for any `UIImage` whose `size * scale` is fractional. The common trigger is an SF Symbol passed through `.withTintColor(_:renderingMode: .alwaysOriginal)` — the usual way to colorize a symbol before using it as a marker icon. Two related floating-point-to-integer mismatches combined to produce the failure — the second was hidden behind the first until the first was fixed. ### 1. `Data(uiImage:)` fallback redrew at cgImage dimensions `CoreMapsImage.init?(uiImage:)` declares the image to the native style API as `uiImage.size * uiImage.scale` pixels. For SF Symbols and `.withTintColor(...)` results, the backing `cgImage` pixel dimensions diverge from `size * scale`. The `default:` fallback in `Data.init(uiImage:)` produced a buffer sized to cgImage dims, so the byte count did not match the declared dimensions. Core: ``` StyleError(rawValue: "Raster image reference has invalid data size") ``` ### 2. Stretches overshoot declared bounds by sub-pixel amounts With (1) addressed, a second validation surfaced: `ImageProperties` computed stretch and content-box values as `Float(size) * scale` — a non-integer when `size * scale` is fractional — while the declared image width on native is `UInt32(size * scale)`, which truncates. The stretch right edge then lay 0.x pixels past the declared edge. Core: ``` StyleError(rawValue: "expected stretchX area lies within an image (width 108), got { left: 0, right: 109 }") ``` ### Reproduction, from `maps-agent-readiness-test/ios/t3-stretch` ```swift let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) let pin = UIImage(systemName: "mappin.circle.fill", withConfiguration: config)! .withTintColor(.systemRed, renderingMode: .alwaysOriginal) annotation.image = .init(image: pin, name: "pin") // no marker, two warnings in console ``` ## Fix - `Data.init(drawing:)` — now takes a `UIImage` (was `CGImage`), allocates the buffer at `size * scale`, and uses `UIImage.draw(at:)` into a `CGContext` scaled by `uiImage.scale` with a flipped Y axis. Fast paths for PNG/JPEG/padded-row images are untouched. - `ImageProperties` — floors stretch and content-box values to the same `Int(size * scale)` bounds the image is declared at, so they cannot overshoot the declared dimensions for any UIImage. ## Wrong-track note This supersedes the closed #12390, which misidentified the root cause as a nil `cgImage` on SF Symbols. `UIImage(systemName:).cgImage` is non-nil on iOS 14+ (empirically verified against iOS 15.5, 16.4, 18.6 simulators). ## Jira MAPSIOS-2182 ## Test plan - [x] `xcodebuild test -scheme MapboxTestHost -only-testing:MapboxMapsTests/ImageTests` — all 7 tests pass, including two new regressions that exercise the exact t3-stretch repro (`testSymbolImageRoundTripsWithMatchingDataSize`, `testImagePropertiesStretchWithinDeclaredBoundsForSymbolImage`). - [ ] Visually verify in `maps-agent-readiness-test/ios/t3-stretch` by pointing the app at this local SDK build and confirming the coffee-shop pins render. - [ ] Full `make build-source-tests-sim` run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) cc @mapbox/maps-ios --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Changelog autocreator bot <github-actions[bot]@users.noreply.github.com> GitOrigin-RevId: 892defc6624da88ae1d521f1e7076ba4e5f3f2b5
1 parent 31e2461 commit daf359e

3 files changed

Lines changed: 83 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Mapbox welcomes participation and contributions from everyone.
55
## main
66

77
* Fix `MapView` rendering blank when attached to an already-active CarPlay scene.
8+
* Fix SF Symbols silently failing to display when used as style images via `PointAnnotation.image` or `StyleManager.addImage`.
89

910
## Features ✨ and improvements 🏁
1011
* Expose `FeaturesetFeature.originalFeature` property.

Sources/MapboxMaps/Style/Types/StyleImage.swift

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,27 @@ struct ImageProperties {
7272
init(uiImage: UIImage, contentInsets: UIEdgeInsets, id: String, sdf: Bool) {
7373
self.id = id
7474
self.scale = Float(uiImage.scale)
75-
self.stretchXFirst = Float(uiImage.capInsets.left) * scale
76-
self.stretchXSecond = Float(uiImage.size.width - uiImage.capInsets.right) * scale
77-
self.stretchYFirst = Float(uiImage.capInsets.top) * scale
78-
self.stretchYSecond = Float(uiImage.size.height - uiImage.capInsets.bottom) * scale
79-
80-
let contentBoxLeft = Float(contentInsets.left) * scale
81-
let contentBoxRight = Float(uiImage.size.width - contentInsets.right) * scale
82-
let contentBoxTop = Float(contentInsets.top) * scale
83-
let contentBoxBottom = Float(uiImage.size.height - contentInsets.bottom) * scale
75+
76+
// Stretch and content-box values in pixels must lie within the
77+
// declared image width/height. `CoreMapsImage.init?(uiImage:)` declares
78+
// those using `UInt32(size * scale)`, which truncates fractional
79+
// results. For UIImages whose `size * scale` is non-integer — notably
80+
// SF Symbols at non-integer point sizes — computing stretches as
81+
// `Float(size) * scale` left them 0.x pixels past the declared edge,
82+
// which the native side rejects with "expected stretchX area lies
83+
// within an image". Floor to the same integer bounds the image is
84+
// declared at.
85+
let widthPx = Float(Int(uiImage.size.width * uiImage.scale))
86+
let heightPx = Float(Int(uiImage.size.height * uiImage.scale))
87+
self.stretchXFirst = Float(Int(uiImage.capInsets.left * uiImage.scale))
88+
self.stretchXSecond = widthPx - Float(Int(uiImage.capInsets.right * uiImage.scale))
89+
self.stretchYFirst = Float(Int(uiImage.capInsets.top * uiImage.scale))
90+
self.stretchYSecond = heightPx - Float(Int(uiImage.capInsets.bottom * uiImage.scale))
91+
92+
let contentBoxLeft = Float(Int(contentInsets.left * uiImage.scale))
93+
let contentBoxRight = widthPx - Float(Int(contentInsets.right * uiImage.scale))
94+
let contentBoxTop = Float(Int(contentInsets.top * uiImage.scale))
95+
let contentBoxBottom = heightPx - Float(Int(contentInsets.bottom * uiImage.scale))
8496
self.contentBox = ImageContent(left: contentBoxLeft,
8597
top: contentBoxTop,
8698
right: contentBoxRight,

Tests/MapboxMapsTests/Style/ImageTests.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,65 @@ final class ImageTests: XCTestCase {
139139
// The resulting image should not have padded rows.
140140
XCTAssertEqual(Int(mbmImage.width * mbmImage.height) * bytesPerPixel, mbmImage.data.data.count)
141141
}
142+
143+
// Regression test: `PointAnnotation.image = .init(image: sfSymbol, ...)`
144+
// previously triggered "Raster image reference has invalid data size" in
145+
// the core and rendered nothing. SF Symbols (and the result of
146+
// `.withTintColor(...)` on them) have a non-nil cgImage whose pixel
147+
// dimensions don't match `uiImage.size * uiImage.scale`. The default
148+
// branch in `Data(uiImage:)` used to redraw at cgImage dims, which
149+
// produced a buffer shorter than what `CoreMapsImage.init` declared to
150+
// the native side. It now redraws at `size * scale`, so declared
151+
// dimensions, buffer length, and intended display size agree.
152+
func testSymbolImageRoundTripsWithMatchingDataSize() throws {
153+
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
154+
let symbol = try XCTUnwrap(UIImage(systemName: "mappin.circle.fill", withConfiguration: config))
155+
.withTintColor(.systemRed, renderingMode: .alwaysOriginal)
156+
157+
// Preconditions for the regression: cgImage diverges from size*scale.
158+
let cgImage = try XCTUnwrap(symbol.cgImage)
159+
let declaredW = Int(symbol.size.width * symbol.scale)
160+
let declaredH = Int(symbol.size.height * symbol.scale)
161+
XCTAssertNotEqual(
162+
declaredW * declaredH, cgImage.width * cgImage.height,
163+
"Preconditions: SF Symbol cgImage dims should diverge from size*scale")
164+
165+
let mbmImage = try XCTUnwrap(CoreMapsImage(uiImage: symbol))
166+
167+
// The native side rejects any mismatch between declared dims and byte count.
168+
XCTAssertEqual(Int(mbmImage.width), declaredW)
169+
XCTAssertEqual(Int(mbmImage.height), declaredH)
170+
XCTAssertEqual(Int(mbmImage.width * mbmImage.height) * 4, mbmImage.data.data.count)
171+
}
172+
173+
// Second half of the SF-Symbol regression: stretch and content-box
174+
// bounds must lie within the declared image dimensions. For a UIImage
175+
// whose `size * scale` is not integer (common for SF Symbols at
176+
// non-integer point sizes), stretchXSecond used to overshoot the
177+
// truncated UInt32 image width by sub-pixel amounts, producing
178+
// `StyleError("expected stretchX area lies within an image")`.
179+
func testImagePropertiesStretchWithinDeclaredBoundsForSymbolImage() throws {
180+
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
181+
let symbol = try XCTUnwrap(UIImage(systemName: "mappin.circle.fill", withConfiguration: config))
182+
.withTintColor(.systemRed, renderingMode: .alwaysOriginal)
183+
184+
// Preconditions: size*scale is non-integer — without this, the
185+
// original float-scaled stretch logic would work.
186+
let fractionalWidth = symbol.size.width * symbol.scale
187+
XCTAssertNotEqual(fractionalWidth, fractionalWidth.rounded(.down),
188+
"Preconditions: SF Symbol size*scale should be non-integer")
189+
190+
let props = ImageProperties(uiImage: symbol, contentInsets: .zero, id: "pin", sdf: false)
191+
192+
let declaredW = Float(UInt32(symbol.size.width * symbol.scale))
193+
let declaredH = Float(UInt32(symbol.size.height * symbol.scale))
194+
XCTAssertGreaterThanOrEqual(props.stretchXFirst, 0)
195+
XCTAssertLessThanOrEqual(props.stretchXSecond, declaredW)
196+
XCTAssertGreaterThanOrEqual(props.stretchYFirst, 0)
197+
XCTAssertLessThanOrEqual(props.stretchYSecond, declaredH)
198+
XCTAssertGreaterThanOrEqual(props.contentBox.left, 0)
199+
XCTAssertLessThanOrEqual(props.contentBox.right, declaredW)
200+
XCTAssertGreaterThanOrEqual(props.contentBox.top, 0)
201+
XCTAssertLessThanOrEqual(props.contentBox.bottom, declaredH)
202+
}
142203
}

0 commit comments

Comments
 (0)