diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index 8c28d00..7f3fb5a 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -70,12 +70,22 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand """ ) + let coloredResources = Key( + "--coloredResources", + description: """ + Optional path to folder to store generated ImageVector Kotlin files for + multi-color icons (components with "colored" in their Figma node name). + Requires --postProcessor to be set — the downloaded SVG is saved here and + converted to a .kt file by --postProcessor (invoked with --outputFormat kt). + """ + ) + let postProcessor = Key( "--postProcessor", "-p", description: """ The path to the bash script to make operations with generated images. - Only executes for generated images from --resources folder. + Executes for generated images from --resources and --coloredResources folders. """ ) @@ -183,6 +193,30 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand """ ) + let sfSymbolKey = Key( + "--sfSymbolKey", + description: """ + Colored icons flag name from Figma. + By default, assets will be generated without processing colored info. + """ + ) + + let symbolRenderAs = Key( + "--symbolRenderAs", + description: """ + Set rendering mode in Xcode assets for SF Symbols, can be 'template`, `multicolor` or `hierarchical'. + By default, Xcode assets will be generated with automatic rendering mode. + """ + ) + + let sfSymbolTemplate = Key( + "--sfSymbolTemplate", + description: """ + Path to the SF Symbol template file. + If no template is passed SF Symbols won't be created. + """ + ) + // MARK: - Initializers init(generator: ImagesGenerator) { @@ -225,7 +259,21 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand case let rawRenderingMode?: guard let mode = ImageRenderingMode(rawValue: rawRenderingMode) else { - fail(message: "Failed to generated images: Invalid rendering mode (\(rawRenderingMode)") + fail(message: "Failed to generate images: Invalid rendering mode (\(rawRenderingMode))") + } + + return mode + } + } + + private func resolveSymbolRenderAs() -> SymbolRenderingMode? { + switch symbolRenderAs.value { + case nil: + return nil + + case let rawSymbolRenderingMode?: + guard let mode = SymbolRenderingMode(rawValue: rawSymbolRenderingMode) else { + fail(message: "Failed to generate symbols: Invalid rendering mode (\(rawSymbolRenderingMode)") } return mode @@ -251,6 +299,7 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand generatation: generationConfiguration, assets: assets.value, resources: resources.value, + coloredResources: coloredResources.value, postProcessor: postProcessor.value, format: resolveImageFormat(), scales: resolveImageScales(), @@ -260,7 +309,10 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand renderAs: resolveRenderingMode(), groupByFrame: groupByFrame.value, groupByComponentSet: groupByComponentSet.value, - namingStyle: resolveNamingStyle() + namingStyle: resolveNamingStyle(), + sfSymbolKey: sfSymbolKey.value, + symbolRenderAs: resolveSymbolRenderAs(), + sfSymbolTemplate: sfSymbolTemplate.value ) } diff --git a/Sources/FigmaGen/Dependencies.swift b/Sources/FigmaGen/Dependencies.swift index 40971f1..b2bca7b 100644 --- a/Sources/FigmaGen/Dependencies.swift +++ b/Sources/FigmaGen/Dependencies.swift @@ -6,6 +6,11 @@ enum Dependencies { // MARK: - Type Properties static let dataProvider: DataProvider = DefaultDataProvider() + static let svgParser: SVGParser = DefaultSVGParser() + static let sfSymbolProvider: SFSymbolProvider = DefaultSFSymbolProvider( + svgParser: svgParser, + templateRenderer: templateRenderer + ) static let gitHubHTTPService: GitHubHTTPService = HTTPService() static let gitHubAPIProvider: RemoteRepoProvider = GitHubAPIProvider(httpService: gitHubHTTPService) @@ -37,7 +42,8 @@ enum Dependencies { static let imageAssetsProvider: ImageAssetsProvider = DefaultImageAssetsProvider( assetsProvider: assetsProvider, - dataProvider: dataProvider + dataProvider: dataProvider, + sfSymbolProvider: sfSymbolProvider ) static let imageResourcesProvider: ImageResourcesProvider = DefaultImageResourcesProvider( diff --git a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift index 7a6673d..b46dc91 100644 --- a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift +++ b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift @@ -73,6 +73,7 @@ extension ImagesConfiguration { scales: scales, assets: assets, resources: resources, + coloredResources: coloredResources, postProcessor: postProcessor, onlyExportables: onlyExportables, useAbsoluteBounds: useAbsoluteBounds, @@ -80,7 +81,10 @@ extension ImagesConfiguration { renderAs: renderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, - namingStyle: namingStyle + namingStyle: namingStyle, + sfSymbolKey: sfSymbolKey, + symbolRenderAs: symbolRenderAs, + sfSymbolTemplate: sfSymbolTemplate ) } } diff --git a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift index 10b76b7..5aba3fc 100644 --- a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift +++ b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift @@ -7,6 +7,7 @@ struct ImagesConfiguration: Decodable { private enum CodingKeys: String, CodingKey { case assets case resources + case coloredResources case postProcessor case format case scales @@ -17,6 +18,9 @@ struct ImagesConfiguration: Decodable { case groupByFrame case groupByComponentSet case namingStyle + case sfSymbolKey + case symbolRenderAs + case sfSymbolTemplate } // MARK: - Instance Properties @@ -24,6 +28,7 @@ struct ImagesConfiguration: Decodable { let generatation: GenerationConfiguration let assets: String? let resources: String? + let coloredResources: String? let postProcessor: String? let format: ImageFormat let scales: [ImageScale] @@ -34,6 +39,9 @@ struct ImagesConfiguration: Decodable { let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle + let sfSymbolKey: String? + let symbolRenderAs: SymbolRenderingMode? + let sfSymbolTemplate: String? // MARK: - Initializers @@ -41,6 +49,7 @@ struct ImagesConfiguration: Decodable { generatation: GenerationConfiguration, assets: String?, resources: String?, + coloredResources: String?, postProcessor: String?, format: ImageFormat, scales: [ImageScale], @@ -50,11 +59,15 @@ struct ImagesConfiguration: Decodable { renderAs: ImageRenderingMode?, groupByFrame: Bool, groupByComponentSet: Bool, - namingStyle: ImageNamingStyle + namingStyle: ImageNamingStyle, + sfSymbolKey: String?, + symbolRenderAs: SymbolRenderingMode?, + sfSymbolTemplate: String? ) { self.generatation = generatation self.assets = assets self.resources = resources + self.coloredResources = coloredResources self.postProcessor = postProcessor self.format = format self.scales = scales @@ -65,6 +78,9 @@ struct ImagesConfiguration: Decodable { self.groupByFrame = groupByFrame self.groupByComponentSet = groupByComponentSet self.namingStyle = namingStyle + self.sfSymbolKey = sfSymbolKey + self.symbolRenderAs = symbolRenderAs + self.sfSymbolTemplate = sfSymbolTemplate } init(from decoder: Decoder) throws { @@ -72,6 +88,7 @@ struct ImagesConfiguration: Decodable { assets = try container.decodeIfPresent(forKey: .assets) resources = try container.decodeIfPresent(forKey: .resources) + coloredResources = try container.decodeIfPresent(forKey: .coloredResources) postProcessor = try container.decodeIfPresent(forKey: .postProcessor) format = try container.decodeIfPresent(forKey: .format) ?? .pdf @@ -83,6 +100,9 @@ struct ImagesConfiguration: Decodable { groupByFrame = try container.decodeIfPresent(forKey: .groupByFrame) ?? false groupByComponentSet = try container.decodeIfPresent(forKey: .groupByComponentSet) ?? false namingStyle = try container.decodeIfPresent(forKey: .namingStyle) ?? .camelCase + sfSymbolKey = try container.decodeIfPresent(forKey: .sfSymbolKey) + symbolRenderAs = try container.decodeIfPresent(forKey: .symbolRenderAs) + sfSymbolTemplate = try container.decodeIfPresent(forKey: .sfSymbolTemplate) generatation = try GenerationConfiguration(from: decoder) } @@ -94,6 +114,7 @@ struct ImagesConfiguration: Decodable { generatation: generatation.resolve(base: base), assets: assets, resources: resources, + coloredResources: coloredResources, postProcessor: postProcessor, format: format, scales: scales, @@ -103,7 +124,10 @@ struct ImagesConfiguration: Decodable { renderAs: renderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, - namingStyle: namingStyle + namingStyle: namingStyle, + sfSymbolKey: sfSymbolKey, + symbolRenderAs: symbolRenderAs, + sfSymbolTemplate: sfSymbolTemplate ) } } diff --git a/Sources/FigmaGen/Models/Images/ImageAsset.swift b/Sources/FigmaGen/Models/Images/ImageAsset.swift index 99ad451..c959623 100644 --- a/Sources/FigmaGen/Models/Images/ImageAsset.swift +++ b/Sources/FigmaGen/Models/Images/ImageAsset.swift @@ -8,4 +8,6 @@ struct ImageAsset: Encodable, Hashable { let filePaths: [ImageScale: String] let preserveVectorData: Bool let renderAs: ImageRenderingMode? + let isSymbol: Bool + let symbolRenderAs: SymbolRenderingMode? } diff --git a/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift b/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift new file mode 100644 index 0000000..85d02ea --- /dev/null +++ b/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift @@ -0,0 +1,8 @@ +import Foundation + +enum SymbolRenderingMode: String, Codable { + + case template + case multicolor + case hierarchical +} diff --git a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift index e46fd58..6fcc5b8 100644 --- a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift +++ b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift @@ -8,6 +8,7 @@ struct ImagesParameters { let scales: [ImageScale] let assets: String? let resources: String? + let coloredResources: String? let postProcessor: String? let onlyExportables: Bool let useAbsoluteBounds: Bool @@ -16,4 +17,7 @@ struct ImagesParameters { let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle + let sfSymbolKey: String? + let symbolRenderAs: SymbolRenderingMode? + let sfSymbolTemplate: String? } diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift new file mode 100644 index 0000000..37ba008 --- /dev/null +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SFSymbolLayer { + + let index: Int + let role: SFSymbolRole + let paths: [SFSymbolPathData] +} diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift new file mode 100644 index 0000000..7afd3bb --- /dev/null +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SFSymbolPathData { + + // Данные пути, уже переведённые в координатное пространство SF Symbols. + let data: String + let fillRule: String? +} diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolRole.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolRole.swift new file mode 100644 index 0000000..cfb950c --- /dev/null +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolRole.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Роль пути в палитре слоёв. Определяется по id пути, а если его нет - по заливке: +/// чёрная заливка из Figma даёт primary, любая другая - secondary. +enum SFSymbolRole: String { + + case primary + case secondary + case tertiary +} diff --git a/Sources/FigmaGen/Models/SVG/SVGCanvas.swift b/Sources/FigmaGen/Models/SVG/SVGCanvas.swift new file mode 100644 index 0000000..386d039 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGCanvas.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Размер корневого элемента ``, который Figma выгружает равным боксу компонента. +/// Геометрия SF Symbols строится именно от этого бокса, а не от границ видимых путей, +/// чтобы сохранить заложенные в Figma отступы. +struct SVGCanvas: Equatable { + + let width: Double + let height: Double +} diff --git a/Sources/FigmaGen/Models/SVG/SVGColor.swift b/Sources/FigmaGen/Models/SVG/SVGColor.swift new file mode 100644 index 0000000..409b5f9 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGColor.swift @@ -0,0 +1,7 @@ +import Foundation + +enum SVGColor: Equatable { + + case black + case other(String) +} diff --git a/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift b/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift new file mode 100644 index 0000000..dd479e1 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SVGGroupContext { + + let id: String? + let fill: SVGColor? + let transform: String? +} diff --git a/Sources/FigmaGen/Models/SVG/SVGImageToken.swift b/Sources/FigmaGen/Models/SVG/SVGImageToken.swift new file mode 100644 index 0000000..55f329a --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGImageToken.swift @@ -0,0 +1,11 @@ +import Foundation + +// Всё, что нужно SVG-шаблону, чтобы разложить рисунок из Figma в координатах SF Symbols. +struct SVGImageToken { + + let name: String + let opticalSize: Int + let designWidth: Double + let designHeight: Double + let layers: [SFSymbolLayer] +} diff --git a/Sources/FigmaGen/Models/SVG/SVGNumber.swift b/Sources/FigmaGen/Models/SVG/SVGNumber.swift new file mode 100644 index 0000000..98835e7 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGNumber.swift @@ -0,0 +1,22 @@ +import Foundation + +enum SVGNumber { + + /// Форматирует число для атрибутов SVG: до шести знаков после запятой, без хвостовых нулей + /// и без отрицательного нуля. + static func string(from value: Double) -> String { + var text = String(format: "%.6f", value) + + if text.contains(".") { + while text.hasSuffix("0") { + text.removeLast() + } + + if text.hasSuffix(".") { + text.removeLast() + } + } + + return text == "-0" ? "0" : text + } +} diff --git a/Sources/FigmaGen/Models/SVG/SVGPath.swift b/Sources/FigmaGen/Models/SVG/SVGPath.swift new file mode 100644 index 0000000..9c20604 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGPath.swift @@ -0,0 +1,44 @@ +import Foundation + +struct SVGPath { + + let attributes: [String: String] + let groupIDs: [String] + let inheritedTransforms: [String] + let fill: SVGColor? + + var id: String? { + attributes["id"] + } + + var wholeID: String { + (groupIDs + [attributes["id"]].compactMap { $0 }) + .joined(separator: ";") + } + + var data: String? { + attributes["d"] + } + + var fillRule: String? { + attributes["fill-rule"] + } + + var allTransforms: [String] { + inheritedTransforms + [attributes["transform"]].compactMap { $0 } + } + + // MARK: - Initializers + + init( + attributes: [String: String], + groupIDs: [String], + inheritedTransforms: [String], + fill: SVGColor? + ) { + self.attributes = attributes + self.groupIDs = groupIDs + self.inheritedTransforms = inheritedTransforms + self.fill = fill + } +} diff --git a/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift b/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift new file mode 100644 index 0000000..5763598 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift @@ -0,0 +1,14 @@ +import Foundation + +struct SVGPathsResult { + + let id: String + let canvas: SVGCanvas? + let allPaths: [SVGPath] + + init(id: String, canvas: SVGCanvas?, allPaths: [SVGPath]) { + self.id = id + self.canvas = canvas + self.allPaths = allPaths + } +} diff --git a/Sources/FigmaGen/Models/SVG/SVGTransform.swift b/Sources/FigmaGen/Models/SVG/SVGTransform.swift new file mode 100644 index 0000000..67546e4 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGTransform.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Аффинное преобразование SVG в том же виде, в каком его записывает атрибут `transform="matrix(a b c d e f)"`: +/// +/// | a c e | +/// | b d f | +/// | 0 0 1 | +/// +/// Точка `(x, y)` переходит в `(a * x + c * y + e, b * x + d * y + f)`. +struct SVGTransform: Equatable { + + // MARK: - Type Properties + + static let identity = Self(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0) + + // MARK: - Type Methods + + static func translation(x: Double, y: Double) -> Self { + Self(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: x, f: y) + } + + static func scale(x: Double, y: Double) -> Self { + Self(a: x, b: 0.0, c: 0.0, d: y, e: 0.0, f: 0.0) + } + + static func rotation(degrees: Double) -> Self { + let radians = degrees * .pi / 180.0 + + return Self(a: cos(radians), b: sin(radians), c: -sin(radians), d: cos(radians), e: 0.0, f: 0.0) + } + + static func skewX(degrees: Double) -> Self { + Self(a: 1.0, b: 0.0, c: tan(degrees * .pi / 180.0), d: 1.0, e: 0.0, f: 0.0) + } + + static func skewY(degrees: Double) -> Self { + Self(a: 1.0, b: tan(degrees * .pi / 180.0), c: 0.0, d: 1.0, e: 0.0, f: 0.0) + } + + // MARK: - Instance Properties + + // Однобуквенные имена взяты из самой спецификации SVG: `matrix(a b c d e f)`. + // swiftlint:disable identifier_name + let a: Double + let b: Double + let c: Double + let d: Double + let e: Double + let f: Double + // swiftlint:enable identifier_name + + /// Определитель линейной части. Отрицательный означает, что преобразование зеркалит плоскость, + /// то есть меняет направление обхода контура на противоположное. + var determinant: Double { + a * d - b * c + } + + /// Длины образов базисных векторов. У подобия (равномерный масштаб с поворотом + /// и, возможно, отражением) они равны между собой. + var linearScaleX: Double { + (a * a + b * b).squareRoot() + } + + var linearScaleY: Double { + (c * c + d * d).squareRoot() + } + + /// `true`, если преобразование переводит любую окружность в окружность, а не в эллипс: + /// образы базисных векторов равны по длине и остаются перпендикулярными. + /// Только для таких преобразований дуги пересчитываются в замкнутой форме, см. `SVGPathTransformer`. + var isSimilarity: Bool { + let scaleX = linearScaleX + let scaleY = linearScaleY + let tolerance = 1e-9 * Swift.max(1.0, scaleX, scaleY) + + return abs(scaleX - scaleY) <= tolerance && abs(a * c + b * d) <= tolerance * Swift.max(1.0, scaleX) + } + + /// Угол поворота линейной части в радианах. Осмыслен только для подобия. + var rotation: Double { + atan2(b, a) + } + + // MARK: - Instance Methods + + /// Матричное произведение `self * other`. В SVG вложенные преобразования применяются снаружи внутрь, + /// поэтому список `transform="A B"` и цепочка групп собираются именно в таком порядке. + func concatenating(_ other: Self) -> Self { + Self( + a: a * other.a + c * other.b, + b: b * other.a + d * other.b, + c: a * other.c + c * other.d, + d: b * other.c + d * other.d, + e: a * other.e + c * other.f + e, + f: b * other.e + d * other.f + f + ) + } + + /// Образ точки. Для векторов (относительных смещений) переносить `e` и `f` нельзя. + func apply(x: Double, y: Double) -> (x: Double, y: Double) { + (x: a * x + c * y + e, y: b * x + d * y + f) + } +} diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index 93e914e..4a3d2b4 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -9,12 +9,18 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes let assetsProvider: AssetsProvider let dataProvider: DataProvider + let sfSymbolProvider: SFSymbolProvider // MARK: - Initializers - init(assetsProvider: AssetsProvider, dataProvider: DataProvider) { + init( + assetsProvider: AssetsProvider, + dataProvider: DataProvider, + sfSymbolProvider: SFSymbolProvider + ) { self.assetsProvider = assetsProvider self.dataProvider = dataProvider + self.sfSymbolProvider = sfSymbolProvider } // MARK: - Instance Methods @@ -22,10 +28,19 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes private func resolveName( for node: ImageRenderedNode, setNode: ImageComponentSetRenderedNode, - namingStyle: ImageNamingStyle + namingStyle: ImageNamingStyle, + sfSymbolKey: String? ) -> String { - let name = setNode.type == .component ? node.base.name : "\(setNode.name) \(node.base.name)" - + var name = setNode.type == .component ? node.base.name : "\(setNode.name) \(node.base.name)" + + if + let sfSymbolKey, + !sfSymbolKey.isEmpty, + name.lowercased().contains(sfSymbolKey.lowercased()) { + name = name + .replacingOccurrences(of: "\(sfSymbolKey)=false", with: "") + .replacingOccurrences(of: "\(sfSymbolKey)=true", with: "\(sfSymbolKey)") + } switch namingStyle { case .camelCase: return name.camelized @@ -41,7 +56,12 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes parameters: ImagesParameters, folderPath: Path ) -> ImageAsset { - let name = resolveName(for: node, setNode: setNode, namingStyle: parameters.namingStyle) + let name = resolveName( + for: node, + setNode: setNode, + namingStyle: parameters.namingStyle, + sfSymbolKey: parameters.sfSymbolKey + ) let folderPath = resolveFolderPath( groupByFrame: parameters.groupByFrame, @@ -50,10 +70,23 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes folderPath: folderPath ) + let isSymbol = parameters.sfSymbolKey != nil + && name.lowercased().contains(parameters.sfSymbolKey?.lowercased() ?? "") + + let assetSetExtension = isSymbol + ? AssetSymbolSet.pathExtension + : AssetImageSet.pathExtension + + let assetExtension = isSymbol + ? ImageFormat.svg.fileExtension + : parameters.format.fileExtension + + let symbolRenderingMode = isSymbol ? parameters.symbolRenderAs : nil + let filePaths = node.urls.keys.reduce(into: [:]) { result, scale in - result[scale] = folderPath - .appending(fileName: name, extension: AssetImageSet.pathExtension) - .appending(fileName: name.appending(scale.fileNameSuffix), extension: parameters.format.fileExtension) + result[isSymbol ? .none : scale] = folderPath + .appending(fileName: name, extension: assetSetExtension) + .appending(fileName: name.appending(scale.fileNameSuffix), extension: assetExtension) .string } @@ -61,7 +94,9 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes name: name, filePaths: filePaths, preserveVectorData: parameters.preserveVectorData, - renderAs: parameters.renderAs + renderAs: parameters.renderAs, + isSymbol: isSymbol, + symbolRenderAs: symbolRenderingMode ) } @@ -103,12 +138,38 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes return AssetImageSet(contents: contents) } + private func makeAssetSymbolSet(for asset: ImageAsset) -> AssetSymbolSet { + let assetImages = asset.filePaths.map { scale, filePath in + AssetImage(fileName: Path(filePath).lastComponent, scale: scale.assetImageScale) + } + let contents = AssetSymbolSetContents( + info: .defaultFigmaGen, + properties: AssetSymbolProperties(from: asset), + symbols: assetImages + ) + return AssetSymbolSet(contents: contents) + } + private func makeAssetImageSets(for assets: [ImageRenderedNode: ImageAsset]) -> [String: AssetImageSet] { assets.values.reduce(into: [:]) { result, asset in + guard !asset.isSymbol else { + return + } + result[asset.name] = makeAssetImageSet(for: asset) } } + private func makeAssetSymbolSets(for assets: [ImageRenderedNode: ImageAsset]) -> [String: AssetSymbolSet] { + assets.values.reduce(into: [:]) { result, asset in + guard asset.isSymbol else { + return + } + + result[asset.name] = makeAssetSymbolSet(for: asset) + } + } + private func saveImageFiles(node: ImageRenderedNode, asset: ImageAsset) -> Promise { let promises = node.urls.compactMap { scale, url in asset.filePaths[scale].map { self.dataProvider.saveData(from: url, to: $0) } @@ -117,6 +178,24 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes return when(fulfilled: promises) } + private func saveSymbolFiles( + node: ImageRenderedNode, + asset: ImageAsset, + parameters: ImagesParameters + ) -> Promise { + let promises = node.urls.compactMap { scale, url in + asset.filePaths[scale].map { + self.sfSymbolProvider.saveData( + from: url, + to: $0, + template: parameters.sfSymbolTemplate + ) + } + } + + return when(fulfilled: promises) + } + private func saveAssetFolders( assets: [ImageComponentSetAsset: AssetFolder], groupByFrame: Bool, @@ -144,10 +223,14 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes return when(fulfilled: promises).asVoid() } - private func saveImageFiles(assets: [ImageComponentSetAsset]) -> Promise { + private func saveImageFiles(assets: [ImageComponentSetAsset], parameters: ImagesParameters) -> Promise { let promises = assets.flatMap { setAsset in setAsset.assets.map { node, asset in - saveImageFiles(node: node, asset: asset) + if asset.isSymbol { + saveSymbolFiles(node: node, asset: asset, parameters: parameters) + } else { + saveImageFiles(node: node, asset: asset) + } } } @@ -172,6 +255,7 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes assets.reduce(into: [:]) { result, asset in result[asset] = AssetFolder( imageSets: self.makeAssetImageSets(for: asset.assets), + symbolSets: self.makeAssetSymbolSets(for: asset.assets), contents: AssetFolderContents(info: .defaultFigmaGen) ) } @@ -183,7 +267,7 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes in: folderPath ) }.then { - self.saveImageFiles(assets: assets) + self.saveImageFiles(assets: assets, parameters: parameters) } } } @@ -220,6 +304,15 @@ extension AssetImageProperties { } } +extension AssetSymbolProperties { + + fileprivate init?(from imageAsset: ImageAsset) { + self.init( + symbolRenderingIntent: imageAsset.symbolRenderAs.map { AssetSymbolRenderingIntent(from: $0) } + ) + } +} + extension AssetImageTemplateRenderingIntent { fileprivate init(from renderingIntent: ImageRenderingMode) { @@ -232,3 +325,19 @@ extension AssetImageTemplateRenderingIntent { } } } + +extension AssetSymbolRenderingIntent { + + fileprivate init(from renderingIntent: SymbolRenderingMode) { + switch renderingIntent { + case .template: + self = .template + + case .multicolor: + self = .multicolor + + case .hierarchical: + self = .hierarchical + } + } +} diff --git a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift index 889dc92..224ee17 100644 --- a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift +++ b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift @@ -228,14 +228,24 @@ final class DefaultImagesProvider: ImagesProvider { ) } }.then { nodes in - self.imageRenderProvider.renderImages( - of: file, - nodes: nodes, - format: parameters.format, - scales: parameters.scales, - useAbsoluteBounds: parameters.useAbsoluteBounds - ) + when( + fulfilled: self.imageRenderProvider.renderImages( + of: file, + nodes: nodes.getImagesWithoutSymbols(by: parameters.sfSymbolKey), + format: parameters.format, + scales: parameters.scales, + useAbsoluteBounds: parameters.useAbsoluteBounds + ), + self.imageRenderProvider.renderImages( + of: file, + nodes: nodes.getSymbols(by: parameters.sfSymbolKey), + format: .svg, + scales: parameters.scales, + useAbsoluteBounds: parameters.useAbsoluteBounds + ) + ).map { $0 + $1 } }.then { nodes in + // сюда приходят url-ы для pdf-ок и для svg self.saveAssetImagesIfNeeded( nodes: nodes, parameters: parameters @@ -243,3 +253,44 @@ final class DefaultImagesProvider: ImagesProvider { } } } + +extension Array where Element == ImageComponentSetNode { + + func getImagesWithoutSymbols(by sfSymbolKey: String?) -> [ImageComponentSetNode] { + guard let sfSymbolKey else { + return self + } + + return compactMap { node in + let images = node.components.filter({ !$0.name.contains("\(sfSymbolKey)=true") }) + guard !images.isEmpty else { + return nil + } + + return ImageComponentSetNode( + name: node.name, + parentName: node.parentName, + components: images + ) + } + } + + func getSymbols(by sfSymbolKey: String?) -> [ImageComponentSetNode] { + guard let sfSymbolKey else { + return [] + } + + return compactMap { node in + let symbols = node.components.filter({ $0.name.contains("\(sfSymbolKey)=true") }) + guard !symbols.isEmpty else { + return nil + } + + return ImageComponentSetNode( + name: node.name, + parentName: node.parentName, + components: symbols + ) + } + } +} diff --git a/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift b/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift index b8fbf5d..9e1ea72 100644 --- a/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift @@ -75,6 +75,7 @@ final class DefaultImageRenderProvider: ImageRenderProvider { .map { $0.id }, format: format.figmaFormat, scale: scale.figmaScale, + svgIncludeID: true, useAbsoluteBounds: useAbsoluteBounds ) diff --git a/Sources/FigmaGen/Providers/Images/Resources/DefaultImageResourcesProvider.swift b/Sources/FigmaGen/Providers/Images/Resources/DefaultImageResourcesProvider.swift index 6e8fddd..ff3f106 100644 --- a/Sources/FigmaGen/Providers/Images/Resources/DefaultImageResourcesProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Resources/DefaultImageResourcesProvider.swift @@ -85,7 +85,8 @@ final class DefaultImageResourcesProvider: ImageResourcesProvider, ImagesFolderP private func saveImageFiles( node: ImageRenderedNode, resource: ImageResource, - postProcessor: String? + postProcessor: String?, + coloredResources: String? ) -> Promise { let promises = node.urls.compactMap { scale, url in resource.filePaths[scale].map { filePath in @@ -93,7 +94,11 @@ final class DefaultImageResourcesProvider: ImageResourcesProvider, ImagesFolderP .saveData(from: url, to: filePath) .done { if let postProcessor { - try self.postProcessor.execute(postProcessorPath: postProcessor, filePath: filePath) + try self.postProcessor.execute( + postProcessorPath: postProcessor, + filePath: filePath, + coloredResources: coloredResources + ) } } } @@ -105,6 +110,7 @@ final class DefaultImageResourcesProvider: ImageResourcesProvider, ImagesFolderP private func saveImageFiles( resources: [ImageRenderedNode: ImageResource], postProcessor: String?, + coloredResources: String?, in folderPath: Path ) throws -> Promise { if folderPath.exists { @@ -112,7 +118,12 @@ final class DefaultImageResourcesProvider: ImageResourcesProvider, ImagesFolderP } let promises = resources.map { node, resource in - saveImageFiles(node: node, resource: resource, postProcessor: postProcessor) + saveImageFiles( + node: node, + resource: resource, + postProcessor: postProcessor, + coloredResources: coloredResources + ) } return when(fulfilled: promises) @@ -132,7 +143,12 @@ final class DefaultImageResourcesProvider: ImageResourcesProvider, ImagesFolderP folderPath: Path(folderPath) ) }.nest { resources in - try self.saveImageFiles(resources: resources, postProcessor: parameters.postProcessor, in: Path(folderPath)) + try self.saveImageFiles( + resources: resources, + postProcessor: parameters.postProcessor, + coloredResources: parameters.coloredResources, + in: Path(folderPath) + ) } } } diff --git a/Sources/FigmaGen/Providers/Images/Resources/ImageResourcesPostProcessor.swift b/Sources/FigmaGen/Providers/Images/Resources/ImageResourcesPostProcessor.swift index ea39d88..6a44808 100644 --- a/Sources/FigmaGen/Providers/Images/Resources/ImageResourcesPostProcessor.swift +++ b/Sources/FigmaGen/Providers/Images/Resources/ImageResourcesPostProcessor.swift @@ -25,10 +25,15 @@ final class ImageResourcesPostProcessor { // MARK: - - func execute(postProcessorPath: String, filePath: String) throws { + func execute(postProcessorPath: String, filePath: String, coloredResources: String? = nil) throws { let postProcessorPath = Path(postProcessorPath).absolute() let filePath = Path(filePath).absolute() try shell("\(postProcessorPath) --filePath \(filePath)") + + let coloredResourcesArgument = coloredResources + .map { " --coloredResources \(Path($0).absolute())" } ?? "" + + try shell("\(postProcessorPath) --filePath \(filePath)\(coloredResourcesArgument)") } } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift new file mode 100644 index 0000000..ef9d876 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift @@ -0,0 +1,208 @@ +import Foundation +import FigmaGenTools +import PromiseKit +import PathKit + +final class DefaultSFSymbolProvider: SFSymbolProvider { + + // MARK: - Instance Properties + + private let templateRenderer: TemplateRenderer + + private let svgParser: SVGParser + private let dataCache = Cache() + + init(svgParser: SVGParser, templateRenderer: TemplateRenderer) { + self.svgParser = svgParser + self.templateRenderer = templateRenderer + } + + // MARK: - Instance Methods + + func saveData(from url: URL, to filePath: String, template: String?) -> Promise { + firstly { + self.fetchData(from: url) + }.map(on: DispatchQueue.global(qos: .userInitiated)) { fileData in + guard let template else { + return + } + + let filePath = Path(filePath) + + if filePath.exists { + try filePath.delete() + } + + let result = try self.svgParser.parse(id: filePath.string, data: fileData) + + let token = try self.extractPaths(from: result) + + try self.templateRenderer.renderTemplate( + RenderTemplate(type: .custom(path: template), options: [:]), + to: RenderDestination.file(path: filePath.string), + context: self.makeContext(for: token) + ) + } + } + + // MARK: - Private methods + + private func fetchData(from url: URL) -> Promise { + perform(on: DispatchQueue.global(qos: .userInitiated)) { + if let data = self.dataCache.value(forKey: url) { + return data + } + + return try Data(contentsOf: url) + }.get { data in + self.dataCache.setValue(data, forKey: url) + } + } + + private func resolveRole(of path: SVGPath) -> SFSymbolRole { + if let id = path.id, let role = SFSymbolRole(rawValue: id.lowercased()) { + return role + } + + return path.role + } + + private func extractPaths(from result: SVGPathsResult) throws -> SVGImageToken { + guard let canvas = result.canvas, canvas.width > 0.0, canvas.height > 0.0 else { + throw SVGParserError.missingCanvasSize + } + + guard !result.allPaths.isEmpty else { + throw SVGParserError.invalidSVG + } + + // Бокс компонента всегда занимает весь em дизайн-бокса, поэтому символ рисуется ровно + // в том кегле, который передан в .font(.system(size:)). Оптический размер рисунка задаётся + // этим кеглем и не зашивается в геометрию. + // Ширина сохраняет пропорции бокса компонента из Figma, чтобы заложенные дизайнером отступы + // и неквадратные компоненты пережили конвертацию. Она округляется до целой дизайн-единицы, + // потому что поля задают итоговый бокс символа, а дробное поле может расширить его на целую + // единицу. Квадратный компонент и так целый, а у неквадратного в худшем случае правое поле + // сдвинется на половину единицы, не задев сам рисунок. + let designHeight = SFSymbolGeometry.emDesignHeight + let scale = designHeight / canvas.height + let designWidth = (canvas.width * scale).rounded() + + // И Figma, и шаблон рисуют сверху вниз, поэтому переворот по Y не нужен - достаточно + // масштаба и сдвига. Сдвиг ставит центр дизайн-бокса в центр cap height той строки, + // в которую группа символа переносится шаблоном. + let baseTransform = SVGTransform( + a: scale, + b: 0.0, + c: 0.0, + d: scale, + e: 0.0, + f: -SFSymbolGeometry.capHeight / 2.0 - designHeight / 2.0 + ) + + return SVGImageToken( + name: URL(fileURLWithPath: result.id).deletingPathExtension().lastPathComponent, + opticalSize: Int(canvas.height.rounded()), + designWidth: designWidth, + designHeight: designHeight, + layers: try makeLayers(from: result, baseTransform: baseTransform) + ) + } + + /// Раскладывает пути по ролям, сохраняя порядок появления ролей в файле: + /// он определяет номера слоёв и motion-групп в шаблоне. + private func makeLayers(from result: SVGPathsResult, baseTransform: SVGTransform) throws -> [SFSymbolLayer] { + var roles: [SFSymbolRole] = [] + var pathsByRole: [SFSymbolRole: [SFSymbolPathData]] = [:] + + for path in result.allPaths { + guard let data = path.data else { + throw SVGParserError.invalidPathData("for \(result.id)") + } + + let role = resolveRole(of: path) + + if !roles.contains(role) { + roles.append(role) + } + + // Собственный и унаследованный transform пути применяются к его координатам + // до перевода в пространство шаблона, поэтому базовое преобразование идёт первым. + let transformer = SVGPathTransformer( + transform: baseTransform.concatenating(try SVGTransformParser.transform(from: path.allTransforms)) + ) + + pathsByRole[role, default: []].append( + SFSymbolPathData( + data: try transformer.transform(pathData: data), + fillRule: path.fillRule + ) + ) + } + + return roles.enumerated().map { index, role in + SFSymbolLayer(index: index, role: role, paths: pathsByRole[role] ?? []) + } + } + + private func makeContext(for token: SVGImageToken) -> [String: Any] { + let layers = token.layers.map { layer -> [String: Any] in + [ + "index": layer.index, + "role": layer.role.rawValue, + // Motion-группы нумеруются от самого верхнего слоя - так их выгружает Xcode. + "motionGroup": token.layers.count - 1 - layer.index, + "paths": layer.paths.map { path in + ["data": path.data, "fillRule": path.fillRule ?? ""] + } + ] + } + + let variants = SFSymbolGeometry.scales.flatMap { scale in + SFSymbolGeometry.weights.map { weight -> [String: Any] in + // Округляется, чтобы оба поля попали на целые дизайн-единицы: центр колонки дробный, + // и точное центрирование поставило бы поля на доли единицы. + let originX = (weight.centerX - token.designWidth / 2.0).rounded() + + return [ + "id": "\(weight.name)-\(scale.name)", + "originX": SVGNumber.string(from: originX), + "baseline": SVGNumber.string(from: scale.baseline), + "leftMargin": SVGNumber.string(from: originX), + "rightMargin": SVGNumber.string(from: originX + token.designWidth), + "guideTop": SVGNumber.string(from: scale.baseline - SFSymbolGeometry.marginGuideTopOffset), + "guideBottom": SVGNumber.string(from: scale.baseline + SFSymbolGeometry.marginGuideBottomOffset) + ] + } + } + + return [ + "name": token.name, + "opticalSize": token.opticalSize, + "designWidth": SVGNumber.string(from: token.designWidth), + "designHeight": SVGNumber.string(from: token.designHeight), + "layers": layers, + "variants": variants + ] + } +} + +extension SVGPath { + + var role: SFSymbolRole { + // TODO: @d.viter тут было еще wholeID.contains("icon") + if id == "primary" || fill == .black { + return .primary + } + + if id == "secondary" || fill != .black || wholeID.contains("detail-a") { + return .secondary + } + + if id == "tertiary" || wholeID.contains("detail-b") { + return .tertiary + } + + return .tertiary + } +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift new file mode 100644 index 0000000..a0cb734 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift @@ -0,0 +1,212 @@ +import Foundation + +final class DefaultSVGParser: NSObject, SVGParser { + + private enum ElementName { + + static let group = "g" + static let path = "path" + static let svg = "svg" + + // Элементы, чьи пути описывают только переиспользуемые определения + // и никогда не участвуют в видимом рисунке. + static let ignored: Set = ["clipPath", "defs", "mask", "pattern", "symbol"] + } + + private enum Attributes { + + static let fill = "fill" + static let height = "height" + static let id = "id" + static let style = "style" + static let transform = "transform" + static let viewBox = "viewBox" + static let width = "width" + } + + private var canvas: SVGCanvas? + private var parsedPaths: [SVGPath] = [] + private var groupStack: [SVGGroupContext] = [] + private var ignoredElementDepth = 0 + + func parse(id: String, data: Data) throws -> SVGPathsResult { + parsedPaths.removeAll() + groupStack.removeAll() + ignoredElementDepth = 0 + canvas = nil + + let parser = XMLParser(data: data) + + parser.delegate = self + parser.shouldResolveExternalEntities = false + parser.shouldProcessNamespaces = false + + guard parser.parse() else { + throw parser.parserError ?? SVGParserError.invalidXML + } + + return SVGPathsResult( + id: id, + canvas: canvas, + allPaths: parsedPaths + ) + } + + private func startGroup(attributes attributeDict: [String: String]) { + // Заливка наследуется от ближайшей родительской группы, если у самой группы её нет. + let effectiveFill = fillColor(from: attributeDict) ?? groupStack.last?.fill + + let context = SVGGroupContext( + id: attributeDict[Attributes.id], + fill: effectiveFill, + transform: attributeDict[Attributes.transform] + ) + + groupStack.append(context) + } + + private func makeCanvas(from attributes: [String: String]) -> SVGCanvas? { + if + let width = length(from: attributes[Attributes.width]), + let height = length(from: attributes[Attributes.height]), + width > 0.0, + height > 0.0 { + return SVGCanvas(width: width, height: height) + } + + let viewBox = attributes[Attributes.viewBox]? + .split { $0 == " " || $0 == "," } + .compactMap { Double($0) } + + guard let viewBox, viewBox.count == 4, viewBox[2] > 0.0, viewBox[3] > 0.0 else { + return nil + } + + return SVGCanvas(width: viewBox[2], height: viewBox[3]) + } + + private func length(from value: String?) -> Double? { + value + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .map { $0.hasSuffix("px") ? String($0.dropLast(2)) : $0 } + .flatMap { Double($0) } + } + + private func fillColor(from attributes: [String: String]) -> SVGColor? { + guard let value = fillValue(from: attributes) else { + return nil + } + + let normalized = value + .lowercased() + .replacingOccurrences(of: " ", with: "") + + switch normalized { + case "black", "#000", "#000000", "rgb(0,0,0)": + return .black + + default: + return .other(value) + } + } + + private func fillValue( + from attributes: [String: String] + ) -> String? { + if let fill = attributes[Attributes.fill] { + return fill + } + + guard let style = attributes[Attributes.style] else { + return nil + } + + let declarations = style.split(separator: ";") + + for declaration in declarations { + let parts = declaration.split( + separator: ":", + maxSplits: 1 + ) + + guard parts.count == 2 else { + continue + } + + let name = parts[0] + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + let value = parts[1] + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard name == Attributes.fill else { + continue + } + + return value + } + + return nil + } +} + +extension DefaultSVGParser: XMLParserDelegate { + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + if ElementName.ignored.contains(elementName) { + ignoredElementDepth += 1 + } + + if elementName == ElementName.svg, canvas == nil { + canvas = makeCanvas(from: attributeDict) + } + + if elementName == ElementName.group { + startGroup(attributes: attributeDict) + } + + guard elementName == ElementName.path, ignoredElementDepth == 0 else { + return + } + + let inheritedFill = fillColor(from: attributeDict) ?? groupStack.last?.fill + let groupIDs = groupStack.compactMap(\.id) + + // Преобразования собираются от внешней группы к внутренней - в том порядке, + // в каком их применяет SVG. Собственный transform пути добавляет `SVGPath.allTransforms`. + let transforms = groupStack.compactMap(\.transform) + + let path = SVGPath( + attributes: attributeDict, + groupIDs: groupIDs, + inheritedTransforms: transforms, + fill: inheritedFill + ) + + parsedPaths.append(path) + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + if ElementName.ignored.contains(elementName) { + ignoredElementDepth = max(ignoredElementDepth - 1, 0) + } + + guard elementName == ElementName.group else { + return + } + + _ = groupStack.popLast() + } +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolGeometry.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolGeometry.swift new file mode 100644 index 0000000..a442c6f --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolGeometry.swift @@ -0,0 +1,31 @@ +import Foundation + +enum SFSymbolGeometry { + + // Типографские метрики, общие для всех строк шаблона. Cap height равен 0.70459 em - это + // cap height SF Pro, и он подтверждает, что один em составляет 100 дизайн-единиц: + // дизайн-бокс высотой emDesignHeight рисуется ровно в кегле шрифта. + static let capHeight = 70.459 + static let emDesignHeight = 99.5 + + // Вертикальные границы направляющих полей, отсчитанные от базовой линии своей строки. + static let marginGuideTopOffset = 95.215 + static let marginGuideBottomOffset = 24.121 + + // Горизонтальные центры колонок начертаний, взятые из подписей + // раздела "Weight/Scale Variations" шаблона. + static let weights: [(name: String, centerX: Double)] = [ + (name: "Ultralight", centerX: 559.711), + (name: "Regular", centerX: 1449.845), + (name: "Black", centerX: 2933.4) + ] + + // Базовые линии строк масштабов. Каждый масштаб задан явно и несёт один и тот же рисунок, + // поэтому SF Symbols ничего не достраивает, и запрошенный приложением image scale + // не может изменить итоговый размер. + static let scales: [(name: String, baseline: Double)] = [ + (name: "S", baseline: 696.0), + (name: "M", baseline: 1126.0), + (name: "L", baseline: 1556.0) + ] +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift new file mode 100644 index 0000000..7a3e877 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift @@ -0,0 +1,11 @@ +import Foundation +import PromiseKit + +protocol SFSymbolProvider { + + func saveData( + from url: URL, + to filePath: String, + template: String? + ) -> Promise +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift new file mode 100644 index 0000000..0a5e235 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol SVGParser { + + func parse(id: String, data: Data) throws -> SVGPathsResult +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift new file mode 100644 index 0000000..f657150 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift @@ -0,0 +1,40 @@ +import Foundation + +enum SVGParserError: LocalizedError { + + case invalidXML + case invalidSVG + case missingCanvasSize + case invalidPathData(String) + case invalidTransform(String) + case unsupportedArcTransform(String) + case unsupportedFill(String) + + var errorDescription: String? { + switch self { + case .invalidXML: + return "The SVG file contains invalid XML." + + case .invalidSVG: + return "The SVG file contains errors." + + case .missingCanvasSize: + return "The SVG file has no valid width and height attributes and no viewBox." + + case let .invalidPathData(pathData): + return "The SVG file contains unsupported path data \"\(pathData)\"." + + case let .invalidTransform(transform): + return "The SVG file contains unsupported transform \"\(transform)\"." + + case let .unsupportedArcTransform(pathData): + return """ + The SVG file contains an elliptical arc in \"\(pathData)\" \ + under a transform that is not a similarity, which would change the shape of the arc. + """ + + case let .unsupportedFill(fill): + return "The SVG file contains a path with unsupported fill \"\(fill)\"." + } + } +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGPathTransformer.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGPathTransformer.swift new file mode 100644 index 0000000..1b7f60e --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGPathTransformer.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Переводит данные пути SVG (атрибут `d`) в другое координатное пространство, +/// применяя аффинное преобразование - масштаб мастера SF Symbols вместе с `transform` +/// групп и самого пути. +/// +/// Разбор попутно нормализует путь: все команды на выходе абсолютные, `H` и `V` становятся `L`. +/// Иначе поворот, отражение и неравномерный масштаб дали бы неверный результат - +/// горизонтальный отрезок после них перестаёт быть горизонтальным. +struct SVGPathTransformer { + + // MARK: - Nested Types + + private enum Command { + + // Количество параметров в одной группе параметров команды. + static let arities: [Character: Int] = [ + "m": 2, + "l": 2, + "t": 2, + "h": 1, + "v": 1, + "c": 6, + "s": 4, + "q": 4, + "a": 7, + "z": 0 + ] + + // Индексы флагов large-arc и sweep у эллиптической дуги. + static let arcFlagIndexes = [3, 4] + } + + private struct Point { + + static let zero = Self(x: 0.0, y: 0.0) + + let x: Double + let y: Double + } + + /// Положение при разборе: текущая точка, от которой отсчитываются относительные команды, + /// и начало текущего контура, в которое возвращает `z`. + private struct Cursor { + + var currentPoint: Point = .zero + var subpathStart: Point = .zero + } + + // MARK: - Instance Properties + + let transform: SVGTransform + + // MARK: - Instance Methods + + func transform(pathData: String) throws -> String { + var reader = SVGReader(pathData) + var cursor = Cursor() + var commands: [String] = [] + + while let commandCharacter = reader.readCommand() { + commands += try makeCommands( + for: commandCharacter, + reader: &reader, + cursor: &cursor, + pathData: pathData + ) + } + + // Хвост, который не удалось разобрать, раньше молча отбрасывался вместе с частью рисунка. + guard !commands.isEmpty, reader.isAtEnd() else { + throw SVGParserError.invalidPathData(pathData) + } + + return commands.joined(separator: " ") + } + + // MARK: - + + /// Разбирает одну букву команды вместе со всеми её группами параметров: + /// в данных пути команду разрешено не повторять для каждой следующей группы. + private func makeCommands( + for commandCharacter: Character, + reader: inout SVGReader, + cursor: inout Cursor, + pathData: String + ) throws -> [String] { + let isAbsolute = commandCharacter.isUppercase + + var command = Character(commandCharacter.lowercased()) + + guard let arity = Command.arities[command] else { + throw SVGParserError.invalidPathData(pathData) + } + + guard arity > 0 else { + // После закрытия контура текущей становится его начальная точка. + cursor.currentPoint = cursor.subpathStart + + return ["Z"] + } + + var commands: [String] = [] + var isFirstGroup = true + + repeat { + let parameters = try readParameters( + count: arity, + isArc: command == "a", + from: &reader, + pathData: pathData + ) + + // Повторные группы параметров после moveto по спецификации трактуются как lineto. + if command == "m", !isFirstGroup { + command = "l" + } + + commands.append( + try makeCommand( + command, + parameters: parameters, + isAbsolute: isAbsolute, + currentPoint: &cursor.currentPoint, + pathData: pathData + ) + ) + + if command == "m" { + cursor.subpathStart = cursor.currentPoint + } + + isFirstGroup = false + } while reader.hasNumber() + + return commands + } + + private func readParameters( + count: Int, + isArc: Bool, + from reader: inout SVGReader, + pathData: String + ) throws -> [Double] { + try (0.. String { + // Все смещения одной группы отсчитываются от точки, в которой команда началась, + // поэтому она фиксируется до пересчёта. + let origin = currentPoint + + func point(_ x: Double, _ y: Double) -> Point { + isAbsolute ? Point(x: x, y: y) : Point(x: origin.x + x, y: origin.y + y) + } + + switch command { + case "m", "l", "t": + let endPoint = point(parameters[0], parameters[1]) + + currentPoint = endPoint + + return makeCommand(Character(command.uppercased()), points: [endPoint]) + + case "h": + let endPoint = Point(x: isAbsolute ? parameters[0] : origin.x + parameters[0], y: origin.y) + + currentPoint = endPoint + + return makeCommand("L", points: [endPoint]) + + case "v": + let endPoint = Point(x: origin.x, y: isAbsolute ? parameters[0] : origin.y + parameters[0]) + + currentPoint = endPoint + + return makeCommand("L", points: [endPoint]) + + case "c": + let endPoint = point(parameters[4], parameters[5]) + + currentPoint = endPoint + + return makeCommand( + "C", + points: [point(parameters[0], parameters[1]), point(parameters[2], parameters[3]), endPoint] + ) + + // Обе команды задают одну контрольную точку и конец. У сглаженной `s` вторая контрольная + // точка подразумевается отражением предыдущей относительно текущей - аффинное + // преобразование сохраняет отражения, поэтому восстанавливать её не нужно. + // По той же причине не нужна и подразумеваемая контрольная точка `t` выше. + case "s", "q": + let endPoint = point(parameters[2], parameters[3]) + + currentPoint = endPoint + + return makeCommand( + command == "s" ? "S" : "Q", + points: [point(parameters[0], parameters[1]), endPoint] + ) + + case "a": + let endPoint = point(parameters[5], parameters[6]) + + currentPoint = endPoint + + return try makeArcCommand(parameters: parameters, endPoint: endPoint, pathData: pathData) + + default: + throw SVGParserError.invalidPathData(pathData) + } + } + + private func makeArcCommand(parameters: [Double], endPoint: Point, pathData: String) throws -> String { + // Произвольное преобразование превращает окружность в эллипс, и новые радиусы приходится + // искать разложением матрицы. Подобие такого не требует, а Figma ничего другого не отдаёт, + // поэтому остальные случаи честно отвергаются, а не пересчитываются неверно. + guard transform.isSimilarity else { + throw SVGParserError.unsupportedArcTransform(pathData) + } + + let scale = transform.linearScaleX + let isMirrored = transform.determinant < 0.0 + let rotation = transform.rotation * 180.0 / .pi + + // Подобие сохраняет пропорции эллипса: радиусы масштабируются, а наклон складывается + // с поворотом преобразования. Отражение меняет знак наклона и направление обхода дуги. + let transformedEndPoint = transform.apply(x: endPoint.x, y: endPoint.y) + + let values = [ + parameters[0] * scale, + parameters[1] * scale, + normalizedRotation(isMirrored ? rotation - parameters[2] : rotation + parameters[2]), + parameters[3], + isMirrored ? 1.0 - parameters[4] : parameters[4], + transformedEndPoint.x, + transformedEndPoint.y + ] + + return "A".appending(values.map(SVGNumber.string).joined(separator: " ")) + } + + /// Эллипс центрально симметричен, поэтому его наклон определён с точностью до 180°. + /// Приведение к [0, 180) убирает из вывода отрицательные и избыточные углы. + private func normalizedRotation(_ degrees: Double) -> Double { + let rotation = degrees.truncatingRemainder(dividingBy: 180.0) + + return rotation < 0.0 ? rotation + 180.0 : rotation + } + + private func makeCommand(_ command: Character, points: [Point]) -> String { + let values = points.flatMap { point -> [Double] in + let transformedPoint = transform.apply(x: point.x, y: point.y) + + return [transformedPoint.x, transformedPoint.y] + } + + return String(command).appending(values.map(SVGNumber.string).joined(separator: " ")) + } +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGReader.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGReader.swift new file mode 100644 index 0000000..18c32f2 --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGReader.swift @@ -0,0 +1,174 @@ +import Foundation + +/// Посимвольный сканер, общий для данных пути (`d`) и для списка преобразований (`transform`). +struct SVGReader { + + // MARK: - Type Properties + + private static let separators: Set = [" ", ",", "\n", "\r", "\t"] + + // MARK: - Instance Properties + + private let characters: [Character] + + private var index = 0 + + // MARK: - Initializers + + init(_ text: String) { + characters = Array(text) + } + + // MARK: - Instance Methods + + private mutating func skipSeparators() { + while index < characters.count, Self.separators.contains(characters[index]) { + index += 1 + } + } + + private func isDigit(at index: Int) -> Bool { + index < characters.count && characters[index].isASCII && characters[index].isNumber + } + + /// `true`, если, кроме разделителей, в строке ничего не осталось. + /// Нужна, чтобы не проглатывать молча хвост, который не удалось разобрать. + mutating func isAtEnd() -> Bool { + skipSeparators() + + return index >= characters.count + } + + mutating func readCommand() -> Character? { + skipSeparators() + + guard index < characters.count, characters[index].isLetter else { + return nil + } + + defer { index += 1 } + + return characters[index] + } + + /// Имя функции преобразования: `translate`, `matrix` и т.д. + mutating func readIdentifier() -> String? { + skipSeparators() + + let start = index + + while index < characters.count, characters[index].isLetter { + index += 1 + } + + guard index > start else { + return nil + } + + return String(characters[start.. Bool { + skipSeparators() + + guard index < characters.count, characters[index] == character else { + return false + } + + index += 1 + + return true + } + + mutating func hasNumber() -> Bool { + skipSeparators() + + guard index < characters.count else { + return false + } + + let character = characters[index] + + return isDigit(at: index) || character == "." || character == "-" || character == "+" + } + + mutating func readNumber() -> Double? { + skipSeparators() + + let start = index + + if index < characters.count, characters[index] == "-" || characters[index] == "+" { + index += 1 + } + + var hasDigits = false + + while isDigit(at: index) { + index += 1 + hasDigits = true + } + + if index < characters.count, characters[index] == "." { + index += 1 + + while isDigit(at: index) { + index += 1 + hasDigits = true + } + } + + guard hasDigits else { + index = start + + return nil + } + + if index < characters.count, characters[index] == "e" || characters[index] == "E" { + let exponentStart = index + + index += 1 + + if index < characters.count, characters[index] == "-" || characters[index] == "+" { + index += 1 + } + + var hasExponentDigits = false + + while isDigit(at: index) { + index += 1 + hasExponentDigits = true + } + + if !hasExponentDigits { + index = exponentStart + } + } + + return Double(String(characters[start.. Double? { + skipSeparators() + + guard index < characters.count else { + return nil + } + + switch characters[index] { + case "0": + index += 1 + + return 0.0 + + case "1": + index += 1 + + return 1.0 + + default: + return nil + } + } +} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGTransformParser.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGTransformParser.swift new file mode 100644 index 0000000..5a9253e --- /dev/null +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGTransformParser.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Разбирает атрибут `transform` в аффинную матрицу. +enum SVGTransformParser { + + // MARK: - Nested Types + + private enum Function { + + static let matrix = "matrix" + static let translate = "translate" + static let scale = "scale" + static let rotate = "rotate" + static let skewX = "skewX" + static let skewY = "skewY" + } + + // MARK: - Type Methods + + /// Складывает цепочку преобразований от внешней группы к внутренней. + /// Порядок важен: `transform` внешнего элемента применяется к результату внутреннего. + static func transform(from values: [String]) throws -> SVGTransform { + try values.reduce(.identity) { result, value in + result.concatenating(try transform(from: value)) + } + } + + static func transform(from value: String) throws -> SVGTransform { + var reader = SVGReader(value) + var result = SVGTransform.identity + + while let name = reader.readIdentifier() { + guard reader.readCharacter("(") else { + throw SVGParserError.invalidTransform(value) + } + + var parameters: [Double] = [] + + while let parameter = reader.readNumber() { + parameters.append(parameter) + } + + guard reader.readCharacter(")") else { + throw SVGParserError.invalidTransform(value) + } + + result = result.concatenating( + try makeTransform(name: name, parameters: parameters, source: value) + ) + } + + guard reader.isAtEnd() else { + throw SVGParserError.invalidTransform(value) + } + + return result + } + + // MARK: - + + private static func makeTransform( + name: String, + parameters: [Double], + source: String + ) throws -> SVGTransform { + switch (name, parameters.count) { + case (Function.matrix, 6): + return SVGTransform( + a: parameters[0], + b: parameters[1], + c: parameters[2], + d: parameters[3], + e: parameters[4], + f: parameters[5] + ) + + case (Function.translate, 1): + return .translation(x: parameters[0], y: 0.0) + + case (Function.translate, 2): + return .translation(x: parameters[0], y: parameters[1]) + + // У scale с одним параметром масштаб по обеим осям одинаковый. + case (Function.scale, 1): + return .scale(x: parameters[0], y: parameters[0]) + + case (Function.scale, 2): + return .scale(x: parameters[0], y: parameters[1]) + + case (Function.rotate, 1): + return .rotation(degrees: parameters[0]) + + // rotate с центром - это поворот, обёрнутый в перенос центра в начало координат и обратно. + case (Function.rotate, 3): + return SVGTransform + .translation(x: parameters[1], y: parameters[2]) + .concatenating(.rotation(degrees: parameters[0])) + .concatenating(.translation(x: -parameters[1], y: -parameters[2])) + + case (Function.skewX, 1): + return .skewX(degrees: parameters[0]) + + case (Function.skewY, 1): + return .skewY(degrees: parameters[0]) + + default: + throw SVGParserError.invalidTransform(source) + } + } +} diff --git a/Sources/FigmaGenTools/Assets/Folder/AssetFolder.swift b/Sources/FigmaGenTools/Assets/Folder/AssetFolder.swift index dcf27bf..ed325cf 100644 --- a/Sources/FigmaGenTools/Assets/Folder/AssetFolder.swift +++ b/Sources/FigmaGenTools/Assets/Folder/AssetFolder.swift @@ -13,6 +13,7 @@ public struct AssetFolder { public var colorSets: [String: AssetColorSet] public var imageSets: [String: AssetImageSet] + public var symbolSets: [String: AssetSymbolSet] public var folders: [String: AssetFolder] public var contents: AssetFolderContents @@ -21,11 +22,13 @@ public struct AssetFolder { public init( colorSets: [String: AssetColorSet] = [:], imageSets: [String: AssetImageSet] = [:], + symbolSets: [String: AssetSymbolSet] = [:], folders: [String: Self] = [:], contents: AssetFolderContents = AssetFolderContents() ) { self.colorSets = colorSets self.imageSets = imageSets + self.symbolSets = symbolSets self.folders = folders self.contents = contents } @@ -41,6 +44,7 @@ public struct AssetFolder { colorSets = [:] imageSets = [:] + symbolSets = [:] folders = [:] try folderPath @@ -57,6 +61,9 @@ public struct AssetFolder { case AssetImageSet.pathExtension: imageSets[nodeName] = try AssetImageSet(folderPath: nodePath.string) + case AssetSymbolSet.pathExtension: + symbolSets[nodeName] = try AssetSymbolSet(folderPath: nodePath.string) + case nil: folders[nodeName] = try Self(folderPath: nodePath.string) @@ -94,6 +101,7 @@ public struct AssetFolder { try saveNodes(colorSets, in: folderPath) try saveNodes(imageSets, in: folderPath) + try saveNodes(symbolSets, in: folderPath) try saveFolders(in: folderPath) let contentsEncoder = JSONEncoder(outputFormatting: [.prettyPrinted, .sortedKeys]) diff --git a/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolProperties.swift b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolProperties.swift new file mode 100644 index 0000000..bdceef2 --- /dev/null +++ b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolProperties.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct AssetSymbolProperties: Codable, Hashable { + + // MARK: - Nested Types + + private enum CodingKeys: String, CodingKey { + case symbolRenderingIntent = "symbol-rendering-intent" + } + + // MARK: - Instance Properties + + public var symbolRenderingIntent: AssetSymbolRenderingIntent? + + // MARK: - Initializers + + public init( + symbolRenderingIntent: AssetSymbolRenderingIntent? = nil + ) { + self.symbolRenderingIntent = symbolRenderingIntent + } +} diff --git a/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolRenderingIntent.swift b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolRenderingIntent.swift new file mode 100644 index 0000000..982e53c --- /dev/null +++ b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolRenderingIntent.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum AssetSymbolRenderingIntent: String, Codable { + + case template + case multicolor + case hierarchical +} diff --git a/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSet.swift b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSet.swift new file mode 100644 index 0000000..fb95f61 --- /dev/null +++ b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSet.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct AssetSymbolSet: AssetNode { + + // MARK: - Type Properties + + public static let pathExtension = "symbolset" + + // MARK: - Instance Properties + + public var contents: AssetSymbolSetContents + + // MARK: - Initializers + + public init(contents: AssetSymbolSetContents = AssetSymbolSetContents()) { + self.contents = contents + } +} diff --git a/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSetContents.swift b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSetContents.swift new file mode 100644 index 0000000..f543da4 --- /dev/null +++ b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSetContents.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct AssetSymbolSetContents: Codable, Hashable { + + // MARK: - Instance Properties + + public var info: AssetInfo? + public var properties: AssetSymbolProperties? + public var symbols: [AssetImage]? + + // MARK: - Initializers + + public init( + info: AssetInfo? = AssetInfo(), + properties: AssetSymbolProperties? = nil, + symbols: [AssetImage]? = [AssetImage()] + ) { + self.info = info + self.properties = properties + self.symbols = symbols?.sorted { $0.scale?.rawValue ?? .empty <= $1.scale?.rawValue ?? .empty } + } +} diff --git a/Templates/SVGTemplate.stencil b/Templates/SVGTemplate.stencil new file mode 100644 index 0000000..09a7afc --- /dev/null +++ b/Templates/SVGTemplate.stencil @@ -0,0 +1,114 @@ + + + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + + Template v.7.0 + Requires Xcode 26 or greater + {{ name }} + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + {% for variant in variants %} + + + {% endfor %} + + + + + + + {% for variant in variants %} + + {% for layer in layers %} + {% for path in layer.paths %} + + {% endfor %} + {% endfor %} + + {% endfor %} + + diff --git a/Tests/FigmaGenTests/DefaultSVGParserTests.swift b/Tests/FigmaGenTests/DefaultSVGParserTests.swift new file mode 100644 index 0000000..1087865 --- /dev/null +++ b/Tests/FigmaGenTests/DefaultSVGParserTests.swift @@ -0,0 +1,101 @@ +#if canImport(FigmaGen) +import XCTest +@testable import FigmaGen + +final class DefaultSVGParserTests: XCTestCase { + + // MARK: - Instance Properties + + private let parser = DefaultSVGParser() + + // MARK: - Instance Methods + + private func parse(_ svg: String) throws -> SVGPathsResult { + try parser.parse(id: "icon.svg", data: Data(svg.utf8)) + } + + // MARK: - + + func testGroupTransformsAreCollectedOuterToInner() throws { + let result = try parse( + """ + + + + + + + + """ + ) + + XCTAssertEqual( + result.allPaths.first?.allTransforms, + ["translate(1 2)", "scale(2)", "rotate(90)"] + ) + } + + func testGroupTransformReachesPathData() throws { + let result = try parse( + """ + + + + """ + ) + + let path = try XCTUnwrap(result.allPaths.first) + let transformer = SVGPathTransformer( + transform: try SVGTransformParser.transform(from: path.allTransforms) + ) + + XCTAssertEqual(try transformer.transform(pathData: XCTUnwrap(path.data)), "M10 20 L11 20") + } + + func testGroupWithoutTransformIsSkipped() throws { + let result = try parse( + """ + + + + """ + ) + + XCTAssertEqual(result.allPaths.first?.allTransforms, ["translate(3 4)"]) + } + + func testTransformsAreNotLeakedToSiblingGroups() throws { + let result = try parse( + """ + + + + + """ + ) + + XCTAssertEqual(result.allPaths.count, 2) + XCTAssertEqual(result.allPaths.first?.allTransforms, ["translate(1 0)"]) + XCTAssertEqual(result.allPaths.last?.allTransforms, []) + } + + func testPathsInsideDefinitionsAreIgnored() throws { + let result = try parse( + """ + + + + + """ + ) + + XCTAssertEqual(result.allPaths.map(\.data), ["M0 0"]) + } + + func testCanvasFallsBackToViewBox() throws { + let result = try parse(#""#) + + XCTAssertEqual(result.canvas, SVGCanvas(width: 16.0, height: 32.0)) + } +} +#endif diff --git a/Tests/FigmaGenTests/SVGPathTransformerTests.swift b/Tests/FigmaGenTests/SVGPathTransformerTests.swift new file mode 100644 index 0000000..1884b35 --- /dev/null +++ b/Tests/FigmaGenTests/SVGPathTransformerTests.swift @@ -0,0 +1,150 @@ +#if canImport(FigmaGen) +import XCTest +@testable import FigmaGen + +final class SVGPathTransformerTests: XCTestCase { + + // MARK: - Instance Methods + + private func transform(_ pathData: String, by transform: SVGTransform) throws -> String { + try SVGPathTransformer(transform: transform).transform(pathData: pathData) + } + + private func transform(_ pathData: String, by attribute: String) throws -> String { + try transform(pathData, by: SVGTransformParser.transform(from: attribute)) + } + + // MARK: - + + func testIdentityNormalizesToAbsoluteCommands() throws { + // Относительные команды и h/v разворачиваются в абсолютные L. + XCTAssertEqual( + try transform("m10 10 l5 0 v5 h-5 z", by: .identity), + "M10 10 L15 10 L15 15 L10 15 Z" + ) + } + + func testImplicitCommandRepetition() throws { + // Повторные группы после moveto - это lineto, а не ещё один moveto. + XCTAssertEqual( + try transform("M0 0 1 1 2 2", by: .identity), + "M0 0 L1 1 L2 2" + ) + + // Повторные группы обычных команд сохраняют свою команду. + XCTAssertEqual( + try transform("M0 0 L1 1 2 2", by: .identity), + "M0 0 L1 1 L2 2" + ) + } + + func testRelativeCommandsAfterClosePath() throws { + // После z текущей точкой становится начало контура. + XCTAssertEqual( + try transform("M10 10 L20 10 Z l0 5", by: .identity), + "M10 10 L20 10 Z L10 15" + ) + } + + func testTranslationIsApplied() throws { + XCTAssertEqual( + try transform("M0 0 L10 0", by: "translate(5 7)"), + "M5 7 L15 7" + ) + } + + func testNestedTransformsAreAppliedOuterToInner() throws { + // translate(10 0) применяется к результату scale(2), а не наоборот. + XCTAssertEqual( + try transform("M1 0", by: "translate(10 0) scale(2)"), + "M12 0" + ) + } + + func testRotationTurnsAxisAlignedSegmentsIntoDiagonals() throws { + // Ровно тот случай, ради которого h/v переводятся в L. + XCTAssertEqual( + try transform("M0 0 h10", by: "rotate(90)"), + "M0 0 L0 10" + ) + } + + func testRelativeOffsetsIgnoreTranslationPart() throws { + // У относительной команды переносится только линейная часть преобразования. + XCTAssertEqual( + try transform("M0 0 l10 0", by: "translate(100 100) scale(2)"), + "M100 100 L120 100" + ) + } + + func testCurveControlPointsAreTransformed() throws { + XCTAssertEqual( + try transform("M0 0 c1 1 2 2 3 3", by: "scale(2)"), + "M0 0 C2 2 4 4 6 6" + ) + } + + func testArcUnderSimilarityScalesRadii() throws { + XCTAssertEqual( + try transform("M0 0 A5 5 0 1 0 10 0", by: "scale(2)"), + "M0 0 A10 10 0 1 0 20 0" + ) + } + + func testArcUnderMirroringFlipsSweepFlag() throws { + XCTAssertEqual( + try transform("M0 0 A5 5 0 1 0 10 0", by: "scale(-1 1)"), + "M0 0 A5 5 0 1 1 -10 0" + ) + } + + func testArcUnderMirroringReflectsEllipseRotation() throws { + // Отражение относительно оси Y переводит наклон +30° в +150°. + XCTAssertEqual( + try transform("M0 0 A10 5 30 0 1 10 0", by: "scale(-1 1)"), + "M0 0 A10 5 150 0 0 -10 0" + ) + } + + func testArcUnderRotationAddsEllipseRotation() throws { + XCTAssertEqual( + try transform("M0 0 A10 5 20 0 1 10 0", by: "rotate(40)"), + "M0 0 A10 5 60 0 1 7.660444 6.427876" + ) + } + + func testArcUnderNonSimilarityIsRejected() throws { + // Неравномерный масштаб превращает окружность в эллипс с другими радиусами - + // такое молча пересчитывать нельзя. + XCTAssertThrowsError(try transform("M0 0 A5 5 0 1 0 10 0", by: "scale(1 2)")) { error in + guard case SVGParserError.unsupportedArcTransform = error else { + return XCTFail("Ожидалась ошибка unsupportedArcTransform, получено \(error)") + } + } + } + + func testNonSimilarityIsAllowedWithoutArcs() throws { + XCTAssertEqual( + try transform("M1 1 L2 2", by: "scale(1 2)"), + "M1 2 L2 4" + ) + } + + func testTrailingGarbageIsRejected() throws { + // Раньше неразобранный хвост молча отбрасывался вместе с частью рисунка. + XCTAssertThrowsError(try transform("M0 0 L10 10 ?!", by: .identity)) + } + + func testUnknownCommandIsRejected() throws { + XCTAssertThrowsError(try transform("M0 0 X10 10", by: .identity)) + } + + func testMissingParametersAreRejected() throws { + XCTAssertThrowsError(try transform("M0 0 L10", by: .identity)) + } + + func testEmptyPathDataIsRejected() throws { + XCTAssertThrowsError(try transform("", by: .identity)) + } +} +#endif diff --git a/Tests/FigmaGenTests/SVGTransformParserTests.swift b/Tests/FigmaGenTests/SVGTransformParserTests.swift new file mode 100644 index 0000000..bf634c8 --- /dev/null +++ b/Tests/FigmaGenTests/SVGTransformParserTests.swift @@ -0,0 +1,102 @@ +#if canImport(FigmaGen) +import XCTest +@testable import FigmaGen + +final class SVGTransformParserTests: XCTestCase { + + // MARK: - Instance Methods + + private func assertEqual( + _ transform: SVGTransform, + _ expected: SVGTransform, + line: UInt = #line + ) { + let components = [ + (transform.a, expected.a), + (transform.b, expected.b), + (transform.c, expected.c), + (transform.d, expected.d), + (transform.e, expected.e), + (transform.f, expected.f) + ] + + components.forEach { actual, expected in + XCTAssertEqual(actual, expected, accuracy: 1e-9, line: line) + } + } + + // MARK: - + + func testEmptyValueIsIdentity() throws { + assertEqual(try SVGTransformParser.transform(from: ""), .identity) + assertEqual(try SVGTransformParser.transform(from: []), .identity) + } + + func testMatrix() throws { + assertEqual( + try SVGTransformParser.transform(from: "matrix(1 2 3 4 5 6)"), + SVGTransform(a: 1.0, b: 2.0, c: 3.0, d: 4.0, e: 5.0, f: 6.0) + ) + } + + func testTranslateWithOmittedY() throws { + assertEqual(try SVGTransformParser.transform(from: "translate(5)"), .translation(x: 5.0, y: 0.0)) + } + + func testScaleWithOmittedYIsUniform() throws { + assertEqual(try SVGTransformParser.transform(from: "scale(3)"), .scale(x: 3.0, y: 3.0)) + } + + func testCommaAndWhitespaceSeparators() throws { + assertEqual( + try SVGTransformParser.transform(from: "translate( 1 , 2 )"), + .translation(x: 1.0, y: 2.0) + ) + } + + func testRotateAroundCenter() throws { + // Поворот на 180° вокруг (10, 0) переводит начало координат в (20, 0). + let transform = try SVGTransformParser.transform(from: "rotate(180 10 0)") + let point = transform.apply(x: 0.0, y: 0.0) + + XCTAssertEqual(point.x, 20.0, accuracy: 1e-9) + XCTAssertEqual(point.y, 0.0, accuracy: 1e-9) + } + + func testTransformListIsAppliedOuterToInner() throws { + // Списку "translate scale" соответствует произведение в том же порядке. + assertEqual( + try SVGTransformParser.transform(from: "translate(10 0) scale(2)"), + SVGTransform(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 10.0, f: 0.0) + ) + } + + func testGroupChainIsAppliedOuterToInner() throws { + assertEqual( + try SVGTransformParser.transform(from: ["translate(10 0)", "scale(2)"]), + SVGTransform(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 10.0, f: 0.0) + ) + } + + func testSimilarityDetection() throws { + XCTAssertTrue(try SVGTransformParser.transform(from: "rotate(30) scale(2)").isSimilarity) + XCTAssertTrue(try SVGTransformParser.transform(from: "scale(-2 2)").isSimilarity) + XCTAssertFalse(try SVGTransformParser.transform(from: "scale(1 2)").isSimilarity) + XCTAssertFalse(try SVGTransformParser.transform(from: "skewX(20)").isSimilarity) + } + + func testUnknownFunctionIsRejected() throws { + XCTAssertThrowsError(try SVGTransformParser.transform(from: "warp(1 2)")) + } + + func testWrongParameterCountIsRejected() throws { + XCTAssertThrowsError(try SVGTransformParser.transform(from: "matrix(1 2 3)")) + XCTAssertThrowsError(try SVGTransformParser.transform(from: "rotate(1 2)")) + } + + func testMalformedValueIsRejected() throws { + XCTAssertThrowsError(try SVGTransformParser.transform(from: "translate(1 2")) + XCTAssertThrowsError(try SVGTransformParser.transform(from: "translate(1 2) garbage")) + } +} +#endif