Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Expand Down
137 changes: 137 additions & 0 deletions SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
#"<svg width="24" height="24"><path d="M0 0 L10 0 C10 5 15 5 15 10 L0 10 Z" /></svg>"#
)
let black = try DOM.SVG.parse(
#"<svg width="24" height="24"><path d="M0 0 L10 0 L15 10 Z" /></svg>"#
)
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 {
Expand Down
Loading