From fd8b256fcee9ffa29b648682869c44ff61dad889 Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Fri, 4 Sep 2026 13:49:09 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20symbolLayersName=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=D0=BC=D0=B5=D0=BD=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=91=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/FigmaGen/Commands/ImagesCommand.swift | 11 +++++- .../Images/DefaultImagesGenerator.swift | 3 +- .../Configuration/ImagesConfiguration.swift | 10 ++++-- .../Models/Parameters/ImagesParameters.swift | 1 + .../Assets/DefaultImageAssetsProvider.swift | 2 +- .../DefaultSFSymbolProvider.swift | 35 ++++++++++++++----- .../SFSymbolProvider/SFSymbolProvider.swift | 2 +- 7 files changed, 50 insertions(+), 14 deletions(-) diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index 7f3fb5a..5fe8670 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -209,6 +209,14 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand """ ) + let symbolLayersName = Key( + "--symbolLayersName", + description: """ + Names for primary, secondary and tertiary layers separated by comma. + By default use black color for primary and other - for secondary, tertiary ignored. + """ + ) + let sfSymbolTemplate = Key( "--sfSymbolTemplate", description: """ @@ -312,7 +320,8 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand namingStyle: resolveNamingStyle(), sfSymbolKey: sfSymbolKey.value, symbolRenderAs: resolveSymbolRenderAs(), - sfSymbolTemplate: sfSymbolTemplate.value + sfSymbolTemplate: sfSymbolTemplate.value, + symbolLayersName: symbolLayersName.value ) } diff --git a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift index b46dc91..417b23c 100644 --- a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift +++ b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift @@ -84,7 +84,8 @@ extension ImagesConfiguration { namingStyle: namingStyle, sfSymbolKey: sfSymbolKey, symbolRenderAs: symbolRenderAs, - sfSymbolTemplate: sfSymbolTemplate + sfSymbolTemplate: sfSymbolTemplate, + symbolLayersName: symbolLayersName ) } } diff --git a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift index 5aba3fc..50b6f2d 100644 --- a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift +++ b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift @@ -21,6 +21,7 @@ struct ImagesConfiguration: Decodable { case sfSymbolKey case symbolRenderAs case sfSymbolTemplate + case symbolLayersName } // MARK: - Instance Properties @@ -42,6 +43,7 @@ struct ImagesConfiguration: Decodable { let sfSymbolKey: String? let symbolRenderAs: SymbolRenderingMode? let sfSymbolTemplate: String? + let symbolLayersName: String? // MARK: - Initializers @@ -62,7 +64,8 @@ struct ImagesConfiguration: Decodable { namingStyle: ImageNamingStyle, sfSymbolKey: String?, symbolRenderAs: SymbolRenderingMode?, - sfSymbolTemplate: String? + sfSymbolTemplate: String?, + symbolLayersName: String? ) { self.generatation = generatation self.assets = assets @@ -81,6 +84,7 @@ struct ImagesConfiguration: Decodable { self.sfSymbolKey = sfSymbolKey self.symbolRenderAs = symbolRenderAs self.sfSymbolTemplate = sfSymbolTemplate + self.symbolLayersName = symbolLayersName } init(from decoder: Decoder) throws { @@ -103,6 +107,7 @@ struct ImagesConfiguration: Decodable { sfSymbolKey = try container.decodeIfPresent(forKey: .sfSymbolKey) symbolRenderAs = try container.decodeIfPresent(forKey: .symbolRenderAs) sfSymbolTemplate = try container.decodeIfPresent(forKey: .sfSymbolTemplate) + symbolLayersName = try container.decodeIfPresent(forKey: .symbolLayersName) generatation = try GenerationConfiguration(from: decoder) } @@ -127,7 +132,8 @@ struct ImagesConfiguration: Decodable { namingStyle: namingStyle, sfSymbolKey: sfSymbolKey, symbolRenderAs: symbolRenderAs, - sfSymbolTemplate: sfSymbolTemplate + sfSymbolTemplate: sfSymbolTemplate, + symbolLayersName: symbolLayersName ) } } diff --git a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift index 6fcc5b8..65db79d 100644 --- a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift +++ b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift @@ -20,4 +20,5 @@ struct ImagesParameters { let sfSymbolKey: String? let symbolRenderAs: SymbolRenderingMode? let sfSymbolTemplate: String? + let symbolLayersName: String? } diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index 4a3d2b4..d8a4173 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -188,7 +188,7 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes self.sfSymbolProvider.saveData( from: url, to: $0, - template: parameters.sfSymbolTemplate + parameters: parameters ) } } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift index ef9d876..9fe3f71 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift @@ -11,6 +11,7 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { private let svgParser: SVGParser private let dataCache = Cache() + private var symbolLayersName = [String]() init(svgParser: SVGParser, templateRenderer: TemplateRenderer) { self.svgParser = svgParser @@ -19,11 +20,20 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { // MARK: - Instance Methods - func saveData(from url: URL, to filePath: String, template: String?) -> Promise { + func saveData( + from url: URL, + to filePath: String, + parameters: ImagesParameters + ) -> Promise { firstly { self.fetchData(from: url) }.map(on: DispatchQueue.global(qos: .userInitiated)) { fileData in - guard let template else { + self.symbolLayersName = parameters + .symbolLayersName? + .replacingOccurrences(of: " ", with: "") + .components(separatedBy: ",") ?? [] + + guard let template = parameters.sfSymbolTemplate else { return } @@ -64,7 +74,7 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { return role } - return path.role + return path.role(symbolLayersName: symbolLayersName) } private func extractPaths(from result: SVGPathsResult) throws -> SVGImageToken { @@ -189,17 +199,26 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { extension SVGPath { - var role: SFSymbolRole { - // TODO: @d.viter тут было еще wholeID.contains("icon") - if id == "primary" || fill == .black { + func role(symbolLayersName: [String]) -> SFSymbolRole { + let primaryLayerName: String = symbolLayersName.first ?? "" + let secondaryLayerName: String = symbolLayersName.dropFirst().first ?? "" + let tertiaryLayerName: String = symbolLayersName.dropFirst(2).first ?? "" + + if + id == "secondary" + || symbolLayersName.isEmpty && fill == .black + || wholeID.contains(primaryLayerName) { return .primary } - if id == "secondary" || fill != .black || wholeID.contains("detail-a") { + if + id == "secondary" + || symbolLayersName.isEmpty && fill != .black + || wholeID.contains(secondaryLayerName) { return .secondary } - if id == "tertiary" || wholeID.contains("detail-b") { + if id == "tertiary" || wholeID.contains(tertiaryLayerName) { return .tertiary } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift index 7a3e877..a5e7d16 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift @@ -6,6 +6,6 @@ protocol SFSymbolProvider { func saveData( from url: URL, to filePath: String, - template: String? + parameters: ImagesParameters ) -> Promise } From 9030a4797894c6027bbe2f6066c8ee31037221da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BB=D1=8C=D1=86=D0=B5=D0=B2=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4?= Date: Fri, 4 Sep 2026 15:07:24 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20/critics=5Frewirte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/FigmaGen/Commands/ImagesCommand.swift | 7 +- Sources/FigmaGen/Dependencies.swift | 1 + .../FigmaGen/Models/Images/ImageNode.swift | 6 + .../FigmaGen/Models/SFSymbol/SFSymbol.swift | 10 + .../Models/SFSymbol/SFSymbolLayer.swift | 3 +- .../Models/SFSymbol/SFSymbolPath.swift | 7 + .../Models/SFSymbol/SFSymbolPathData.swift | 8 - .../Models/SFSymbol/SFSymbolRole.swift | 18 +- Sources/FigmaGen/Models/SVG/SVGDocument.swift | 7 + .../FigmaGen/Models/SVG/SVGGroupContext.swift | 8 - .../FigmaGen/Models/SVG/SVGImageToken.swift | 11 - Sources/FigmaGen/Models/SVG/SVGPath.swift | 46 +--- .../FigmaGen/Models/SVG/SVGPathsResult.swift | 14 -- .../Assets/DefaultImageAssetsProvider.swift | 57 +++-- .../Images/DefaultImagesProvider.swift | 82 ++++--- .../DefaultSFSymbolProvider.swift | 188 ++++++---------- .../SFSymbolProvider/DefaultSVGParser.swift | 207 ++++++++---------- .../SFSymbolProvider/SFSymbolProvider.swift | 2 +- .../SFSymbolProvider/SVGParser.swift | 2 +- .../SFSymbolProvider/SVGParserError.swift | 4 - .../SFSymbolProvider/SVGPathTransformer.swift | 1 - .../FigmaGenTests/DefaultSVGParserTests.swift | 50 +++-- Tests/FigmaGenTests/SFSymbolRoleTests.swift | 47 ++++ .../SVGPathTransformerTests.swift | 1 - 24 files changed, 372 insertions(+), 415 deletions(-) create mode 100644 Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift create mode 100644 Sources/FigmaGen/Models/SFSymbol/SFSymbolPath.swift delete mode 100644 Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift create mode 100644 Sources/FigmaGen/Models/SVG/SVGDocument.swift delete mode 100644 Sources/FigmaGen/Models/SVG/SVGGroupContext.swift delete mode 100644 Sources/FigmaGen/Models/SVG/SVGImageToken.swift delete mode 100644 Sources/FigmaGen/Models/SVG/SVGPathsResult.swift create mode 100644 Tests/FigmaGenTests/SFSymbolRoleTests.swift diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index 5fe8670..db9341a 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -196,8 +196,9 @@ 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. + Name of the Figma property that marks SF Symbols: components named with '=true' + are rendered as SVG and saved to .symbolset instead of .imageset. + By default, no SF Symbols are generated. """ ) @@ -281,7 +282,7 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand case let rawSymbolRenderingMode?: guard let mode = SymbolRenderingMode(rawValue: rawSymbolRenderingMode) else { - fail(message: "Failed to generate symbols: Invalid rendering mode (\(rawSymbolRenderingMode)") + fail(message: "Failed to generate symbols: Invalid rendering mode (\(rawSymbolRenderingMode))") } return mode diff --git a/Sources/FigmaGen/Dependencies.swift b/Sources/FigmaGen/Dependencies.swift index b2bca7b..b68dcec 100644 --- a/Sources/FigmaGen/Dependencies.swift +++ b/Sources/FigmaGen/Dependencies.swift @@ -8,6 +8,7 @@ enum Dependencies { static let dataProvider: DataProvider = DefaultDataProvider() static let svgParser: SVGParser = DefaultSVGParser() static let sfSymbolProvider: SFSymbolProvider = DefaultSFSymbolProvider( + dataProvider: dataProvider, svgParser: svgParser, templateRenderer: templateRenderer ) diff --git a/Sources/FigmaGen/Models/Images/ImageNode.swift b/Sources/FigmaGen/Models/Images/ImageNode.swift index 74dcc5e..b94b2ee 100644 --- a/Sources/FigmaGen/Models/Images/ImageNode.swift +++ b/Sources/FigmaGen/Models/Images/ImageNode.swift @@ -7,4 +7,10 @@ struct ImageNode: Encodable, Hashable { let id: String let name: String let description: String? + + // MARK: - Instance Methods + + func isSFSymbol(key: String?) -> Bool { + key.map { name.contains("\($0)=true") } ?? false + } } diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift new file mode 100644 index 0000000..aaa0572 --- /dev/null +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift @@ -0,0 +1,10 @@ +import Foundation + +struct SFSymbol { + + let name: String + let opticalSize: Int + let designWidth: Double + let designHeight: Double + let layers: [SFSymbolLayer] +} diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift index 37ba008..59412a5 100644 --- a/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolLayer.swift @@ -2,7 +2,6 @@ import Foundation struct SFSymbolLayer { - let index: Int let role: SFSymbolRole - let paths: [SFSymbolPathData] + let paths: [SFSymbolPath] } diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolPath.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolPath.swift new file mode 100644 index 0000000..18a45e4 --- /dev/null +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolPath.swift @@ -0,0 +1,7 @@ +import Foundation + +struct SFSymbolPath { + + let data: String + let fillRule: String? +} diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift deleted file mode 100644 index 7afd3bb..0000000 --- a/Sources/FigmaGen/Models/SFSymbol/SFSymbolPathData.swift +++ /dev/null @@ -1,8 +0,0 @@ -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 index cfb950c..1a784e4 100644 --- a/Sources/FigmaGen/Models/SFSymbol/SFSymbolRole.swift +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbolRole.swift @@ -1,10 +1,24 @@ import Foundation -/// Роль пути в палитре слоёв. Определяется по id пути, а если его нет - по заливке: -/// чёрная заливка из Figma даёт primary, любая другая - secondary. enum SFSymbolRole: String { case primary case secondary case tertiary + + // MARK: - Initializers + + init(of path: SVGPath, layerNames: [String]) { + if let ownRole = path.id.flatMap({ Self(rawValue: $0.lowercased()) }) { + self = ownRole + } else if layerNames.isEmpty { + self = path.fill == .black ? .primary : .secondary + } else if path.idChain.contains(layerNames[0]) { + self = .primary + } else if layerNames.count > 1, path.idChain.contains(layerNames[1]) { + self = .secondary + } else { + self = .tertiary + } + } } diff --git a/Sources/FigmaGen/Models/SVG/SVGDocument.swift b/Sources/FigmaGen/Models/SVG/SVGDocument.swift new file mode 100644 index 0000000..9b66d28 --- /dev/null +++ b/Sources/FigmaGen/Models/SVG/SVGDocument.swift @@ -0,0 +1,7 @@ +import Foundation + +struct SVGDocument { + + let canvas: SVGCanvas? + let paths: [SVGPath] +} diff --git a/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift b/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift deleted file mode 100644 index dd479e1..0000000 --- a/Sources/FigmaGen/Models/SVG/SVGGroupContext.swift +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 55f329a..0000000 --- a/Sources/FigmaGen/Models/SVG/SVGImageToken.swift +++ /dev/null @@ -1,11 +0,0 @@ -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/SVGPath.swift b/Sources/FigmaGen/Models/SVG/SVGPath.swift index 9c20604..f42a6d1 100644 --- a/Sources/FigmaGen/Models/SVG/SVGPath.swift +++ b/Sources/FigmaGen/Models/SVG/SVGPath.swift @@ -2,43 +2,21 @@ 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: ";") - } + // MARK: - Instance Properties - var data: String? { - attributes["d"] - } - - var fillRule: String? { - attributes["fill-rule"] - } + let id: String? + let data: String? + let fillRule: String? + let fill: SVGColor? + let transform: String? + let groupIDs: [String] + let groupTransforms: [String] - var allTransforms: [String] { - inheritedTransforms + [attributes["transform"]].compactMap { $0 } + var idChain: String { + (groupIDs + [id].compactMap { $0 }).joined(separator: ";") } - // MARK: - Initializers - - init( - attributes: [String: String], - groupIDs: [String], - inheritedTransforms: [String], - fill: SVGColor? - ) { - self.attributes = attributes - self.groupIDs = groupIDs - self.inheritedTransforms = inheritedTransforms - self.fill = fill + var transformChain: [String] { + groupTransforms + [transform].compactMap { $0 } } } diff --git a/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift b/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift deleted file mode 100644 index 5763598..0000000 --- a/Sources/FigmaGen/Models/SVG/SVGPathsResult.swift +++ /dev/null @@ -1,14 +0,0 @@ -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/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index d8a4173..c57d43d 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -33,14 +33,12 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes ) -> String { var name = setNode.type == .component ? node.base.name : "\(setNode.name) \(node.base.name)" - if - let sfSymbolKey, - !sfSymbolKey.isEmpty, - name.lowercased().contains(sfSymbolKey.lowercased()) { + if let sfSymbolKey, !sfSymbolKey.isEmpty { name = name .replacingOccurrences(of: "\(sfSymbolKey)=false", with: "") - .replacingOccurrences(of: "\(sfSymbolKey)=true", with: "\(sfSymbolKey)") + .replacingOccurrences(of: "\(sfSymbolKey)=true", with: sfSymbolKey) } + switch namingStyle { case .camelCase: return name.camelized @@ -70,24 +68,27 @@ 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[isSymbol ? .none : scale] = folderPath - .appending(fileName: name, extension: assetSetExtension) - .appending(fileName: name.appending(scale.fileNameSuffix), extension: assetExtension) - .string + let isSymbol = node.base.isSFSymbol(key: parameters.sfSymbolKey) + + let filePaths: [ImageScale: String] + + if isSymbol { + filePaths = [ + .none: folderPath + .appending(fileName: name, extension: AssetSymbolSet.pathExtension) + .appending(fileName: name, extension: ImageFormat.svg.fileExtension) + .string + ] + } else { + 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 + ) + .string + } } return ImageAsset( @@ -96,7 +97,7 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes preserveVectorData: parameters.preserveVectorData, renderAs: parameters.renderAs, isSymbol: isSymbol, - symbolRenderAs: symbolRenderingMode + symbolRenderAs: isSymbol ? parameters.symbolRenderAs : nil ) } @@ -184,12 +185,8 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes parameters: ImagesParameters ) -> Promise { let promises = node.urls.compactMap { scale, url in - asset.filePaths[scale].map { - self.sfSymbolProvider.saveData( - from: url, - to: $0, - parameters: parameters - ) + asset.filePaths[scale].map { filePath in + self.sfSymbolProvider.saveSymbol(from: url, to: filePath, parameters: parameters) } } diff --git a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift index 224ee17..390349c 100644 --- a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift +++ b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift @@ -228,68 +228,62 @@ final class DefaultImagesProvider: ImagesProvider { ) } }.then { nodes in - 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.renderImagesAndSymbols(of: file, nodes: nodes, parameters: parameters) + }.then { renderedNodes in self.saveAssetImagesIfNeeded( - nodes: nodes, + nodes: renderedNodes, parameters: parameters ) } } + + private func renderImagesAndSymbols( + of file: FileParameters, + nodes: [ImageComponentSetNode], + parameters: ImagesParameters + ) -> Promise<[ImageComponentSetRenderedNode]> { + let imageNodes = nodes.filteringComponents { !$0.isSFSymbol(key: parameters.sfSymbolKey) } + let symbolNodes = nodes.filteringComponents { $0.isSFSymbol(key: parameters.sfSymbolKey) } + + return when( + fulfilled: imageRenderProvider.renderImages( + of: file, + nodes: imageNodes, + format: parameters.format, + scales: parameters.scales, + useAbsoluteBounds: parameters.useAbsoluteBounds + ), + imageRenderProvider.renderImages( + of: file, + nodes: symbolNodes, + format: .svg, + scales: [.none], + useAbsoluteBounds: parameters.useAbsoluteBounds + ) + ).map { renderedImages, renderedSymbols in + renderedImages + renderedSymbols + } + } } extension Array where Element == ImageComponentSetNode { - func getImagesWithoutSymbols(by sfSymbolKey: String?) -> [ImageComponentSetNode] { - guard let sfSymbolKey else { - return self - } + func filteringComponents(_ isIncluded: (ImageNode) -> Bool) -> [ImageComponentSetNode] { + compactMap { node in + let components = node.components.filter(isIncluded) - return compactMap { node in - let images = node.components.filter({ !$0.name.contains("\(sfSymbolKey)=true") }) - guard !images.isEmpty else { + guard !components.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 + guard node.type == .componentSet else { + return node } return ImageComponentSetNode( name: node.name, parentName: node.parentName, - components: symbols + components: components ) } } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift index 9fe3f71..5d8a70b 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift @@ -7,33 +7,29 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { // MARK: - Instance Properties + private let dataProvider: DataProvider + private let svgParser: SVGParser private let templateRenderer: TemplateRenderer - private let svgParser: SVGParser - private let dataCache = Cache() - private var symbolLayersName = [String]() + // MARK: - Initializers - init(svgParser: SVGParser, templateRenderer: TemplateRenderer) { + init(dataProvider: DataProvider, svgParser: SVGParser, templateRenderer: TemplateRenderer) { + self.dataProvider = dataProvider self.svgParser = svgParser self.templateRenderer = templateRenderer } // MARK: - Instance Methods - func saveData( + func saveSymbol( from url: URL, to filePath: String, parameters: ImagesParameters ) -> Promise { firstly { - self.fetchData(from: url) - }.map(on: DispatchQueue.global(qos: .userInitiated)) { fileData in - self.symbolLayersName = parameters - .symbolLayersName? - .replacingOccurrences(of: " ", with: "") - .components(separatedBy: ",") ?? [] - - guard let template = parameters.sfSymbolTemplate else { + self.dataProvider.fetchData(from: url) + }.map(on: DispatchQueue.global(qos: .userInitiated)) { svgData in + guard let templatePath = parameters.sfSymbolTemplate else { return } @@ -43,125 +39,110 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { try filePath.delete() } - let result = try self.svgParser.parse(id: filePath.string, data: fileData) + let layerNames = parameters.symbolLayersName? + .replacingOccurrences(of: " ", with: "") + .components(separatedBy: ",") ?? [] - let token = try self.extractPaths(from: result) + let symbol = try self.makeSymbol( + from: self.svgParser.parse(data: svgData), + filePath: filePath, + layerNames: layerNames + ) try self.templateRenderer.renderTemplate( - RenderTemplate(type: .custom(path: template), options: [:]), - to: RenderDestination.file(path: filePath.string), - context: self.makeContext(for: token) + RenderTemplate(type: .custom(path: templatePath), options: [:]), + to: .file(path: filePath.string), + context: self.makeContext(for: symbol) ) } } - // MARK: - Private methods + // MARK: - - 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(symbolLayersName: symbolLayersName) - } - - private func extractPaths(from result: SVGPathsResult) throws -> SVGImageToken { - guard let canvas = result.canvas, canvas.width > 0.0, canvas.height > 0.0 else { + private func makeSymbol(from document: SVGDocument, filePath: Path, layerNames: [String]) throws -> SFSymbol { + guard let canvas = document.canvas, canvas.width > 0.0, canvas.height > 0.0 else { throw SVGParserError.missingCanvasSize } - guard !result.allPaths.isEmpty else { + guard !document.paths.isEmpty else { throw SVGParserError.invalidSVG } // Бокс компонента всегда занимает весь em дизайн-бокса, поэтому символ рисуется ровно - // в том кегле, который передан в .font(.system(size:)). Оптический размер рисунка задаётся - // этим кеглем и не зашивается в геометрию. + // в том кегле, который передан в .font(.system(size:)). // Ширина сохраняет пропорции бокса компонента из Figma, чтобы заложенные дизайнером отступы // и неквадратные компоненты пережили конвертацию. Она округляется до целой дизайн-единицы, // потому что поля задают итоговый бокс символа, а дробное поле может расширить его на целую - // единицу. Квадратный компонент и так целый, а у неквадратного в худшем случае правое поле - // сдвинется на половину единицы, не задев сам рисунок. + // единицу. let designHeight = SFSymbolGeometry.emDesignHeight - let scale = designHeight / canvas.height - let designWidth = (canvas.width * scale).rounded() + let canvasScale = designHeight / canvas.height + let designWidth = (canvas.width * canvasScale).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 - ) + let canvasToTemplate = SVGTransform + .translation(x: 0.0, y: -SFSymbolGeometry.capHeight / 2.0 - designHeight / 2.0) + .concatenating(.scale(x: canvasScale, y: canvasScale)) - return SVGImageToken( - name: URL(fileURLWithPath: result.id).deletingPathExtension().lastPathComponent, + return SFSymbol( + name: filePath.lastComponentWithoutExtension, opticalSize: Int(canvas.height.rounded()), designWidth: designWidth, designHeight: designHeight, - layers: try makeLayers(from: result, baseTransform: baseTransform) + layers: try makeLayers( + from: document.paths, + canvasToTemplate: canvasToTemplate, + layerNames: layerNames, + filePath: filePath + ) ) } - /// Раскладывает пути по ролям, сохраняя порядок появления ролей в файле: - /// он определяет номера слоёв и 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)") + private func makeLayers( + from paths: [SVGPath], + canvasToTemplate: SVGTransform, + layerNames: [String], + filePath: Path + ) throws -> [SFSymbolLayer] { + var layerRoles: [SFSymbolRole] = [] + var pathsByRole: [SFSymbolRole: [SFSymbolPath]] = [:] + + for path in paths { + guard let pathData = path.data else { + throw SVGParserError.invalidPathData("for \(filePath.string)") } - let role = resolveRole(of: path) + let role = SFSymbolRole(of: path, layerNames: layerNames) - if !roles.contains(role) { - roles.append(role) + if !layerRoles.contains(role) { + layerRoles.append(role) } - // Собственный и унаследованный transform пути применяются к его координатам - // до перевода в пространство шаблона, поэтому базовое преобразование идёт первым. - let transformer = SVGPathTransformer( - transform: baseTransform.concatenating(try SVGTransformParser.transform(from: path.allTransforms)) + let pathTransform = canvasToTemplate.concatenating( + try SVGTransformParser.transform(from: path.transformChain) ) pathsByRole[role, default: []].append( - SFSymbolPathData( - data: try transformer.transform(pathData: data), + SFSymbolPath( + data: try SVGPathTransformer(transform: pathTransform).transform(pathData: pathData), fillRule: path.fillRule ) ) } - return roles.enumerated().map { index, role in - SFSymbolLayer(index: index, role: role, paths: pathsByRole[role] ?? []) + return layerRoles.map { role in + SFSymbolLayer(role: role, paths: pathsByRole[role] ?? []) } } - private func makeContext(for token: SVGImageToken) -> [String: Any] { - let layers = token.layers.map { layer -> [String: Any] in + private func makeContext(for symbol: SFSymbol) -> [String: Any] { + let layers = symbol.layers.enumerated().map { index, layer -> [String: Any] in [ - "index": layer.index, + "index": index, "role": layer.role.rawValue, // Motion-группы нумеруются от самого верхнего слоя - так их выгружает Xcode. - "motionGroup": token.layers.count - 1 - layer.index, + "motionGroup": symbol.layers.count - 1 - index, "paths": layer.paths.map { path in ["data": path.data, "fillRule": path.fillRule ?? ""] } @@ -172,14 +153,14 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { SFSymbolGeometry.weights.map { weight -> [String: Any] in // Округляется, чтобы оба поля попали на целые дизайн-единицы: центр колонки дробный, // и точное центрирование поставило бы поля на доли единицы. - let originX = (weight.centerX - token.designWidth / 2.0).rounded() + let originX = (weight.centerX - symbol.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), + "rightMargin": SVGNumber.string(from: originX + symbol.designWidth), "guideTop": SVGNumber.string(from: scale.baseline - SFSymbolGeometry.marginGuideTopOffset), "guideBottom": SVGNumber.string(from: scale.baseline + SFSymbolGeometry.marginGuideBottomOffset) ] @@ -187,41 +168,12 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { } return [ - "name": token.name, - "opticalSize": token.opticalSize, - "designWidth": SVGNumber.string(from: token.designWidth), - "designHeight": SVGNumber.string(from: token.designHeight), + "name": symbol.name, + "opticalSize": symbol.opticalSize, + "designWidth": SVGNumber.string(from: symbol.designWidth), + "designHeight": SVGNumber.string(from: symbol.designHeight), "layers": layers, "variants": variants ] } } - -extension SVGPath { - - func role(symbolLayersName: [String]) -> SFSymbolRole { - let primaryLayerName: String = symbolLayersName.first ?? "" - let secondaryLayerName: String = symbolLayersName.dropFirst().first ?? "" - let tertiaryLayerName: String = symbolLayersName.dropFirst(2).first ?? "" - - if - id == "secondary" - || symbolLayersName.isEmpty && fill == .black - || wholeID.contains(primaryLayerName) { - return .primary - } - - if - id == "secondary" - || symbolLayersName.isEmpty && fill != .black - || wholeID.contains(secondaryLayerName) { - return .secondary - } - - if id == "tertiary" || wholeID.contains(tertiaryLayerName) { - return .tertiary - } - - return .tertiary - } -} diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift index a0cb734..5b2ca89 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSVGParser.swift @@ -1,80 +1,78 @@ import Foundation -final class DefaultSVGParser: NSObject, SVGParser { +final class DefaultSVGParser: SVGParser { + + // MARK: - Instance Methods + + func parse(data: Data) throws -> SVGDocument { + let parser = XMLParser(data: data) + let documentBuilder = SVGDocumentBuilder() + + parser.delegate = documentBuilder + parser.shouldResolveExternalEntities = false + parser.shouldProcessNamespaces = false + + guard parser.parse() else { + throw parser.parserError ?? SVGParserError.invalidXML + } + + return SVGDocument(canvas: documentBuilder.canvas, paths: documentBuilder.paths) + } +} + +private final class SVGDocumentBuilder: NSObject, XMLParserDelegate { + + // MARK: - Nested Types private enum ElementName { + static let svg = "svg" static let group = "g" static let path = "path" - static let svg = "svg" - // Элементы, чьи пути описывают только переиспользуемые определения - // и никогда не участвуют в видимом рисунке. - static let ignored: Set = ["clipPath", "defs", "mask", "pattern", "symbol"] + static let definitions: Set = ["clipPath", "defs", "mask", "pattern", "symbol"] } - private enum Attributes { + private enum AttributeName { - static let fill = "fill" - static let height = "height" static let id = "id" + static let data = "d" + static let fill = "fill" + static let fillRule = "fill-rule" static let style = "style" static let transform = "transform" - static let viewBox = "viewBox" static let width = "width" + static let height = "height" + static let viewBox = "viewBox" } - 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 - } + private struct Group { - return SVGPathsResult( - id: id, - canvas: canvas, - allPaths: parsedPaths - ) + let id: String? + let fill: SVGColor? + let transform: String? } - private func startGroup(attributes attributeDict: [String: String]) { - // Заливка наследуется от ближайшей родительской группы, если у самой группы её нет. - let effectiveFill = fillColor(from: attributeDict) ?? groupStack.last?.fill + // MARK: - Instance Properties - let context = SVGGroupContext( - id: attributeDict[Attributes.id], - fill: effectiveFill, - transform: attributeDict[Attributes.transform] - ) + private(set) var canvas: SVGCanvas? + private(set) var paths: [SVGPath] = [] - groupStack.append(context) - } + private var openGroups: [Group] = [] + private var definitionsDepth = 0 + + // MARK: - Instance Methods private func makeCanvas(from attributes: [String: String]) -> SVGCanvas? { if - let width = length(from: attributes[Attributes.width]), - let height = length(from: attributes[Attributes.height]), + let width = length(from: attributes[AttributeName.width]), + let height = length(from: attributes[AttributeName.height]), width > 0.0, height > 0.0 { return SVGCanvas(width: width, height: height) } - let viewBox = attributes[Attributes.viewBox]? + let viewBox = attributes[AttributeName.viewBox]? .split { $0 == " " || $0 == "," } .compactMap { Double($0) } @@ -92,16 +90,32 @@ final class DefaultSVGParser: NSObject, SVGParser { .flatMap { Double($0) } } + private func makeGroup(from attributes: [String: String]) -> Group { + Group( + id: attributes[AttributeName.id], + fill: fillColor(from: attributes) ?? openGroups.last?.fill, + transform: attributes[AttributeName.transform] + ) + } + + private func makePath(from attributes: [String: String]) -> SVGPath { + SVGPath( + id: attributes[AttributeName.id], + data: attributes[AttributeName.data], + fillRule: attributes[AttributeName.fillRule], + fill: fillColor(from: attributes) ?? openGroups.last?.fill, + transform: attributes[AttributeName.transform], + groupIDs: openGroups.compactMap(\.id), + groupTransforms: openGroups.compactMap(\.transform) + ) + } + private func fillColor(from attributes: [String: String]) -> SVGColor? { - guard let value = fillValue(from: attributes) else { + guard let value = attributes[AttributeName.fill] ?? styleFill(from: attributes) else { return nil } - let normalized = value - .lowercased() - .replacingOccurrences(of: " ", with: "") - - switch normalized { + switch value.lowercased().replacingOccurrences(of: " ", with: "") { case "black", "#000", "#000000", "rgb(0,0,0)": return .black @@ -110,48 +124,25 @@ final class DefaultSVGParser: NSObject, SVGParser { } } - private func fillValue( - from attributes: [String: String] - ) -> String? { - if let fill = attributes[Attributes.fill] { - return fill - } - - guard let style = attributes[Attributes.style] else { + private func styleFill(from attributes: [String: String]) -> String? { + guard let style = attributes[AttributeName.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) + for declaration in style.split(separator: ";") { + let nameAndValue = declaration + .split(separator: ":", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard name == Attributes.fill else { - continue + if nameAndValue.count == 2, nameAndValue[0].lowercased() == AttributeName.fill { + return nameAndValue[1] } - - return value } return nil } -} -extension DefaultSVGParser: XMLParserDelegate { + // MARK: - XMLParserDelegate func parser( _ parser: XMLParser, @@ -160,37 +151,23 @@ extension DefaultSVGParser: XMLParserDelegate { qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] ) { - if ElementName.ignored.contains(elementName) { - ignoredElementDepth += 1 + if ElementName.definitions.contains(elementName) { + definitionsDepth += 1 } - if elementName == ElementName.svg, canvas == nil { + switch elementName { + case ElementName.svg where 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) + case ElementName.group: + openGroups.append(makeGroup(from: attributeDict)) - // Преобразования собираются от внешней группы к внутренней - в том порядке, - // в каком их применяет SVG. Собственный transform пути добавляет `SVGPath.allTransforms`. - let transforms = groupStack.compactMap(\.transform) - - let path = SVGPath( - attributes: attributeDict, - groupIDs: groupIDs, - inheritedTransforms: transforms, - fill: inheritedFill - ) + case ElementName.path where definitionsDepth == 0: + paths.append(makePath(from: attributeDict)) - parsedPaths.append(path) + default: + break + } } func parser( @@ -199,14 +176,12 @@ extension DefaultSVGParser: XMLParserDelegate { namespaceURI: String?, qualifiedName qName: String? ) { - if ElementName.ignored.contains(elementName) { - ignoredElementDepth = max(ignoredElementDepth - 1, 0) + if ElementName.definitions.contains(elementName) { + definitionsDepth = max(definitionsDepth - 1, 0) } - guard elementName == ElementName.group else { - return + if elementName == ElementName.group { + _ = openGroups.popLast() } - - _ = groupStack.popLast() } } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift index a5e7d16..1d53afc 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift @@ -3,7 +3,7 @@ import PromiseKit protocol SFSymbolProvider { - func saveData( + func saveSymbol( from url: URL, to filePath: String, parameters: ImagesParameters diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift index 0a5e235..7af478b 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParser.swift @@ -2,5 +2,5 @@ import Foundation protocol SVGParser { - func parse(id: String, data: Data) throws -> SVGPathsResult + func parse(data: Data) throws -> SVGDocument } diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift index f657150..1851b48 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGParserError.swift @@ -8,7 +8,6 @@ enum SVGParserError: LocalizedError { case invalidPathData(String) case invalidTransform(String) case unsupportedArcTransform(String) - case unsupportedFill(String) var errorDescription: String? { switch self { @@ -32,9 +31,6 @@ enum SVGParserError: LocalizedError { 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 index 1b7f60e..337b484 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/SVGPathTransformer.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/SVGPathTransformer.swift @@ -67,7 +67,6 @@ struct SVGPathTransformer { ) } - // Хвост, который не удалось разобрать, раньше молча отбрасывался вместе с частью рисунка. guard !commands.isEmpty, reader.isAtEnd() else { throw SVGParserError.invalidPathData(pathData) } diff --git a/Tests/FigmaGenTests/DefaultSVGParserTests.swift b/Tests/FigmaGenTests/DefaultSVGParserTests.swift index 1087865..7fbbdc2 100644 --- a/Tests/FigmaGenTests/DefaultSVGParserTests.swift +++ b/Tests/FigmaGenTests/DefaultSVGParserTests.swift @@ -10,14 +10,14 @@ final class DefaultSVGParserTests: XCTestCase { // MARK: - Instance Methods - private func parse(_ svg: String) throws -> SVGPathsResult { - try parser.parse(id: "icon.svg", data: Data(svg.utf8)) + private func parse(_ svg: String) throws -> SVGDocument { + try parser.parse(data: Data(svg.utf8)) } // MARK: - func testGroupTransformsAreCollectedOuterToInner() throws { - let result = try parse( + let document = try parse( """ @@ -30,13 +30,13 @@ final class DefaultSVGParserTests: XCTestCase { ) XCTAssertEqual( - result.allPaths.first?.allTransforms, + document.paths.first?.transformChain, ["translate(1 2)", "scale(2)", "rotate(90)"] ) } func testGroupTransformReachesPathData() throws { - let result = try parse( + let document = try parse( """ @@ -44,16 +44,16 @@ final class DefaultSVGParserTests: XCTestCase { """ ) - let path = try XCTUnwrap(result.allPaths.first) + let path = try XCTUnwrap(document.paths.first) let transformer = SVGPathTransformer( - transform: try SVGTransformParser.transform(from: path.allTransforms) + transform: try SVGTransformParser.transform(from: path.transformChain) ) XCTAssertEqual(try transformer.transform(pathData: XCTUnwrap(path.data)), "M10 20 L11 20") } func testGroupWithoutTransformIsSkipped() throws { - let result = try parse( + let document = try parse( """ @@ -61,11 +61,11 @@ final class DefaultSVGParserTests: XCTestCase { """ ) - XCTAssertEqual(result.allPaths.first?.allTransforms, ["translate(3 4)"]) + XCTAssertEqual(document.paths.first?.transformChain, ["translate(3 4)"]) } func testTransformsAreNotLeakedToSiblingGroups() throws { - let result = try parse( + let document = try parse( """ @@ -74,13 +74,29 @@ final class DefaultSVGParserTests: XCTestCase { """ ) - XCTAssertEqual(result.allPaths.count, 2) - XCTAssertEqual(result.allPaths.first?.allTransforms, ["translate(1 0)"]) - XCTAssertEqual(result.allPaths.last?.allTransforms, []) + XCTAssertEqual(document.paths.count, 2) + XCTAssertEqual(document.paths.first?.transformChain, ["translate(1 0)"]) + XCTAssertEqual(document.paths.last?.transformChain, []) + } + + func testGroupIDsAndFillAreInherited() throws { + let document = try parse( + """ + + + + + """ + ) + + XCTAssertEqual(document.paths.first?.idChain, "Icon;Primary;Vector") + XCTAssertEqual(document.paths.first?.fill, .black) + XCTAssertEqual(document.paths.last?.idChain, "Secondary") + XCTAssertEqual(document.paths.last?.fill, .other("#FF0002")) } func testPathsInsideDefinitionsAreIgnored() throws { - let result = try parse( + let document = try parse( """ @@ -89,13 +105,13 @@ final class DefaultSVGParserTests: XCTestCase { """ ) - XCTAssertEqual(result.allPaths.map(\.data), ["M0 0"]) + XCTAssertEqual(document.paths.map(\.data), ["M0 0"]) } func testCanvasFallsBackToViewBox() throws { - let result = try parse(#""#) + let document = try parse(#""#) - XCTAssertEqual(result.canvas, SVGCanvas(width: 16.0, height: 32.0)) + XCTAssertEqual(document.canvas, SVGCanvas(width: 16.0, height: 32.0)) } } #endif diff --git a/Tests/FigmaGenTests/SFSymbolRoleTests.swift b/Tests/FigmaGenTests/SFSymbolRoleTests.swift new file mode 100644 index 0000000..159e532 --- /dev/null +++ b/Tests/FigmaGenTests/SFSymbolRoleTests.swift @@ -0,0 +1,47 @@ +#if canImport(FigmaGen) +import XCTest +@testable import FigmaGen + +final class SFSymbolRoleTests: XCTestCase { + + // MARK: - Instance Methods + + private func makePath(id: String? = nil, fill: SVGColor? = nil, groupIDs: [String] = []) -> SVGPath { + SVGPath( + id: id, + data: "M0 0", + fillRule: nil, + fill: fill, + transform: nil, + groupIDs: groupIDs, + groupTransforms: [] + ) + } + + // MARK: - + + func testOwnIDNamesRole() { + XCTAssertEqual(SFSymbolRole(of: makePath(id: "Secondary", fill: .black), layerNames: []), .secondary) + XCTAssertEqual(SFSymbolRole(of: makePath(id: "tertiary"), layerNames: ["tertiary"]), .tertiary) + } + + func testWithoutLayerNamesRoleFollowsFill() { + XCTAssertEqual(SFSymbolRole(of: makePath(fill: .black), layerNames: []), .primary) + XCTAssertEqual(SFSymbolRole(of: makePath(fill: .other("#FF0002")), layerNames: []), .secondary) + XCTAssertEqual(SFSymbolRole(of: makePath(fill: nil), layerNames: []), .secondary) + } + + func testLayerNamesAreMatchedAgainstGroupIDs() { + let layerNames = ["Front", "Back", "Shadow"] + + XCTAssertEqual(SFSymbolRole(of: makePath(groupIDs: ["Icon", "Front"]), layerNames: layerNames), .primary) + XCTAssertEqual(SFSymbolRole(of: makePath(groupIDs: ["Icon", "Back"]), layerNames: layerNames), .secondary) + XCTAssertEqual(SFSymbolRole(of: makePath(groupIDs: ["Icon", "Shadow"]), layerNames: layerNames), .tertiary) + XCTAssertEqual(SFSymbolRole(of: makePath(fill: .black, groupIDs: ["Icon"]), layerNames: layerNames), .tertiary) + } + + func testMissingSecondaryLayerNameLeavesOtherPathsTertiary() { + XCTAssertEqual(SFSymbolRole(of: makePath(groupIDs: ["Back"]), layerNames: ["Front"]), .tertiary) + } +} +#endif diff --git a/Tests/FigmaGenTests/SVGPathTransformerTests.swift b/Tests/FigmaGenTests/SVGPathTransformerTests.swift index 1884b35..8cb082f 100644 --- a/Tests/FigmaGenTests/SVGPathTransformerTests.swift +++ b/Tests/FigmaGenTests/SVGPathTransformerTests.swift @@ -131,7 +131,6 @@ final class SVGPathTransformerTests: XCTestCase { } func testTrailingGarbageIsRejected() throws { - // Раньше неразобранный хвост молча отбрасывался вместе с частью рисунка. XCTAssertThrowsError(try transform("M0 0 L10 10 ?!", by: .identity)) } From 37cd8e509796f0443bc1c448b58914429c9a3b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BB=D1=8C=D1=86=D0=B5=D0=B2=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4?= Date: Mon, 7 Sep 2026 18:18:26 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/FigmaGen/Commands/ImagesCommand.swift | 2 +- Sources/FigmaGen/Models/Images/ImageNode.swift | 2 +- Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift | 7 ++++--- .../Images/Assets/DefaultImageAssetsProvider.swift | 4 ++-- .../SFSymbolProvider/DefaultSFSymbolProvider.swift | 6 ++++-- Templates/SVGTemplate.stencil | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index db9341a..9b36410 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -197,7 +197,7 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand "--sfSymbolKey", description: """ Name of the Figma property that marks SF Symbols: components named with '=true' - are rendered as SVG and saved to .symbolset instead of .imageset. + (case-insensitive) are rendered as SVG and saved to .symbolset instead of .imageset. By default, no SF Symbols are generated. """ ) diff --git a/Sources/FigmaGen/Models/Images/ImageNode.swift b/Sources/FigmaGen/Models/Images/ImageNode.swift index b94b2ee..cda7df8 100644 --- a/Sources/FigmaGen/Models/Images/ImageNode.swift +++ b/Sources/FigmaGen/Models/Images/ImageNode.swift @@ -11,6 +11,6 @@ struct ImageNode: Encodable, Hashable { // MARK: - Instance Methods func isSFSymbol(key: String?) -> Bool { - key.map { name.contains("\($0)=true") } ?? false + key.map { name.lowercased().contains("\($0.lowercased())=true") } ?? false } } diff --git a/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift b/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift index aaa0572..30ad17a 100644 --- a/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift +++ b/Sources/FigmaGen/Models/SFSymbol/SFSymbol.swift @@ -3,8 +3,9 @@ import Foundation struct SFSymbol { let name: String - let opticalSize: Int - let designWidth: Double - let designHeight: Double + let opticalWidth: CGFloat + let opticalHeight: CGFloat + let designWidth: CGFloat + let designHeight: CGFloat let layers: [SFSymbolLayer] } diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index c57d43d..068f8d7 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -35,8 +35,8 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes if let sfSymbolKey, !sfSymbolKey.isEmpty { name = name - .replacingOccurrences(of: "\(sfSymbolKey)=false", with: "") - .replacingOccurrences(of: "\(sfSymbolKey)=true", with: sfSymbolKey) + .replacingOccurrences(of: "\(sfSymbolKey)=false", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "\(sfSymbolKey)=true", with: sfSymbolKey, options: .caseInsensitive) } switch namingStyle { diff --git a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift index 5d8a70b..50a8a5d 100644 --- a/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift +++ b/Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift @@ -87,7 +87,8 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { return SFSymbol( name: filePath.lastComponentWithoutExtension, - opticalSize: Int(canvas.height.rounded()), + opticalWidth: canvas.width, + opticalHeight: canvas.height, designWidth: designWidth, designHeight: designHeight, layers: try makeLayers( @@ -169,7 +170,8 @@ final class DefaultSFSymbolProvider: SFSymbolProvider { return [ "name": symbol.name, - "opticalSize": symbol.opticalSize, + "opticalWidth": SVGNumber.string(from: symbol.opticalWidth), + "opticalHeight": SVGNumber.string(from: symbol.opticalHeight), "designWidth": SVGNumber.string(from: symbol.designWidth), "designHeight": SVGNumber.string(from: symbol.designHeight), "layers": layers, diff --git a/Templates/SVGTemplate.stencil b/Templates/SVGTemplate.stencil index 09a7afc..b6b3a77 100644 --- a/Templates/SVGTemplate.stencil +++ b/Templates/SVGTemplate.stencil @@ -2,7 +2,7 @@ - +