diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
index 953e0a9..b0d1bd6 100644
--- a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
+++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
@@ -138,6 +138,7 @@ public struct SFSymbolRenderer {
}
template.normalizeVariants()
+ try template.validateVariants()
template.setSize(size)
let element = try XML.Formatter.SVG(formatter: formatter).makeElement(from: template.svg)
@@ -460,6 +461,53 @@ struct SFSymbolTemplate {
}
}
+ /// Verifies that all three weight variants share an identical path structure.
+ ///
+ /// SF Symbols stores only the ultralight, regular and black anchors and derives every
+ /// other weight and scale by interpolating between them point by point. That mapping
+ /// requires the three variants to agree on the number of paths and, per path, on the
+ /// sequence of segment types. When they don't, the symbol compiles into an asset
+ /// catalog without complaint and CoreUI raises an exception the first time it is drawn.
+ ///
+ /// `normalizeVariants()` repairs the cases it can; this reports what is left.
+ func validateVariants() throws {
+ let ultralightPaths = ultralight.contents.paths
+ let regularPaths = regular.contents.paths
+ let blackPaths = black.contents.paths
+
+ guard ultralightPaths.count == regularPaths.count,
+ regularPaths.count == blackPaths.count else {
+ throw Self.makeVariantError(
+ "path count is \(ultralightPaths.count)/\(regularPaths.count)/\(blackPaths.count) (ultralight/regular/black)"
+ )
+ }
+
+ for index in regularPaths.indices {
+ let ultralightTypes = ultralightPaths[index].segments.map(\.commandType)
+ let regularTypes = regularPaths[index].segments.map(\.commandType)
+ let blackTypes = blackPaths[index].segments.map(\.commandType)
+
+ guard ultralightTypes.count == regularTypes.count,
+ regularTypes.count == blackTypes.count else {
+ throw Self.makeVariantError(
+ "path \(index) has \(ultralightTypes.count)/\(regularTypes.count)/\(blackTypes.count) segments (ultralight/regular/black)"
+ )
+ }
+
+ guard ultralightTypes == regularTypes, regularTypes == blackTypes else {
+ throw Self.makeVariantError("path \(index) has mismatched segment types")
+ }
+ }
+ }
+
+ static func makeVariantError(_ detail: String) -> SFSymbolRenderer.Error {
+ SFSymbolRenderer.Error(
+ "Variants are not interpolatable: \(detail). "
+ + "All three variants must share an identical path structure — same number of paths, "
+ + "and the same sequence of segment types within each path."
+ )
+ }
+
static func normalizeSegments(
_ a: inout [DOM.Path.Segment],
_ b: inout [DOM.Path.Segment],
diff --git a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift
index ed2c004..f792a53 100644
--- a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift
+++ b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift
@@ -282,6 +282,143 @@ final class RendererSFSymbolTests: XCTestCase {
XCTFail("Expected cubic segment")
}
}
+
+ // MARK: - Variant Validation Tests
+
+ func testValidateVariants_MatchingStructure() throws {
+ var template = try SFSymbolTemplate.make()
+ template.setVariantSegments(ultralight: [.squareSegments], regular: [.squareSegments], black: [.squareSegments])
+
+ XCTAssertNoThrow(try template.validateVariants())
+ }
+
+ func testValidateVariants_ThrowsForPathCountMismatch() throws {
+ var template = try SFSymbolTemplate.make()
+ template.setVariantSegments(
+ ultralight: [.squareSegments, .squareSegments],
+ regular: [.squareSegments, .squareSegments],
+ black: [.squareSegments]
+ )
+
+ XCTAssertThrowsError(try template.validateVariants()) {
+ XCTAssertTrue(
+ $0.localizedDescription.contains("path count is 2/2/1"),
+ "Expected the path counts in the message, got: \($0.localizedDescription)"
+ )
+ }
+ }
+
+ func testValidateVariants_ThrowsForSegmentCountMismatch() throws {
+ var template = try SFSymbolTemplate.make()
+ template.setVariantSegments(
+ ultralight: [.squareSegments],
+ regular: [.squareSegments],
+ black: [.triangleSegments]
+ )
+
+ XCTAssertThrowsError(try template.validateVariants()) {
+ XCTAssertTrue(
+ $0.localizedDescription.contains("path 0 has 4/4/3 segments"),
+ "Expected the segment counts in the message, got: \($0.localizedDescription)"
+ )
+ }
+ }
+
+ func testValidateVariants_ThrowsForSegmentTypeMismatch() throws {
+ var template = try SFSymbolTemplate.make()
+ template.setVariantSegments(
+ ultralight: [.squareSegments],
+ regular: [.squareSegments],
+ black: [.curvedSquareSegments]
+ )
+
+ XCTAssertThrowsError(try template.validateVariants()) {
+ XCTAssertTrue(
+ $0.localizedDescription.contains("path 0 has mismatched segment types"),
+ "Expected a segment type mismatch, got: \($0.localizedDescription)"
+ )
+ }
+ }
+
+ func testRender_ThrowsForNonInterpolatableVariants() throws {
+ // The regular variant draws its outline with a cubic where black uses a line;
+ // normalization cannot align them, so rendering must fail instead of emitting
+ // a symbol that crashes CoreUI at draw time.
+ let regular = try DOM.SVG.parse(
+ #""#
+ )
+ let black = try DOM.SVG.parse(
+ #""#
+ )
+ let renderer = SFSymbolRenderer(
+ size: .small,
+ options: [],
+ insets: .init(),
+ insetsUltralight: .init(),
+ insetsBlack: .init(),
+ precision: 3,
+ isLegacyInsets: false
+ )
+
+ XCTAssertThrowsError(try renderer.render(default: regular, ultralight: nil, black: black)) {
+ XCTAssertTrue(
+ $0.localizedDescription.contains("not interpolatable"),
+ "Expected an interpolation error, got: \($0.localizedDescription)"
+ )
+ }
+ }
+}
+
+private extension SFSymbolTemplate {
+
+ /// Replaces the contents of all three weight variants with paths built from `segments`.
+ mutating func setVariantSegments(
+ ultralight: [[DOM.Path.Segment]],
+ regular: [[DOM.Path.Segment]],
+ black: [[DOM.Path.Segment]]
+ ) {
+ self.ultralight.contents.paths = ultralight.map { .make($0) }
+ self.regular.contents.paths = regular.map { .make($0) }
+ self.black.contents.paths = black.map { .make($0) }
+ }
+}
+
+private extension DOM.Path {
+
+ static func make(_ segments: [Segment]) -> DOM.Path {
+ let path = DOM.Path(x: 0, y: 0)
+ path.segments = segments
+ return path
+ }
+}
+
+private extension [DOM.Path.Segment] {
+
+ static var squareSegments: Self {
+ [
+ .move(x: 0, y: 0, space: .absolute),
+ .line(x: 10, y: 0, space: .absolute),
+ .line(x: 10, y: 10, space: .absolute),
+ .close
+ ]
+ }
+
+ static var triangleSegments: Self {
+ [
+ .move(x: 0, y: 0, space: .absolute),
+ .line(x: 10, y: 0, space: .absolute),
+ .close
+ ]
+ }
+
+ static var curvedSquareSegments: Self {
+ [
+ .move(x: 0, y: 0, space: .absolute),
+ .cubic(x1: 0, y1: 0, x2: 10, y2: 0, x: 10, y: 0, space: .absolute),
+ .line(x: 10, y: 10, space: .absolute),
+ .close
+ ]
+ }
}
private extension DOM.SVG {