From ba1801e639d6f4636ab83b500ff105af97750920 Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Tue, 1 Sep 2026 14:44:46 +0300 Subject: [PATCH 1/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20pdf=20=D0=B8=20svg=20=D0=BF=D0=BE=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Images/DefaultImagesProvider.swift | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift index 889dc92..ccf69f7 100644 --- a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift +++ b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift @@ -227,15 +227,29 @@ final class DefaultImagesProvider: ImagesProvider { onlyExportables: parameters.onlyExportables ) } - }.then { nodes in - self.imageRenderProvider.renderImages( - of: file, - nodes: nodes, - format: parameters.format, - scales: parameters.scales, - useAbsoluteBounds: parameters.useAbsoluteBounds - ) - }.then { nodes in + } + .then { nodes in + when( + fulfilled: self.imageRenderProvider.renderImages( + of: file, + // TODO: @d.viter тут должно быть parameters.sfSymbolKey + nodes: nodes.getImagesWithoutSymbols(by: "colored"), + format: parameters.format, + scales: parameters.scales, + useAbsoluteBounds: parameters.useAbsoluteBounds + ), + self.imageRenderProvider.renderImages( + of: file, + // TODO: @d.viter тут должно быть parameters.sfSymbolKey + nodes: nodes.getSymbols(by: "colored"), + 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 +257,39 @@ 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 + ImageComponentSetNode( + name: node.name, + parentName: node.parentName, + components: node.components.filter({ !$0.name.contains("\(sfSymbolKey)=true") }) + ) + } + } + + 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 + ) + } + } +} From 44f11a1e11949699dd641e4d70a309cfec10d2af Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Wed, 2 Sep 2026 12:26:28 +0300 Subject: [PATCH 2/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83?= =?UTF-8?q?=20colored-=D0=B8=D0=BA=D0=BE=D0=BD=D0=BE=D0=BA?= 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 | 17 ++++++++++++++--- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index 8c28d00..a00778e 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -183,6 +183,14 @@ 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. + """ + ) + // MARK: - Initializers init(generator: ImagesGenerator) { @@ -260,7 +268,8 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand renderAs: resolveRenderingMode(), groupByFrame: groupByFrame.value, groupByComponentSet: groupByComponentSet.value, - namingStyle: resolveNamingStyle() + namingStyle: resolveNamingStyle(), + sfSymbolKey: sfSymbolKey.value ) } diff --git a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift index 7a6673d..cfc8307 100644 --- a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift +++ b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift @@ -80,7 +80,8 @@ extension ImagesConfiguration { renderAs: renderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, - namingStyle: namingStyle + namingStyle: namingStyle, + sfSymbolKey: sfSymbolKey ) } } diff --git a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift index 10b76b7..bda014d 100644 --- a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift +++ b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift @@ -17,6 +17,7 @@ struct ImagesConfiguration: Decodable { case groupByFrame case groupByComponentSet case namingStyle + case sfSymbolKey } // MARK: - Instance Properties @@ -34,6 +35,7 @@ struct ImagesConfiguration: Decodable { let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle + let sfSymbolKey: String? // MARK: - Initializers @@ -50,7 +52,8 @@ struct ImagesConfiguration: Decodable { renderAs: ImageRenderingMode?, groupByFrame: Bool, groupByComponentSet: Bool, - namingStyle: ImageNamingStyle + namingStyle: ImageNamingStyle, + sfSymbolKey: String? ) { self.generatation = generatation self.assets = assets @@ -65,6 +68,7 @@ struct ImagesConfiguration: Decodable { self.groupByFrame = groupByFrame self.groupByComponentSet = groupByComponentSet self.namingStyle = namingStyle + self.sfSymbolKey = sfSymbolKey } init(from decoder: Decoder) throws { @@ -83,6 +87,7 @@ 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) generatation = try GenerationConfiguration(from: decoder) } @@ -103,7 +108,8 @@ struct ImagesConfiguration: Decodable { renderAs: renderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, - namingStyle: namingStyle + namingStyle: namingStyle, + sfSymbolKey: sfSymbolKey ) } } diff --git a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift index e46fd58..d40c374 100644 --- a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift +++ b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift @@ -16,4 +16,5 @@ struct ImagesParameters { let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle + let sfSymbolKey: String? } diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index 93e914e..db7cd16 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -22,10 +22,16 @@ 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 !sfSymbolKey.isEmpty, name.contains(sfSymbolKey) { + name = name + .replacingOccurrences(of: "\(sfSymbolKey)=false", with: "") + .replacingOccurrences(of: "\(sfSymbolKey)=true", with: "\(sfSymbolKey)") + } switch namingStyle { case .camelCase: return name.camelized @@ -41,7 +47,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, From d7b7b9e5938d2a7f8bc301233b67c2199760fe35 Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Wed, 2 Sep 2026 13:20:19 +0300 Subject: [PATCH 3/8] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20symbolset=20=D0=BA=20imageset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/FigmaGen/Commands/ImagesCommand.swift | 23 ++++++ .../Images/DefaultImagesGenerator.swift | 1 + .../Configuration/ImagesConfiguration.swift | 6 ++ .../FigmaGen/Models/Images/ImageAsset.swift | 5 ++ .../Models/Images/SymbolRenderingMode.swift | 10 +++ .../Models/Parameters/ImagesParameters.swift | 1 + .../Assets/DefaultImageAssetsProvider.swift | 80 +++++++++++++++++-- .../Images/DefaultImagesProvider.swift | 6 +- .../Render/DefaultImageRenderProvider.swift | 2 + .../Assets/Folder/AssetFolder.swift | 8 ++ .../SymbolSet/AssetSymbolProperties.swift | 22 +++++ .../AssetSymbolRenderingIntent.swift | 10 +++ .../Assets/SymbolSet/AssetSymbolSet.swift | 18 +++++ .../SymbolSet/AssetSymbolSetContents.swift | 22 +++++ 14 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift create mode 100644 Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolProperties.swift create mode 100644 Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolRenderingIntent.swift create mode 100644 Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSet.swift create mode 100644 Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolSetContents.swift diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index a00778e..348db26 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -191,6 +191,14 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand """ ) + 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. + """ + ) + // MARK: - Initializers init(generator: ImagesGenerator) { @@ -240,6 +248,20 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand } } + 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 generated images: Invalid rendering mode (\(rawSymbolRenderingMode)") + } + + return mode + } + } + private func resolveNamingStyle() -> ImageNamingStyle { switch namingStyle.value { case nil: @@ -266,6 +288,7 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand useAbsoluteBounds: useAbsoluteBounds.value, preserveVectorData: preserveVectorData.value, renderAs: resolveRenderingMode(), + symbolRenderAs: resolveSymbolRenderAs(), groupByFrame: groupByFrame.value, groupByComponentSet: groupByComponentSet.value, namingStyle: resolveNamingStyle(), diff --git a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift index cfc8307..f74a4cb 100644 --- a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift +++ b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift @@ -78,6 +78,7 @@ extension ImagesConfiguration { useAbsoluteBounds: useAbsoluteBounds, preserveVectorData: preserveVectorData, renderAs: renderAs, + symbolRenderAs: symbolRenderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, namingStyle: namingStyle, diff --git a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift index bda014d..1882225 100644 --- a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift +++ b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift @@ -14,6 +14,7 @@ struct ImagesConfiguration: Decodable { case useAbsoluteBounds case preserveVectorData case renderAs + case symbolRenderAs case groupByFrame case groupByComponentSet case namingStyle @@ -32,6 +33,7 @@ struct ImagesConfiguration: Decodable { let useAbsoluteBounds: Bool let preserveVectorData: Bool let renderAs: ImageRenderingMode? + let symbolRenderAs: SymbolRenderingMode? let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle @@ -50,6 +52,7 @@ struct ImagesConfiguration: Decodable { useAbsoluteBounds: Bool, preserveVectorData: Bool, renderAs: ImageRenderingMode?, + symbolRenderAs: SymbolRenderingMode?, groupByFrame: Bool, groupByComponentSet: Bool, namingStyle: ImageNamingStyle, @@ -65,6 +68,7 @@ struct ImagesConfiguration: Decodable { self.useAbsoluteBounds = useAbsoluteBounds self.preserveVectorData = preserveVectorData self.renderAs = renderAs + self.symbolRenderAs = symbolRenderAs self.groupByFrame = groupByFrame self.groupByComponentSet = groupByComponentSet self.namingStyle = namingStyle @@ -84,6 +88,7 @@ struct ImagesConfiguration: Decodable { useAbsoluteBounds = try container.decodeIfPresent(forKey: .useAbsoluteBounds) ?? false preserveVectorData = try container.decodeIfPresent(forKey: .preserveVectorData) ?? false renderAs = try container.decodeIfPresent(forKey: .renderAs) + symbolRenderAs = try container.decodeIfPresent(forKey: .symbolRenderAs) groupByFrame = try container.decodeIfPresent(forKey: .groupByFrame) ?? false groupByComponentSet = try container.decodeIfPresent(forKey: .groupByComponentSet) ?? false namingStyle = try container.decodeIfPresent(forKey: .namingStyle) ?? .camelCase @@ -106,6 +111,7 @@ struct ImagesConfiguration: Decodable { useAbsoluteBounds: useAbsoluteBounds, preserveVectorData: preserveVectorData, renderAs: renderAs, + symbolRenderAs: symbolRenderAs, groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, namingStyle: namingStyle, diff --git a/Sources/FigmaGen/Models/Images/ImageAsset.swift b/Sources/FigmaGen/Models/Images/ImageAsset.swift index 99ad451..ae1ff5c 100644 --- a/Sources/FigmaGen/Models/Images/ImageAsset.swift +++ b/Sources/FigmaGen/Models/Images/ImageAsset.swift @@ -8,4 +8,9 @@ struct ImageAsset: Encodable, Hashable { let filePaths: [ImageScale: String] let preserveVectorData: Bool let renderAs: ImageRenderingMode? + let symbolRenderAs: SymbolRenderingMode? + + var isSymbol: Bool { + symbolRenderAs != nil + } } diff --git a/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift b/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift new file mode 100644 index 0000000..e8c7811 --- /dev/null +++ b/Sources/FigmaGen/Models/Images/SymbolRenderingMode.swift @@ -0,0 +1,10 @@ +import Foundation + +enum SymbolRenderingMode: String, Codable { + + // MARK: - Enumeration Cases + + case template + case multicolor + case hierarchical +} diff --git a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift index d40c374..389f38e 100644 --- a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift +++ b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift @@ -13,6 +13,7 @@ struct ImagesParameters { let useAbsoluteBounds: Bool let preserveVectorData: Bool let renderAs: ImageRenderingMode? + let symbolRenderAs: SymbolRenderingMode? let groupByFrame: Bool let groupByComponentSet: Bool let namingStyle: ImageNamingStyle diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index db7cd16..383b79d 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -61,10 +61,22 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes folderPath: folderPath ) - let filePaths = node.urls.keys.reduce(into: [:]) { result, scale in + let isSymbol = name.lowercased().contains(parameters.sfSymbolKey ?? "") + + let assetSetExtension = isSymbol + ? AssetSymbolSet.pathExtension + : AssetImageSet.pathExtension + + let assetExtension = isSymbol + ? ImageFormat.svg.fileExtension + : parameters.format.fileExtension + + 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) + .appending(fileName: name, extension: assetSetExtension) + .appending(fileName: name.appending(scale.fileNameSuffix), extension: assetExtension) .string } @@ -72,7 +84,8 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes name: name, filePaths: filePaths, preserveVectorData: parameters.preserveVectorData, - renderAs: parameters.renderAs + renderAs: parameters.renderAs, + symbolRenderAs: isSymbol ? parameters.symbolRenderAs : nil ) } @@ -81,7 +94,12 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes parameters: ImagesParameters, folderPath: Path ) -> [ImageComponentSetAsset] { - nodes.map { setNode in + nodes.compactMap { setNode in + // TODO: @d.viter delete next + guard setNode.name.contains("bubble round hyperstar") else { + return nil + } + var assets: [ImageRenderedNode: ImageAsset] = [:] setNode.components.forEach { node in @@ -114,12 +132,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) } @@ -183,6 +227,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) ) } @@ -231,6 +276,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) { @@ -243,3 +297,19 @@ extension AssetImageTemplateRenderingIntent { } } } + +extension AssetSymbolRenderingIntent { + + fileprivate init(from renderingIntent: SymbolRenderingMode) { + switch renderingIntent { + case .template: + self = .hierarchical + + case .multicolor: + self = .hierarchical + + case .hierarchical: + self = .hierarchical + } + } +} diff --git a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift index ccf69f7..bf394d2 100644 --- a/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift +++ b/Sources/FigmaGen/Providers/Images/DefaultImagesProvider.swift @@ -232,16 +232,14 @@ final class DefaultImagesProvider: ImagesProvider { when( fulfilled: self.imageRenderProvider.renderImages( of: file, - // TODO: @d.viter тут должно быть parameters.sfSymbolKey - nodes: nodes.getImagesWithoutSymbols(by: "colored"), + nodes: nodes.getImagesWithoutSymbols(by: parameters.sfSymbolKey), format: parameters.format, scales: parameters.scales, useAbsoluteBounds: parameters.useAbsoluteBounds ), self.imageRenderProvider.renderImages( of: file, - // TODO: @d.viter тут должно быть parameters.sfSymbolKey - nodes: nodes.getSymbols(by: "colored"), + nodes: nodes.getSymbols(by: parameters.sfSymbolKey), format: .svg, scales: parameters.scales, useAbsoluteBounds: parameters.useAbsoluteBounds diff --git a/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift b/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift index b8fbf5d..d9f28a0 100644 --- a/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Render/DefaultImageRenderProvider.swift @@ -75,6 +75,8 @@ final class DefaultImageRenderProvider: ImageRenderProvider { .map { $0.id }, format: format.figmaFormat, scale: scale.figmaScale, + // TODO: @d.viter check is next needed + svgIncludeID: true, useAbsoluteBounds: useAbsoluteBounds ) 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..1288930 --- /dev/null +++ b/Sources/FigmaGenTools/Assets/SymbolSet/AssetSymbolRenderingIntent.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum AssetSymbolRenderingIntent: String, Codable { + + // MARK: - Enumeration Cases + + 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 } + } +} From fdd951c5a3c8f5465437ae0288f0b341bc43ebcc Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Thu, 3 Sep 2026 12:05:25 +0300 Subject: [PATCH 4/8] =?UTF-8?q?=D0=9A=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20SF=20Symbol?= 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 +- .../FigmaGen/Models/Images/ImageAsset.swift | 5 +- .../Models/Parameters/ImagesParameters.swift | 1 + .../DataProvider/DefaultDataProvider.swift | 39 ++- .../DataProvider/SFSymbolProvider.swift | 221 ++++++++++++++ .../Assets/DefaultImageAssetsProvider.swift | 5 +- .../SFSymbols/Models/SFSymbolLayer.swift | 8 + .../SFSymbols/Models/SFSymbolPathData.swift | 8 + .../SFSymbols/Models/SFSymbolRole.swift | 9 + .../Images/SFSymbols/Models/SVGCanvas.swift | 10 + .../Images/SFSymbols/Models/SVGColor.swift | 7 + .../SFSymbols/Models/SVGGroupContext.swift | 8 + .../SFSymbols/Models/SVGImageToken.swift | 11 + .../SFSymbols/Models/SVGParserError.swift | 29 ++ .../Images/SFSymbols/Models/SVGPath.swift | 57 ++++ .../SFSymbols/Models/SVGPathsResult.swift | 23 ++ .../Images/SFSymbols/SVGParser.swift | 249 ++++++++++++++++ .../Images/SFSymbols/SVGPathTransformer.swift | 276 ++++++++++++++++++ Templates/SVGTemplate.stencil | 114 ++++++++ 21 files changed, 1091 insertions(+), 13 deletions(-) create mode 100644 Sources/FigmaGen/Providers/DataProvider/SFSymbolProvider.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolLayer.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolPathData.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolRole.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGCanvas.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGColor.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGGroupContext.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGImageToken.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGParserError.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPath.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPathsResult.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/SVGParser.swift create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/SVGPathTransformer.swift create mode 100644 Templates/SVGTemplate.stencil diff --git a/Sources/FigmaGen/Commands/ImagesCommand.swift b/Sources/FigmaGen/Commands/ImagesCommand.swift index 348db26..ec2aea7 100644 --- a/Sources/FigmaGen/Commands/ImagesCommand.swift +++ b/Sources/FigmaGen/Commands/ImagesCommand.swift @@ -199,6 +199,14 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand """ ) + let sfSymbolTemplate = Key( + "--sfSymbolTemplate", + description: """ + Path to the SF Symbol template file. + If no template is passed a default template will be used. + """ + ) + // MARK: - Initializers init(generator: ImagesGenerator) { @@ -292,7 +300,8 @@ final class ImagesCommand: AsyncExecutableCommand, GenerationConfigurableCommand groupByFrame: groupByFrame.value, groupByComponentSet: groupByComponentSet.value, namingStyle: resolveNamingStyle(), - sfSymbolKey: sfSymbolKey.value + sfSymbolKey: sfSymbolKey.value, + sfSymbolTemplate: sfSymbolTemplate.value ) } diff --git a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift index f74a4cb..73d7762 100644 --- a/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift +++ b/Sources/FigmaGen/Generators/Images/DefaultImagesGenerator.swift @@ -82,7 +82,8 @@ extension ImagesConfiguration { groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, namingStyle: namingStyle, - sfSymbolKey: sfSymbolKey + sfSymbolKey: sfSymbolKey, + sfSymbolTemplate: sfSymbolTemplate ) } } diff --git a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift index 1882225..57268e9 100644 --- a/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift +++ b/Sources/FigmaGen/Models/Configuration/ImagesConfiguration.swift @@ -19,6 +19,7 @@ struct ImagesConfiguration: Decodable { case groupByComponentSet case namingStyle case sfSymbolKey + case sfSymbolTemplate } // MARK: - Instance Properties @@ -38,6 +39,7 @@ struct ImagesConfiguration: Decodable { let groupByComponentSet: Bool let namingStyle: ImageNamingStyle let sfSymbolKey: String? + let sfSymbolTemplate: String? // MARK: - Initializers @@ -56,7 +58,8 @@ struct ImagesConfiguration: Decodable { groupByFrame: Bool, groupByComponentSet: Bool, namingStyle: ImageNamingStyle, - sfSymbolKey: String? + sfSymbolKey: String?, + sfSymbolTemplate: String? ) { self.generatation = generatation self.assets = assets @@ -73,6 +76,7 @@ struct ImagesConfiguration: Decodable { self.groupByComponentSet = groupByComponentSet self.namingStyle = namingStyle self.sfSymbolKey = sfSymbolKey + self.sfSymbolTemplate = sfSymbolTemplate } init(from decoder: Decoder) throws { @@ -93,6 +97,7 @@ struct ImagesConfiguration: Decodable { groupByComponentSet = try container.decodeIfPresent(forKey: .groupByComponentSet) ?? false namingStyle = try container.decodeIfPresent(forKey: .namingStyle) ?? .camelCase sfSymbolKey = try container.decodeIfPresent(forKey: .sfSymbolKey) + sfSymbolTemplate = try container.decodeIfPresent(forKey: .sfSymbolTemplate) generatation = try GenerationConfiguration(from: decoder) } @@ -115,7 +120,8 @@ struct ImagesConfiguration: Decodable { groupByFrame: groupByFrame, groupByComponentSet: groupByComponentSet, namingStyle: namingStyle, - sfSymbolKey: sfSymbolKey + sfSymbolKey: sfSymbolKey, + sfSymbolTemplate: sfSymbolTemplate ) } } diff --git a/Sources/FigmaGen/Models/Images/ImageAsset.swift b/Sources/FigmaGen/Models/Images/ImageAsset.swift index ae1ff5c..866bfe4 100644 --- a/Sources/FigmaGen/Models/Images/ImageAsset.swift +++ b/Sources/FigmaGen/Models/Images/ImageAsset.swift @@ -9,8 +9,5 @@ struct ImageAsset: Encodable, Hashable { let preserveVectorData: Bool let renderAs: ImageRenderingMode? let symbolRenderAs: SymbolRenderingMode? - - var isSymbol: Bool { - symbolRenderAs != nil - } + let isSymbol: Bool } diff --git a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift index 389f38e..ee51633 100644 --- a/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift +++ b/Sources/FigmaGen/Models/Parameters/ImagesParameters.swift @@ -18,4 +18,5 @@ struct ImagesParameters { let groupByComponentSet: Bool let namingStyle: ImageNamingStyle let sfSymbolKey: String? + let sfSymbolTemplate: String? } diff --git a/Sources/FigmaGen/Providers/DataProvider/DefaultDataProvider.swift b/Sources/FigmaGen/Providers/DataProvider/DefaultDataProvider.swift index ea16aae..3669b4e 100644 --- a/Sources/FigmaGen/Providers/DataProvider/DefaultDataProvider.swift +++ b/Sources/FigmaGen/Providers/DataProvider/DefaultDataProvider.swift @@ -29,12 +29,43 @@ final class DefaultDataProvider: DataProvider { }.map(on: DispatchQueue.global(qos: .userInitiated)) { fileData in let filePath = Path(filePath) - if filePath.exists { - try filePath.delete() +// if filePath.exists { +// try filePath.delete() +// } + + if filePath.string.lowercased().contains("colored") { +// print("Data count:", fileData.count) +// +// print( +// "First bytes:", +// fileData.prefix(32) +// .map { String(format: "%02X", $0) } +// .joined(separator: " ") +// ) +// +// print( +// "Content:", +// String(data: fileData, encoding: .utf8) ?? "" +// ) +// print(filePath.string) + let parser = SVGParser() + let result = try parser.parse(data: fileData) + let provider = SFSymbolProvider() + try provider.generate( + renderParameters: RenderParameters( + template: RenderTemplate(type: .custom(path: "/Users/d.viter/Project/FigmaGen/Templates/SVGTemplate.stencil"), options: [:]), + destination: RenderDestination.file(path: filePath.string) + ), + tokenValues: TokenValues(core: [], semantic: [], colors: [], typography: [], themedTokens: [:]), + result: SVGPathsResult(id: filePath.string, canvas: parser.canvas, allPaths: result), + themes: [], + fallbackTheme: .light + ) + } else { + try filePath.parent().mkpath() + try filePath.write(fileData) } - try filePath.parent().mkpath() - try filePath.write(fileData) } } } diff --git a/Sources/FigmaGen/Providers/DataProvider/SFSymbolProvider.swift b/Sources/FigmaGen/Providers/DataProvider/SFSymbolProvider.swift new file mode 100644 index 0000000..a4cffb6 --- /dev/null +++ b/Sources/FigmaGen/Providers/DataProvider/SFSymbolProvider.swift @@ -0,0 +1,221 @@ +import Foundation + +final class SFSymbolProvider { + + // MARK: - Nested Types + + // Geometry of the SF Symbols template, matching the reference pipeline + // described in Kolya/SF_SYMBOLS.md. + private enum Geometry { + + // Typographic metrics shared by every row of the template. The cap height is 0.70459 of the + // em, which matches the cap height of SF Pro and confirms that one em is 100 design units: + // a design box of emDesignHeight units renders at exactly the point size of the font. + static let capHeight = 70.459 + static let emDesignHeight = 99.5 // 100.0 + + // Vertical extent of the margin guides, relative to the baseline of their own row. + static let marginGuideTopOffset = 95.215 + static let marginGuideBottomOffset = 24.121 + + // Horizontal centers of the weight columns, taken from the labels + // of the "Weight/Scale Variations" section of the template. + static let weights: [(name: String, centerX: Double)] = [ + (name: "Ultralight", centerX: 559.711), + (name: "Regular", centerX: 1449.845), + (name: "Black", centerX: 2933.4) + ] + + // Baselines of the scale rows. Every scale is authored explicitly and carries the same + // drawing, so SF Symbols derives nothing and the image scale requested by the application + // cannot change the rendered size. + static let scales: [(name: String, baseline: Double)] = [ + (name: "S", baseline: 696.0), + (name: "M", baseline: 1126.0), + (name: "L", baseline: 1556.0) + ] + } + + private enum Fill { + + // Figma fills mapped to the secondary Palette layer. Black is mapped to the primary layer + // by SVGParser, and any other fill fails generation. + static let secondary: Set = ["#ff0002"] + } + + // MARK: - Instance Properties + + private let templateRenderer = DefaultTemplateRenderer( + contextCoder: DefaultTemplateContextCoder(), + stencilExtensions: [ + StencilByteToHexFilter(), + StencilHexToByteFilter(), + StencilByteToFloatFilter(), + StencilFloatToByteFilter(), + StencilVectorInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilColorRGBHexInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilColorRGBAHexInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilColorRGBInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilColorRGBAInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilColorInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilFontInfoFilter(contextCoder: DefaultTemplateContextCoder()), + StencilFontInitializerModificator(contextCoder: DefaultTemplateContextCoder()), + StencilFontSystemFilter(contextCoder: DefaultTemplateContextCoder()), + StencilCollectionDropFirstModificator(), + StencilCollectionDropLastModificator(), + StencilCollectionRemovingFirstModificator(), + StencilHexToAlphaFilter(), + StencilFullHexModificator(), + StencilRecursiveTokenFindModicator() + ] + ) + + // MARK: - Instance Methods + + private func resolveRole(of path: SVGPath) throws -> SFSymbolRole { + if let id = path.id, let role = SFSymbolRole(rawValue: id.lowercased()) { + return role + } + + switch path.fill { + case .black: + return .primary + + case let .other(fill) where Fill.secondary.contains(normalizeFill(fill)): + return .secondary + + case let .other(fill): + throw SVGParserError.unsupportedFill(fill) + + case nil: + throw SVGParserError.unsupportedFill("") + } + } + + private func normalizeFill(_ fill: String) -> String { + fill + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + } + + 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 + } + + // The component box always fills the em design box, so the symbol renders at exactly the + // point size given to .font(.system(size:)). The optical size of the drawing is therefore + // chosen by that point size and not baked into the geometry. + // The width keeps the proportions of the Figma component box, so that intentional padding + // and non-square components survive the conversion. It is rounded to a whole design unit + // because the margin guides define the reported symbol box and a fractional guide can widen + // that box by a whole unit. A square component is already integral, and the worst case for + // a non-square one moves the right guide by half a unit without touching the artwork. + let designHeight = Geometry.emDesignHeight + let scale = designHeight / canvas.height + let designWidth = (canvas.width * scale).rounded() + + // Figma draws downwards from the top left corner of the component box, while the template + // draws upwards from the baseline. The box is centered on the cap height center. + let transformer = SVGPathTransformer( + scale: scale, + translationY: -Geometry.capHeight / 2.0 - designHeight / 2.0 + ) + + var roles: [SFSymbolRole] = [] + var pathsByRole: [SFSymbolRole: [SFSymbolPathData]] = [:] + + for path in result.allPaths { + guard let data = path.data else { + throw SVGParserError.invalidPathData("") + } + + let role = try resolveRole(of: path) + + if !roles.contains(role) { + roles.append(role) + } + + pathsByRole[role, default: []].append( + SFSymbolPathData( + data: try transformer.transform(pathData: data), + fillRule: path.fillRule + ) + ) + } + + return SVGImageToken( + name: URL(fileURLWithPath: result.id).deletingPathExtension().lastPathComponent, + opticalSize: Int(canvas.height.rounded()), + designWidth: designWidth, + designHeight: designHeight, + layers: 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 groups are numbered from the topmost layer, the way Xcode exports them. + "motionGroup": token.layers.count - 1 - layer.index, + "paths": layer.paths.map { path in + ["data": path.data, "fillRule": path.fillRule ?? ""] + } + ] + } + + let variants = Geometry.scales.flatMap { scale in + Geometry.weights.map { weight -> [String: Any] in + // Rounded so that both margin guides land on whole design units. The column center + // is fractional, so centering exactly would put the guides on fractions of a unit. + 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 - Geometry.marginGuideTopOffset), + "guideBottom": SVGNumber.string(from: scale.baseline + Geometry.marginGuideBottomOffset) + ] + } + } + + return [ + "name": token.name, + "opticalSize": token.opticalSize, + "designWidth": SVGNumber.string(from: token.designWidth), + "designHeight": SVGNumber.string(from: token.designHeight), + "layers": layers, + "variants": variants + ] + } + + // MARK: - BorderTokensGenerator + + func generate( + renderParameters: RenderParameters, + tokenValues: TokenValues, + result: SVGPathsResult, + themes: [Theme], + fallbackTheme: Theme + ) throws { + let token = try extractPaths(from: result) + + try templateRenderer.renderTemplate( + renderParameters.template, + to: renderParameters.destination, + context: makeContext(for: token) + ) + } +} diff --git a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift index 383b79d..43b871c 100644 --- a/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift +++ b/Sources/FigmaGen/Providers/Images/Assets/DefaultImageAssetsProvider.swift @@ -9,6 +9,8 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes let assetsProvider: AssetsProvider let dataProvider: DataProvider + let svgParser = SVGParser() + let sfSymbolProvider = SFSymbolProvider() // MARK: - Initializers @@ -85,7 +87,8 @@ final class DefaultImageAssetsProvider: ImageAssetsProvider, ImagesFolderPathRes filePaths: filePaths, preserveVectorData: parameters.preserveVectorData, renderAs: parameters.renderAs, - symbolRenderAs: isSymbol ? parameters.symbolRenderAs : nil + symbolRenderAs: isSymbol ? parameters.symbolRenderAs : nil, + isSymbol: isSymbol ) } diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolLayer.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolLayer.swift new file mode 100644 index 0000000..37ba008 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolLayer.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SFSymbolLayer { + + let index: Int + let role: SFSymbolRole + let paths: [SFSymbolPathData] +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolPathData.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolPathData.swift new file mode 100644 index 0000000..571565e --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolPathData.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SFSymbolPathData { + + // Path data already converted into the coordinate space of an SF Symbols variant. + let data: String + let fillRule: String? +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolRole.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolRole.swift new file mode 100644 index 0000000..935b768 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SFSymbolRole.swift @@ -0,0 +1,9 @@ +import Foundation + +// Palette layer role of a path. Black Figma fills become primary, #FF0002 fills become secondary. +enum SFSymbolRole: String { + + case primary + case secondary + case tertiary +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGCanvas.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGCanvas.swift new file mode 100644 index 0000000..3c32ab5 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGCanvas.swift @@ -0,0 +1,10 @@ +import Foundation + +// Size of the root `` element, which Figma exports equal to the component box. +// SF Symbols geometry is built from this box and not from the visible path bounds, +// so that the padding designed in Figma is preserved. +struct SVGCanvas: Equatable { + + let width: Double + let height: Double +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGColor.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGColor.swift new file mode 100644 index 0000000..409b5f9 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGColor.swift @@ -0,0 +1,7 @@ +import Foundation + +enum SVGColor: Equatable { + + case black + case other(String) +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGGroupContext.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGGroupContext.swift new file mode 100644 index 0000000..dd479e1 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGGroupContext.swift @@ -0,0 +1,8 @@ +import Foundation + +struct SVGGroupContext { + + let id: String? + let fill: SVGColor? + let transform: String? +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGImageToken.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGImageToken.swift new file mode 100644 index 0000000..685d3a7 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGImageToken.swift @@ -0,0 +1,11 @@ +import Foundation + +// Everything the SVG template needs to lay a Figma drawing out in SF Symbols coordinates. +struct SVGImageToken { + + let name: String + let opticalSize: Int + let designWidth: Double + let designHeight: Double + let layers: [SFSymbolLayer] +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGParserError.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGParserError.swift new file mode 100644 index 0000000..0003670 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGParserError.swift @@ -0,0 +1,29 @@ +import Foundation + +enum SVGParserError: LocalizedError { + + case invalidXML + case invalidSVG + case missingCanvasSize + case invalidPathData(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 .unsupportedFill(fill): + return "The SVG file contains a path with unsupported fill \"\(fill)\"." + } + } +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPath.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPath.swift new file mode 100644 index 0000000..a4030b3 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPath.swift @@ -0,0 +1,57 @@ +import Foundation + +struct SVGPath { + + let attributes: [String: String] + let groupIDs: [String] + let inheritedTransforms: [String] + let fill: SVGColor? + + init( + attributes: [String : String], + groupIDs: [String], + inheritedTransforms: [String], + fill: SVGColor? + ) { + self.attributes = attributes + self.groupIDs = groupIDs + self.inheritedTransforms = inheritedTransforms + self.fill = fill + } + + var id: String? { + attributes["id"] + } + + var data: String? { + attributes["d"] + } + + var fillRule: String? { + attributes["fill-rule"] + } + + var isPrimary: Bool { + id == "primary" || fill == .black + } + + var isSecondary: Bool { + id == "secondary" || fill != .black + } + + var isTertiary: Bool { + id == "tertiary" || fill != .black + } + + var isUnknown: Bool { + fill == nil + } + + var transform: String? { + attributes["transform"] + } + + var allTransforms: [String] { + inheritedTransforms + [transform].compactMap { $0 } + } +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPathsResult.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPathsResult.swift new file mode 100644 index 0000000..aa9595b --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/Models/SVGPathsResult.swift @@ -0,0 +1,23 @@ +import Foundation + +struct SVGPathsResult { + + let id: String + let canvas: SVGCanvas? + let allPaths: [SVGPath] + let primaryPaths: [SVGPath] + let secondaryPaths: [SVGPath] + let tertiaryPaths: [SVGPath] + let unknownPaths: [SVGPath] + + init(id: String, canvas: SVGCanvas? = nil, allPaths: [SVGPath]) { + self.id = id + self.canvas = canvas + self.allPaths = allPaths + + primaryPaths = allPaths.filter(\.isPrimary) + secondaryPaths = allPaths.filter(\.isSecondary) + tertiaryPaths = allPaths.filter(\.isTertiary) + unknownPaths = allPaths.filter(\.isUnknown) + } +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/SVGParser.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/SVGParser.swift new file mode 100644 index 0000000..3c13c04 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/SVGParser.swift @@ -0,0 +1,249 @@ +import Foundation +import FigmaGenTools + +final class SVGParser: NSObject { + + private enum ElementName { + + static let group = "g" + static let path = "path" + static let svg = "svg" + + // Elements whose paths only describe reusable definitions and never + // contribute to the visible drawing. + 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 parsedPaths: [SVGPath] = [] + private var groupStack: [SVGGroupContext] = [] + private var ignoredElementDepth = 0 + + private(set) var canvas: SVGCanvas? + + func parse(data: Data) throws -> [SVGPath] { + parsedPaths.removeAll() + groupStack.removeAll() + ignoredElementDepth = 0 + canvas = nil + +// let decoder = JSONDecoder() +// let response = try? decoder.decode(AnyCodable.self, from: data) + + let parser = XMLParser(data: data) + + parser.delegate = self + parser.shouldResolveExternalEntities = false + parser.shouldProcessNamespaces = false + + guard parser.parse() else { + let error = parser.parserError + + print("XML parsing failed") + print("Error:", error?.localizedDescription ?? "") + print("Domain:", (error as NSError?)?.domain ?? "") + print("Code:", (error as NSError?)?.code ?? -1) + print("Line:", parser.lineNumber) + print("Column:", parser.columnNumber) + + if let error = error as? NSError { + print("User info:", error.userInfo) + } + + if let text = String(data: data, encoding: .utf8) { + print("SVG prefix:") + print(String(text.prefix(500))) + + print("SVG suffix:") + print(String(text.suffix(500))) + } + throw error + ?? SVGParserError.invalidXML + } + + return 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 SVGParser: 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) + 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() + } + + func combinedTransform( + groupStack: [SVGGroupContext], + pathAttributes: [String: String] + ) -> [String] { + let groupTransforms = groupStack.compactMap(\.transform) + + let pathTransform = pathAttributes[Attributes.transform] + + let allTransforms = groupTransforms + + [pathTransform].compactMap { $0 } + + guard !allTransforms.isEmpty else { + return [] + } + + return allTransforms + } +} diff --git a/Sources/FigmaGen/Providers/Images/SFSymbols/SVGPathTransformer.swift b/Sources/FigmaGen/Providers/Images/SFSymbols/SVGPathTransformer.swift new file mode 100644 index 0000000..6c47496 --- /dev/null +++ b/Sources/FigmaGen/Providers/Images/SFSymbols/SVGPathTransformer.swift @@ -0,0 +1,276 @@ +import Foundation + +// Applies a uniform scale and a vertical translation to SVG path data, +// mapping Figma coordinates into the coordinate space of an SF Symbols variant. +struct SVGPathTransformer { + + // MARK: - Nested Types + + private enum Command { + + // Number of parameters in a single parameter group of a 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 + ] + + // Indexes of the large-arc and sweep flags of an elliptical arc. + static let arcFlagIndexes = [3, 4] + } + + private struct Reader { + + // 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 + } + + mutating func readCommand() -> Character? { + skipSeparators() + + guard index < characters.count, characters[index].isLetter else { + return nil + } + + defer { index += 1 } + + return characters[index] + } + + 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 + } + } + } + + // MARK: - Instance Properties + + let scale: Double + let translationY: Double + + // MARK: - Instance Methods + + private func transformed( + parameters: [Double], + command: Character, + isAbsolute: Bool + ) -> [Double] { + switch command { + case "h": + return parameters.map { $0 * scale } + + case "v": + return parameters.map { isAbsolute ? $0 * scale + translationY : $0 * scale } + + case "a": + var parameters = parameters + + parameters[0] *= scale + parameters[1] *= scale + parameters[5] *= scale + parameters[6] = isAbsolute ? parameters[6] * scale + translationY : parameters[6] * scale + + return parameters + + default: + return parameters.enumerated().map { index, value in + guard index.isMultiple(of: 2) else { + return isAbsolute ? value * scale + translationY : value * scale + } + + return value * scale + } + } + } + + func transform(pathData: String) throws -> String { + var reader = Reader(pathData) + var commands: [String] = [] + + while let command = reader.readCommand() { + let lowercased = Character(command.lowercased()) + + guard let arity = Command.arities[lowercased] else { + throw SVGParserError.invalidPathData(pathData) + } + + guard arity > 0 else { + commands.append(String(command)) + + continue + } + + let isAbsolute = command.isUppercase + var groups: [String] = [] + + repeat { + var parameters: [Double] = [] + + for index in 0.. 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/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 %} + + From 1fb7641cfed1d25c85d2116b113209845a2968e5 Mon Sep 17 00:00:00 2001 From: Darya Viter Date: Thu, 3 Sep 2026 15:02:08 +0300 Subject: [PATCH 5/8] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Kolya/SF_SYMBOLS.md | 349 +++++++++++++++ ...n-bubble-round-hyperstar-filled-origin.svg | 11 + Kolya/icon-bubble-round-hyperstar-filled.svg | 38 ++ ...bubble-round-hyperstar-outlined-origin.svg | 12 + .../icon-bubble-round-hyperstar-outlined.svg | 40 ++ Kolya/icon-folder-hyperstar-filled-origin.svg | 17 + Kolya/icon-folder-hyperstar-filled.svg | 40 ++ .../icon-folder-hyperstar-outlined-origin.svg | 16 + Kolya/icon-folder-hyperstar-outlined.svg | 38 ++ Kolya/icon-image-hyperstar-filled.svg | 40 ++ Kolya/icon-image-hyperstar-outlined.svg | 40 ++ ...icon-magnifier-filled-hyperstar-filled.svg | 38 ++ ...on-magnifier-outlined-hyperstar-filled.svg | 38 ++ Kolya/icon-videocamera-hyperstar-filled.svg | 38 ++ Kolya/icon-videocamera-hyperstar-outlined.svg | 38 ++ Kolya/image.with.tertiary.svg | 115 +++++ Kolya/sf-symbol-analysis.ts | 153 +++++++ Kolya/sf-symbol-cli.ts | 28 ++ Kolya/sf-symbol-config.ts | 34 ++ Kolya/sf-symbol-generation.ts | 412 ++++++++++++++++++ Sources/FigmaGen/Dependencies.swift | 8 +- .../SFSymbol}/SFSymbolLayer.swift | 0 .../SFSymbol}/SFSymbolPathData.swift | 0 .../SFSymbol}/SFSymbolRole.swift | 0 .../Models => Models/SVG}/SVGCanvas.swift | 0 .../Models => Models/SVG}/SVGColor.swift | 0 .../SVG}/SVGGroupContext.swift | 0 .../Models => Models/SVG}/SVGImageToken.swift | 0 Sources/FigmaGen/Models/SVG/SVGNumber.swift | 23 + .../Models => Models/SVG}/SVGPath.swift | 2 + .../SVG}/SVGPathsResult.swift | 2 +- .../DataProvider/DefaultDataProvider.swift | 39 +- .../DataProvider/SFSymbolProvider.swift | 221 ---------- .../Assets/DefaultImageAssetsProvider.swift | 59 ++- .../Render/DefaultImageRenderProvider.swift | 2 - .../Images/SFSymbols/SVGParser.swift | 46 +- .../{Models => }/SVGParserError.swift | 0 .../Images/SFSymbols/SVGPathTransformer.swift | 212 ++------- .../Images/SFSymbols/SVGReader.swift | 135 ++++++ .../DefaultSFSymbolProvider.swift | 302 +++++++++++++ .../SFSymbolProvider/SFSymbolProvider.swift | 13 + 41 files changed, 2108 insertions(+), 491 deletions(-) create mode 100644 Kolya/SF_SYMBOLS.md create mode 100644 Kolya/icon-bubble-round-hyperstar-filled-origin.svg create mode 100644 Kolya/icon-bubble-round-hyperstar-filled.svg create mode 100644 Kolya/icon-bubble-round-hyperstar-outlined-origin.svg create mode 100644 Kolya/icon-bubble-round-hyperstar-outlined.svg create mode 100644 Kolya/icon-folder-hyperstar-filled-origin.svg create mode 100644 Kolya/icon-folder-hyperstar-filled.svg create mode 100644 Kolya/icon-folder-hyperstar-outlined-origin.svg create mode 100644 Kolya/icon-folder-hyperstar-outlined.svg create mode 100644 Kolya/icon-image-hyperstar-filled.svg create mode 100644 Kolya/icon-image-hyperstar-outlined.svg create mode 100644 Kolya/icon-magnifier-filled-hyperstar-filled.svg create mode 100644 Kolya/icon-magnifier-outlined-hyperstar-filled.svg create mode 100644 Kolya/icon-videocamera-hyperstar-filled.svg create mode 100644 Kolya/icon-videocamera-hyperstar-outlined.svg create mode 100644 Kolya/image.with.tertiary.svg create mode 100644 Kolya/sf-symbol-analysis.ts create mode 100644 Kolya/sf-symbol-cli.ts create mode 100644 Kolya/sf-symbol-config.ts create mode 100644 Kolya/sf-symbol-generation.ts rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SFSymbol}/SFSymbolLayer.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SFSymbol}/SFSymbolPathData.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SFSymbol}/SFSymbolRole.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGCanvas.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGColor.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGGroupContext.swift (100%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGImageToken.swift (100%) create mode 100644 Sources/FigmaGen/Models/SVG/SVGNumber.swift rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGPath.swift (96%) rename Sources/FigmaGen/{Providers/Images/SFSymbols/Models => Models/SVG}/SVGPathsResult.swift (88%) delete mode 100644 Sources/FigmaGen/Providers/DataProvider/SFSymbolProvider.swift rename Sources/FigmaGen/Providers/Images/SFSymbols/{Models => }/SVGParserError.swift (100%) create mode 100644 Sources/FigmaGen/Providers/Images/SFSymbols/SVGReader.swift create mode 100644 Sources/FigmaGen/Providers/SFSymbolProvider/DefaultSFSymbolProvider.swift create mode 100644 Sources/FigmaGen/Providers/SFSymbolProvider/SFSymbolProvider.swift diff --git a/Kolya/SF_SYMBOLS.md b/Kolya/SF_SYMBOLS.md new file mode 100644 index 0000000..181ee2f --- /dev/null +++ b/Kolya/SF_SYMBOLS.md @@ -0,0 +1,349 @@ +# SF Symbols Generation + +This document describes the multicolor SF Symbols pipeline for developers maintaining or consuming the generated assets. + +The SF Symbols pipeline is separate from the monochrome icon-font pipeline. It exists because colored icons need two independently configurable colors on iOS. The generated symbols use SF Symbols Palette rendering, where black Figma paths become the primary layer and red paths become the secondary layer. + +## Main Points + +- Figma remains the source of icon geometry. +- Only `colored=true` variants from the configured `icon` page and `icon` frame are included. +- Every symbol requires separate 16 px and 24 px optical drawings. +- The 16 px drawing is not generated by scaling the 24 px drawing. +- Black paths map to the Palette primary color. +- Red `#FF0002` paths map to the Palette secondary color. +- The 16 px drawing is stored as the `Regular-S` symbol variant. +- The 24 px drawing is stored as the `Regular-M` symbol variant. +- Both variants are designed for a 24-point base symbol configuration. +- SF Symbols do not provide a persistent four-sided image canvas. iOS layout must provide the final `16×16` or `24×24` frame. +- Do not use `.resizable()` to size these symbols. It scales visible path bounds and changes the relationship between artwork and its original Figma box. + +## Pipeline + +```text +Figma library +→ find colored component sets +→ select size=16 and size=24, colored=true +→ export raw SVG files +→ validate SVG features and colors +→ convert evenodd geometry to nonzero paths +→ map paths to Palette layers +→ transform optical drawings into SF Symbols coordinates +→ generate Template v3 SVG symbolsets +→ generate an Xcode asset catalog +→ write a geometry and validation report +``` + +Run the complete pipeline with: + +```bash +npm run symbols:build +``` + +The command loads `FIGMA_TOKEN` from `.env.local` through Node's `--env-file` option. + +## Source Requirements in Figma + +The configured source is defined in `magritte-sf-symbols.config.ts`: + +- Figma file key: `figmaFileKey` +- Page: `icon` +- Top-level frame: `icon` +- Name prefix: `icon` +- Required optical sizes: `16` and `24` + +A component set is eligible only when its component property definitions contain a `colored` variant with a `true` option. + +For every eligible component set, the pipeline requires: + +```text +size=16, colored=true +size=24, colored=true +``` + +The component height must equal its configured optical size. Width may vary. A `27×16` component is valid for the 16 px optical size, and a `37×24` component is valid for the 24 px optical size. + +The component box is the source of layout dimensions. The visible paths are not used to infer the component width or height. This preserves intentional Figma padding and non-square proportions during coordinate conversion. + +Generated names use the configured prefix plus the component-set name. Name normalization is handled by `src/names.ts`. + +## Color and Layer Mapping + +The pipeline currently supports two source colors: + +| Figma color | SF Symbols role | +| --- | --- | +| Black, `#000`, or `#000000` | Primary | +| `#FF0002` | Secondary | + +Every SVG path must use one of these fills. Unknown or missing fills fail the build. + +Each optical size must contain the same sequence of Palette roles. For example, if the 16 px drawing exports paths as `primary, secondary, primary`, the 24 px drawing must export the same role sequence. This requirement keeps SF Symbols layer annotations synchronized across variants. + +The path geometry may differ between optical sizes, but path count and role order must remain compatible. + +## SVG Validation + +Raw SVGs are exported to: + +```text +cache/sf-symbols-raw/16/ +cache/sf-symbols-raw/24/ +``` + +The SF Symbols pipeline accepts filled paths and rejects features that cannot be represented safely in the generated symbol template. + +Rejected features include: + +- Raster images +- Gradients +- Masks +- Filters +- Patterns +- `