diff --git a/CLAUDE.md b/CLAUDE.md index 4b1eea71..de00150f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Fourteen modules in `Sources/`: +Thirteen modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | @@ -113,7 +113,6 @@ Fourteen modules in `Sources/`: | `AndroidExport` | Android export (XML resources, Compose, Vector Drawables) | | `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | | `WebExport` | Web/React export (CSS variables, JSX icons) | -| `SVGKit` | SVG parsing, ImageVector/VectorDrawable generation | | `JinjaSupport` | Shared Jinja2 template rendering across Export modules | **Data flow:** CLI -> PKL config parsing -> FigmaAPI fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write @@ -315,22 +314,23 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Dependencies -| Package | Version | Purpose | -| --------------------- | ------- | ------------------------------- | -| swift-argument-parser | 1.5.0+ | CLI framework | -| swift-collections | 1.2.x | Ordered collections | -| swift-jinja | 2.0.0+ | Jinja2 template engine | -| XcodeProj | 8.27.0+ | Xcode project manipulation | -| swift-log | 1.6.0+ | Logging | -| Rainbow | 4.2.0+ | Terminal colors | -| libwebp | 1.4.1+ | WebP encoding | -| libpng | 1.6.45+ | PNG decoding | -| swift-custom-dump | 1.3.0+ | Test assertions | -| Noora | 0.54.0+ | Terminal UI design system | -| swift-resvg | 0.45.1 | SVG parsing/rendering | -| swift-docc-plugin | 1.4.5+ | DocC documentation | -| swift-yyjson | 0.5.0+ | High-performance JSON codec | -| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | +| Package | Version | Purpose | +| --------------------- | ------- | -------------------------------------------------- | +| swift-argument-parser | 1.5.0+ | CLI framework | +| swift-collections | 1.2.x | Ordered collections | +| swift-jinja | 2.0.0+ | Jinja2 template engine | +| XcodeProj | 8.27.0+ | Xcode project manipulation | +| swift-log | 1.6.0+ | Logging | +| Rainbow | 4.2.0+ | Terminal colors | +| libwebp | 1.4.1+ | WebP encoding | +| libpng | 1.6.45+ | PNG decoding | +| swift-custom-dump | 1.3.0+ | Test assertions | +| Noora | 0.54.0+ | Terminal UI design system | +| swift-svgkit | 0.1.0+ | SVG parsing, ImageVector/VectorDrawable generation | +| swift-resvg | 0.45.1 | SVG parsing/rendering | +| swift-docc-plugin | 1.4.5+ | DocC documentation | +| swift-yyjson | 0.5.0+ | High-performance JSON codec | +| pkl-swift | 0.8.0+ | PKL config evaluation & codegen | ## Troubleshooting diff --git a/Package.resolved b/Package.resolved index 4d7466bf..febda9fb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4579a4d7673ce1d4fb4b31b7df0fc906f78237ed8c418874c56b9fbe086e1097", + "originHash" : "50c42a046c7b934c9045956a4176324272debf4d406c8a184df7d8ff640c4239", "pins" : [ { "identity" : "aexml", @@ -163,6 +163,15 @@ "version" : "0.45.1-swift.3" } }, + { + "identity" : "swift-svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DesignPipe/swift-svgkit.git", + "state" : { + "revision" : "ec56044b8d0dcd0caed7ad4bac21b805b44cd14f", + "version" : "0.1.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index bfc445cf..f2e5dcc3 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"), .package(url: "https://github.com/mattt/swift-yyjson", from: "0.5.0"), .package(url: "https://github.com/apple/pkl-swift", from: "0.8.0"), + .package(url: "https://github.com/DesignPipe/swift-svgkit.git", from: "0.1.0"), ], targets: [ // Main target @@ -39,7 +40,7 @@ let package = Package( "AndroidExport", "FlutterExport", "WebExport", - "SVGKit", + .product(name: "SVGKit", package: "swift-svgkit"), "ExFig-iOS", "ExFig-Android", "ExFig-Flutter", @@ -109,22 +110,12 @@ let package = Package( ] ), - // SVG parsing and code generation - .target( - name: "SVGKit", - dependencies: [ - .product(name: "Logging", package: "swift-log"), - .product(name: "Resvg", package: "swift-resvg"), - ], - exclude: ["CLAUDE.md", "AGENTS.md"] - ), - // Exports resources to Android project .target( name: "AndroidExport", dependencies: [ "ExFigCore", - "SVGKit", + .product(name: "SVGKit", package: "swift-svgkit"), "JinjaSupport", .product(name: "OrderedCollections", package: "swift-collections"), ], @@ -180,7 +171,7 @@ let package = Package( "ExFigCore", "ExFigConfig", "AndroidExport", - "SVGKit", + .product(name: "SVGKit", package: "swift-svgkit"), ], exclude: ["CLAUDE.md", "AGENTS.md"] ), @@ -271,10 +262,6 @@ let package = Package( "WebExport", .product(name: "CustomDump", package: "swift-custom-dump"), ] ), - .testTarget( - name: "SVGKitTests", - dependencies: ["SVGKit", .product(name: "CustomDump", package: "swift-custom-dump")] - ), // MARK: - Plugin Tests diff --git a/Sources/SVGKit/AGENTS.md b/Sources/SVGKit/AGENTS.md deleted file mode 100644 index ceb2b988..00000000 --- a/Sources/SVGKit/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md diff --git a/Sources/SVGKit/CLAUDE.md b/Sources/SVGKit/CLAUDE.md deleted file mode 100644 index 8b0a56a9..00000000 --- a/Sources/SVGKit/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Module Purpose - -SVGKit is a pure Swift SVG-to-native-format converter. It parses SVG files into an intermediate `ParsedSVG` representation and generates two output formats: **Jetpack Compose ImageVector** (Kotlin) and **Android VectorDrawable XML**. It replaces the external `vd-tool` Java dependency. - -## Build & Test - -```bash -./bin/mise run build # Build all (includes SVGKit) -./bin/mise run test:filter SVGKitTests # Run SVGKit tests only -./bin/mise run test:filter SVGKitTests.SVGParserTests # Single test class -``` - -## Architecture - -**Pipeline:** SVG Data → `SVGParser` (+ usvg normalization) → `ParsedSVG` → Generator → output string - -``` -SVGParser.swift # SVG XML → ParsedSVG (the core parser, ~1500 lines) -SVGPathParser.swift # SVG path `d` attribute → [SVGPathCommand] (UTF-8 scanner) -SVGTypes.swift # Domain types: SVGTransform, SVGColor, SVGGradient*, SVGGroup, SVGElement, SVGFill -ImageVectorGenerator.swift # ParsedSVG → Kotlin ImageVector code (Compose) -VectorDrawableXMLGenerator.swift # ParsedSVG → Android VectorDrawable XML -NativeVectorDrawableConverter.swift # Batch directory converter (SVG→XML, async parallel) -PathDataValidator.swift # Android pathData length validation (800 char lint / 32KB AAPT limit) -ResvgPathConverter.swift # resvg Path segments → SVG path data string (for mask/clip-path extraction) -``` - -## Key Design Decisions - -**Dual element representation:** `ParsedSVG` has both a flat `paths` array and structured `elements: [SVGElement]` (an enum of `.path`/`.group`). The `elements` array preserves SVG document order and is the preferred access path. The flat `paths`/`groups` arrays exist for backward compatibility — generators check `elements` first, fall back to legacy arrays. - -**usvg normalization:** `SVGParser.parse(_:normalize:)` normalizes SVG via resvg's usvg before XML parsing (resolves ``, inlines CSS, applies defaults). `NativeVectorDrawableConverter` defaults to `normalize: false` to preserve Figma mask/clip-path structure. - -**Shape-to-path conversion:** All SVG shapes (`rect`, `circle`, `ellipse`, `line`, `polygon`, `polyline`) are converted to path data using **absolute commands and cubic Bezier curves** (not arcs) for Android VectorDrawable compatibility. Rounded rects use k=0.5523 arc approximation. - -**Linux XML compatibility:** `SVGParser` wraps all XML access through `elementName()`, `childElements(of:named:)`, and `attributeValue(_:forName:)` helpers because FoundationXML on Linux has issues with default xmlns namespaces. - -## Gotchas - -- `SVGParser` is a class with mutable state (gradient/clip/mask/symbol defs) — it resets state at the start of each `parse()` call but is not thread-safe for concurrent parsing. Create separate instances for concurrent use. -- `SVGColor.parse()` supports a limited set of named colors (14 colors). Unknown names return nil. -- Gradient coordinates support both absolute values and percentages; percentage conversion uses `currentViewBox` which is set during parse. -- `` elements are treated as clip-paths (Figma uses masks for rounded corners on flags). -- `` resolution has a `maxUseDepth = 10` recursion guard. -- `PathDataValidator` thresholds: 800 chars (Android Lint warning), 32,767 bytes (AAPT STRING_TOO_LARGE crash). - -## Dependencies - -| Package | Purpose | -| ------------------- | --------------------------------------------------- | -| swift-log | Logging | -| swift-resvg (Resvg) | SVG normalization via usvg, path segment extraction | diff --git a/Sources/SVGKit/ImageVectorGenerator.swift b/Sources/SVGKit/ImageVectorGenerator.swift deleted file mode 100644 index 1b81fd93..00000000 --- a/Sources/SVGKit/ImageVectorGenerator.swift +++ /dev/null @@ -1,678 +0,0 @@ -// swiftlint:disable file_length type_body_length -import Foundation - -/// Generates Jetpack Compose ImageVector Kotlin code from parsed SVG -public struct ImageVectorGenerator: Sendable { - /// Configuration for the generator - public struct Config: Sendable { - /// Package name for the generated file - public let packageName: String - /// Extension target (e.g., "com.example.AppIcons") - public let extensionTarget: String? - /// Whether to generate @Preview annotation - public let generatePreview: Bool - /// Color mappings to apply (from hex -> to Kotlin code) - public let colorMappings: [String: String] - - public init( - packageName: String, - extensionTarget: String? = nil, - generatePreview: Bool = true, - colorMappings: [String: String] = [:] - ) { - self.packageName = packageName - self.extensionTarget = extensionTarget - self.generatePreview = generatePreview - self.colorMappings = colorMappings - } - } - - private let config: Config - - public init(config: Config) { - self.config = config - } - - /// Generates Kotlin code for a single icon - /// - Parameters: - /// - name: Icon name (will be converted to PascalCase) - /// - svg: Parsed SVG data - /// - Returns: Complete Kotlin file content - public func generate(name: String, svg: ParsedSVG) -> String { - let iconName = name.toPascalCase() - let privateVarName = "_\(name.toCamelCase())" - - var code = generateHeader() - code += generatePackage() - code += generateImports(svg: svg) - code += "\n" - code += generateIconProperty(iconName: iconName, privateVarName: privateVarName, svg: svg) - code += "\n" - code += generatePrivateVar(privateVarName: privateVarName) - - if config.generatePreview { - code += "\n" - code += generatePreview(iconName: iconName) - } - - return code - } - - // MARK: - Private Methods - - private func generateHeader() -> String { - """ - // This file is auto-generated by ExFig. Do not edit manually. - - """ - } - - private func generatePackage() -> String { - """ - package \(config.packageName) - - """ - } - - private func generateImports(svg: ParsedSVG) -> String { - var imports = Set() - - imports.insert("androidx.compose.ui.graphics.Color") - imports.insert("androidx.compose.ui.graphics.SolidColor") - imports.insert("androidx.compose.ui.graphics.vector.ImageVector") - imports.insert("androidx.compose.ui.graphics.vector.path") - imports.insert("androidx.compose.ui.unit.dp") - - // Check for groups - if hasGroups(svg) { - imports.insert("androidx.compose.ui.graphics.vector.group") - } - - // Check for stroke properties and gradients - for path in svg.paths { - if path.stroke != nil { - imports.insert("androidx.compose.ui.graphics.StrokeCap") - imports.insert("androidx.compose.ui.graphics.StrokeJoin") - } - if path.fillRule == .evenOdd { - imports.insert("androidx.compose.ui.graphics.PathFillType") - } - // Check for gradient fills - switch path.fillType { - case .linearGradient, .radialGradient: - imports.insert("androidx.compose.ui.graphics.Brush") - imports.insert("androidx.compose.ui.geometry.Offset") - default: - break - } - } - - if config.generatePreview { - imports.insert("androidx.compose.material3.Icon") - imports.insert("androidx.compose.runtime.Composable") - imports.insert("androidx.compose.ui.tooling.preview.Preview") - } - - if config.extensionTarget != nil { - // Extract the class/object to import - if let target = config.extensionTarget { - imports.insert(target) - } - } - - let sortedImports = imports.sorted() - return sortedImports.map { "import \($0)" }.joined(separator: "\n") + "\n" - } - - private func generateIconProperty(iconName: String, privateVarName: String, svg: ParsedSVG) -> String { - let extensionPrefix: String - if let target = config.extensionTarget { - let targetName = target.components(separatedBy: ".").last ?? target - extensionPrefix = "\(targetName)." - } else { - extensionPrefix = "" - } - - var code = """ - public val \(extensionPrefix)\(iconName): ImageVector - get() { - if (\(privateVarName) != null) { - return \(privateVarName)!! - } - \(privateVarName) = ImageVector.Builder( - name = "\(iconName)", - defaultWidth = \(formatDouble(svg.width)).dp, - defaultHeight = \(formatDouble(svg.height)).dp, - viewportWidth = \(formatDouble(svg.viewportWidth))f, - viewportHeight = \(formatDouble(svg.viewportHeight))f, - ).apply { - - """ - - // Generate elements in document order (preserves correct layer order) - if !svg.elements.isEmpty { - for element in svg.elements { - switch element { - case let .path(path): - code += generatePath(path, indent: 3) - case let .group(group): - code += generateGroup(group, indent: 3) - } - } - } else if let groups = svg.groups, !groups.isEmpty { - // Fallback for backward compatibility - for path in svg.paths { - code += generatePath(path, indent: 3) - } - for group in groups { - code += generateGroup(group, indent: 3) - } - } else { - for path in svg.paths { - code += generatePath(path, indent: 3) - } - } - - code += """ - }.build() - return \(privateVarName)!! - } - - """ - - return code - } - - private func generatePrivateVar(privateVarName: String) -> String { - """ - private var \(privateVarName): ImageVector? = null - - """ - } - - private func generatePath(_ path: SVGPath, indent: Int = 3) -> String { - let indentStr = String(repeating: " ", count: indent) - let innerIndentStr = String(repeating: " ", count: indent + 1) - - var params: [String] = [] - params.append(contentsOf: buildFillParams(path, indent: indent)) - params.append(contentsOf: buildStrokeParams(path)) - if let fillRuleParam = buildFillRuleParam(path.fillRule) { - params.append(fillRuleParam) - } - - var code = "\(indentStr)path(\n" - if !params.isEmpty { - code += "\(innerIndentStr)" + params.joined(separator: ",\n\(innerIndentStr)") + ",\n" - } - code += "\(indentStr)) {\n" - code += generatePathCommands(path.commands, indent: indent) - code += "\(indentStr)}\n" - - return code - } - - private func buildFillParams(_ path: SVGPath, indent: Int) -> [String] { - var params: [String] = [] - - switch path.fillType { - case let .linearGradient(gradient): - params.append(generateLinearGradientFill(gradient, indent: indent)) - case let .radialGradient(gradient): - params.append(generateRadialGradientFill(gradient, indent: indent)) - case let .solid(color): - params.append("fill = SolidColor(\(mapColor(color)))") - case .none: - if let fill = path.fill { - params.append("fill = SolidColor(\(mapColor(fill)))") - } else if path.stroke == nil { - params.append("fill = SolidColor(Color.Black)") - } - } - - if let fillOpacity = path.fillOpacity, fillOpacity < 1.0 { - params.append("fillAlpha = \(formatDouble(fillOpacity))f") - } else if let opacity = path.opacity, opacity < 1.0 { - params.append("fillAlpha = \(formatDouble(opacity))f") - } - - return params - } - - private func buildStrokeParams(_ path: SVGPath) -> [String] { - var params: [String] = [] - - if let stroke = path.stroke { - params.append("stroke = SolidColor(\(mapColor(stroke)))") - } - - if let strokeWidth = path.strokeWidth { - params.append("strokeLineWidth = \(formatDouble(strokeWidth))f") - } - - if let cap = path.strokeLineCap { - let capValue = switch cap { - case .butt: "StrokeCap.Butt" - case .round: "StrokeCap.Round" - case .square: "StrokeCap.Square" - } - params.append("strokeLineCap = \(capValue)") - } - - if let join = path.strokeLineJoin { - let joinValue = switch join { - case .miter: "StrokeJoin.Miter" - case .round: "StrokeJoin.Round" - case .bevel: "StrokeJoin.Bevel" - } - params.append("strokeLineJoin = \(joinValue)") - } - - return params - } - - private func buildFillRuleParam(_ fillRule: SVGPath.FillRule?) -> String? { - guard let fillRule else { return nil } - let ruleValue = switch fillRule { - case .nonZero: "PathFillType.NonZero" - case .evenOdd: "PathFillType.EvenOdd" - } - return "pathFillType = \(ruleValue)" - } - - private func generateGroup(_ group: SVGGroup, indent: Int) -> String { - let indentStr = String(repeating: " ", count: indent) - var params = buildGroupTransformParams(group.transform) - - // Clip path - pass as clipPathData using PathBuilder - if let clipPath = group.clipPath { - params.append("clipPathData = PathData { \(generateClipPathData(clipPath)) }") - } - - var code = "\(indentStr)group(\n" - if !params.isEmpty { - let innerIndentStr = String(repeating: " ", count: indent + 1) - code += "\(innerIndentStr)" + params.joined(separator: ",\n\(innerIndentStr)") + ",\n" - } - code += "\(indentStr)) {\n" - code += generateGroupContent(group, indent: indent) - code += "\(indentStr)}\n" - - return code - } - - private func buildGroupTransformParams(_ transform: SVGTransform?) -> [String] { - guard let transform else { return [] } - var params: [String] = [] - if let rotation = transform.rotation { - params.append("rotate = \(formatDouble(rotation))f") - } - if let pivotX = transform.pivotX { - params.append("pivotX = \(formatDouble(pivotX))f") - } - if let pivotY = transform.pivotY { - params.append("pivotY = \(formatDouble(pivotY))f") - } - if let scaleX = transform.scaleX, scaleX != 1.0 { - params.append("scaleX = \(formatDouble(scaleX))f") - } - if let scaleY = transform.scaleY, scaleY != 1.0 { - params.append("scaleY = \(formatDouble(scaleY))f") - } - if let translateX = transform.translateX { - params.append("translationX = \(formatDouble(translateX))f") - } - if let translateY = transform.translateY { - params.append("translationY = \(formatDouble(translateY))f") - } - return params - } - - private func generateGroupContent(_ group: SVGGroup, indent: Int) -> String { - var code = "" - if !group.elements.isEmpty { - for element in group.elements { - switch element { - case let .path(path): - code += generatePath(path, indent: indent + 1) - case let .group(childGroup): - code += generateGroup(childGroup, indent: indent + 1) - } - } - } else { - // Fallback for backward compatibility - for path in group.paths { - code += generatePath(path, indent: indent + 1) - } - for childGroup in group.children { - code += generateGroup(childGroup, indent: indent + 1) - } - } - return code - } - - private func generateClipPathData(_ pathData: String) -> String { - // Parse path data and convert to Compose path commands - // For simplicity, we pass the raw path data string - // In a full implementation, this would parse and convert each command - pathData - } - - private func hasGroups(_ svg: ParsedSVG) -> Bool { - if !svg.elements.isEmpty { - return svg.elements.contains { element in - if case .group = element { return true } - return false - } - } - return svg.groups?.isEmpty == false - } - - private func generatePathCommands(_ commands: [SVGPathCommand], indent: Int = 3) -> String { - var code = "" - let innerIndentStr = String(repeating: " ", count: indent + 1) - - for command in commands { - code += innerIndentStr - code += generatePathCommand(command) - code += "\n" - } - - return code - } - - // swiftlint:disable:next cyclomatic_complexity function_body_length - private func generatePathCommand(_ command: SVGPathCommand) -> String { - switch command { - case let .moveTo(x, y, relative): - if relative { - "moveToRelative(\(formatDouble(x))f, \(formatDouble(y))f)" - } else { - "moveTo(\(formatDouble(x))f, \(formatDouble(y))f)" - } - - case let .lineTo(x, y, relative): - if relative { - "lineToRelative(\(formatDouble(x))f, \(formatDouble(y))f)" - } else { - "lineTo(\(formatDouble(x))f, \(formatDouble(y))f)" - } - - case let .horizontalLineTo(x, relative): - if relative { - "horizontalLineToRelative(\(formatDouble(x))f)" - } else { - "horizontalLineTo(\(formatDouble(x))f)" - } - - case let .verticalLineTo(y, relative): - if relative { - "verticalLineToRelative(\(formatDouble(y))f)" - } else { - "verticalLineTo(\(formatDouble(y))f)" - } - - case let .curveTo(x1, y1, x2, y2, x, y, relative): - if relative { - """ - curveToRelative(\ - \(formatDouble(x1))f, \(formatDouble(y1))f, \ - \(formatDouble(x2))f, \(formatDouble(y2))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } else { - """ - curveTo(\ - \(formatDouble(x1))f, \(formatDouble(y1))f, \ - \(formatDouble(x2))f, \(formatDouble(y2))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } - - case let .smoothCurveTo(x2, y2, x, y, relative): - if relative { - """ - reflectiveCurveToRelative(\ - \(formatDouble(x2))f, \(formatDouble(y2))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } else { - """ - reflectiveCurveTo(\ - \(formatDouble(x2))f, \(formatDouble(y2))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } - - case let .quadraticBezierCurveTo(x1, y1, x, y, relative): - if relative { - """ - quadToRelative(\ - \(formatDouble(x1))f, \(formatDouble(y1))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } else { - """ - quadTo(\ - \(formatDouble(x1))f, \(formatDouble(y1))f, \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } - - case let .smoothQuadraticBezierCurveTo(x, y, relative): - if relative { - "reflectiveQuadToRelative(\(formatDouble(x))f, \(formatDouble(y))f)" - } else { - "reflectiveQuadTo(\(formatDouble(x))f, \(formatDouble(y))f)" - } - - case let .arcTo(rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y, relative): - if relative { - """ - arcToRelative(\ - \(formatDouble(rx))f, \(formatDouble(ry))f, \ - \(formatDouble(xAxisRotation))f, \ - \(largeArcFlag), \(sweepFlag), \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } else { - """ - arcTo(\ - \(formatDouble(rx))f, \(formatDouble(ry))f, \ - \(formatDouble(xAxisRotation))f, \ - \(largeArcFlag), \(sweepFlag), \ - \(formatDouble(x))f, \(formatDouble(y))f) - """ - } - - case .closePath: - "close()" - } - } - - private func generatePreview(iconName: String) -> String { - let extensionPrefix: String - if let target = config.extensionTarget { - let targetName = target.components(separatedBy: ".").last ?? target - extensionPrefix = "\(targetName)." - } else { - extensionPrefix = "" - } - - return """ - @Preview(showBackground = true) - @Composable - private fun \(iconName)Preview() { - Icon( - imageVector = \(extensionPrefix)\(iconName), - contentDescription = null, - ) - } - - """ - } - - private func mapColor(_ color: SVGColor) -> String { - // Check color mappings first - let hexWithoutAlpha = String(format: "%02x%02x%02x", color.red, color.green, color.blue).lowercased() - - // Check for wildcard mapping - if let mappedColor = config.colorMappings["*"] { - return mappedColor - } - - // Check for specific color mapping - if let mappedColor = config.colorMappings[hexWithoutAlpha] { - return mappedColor - } - - // Generate Color() with hex - return "Color(\(color.composeHex))" - } - - private func formatDouble(_ value: Double) -> String { - // Format to remove unnecessary trailing zeros - if value == value.rounded(), abs(value) < 10000 { - return String(format: "%.0f", value) - } else if abs(value - value.rounded()) < 0.0001 { - return String(format: "%.0f", value) - } else { - let formatted = String(format: "%.4f", value) - // Remove trailing zeros after decimal point - var result = formatted - while result.hasSuffix("0"), !result.hasSuffix(".0") { - result.removeLast() - } - if result.hasSuffix(".") { - result.removeLast() - } - return result - } - } - - // MARK: - Gradient Generation - - private func generateLinearGradientFill(_ gradient: SVGLinearGradient, indent: Int = 3) -> String { - let innerIndentStr = String(repeating: " ", count: indent + 1) - let innerInnerIndentStr = String(repeating: " ", count: indent + 2) - - // Apply gradientTransform if present - var x1 = gradient.x1 - var y1 = gradient.y1 - var x2 = gradient.x2 - var y2 = gradient.y2 - - if let transform = gradient.gradientTransform { - (x1, y1) = applyTransformToPoint(x: x1, y: y1, transform: transform) - (x2, y2) = applyTransformToPoint(x: x2, y: y2, transform: transform) - } - - var code = "fill = Brush.linearGradient(\n" - code += "\(innerIndentStr)colorStops = arrayOf(\n" - - for stop in gradient.stops { - let colorHex = colorToComposeHex(stop.color, opacity: stop.opacity) - code += "\(innerInnerIndentStr)\(formatDouble(stop.offset))f to Color(\(colorHex)),\n" - } - - code += "\(innerIndentStr)),\n" - code += "\(innerIndentStr)start = Offset(\(formatDouble(x1))f, \(formatDouble(y1))f),\n" - code += "\(innerIndentStr)end = Offset(\(formatDouble(x2))f, \(formatDouble(y2))f),\n" - code += "\(String(repeating: " ", count: indent)))" - - return code - } - - private func generateRadialGradientFill(_ gradient: SVGRadialGradient, indent: Int = 3) -> String { - let innerIndentStr = String(repeating: " ", count: indent + 1) - let innerInnerIndentStr = String(repeating: " ", count: indent + 2) - - // Apply gradientTransform if present - var cx = gradient.cx - var cy = gradient.cy - var r = gradient.r - - if let transform = gradient.gradientTransform { - (cx, cy) = applyTransformToPoint(x: cx, y: cy, transform: transform) - // Scale radius by average of scaleX and scaleY - let scaleX = transform.scaleX ?? 1.0 - let scaleY = transform.scaleY ?? 1.0 - r *= (abs(scaleX) + abs(scaleY)) / 2.0 - } - - var code = "fill = Brush.radialGradient(\n" - code += "\(innerIndentStr)colorStops = arrayOf(\n" - - for stop in gradient.stops { - let colorHex = colorToComposeHex(stop.color, opacity: stop.opacity) - code += "\(innerInnerIndentStr)\(formatDouble(stop.offset))f to Color(\(colorHex)),\n" - } - - code += "\(innerIndentStr)),\n" - code += "\(innerIndentStr)center = Offset(\(formatDouble(cx))f, \(formatDouble(cy))f),\n" - code += "\(innerIndentStr)radius = \(formatDouble(r))f,\n" - code += "\(String(repeating: " ", count: indent)))" - - return code - } - - /// Applies an SVG transform to a point - private func applyTransformToPoint(x: Double, y: Double, transform: SVGTransform) -> (Double, Double) { - var newX = x - var newY = y - - // Apply scale - if let scaleX = transform.scaleX { - newX *= scaleX - } - if let scaleY = transform.scaleY { - newY *= scaleY - } - - // Apply rotation (around origin or pivot point) - if let rotation = transform.rotation { - let radians = rotation * .pi / 180.0 - let cosR = cos(radians) - let sinR = sin(radians) - let pivotX = transform.pivotX ?? 0 - let pivotY = transform.pivotY ?? 0 - - let dx = newX - pivotX - let dy = newY - pivotY - newX = pivotX + dx * cosR - dy * sinR - newY = pivotY + dx * sinR + dy * cosR - } - - // Apply translation - if let translateX = transform.translateX { - newX += translateX - } - if let translateY = transform.translateY { - newY += translateY - } - - return (newX, newY) - } - - private func colorToComposeHex(_ color: SVGColor, opacity: Double) -> String { - let alpha = Int((opacity * 255).rounded()) - return String(format: "0x%02X%02X%02X%02X", alpha, color.red, color.green, color.blue) - } -} - -// MARK: - String Extensions - -private extension String { - func toPascalCase() -> String { - let components = split { !$0.isLetter && !$0.isNumber } - return components.map { component in - component.prefix(1).uppercased() + component.dropFirst().lowercased() - }.joined() - } - - func toCamelCase() -> String { - let pascal = toPascalCase() - guard let first = pascal.first else { return pascal } - return first.lowercased() + pascal.dropFirst() - } -} diff --git a/Sources/SVGKit/NativeVectorDrawableConverter.swift b/Sources/SVGKit/NativeVectorDrawableConverter.swift deleted file mode 100644 index 1e95702b..00000000 --- a/Sources/SVGKit/NativeVectorDrawableConverter.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Foundation -import Logging - -/// Errors that can occur during SVG to Vector Drawable conversion -public enum VectorDrawableConverterError: Error, LocalizedError { - case directoryNotFound(URL) - case invalidSVG(URL, Error) - case pathDataExceedsCriticalLimit(iconName: String, byteLength: Int) - - public var errorDescription: String? { - switch self { - case let .directoryNotFound(url): - "Directory not found: \(url.path)" - case let .invalidSVG(url, error): - "Invalid SVG: \(url.lastPathComponent) - \(error.localizedDescription)" - case let .pathDataExceedsCriticalLimit(iconName, byteLength): - """ - pathData exceeds 32,767 bytes (\(byteLength) bytes) in \(iconName). \ - This will cause STRING_TOO_LARGE error during Android build. - """ - } - } - - public var recoverySuggestion: String? { - switch self { - case .directoryNotFound: - "Check the directory path exists" - case .invalidSVG: - "Re-export SVG from Figma or check SVG syntax" - case .pathDataExceedsCriticalLimit: - "Simplify the path in Figma or use raster format (PNG/WebP)" - } - } -} - -/// Native SVG to Android Vector Drawable XML converter -/// Replaces the external vd-tool Java dependency with pure Swift implementation -public struct NativeVectorDrawableConverter: Sendable { - private let autoMirrored: Bool - private let normalize: Bool - private let maxConcurrent: Int - private let validatePathData: Bool - private let strictPathValidation: Bool - private let logger = Logger(label: "com.designpipe.exfig.native-vector-drawable-converter") - private let validator = PathDataValidator() - - /// - Parameters: - /// - autoMirrored: If true, generates autoMirrored attribute for RTL support - /// - normalize: If true, normalizes SVG via usvg before parsing. - /// Default is false to preserve mask/clip-path structure from Figma. - /// - maxConcurrent: Maximum concurrent conversions - /// - validatePathData: If true, validates pathData length and logs warnings for long paths. - /// Default is true. - /// - strictPathValidation: If true, throws error when pathData exceeds 32,767 bytes (AAPT limit). - /// Default is false (only logs warning). - public init( - autoMirrored: Bool = false, - normalize: Bool = false, - maxConcurrent: Int = 4, - validatePathData: Bool = true, - strictPathValidation: Bool = false - ) { - self.autoMirrored = autoMirrored - self.normalize = normalize - self.maxConcurrent = maxConcurrent - self.validatePathData = validatePathData - self.strictPathValidation = strictPathValidation - } - - /// Finds all SVG files in a directory - private func findSVGFiles(in directoryUrl: URL) throws -> [URL] { - let fileManager = FileManager.default - - // Verify directory exists - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: directoryUrl.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw VectorDrawableConverterError.directoryNotFound(directoryUrl) - } - - // Find all SVG files - let enumerator = fileManager.enumerator(at: directoryUrl, includingPropertiesForKeys: nil) - var svgFiles: [URL] = [] - - while let file = enumerator?.nextObject() as? URL { - if file.pathExtension.lowercased() == "svg" { - svgFiles.append(file) - } - } - - return svgFiles - } - - /// Converts all SVG files in a directory to Android Vector Drawable XML format (async, parallel) - /// - Parameters: - /// - inputDirectoryUrl: URL to directory containing SVG files - /// - rtlFiles: Set of file names (without extension) that should have autoMirrored=true - /// - Throws: `VectorDrawableConverterError.directoryNotFound` if directory doesn't exist - public func convertAsync(inputDirectoryUrl: URL, rtlFiles: Set = []) async throws { - let svgFiles = try findSVGFiles(in: inputDirectoryUrl) - - guard !svgFiles.isEmpty else { - logger.info("No SVG files found in \(inputDirectoryUrl.path)") - return - } - - logger.info("Converting \(svgFiles.count) SVG file(s) to Vector Drawable XML") - - let successCount = Lock(0) - let failCount = Lock(0) - let criticalErrors = Lock<[VectorDrawableConverterError]>([]) - - // Helper to process a single file with error tracking - let processFile: @Sendable (URL) -> Void = { [self] svgFile in - do { - try convertFileSync(svgFile, rtlFiles: rtlFiles) - successCount.withLock { $0 += 1 } - } catch let error as VectorDrawableConverterError { - logger.warning("Failed to convert \(svgFile.lastPathComponent): \(error.localizedDescription)") - failCount.withLock { $0 += 1 } - if case .pathDataExceedsCriticalLimit = error { - criticalErrors.withLock { $0.append(error) } - } - } catch { - logger.warning("Failed to convert \(svgFile.lastPathComponent): \(error.localizedDescription)") - failCount.withLock { $0 += 1 } - } - } - - try await withThrowingTaskGroup(of: Void.self) { group in - var iterator = svgFiles.makeIterator() - - // Start initial batch - for _ in 0 ..< min(maxConcurrent, svgFiles.count) { - if let svgFile = iterator.next() { - group.addTask { [svgFile] in processFile(svgFile) } - } - } - - // Process completed and start new ones - for try await _ in group { - if let svgFile = iterator.next() { - group.addTask { [svgFile] in processFile(svgFile) } - } - } - } - - logger.info("Conversion complete: \(successCount.value) succeeded, \(failCount.value) failed") - - // If strict validation is enabled and there were critical errors, throw the first one - if strictPathValidation, let firstCritical = criticalErrors.value.first { - throw firstCritical - } - } - - /// Converts all SVG files in a directory to Android Vector Drawable XML format (sync) - /// - Parameters: - /// - inputDirectoryUrl: URL to directory containing SVG files - /// - rtlFiles: Set of file names (without extension) that should have autoMirrored=true - /// - Throws: `VectorDrawableConverterError.directoryNotFound` if directory doesn't exist - public func convert(inputDirectoryUrl: URL, rtlFiles: Set = []) throws { - let svgFiles = try findSVGFiles(in: inputDirectoryUrl) - - guard !svgFiles.isEmpty else { - logger.info("No SVG files found in \(inputDirectoryUrl.path)") - return - } - - logger.info("Converting \(svgFiles.count) SVG file(s) to Vector Drawable XML") - - var successCount = 0 - var failCount = 0 - var criticalErrors: [VectorDrawableConverterError] = [] - - for svgFile in svgFiles { - do { - try convertFileSync(svgFile, rtlFiles: rtlFiles) - successCount += 1 - } catch let error as VectorDrawableConverterError { - logger.warning("Failed to convert \(svgFile.lastPathComponent): \(error.localizedDescription)") - failCount += 1 - // Track critical errors for later - if case .pathDataExceedsCriticalLimit = error { - criticalErrors.append(error) - } - } catch { - logger.warning("Failed to convert \(svgFile.lastPathComponent): \(error.localizedDescription)") - failCount += 1 - } - } - - logger.info("Conversion complete: \(successCount) succeeded, \(failCount) failed") - - // If strict validation is enabled and there were critical errors, throw the first one - if strictPathValidation, let firstCritical = criticalErrors.first { - throw firstCritical - } - } - - private func convertFileSync(_ svgFile: URL, rtlFiles: Set) throws { - let fileManager = FileManager.default - let parser = SVGParser() - - // Check if this file should be auto-mirrored - let fileName = svgFile.deletingPathExtension().lastPathComponent - let shouldAutoMirror = autoMirrored || rtlFiles.contains(fileName) - let generator = VectorDrawableXMLGenerator(autoMirrored: shouldAutoMirror) - - // Read SVG data - let svgData = try Data(contentsOf: svgFile) - - // Parse SVG - let parsedSVG = try parser.parse(svgData, normalize: normalize) - - // Validate pathData if enabled - if validatePathData { - let issues = validator.validate(svg: parsedSVG, iconName: fileName) - validator.logIssues(issues, iconName: fileName) - - // Check for critical issues if strict mode enabled - if strictPathValidation, let critical = issues.first(where: { $0.isCritical }) { - throw VectorDrawableConverterError.pathDataExceedsCriticalLimit( - iconName: fileName, - byteLength: critical.result.byteLength - ) - } - } - - // Generate Vector Drawable XML - let xmlContent = generator.generate(from: parsedSVG) - - // Write XML file (same name, .xml extension) - let xmlFile = svgFile.deletingPathExtension().appendingPathExtension("xml") - try xmlContent.write(to: xmlFile, atomically: true, encoding: .utf8) - - // Remove original SVG file - try fileManager.removeItem(at: svgFile) - } -} - -// MARK: - Thread-safe counter - -private final class Lock: @unchecked Sendable { - private var _value: Value - private let lock = NSLock() - - init(_ value: Value) { - _value = value - } - - var value: Value { - lock.lock() - defer { lock.unlock() } - return _value - } - - func withLock(_ body: (inout Value) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return body(&_value) - } -} diff --git a/Sources/SVGKit/PathDataValidator.swift b/Sources/SVGKit/PathDataValidator.swift deleted file mode 100644 index 9029e1a4..00000000 --- a/Sources/SVGKit/PathDataValidator.swift +++ /dev/null @@ -1,199 +0,0 @@ -import Foundation -import Logging - -/// Result of path data validation -public struct PathDataValidationResult: Sendable, Equatable { - /// Length of pathData in characters - public let charLength: Int - /// Length of pathData in UTF-8 bytes - public let byteLength: Int - /// True if pathData exceeds Android Lint threshold (800 chars) - public let exceedsLintThreshold: Bool - /// True if pathData exceeds AAPT critical limit (32,767 bytes) - public let exceedsCriticalLimit: Bool - - public init(charLength: Int, byteLength: Int) { - self.charLength = charLength - self.byteLength = byteLength - exceedsLintThreshold = charLength > PathDataValidator.lintThreshold - exceedsCriticalLimit = byteLength > PathDataValidator.criticalThreshold - } -} - -/// Validation issue for a specific path -public struct PathValidationIssue: Sendable { - public let pathName: String - public let result: PathDataValidationResult - public let isCritical: Bool - - public init(pathName: String, result: PathDataValidationResult) { - self.pathName = pathName - self.result = result - isCritical = result.exceedsCriticalLimit - } -} - -/// Validates Android VectorDrawable pathData for length constraints -/// -/// Android has two important limits for pathData strings: -/// - **Lint threshold (800 chars)**: Android Lint warns about performance impact -/// - **Critical limit (32,767 bytes)**: AAPT StringPool uses signed 16-bit int, -/// causing STRING_TOO_LARGE error during build -/// -/// Sources: -/// - https://googlesamples.github.io/android-custom-lint-rules/checks/VectorPath.md.html -/// - https://brightinventions.pl/blog/string-too-large-in-android-resources/ -public struct PathDataValidator: Sendable { - /// Android Lint threshold for pathData length warning (characters) - public static let lintThreshold = 800 - - /// AAPT critical limit for any XML string attribute (bytes) - /// StringPool.cpp uses signed 16-bit integer for string length - public static let criticalThreshold = 32767 - - private let logger = Logger(label: "com.designpipe.exfig.path-validator") - - public init() {} - - /// Validates a single pathData string - /// - Parameter pathData: The path data string to validate - /// - Returns: Validation result with length metrics and threshold flags - public func validate(pathData: String) -> PathDataValidationResult { - let charLength = pathData.count - let byteLength = pathData.utf8.count - return PathDataValidationResult(charLength: charLength, byteLength: byteLength) - } - - /// Validates multiple paths and returns issues found - /// - Parameter paths: Array of tuples (pathName, pathData) - /// - Returns: Array of validation issues (only paths that exceed thresholds) - public func validatePaths(_ paths: [(name: String, pathData: String)]) -> [PathValidationIssue] { - paths.compactMap { name, pathData in - let result = validate(pathData: pathData) - guard result.exceedsLintThreshold || result.exceedsCriticalLimit else { - return nil - } - return PathValidationIssue(pathName: name, result: result) - } - } - - /// Logs validation issues with appropriate severity - /// - Parameters: - /// - issues: Array of validation issues to log - /// - iconName: Name of the icon being processed (for context) - /// - logWarnings: If true, logs lint threshold warnings (>800 chars). Default is false. - public func logIssues(_ issues: [PathValidationIssue], iconName: String, logWarnings: Bool = false) { - for issue in issues { - if issue.isCritical { - logger.error( - """ - pathData exceeds 32,767 bytes (\(issue.result.byteLength) bytes) \ - in \(iconName)/\(issue.pathName). \ - This will cause STRING_TOO_LARGE error during Android build. \ - Consider simplifying the path in Figma or using raster format. - """ - ) - } else if logWarnings, issue.result.exceedsLintThreshold { - logger.warning( - """ - pathData exceeds 800 chars (\(issue.result.charLength) chars) \ - in \(iconName)/\(issue.pathName). \ - This may cause performance issues. Consider simplifying in Figma. - """ - ) - } - } - } - - /// Validates a ParsedSVG and returns all issues - /// - Parameters: - /// - svg: The parsed SVG to validate - /// - iconName: Name of the icon (for logging context) - /// - Returns: Array of validation issues - public func validate(svg: ParsedSVG, iconName: String) -> [PathValidationIssue] { - var pathsToValidate: [(name: String, pathData: String)] = [] - var pathIndex = 0 - - // Collect paths from elements - if !svg.elements.isEmpty { - collectPaths(from: svg.elements, into: &pathsToValidate, pathIndex: &pathIndex) - } else { - // Fallback for legacy structure - for path in svg.paths { - pathsToValidate.append(("path_\(pathIndex)", path.pathData)) - pathIndex += 1 - } - if let groups = svg.groups { - for group in groups { - collectPathsFromGroup(group, into: &pathsToValidate, pathIndex: &pathIndex) - } - } - } - - return validatePaths(pathsToValidate) - } - - private func collectPaths( - from elements: [SVGElement], - into paths: inout [(name: String, pathData: String)], - pathIndex: inout Int - ) { - for element in elements { - switch element { - case let .path(path): - paths.append(("path_\(pathIndex)", path.pathData)) - pathIndex += 1 - case let .group(group): - collectPaths(from: group.elements, into: &paths, pathIndex: &pathIndex) - // Also check legacy paths array - for path in group.paths { - paths.append(("path_\(pathIndex)", path.pathData)) - pathIndex += 1 - } - } - } - } - - private func collectPathsFromGroup( - _ group: SVGGroup, - into paths: inout [(name: String, pathData: String)], - pathIndex: inout Int - ) { - for path in group.paths { - paths.append(("path_\(pathIndex)", path.pathData)) - pathIndex += 1 - } - for child in group.children { - collectPathsFromGroup(child, into: &paths, pathIndex: &pathIndex) - } - } -} - -// MARK: - Validation Summary - -/// Summary of path validation across multiple icons -public struct PathValidationSummary: Sendable { - public let totalIcons: Int - public let iconsWithWarnings: Int - public let iconsWithCriticalErrors: Int - public let allIssues: [(iconName: String, issues: [PathValidationIssue])] - - public var hasWarnings: Bool { - iconsWithWarnings > 0 - } - - public var hasCriticalErrors: Bool { - iconsWithCriticalErrors > 0 - } - - public init(results: [(iconName: String, issues: [PathValidationIssue])]) { - totalIcons = results.count - allIssues = results.filter { !$0.issues.isEmpty } - iconsWithWarnings = results.filter { iconResult in - iconResult.issues.contains { $0.result.exceedsLintThreshold } - }.count - iconsWithCriticalErrors = results.filter { iconResult in - iconResult.issues.contains { $0.isCritical } - }.count - } -} diff --git a/Sources/SVGKit/ResvgPathConverter.swift b/Sources/SVGKit/ResvgPathConverter.swift deleted file mode 100644 index 0aaebeb8..00000000 --- a/Sources/SVGKit/ResvgPathConverter.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation -import Resvg - -/// Converts resvg Path segments to SVG path data string format. -/// -/// This utility enables extracting path data from the resvg Tree Traversal API -/// for use in clip-path and mask conversion to VectorDrawable format. -public enum ResvgPathConverter { - /// Converts an array of resvg PathSegments to SVG path data string. - /// - /// - Parameter segments: Array of PathSegment from resvg Path - /// - Returns: SVG path data string (d attribute format) - /// - /// Example: - /// ```swift - /// let tree = try SvgTree(data: svgData) - /// if let mask = tree.root.mask { - /// for child in mask.root.children { - /// if let path = child.asPath() { - /// let pathData = ResvgPathConverter.toPathString(path.segments) - /// // Use pathData for VectorDrawable clip-path - /// } - /// } - /// } - /// ``` - public static func toPathString(_ segments: [PathSegment]) -> String { - var parts: [String] = [] - parts.reserveCapacity(segments.count) - - for segment in segments { - switch segment.type { - case .moveTo: - parts.append("M\(formatFloat(segment.x)),\(formatFloat(segment.y))") - case .lineTo: - parts.append("L\(formatFloat(segment.x)),\(formatFloat(segment.y))") - case .quadTo: - parts.append( - "Q\(formatFloat(segment.x1)),\(formatFloat(segment.y1)) " + - "\(formatFloat(segment.x)),\(formatFloat(segment.y))" - ) - case .cubicTo: - parts.append( - "C\(formatFloat(segment.x1)),\(formatFloat(segment.y1)) " + - "\(formatFloat(segment.x2)),\(formatFloat(segment.y2)) " + - "\(formatFloat(segment.x)),\(formatFloat(segment.y))" - ) - case .close: - parts.append("Z") - } - } - return parts.joined() - } - - /// Converts a resvg Path to SVG path data string. - /// - /// - Parameter path: resvg Path from Tree Traversal API - /// - Returns: SVG path data string (d attribute format) - public static func toPathString(_ path: Resvg.Path) -> String { - toPathString(path.segments) - } - - /// Extracts the first path data from a mask's content. - /// - /// Figma uses masks instead of clip-paths for rounded corners on flags. - /// This helper extracts the path data from the first path in the mask, - /// which can then be used as a clip-path in VectorDrawable. - /// - /// - Parameter mask: Mask from Group.mask - /// - Returns: SVG path data string, or nil if mask has no path children - public static func extractPathFromMask(_ mask: Mask) -> String? { - extractPathFromGroup(mask.root) - } - - /// Extracts the first path data from a clip-path's content. - /// - /// - Parameter clipPath: ClipPath from Group.clipPath - /// - Returns: SVG path data string, or nil if clip-path has no path children - public static func extractPathFromClipPath(_ clipPath: ClipPath) -> String? { - extractPathFromGroup(clipPath.root) - } - - /// Extracts the first path data from a group's children. - /// - /// - Parameter group: Group to search for paths - /// - Returns: SVG path data string from first path found, or nil if none - public static func extractPathFromGroup(_ group: Group) -> String? { - for child in group.children { - if let path = child.asPath() { - return toPathString(path) - } - if let childGroup = child.asGroup() { - if let pathData = extractPathFromGroup(childGroup) { - return pathData - } - } - } - return nil - } - - // MARK: - Private Helpers - - /// Formats a Float to string, removing unnecessary trailing zeros. - private static func formatFloat(_ value: Float) -> String { - if value == Float(Int(value)) { - return String(Int(value)) - } - // Format with up to 4 decimal places, trimming trailing zeros - let formatted = String(format: "%.4f", value) - return formatted - .replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) - } -} diff --git a/Sources/SVGKit/SVGParser.swift b/Sources/SVGKit/SVGParser.swift deleted file mode 100644 index 1e1d1731..00000000 --- a/Sources/SVGKit/SVGParser.swift +++ /dev/null @@ -1,1557 +0,0 @@ -// swiftlint:disable file_length -import Foundation -import Logging -import Resvg -#if os(Linux) - import FoundationXML -#endif - -/// Represents a parsed SVG document ready for ImageVector conversion -public struct ParsedSVG: Equatable, Sendable { - public let width: Double - public let height: Double - public let viewportWidth: Double - public let viewportHeight: Double - public let paths: [SVGPath] - public let groups: [SVGGroup]? - public let linearGradients: [String: SVGLinearGradient] - public let radialGradients: [String: SVGRadialGradient] - /// Ordered root elements (preserves SVG document order) - public let elements: [SVGElement] - - public init( - width: Double, - height: Double, - viewportWidth: Double, - viewportHeight: Double, - paths: [SVGPath], - groups: [SVGGroup]? = nil, - linearGradients: [String: SVGLinearGradient] = [:], - radialGradients: [String: SVGRadialGradient] = [:], - elements: [SVGElement] = [] - ) { - self.width = width - self.height = height - self.viewportWidth = viewportWidth - self.viewportHeight = viewportHeight - self.paths = paths - self.groups = groups - self.linearGradients = linearGradients - self.radialGradients = radialGradients - self.elements = elements - } -} - -/// Represents a single path element in SVG -public struct SVGPath: Equatable, Sendable { - public let pathData: String - public let commands: [SVGPathCommand] - public let fill: SVGColor? - public let fillType: SVGFill - public let stroke: SVGColor? - public let strokeWidth: Double? - public let strokeLineCap: StrokeCap? - public let strokeLineJoin: StrokeJoin? - public let strokeDashArray: [Double]? - public let strokeDashOffset: Double? - public let fillRule: FillRule? - public let opacity: Double? - public let fillOpacity: Double? - - public init( - pathData: String, - commands: [SVGPathCommand], - fill: SVGColor?, - fillType: SVGFill = .none, - stroke: SVGColor?, - strokeWidth: Double?, - strokeLineCap: StrokeCap?, - strokeLineJoin: StrokeJoin?, - strokeDashArray: [Double]? = nil, - strokeDashOffset: Double? = nil, - fillRule: FillRule?, - opacity: Double?, - fillOpacity: Double? = nil - ) { - self.pathData = pathData - self.commands = commands - self.fill = fill - self.fillType = fillType - self.stroke = stroke - self.strokeWidth = strokeWidth - self.strokeLineCap = strokeLineCap - self.strokeLineJoin = strokeLineJoin - self.strokeDashArray = strokeDashArray - self.strokeDashOffset = strokeDashOffset - self.fillRule = fillRule - self.opacity = opacity - self.fillOpacity = fillOpacity - } - - public enum StrokeCap: String, Sendable { - case butt - case round - case square - } - - public enum StrokeJoin: String, Sendable { - case miter - case round - case bevel - } - - public enum FillRule: String, Sendable { - case nonZero - case evenOdd - } -} - -/// Represents a color in SVG -public struct SVGColor: Equatable, Sendable { - public let red: UInt8 - public let green: UInt8 - public let blue: UInt8 - public let alpha: Double - - public init(red: UInt8, green: UInt8, blue: UInt8, alpha: Double = 1.0) { - self.red = red - self.green = green - self.blue = blue - self.alpha = alpha - } - - /// Parses a color string (hex, rgb, or named color) - public static func parse(_ string: String) -> SVGColor? { - let trimmed = string.trimmingCharacters(in: .whitespaces) - - if trimmed == "none" || trimmed.isEmpty { - return nil - } - - if trimmed == "currentColor" { - return SVGColor(red: 0, green: 0, blue: 0) // Default to black - } - - // Hex color - if trimmed.hasPrefix("#") { - return parseHex(trimmed) - } - - // rgb() or rgba() function - if trimmed.hasPrefix("rgb") { - return parseRGB(trimmed) - } - - // Named colors - return namedColor(trimmed) - } - - private static func parseHex(_ hex: String) -> SVGColor? { - var hexString = hex.trimmingCharacters(in: .whitespaces) - if hexString.hasPrefix("#") { - hexString.removeFirst() - } - - var rgb: UInt64 = 0 - Scanner(string: hexString).scanHexInt64(&rgb) - - switch hexString.count { - case 3: // RGB shorthand - let r = UInt8((rgb >> 8 & 0xF) * 17) - let g = UInt8((rgb >> 4 & 0xF) * 17) - let b = UInt8((rgb & 0xF) * 17) - return SVGColor(red: r, green: g, blue: b) - case 6: // RRGGBB - let r = UInt8(rgb >> 16 & 0xFF) - let g = UInt8(rgb >> 8 & 0xFF) - let b = UInt8(rgb & 0xFF) - return SVGColor(red: r, green: g, blue: b) - case 8: // RRGGBBAA - let r = UInt8(rgb >> 24 & 0xFF) - let g = UInt8(rgb >> 16 & 0xFF) - let b = UInt8(rgb >> 8 & 0xFF) - let a = Double(rgb & 0xFF) / 255.0 - return SVGColor(red: r, green: g, blue: b, alpha: a) - default: - return nil - } - } - - private static func parseRGB(_ rgb: String) -> SVGColor? { - let pattern = #"rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: []), - let match = regex.firstMatch( - in: rgb, - options: [], - range: NSRange(rgb.startIndex..., in: rgb) - ) - else { - return nil - } - - let getValue: (Int) -> String? = { index in - guard let range = Range(match.range(at: index), in: rgb) else { return nil } - return String(rgb[range]) - } - - guard let rStr = getValue(1), let r = UInt8(rStr), - let gStr = getValue(2), let g = UInt8(gStr), - let bStr = getValue(3), let b = UInt8(bStr) - else { - return nil - } - - let alpha = getValue(4).flatMap { Double($0) } ?? 1.0 - return SVGColor(red: r, green: g, blue: b, alpha: alpha) - } - - private static func namedColor(_ name: String) -> SVGColor? { - let colors: [String: (UInt8, UInt8, UInt8)] = [ - "black": (0, 0, 0), - "white": (255, 255, 255), - "red": (255, 0, 0), - "green": (0, 128, 0), - "blue": (0, 0, 255), - "yellow": (255, 255, 0), - "cyan": (0, 255, 255), - "magenta": (255, 0, 255), - "gray": (128, 128, 128), - "grey": (128, 128, 128), - "orange": (255, 165, 0), - "purple": (128, 0, 128), - "pink": (255, 192, 203), - "brown": (165, 42, 42), - "transparent": (0, 0, 0), - ] - - guard let (r, g, b) = colors[name.lowercased()] else { - return nil - } - - let alpha: Double = name.lowercased() == "transparent" ? 0.0 : 1.0 - return SVGColor(red: r, green: g, blue: b, alpha: alpha) - } - - /// Returns hex string representation (0xAARRGGBB format for Compose) - public var composeHex: String { - let alphaInt = UInt8(alpha * 255) - return String(format: "0x%02X%02X%02X%02X", alphaInt, red, green, blue) - } -} - -/// Parses SVG files into ParsedSVG structures -public final class SVGParser: @unchecked Sendable { // swiftlint:disable:this type_body_length - private static let inheritableAttributes = [ - "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", - "stroke-dasharray", "stroke-dashoffset", "fill-rule", "opacity", "fill-opacity", - ] - - private let pathParser = SVGPathParser() - - /// Storage for clip-path definitions - private var clipPathDefs: [String: String] = [:] - - /// Storage for mask definitions (mask id -> path data for clip-path conversion) - private var maskDefs: [String: String] = [:] - - /// Storage for linear gradient definitions - private var linearGradientDefs: [String: SVGLinearGradient] = [:] - - /// Storage for radial gradient definitions - private var radialGradientDefs: [String: SVGRadialGradient] = [:] - - /// Storage for and reusable element definitions (for support) - private var symbolDefs: [String: XMLElement] = [:] - - /// Storage for all elements with id attribute (for support) - private var elementDefs: [String: XMLElement] = [:] - - /// Storage for CSS styles from - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].fill?.green, 0) - XCTAssertEqual(parsed.paths[0].fill?.blue, 0) - } - - func testClassSelectorAppliesStroke() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].stroke?.green, 255) - XCTAssertEqual(parsed.paths[0].strokeWidth, 2) - } - - // MARK: - ID Selector Tests - - func testIdSelectorAppliesFill() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.blue, 255) - } - - // MARK: - Multiple Classes Tests - - func testMultipleClassesMergeStyles() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].strokeWidth, 3) - } - - // MARK: - Specificity Tests - - func testInlineStyleOverridesCSS() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - // Inline style should override CSS - XCTAssertEqual(parsed.paths[0].fill?.green, 255) - XCTAssertEqual(parsed.paths[0].fill?.red, 0) - } - - func testInlineStyleAttributeOverridesCSS() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - // Inline style attribute overrides CSS fill - XCTAssertEqual(parsed.paths[0].fill?.green, 255) - // CSS stroke should still apply - XCTAssertEqual(parsed.paths[0].stroke?.blue, 255) - } - - // MARK: - Multiple Rules Tests - - func testMultipleSelectorsInOneRule() throws { - let svg = """ - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 2) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[1].fill?.red, 255) - } - - // MARK: - Edge Cases - - func testEmptyStyleBlockNoError() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - } - - func testStyleInDefsSection() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - } - - func testCDATAWrappedStyle() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - } - - func testMalformedCSSSkippedGracefully() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - // Should not crash, valid rule should still apply - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.green, 255) - } - - // MARK: - CSS Property Support Tests - - func testCSSFillOpacity() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].opacity, 0.5) - } - - func testCSSStrokeLinecap() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeLineCap, .round) - } - - func testCSSFillRule() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fillRule, .evenOdd) - } - - // MARK: - Group Inheritance Tests - - func testCSSAppliedToGroup() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - // CSS on group should inherit to children - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - } -} diff --git a/Tests/SVGKitTests/SVGElementOrderTests.swift b/Tests/SVGKitTests/SVGElementOrderTests.swift deleted file mode 100644 index 23ffbc5a..00000000 --- a/Tests/SVGKitTests/SVGElementOrderTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for SVG element order preservation in parsing and generation. -/// Ensures paths and groups maintain their document order (z-order). -final class SVGElementOrderTests: XCTestCase { - var parser: SVGParser! - let vectorGenerator = VectorDrawableXMLGenerator() - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Root Level Order - - func testRootElementOrderPreserved() throws { - let svg = """ - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.elements.count, 3, "Should have 3 root elements") - - if case let .path(path1) = parsed.elements[0] { - XCTAssertEqual(path1.fill?.red, 255, "First element should be red path") - } else { - XCTFail("First element should be a path") - } - - if case .group = parsed.elements[1] { - // OK - } else { - XCTFail("Second element should be a group") - } - - if case let .path(path3) = parsed.elements[2] { - XCTAssertEqual(path3.fill?.blue, 255, "Third element should be blue path") - } else { - XCTFail("Third element should be a path") - } - } - - func testVectorDrawableOutputOrder() throws { - let svg = """ - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - let xml = vectorGenerator.generate(from: parsed) - - let redIndex = xml.range(of: "#FF0000")?.lowerBound - let groupIndex = xml.range(of: "")?.lowerBound - let blueIndex = xml.range(of: "#0000FF")?.lowerBound - - XCTAssertNotNil(redIndex) - XCTAssertNotNil(groupIndex) - XCTAssertNotNil(blueIndex) - XCTAssertTrue(try XCTUnwrap(redIndex) < groupIndex!, "Red path should come before group") - XCTAssertTrue(try XCTUnwrap(groupIndex) < blueIndex!, "Group should come before blue path") - } - - // MARK: - Group Level Order - - func testGroupElementsOrderPreserved() throws { - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - let group = try XCTUnwrap(parsed.groups?[0]) - XCTAssertEqual(group.elements.count, 3, "Group should have 3 elements") - - if case let .path(path1) = group.elements[0] { - XCTAssertEqual(path1.fill?.red, 255) - } else { - XCTFail("First group element should be red path") - } - - if case .group = group.elements[1] { - // OK - nested group - } else { - XCTFail("Second group element should be nested group") - } - - if case let .path(path3) = group.elements[2] { - XCTAssertEqual(path3.fill?.blue, 255) - } else { - XCTFail("Third group element should be blue path") - } - } - - // MARK: - Flag Layer Order - - func testFlagBackgroundBeforeForeground() throws { - let svg = """ - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - let xml = vectorGenerator.generate(from: parsed) - - let blueIndex = xml.range(of: "#4189DD")?.lowerBound - let yellowIndex = xml.range(of: "#F9D616")?.lowerBound - - XCTAssertNotNil(blueIndex, "Blue background should be in output") - XCTAssertNotNil(yellowIndex, "Yellow foreground should be in output") - XCTAssertTrue(try XCTUnwrap(blueIndex) < yellowIndex!, "Background should come before foreground") - } - - func testElementsInDocumentOrder() throws { - // Two groups should maintain their order - let svg = """ - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - let xml = vectorGenerator.generate(from: parsed) - - let redIndex = xml.range(of: "#D21034")?.lowerBound - let blueIndex = xml.range(of: "#4189DD")?.lowerBound - - XCTAssertNotNil(redIndex) - XCTAssertNotNil(blueIndex) - XCTAssertTrue(try XCTUnwrap(redIndex) < blueIndex!, "Elements should maintain SVG document order") - } -} diff --git a/Tests/SVGKitTests/SVGFillOpacityTests.swift b/Tests/SVGKitTests/SVGFillOpacityTests.swift deleted file mode 100644 index 3f228bba..00000000 --- a/Tests/SVGKitTests/SVGFillOpacityTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -@testable import SVGKit -import Testing - -@Suite("SVG Fill Opacity Tests") -struct SVGFillOpacityTests { - @Test("Parse fill-opacity from circle element") - func parseFillOpacityFromCircle() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - #expect(parsed.paths.count == 1) - #expect(parsed.paths[0].fillOpacity == 0.5) - } - - @Test("Parse fill-opacity from path element") - func parseFillOpacityFromPath() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - #expect(parsed.paths.count == 1) - #expect(parsed.paths[0].fillOpacity == 0.3) - } - - @Test("Generate VectorDrawable with fillAlpha from fillOpacity") - func generateVectorDrawableWithFillAlpha() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - #expect(xml.contains("fillAlpha=\"0.5\"")) - } - - @Test("Generate ImageVector with fillAlpha from fillOpacity") - func generateImageVectorWithFillAlpha() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let config = ImageVectorGenerator.Config(packageName: "com.test", generatePreview: false) - let generator = ImageVectorGenerator(config: config) - let kotlin = generator.generate(name: "test", svg: parsed) - - #expect(kotlin.contains("fillAlpha = 0.5f")) - } - - @Test("Fill opacity does not affect stroke-only paths") - func fillOpacityStrokeOnlyPath() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // Path with fill="none" should still parse fill-opacity - #expect(parsed.paths[0].fillOpacity == 0.5) - } - - @Test("Inherit fill-opacity from parent group") - func inheritFillOpacityFromGroup() throws { - let svg = """ - - - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // Check that the circle inherits fill-opacity from the group - #expect(parsed.groups?.first?.paths.first?.fillOpacity == 0.7) - } -} diff --git a/Tests/SVGKitTests/SVGGradientParsingTests.swift b/Tests/SVGKitTests/SVGGradientParsingTests.swift deleted file mode 100644 index 191a7e6e..00000000 --- a/Tests/SVGKitTests/SVGGradientParsingTests.swift +++ /dev/null @@ -1,464 +0,0 @@ -// swiftlint:disable file_length type_body_length -@testable import SVGKit -import XCTest - -final class SVGGradientParsingTests: XCTestCase { - private let parser = SVGParser() - - // MARK: - Linear Gradient Parsing Tests - - func testParseLinearGradientBasic() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.linearGradients.count, 1) - - let gradient = parsed.linearGradients["grad1"] - XCTAssertNotNil(gradient) - XCTAssertEqual(gradient?.x1, 0) - XCTAssertEqual(gradient?.y1, 0) - XCTAssertEqual(gradient?.x2, 24) - XCTAssertEqual(gradient?.y2, 24) - XCTAssertEqual(gradient?.stops.count, 2) - XCTAssertEqual(gradient?.stops[0].color.red, 255) - XCTAssertEqual(gradient?.stops[0].color.green, 0) - XCTAssertEqual(gradient?.stops[0].color.blue, 0) - XCTAssertEqual(gradient?.stops[1].color.red, 0) - XCTAssertEqual(gradient?.stops[1].color.green, 0) - XCTAssertEqual(gradient?.stops[1].color.blue, 255) - } - - func testParseLinearGradientPercentageCoords() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - // Percentages should be converted to viewport coordinates - XCTAssertEqual(gradient?.x1, 0) - XCTAssertEqual(gradient?.y1, 0) - XCTAssertEqual(gradient?.x2, 100) - XCTAssertEqual(gradient?.y2, 100) - } - - func testParseLinearGradientWithSpreadMethod() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.spreadMethod, .reflect) - } - - func testParseLinearGradientWithRepeatSpreadMethod() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.spreadMethod, .repeating) - } - - func testParseLinearGradientDefaultSpreadMethod() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.spreadMethod, .pad) - } - - // MARK: - Radial Gradient Parsing Tests - - func testParseRadialGradientBasic() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.radialGradients.count, 1) - - let gradient = parsed.radialGradients["grad2"] - XCTAssertNotNil(gradient) - XCTAssertEqual(gradient?.cx, 12) - XCTAssertEqual(gradient?.cy, 12) - XCTAssertEqual(gradient?.r, 12) - XCTAssertNil(gradient?.fx) - XCTAssertNil(gradient?.fy) - XCTAssertEqual(gradient?.stops.count, 2) - } - - func testParseRadialGradientWithFocalPoint() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.radialGradients["grad2"] - - XCTAssertEqual(gradient?.fx, 8) - XCTAssertEqual(gradient?.fy, 8) - } - - func testParseRadialGradientPercentageCoords() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.radialGradients["grad1"] - - XCTAssertEqual(gradient?.cx, 50) - XCTAssertEqual(gradient?.cy, 50) - XCTAssertEqual(gradient?.r, 50) - } - - // MARK: - Stop Element Parsing Tests - - func testParseStopWithOpacity() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.stops[0].opacity, 0.5) - XCTAssertEqual(gradient?.stops[1].opacity, 1.0) - } - - func testParseStopDefaultOpacity() throws { - let svg = """ - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.stops[0].opacity, 1.0) - } - - func testParseStopPercentageOffset() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.stops[0].offset, 0.0) - XCTAssertEqual(gradient?.stops[1].offset, 0.5) - XCTAssertEqual(gradient?.stops[2].offset, 1.0) - } - - func testParseStopsAreSortedByOffset() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.stops[0].offset, 0.0) - XCTAssertEqual(gradient?.stops[1].offset, 0.5) - XCTAssertEqual(gradient?.stops[2].offset, 1.0) - } - - // MARK: - Fill URL Resolution Tests - - func testResolveFillUrlReferenceToLinearGradient() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - - if case let .linearGradient(gradient) = parsed.paths[0].fillType { - XCTAssertEqual(gradient.id, "myGrad") - } else { - XCTFail("Expected linearGradient fill, got \(String(describing: parsed.paths[0].fillType))") - } - } - - func testResolveFillUrlReferenceToRadialGradient() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - - if case let .radialGradient(gradient) = parsed.paths[0].fillType { - XCTAssertEqual(gradient.id, "myGrad") - } else { - XCTFail("Expected radialGradient fill, got \(String(describing: parsed.paths[0].fillType))") - } - } - - func testResolveFillUrlNotFound() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - // Should fall back to none when gradient not found - if case .none = parsed.paths[0].fillType { - // Success - } else { - XCTFail("Expected .none fill for missing gradient, got \(String(describing: parsed.paths[0].fillType))") - } - } - - // MARK: - Backward Compatibility Tests - - func testParseSVGWithoutGradients() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertTrue(parsed.linearGradients.isEmpty) - XCTAssertTrue(parsed.radialGradients.isEmpty) - - if case let .solid(color) = parsed.paths[0].fillType { - XCTAssertEqual(color.red, 255) - XCTAssertEqual(color.green, 0) - XCTAssertEqual(color.blue, 0) - } else { - XCTFail("Expected solid fill, got \(String(describing: parsed.paths[0].fillType))") - } - } - - func testParseSVGWithEmptyDefs() throws { - let svg = """ - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertTrue(parsed.linearGradients.isEmpty) - XCTAssertTrue(parsed.radialGradients.isEmpty) - } - - func testParseSVGWithNoDefs() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertTrue(parsed.linearGradients.isEmpty) - XCTAssertTrue(parsed.radialGradients.isEmpty) - } - - // MARK: - Multiple Gradients Tests - - func testParseMultipleGradients() throws { - let svg = """ - - - - - - - - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.linearGradients.count, 2) - XCTAssertEqual(parsed.radialGradients.count, 1) - XCTAssertNotNil(parsed.linearGradients["linear1"]) - XCTAssertNotNil(parsed.linearGradients["linear2"]) - XCTAssertNotNil(parsed.radialGradients["radial1"]) - } - - // MARK: - Shorthand Color Tests - - func testParseStopWithShorthandColor() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let gradient = parsed.linearGradients["grad1"] - - XCTAssertEqual(gradient?.stops[0].color.red, 255) - XCTAssertEqual(gradient?.stops[0].color.green, 0) - XCTAssertEqual(gradient?.stops[0].color.blue, 0) - XCTAssertEqual(gradient?.stops[1].color.red, 0) - XCTAssertEqual(gradient?.stops[1].color.green, 0) - XCTAssertEqual(gradient?.stops[1].color.blue, 255) - } - - // MARK: - Path with Gradient Fill - - func testParsePathWithGradientFill() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - - if case let .linearGradient(gradient) = parsed.paths[0].fillType { - XCTAssertEqual(gradient.id, "grad1") - XCTAssertEqual(gradient.stops.count, 2) - } else { - XCTFail("Expected linearGradient fill") - } - } -} diff --git a/Tests/SVGKitTests/SVGGradientTests.swift b/Tests/SVGKitTests/SVGGradientTests.swift deleted file mode 100644 index df29986d..00000000 --- a/Tests/SVGKitTests/SVGGradientTests.swift +++ /dev/null @@ -1,250 +0,0 @@ -@testable import SVGKit -import XCTest - -final class SVGGradientTests: XCTestCase { - // MARK: - SVGGradientStop Tests - - func testGradientStopCreation() { - let color = SVGColor(red: 255, green: 0, blue: 0) - let stop = SVGGradientStop(offset: 0.5, color: color, opacity: 0.8) - - XCTAssertEqual(stop.offset, 0.5) - XCTAssertEqual(stop.color.red, 255) - XCTAssertEqual(stop.color.green, 0) - XCTAssertEqual(stop.color.blue, 0) - XCTAssertEqual(stop.opacity, 0.8) - } - - func testGradientStopDefaultOpacity() { - let color = SVGColor(red: 0, green: 0, blue: 0) - let stop = SVGGradientStop(offset: 0, color: color) - - XCTAssertEqual(stop.opacity, 1.0) - } - - func testGradientStopEquatable() { - let color1 = SVGColor(red: 255, green: 0, blue: 0) - let color2 = SVGColor(red: 255, green: 0, blue: 0) - let color3 = SVGColor(red: 0, green: 0, blue: 255) - - let stop1 = SVGGradientStop(offset: 0.5, color: color1) - let stop2 = SVGGradientStop(offset: 0.5, color: color2) - let stop3 = SVGGradientStop(offset: 0.7, color: color1) - let stop4 = SVGGradientStop(offset: 0.5, color: color3) - - XCTAssertEqual(stop1, stop2) - XCTAssertNotEqual(stop1, stop3) // Different offset - XCTAssertNotEqual(stop1, stop4) // Different color - } - - func testGradientStopSendable() { - let color = SVGColor(red: 255, green: 0, blue: 0) - let stop = SVGGradientStop(offset: 0.5, color: color) - - // Verify Sendable conformance compiles - Task { - _ = stop // Should compile without warnings - } - } - - // MARK: - SVGLinearGradient Tests - - func testLinearGradientCreation() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 0, blue: 0)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 255)), - ] - let gradient = SVGLinearGradient( - id: "grad1", - x1: 0, y1: 0, - x2: 24, y2: 24, - stops: stops - ) - - XCTAssertEqual(gradient.id, "grad1") - XCTAssertEqual(gradient.x1, 0) - XCTAssertEqual(gradient.y1, 0) - XCTAssertEqual(gradient.x2, 24) - XCTAssertEqual(gradient.y2, 24) - XCTAssertEqual(gradient.stops.count, 2) - XCTAssertEqual(gradient.spreadMethod, .pad) // Default - } - - func testLinearGradientWithSpreadMethod() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 0, green: 0, blue: 0)), - ] - let gradient = SVGLinearGradient( - id: "grad", - x1: 0, y1: 0, - x2: 1, y2: 1, - stops: stops, - spreadMethod: .reflect - ) - - XCTAssertEqual(gradient.spreadMethod, .reflect) - } - - func testLinearGradientSendable() { - let gradient = SVGLinearGradient( - id: "g", - x1: 0, y1: 0, - x2: 1, y2: 1, - stops: [] - ) - - // Verify Sendable conformance compiles - Task { - _ = gradient - } - } - - func testLinearGradientEquatable() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 0, green: 0, blue: 0)), - ] - let gradient1 = SVGLinearGradient(id: "g1", x1: 0, y1: 0, x2: 1, y2: 1, stops: stops) - let gradient2 = SVGLinearGradient(id: "g1", x1: 0, y1: 0, x2: 1, y2: 1, stops: stops) - let gradient3 = SVGLinearGradient(id: "g2", x1: 0, y1: 0, x2: 1, y2: 1, stops: stops) - - XCTAssertEqual(gradient1, gradient2) - XCTAssertNotEqual(gradient1, gradient3) - } - - // MARK: - SVGRadialGradient Tests - - func testRadialGradientCreation() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 255, blue: 255)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 0)), - ] - let gradient = SVGRadialGradient( - id: "grad2", - cx: 12, cy: 12, - r: 12, - fx: nil, fy: nil, - stops: stops - ) - - XCTAssertEqual(gradient.id, "grad2") - XCTAssertEqual(gradient.cx, 12) - XCTAssertEqual(gradient.cy, 12) - XCTAssertEqual(gradient.r, 12) - XCTAssertNil(gradient.fx) - XCTAssertNil(gradient.fy) - XCTAssertEqual(gradient.stops.count, 2) - XCTAssertEqual(gradient.spreadMethod, .pad) - } - - func testRadialGradientWithFocalPoint() { - let gradient = SVGRadialGradient( - id: "grad3", - cx: 12, cy: 12, - r: 12, - fx: 8, fy: 8, - stops: [] - ) - - XCTAssertEqual(gradient.fx, 8) - XCTAssertEqual(gradient.fy, 8) - } - - func testRadialGradientSendable() { - let gradient = SVGRadialGradient( - id: "g", - cx: 12, cy: 12, - r: 12, - stops: [] - ) - - // Verify Sendable conformance compiles - Task { - _ = gradient - } - } - - func testRadialGradientEquatable() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 0, green: 0, blue: 0)), - ] - let gradient1 = SVGRadialGradient(id: "g1", cx: 12, cy: 12, r: 12, stops: stops) - let gradient2 = SVGRadialGradient(id: "g1", cx: 12, cy: 12, r: 12, stops: stops) - let gradient3 = SVGRadialGradient(id: "g1", cx: 12, cy: 12, r: 24, stops: stops) - - XCTAssertEqual(gradient1, gradient2) - XCTAssertNotEqual(gradient1, gradient3) - } - - // MARK: - SVGFill Tests - - func testSVGFillNone() { - let fill = SVGFill.none - if case .none = fill { - // Success - } else { - XCTFail("Expected .none") - } - } - - func testSVGFillSolid() { - let color = SVGColor(red: 255, green: 0, blue: 0) - let fill = SVGFill.solid(color) - - if case let .solid(fillColor) = fill { - XCTAssertEqual(fillColor.red, 255) - XCTAssertEqual(fillColor.green, 0) - XCTAssertEqual(fillColor.blue, 0) - } else { - XCTFail("Expected .solid") - } - } - - func testSVGFillLinearGradient() { - let gradient = SVGLinearGradient(id: "g", x1: 0, y1: 0, x2: 1, y2: 1, stops: []) - let fill = SVGFill.linearGradient(gradient) - - if case let .linearGradient(g) = fill { - XCTAssertEqual(g.id, "g") - } else { - XCTFail("Expected .linearGradient") - } - } - - func testSVGFillRadialGradient() { - let gradient = SVGRadialGradient(id: "rg", cx: 12, cy: 12, r: 12, stops: []) - let fill = SVGFill.radialGradient(gradient) - - if case let .radialGradient(g) = fill { - XCTAssertEqual(g.id, "rg") - } else { - XCTFail("Expected .radialGradient") - } - } - - func testSVGFillEquatable() { - let color = SVGColor(red: 255, green: 0, blue: 0) - let fill1 = SVGFill.solid(color) - let fill2 = SVGFill.solid(color) - let fill3 = SVGFill.none - - XCTAssertEqual(fill1, fill2) - XCTAssertNotEqual(fill1, fill3) - } - - func testSVGFillSendable() { - let fill = SVGFill.none - - // Verify Sendable conformance compiles - Task { - _ = fill - } - } - - // MARK: - SpreadMethod Tests - - func testSpreadMethodRawValues() { - XCTAssertEqual(SVGLinearGradient.SpreadMethod.pad.rawValue, "pad") - XCTAssertEqual(SVGLinearGradient.SpreadMethod.reflect.rawValue, "reflect") - XCTAssertEqual(SVGLinearGradient.SpreadMethod.repeating.rawValue, "repeat") - } -} diff --git a/Tests/SVGKitTests/SVGGradientTransformTests.swift b/Tests/SVGKitTests/SVGGradientTransformTests.swift deleted file mode 100644 index e33547c7..00000000 --- a/Tests/SVGKitTests/SVGGradientTransformTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for gradientTransform parsing and application in SVG gradients. -final class SVGGradientTransformTests: XCTestCase { - var parser: SVGParser! - let vectorGenerator = VectorDrawableXMLGenerator() - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Parsing - - func testParseLinearGradientWithTranslateTransform() throws { - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - let gradient = parsed.linearGradients["grad1"] - XCTAssertNotNil(gradient) - XCTAssertNotNil(gradient?.gradientTransform, "gradientTransform should be parsed") - XCTAssertEqual(gradient?.gradientTransform?.translateX, 5) - XCTAssertEqual(gradient?.gradientTransform?.translateY, 10) - } - - func testParseRadialGradientWithTranslateAndScaleTransform() throws { - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - let gradient = parsed.radialGradients["grad1"] - XCTAssertNotNil(gradient) - XCTAssertNotNil(gradient?.gradientTransform, "gradientTransform should be parsed") - XCTAssertEqual(gradient?.gradientTransform?.translateX, 2) - XCTAssertEqual(gradient?.gradientTransform?.translateY, 3) - XCTAssertEqual(gradient?.gradientTransform?.scaleX, 1.5) - } - - func testParseRadialGradientWithMatrixTransform() throws { - // Figma often exports matrix transforms for gradients - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - let gradient = parsed.radialGradients["grad1"] - XCTAssertNotNil(gradient) - XCTAssertNotNil(gradient?.gradientTransform) - XCTAssertEqual(gradient?.gradientTransform?.translateX, 12) - XCTAssertEqual(gradient?.gradientTransform?.translateY, 12) - XCTAssertEqual(gradient?.gradientTransform?.scaleX, 10) - XCTAssertEqual(gradient?.gradientTransform?.scaleY, 10) - } - - // MARK: - VectorDrawable Application - - func testVectorDrawableAppliesLinearGradientTransform() { - let transform = SVGTransform(translateX: 5, translateY: 10) - let gradient = SVGLinearGradient( - id: "grad1", - x1: 0, y1: 0, x2: 10, y2: 0, - stops: [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 0, blue: 0)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 255)), - ], - gradientTransform: transform - ) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .linearGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path], - linearGradients: ["grad1": gradient] - ) - - let xml = vectorGenerator.generate(from: svg) - - // x1=0 + translateX=5 = 5, x2=10 + translateX=5 = 15 - XCTAssertTrue(xml.contains("android:startX=\"5\""), "startX should be transformed:\n\(xml)") - XCTAssertTrue(xml.contains("android:startY=\"10\""), "startY should be transformed:\n\(xml)") - XCTAssertTrue(xml.contains("android:endX=\"15\""), "endX should be transformed:\n\(xml)") - XCTAssertTrue(xml.contains("android:endY=\"10\""), "endY should be transformed:\n\(xml)") - } - - func testVectorDrawableAppliesRadialGradientTransform() { - let transform = SVGTransform(translateX: 6, translateY: 6, scaleX: 2, scaleY: 2) - let gradient = SVGRadialGradient( - id: "grad1", - cx: 0, cy: 0, r: 5, - stops: [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 255, blue: 255)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 0)), - ], - gradientTransform: transform - ) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .radialGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path], - radialGradients: ["grad1": gradient] - ) - - let xml = vectorGenerator.generate(from: svg) - - // cx=0*2+6=6, cy=0*2+6=6, r=5*2=10 - XCTAssertTrue(xml.contains("android:centerX=\"6\""), "centerX should be transformed:\n\(xml)") - XCTAssertTrue(xml.contains("android:centerY=\"6\""), "centerY should be transformed:\n\(xml)") - XCTAssertTrue(xml.contains("android:gradientRadius=\"10\""), "radius should be scaled:\n\(xml)") - } - - // MARK: - Integration - - func testEndToEndRadialGradientWithTransform() throws { - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - let xml = vectorGenerator.generate(from: parsed) - - // After transform: cx=0*10+12=12, cy=0*10+12=12, r=1*10=10 - XCTAssertTrue(xml.contains("android:centerX=\"12\""), "centerX should be 12:\n\(xml)") - XCTAssertTrue(xml.contains("android:centerY=\"12\""), "centerY should be 12:\n\(xml)") - XCTAssertTrue(xml.contains("android:gradientRadius=\"10\""), "radius should be 10:\n\(xml)") - } -} diff --git a/Tests/SVGKitTests/SVGGroupParsingTests.swift b/Tests/SVGKitTests/SVGGroupParsingTests.swift deleted file mode 100644 index bf8df18b..00000000 --- a/Tests/SVGKitTests/SVGGroupParsingTests.swift +++ /dev/null @@ -1,410 +0,0 @@ -// swiftlint:disable file_length type_body_length -import CustomDump -import Foundation -@testable import SVGKit -import XCTest - -final class SVGGroupParsingTests: XCTestCase { - var parser: SVGParser! - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Basic Group Parsing - - func testParseGroupElement() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].paths.count, 1) - } - - func testParseGroupWithTransform() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].transform?.translateX, 10) - XCTAssertEqual(parsed.groups?[0].transform?.translateY, 20) - } - - func testParseGroupWithScaleTransform() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?[0].transform?.scaleX, 2) - XCTAssertEqual(parsed.groups?[0].transform?.scaleY, 3) - } - - func testParseGroupWithRotateTransform() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?[0].transform?.rotation, 45) - XCTAssertEqual(parsed.groups?[0].transform?.pivotX, 12) - XCTAssertEqual(parsed.groups?[0].transform?.pivotY, 12) - } - - // MARK: - Clip Path Parsing - - func testParseGroupWithClipPath() throws { - let svg = """ - - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].clipPath, "M0,0 L24,0 L24,24 L0,24 Z") - } - - // MARK: - Mask Parsing (Figma uses mask instead of clip-path) - - func testParseGroupWithMask() throws { - // Figma exports flags with mask instead of clipPath - let svg = """ - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].clipPath, "M0,0 L24,0 L24,24 L0,24 Z") - } - - func testParseGroupWithMaskContainingRect() throws { - // Figma often uses rect inside mask for rounded corners - let svg = """ - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - // Rect should be converted to rounded rectangle path - let clipPath = parsed.groups?[0].clipPath - XCTAssertNotNil(clipPath) - XCTAssertTrue(clipPath?.contains("M2.0,4.25") == true, "Should start at x + rx") - XCTAssertTrue(clipPath?.hasPrefix("M") == true) - XCTAssertTrue(clipPath?.hasSuffix("Z") == true) - } - - func testParseGroupWithMaskInDefs() throws { - // Mask can also be inside defs - let svg = """ - - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].clipPath, "M2,2 L22,2 L22,22 L2,22 Z") - } - - func testClipPathTakesPrecedenceOverMask() throws { - // If both clip-path and mask are present, clip-path wins - let svg = """ - - - - - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - // clip-path takes precedence over mask - XCTAssertEqual(parsed.groups?[0].clipPath, "M0,0 L24,0 L24,24 L0,24 Z") - } - - func testParseFigmaFlagStyleSVG() throws { - // Real-world Figma flag SVG structure - let svg = """ - - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - // Should have groups (the masked group) - XCTAssertNotNil(parsed.groups) - XCTAssertGreaterThanOrEqual(parsed.groups?.count ?? 0, 1) - - // Find the masked group - let maskedGroup = parsed.groups?.first { $0.clipPath != nil } - XCTAssertNotNil(maskedGroup, "Should have a group with clip-path from mask") - - // Clip path should be a rounded rectangle - let clipPath = maskedGroup?.clipPath - XCTAssertNotNil(clipPath) - XCTAssertTrue(clipPath?.contains("a") == true, "Should contain arc commands for rounded corners") - } - - func testParseGroupWithMaskRectMissingXAttribute() throws { - // US flag style: mask rect has no explicit x attribute (defaults to 0 per SVG spec) - let svg = """ - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - // Should have a group with clip-path from mask - XCTAssertEqual(parsed.groups?.count, 1) - let clipPath = parsed.groups?[0].clipPath - XCTAssertNotNil(clipPath, "Should parse mask rect even without explicit x attribute") - XCTAssertTrue(clipPath?.hasPrefix("M2.0,4") == true, "Should start at x=0+rx, y=4") - XCTAssertTrue(clipPath?.contains("a") == true, "Should contain arc commands for rounded corners") - } - - func testRectFillPreservationInMaskedGroup() throws { - // Reproduces bug where inside masked group loses fill color - let svg = """ - - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].paths.count, 2) - - // First path (converted from rect) should have red fill - let redPath = parsed.groups?[0].paths[0] - XCTAssertNotNil(redPath?.fill, "Rect should preserve fill color") - XCTAssertEqual(redPath?.fill?.red, 255, "Red component should be 255") - XCTAssertEqual(redPath?.fill?.green, 0, "Green component should be 0") - XCTAssertEqual(redPath?.fill?.blue, 0, "Blue component should be 0") - - // Second path should have white fill - let whitePath = parsed.groups?[0].paths[1] - XCTAssertEqual(whitePath?.fill?.red, 255) - XCTAssertEqual(whitePath?.fill?.green, 255) - XCTAssertEqual(whitePath?.fill?.blue, 255) - - // Verify generated XML has correct colors - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - XCTAssertTrue(xml.contains("#FF0000"), "Output should contain #FF0000 (red)") - XCTAssertFalse(xml.contains("#FF000000"), "Output should NOT contain #FF000000 (black fallback)") - } - - // MARK: - Nested Groups - - func testParseNestedGroups() throws { - let svg = """ - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].transform?.translateX, 5) - XCTAssertEqual(parsed.groups?[0].children.count, 1) - XCTAssertEqual(parsed.groups?[0].children[0].transform?.scaleX, 2) - } - - func testParseMultipleGroupsAtSameLevel() throws { - let svg = """ - - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?.count, 2) - XCTAssertEqual(parsed.groups?[0].transform?.translateX, 0) - XCTAssertEqual(parsed.groups?[1].transform?.translateX, 10) - } - - // MARK: - Group Opacity - - func testParseGroupWithOpacity() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.groups?[0].opacity, 0.5) - } - - // MARK: - Combined Transforms - - func testParseGroupWithMultipleTransforms() throws { - let svg = """ - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - let transform = parsed.groups?[0].transform - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 10) - XCTAssertEqual(transform?.rotation, 45) - XCTAssertEqual(transform?.scaleX, 2) - } - - // MARK: - Backward Compatibility - - func testFlattenedPathsStillWork() throws { - // The paths property should still contain all paths flattened - let svg = """ - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - // Flattened paths should include all paths - XCTAssertEqual(parsed.paths.count, 2) - } - - // MARK: - Real-World Icon - - func testParseRealWorldIconWithGroups() throws { - // A Material Design-style icon with groups - let svg = """ - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 24) - XCTAssertEqual(parsed.height, 24) - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].children.count, 1) - } -} diff --git a/Tests/SVGKitTests/SVGMatrixTransformTests.swift b/Tests/SVGKitTests/SVGMatrixTransformTests.swift deleted file mode 100644 index ebe605fe..00000000 --- a/Tests/SVGKitTests/SVGMatrixTransformTests.swift +++ /dev/null @@ -1,260 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for `matrix()`, `skewX()`, and `skewY()` transform support -final class SVGMatrixTransformTests: XCTestCase { - // MARK: - matrix() Transform Tests - - func testMatrixTranslateOnly() { - // matrix(1,0,0,1,10,20) = translate(10,20) - let transform = SVGTransform.parse("matrix(1,0,0,1,10,20)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 20) - // Scale should be 1 (identity) - XCTAssertEqual(transform?.scaleX ?? 0, 1, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 1, accuracy: 0.001) - } - - func testMatrixScaleOnly() { - // matrix(2,0,0,3,0,0) = scale(2,3) - let transform = SVGTransform.parse("matrix(2,0,0,3,0,0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.scaleX ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 3, accuracy: 0.001) - XCTAssertEqual(transform?.translateX, 0) - XCTAssertEqual(transform?.translateY, 0) - } - - func testMatrixUniformScale() { - // matrix(2,0,0,2,0,0) = scale(2) - let transform = SVGTransform.parse("matrix(2,0,0,2,0,0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.scaleX ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 2, accuracy: 0.001) - } - - func testMatrixRotation90() { - // matrix(0,1,-1,0,0,0) = rotate(90) - // cos(90°) = 0, sin(90°) = 1 - let transform = SVGTransform.parse("matrix(0,1,-1,0,0,0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.rotation ?? 0, 90, accuracy: 0.1) - } - - func testMatrixRotation45() { - // matrix(0.707,0.707,-0.707,0.707,0,0) ≈ rotate(45) - let cos45 = cos(45 * Double.pi / 180) - let sin45 = sin(45 * Double.pi / 180) - let transform = SVGTransform.parse("matrix(\(cos45),\(sin45),\(-sin45),\(cos45),0,0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.rotation ?? 0, 45, accuracy: 0.1) - } - - func testMatrixCombinedScaleAndTranslate() { - // matrix(2,0,0,2,100,50) = scale(2) + translate(100,50) - let transform = SVGTransform.parse("matrix(2,0,0,2,100,50)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.scaleX ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.translateX, 100) - XCTAssertEqual(transform?.translateY, 50) - } - - func testMatrixIdentity() { - // matrix(1,0,0,1,0,0) = identity (no transform) - let transform = SVGTransform.parse("matrix(1,0,0,1,0,0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.scaleX ?? 0, 1, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 1, accuracy: 0.001) - XCTAssertEqual(transform?.translateX, 0) - XCTAssertEqual(transform?.translateY, 0) - } - - func testMatrixInvalidArgCount() { - // matrix with wrong number of arguments should be ignored - let transform = SVGTransform.parse("matrix(1,0,0,1,0)") - XCTAssertNil(transform) - - // Extra args are ignored, implementation parses first 6 values - _ = SVGTransform.parse("matrix(1,0,0,1,0,0,0)") - } - - // MARK: - skewX() Transform Tests - - func testSkewX30() { - let transform = SVGTransform.parse("skewX(30)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, 30) - XCTAssertNil(transform?.skewY) - } - - func testSkewX45() { - let transform = SVGTransform.parse("skewX(45)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, 45) - } - - func testSkewXNegative() { - let transform = SVGTransform.parse("skewX(-15)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, -15) - } - - func testSkewXZero() { - let transform = SVGTransform.parse("skewX(0)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, 0) - } - - // MARK: - skewY() Transform Tests - - func testSkewY45() { - let transform = SVGTransform.parse("skewY(45)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewY, 45) - XCTAssertNil(transform?.skewX) - } - - func testSkewY30() { - let transform = SVGTransform.parse("skewY(30)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewY, 30) - } - - func testSkewYNegative() { - let transform = SVGTransform.parse("skewY(-20)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewY, -20) - } - - // MARK: - Combined Transform Tests - - func testTranslateAndSkewX() { - let transform = SVGTransform.parse("translate(10,20) skewX(30)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 20) - XCTAssertEqual(transform?.skewX, 30) - } - - func testScaleAndSkewY() { - let transform = SVGTransform.parse("scale(2) skewY(15)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.scaleX, 2) - XCTAssertEqual(transform?.skewY, 15) - } - - func testSkewXAndSkewY() { - let transform = SVGTransform.parse("skewX(10) skewY(20)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, 10) - XCTAssertEqual(transform?.skewY, 20) - } - - func testRotateAndSkew() { - let transform = SVGTransform.parse("rotate(45) skewX(10)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.rotation, 45) - XCTAssertEqual(transform?.skewX, 10) - } - - // MARK: - SVG Integration Tests - - func testSkewXInSVGGroup() throws { - let parser = SVGParser() - let svg = """ - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertNotNil(parsed.groups) - XCTAssertEqual(parsed.groups?.count, 1) - XCTAssertEqual(parsed.groups?[0].transform?.skewX, 15) - } - - func testSkewYInSVGGroup() throws { - let parser = SVGParser() - let svg = """ - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertNotNil(parsed.groups) - XCTAssertEqual(parsed.groups?[0].transform?.skewY, 20) - } - - func testMatrixTransformInSVG() throws { - let parser = SVGParser() - let svg = """ - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - - XCTAssertNotNil(parsed.groups) - let transform = parsed.groups?[0].transform - XCTAssertEqual(transform?.scaleX ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.scaleY ?? 0, 2, accuracy: 0.001) - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 10) - } - - // MARK: - Edge Cases - - func testSkewWithDecimalValue() { - let transform = SVGTransform.parse("skewX(15.5)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.skewX, 15.5) - } - - func testMatrixWithSpaces() { - let transform = SVGTransform.parse("matrix(1, 0, 0, 1, 10, 20)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 20) - } - - func testMatrixWithNoSpaces() { - let transform = SVGTransform.parse("matrix(1,0,0,1,5,10)") - - XCTAssertNotNil(transform) - XCTAssertEqual(transform?.translateX, 5) - XCTAssertEqual(transform?.translateY, 10) - } -} diff --git a/Tests/SVGKitTests/SVGMinusIconTest.swift b/Tests/SVGKitTests/SVGMinusIconTest.swift deleted file mode 100644 index 936bbbc1..00000000 --- a/Tests/SVGKitTests/SVGMinusIconTest.swift +++ /dev/null @@ -1,57 +0,0 @@ -@testable import SVGKit -import Testing - -@Suite("Minus Icon Regression Test") -struct SVGMinusIconTest { - @Test("Convert minus.svg to VectorDrawable with correct group transform") - func convertMinusSvgToVectorDrawable() throws { - // This is the actual minus.svg from the iOS project - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - print("Generated VectorDrawable XML:") - print(xml) - - // Verify the group has the correct transform attributes - #expect(xml.contains("")) - } - - @Test("Convert closeOverlayColorL.svg with fillAlpha") - func convertCloseOverlayWithFillAlpha() throws { - // This is similar to closeOverlayColorL.svg with fill-opacity - let svg = """ - - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - print("Generated VectorDrawable XML:") - print(xml) - - // Verify fillAlpha is present - #expect(xml.contains("fillAlpha=\"0.5\"")) - } -} diff --git a/Tests/SVGKitTests/SVGNormalizationTests.swift b/Tests/SVGKitTests/SVGNormalizationTests.swift deleted file mode 100644 index ea8c5c90..00000000 --- a/Tests/SVGKitTests/SVGNormalizationTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for SVG normalization via usvg -final class SVGNormalizationTests: XCTestCase { - var parser: SVGParser! - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Default Fill - - func testNormalizationAppliesDefaultFill() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - XCTAssertEqual(parsed.paths.count, 1) - // usvg applies default black fill - XCTAssertNotNil(parsed.paths.first?.fill) - XCTAssertEqual(parsed.paths.first?.fill?.red, 0) - XCTAssertEqual(parsed.paths.first?.fill?.green, 0) - XCTAssertEqual(parsed.paths.first?.fill?.blue, 0) - } - - // MARK: - Use Element Resolution - - func testNormalizationResolvesUseElement() throws { - let svg = """ - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - // usvg resolves to actual path - XCTAssertGreaterThan(parsed.paths.count, 0) - } - - // MARK: - CSS Inlining - - func testNormalizationInlinesCSS() throws { - let svg = """ - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - XCTAssertEqual(parsed.paths.count, 1) - // CSS class should be resolved to inline fill - XCTAssertNotNil(parsed.paths.first?.fill) - XCTAssertEqual(parsed.paths.first?.fill?.red, 255) - XCTAssertEqual(parsed.paths.first?.fill?.green, 0) - XCTAssertEqual(parsed.paths.first?.fill?.blue, 0) - } - - // MARK: - Shape Conversion - - func testNormalizationConvertsCircleToPath() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - // usvg converts circle to path with arc commands - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(try XCTUnwrap(parsed.paths.first?.pathData.isEmpty)) - } - - func testNormalizationConvertsRectToPath() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNotNil(parsed.paths.first?.fill) - XCTAssertEqual(parsed.paths.first?.fill?.blue, 255) - } - - // MARK: - Transform Flattening - - func testNormalizationFlattensTransforms() throws { - let svg = """ - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - // usvg flattens transforms into path coordinates - XCTAssertGreaterThan(parsed.paths.count, 0) - } - - // MARK: - Gradient Preservation - - func testNormalizationPreservesLinearGradient() throws { - let svg = """ - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - XCTAssertEqual(parsed.paths.count, 1) - if case .linearGradient = parsed.paths.first?.fillType { - // Gradient preserved - } else { - // usvg may convert gradient to solid or preserve it - // This is acceptable behavior - } - } - - // MARK: - Normalize vs No Normalize Comparison - - func testNormalizeProducesDifferentOutput() throws { - let svg = """ - - - - """ - - let normalized = try parser.parse(Data(svg.utf8), normalize: true) - let raw = try parser.parse(Data(svg.utf8), normalize: false) - - // Normalized converts circle to path commands - // Raw parsing also converts but path data format may differ - XCTAssertEqual(normalized.paths.count, 1) - XCTAssertEqual(raw.paths.count, 1) - - // Both should have same fill color - XCTAssertEqual(normalized.paths.first?.fill?.red, raw.paths.first?.fill?.red) - } - - // MARK: - Edge Cases - - func testNormalizationHandlesEmptySVG() throws { - let svg = """ - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - XCTAssertTrue(parsed.paths.isEmpty) - } - - func testNormalizationHandlesComplexSVG() throws { - let svg = """ - - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: true) - - // Should handle clip-path and produce valid output - XCTAssertGreaterThan(parsed.paths.count, 0) - } -} diff --git a/Tests/SVGKitTests/SVGOpacityTests.swift b/Tests/SVGKitTests/SVGOpacityTests.swift deleted file mode 100644 index e91b7afc..00000000 --- a/Tests/SVGKitTests/SVGOpacityTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for SVG opacity/alpha parsing and generation. -final class SVGOpacityTests: XCTestCase { - var parser: SVGParser! - let vectorGenerator = VectorDrawableXMLGenerator() - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Parsing - - func testParsePathWithOpacity() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].opacity, 0.5) - } - - func testParsePathWithLowOpacity() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].opacity, 0.25) - } - - // MARK: - VectorDrawable Generation - - func testVectorDrawableFillAlpha() { - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: SVGColor(red: 255, green: 0, blue: 0), - fillType: .solid(SVGColor(red: 255, green: 0, blue: 0)), - stroke: nil, strokeWidth: nil, strokeLineCap: nil, strokeLineJoin: nil, - fillRule: nil, - opacity: 0.5 - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path] - ) - - let xml = vectorGenerator.generate(from: svg) - - XCTAssertTrue(xml.contains("android:fillAlpha=\"0.5\""), "Should have fillAlpha:\n\(xml)") - } - - // MARK: - ImageVector Generation - - func testImageVectorFillAlpha() { - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [.moveTo(x: 0, y: 0, relative: false), .closePath], - fill: SVGColor(red: 255, green: 0, blue: 0), - fillType: .solid(SVGColor(red: 255, green: 0, blue: 0)), - stroke: nil, strokeWidth: nil, strokeLineCap: nil, strokeLineJoin: nil, - fillRule: nil, - opacity: 0.3 - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path] - ) - - let config = ImageVectorGenerator.Config(packageName: "com.test") - let generator = ImageVectorGenerator(config: config) - let kotlin = generator.generate(name: "TestIcon", svg: svg) - - XCTAssertTrue(kotlin.contains("fillAlpha = 0.3f"), "Should have fillAlpha:\n\(kotlin)") - } - - func testImageVectorOmitsFillAlphaForFullOpacity() { - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [.moveTo(x: 0, y: 0, relative: false), .closePath], - fill: SVGColor(red: 255, green: 0, blue: 0), - fillType: .solid(SVGColor(red: 255, green: 0, blue: 0)), - stroke: nil, strokeWidth: nil, strokeLineCap: nil, strokeLineJoin: nil, - fillRule: nil, - opacity: 1.0 - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path] - ) - - let config = ImageVectorGenerator.Config(packageName: "com.test") - let generator = ImageVectorGenerator(config: config) - let kotlin = generator.generate(name: "TestIcon", svg: svg) - - XCTAssertFalse(kotlin.contains("fillAlpha"), "Should not have fillAlpha for 1.0:\n\(kotlin)") - } - - func testImageVectorOmitsFillAlphaForNilOpacity() { - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [.moveTo(x: 0, y: 0, relative: false), .closePath], - fill: SVGColor(red: 255, green: 0, blue: 0), - fillType: .solid(SVGColor(red: 255, green: 0, blue: 0)), - stroke: nil, strokeWidth: nil, strokeLineCap: nil, strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - let svg = ParsedSVG( - width: 24, height: 24, - viewportWidth: 24, viewportHeight: 24, - paths: [path] - ) - - let config = ImageVectorGenerator.Config(packageName: "com.test") - let generator = ImageVectorGenerator(config: config) - let kotlin = generator.generate(name: "TestIcon", svg: svg) - - XCTAssertFalse(kotlin.contains("fillAlpha"), "Should not have fillAlpha for nil:\n\(kotlin)") - } -} diff --git a/Tests/SVGKitTests/SVGParserTests.swift b/Tests/SVGKitTests/SVGParserTests.swift deleted file mode 100644 index aa669c76..00000000 --- a/Tests/SVGKitTests/SVGParserTests.swift +++ /dev/null @@ -1,427 +0,0 @@ -// swiftlint:disable file_length -import CustomDump -import Foundation -@testable import SVGKit -import XCTest - -final class SVGParserTests: XCTestCase { - var parser: SVGParser! - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Basic SVG Parsing - - func testParseSimpleSVG() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 24) - XCTAssertEqual(parsed.height, 24) - XCTAssertEqual(parsed.viewportWidth, 24) - XCTAssertEqual(parsed.viewportHeight, 24) - XCTAssertEqual(parsed.paths.count, 1) - } - - func testParseSVGWithViewBoxOnly() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 48) - XCTAssertEqual(parsed.height, 48) - XCTAssertEqual(parsed.viewportWidth, 48) - XCTAssertEqual(parsed.viewportHeight, 48) - } - - func testParseSVGWithDimensionsNoViewBox() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 32) - XCTAssertEqual(parsed.height, 32) - XCTAssertEqual(parsed.viewportWidth, 32) - XCTAssertEqual(parsed.viewportHeight, 32) - } - - func testParseSVGWithPixelUnits() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 24) - XCTAssertEqual(parsed.height, 24) - } - - // MARK: - Path Attributes - - func testParsePathWithFill() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNotNil(parsed.paths[0].fill) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].fill?.green, 0) - XCTAssertEqual(parsed.paths[0].fill?.blue, 0) - } - - func testParsePathWithStroke() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNil(parsed.paths[0].fill) - XCTAssertNotNil(parsed.paths[0].stroke) - XCTAssertEqual(parsed.paths[0].stroke?.red, 0) - XCTAssertEqual(parsed.paths[0].stroke?.green, 255) - XCTAssertEqual(parsed.paths[0].stroke?.blue, 0) - XCTAssertEqual(parsed.paths[0].strokeWidth, 2) - } - - func testParsePathWithStrokeLineCap() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths[0].strokeLineCap, .round) - } - - func testParsePathWithStrokeLineJoin() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths[0].strokeLineJoin, .bevel) - } - - func testParsePathWithFillRule() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths[0].fillRule, .evenOdd) - } - - func testParsePathWithOpacity() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths[0].opacity, 0.5) - } - - // MARK: - Style Attribute - - func testParsePathWithStyleAttribute() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].stroke?.green, 255) - XCTAssertEqual(parsed.paths[0].strokeWidth, 2) - } - - // MARK: - Shape Conversion - - func testParseRectElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(parsed.paths[0].pathData.isEmpty) - } - - func testParseCircleElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(parsed.paths[0].pathData.isEmpty) - } - - func testParseEllipseElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(parsed.paths[0].pathData.isEmpty) - } - - func testParseLineElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertTrue(parsed.paths[0].pathData.contains("M4.0,4.0")) - XCTAssertTrue(parsed.paths[0].pathData.contains("L20.0,20.0")) - } - - func testParsePolygonElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertTrue(parsed.paths[0].pathData.hasSuffix("Z")) - } - - func testParsePolylineElement() throws { - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(parsed.paths[0].pathData.hasSuffix("Z")) - } - - // MARK: - Nested Groups - - func testParseNestedGroups() throws { - let svg = """ - - - - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[0].stroke?.green, 255) - } - - func testParseMultiplePaths() throws { - let svg = """ - - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.paths.count, 2) - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - XCTAssertEqual(parsed.paths[1].fill?.green, 255) - } - - // MARK: - Error Cases - - func testParseInvalidSVGRoot() throws { - let svg = """ - Not an SVG - """ - let data = Data(svg.utf8) - - XCTAssertThrowsError(try parser.parse(data, normalize: false)) { error in - XCTAssertEqual(error as? SVGParserError, .invalidSVGRoot) - } - } - - // MARK: - Real-World Icon - - func testParseRealWorldIcon() throws { - // A Material Design-style checkmark icon - let svg = """ - - - - """ - let data = Data(svg.utf8) - let parsed = try parser.parse(data, normalize: false) - - XCTAssertEqual(parsed.width, 24) - XCTAssertEqual(parsed.height, 24) - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertFalse(parsed.paths[0].commands.isEmpty) - } -} - -// MARK: - SVGColor Tests - -final class SVGColorTests: XCTestCase { - // MARK: - Hex Color Parsing - - func testParseHex6() { - let color = SVGColor.parse("#FF0000") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 0) - XCTAssertEqual(color?.blue, 0) - XCTAssertEqual(color?.alpha, 1.0) - } - - func testParseHex3() { - let color = SVGColor.parse("#F00") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 0) - XCTAssertEqual(color?.blue, 0) - } - - func testParseHex8() throws { - let color = SVGColor.parse("#FF000080") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 0) - XCTAssertEqual(color?.blue, 0) - XCTAssertEqual(try XCTUnwrap(color?.alpha), 128.0 / 255.0, accuracy: 0.01) - } - - // MARK: - RGB Color Parsing - - func testParseRGB() { - let color = SVGColor.parse("rgb(255, 128, 0)") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 128) - XCTAssertEqual(color?.blue, 0) - XCTAssertEqual(color?.alpha, 1.0) - } - - func testParseRGBA() { - let color = SVGColor.parse("rgba(255, 128, 0, 0.5)") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 128) - XCTAssertEqual(color?.blue, 0) - XCTAssertEqual(color?.alpha, 0.5) - } - - // MARK: - Named Colors - - func testParseNamedColorBlack() { - let color = SVGColor.parse("black") - XCTAssertEqual(color?.red, 0) - XCTAssertEqual(color?.green, 0) - XCTAssertEqual(color?.blue, 0) - } - - func testParseNamedColorWhite() { - let color = SVGColor.parse("white") - XCTAssertEqual(color?.red, 255) - XCTAssertEqual(color?.green, 255) - XCTAssertEqual(color?.blue, 255) - } - - func testParseNamedColorTransparent() { - let color = SVGColor.parse("transparent") - XCTAssertEqual(color?.alpha, 0.0) - } - - // MARK: - Special Values - - func testParseNone() { - let color = SVGColor.parse("none") - XCTAssertNil(color) - } - - func testParseEmpty() { - let color = SVGColor.parse("") - XCTAssertNil(color) - } - - func testParseCurrentColor() { - let color = SVGColor.parse("currentColor") - XCTAssertEqual(color?.red, 0) - XCTAssertEqual(color?.green, 0) - XCTAssertEqual(color?.blue, 0) - } - - // MARK: - Compose Hex Output - - func testComposeHexOutput() { - let color = SVGColor(red: 255, green: 128, blue: 0, alpha: 1.0) - XCTAssertEqual(color.composeHex, "0xFFFF8000") - } - - func testComposeHexOutputWithAlpha() { - let color = SVGColor(red: 255, green: 0, blue: 0, alpha: 0.5) - // 0.5 * 255 = 127.5 → UInt8 truncates to 127 → 0x7F - XCTAssertEqual(color.composeHex, "0x7FFF0000") - } -} diff --git a/Tests/SVGKitTests/SVGPathFormatTests.swift b/Tests/SVGKitTests/SVGPathFormatTests.swift deleted file mode 100644 index b0af0874..00000000 --- a/Tests/SVGKitTests/SVGPathFormatTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -@testable import SVGKit -import Testing - -@Suite("SVG Path Format Preservation Tests") -struct SVGPathFormatTests { - @Test("Rect element converts to absolute path commands") - func rectConvertsToAbsoluteCommands() throws { - // This is how Figma exports the rounded rect border for flags - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - #expect(parsed.paths.count == 1) - let pathData = parsed.paths[0].pathData - - print("Rect converted to path: \(pathData)") - - // Should use absolute L and C commands, NOT relative h, v, a - #expect(pathData.contains("L")) // Absolute line - #expect(pathData.contains("C")) // Absolute cubic Bezier - #expect(!pathData.contains("h")) // NO relative horizontal - #expect(!pathData.contains("v")) // NO relative vertical - #expect(!pathData.contains("a")) // NO relative arc - } - - @Test("Simple rect converts to absolute L commands") - func simpleRectConvertsToAbsoluteL() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let pathData = parsed.paths[0].pathData - print("Simple rect path: \(pathData)") - - // Should be M2,4L22,4L22,20L2,20Z (absolute commands) - #expect(pathData.hasPrefix("M2,4")) - #expect(pathData.contains("L22,4")) - #expect(pathData.contains("L22,20")) - #expect(pathData.contains("L2,20")) - #expect(!pathData.contains("h")) // NO relative - #expect(!pathData.contains("v")) // NO relative - } - - @Test("Path data format is preserved without normalization") - func pathDataPreservedWithoutNormalization() throws { - // This is the exact path format from the dev branch Zambia flag - // swiftlint:disable:next line_length - let pathStr = "M2,4L22,4C23.105,4 24,4.895 24,6L24,18C24,19.105 23.105,20 22,20L2,20C0.895,20 0,19.105 0,18L0,6C0,4.895 0.895,4 2,4Z" - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // The path data should be exactly preserved - #expect(parsed.paths.count == 1) - let pathData = parsed.paths[0].pathData - - print("Original: \(pathStr)") - print("Parsed: \(pathData)") - - // Check that it still uses absolute commands (L, C) not relative (l, c, h, a) - #expect(pathData.contains("L22")) - #expect(pathData.contains("C23")) - #expect(!pathData.contains("h20")) // Should NOT have relative commands - #expect(!pathData.contains("a2")) // Should NOT have arc commands - } - - @Test("Path data format changes with normalization") - func pathDataChangesWithNormalization() throws { - // swiftlint:disable:next line_length - let pathStr = "M2,4L22,4C23.105,4 24,4.895 24,6L24,18C24,19.105 23.105,20 22,20L2,20C0.895,20 0,19.105 0,18L0,6C0,4.895 0.895,4 2,4Z" - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: true) - - print("Normalized path: \(parsed.paths.first?.pathData ?? "none")") - - // Just verify it parses - the format may be different after normalization - #expect(parsed.paths.count == 1) - } - - @Test("VectorDrawable output preserves path data format") - func vectorDrawablePreservesPathFormat() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - print("Generated XML:") - print(xml) - - // The output should contain the original path format - #expect(xml.contains("M2,4L22,4C23.105,4 24,4.895 24,6")) - } -} diff --git a/Tests/SVGKitTests/SVGPathParserTests.swift b/Tests/SVGKitTests/SVGPathParserTests.swift deleted file mode 100644 index d812cf55..00000000 --- a/Tests/SVGKitTests/SVGPathParserTests.swift +++ /dev/null @@ -1,238 +0,0 @@ -import CustomDump -import SVGKit -import XCTest - -final class SVGPathParserTests: XCTestCase { - private let parser = SVGPathParser() - - // MARK: - Basic Commands - - func testParseMoveToAbsolute() throws { - let commands = try parser.parse("M10,20") - expectNoDifference(commands, [.moveTo(x: 10, y: 20, relative: false)]) - } - - func testParseMoveToRelative() throws { - let commands = try parser.parse("m10,20") - expectNoDifference(commands, [.moveTo(x: 10, y: 20, relative: true)]) - } - - func testParseLineToAbsolute() throws { - let commands = try parser.parse("L30,40") - expectNoDifference(commands, [.lineTo(x: 30, y: 40, relative: false)]) - } - - func testParseLineToRelative() throws { - let commands = try parser.parse("l30,40") - expectNoDifference(commands, [.lineTo(x: 30, y: 40, relative: true)]) - } - - func testParseHorizontalLineToAbsolute() throws { - let commands = try parser.parse("H50") - expectNoDifference(commands, [.horizontalLineTo(x: 50, relative: false)]) - } - - func testParseHorizontalLineToRelative() throws { - let commands = try parser.parse("h50") - expectNoDifference(commands, [.horizontalLineTo(x: 50, relative: true)]) - } - - func testParseVerticalLineToAbsolute() throws { - let commands = try parser.parse("V60") - expectNoDifference(commands, [.verticalLineTo(y: 60, relative: false)]) - } - - func testParseVerticalLineToRelative() throws { - let commands = try parser.parse("v60") - expectNoDifference(commands, [.verticalLineTo(y: 60, relative: true)]) - } - - func testParseClosePath() throws { - let commands = try parser.parse("Z") - expectNoDifference(commands, [.closePath]) - } - - func testParseClosePathLowercase() throws { - let commands = try parser.parse("z") - expectNoDifference(commands, [.closePath]) - } - - // MARK: - Curve Commands - - func testParseCurveToAbsolute() throws { - let commands = try parser.parse("C10,20,30,40,50,60") - expectNoDifference(commands, [.curveTo(x1: 10, y1: 20, x2: 30, y2: 40, x: 50, y: 60, relative: false)]) - } - - func testParseCurveToRelative() throws { - let commands = try parser.parse("c10,20,30,40,50,60") - expectNoDifference(commands, [.curveTo(x1: 10, y1: 20, x2: 30, y2: 40, x: 50, y: 60, relative: true)]) - } - - func testParseSmoothCurveToAbsolute() throws { - let commands = try parser.parse("S30,40,50,60") - expectNoDifference(commands, [.smoothCurveTo(x2: 30, y2: 40, x: 50, y: 60, relative: false)]) - } - - func testParseSmoothCurveToRelative() throws { - let commands = try parser.parse("s30,40,50,60") - expectNoDifference(commands, [.smoothCurveTo(x2: 30, y2: 40, x: 50, y: 60, relative: true)]) - } - - func testParseQuadraticBezierToAbsolute() throws { - let commands = try parser.parse("Q10,20,30,40") - expectNoDifference(commands, [.quadraticBezierCurveTo(x1: 10, y1: 20, x: 30, y: 40, relative: false)]) - } - - func testParseQuadraticBezierToRelative() throws { - let commands = try parser.parse("q10,20,30,40") - expectNoDifference(commands, [.quadraticBezierCurveTo(x1: 10, y1: 20, x: 30, y: 40, relative: true)]) - } - - func testParseSmoothQuadraticBezierToAbsolute() throws { - let commands = try parser.parse("T30,40") - expectNoDifference(commands, [.smoothQuadraticBezierCurveTo(x: 30, y: 40, relative: false)]) - } - - func testParseSmoothQuadraticBezierToRelative() throws { - let commands = try parser.parse("t30,40") - expectNoDifference(commands, [.smoothQuadraticBezierCurveTo(x: 30, y: 40, relative: true)]) - } - - // MARK: - Arc Commands - - func testParseArcToAbsolute() throws { - let commands = try parser.parse("A10,20,30,1,0,50,60") - expectNoDifference(commands, [.arcTo( - rx: 10, ry: 20, - xAxisRotation: 30, - largeArcFlag: true, - sweepFlag: false, - x: 50, y: 60, - relative: false - )]) - } - - func testParseArcToRelative() throws { - let commands = try parser.parse("a10,20,30,0,1,50,60") - expectNoDifference(commands, [.arcTo( - rx: 10, ry: 20, - xAxisRotation: 30, - largeArcFlag: false, - sweepFlag: true, - x: 50, y: 60, - relative: true - )]) - } - - // MARK: - Complex Paths - - func testParseComplexPath() throws { - let commands = try parser.parse("M10,20 L30,40 H50 V60 Z") - expectNoDifference(commands, [ - .moveTo(x: 10, y: 20, relative: false), - .lineTo(x: 30, y: 40, relative: false), - .horizontalLineTo(x: 50, relative: false), - .verticalLineTo(y: 60, relative: false), - .closePath, - ]) - } - - func testParsePathWithoutSpaces() throws { - let commands = try parser.parse("M0,0L10,10L20,0Z") - expectNoDifference(commands, [ - .moveTo(x: 0, y: 0, relative: false), - .lineTo(x: 10, y: 10, relative: false), - .lineTo(x: 20, y: 0, relative: false), - .closePath, - ]) - } - - func testParsePathWithNegativeNumbers() throws { - let commands = try parser.parse("M-10,-20 L-30,40") - expectNoDifference(commands, [ - .moveTo(x: -10, y: -20, relative: false), - .lineTo(x: -30, y: 40, relative: false), - ]) - } - - func testParsePathWithDecimals() throws { - let commands = try parser.parse("M10.5,20.25 L30.75,40.125") - expectNoDifference(commands, [ - .moveTo(x: 10.5, y: 20.25, relative: false), - .lineTo(x: 30.75, y: 40.125, relative: false), - ]) - } - - func testParsePathWithScientificNotation() throws { - let commands = try parser.parse("M1e2,2e-1") - expectNoDifference(commands, [ - .moveTo(x: 100, y: 0.2, relative: false), - ]) - } - - func testParseMultipleMoveToBecomesLineTo() throws { - let commands = try parser.parse("M10,20 30,40 50,60") - expectNoDifference(commands, [ - .moveTo(x: 10, y: 20, relative: false), - .lineTo(x: 30, y: 40, relative: false), - .lineTo(x: 50, y: 60, relative: false), - ]) - } - - func testParseMultipleLineTo() throws { - let commands = try parser.parse("L10,20 30,40") - expectNoDifference(commands, [ - .lineTo(x: 10, y: 20, relative: false), - .lineTo(x: 30, y: 40, relative: false), - ]) - } - - func testParseMultipleHorizontalLineTo() throws { - let commands = try parser.parse("H10 20 30") - expectNoDifference(commands, [ - .horizontalLineTo(x: 10, relative: false), - .horizontalLineTo(x: 20, relative: false), - .horizontalLineTo(x: 30, relative: false), - ]) - } - - func testParseMultipleVerticalLineTo() throws { - let commands = try parser.parse("V10 20 30") - expectNoDifference(commands, [ - .verticalLineTo(y: 10, relative: false), - .verticalLineTo(y: 20, relative: false), - .verticalLineTo(y: 30, relative: false), - ]) - } - - // MARK: - Real-World Icon Path - - func testParseRealWorldIconPath() throws { - // A simplified sun icon path - let pathData = """ - M12,4 L12,2 M12,20 L12,22 M12,17 \ - C9.23858,17 7,14.7614 7,12 C7,9.23858 9.23858,7 12,7 \ - C14.7614,7 17,9.23858 17,12 C17,14.7614 14.7614,17 12,17 Z - """ - let commands = try parser.parse(pathData) - - // M12,4 L12,2 M12,20 L12,22 M12,17 C... C... C... C... Z = 10 commands - XCTAssertEqual(commands.count, 10) - expectNoDifference(commands[0], .moveTo(x: 12, y: 4, relative: false)) - expectNoDifference(commands[1], .lineTo(x: 12, y: 2, relative: false)) - expectNoDifference(commands[9], .closePath) - } - - // MARK: - Edge Cases - - func testParseEmptyString() throws { - let commands = try parser.parse("") - XCTAssertTrue(commands.isEmpty) - } - - func testParseWhitespaceOnly() throws { - let commands = try parser.parse(" \n\t ") - XCTAssertTrue(commands.isEmpty) - } -} diff --git a/Tests/SVGKitTests/SVGShapeTransformTests.swift b/Tests/SVGKitTests/SVGShapeTransformTests.swift deleted file mode 100644 index fa5862f6..00000000 --- a/Tests/SVGKitTests/SVGShapeTransformTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -@testable import SVGKit -import Testing - -@Suite("SVG Shape Transform Tests") -struct SVGShapeTransformTests { - @Test("Rect with transform produces group wrapper") - func rectWithTransformProducesGroup() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // Should have an element which is a group - #expect(!parsed.elements.isEmpty) - - if case let .group(group) = parsed.elements.first { - // Group should have transform - #expect(group.transform != nil) - #expect(group.transform?.translateX == 3) - #expect(group.transform?.translateY == 13) - #expect(group.transform?.scaleY == -1) - - // Group should contain the path - #expect(!group.paths.isEmpty) - } else { - Issue.record("Expected a group element but got \(String(describing: parsed.elements.first))") - } - } - - @Test("Circle with translate transform") - func circleWithTranslateTransform() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - #expect(!parsed.elements.isEmpty) - - if case let .group(group) = parsed.elements.first { - #expect(group.transform?.translateX == 10) - #expect(group.transform?.translateY == 10) - } else { - Issue.record("Expected a group element") - } - } - - @Test("Shape without transform remains as path") - func shapeWithoutTransformRemainsPath() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // Without transform, it should be a direct path - #expect(parsed.paths.count == 1) - #expect(parsed.elements.isEmpty || parsed.elements.allSatisfy { - if case .path = $0 { return true } - return false - }) - } - - @Test("Generate VectorDrawable with group from transformed rect") - func generateVectorDrawableWithGroupFromTransform() throws { - let svg = """ - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - let generator = VectorDrawableXMLGenerator() - let xml = generator.generate(from: parsed) - - // Should contain group with translate and scale - #expect(xml.contains(" - - - - - """ - - let parser = SVGParser() - let parsed = try parser.parse(#require(svg.data(using: .utf8)), normalize: false) - - // Should have nested group structure - #expect(parsed.groups?.count == 1) - - let outerGroup = parsed.groups?.first - #expect(outerGroup?.elements.count == 1) - - // The inner element should be a group (due to transform on rect) - if case let .group(innerGroup) = outerGroup?.elements.first { - #expect(innerGroup.transform?.scaleX == 2) - #expect(innerGroup.transform?.scaleY == 2) - } else { - Issue.record("Expected inner group from transformed rect") - } - } -} diff --git a/Tests/SVGKitTests/SVGStrokeDashTests.swift b/Tests/SVGKitTests/SVGStrokeDashTests.swift deleted file mode 100644 index bf89f199..00000000 --- a/Tests/SVGKitTests/SVGStrokeDashTests.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for `stroke-dasharray` and `stroke-dashoffset` support -final class SVGStrokeDashTests: XCTestCase { - var parser: SVGParser! - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Helper - - private func parse(_ svg: String) throws -> ParsedSVG { - try parser.parse(Data(svg.utf8), normalize: false) - } - - // MARK: - stroke-dasharray Parsing Tests - - func testStrokeDashArrayCommaSeparated() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [5.0, 3.0]) - } - - func testStrokeDashArraySpaceSeparated() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [5.0, 3.0, 2.0]) - } - - func testStrokeDashArrayMixedSeparators() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [10.0, 5.0, 2.0]) - } - - func testStrokeDashArraySingleValue() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [5.0]) - } - - func testStrokeDashArrayDecimalValues() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [5.5, 3.2, 1.8]) - } - - // MARK: - stroke-dashoffset Parsing Tests - - func testStrokeDashOffset() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, 2.0) - } - - func testStrokeDashOffsetDecimal() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, 1.5) - } - - func testStrokeDashOffsetNegative() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, -2.0) - } - - // MARK: - Edge Cases - - func testEmptyStrokeDashArray() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNil(parsed.paths[0].strokeDashArray) - } - - func testNoneStrokeDashArray() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNil(parsed.paths[0].strokeDashArray) - } - - func testNoStrokeDashArray() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertNil(parsed.paths[0].strokeDashArray) - XCTAssertNil(parsed.paths[0].strokeDashOffset) - } - - // MARK: - CSS Style Tests - - func testStrokeDashArrayFromCSS() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [4.0, 2.0]) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, 1.0) - } - - func testStrokeDashArrayFromInlineStyle() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [6.0, 3.0]) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, 2.0) - } - - // MARK: - Inheritance Tests - - func testStrokeDashArrayInheritedFromGroup() throws { - let svg = """ - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [3.0, 1.0]) - XCTAssertEqual(parsed.paths[0].strokeDashOffset, 0.5) - } - - // MARK: - Shape Element Tests - - func testStrokeDashArrayOnRect() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [5.0, 5.0]) - } - - func testStrokeDashArrayOnCircle() throws { - let svg = """ - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - XCTAssertEqual(parsed.paths[0].strokeDashArray, [3.0, 2.0]) - } -} diff --git a/Tests/SVGKitTests/SVGTypesTests.swift b/Tests/SVGKitTests/SVGTypesTests.swift deleted file mode 100644 index 863b6492..00000000 --- a/Tests/SVGKitTests/SVGTypesTests.swift +++ /dev/null @@ -1,297 +0,0 @@ -import CustomDump -@testable import SVGKit -import XCTest - -// MARK: - SVGTransform Tests - -final class SVGTransformTests: XCTestCase { - // MARK: - Initialization - - func testInitWithAllProperties() { - let transform = SVGTransform( - translateX: 10, - translateY: 20, - scaleX: 1.5, - scaleY: 2.0, - rotation: 45, - pivotX: 12, - pivotY: 12 - ) - - XCTAssertEqual(transform.translateX, 10) - XCTAssertEqual(transform.translateY, 20) - XCTAssertEqual(transform.scaleX, 1.5) - XCTAssertEqual(transform.scaleY, 2.0) - XCTAssertEqual(transform.rotation, 45) - XCTAssertEqual(transform.pivotX, 12) - XCTAssertEqual(transform.pivotY, 12) - } - - func testInitWithNilProperties() { - let transform = SVGTransform( - translateX: nil, - translateY: nil, - scaleX: nil, - scaleY: nil, - rotation: nil, - pivotX: nil, - pivotY: nil - ) - - XCTAssertNil(transform.translateX) - XCTAssertNil(transform.translateY) - XCTAssertNil(transform.scaleX) - XCTAssertNil(transform.scaleY) - XCTAssertNil(transform.rotation) - XCTAssertNil(transform.pivotX) - XCTAssertNil(transform.pivotY) - } - - func testTransformEquality() { - let transform1 = SVGTransform(translateX: 10, translateY: 20) - let transform2 = SVGTransform(translateX: 10, translateY: 20) - let transform3 = SVGTransform(translateX: 10, translateY: 30) - - XCTAssertEqual(transform1, transform2) - XCTAssertNotEqual(transform1, transform3) - } - - func testTransformIsSendable() { - let transform = SVGTransform(translateX: 10, translateY: 20) - - // Verify Sendable conformance by passing across task boundaries - let expectation = expectation(description: "Sendable") - Task { - _ = transform.translateX - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Parsing - - func testParseTranslate() { - let transform = SVGTransform.parse("translate(10, 20)") - - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 20) - } - - func testParseTranslateSingleValue() { - let transform = SVGTransform.parse("translate(10)") - - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 0) - } - - func testParseScale() { - let transform = SVGTransform.parse("scale(2, 3)") - - XCTAssertEqual(transform?.scaleX, 2) - XCTAssertEqual(transform?.scaleY, 3) - } - - func testParseScaleSingleValue() { - let transform = SVGTransform.parse("scale(2)") - - XCTAssertEqual(transform?.scaleX, 2) - XCTAssertEqual(transform?.scaleY, 2) - } - - func testParseRotate() { - let transform = SVGTransform.parse("rotate(45)") - - XCTAssertEqual(transform?.rotation, 45) - XCTAssertNil(transform?.pivotX) - XCTAssertNil(transform?.pivotY) - } - - func testParseRotateWithPivot() { - let transform = SVGTransform.parse("rotate(45, 12, 12)") - - XCTAssertEqual(transform?.rotation, 45) - XCTAssertEqual(transform?.pivotX, 12) - XCTAssertEqual(transform?.pivotY, 12) - } - - func testParseInvalidTransform() { - let transform = SVGTransform.parse("invalid()") - XCTAssertNil(transform) - } - - func testParseEmptyString() { - let transform = SVGTransform.parse("") - XCTAssertNil(transform) - } - - // MARK: - Combining Transforms - - func testParseMultipleTransforms() { - let transform = SVGTransform.parse("translate(10, 20) rotate(45)") - - // Combined transform should have both translate and rotate - XCTAssertEqual(transform?.translateX, 10) - XCTAssertEqual(transform?.translateY, 20) - XCTAssertEqual(transform?.rotation, 45) - } -} - -// MARK: - SVGGroup Tests - -final class SVGGroupTests: XCTestCase { - // MARK: - Initialization - - func testInitWithAllProperties() { - let path = SVGPath( - pathData: "M0,0 L10,10", - commands: [.moveTo(x: 0, y: 0, relative: false), .lineTo(x: 10, y: 10, relative: false)], - fill: SVGColor(red: 0, green: 0, blue: 0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - - let transform = SVGTransform(translateX: 10, translateY: 20) - - let group = SVGGroup( - transform: transform, - clipPath: "M0,0 L24,0 L24,24 L0,24 Z", - paths: [path], - children: [], - opacity: 0.5 - ) - - XCTAssertEqual(group.transform, transform) - XCTAssertEqual(group.clipPath, "M0,0 L24,0 L24,24 L0,24 Z") - XCTAssertEqual(group.paths.count, 1) - XCTAssertEqual(group.children.count, 0) - XCTAssertEqual(group.opacity, 0.5) - } - - func testGroupEquality() { - let group1 = SVGGroup( - transform: nil, - clipPath: nil, - paths: [], - children: [], - opacity: nil - ) - let group2 = SVGGroup( - transform: nil, - clipPath: nil, - paths: [], - children: [], - opacity: nil - ) - - XCTAssertEqual(group1, group2) - } - - func testGroupIsSendable() { - let group = SVGGroup( - transform: nil, - clipPath: nil, - paths: [], - children: [], - opacity: nil - ) - - let expectation = expectation(description: "Sendable") - Task { - _ = group.paths - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Nested Groups - - func testNestedGroups() { - let innerGroup = SVGGroup( - transform: SVGTransform(translateX: 5, translateY: 5), - clipPath: nil, - paths: [], - children: [], - opacity: nil - ) - - let outerGroup = SVGGroup( - transform: SVGTransform(translateX: 10, translateY: 10), - clipPath: nil, - paths: [], - children: [innerGroup], - opacity: nil - ) - - XCTAssertEqual(outerGroup.children.count, 1) - XCTAssertEqual(outerGroup.children[0].transform?.translateX, 5) - } -} - -// MARK: - Extended ParsedSVG Tests - -final class ParsedSVGExtendedTests: XCTestCase { - func testParsedSVGWithGroups() { - let group = SVGGroup( - transform: SVGTransform(translateX: 10, translateY: 10), - clipPath: nil, - paths: [], - children: [], - opacity: nil - ) - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [], - groups: [group] - ) - - XCTAssertEqual(svg.groups?.count, 1) - XCTAssertEqual(svg.groups?[0].transform?.translateX, 10) - } - - func testParsedSVGWithoutGroups() { - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [] - ) - - XCTAssertNil(svg.groups) - } - - func testParsedSVGBackwardCompatibility() { - // Existing code should work without groups - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [ - SVGPath( - pathData: "M0,0", - commands: [.moveTo(x: 0, y: 0, relative: false)], - fill: nil, - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ] - ) - - XCTAssertEqual(svg.width, 24) - XCTAssertEqual(svg.height, 24) - XCTAssertEqual(svg.paths.count, 1) - } -} diff --git a/Tests/SVGKitTests/SVGUseSymbolTests.swift b/Tests/SVGKitTests/SVGUseSymbolTests.swift deleted file mode 100644 index ba3bf1a5..00000000 --- a/Tests/SVGKitTests/SVGUseSymbolTests.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Tests for `` and `` SVG element support -final class SVGUseSymbolTests: XCTestCase { - var parser: SVGParser! - - override func setUp() { - super.setUp() - parser = SVGParser() - } - - override func tearDown() { - parser = nil - super.tearDown() - } - - // MARK: - Helper - - private func parse(_ svg: String) throws -> ParsedSVG { - try parser.parse(Data(svg.utf8), normalize: false) - } - - // MARK: - Basic Resolution - - func testBasicUseHrefResolvesToReferencedPath() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1, "Should resolve to the referenced path") - XCTAssertEqual(parsed.paths[0].pathData, "M0,0 L10,10") - XCTAssertEqual(parsed.paths[0].fill?.red, 255) - } - - func testSymbolInDefsIsResolved() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1, "Should resolve referencing ") - XCTAssertEqual(parsed.paths[0].pathData, "M5,5 L15,15") - XCTAssertEqual(parsed.paths[0].fill?.green, 255) - } - - // MARK: - with Position Offset - - func testUseWithXYAppliesTranslateOffset() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - // The with x/y should create a group with translate transform - XCTAssertNotNil(parsed.groups, "Should have groups when has x/y offset") - XCTAssertEqual(parsed.groups?.count, 1) - - let group = parsed.groups?[0] - XCTAssertEqual(group?.transform?.translateX, 10) - XCTAssertEqual(group?.transform?.translateY, 20) - } - - // MARK: - Nested References - - func testNestedUseReferencesWithDepthLimit() throws { - // Create SVG with nested references - let svg = """ - - - - - - - - - """ - - let parsed = try parse(svg) - - // Should resolve nested references up to depth limit - XCTAssertGreaterThanOrEqual(parsed.paths.count, 1, "Should resolve nested references") - } - - func testDeeplyNestedUseStopsAtDepthLimit() throws { - // Create SVG with very deep nesting that should be limited - // This tests that we don't infinite loop or crash - var defsContent = "" - for i in 1 ... 15 { - defsContent += "" - } - - let svg = """ - - \(defsContent) - - - """ - - // Should not crash and should return something (possibly empty due to depth limit) - let parsed = try parse(svg) - // Just verify it doesn't crash - the exact behavior depends on depth limit - XCTAssertNotNil(parsed) - } - - // MARK: - Missing Reference Handling - - func testMissingReferenceSkipsGracefully() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - // Should skip the missing reference but still parse other elements - XCTAssertEqual(parsed.paths.count, 1, "Should skip missing reference and parse other paths") - XCTAssertEqual(parsed.paths[0].pathData, "M0,0 L10,10") - } - - func testEmptyHrefSkipsGracefully() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1, "Should skip empty href and parse other paths") - } - - // MARK: - Legacy xlink:href Support - - func testXlinkHrefLegacySyntax() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1, "Should support xlink:href legacy syntax") - } - - // MARK: - with Transform - - func testUseWithTransformAttribute() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertNotNil(parsed.groups, "Should create group for with transform") - XCTAssertEqual(parsed.groups?[0].transform?.rotation, 90) - } - - // MARK: - Multiple of Same Element - - func testMultipleUsesOfSameElement() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parse(svg) - - // Each should create its own copy - XCTAssertEqual(parsed.groups?.count, 3, "Should create 3 instances from 3 elements") - } - - // MARK: - with viewBox - - func testSymbolWithViewBox() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parse(svg) - - // Should parse the symbol content - XCTAssertGreaterThanOrEqual(parsed.paths.count, 1, "Should parse content") - } - - // MARK: - Outside - - func testUseReferencingElementOutsideDefs() throws { - let svg = """ - - - - - """ - - let parsed = try parse(svg) - - // Should have original path + use copy - XCTAssertGreaterThanOrEqual(parsed.paths.count, 1, "Should find original path") - // The should also create something (either path or group) - } - - // MARK: - Style Inheritance - - func testUseInheritsStyleFromReferencedElement() throws { - let svg = """ - - - - - - - """ - - let parsed = try parse(svg) - - XCTAssertEqual(parsed.paths.count, 1) - let path = parsed.paths[0] - XCTAssertEqual(path.fill?.red, 0x12) - XCTAssertEqual(path.fill?.green, 0x34) - XCTAssertEqual(path.fill?.blue, 0x56) - XCTAssertEqual(path.stroke?.red, 0x65) - XCTAssertEqual(path.strokeWidth, 2) - } -} diff --git a/Tests/SVGKitTests/VectorDrawableGradientTests.swift b/Tests/SVGKitTests/VectorDrawableGradientTests.swift deleted file mode 100644 index fa919563..00000000 --- a/Tests/SVGKitTests/VectorDrawableGradientTests.swift +++ /dev/null @@ -1,294 +0,0 @@ -@testable import SVGKit -import XCTest - -final class VectorDrawableGradientTests: XCTestCase { - private let generator = VectorDrawableXMLGenerator() - - // MARK: - Helper Methods - - private func createSVGWithLinearGradient() -> ParsedSVG { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 0, blue: 0)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 255)), - ] - let gradient = SVGLinearGradient(id: "grad1", x1: 0, y1: 0, x2: 24, y2: 24, stops: stops) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .linearGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - return ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [path], - linearGradients: ["grad1": gradient] - ) - } - - private func createSVGWithRadialGradient() -> ParsedSVG { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 255, blue: 255)), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 0)), - ] - let gradient = SVGRadialGradient(id: "grad1", cx: 12, cy: 12, r: 12, stops: stops) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .radialGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - return ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [path], - radialGradients: ["grad1": gradient] - ) - } - - private func createSVGWithSolidFill() -> ParsedSVG { - let color = SVGColor(red: 255, green: 0, blue: 0) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: color, - fillType: .solid(color), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - return ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [path] - ) - } - - // MARK: - Namespace Tests - - func testVectorDrawableWithGradientHasAaptNamespace() { - let svg = createSVGWithLinearGradient() - let xml = generator.generate(from: svg) - - XCTAssertTrue( - xml.contains("xmlns:aapt=\"http://schemas.android.com/aapt\""), - "Expected aapt namespace in XML:\n\(xml)" - ) - } - - func testVectorDrawableWithRadialGradientHasAaptNamespace() { - let svg = createSVGWithRadialGradient() - let xml = generator.generate(from: svg) - - XCTAssertTrue( - xml.contains("xmlns:aapt=\"http://schemas.android.com/aapt\""), - "Expected aapt namespace in XML:\n\(xml)" - ) - } - - func testVectorDrawableWithoutGradientNoAaptNamespace() { - let svg = createSVGWithSolidFill() - let xml = generator.generate(from: svg) - - XCTAssertFalse( - xml.contains("xmlns:aapt"), - "Should not have aapt namespace for solid fill:\n\(xml)" - ) - } - - // MARK: - Linear Gradient XML Tests - - func testGenerateLinearGradientFill() { - let svg = createSVGWithLinearGradient() - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains(""), "Missing aapt:attr:\n\(xml)") - XCTAssertTrue(xml.contains("android:type=\"linear\""), "Missing type=linear:\n\(xml)") - XCTAssertTrue(xml.contains("android:startX=\"0\""), "Missing startX:\n\(xml)") - XCTAssertTrue(xml.contains("android:startY=\"0\""), "Missing startY:\n\(xml)") - XCTAssertTrue(xml.contains("android:endX=\"24\""), "Missing endX:\n\(xml)") - XCTAssertTrue(xml.contains("android:endY=\"24\""), "Missing endY:\n\(xml)") - XCTAssertTrue(xml.contains(""), "Missing gradient close tag:\n\(xml)") - XCTAssertTrue(xml.contains(""), "Missing aapt:attr close tag:\n\(xml)") - } - - // MARK: - Radial Gradient XML Tests - - func testGenerateRadialGradientFill() { - let svg = createSVGWithRadialGradient() - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains(""), "Missing aapt:attr:\n\(xml)") - XCTAssertTrue(xml.contains("android:type=\"radial\""), "Missing type=radial:\n\(xml)") - XCTAssertTrue(xml.contains("android:centerX=\"12\""), "Missing centerX:\n\(xml)") - XCTAssertTrue(xml.contains("android:centerY=\"12\""), "Missing centerY:\n\(xml)") - XCTAssertTrue(xml.contains("android:gradientRadius=\"12\""), "Missing gradientRadius:\n\(xml)") - } - - // MARK: - Gradient Stop Opacity Tests - - func testGradientStopWithOpacity() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 0, blue: 0), opacity: 0.5), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 255), opacity: 1.0), - ] - let gradient = SVGLinearGradient(id: "grad1", x1: 0, y1: 0, x2: 24, y2: 24, stops: stops) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .linearGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [path], - linearGradients: ["grad1": gradient] - ) - - let xml = generator.generate(from: svg) - - // 0.5 opacity = 0x80 (128) alpha - XCTAssertTrue(xml.contains("android:color=\"#80FF0000\""), "Expected 50% alpha red (#80FF0000):\n\(xml)") - XCTAssertTrue(xml.contains("android:color=\"#FF0000FF\""), "Expected full alpha blue (#FF0000FF):\n\(xml)") - } - - func testGradientStopWithZeroOpacity() { - let stops = [ - SVGGradientStop(offset: 0, color: SVGColor(red: 255, green: 0, blue: 0), opacity: 0.0), - SVGGradientStop(offset: 1, color: SVGColor(red: 0, green: 0, blue: 255), opacity: 1.0), - ] - let gradient = SVGLinearGradient(id: "grad1", x1: 0, y1: 0, x2: 24, y2: 24, stops: stops) - let path = SVGPath( - pathData: "M0 0h24v24H0z", - commands: [], - fill: nil, - fillType: .linearGradient(gradient), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ) - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [path], - linearGradients: ["grad1": gradient] - ) - - let xml = generator.generate(from: svg) - - // 0.0 opacity = 0x00 alpha - XCTAssertTrue(xml.contains("android:color=\"#00FF0000\""), "Expected 0% alpha red (#00FF0000):\n\(xml)") - } - - // MARK: - Backward Compatibility Tests - - func testSolidFillStillWorks() { - let svg = createSVGWithSolidFill() - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains("android:fillColor=\"#FF0000\""), "Expected solid fill color:\n\(xml)") - XCTAssertFalse(xml.contains(""), "Should close vector element") - #endif - } -} diff --git a/Tests/SVGKitTests/VectorDrawableIntegrationTests.swift b/Tests/SVGKitTests/VectorDrawableIntegrationTests.swift deleted file mode 100644 index 3ec525a8..00000000 --- a/Tests/SVGKitTests/VectorDrawableIntegrationTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Foundation -@testable import SVGKit -import XCTest - -/// Integration tests comparing native VectorDrawableXMLGenerator output with expected format -final class VectorDrawableIntegrationTests: XCTestCase { - var parser: SVGParser! - var generator: VectorDrawableXMLGenerator! - - override func setUp() { - super.setUp() - parser = SVGParser() - generator = VectorDrawableXMLGenerator() - } - - override func tearDown() { - parser = nil - generator = nil - super.tearDown() - } - - // MARK: - Real Icon Tests - - // swiftlint:disable:next function_body_length - func testGenerateDotsIcon() throws { - // swiftlint:disable:next line_length - let pathData = "M6 10C7.10457 10 8 10.8954 8 12C8 13.1046 7.10457 14 6 14C4.89543 14 4 13.1046 4 12C4 10.8954 4.89543 10 6 10ZM12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12C10 10.8954 10.8954 10 12 10ZM20 12C20 10.8954 19.1046 10 18 10C16.8954 10 16 10.8954 16 12C16 13.1046 16.8954 14 18 14C19.1046 14 20 13.1046 20 12Z" - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - // Verify structure - XCTAssertTrue(xml.contains("")) - XCTAssertTrue(xml.contains("")) - } - - func testGenerateArrowIcon() throws { - // swiftlint:disable:next line_length - let pathData = "M6.4142 13L12.9981 19.5839L11.5839 20.9981L3.2929 12.7071C2.9024 12.3166 2.9024 11.6834 3.2929 11.2929L11.5839 3.0019L12.9981 4.4161L6.4142 11H21V13H6.4142Z" - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - XCTAssertTrue(xml.contains("android:pathData=\"M6.4142 13L12.9981 19.5839")) - XCTAssertTrue(xml.contains("android:fillColor=\"#000000\"")) - XCTAssertTrue(xml.contains("android:fillType=\"evenOdd\"")) - } - - func testGenerateIconWithStroke() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - XCTAssertTrue(xml.contains("android:strokeColor=\"#FF0000\"")) - XCTAssertTrue(xml.contains("android:strokeWidth=\"2\"")) - XCTAssertTrue(xml.contains("android:strokeLineCap=\"round\"")) - } - - func testGenerateIconWithGroup() throws { - let svg = """ - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - XCTAssertTrue(xml.contains("")) - } - - func testGenerateIconWithAutoMirrored() throws { - let svg = """ - - - - """ - - let generatorWithMirror = VectorDrawableXMLGenerator(autoMirrored: true) - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generatorWithMirror.generate(from: parsed) - - XCTAssertTrue(xml.contains("android:autoMirrored=\"true\"")) - } - - func testGenerateIconWithClipPath() throws { - let svg = """ - - - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - XCTAssertTrue(xml.contains(" - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - // Alpha 0.5 = 127 (0x7F) - XCTAssertTrue(xml.contains("#7FFF0000") || xml.contains("#80FF0000")) - } - - // MARK: - XML Validity Tests - - func testGeneratedXMLIsWellFormed() throws { - let svg = """ - - - - - - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - // Verify XML is well-formed by parsing it - // XMLDocument is not available on Linux, so we do basic structure validation there - #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - XCTAssertNoThrow(try XMLDocument(xmlString: xml, options: [])) - #else - // On Linux, just verify basic structure - XCTAssertTrue(xml.hasPrefix("")) - #endif - } - - func testGeneratedXMLHasCorrectIndentation() throws { - let svg = """ - - - - """ - - let parsed = try parser.parse(Data(svg.utf8), normalize: false) - let xml = generator.generate(from: parsed) - - // Check indentation (4 spaces) - XCTAssertTrue(xml.contains(" xmlns:android=")) - XCTAssertTrue(xml.contains(" ")) - XCTAssertTrue(xml.contains("")) - } - - func testGenerateGroupWithScale() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [], - groups: [ - SVGGroup( - transform: SVGTransform(scaleX: 2, scaleY: 3), - clipPath: nil, - paths: [], - children: [], - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains("android:scaleX=\"2\"")) - XCTAssertTrue(xml.contains("android:scaleY=\"3\"")) - } - - func testGenerateGroupWithRotation() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [], - groups: [ - SVGGroup( - transform: SVGTransform(rotation: 45, pivotX: 12, pivotY: 12), - clipPath: nil, - paths: [], - children: [], - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains("android:rotation=\"45\"")) - XCTAssertTrue(xml.contains("android:pivotX=\"12\"")) - XCTAssertTrue(xml.contains("android:pivotY=\"12\"")) - } - - // MARK: - Clip Path Tests - - func testGenerateGroupWithClipPath() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [], - groups: [ - SVGGroup( - transform: nil, - clipPath: "M0,0 L24,0 L24,24 L0,24 Z", - paths: [ - SVGPath( - pathData: "M12,4 L12,20", - commands: [], - fill: SVGColor(red: 0, green: 0, blue: 0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ], - children: [], - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains("").count - 1 - - XCTAssertEqual(groupOpenCount, 2) - XCTAssertEqual(groupCloseCount, 2) - } - - // MARK: - Color Formatting Tests - - func testColorFormattingWithFullAlpha() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [ - SVGPath( - pathData: "M0,0", - commands: [], - fill: SVGColor(red: 255, green: 128, blue: 64, alpha: 1.0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.contains("android:fillColor=\"#FF8040\"")) - } - - func testColorFormattingWithAlpha() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [ - SVGPath( - pathData: "M0,0", - commands: [], - fill: SVGColor(red: 255, green: 0, blue: 0, alpha: 0.5), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - // 0.5 alpha = 0x7F, so #7FFF0000 - XCTAssertTrue(xml.contains("android:fillColor=\"#7FFF0000\"")) - } - - // MARK: - Real-World Icon Test - - func testGenerateRealWorldIcon() { - let generator = VectorDrawableXMLGenerator() - - // Material Design checkmark icon - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [ - SVGPath( - pathData: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z", - commands: [], - fill: SVGColor(red: 0, green: 0, blue: 0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - XCTAssertTrue(xml.hasPrefix("")) - XCTAssertTrue(xml.contains("")) - XCTAssertTrue(xml.contains("M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z")) - } - - // MARK: - Multiple Paths Tests - - func testGenerateMultiplePaths() { - let generator = VectorDrawableXMLGenerator() - - let svg = ParsedSVG( - width: 24, - height: 24, - viewportWidth: 24, - viewportHeight: 24, - paths: [ - SVGPath( - pathData: "M12,4 L12,20", - commands: [], - fill: SVGColor(red: 255, green: 0, blue: 0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - SVGPath( - pathData: "M4,12 L20,12", - commands: [], - fill: SVGColor(red: 0, green: 255, blue: 0), - stroke: nil, - strokeWidth: nil, - strokeLineCap: nil, - strokeLineJoin: nil, - fillRule: nil, - opacity: nil - ), - ] - ) - - let xml = generator.generate(from: svg) - - let pathCount = xml.components(separatedBy: "