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 {