diff --git a/CONFIG.md b/CONFIG.md index 8c4b1dfe..d202c641 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -15,6 +15,10 @@ figma: lightFileId: shPilWnVdJfo10YF12345 # [optional] Identifier of the file containing dark color palette and dark images. darkFileId: KfF6DnJTWHGZzC912345 + # [optional] Identifier of the file containing light high contrast color palette. + lightHighContrastFileId: KfF6DnJTWHGZzC912345 + # [optional] Identifier of the file containing dark high contrast color palette. + darkHighContrastFileId: KfF6DnJTWHGZzC912345 # [optional] Figma API request timeout. The default value of this property is 30 (seconds). If you have a lot of resources to export set this value to 60 or more to give Figma API more time to prepare resources for exporting. # timeout: 30 @@ -30,6 +34,10 @@ common: useSingleFile: true # [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' darkModeSuffix: '_dark' + # [optional] If useSingleFile is true, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' + lightHCModeSuffix: '_lightHC' + # [optional] If useSingleFile is true, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' + darkHCModeSuffix: '_darkHC' # [optional] icons: # [optional] Name of the Figma's frame where icons components are located diff --git a/Examples/Example/figma-export.yaml b/Examples/Example/figma-export.yaml index c3df305c..fccd25e0 100644 --- a/Examples/Example/figma-export.yaml +++ b/Examples/Example/figma-export.yaml @@ -8,7 +8,7 @@ common: # [optional] colors: # [optional] RegExp pattern for color name validation before exporting. Use to validate color name in Figma file - nameValidateRegexp: '^[a-zA-Z_]+$' # RegExp pattern for: background, background_primary, widget_primary_background + nameValidateRegexp: '^([a-zA-Z_]+)$' # RegExp pattern for: background, background_primary, widget_primary_background # [optional] icons: # [optional] RegExp pattern for icon name validation before exporting. Use to validate icon name in Figma file @@ -32,7 +32,7 @@ ios: # Parameters for exporting colors colors: # Should be generate color assets instead of pure swift code - useColorAssets: True + useColorAssets: true # Name of the folder inside Assets.xcassets where to place colors (.colorset directories) assetsFolder: Colors # Color name style: camelCase or snake_case diff --git a/README.md b/README.md index f1723a33..9a35bee9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Table of Contents: * Export images to Xcode / Android Studio project * Export text styles to Xcode / Android Studio project * Supports Dark Mode +* Supports High contrast colors for Xcode * Supports SwiftUI and UIKit * Supports Objective-C @@ -454,7 +455,11 @@ For `figma-export colors` By default, if you support dark mode your Figma project must contains two files. One should contains a dark color palette, and the another light color palette. If you would like to specify light and dark colors in the same file, you can do so with the `useSingleFile` configuration option. You can then denote dark mode colors by adding a suffix like `_dark`. The suffix is also configurable. See [CONFIG.md](CONFIG.md) for more information in the colors section. -The light color palette may contain more colors than the dark color palette. If a light-only color is present, it will be considered as universal color for the iOS color palette. Names of the dark colors must match the light colors. +If you support high contrast mode without dark mode your Figma project must contains two files. One should contains a high contrast color palette, and the another light color palette. If you would like to specify light and high contrast colors in the same file, you can do so with the `useSingleFile` configuration option. You can then denote high contrast mode colors by adding a suffix like `_lightHC`. The suffix is also configurable. See [CONFIG.md](CONFIG.md) for more information in the colors section. + +If you support high contrast mode with dark mode your Figma project must contains four files. Should be like this: light, dark, high contrast light, high contrast dark. If you would like to specify colors in the same file, you can do so with the `useSingleFile` configuration option. You can then denote high contrast mode colors by adding a suffix like `_lightHC` for light and `_darkHC` for dark high contrast colors. The suffix is also configurable. See [CONFIG.md](CONFIG.md) for more information in the colors section. + +The light color palette may contain more colors than the dark or high light color palette wherein the dark color palette may contain more colors than the high dark color palette. If a light-only color is present, it will be considered as universal color for the iOS color palette. Names of the dark, high light and high dark colors must match the light colors. Example diff --git a/Sources/FigmaExport/Input/Params.swift b/Sources/FigmaExport/Input/Params.swift index 4babcfc1..4f8db0a5 100644 --- a/Sources/FigmaExport/Input/Params.swift +++ b/Sources/FigmaExport/Input/Params.swift @@ -8,6 +8,8 @@ struct Params: Decodable { struct Figma: Decodable { let lightFileId: String let darkFileId: String? + let lightHighContrastFileId: String? + let darkHighContrastFileId: String? let timeout: TimeInterval? } @@ -17,6 +19,8 @@ struct Params: Decodable { let nameReplaceRegexp: String? let useSingleFile: Bool? let darkModeSuffix: String? + let lightHCModeSuffix: String? + let darkHCModeSuffix: String? } struct Icons: Decodable { diff --git a/Sources/FigmaExport/Loaders/ColorsLoader.swift b/Sources/FigmaExport/Loaders/ColorsLoader.swift index 3df7b749..2de005b8 100644 --- a/Sources/FigmaExport/Loaders/ColorsLoader.swift +++ b/Sources/FigmaExport/Loaders/ColorsLoader.swift @@ -13,34 +13,55 @@ final class ColorsLoader { self.figmaParams = figmaParams self.colorParams = colorParams } - - func load() throws -> (light: [Color], dark: [Color]?) { - if let useSingleFile = colorParams?.useSingleFile, useSingleFile { - return try loadColorsFromSingleFile() - } else { + + func load() throws -> (light: [Color], dark: [Color]?, lightHC: [Color]?, darkHC: [Color]?) { + guard let useSingleFile = colorParams?.useSingleFile, useSingleFile else { return try loadColorsFromLightAndDarkFile() } + return try loadColorsFromSingleFile() } - private func loadColorsFromLightAndDarkFile() throws -> (light: [Color], dark: [Color]?) { + private func loadColorsFromLightAndDarkFile() throws -> (light: [Color], + dark: [Color]?, + lightHC: [Color]?, + darkHC: [Color]?) { let lightColors = try loadColors(fileId: figmaParams.lightFileId) let darkColors = try figmaParams.darkFileId.map { try loadColors(fileId: $0) } - return (lightColors, darkColors) + let lightHighContrastColors = try figmaParams.lightHighContrastFileId.map { try loadColors(fileId: $0) } + let darkHighContrastColors = try figmaParams.darkHighContrastFileId.map { try loadColors(fileId: $0) } + return (lightColors, darkColors, lightHighContrastColors, darkHighContrastColors) } - private func loadColorsFromSingleFile() throws -> (light: [Color], dark: [Color]?) { + private func loadColorsFromSingleFile() throws -> (light: [Color], + dark: [Color]?, + lightHC: [Color]?, + darkHC: [Color]?) { let colors = try loadColors(fileId: figmaParams.lightFileId) let darkSuffix = colorParams?.darkModeSuffix ?? "_dark" + let lightHCSuffix = colorParams?.lightHCModeSuffix ?? "_lightHC" + let darkHCSuffix = colorParams?.darkHCModeSuffix ?? "_darkHC" + let lightColors = colors - .filter { !$0.name.hasSuffix(darkSuffix) } - let darkColors = colors - .filter { $0.name.hasSuffix(darkSuffix) } + .filter { + !$0.name.hasSuffix(darkSuffix) && + !$0.name.hasSuffix(lightHCSuffix) && + !$0.name.hasSuffix(darkHCSuffix) + } + let darkColors = filteredColors(colors, suffix: darkSuffix) + let lightHCColors = filteredColors(colors, suffix: lightHCSuffix) + let darkHCColors = filteredColors(colors, suffix: darkHCSuffix) + return (lightColors, darkColors, lightHCColors, darkHCColors) + } + + private func filteredColors(_ colors: [Color], suffix: String) -> [Color] { + let filteredColors = colors + .filter { $0.name.hasSuffix(suffix) } .map { color -> Color in var newColor = color - newColor.name = String(color.name.dropLast(darkSuffix.count)) + newColor.name = String(color.name.dropLast(suffix.count)) return newColor } - return (lightColors, darkColors) + return filteredColors } private func loadColors(fileId: String) throws -> [Color] { diff --git a/Sources/FigmaExport/Subcommands/ExportColors.swift b/Sources/FigmaExport/Subcommands/ExportColors.swift index 5bf2bf42..9a345ce1 100644 --- a/Sources/FigmaExport/Subcommands/ExportColors.swift +++ b/Sources/FigmaExport/Subcommands/ExportColors.swift @@ -34,7 +34,10 @@ extension FigmaExportCommand { nameReplaceRegexp: options.params.common?.colors?.nameReplaceRegexp, nameStyle: options.params.ios?.colors?.nameStyle ) - let colorPairs = processor.process(light: colors.light, dark: colors.dark) + let colorPairs = processor.process(light: colors.light, + dark: colors.dark, + lightHC: colors.lightHC, + darkHC: colors.darkHC) if let warning = colorPairs.warning?.errorDescription { logger.warning("\(warning)") } @@ -68,7 +71,7 @@ extension FigmaExportCommand { logger.info("Done!") } } - + private func exportXcodeColors(colorPairs: [AssetPair], iosParams: Params.iOS) throws { guard let colorParams = iosParams.colors else { logger.error("Nothing to do. Add ios.colors parameters to the config file.") @@ -97,7 +100,7 @@ extension FigmaExportCommand { let exporter = XcodeColorExporter(output: output) let files = try exporter.export(colorPairs: colorPairs) - + if colorParams.useColorAssets, let url = colorsURL { try? FileManager.default.removeItem(atPath: url.path) } diff --git a/Sources/FigmaExportCore/AssetPair.swift b/Sources/FigmaExportCore/AssetPair.swift index bd803a0e..50b53ff6 100644 --- a/Sources/FigmaExportCore/AssetPair.swift +++ b/Sources/FigmaExportCore/AssetPair.swift @@ -2,9 +2,18 @@ public struct AssetPair where AssetType: Asset { public let light: AssetType public let dark: AssetType? + public let lightHC: AssetType? + public let darkHC: AssetType? - public init(light: AssetType, dark: AssetType?) { + public init( + light: AssetType, + dark: AssetType?, + lightHC: AssetType? = nil, + darkHC: AssetType? = nil + ) { self.light = light self.dark = dark + self.lightHC = lightHC + self.darkHC = darkHC } } diff --git a/Sources/FigmaExportCore/Helpers/Zip4Sequence.swift b/Sources/FigmaExportCore/Helpers/Zip4Sequence.swift new file mode 100644 index 00000000..18c2ca03 --- /dev/null +++ b/Sources/FigmaExportCore/Helpers/Zip4Sequence.swift @@ -0,0 +1,203 @@ + +/// Creates a sequence of tuples built out of 4 underlying sequences. +/// +/// In the `Zip4Sequence` instance returned by this function, the elements of +/// the *i*th tuple are the *i*th elements of each underlying sequence. The +/// following example uses the `zip(_:_:)` function to iterate over an array +/// of strings and a countable range at the same time: +/// +/// let words = ["one", "two", "three", "four"] +/// let numbers = 1...4 +/// +/// for (word, number) in zip(words, numbers) { +/// print("\(word): \(number)") +/// } +/// // Prints "one: 1" +/// // Prints "two: 2 +/// // Prints "three: 3" +/// // Prints "four: 4" +/// +/// If the 4 sequences passed to `zip(_:_:_:_:)` are different lengths, the +/// resulting sequence is the same length as the shortest sequence. In this +/// example, the resulting array is the same length as `words`: +/// +/// let naturalNumbers = 1...Int.max +/// let zipped = Array(zip(words, naturalNumbers)) +/// // zipped == [("one", 1), ("two", 2), ("three", 3), ("four", 4)] +/// +/// - Parameters: +/// - sequence1: The sequence or collection in position 1 of each tuple. +/// - sequence2: The sequence or collection in position 2 of each tuple. +/// - sequence3: The sequence or collection in position 3 of each tuple. +/// - sequence4: The sequence or collection in position 4 of each tuple. +/// - Returns: A sequence of tuple pairs, where the elements of each pair are +/// corresponding elements of `sequence1` and `sequence2`. +public func zip< + Sequence1 : Sequence, + Sequence2 : Sequence, + Sequence3 : Sequence, + Sequence4 : Sequence +>( + _ sequence1: Sequence1, + _ sequence2: Sequence2, + _ sequence3: Sequence3, + _ sequence4: Sequence4 + +) -> Zip4Sequence< + Sequence1, + Sequence2, + Sequence3, + Sequence4 +> { + return Zip4Sequence( + _sequence1: sequence1, + _sequence2: sequence2, + _sequence3: sequence3, + _sequence4: sequence4 + ) +} + +/// An iterator for `Zip4Sequence`. +public struct Zip4Iterator< + Iterator1 : IteratorProtocol, + Iterator2 : IteratorProtocol, + Iterator3 : IteratorProtocol, + Iterator4 : IteratorProtocol +> : IteratorProtocol { + /// The type of element returned by `next()`. + public typealias Element = ( + Iterator1.Element, + Iterator2.Element, + Iterator3.Element, + Iterator4.Element + ) + + /// Creates an instance around the underlying iterators. + internal init( + _ iterator1: Iterator1, + _ iterator2: Iterator2, + _ iterator3: Iterator3, + _ iterator4: Iterator4 + ) { + _baseStream1 = iterator1 + _baseStream2 = iterator2 + _baseStream3 = iterator3 + _baseStream4 = iterator4 + } + + /// Advances to the next element and returns it, or `nil` if no next element + /// exists. + /// + /// Once `nil` has been returned, all subsequent calls return `nil`. + public mutating func next() -> Element? { + // The next() function needs to track if it has reached the end. If we + // didn't, and the first sequence is longer than the second, then when we + // have already exhausted the second sequence, on every subsequent call to + // next() we would consume and discard one additional element from the + // first sequence, even though next() had already returned nil. + + if _reachedEnd { + return nil + } + + guard + let element1 = _baseStream1.next(), + let element2 = _baseStream2.next(), + let element3 = _baseStream3.next(), + let element4 = _baseStream4.next() + else { + _reachedEnd = true + return nil + } + + return ( + element1, + element2, + element3, + element4 + ) + } + + internal var _baseStream1: Iterator1 + internal var _baseStream2: Iterator2 + internal var _baseStream3: Iterator3 + internal var _baseStream4: Iterator4 + internal var _reachedEnd: Bool = false +} + +/// A sequence of pairs built out of two underlying sequences. +/// +/// In a `Zip4Sequence` instance, the elements of the *i*th pair are the *i*th +/// elements of each underlying sequence. To create a `Zip4Sequence` instance, +/// use the `zip(_:_:_:_:)` function. +/// +/// The following example uses the `zip(_:_:)` function to iterate over an +/// array of strings and a countable range at the same time: +/// +/// let words = ["one", "two", "three", "four"] +/// let numbers = 1...4 +/// +/// for (word, number) in zip(words, numbers) { +/// print("\(word): \(number)") +/// } +/// // Prints "one: 1" +/// // Prints "two: 2 +/// // Prints "three: 3" +/// // Prints "four: 4" +/// +/// - SeeAlso: `zip(_:_:_:_:)` +public struct Zip4Sequence< + Sequence1 : Sequence, + Sequence2 : Sequence, + Sequence3 : Sequence, + Sequence4 : Sequence +> +: Sequence { + + public typealias Stream1 = Sequence1.Iterator + public typealias Stream2 = Sequence2.Iterator + public typealias Stream3 = Sequence3.Iterator + public typealias Stream4 = Sequence4.Iterator + + /// A type whose instances can produce the elements of this + /// sequence, in order. + public typealias Iterator = Zip4Iterator< + Stream1, + Stream2, + Stream3, + Stream4 + > + + @available(*, unavailable, renamed: "Iterator") + public typealias Generator = Iterator + + /// Creates an instance that makes pairs of elements from `sequence1` and + /// `sequence2`. + public // @testable + init( + _sequence1 sequence1: Sequence1, + _sequence2 sequence2: Sequence2, + _sequence3 sequence3: Sequence3, + _sequence4 sequence4: Sequence4 + ) { + _sequence1 = sequence1 + _sequence2 = sequence2 + _sequence3 = sequence3 + _sequence4 = sequence4 + } + + /// Returns an iterator over the elements of this sequence. + public func makeIterator() -> Iterator { + return Iterator( + _sequence1.makeIterator(), + _sequence2.makeIterator(), + _sequence3.makeIterator(), + _sequence4.makeIterator() + ) + } + + internal let _sequence1: Sequence1 + internal let _sequence2: Sequence2 + internal let _sequence3: Sequence3 + internal let _sequence4: Sequence4 +} diff --git a/Sources/FigmaExportCore/Processor/AssetsProcessor.swift b/Sources/FigmaExportCore/Processor/AssetsProcessor.swift index eec46ef3..cc11cd51 100644 --- a/Sources/FigmaExportCore/Processor/AssetsProcessor.swift +++ b/Sources/FigmaExportCore/Processor/AssetsProcessor.swift @@ -36,7 +36,7 @@ public protocol AssetsProcessable: AssetNameProcessable { var platform: Platform { get } - func process(light: [AssetType], dark: [AssetType]?) -> ProcessingPairResult + func process(light: [AssetType], dark: [AssetType]?, lightHC: [AssetType]?, darkHC: [AssetType]?) -> ProcessingPairResult func process(assets: [AssetType]) -> ProcessingResult } @@ -90,184 +90,170 @@ public struct ImagesProcessor: AssetsProcessable { public extension AssetsProcessable { - func process(light: [AssetType], dark: [AssetType]?) -> ProcessingPairResult { - if let dark = dark { - return validateAndMakePairs( - light: normalizeAssetName(assets: light), - dark: normalizeAssetName(assets: dark) - ) - } else { - return validateAndMakePairs( - light: normalizeAssetName(assets: light) - ) - } + func process(light: [AssetType], + dark: [AssetType]?, + lightHC: [AssetType]? = nil, + darkHC: [AssetType]? = nil) -> ProcessingPairResult { + guard let dark = dark else { return lightProcess(light: light, lightHC: lightHC) } + return darkProcess(light: light, dark: dark, lightHC: lightHC, darkHC: darkHC) } - - func process(assets: [AssetType]) -> ProcessingResult { - let assets = normalizeAssetName(assets: assets) - return validateAndProcess(assets: assets) - } - - private func validateAndProcess(assets: [AssetType]) -> ProcessingResult { - var errors = ErrorGroup() - // foundDuplicate - var set: Set = [] - assets.forEach { asset in - - // badName - if !isNameValid(asset.name) { - errors.all.append(AssetsValidatorError.badName(name: asset.name)) - } - - switch set.insert(asset) { - case (true, _): - break // ok - case (false, let oldMember): // already exists - errors.all.append(AssetsValidatorError.foundDuplicate(assetName: oldMember.name)) - } - } + private func lightProcess(light: [AssetType], lightHC: [AssetType]?) -> ProcessingPairResult { + return validateAndMakePairs(light: normalizeAssetName(light), + lightHighContrast: normalizeAssetName(lightHC ?? [])) + } - if !errors.all.isEmpty { - return .failure(errors) - } - - let assets = set - .sorted { $0.name < $1.name } - .filter { $0.platform == nil || $0.platform == platform } - .map { processedAssetName($0) } - - return .success(assets) + private func darkProcess(light: [AssetType], + dark: [AssetType], + lightHC: [AssetType]?, + darkHC: [AssetType]?) -> ProcessingPairResult { + return validateAndMakePairs(light: normalizeAssetName(light), + dark: normalizeAssetName(dark), + lightHC: normalizeAssetName(lightHC ?? []), + darkHC: normalizeAssetName(darkHC ?? [])) } - - private func replace(_ name: String, matchRegExp: String, replaceRegExp: String) -> String { - let result = name.replace(matchRegExp) { array in - replaceRegExp.replace(#"\$(\d)"#) { - let index = Int($0[1])! - return array[index] - } - } - - return result + + func process(assets: [AssetType]) -> ProcessingResult { + let assets = normalizeAssetName(assets) + return validateAndProcess(assets: assets) } - private func validateAndMakePairs(light: [AssetType]) -> ProcessingPairResult { + private func validateAndMakePairs(light: [AssetType], + lightHighContrast: [AssetType]) -> ProcessingPairResult { + // Error checks var errors = ErrorGroup() - - // foundDuplicate - var lightSet: Set = [] - light.forEach { asset in - - // badName - if !isNameValid(asset.name) { - errors.all.append(AssetsValidatorError.badName(name: asset.name)) - } - - switch lightSet.insert(asset) { - case (true, _): - break // ok - case (false, let oldMember): // already exists - errors.all.append(AssetsValidatorError.foundDuplicate(assetName: oldMember.name)) + // CountMismatch + if light.count < lightHighContrast.count { + errors.all.append(AssetsValidatorError.countMismatchLightHighContrastColors(light: light.count, lightHC: lightHighContrast.count)) + } + // FoundDuplicate + let lightSet: Set = foundDuplicate(assets: light, errors: &errors, isLightAssetSet: true) + let lightHCSet: Set = foundDuplicate(assets: lightHighContrast, errors: &errors) + // AssetNotFoundInLightPalette + checkSubtracting(firstAssetSet: lightSet, + firstAssetName: "Light", + secondAssetSet: lightHCSet, + secondAssetName: "Light high contrast", + errors: &errors) + // DescriptionMismatch + lightSet.forEach { asset in + if let platform = asset.platform, + let dark = lightHCSet.first(where: { $0.name == asset.name }), + dark.platform != platform { + let error = AssetsValidatorError.descriptionMismatch( + assetName: asset.name, + light: platform.rawValue, + dark: dark.platform?.rawValue ?? "") + errors.all.append(error) } } - - if !errors.all.isEmpty { - return .failure(errors) + // Return failure + guard errors.all.isEmpty else { return .failure(errors) } + // Warning checks + var warning: AssetsValidatorWarning? + // LightAssetNotFoundInLightHCPalette + if !lightHCSet.isEmpty { + let lightElements = lightSet.subtracting(lightHCSet) + if !lightElements.isEmpty { warning = .lightHCAssetsNotFoundInLightPalette(assets: lightElements.map { $0.name }) } } - - let pairs = makeSortedAssetPairs(lightSet: lightSet) - return .success(pairs) + let pairs = makeSortedAssetPairs(lightSet: lightSet, lightHCSet: lightHCSet) + return .success(pairs, warning: warning) } - private func validateAndMakePairs(light: [AssetType], dark: [AssetType]) -> ProcessingPairResult { - + private func validateAndMakePairs(light: [AssetType], + dark: [AssetType], + lightHC: [AssetType], + darkHC: [AssetType]) -> ProcessingPairResult { // Error checks - var errors = ErrorGroup() - - // 1. countMismatch + // CountMismatch if light.count < dark.count { errors.all.append(AssetsValidatorError.countMismatch(light: light.count, dark: dark.count)) } - - // 2. foundDuplicate - var lightSet: Set = [] - light.forEach { asset in - - // badName - if !isNameValid(asset.name) { - errors.all.append(AssetsValidatorError.badName(name: asset.name)) - } - - switch lightSet.insert(asset) { - case (true, _): - break // ok - case (false, let oldMember): // already exists - errors.all.append(AssetsValidatorError.foundDuplicate(assetName: oldMember.name)) - } - } - - var darkSet: Set = [] - dark.forEach { asset in - switch darkSet.insert(asset) { - case (true, _): - break // ok - case (false, let oldMember): // already exists - errors.all.append(AssetsValidatorError.foundDuplicate(assetName: oldMember.name)) - } + if light.count < lightHC.count { + errors.all.append(AssetsValidatorError.countMismatchLightHighContrastColors(light: light.count, lightHC: lightHC.count)) } - - // 3. darkAssetNotFoundInLightPalette - let darkElements = darkSet.subtracting(lightSet) - if !darkElements.isEmpty { - errors.all.append(AssetsValidatorError.darkAssetsNotFoundInLightPalette(assets: darkElements.map { $0.name })) + if dark.count < darkHC.count { + errors.all.append(AssetsValidatorError.countMismatchDarkHighContrastColors(dark: dark.count, darkHC: darkHC.count)) } - - // 4. descriptionMismatch + // FoundDuplicate + let lightSet: Set = foundDuplicate(assets: light, errors: &errors, isLightAssetSet: true) + let darkSet: Set = foundDuplicate(assets: dark, errors: &errors) + let lightHCSet: Set = foundDuplicate(assets: lightHC, errors: &errors) + let darkHCSet: Set = foundDuplicate(assets: darkHC, errors: &errors) + // AssetNotFoundInLightPalette + checkSubtracting(firstAssetSet: lightSet, + firstAssetName: "Light", + secondAssetSet: darkSet, + secondAssetName: "Dark", + errors: &errors) + checkSubtracting(firstAssetSet: lightSet, + firstAssetName: "Light", + secondAssetSet: lightHCSet, + secondAssetName: "Light high contrast", + errors: &errors) + checkSubtracting(firstAssetSet: darkSet, + firstAssetName: "Dark", + secondAssetSet: darkHCSet, + secondAssetName: "Dark high contrast", + errors: &errors) + // DescriptionMismatch lightSet.forEach { asset in - if - let platform = asset.platform, - let dark = darkSet.first(where: { $0.name == asset.name }), - dark.platform != platform { - + if let platform = asset.platform, + let dark = darkSet.first(where: { $0.name == asset.name }), + dark.platform != platform { let error = AssetsValidatorError.descriptionMismatch( assetName: asset.name, light: platform.rawValue, dark: dark.platform?.rawValue ?? "") - errors.all.append(error) } } - - if !errors.all.isEmpty { - return .failure(errors) - } - + // Return failure + guard errors.all.isEmpty else { return .failure(errors) } // Warning checks - var warning: AssetsValidatorWarning? - - // 1. lightAssetNotFoundInDarkPalette + // LightAssetNotFoundInDarkPalette let lightElements = lightSet.subtracting(darkSet) - if !lightElements.isEmpty { - warning = .lightAssetsNotFoundInDarkPalette(assets: lightElements.map { $0.name }) + if !lightElements.isEmpty { warning = .lightAssetsNotFoundInDarkPalette(assets: lightElements.map { $0.name }) } + // LightAssetNotFoundInLightHCPalette + if !lightHCSet.isEmpty { + let lightHCElements = lightSet.subtracting(lightHCSet) + if !lightHCElements.isEmpty { warning = .lightHCAssetsNotFoundInLightPalette(assets: lightHCElements.map { $0.name }) } + } + // DarkAssetNotFoundInDarkHCPalette + if !darkHCSet.isEmpty { + let darkHCElements = darkSet.subtracting(darkHCSet) + if !darkHCElements.isEmpty { warning = .darkHCAssetsNotFoundInDarkPalette(assets: darkHCElements.map { $0.name }) } } - let pairs = makeSortedAssetPairs(lightSet: lightSet, darkSet: darkSet) + let pairs = makeSortedAssetPairs(lightSet: lightSet, darkSet: darkSet, lightHCSet: lightHCSet, darkHCSet: darkHCSet) return .success(pairs, warning: warning) } - - private func makeSortedAssetPairs(lightSet: Set) -> [AssetPair] { - return lightSet + + private func makeSortedAssetPairs(lightSet: Set, + lightHCSet: Set) -> [AssetPair] { + let lightAssets = lightSet + .filter { $0.platform == platform || $0.platform == nil } .sorted { $0.name < $1.name } - .filter { $0.platform == nil || $0.platform == platform } - .map { AssetPair(light: processedAssetName($0), dark: nil) } + let lightHCAssetMap: [String: AssetType] = lightHCSet.reduce(into: [:]) { $0[$1.name] = $1 } + let lightHCAssets = lightAssets.map { lightHCAsset in lightHCAssetMap[lightHCAsset.name] } + let zipResult = zip(lightAssets, lightHCAssets) + return zipResult + .map { lightAsset, lightHCAsset in + AssetPair( + light: processedAssetName(lightAsset), + dark: nil, + lightHC: lightHCAsset.map { processedAssetName($0) + } + ) + } } - private func makeSortedAssetPairs( - lightSet: Set, - darkSet: Set) -> [AssetPair] { - + private func makeSortedAssetPairs(lightSet: Set, + darkSet: Set, + lightHCSet: Set, + darkHCSet: Set) -> [AssetPair] { let lightAssets = lightSet .filter { $0.platform == platform || $0.platform == nil } .sorted { $0.name < $1.name } @@ -276,17 +262,75 @@ public extension AssetsProcessable { // However the dark array may be smaller than the light array // Create a same size array of dark assets so we can zip in the next step let darkAssetMap: [String: AssetType] = darkSet.reduce(into: [:]) { $0[$1.name] = $1 } - let darkAssets = lightAssets.map { lightAsset in darkAssetMap[lightAsset.name] } + let darkAssets = lightAssets.map { darkAsset in darkAssetMap[darkAsset.name] } + let lightHCAssetMap: [String: AssetType] = lightHCSet.reduce(into: [:]) { $0[$1.name] = $1 } + let lightHCAssets = lightAssets.map { lightHCAsset in lightHCAssetMap[lightHCAsset.name] } + let darkHCAssetMap: [String: AssetType] = darkHCSet.reduce(into: [:]) { $0[$1.name] = $1 } + let darkHCAssets = lightAssets.map { darkHCAsset in darkHCAssetMap[darkHCAsset.name] } - let zipResult = zip(lightAssets, darkAssets) + let zipResult = zip(lightAssets, darkAssets, lightHCAssets, darkHCAssets) return zipResult - .map { lightAsset, darkAsset in - AssetPair( - light: processedAssetName(lightAsset), - dark: darkAsset.map { processedAssetName($0) } - ) + .map { lightAsset, darkAsset, lightHCAsset, darkHCAsset in + AssetPair(light: processedAssetName(lightAsset), + dark: darkAsset.map { processedAssetName($0) }, + lightHC: lightHCAsset.map { processedAssetName($0) }, + darkHC: darkHCAsset.map { processedAssetName($0) }) + } + } + + private func checkSubtracting(firstAssetSet: Set, + firstAssetName: String, + secondAssetSet: Set, + secondAssetName: String, + errors: inout ErrorGroup) { + let elements = secondAssetSet.subtracting(firstAssetSet) + if !elements.isEmpty { + errors.all.append(AssetsValidatorError.secondAssetsNotFoundInFirstPalette( + assets: elements.map { $0.name }, firstAssetsName: firstAssetName, secondAssetsName: secondAssetName)) + } + } + + private func validateAndProcess(assets: [AssetType]) -> ProcessingResult { + var errors = ErrorGroup() + // FoundDuplicate + let set = foundDuplicate(assets: assets, errors: &errors, isLightAssetSet: true) + // Return failure + guard errors.all.isEmpty else { return .failure(errors) } + let assets = set + .sorted { $0.name < $1.name } + .filter { $0.platform == nil || $0.platform == platform } + .map { processedAssetName($0) } + return .success(assets) + } + + private func foundDuplicate(assets: [AssetType], + errors: inout ErrorGroup, + isLightAssetSet: Bool = false) -> Set { + var assetSet: Set = [] + assets.forEach { asset in + if isLightAssetSet == true, !isNameValid(asset.name) { + errors.all.append(AssetsValidatorError.badName(name: asset.name)) } + switch assetSet.insert(asset) { + case (true, _): + break // ok + case (false, let oldMember): // already exists + errors.all.append(AssetsValidatorError.foundDuplicate(assetName: oldMember.name)) + } + } + return assetSet + } + + private func replace(_ name: String, matchRegExp: String, replaceRegExp: String) -> String { + let result = name.replace(matchRegExp) { array in + replaceRegExp.replace(#"\$(\d)"#) { + let index = Int($0[1])! + return array[index] + } + } + + return result } /// Runs the name replacement and name validation regexps, and name styles, if they are defined @@ -307,7 +351,7 @@ public extension AssetsProcessable { } /// Normalizes asset name by replacing "/" with "_" and by removing duplication (e.g. "color/color" becomes "color" - private func normalizeAssetName(assets: [AssetType]) -> [AssetType] { + private func normalizeAssetName(_ assets: [AssetType]) -> [AssetType] { assets.map { asset -> AssetType in var renamedAsset = asset diff --git a/Sources/FigmaExportCore/Processor/AssetsValidatorError.swift b/Sources/FigmaExportCore/Processor/AssetsValidatorError.swift index 2306736b..05b8c6bd 100644 --- a/Sources/FigmaExportCore/Processor/AssetsValidatorError.swift +++ b/Sources/FigmaExportCore/Processor/AssetsValidatorError.swift @@ -3,8 +3,10 @@ import Foundation enum AssetsValidatorError: LocalizedError { case badName(name: String) case countMismatch(light: Int, dark: Int) + case countMismatchLightHighContrastColors(light: Int, lightHC: Int) + case countMismatchDarkHighContrastColors(dark: Int, darkHC: Int) case foundDuplicate(assetName: String) - case darkAssetsNotFoundInLightPalette(assets: [String]) + case secondAssetsNotFoundInFirstPalette(assets: [String], firstAssetsName: String, secondAssetsName: String) case descriptionMismatch(assetName: String, light: String, dark: String) var errorDescription: String? { @@ -14,8 +16,12 @@ enum AssetsValidatorError: LocalizedError { error = "Bad asset name «\(name)»" case .countMismatch(let light, let dark): error = "The number of assets doesn’t match. Light theme contains \(light), and dark \(dark)." - case .darkAssetsNotFoundInLightPalette(let darks): - error = "Light theme doesn’t contains following assets: \(darks.joined(separator: ", ")), which exists in dark theme. Add these assets to light theme and publish to the Team Library." + case .countMismatchLightHighContrastColors(let light, let lightHC): + error = "The number of assets doesn’t match. Light color palette contains \(light), and light high contrast color palette \(lightHC)." + case .countMismatchDarkHighContrastColors(let dark, let darkHC): + error = "The number of assets doesn’t match. Dark color palette contains \(dark), and dark high contrast color palette \(darkHC)." + case .secondAssetsNotFoundInFirstPalette(let secondAsset, let firstAssetsName, let secondAssetsName): + error = "\(firstAssetsName) theme doesn’t contains following assets: \(secondAsset.joined(separator: ", ")), which exists in \(secondAssetsName.lowercased()) theme. Add these assets to \(firstAssetsName.lowercased()) theme and publish to the Team Library." case .foundDuplicate(let assetName): error = "Found duplicates of asset with name \(assetName). Remove duplicates." case .descriptionMismatch(let assetName, let light, let dark): diff --git a/Sources/FigmaExportCore/Processor/AssetsValidatorWarning.swift b/Sources/FigmaExportCore/Processor/AssetsValidatorWarning.swift index e134150a..02bfa938 100644 --- a/Sources/FigmaExportCore/Processor/AssetsValidatorWarning.swift +++ b/Sources/FigmaExportCore/Processor/AssetsValidatorWarning.swift @@ -2,12 +2,18 @@ import Foundation public enum AssetsValidatorWarning: LocalizedError { case lightAssetsNotFoundInDarkPalette(assets: [String]) - + case lightHCAssetsNotFoundInLightPalette(assets: [String]) + case darkHCAssetsNotFoundInDarkPalette(assets: [String]) + public var errorDescription: String? { var warning: String switch self { case .lightAssetsNotFoundInDarkPalette(let lights): warning = "The following assets will be considered universal because they are not found in the dark palette: \(lights.joined(separator: ", "))" + case .lightHCAssetsNotFoundInLightPalette(let lightsHC): + warning = "The following assets will be considered universal because they are not found in the light palette: \(lightsHC.joined(separator: ", "))" + case .darkHCAssetsNotFoundInDarkPalette(let darkHC): + warning = "The following assets will be considered universal because they are not found in the dark palette: \(darkHC.joined(separator: ", "))" } return "⚠️ \(warning)" } diff --git a/Sources/XcodeExport/Model/XcodeAssetContents.swift b/Sources/XcodeExport/Model/XcodeAssetContents.swift index 79c3b9df..61d4a306 100644 --- a/Sources/XcodeExport/Model/XcodeAssetContents.swift +++ b/Sources/XcodeExport/Model/XcodeAssetContents.swift @@ -15,9 +15,9 @@ struct XcodeAssetContents: Encodable { let version = 1 let author = "xcode" } - struct DarkAppearance: Encodable { - let appearance = "luminosity" - let value = "dark" + struct Appearance: Encodable { + let appearance: String + let value: String } struct Components: Encodable { var red: String @@ -35,13 +35,13 @@ struct XcodeAssetContents: Encodable { } struct ColorData: Encodable { let idiom = "universal" - var appearances: [DarkAppearance]? + var appearances: [Appearance]? var color: ColorInfo } struct ImageData: Encodable { let idiom: XcodeAssetIdiom var scale: String? - var appearances: [DarkAppearance]? + var appearances: [Appearance]? let filename: String } @@ -87,3 +87,8 @@ struct XcodeAssetContents: Encodable { self.properties = properties } } + +extension XcodeAssetContents.Appearance { + static var dark = Self(appearance: "luminosity", value: "dark") + static var highContrast = Self(appearance: "contrast", value: "high") +} diff --git a/Sources/XcodeExport/Model/XcodeExportExtensions.swift b/Sources/XcodeExport/Model/XcodeExportExtensions.swift index 88ce99e7..4ac67d2b 100644 --- a/Sources/XcodeExport/Model/XcodeExportExtensions.swift +++ b/Sources/XcodeExport/Model/XcodeExportExtensions.swift @@ -158,7 +158,7 @@ extension Image { func makeXcodeAssetContentsImageData(scale: Scale, appearance: Appearance? = nil) -> XcodeAssetContents.ImageData { let filename = makeFileURL(scale: scale, appearance: appearance).absoluteString let xcodeIdiom = idiom.flatMap { XcodeAssetIdiom(rawValue: $0) } ?? .universal - let appearances = appearance.flatMap { $0 == .dark ? [XcodeAssetContents.DarkAppearance()] : nil } + let appearances = appearance.flatMap { $0 == .dark ? [XcodeAssetContents.Appearance.dark] : nil } let scaleString = scale.string return XcodeAssetContents.ImageData( diff --git a/Sources/XcodeExport/XcodeColorExporter.swift b/Sources/XcodeExport/XcodeColorExporter.swift index fcc87d4d..cf02b888 100644 --- a/Sources/XcodeExport/XcodeColorExporter.swift +++ b/Sources/XcodeExport/XcodeColorExporter.swift @@ -124,7 +124,8 @@ final public class XcodeColorExporter: XcodeExporterBase { ) } - private func makeAssets(for colorPairs: [AssetPair], assetCatalogURL: URL) throws -> [FileContents] { + private func makeAssets(for colorPairs: [AssetPair], + assetCatalogURL: URL) throws -> [FileContents] { try colorPairs.flatMap { colorPair -> [FileContents] in var files = [FileContents]() @@ -155,15 +156,37 @@ final public class XcodeColorExporter: XcodeExporterBase { XcodeAssetContents.ColorData( appearances: nil, color: XcodeAssetContents.ColorInfo( - components: colorPair.light.toHexComponents()) + components: colorPair.light.toHexComponents() + ) ) ] if let darkColor = colorPair.dark { colors.append( XcodeAssetContents.ColorData( - appearances: [XcodeAssetContents.DarkAppearance()], + appearances: [.dark], + color: XcodeAssetContents.ColorInfo( + components: darkColor.toHexComponents() + ) + ) + ) + } + if let lightHCColor = colorPair.lightHC { + colors.append( + XcodeAssetContents.ColorData( + appearances: [.highContrast], + color: XcodeAssetContents.ColorInfo( + components: lightHCColor.toHexComponents() + ) + ) + ) + } + if let darkHCColor = colorPair.darkHC { + colors.append( + XcodeAssetContents.ColorData( + appearances: [.dark, .highContrast], color: XcodeAssetContents.ColorInfo( - components: darkColor.toHexComponents()) + components: darkHCColor.toHexComponents() + ) ) ) } diff --git a/Tests/FigmaExportTests/AssetsProcessorTests.swift b/Tests/FigmaExportTests/AssetsProcessorTests.swift index 1ec40112..df2bdae2 100644 --- a/Tests/FigmaExportTests/AssetsProcessorTests.swift +++ b/Tests/FigmaExportTests/AssetsProcessorTests.swift @@ -176,6 +176,151 @@ final class AssetsProcessorTests: XCTestCase { XCTAssertThrowsError(try processor.process(light: lights, dark: darks).get()) } + + // Light count can exceed lightHC count + func testProcessWithUniversalAsset3() throws { + let lights = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let lightHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + nameStyle: .camelCase + ) + let colors = try processor.process(light: lights, dark: nil, lightHC: lightHC).get() + + XCTAssertEqual( + [colors.compactMap { $0.light.name }, colors.compactMap { $0.lightHC?.name }], + [["primaryLink", "primaryText"], ["primaryText"]] + ) + } + + // LightHC count cannot exceed light count + func testProcessWithUniversalAsset4() throws { + let lights = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let lightHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryIcon", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + nameStyle: .camelCase + ) + + XCTAssertThrowsError(try processor.process(light: lights, dark: nil, lightHC: lightHC).get()) + } + + // LightHC count cannot exceed light count + func testProcessWithUniversalAsset5() throws { + let lights = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let darks = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let lightsHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryIcon", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + nameStyle: .camelCase + ) + + XCTAssertThrowsError(try processor.process(light: lights, dark: darks, lightHC: lightsHC).get()) + } + + // DarkHC count cannot exceed lightHC count + func testProcessWithUniversalAsset6() throws { + let lights = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let darks = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let lightsHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let darksHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryIcon", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + nameStyle: .camelCase + ) + + XCTAssertThrowsError(try processor.process(light: lights, dark: darks, lightHC: lightsHC, darkHC: darksHC).get()) + } + + // Light count can exceed dark, lightHC and darkHC count + func testProcessWithUniversalAsset7() throws { + let lights = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryIcon", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let darks = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let lightHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0), + Color(name: "primaryLink", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let darkHC = [ + Color(name: "primaryText", platform: .ios, red: 0, green: 0, blue: 0, alpha: 0) + ] + + let processor = ColorsProcessor( + platform: .ios, + nameValidateRegexp: nil, + nameReplaceRegexp: nil, + nameStyle: .camelCase + ) + let colors = try processor.process(light: lights, dark: darks, lightHC: lightHC, darkHC: darkHC).get() + + XCTAssertEqual( + [colors.compactMap { $0.light.name }, colors.compactMap { $0.dark?.name }, colors.compactMap { $0.lightHC?.name }, colors.compactMap { $0.darkHC?.name }], + [["primaryIcon", "primaryLink", "primaryText"], ["primaryLink", "primaryText"], ["primaryLink", "primaryText"], ["primaryText"]] + ) + } func testProcess() throws { let images = [