diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e158960..961f0ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,31 +2,39 @@ on: release: types: [created] -name: Upload tarball for release +name: Create new formula version jobs: upload-release: - name: Upload Release Asset + name: Create new formula version runs-on: ubuntu-latest steps: - - name: Set release tarball name - run: | - echo "TARBALL_NAME=$(echo MakeColors-${GITHUB_REF##*/v})" >> $GITHUB_ENV - - - name: Checkout code + - name: Checkout formula repo uses: actions/checkout@v2 - - - name: Pack tarball - run: | - git archive HEAD --prefix=${{ env.TARBALL_NAME }}/ | bzip2 > ${{ env.TARBALL_NAME }}.tar.bz2 - - - name: Upload Release Asset - id: upload - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: ./${{ env.TARBALL_NAME }}.tar.bz2 - asset_name: ${{ env.TARBALL_NAME }}.tar.bz2 - asset_content_type: application/x-bzip2 + repository: ${{ github.repository_owner }}/homebrew-makecolors + token: ${{ secrets.PUBLISH_FORMULA_TOKEN }} + - name: Update formula + run: | + cat << EOF > Formula/make-colors.rb + class MakeColors < Formula + desc "Converts a simple list of color definitions to asset catalogs for Xcode and resource XML for Android" + homepage "https://github.com/${{ github.repository }}" + url "https://github.com/${{ github.repository }}.git", :tag => "${{ github.event.release.tag_name }}", :revision => "${{ github.sha }}" + head "https://github.com/${{ github.repository }}.git" + license "MIT" + + depends_on :xcode => ["14.0", :build] + + def install + system "make", "install", "prefix=#{prefix}" + end + end + EOF + + git config user.name github-actions + git config user.email github-actions@github.com + + git add Formula/make-colors.rb + git commit -m "Update formula for ${{ github.event.release.tag_name }}" + git push diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be00eaa..3b150ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: jobs: BuildAndTest: - runs-on: macos-latest + runs-on: macos-12 steps: - uses: actions/checkout@v1 diff --git a/.swift-version b/.swift-version index d346e2a..760606e 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.3 +5.7 diff --git a/Docs/assetcatalog.png b/Docs/assetcatalog.png new file mode 100644 index 0000000..0340a76 Binary files /dev/null and b/Docs/assetcatalog.png differ diff --git a/Docs/html.png b/Docs/html.png new file mode 100644 index 0000000..46391a2 Binary files /dev/null and b/Docs/html.png differ diff --git a/Example/.gitignore b/Example/.gitignore new file mode 100644 index 0000000..5d1cee6 --- /dev/null +++ b/Example/.gitignore @@ -0,0 +1,2 @@ +Example.* +!Example.txt diff --git a/Example/Example.txt b/Example/Example.txt new file mode 100644 index 0000000..c2be7ac --- /dev/null +++ b/Example/Example.txt @@ -0,0 +1,10 @@ +Base/Green #8fd151 +Base/PaleGreen #d0f9a9 +Base/Red rgb(249, 39, 7) +TransparentRed rgba(255, 0, 0, 128) +Base/Yellow #ff0 + +Error @Base/Red +Warning @Base/Yellow +Good @Base/Green + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98c4dcc --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +prefix ?= /usr/local +bindir = $(prefix)/bin + +build: + swift build -c release --disable-sandbox + +install: build + install -d "$(bindir)" + install ".build/release/MakeColors" "$(bindir)/make-colors" + +uninstall: + rm -rf "$(bindir)/make-colors" + +clean: + rm -rf .build + +.PHONY: build install uninstall clean diff --git a/Package.resolved b/Package.resolved index 228d9c6..717a74e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,23 @@ { - "object": { - "pins": [ - { - "package": "swift-argument-parser", - "repositoryURL": "https://github.com/apple/swift-argument-parser", - "state": { - "branch": null, - "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff", - "version": "0.3.1" - } + "pins" : [ + { + "identity" : "rbbjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/robb/RBBJSON", + "state" : { + "branch" : "main", + "revision" : "102c970283e105d7c5be2e29630db29c808c20eb" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", + "version" : "1.1.4" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index ea9aaaa..05a09d5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,27 +6,25 @@ import PackageDescription let package = Package( name: "MakeColors", platforms: [ - .macOS(.v10_15), + .macOS("12.0"), ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.1")), + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")), + .package(url: "https://github.com/robb/RBBJSON", branch: "main"), ], targets: [ - .target( + .executableTarget( name: "MakeColors", - dependencies: [ - "LibMakeColors", - ] - ), - .target( - name: "LibMakeColors", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), .testTarget( name: "MakeColorsTests", - dependencies: ["LibMakeColors"] + dependencies: [ + "MakeColors", + .product(name: "RBBJSON", package: "RBBJSON"), + ] ), ] ) diff --git a/README.md b/README.md index 23d4152..b9b8980 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,70 @@ Converts a simple list of color definitions to asset catalogs for Xcode, resource XML for Android or an HTML preview. +## Installation + +Install via [Homebrew](https://brew.sh): + +``` +brew tap 5sw/makecolors +brew install make-colors +``` + +If you don’t use Homebrew you can also install directly from source. Clone the repository or download the release and run `make install` inside the working copy. + +## Usage + +``` +USAGE: make-colors [--ios] [--android] [--html] [--prefix ] [--output ] [--dump] + +ARGUMENTS: + The color list to process. + Use - to process the standard input. + +OPTIONS: + --ios/--android/--html The formatter to use. (default: ios) + --prefix Prefix for color names. + --output Output file to write. + Use - for standard output. + Default is the input file name with the appropriate file extension. If + the input is - the default is standard output. + Note that asset catalogs cannot be written to standard output. + --dump List read colors on console. + -h, --help Show help information. +``` + ## Input format Each line in your input contains one color definition. That is a name followed by the actual color. We support RGB colors in a few formats similar to CSS: ``` -Color/Color1 #fff -Color/Color2 #abcdeff0 -Color/Color3 rgb(12, 13, 53) -Color/Color4 rgba(250, 250, 250, 128) +Base/Green #8fd151 +Base/PaleGreen #d0f9a9 +Base/Red rgb(249, 39, 7) +TransparentRed rgba(255, 0, 0, 128) +Base/Yellow #ff0 +``` + +Grayscale colors can be produced with the `white(value)` and `white(value, alpha)` syntax. A value of zero means black while a value of 255 is pure white. + +``` +Black white(0) +MediumGray white(128) +TransparentGray white(128, 128) +``` + +HSV colors can be specified as `hsv(hue, saturation, value)` or `hsva(hue, saturation, value, alpha)` syntax. Hue is specified as degrees with the `°` or `deg` suffix. + +``` +HSV/Yellow hsv(60°, 255, 255) ``` Colors can also reference other colors by prefixing them with an `@` sign: ``` -ColorAlias @Color/Color1 +Error @Base/Red +Warning @Base/Yellow +Good @Base/Green ``` ## Output format @@ -25,17 +74,41 @@ ColorAlias @Color/Color1 The optional prefix followed by a `/` is added in front of the color name. Then for each part separate by / a new folder that provides namespace is inserted in the asset catalogs. Spaces are inserted between CamelCase words. Color references are inserted as copies of the original color. +For the given example input the generated asset catalog looks like this: + +![](Docs/assetcatalog.png) + ### Android resource XML (`--android`) The optional prefix, followed by a underscore is added in front of the name. Names are converted from CamelCase to snake_case and / is replaced by underscores as well. Color references the generated color resource also references the original color. +The generated XML for the example input looks like this: + +``` + + + #8FD151 + #D0F9A9 + #F92707 + #FFFF00 + #FF000080 + @color/base_red + @color/base_green + @color/base_yellow + + +``` + ### HTML preview (`--html`) Generates a simple HTML table with the color names, values and a sample. +The generated HTML looks like this: + +![](Docs/html.png) + ## Future work -- Support other color formats (HSV, ...) - Calculate derived colors (blend, change hue/saturation/brightness/alpha) - Support for dark/light mode - Improved error reporting in the parser diff --git a/Sources/LibMakeColors/MakeColors.swift b/Sources/LibMakeColors/MakeColors.swift deleted file mode 100644 index 8dfa743..0000000 --- a/Sources/LibMakeColors/MakeColors.swift +++ /dev/null @@ -1,83 +0,0 @@ -import ArgumentParser -import Foundation - -private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { - static let allCases: [GeneratorOption] = [ - .init(type: AssetCatalogGenerator.self), - .init(type: AndroidGenerator.self), - .init(type: HTMLGenerator.self), - ] - - let type: Generator.Type - - var description: String { - type.option - } - - static func == (lhs: GeneratorOption, rhs: GeneratorOption) -> Bool { - lhs.type == rhs.type - } -} - -enum Errors: Error { - case syntaxError - case duplicateColor(String) - case missingReference(String) - case cyclicReference(String) -} - -public final class MakeColors: ParsableCommand, Context { - @Argument(help: "The color list to proces") - var input: String - - @Flag(help: "The formatter to use") - private var formatter = GeneratorOption.allCases[0] - - @Option(help: "Prefix for color names") - var prefix: String? - - @Option(help: "Output file") - var output: String? - - public init() {} - - public func run() throws { - let url = URL(fileURLWithPath: input) - let string = try String(contentsOf: url) - - let scanner = Scanner(string: string) - scanner.charactersToBeSkipped = .whitespaces - - let data = try scanner.colorList() - - for (key, color) in data.sorted() { - let resolved = try data.resolve(key) - switch color { - case .color: - print(key.insertCamelCaseSeparators(), resolved, separator: ": ") - - case let .reference(referenced): - print( - "\(key.insertCamelCaseSeparators()) (@\(referenced.insertCamelCaseSeparators()))", - resolved, - separator: ": " - ) - } - } - - let generator = formatter.type.init(context: self) - let fileWrapper = try generator.generate(data: data) - - let writeURL = outputURL(extension: formatter.type.defaultExtension) - try fileWrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil) - } - - func outputURL(extension: String) -> URL { - if let output = output { - return URL(fileURLWithPath: output) - } else { - let basename = URL(fileURLWithPath: input).deletingPathExtension().lastPathComponent - return URL(fileURLWithPath: basename).appendingPathExtension(`extension`) - } - } -} diff --git a/Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift b/Sources/MakeColors/Extensions/FileWrapper+Extensions.swift similarity index 100% rename from Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift rename to Sources/MakeColors/Extensions/FileWrapper+Extensions.swift diff --git a/Sources/LibMakeColors/Extensions/StringProtocol+Extensions.swift b/Sources/MakeColors/Extensions/StringProtocol+Extensions.swift similarity index 100% rename from Sources/LibMakeColors/Extensions/StringProtocol+Extensions.swift rename to Sources/MakeColors/Extensions/StringProtocol+Extensions.swift diff --git a/Sources/LibMakeColors/Generators/AndroidGenerator.swift b/Sources/MakeColors/Generators/AndroidGenerator.swift similarity index 100% rename from Sources/LibMakeColors/Generators/AndroidGenerator.swift rename to Sources/MakeColors/Generators/AndroidGenerator.swift diff --git a/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift b/Sources/MakeColors/Generators/AssetCatalogGenerator.swift similarity index 76% rename from Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift rename to Sources/MakeColors/Generators/AssetCatalogGenerator.swift index 7851575..f21d153 100644 --- a/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift +++ b/Sources/MakeColors/Generators/AssetCatalogGenerator.swift @@ -1,7 +1,7 @@ import Foundation final class AssetCatalogGenerator: Generator { - static let defaultExtension = "xcasset" + static let defaultExtension = "xcassets" static let option = "ios" let context: Context @@ -58,7 +58,7 @@ private let infoTag = """ } """ -private extension Color { +extension Color { func json() -> String { """ { @@ -67,10 +67,10 @@ private extension Color { "color" : { "color-space" : "srgb", "components" : { - "alpha" : "\(Float(alpha) / 256)", - "blue" : "0x\(String(blue, radix: 16))", - "green" : "0x\(String(green, radix: 16))", - "red" : "0x\(String(red, radix: 16))" + "alpha" : "\(Double(alpha) / 0xFF)", + "blue" : "0x\(blue, radix: 16, width: 2)", + "green" : "0x\(green, radix: 16, width: 2)", + "red" : "0x\(red, radix: 16, width: 2)" } }, "idiom" : "universal" @@ -88,6 +88,22 @@ private extension Color { } } +extension String.StringInterpolation { + mutating func appendInterpolation( + _ value: I, + radix: Int, + width: Int = 0, + uppercase: Bool = true + ) { + var string = String(value, radix: radix, uppercase: uppercase) + if width > string.count { + string.insert(contentsOf: String(repeating: "0", count: width - string.count), at: string.startIndex) + } + + appendLiteral(string) + } +} + private let group = """ { "properties" : { diff --git a/Sources/LibMakeColors/Generators/Generator.swift b/Sources/MakeColors/Generators/Generator.swift similarity index 85% rename from Sources/LibMakeColors/Generators/Generator.swift rename to Sources/MakeColors/Generators/Generator.swift index 8561ac3..085021b 100644 --- a/Sources/LibMakeColors/Generators/Generator.swift +++ b/Sources/MakeColors/Generators/Generator.swift @@ -1,6 +1,6 @@ import Foundation -protocol Generator: class { +protocol Generator: AnyObject { static var defaultExtension: String { get } static var option: String { get } @@ -9,7 +9,7 @@ protocol Generator: class { func generate(data: [String: ColorDef]) throws -> FileWrapper } -protocol Context: class { +protocol Context: AnyObject { var prefix: String? { get } } diff --git a/Sources/LibMakeColors/Generators/HTMLGenerator.swift b/Sources/MakeColors/Generators/HTMLGenerator.swift similarity index 100% rename from Sources/LibMakeColors/Generators/HTMLGenerator.swift rename to Sources/MakeColors/Generators/HTMLGenerator.swift diff --git a/Sources/MakeColors/Importers/Figma/FigmaImporter.swift b/Sources/MakeColors/Importers/Figma/FigmaImporter.swift new file mode 100644 index 0000000..c84339f --- /dev/null +++ b/Sources/MakeColors/Importers/Figma/FigmaImporter.swift @@ -0,0 +1,151 @@ +import Foundation + +enum FigmaErrors: Error { + case invalidUrl + case missingToken + case invalidResponse + case missingColor(String) +} + +class FigmaImporter: Importer { + let key: String + let token: String + let outputName: String + + required init(source: String) throws { + // https://www.figma.com/file/:key/:title + guard + let url = URL(string: source), + url.host == "www.figma.com", + url.pathComponents.count >= 4, + url.pathComponents[1] == "file" + else { + throw FigmaErrors.invalidUrl + } + + key = url.pathComponents[2] + outputName = url.pathComponents[3] + + guard let token = ProcessInfo.processInfo.environment["FIGMA_TOKEN"] else { + throw FigmaErrors.missingToken + } + + self.token = token + } + + func read() async throws -> [String: ColorDef] { + let styles = try await request(StylesResponse.self, path: "/v1/files/\(key)/styles").meta.styles + .filter { $0.styleType == "FILL" } + + let ids = styles.map(\.nodeId).joined(separator: ",") + + let nodes = try await request( + NodesResponse.self, + path: "/v1/files/\(key)/nodes", + query: [URLQueryItem(name: "ids", value: ids)] + ) + .nodes + + var result: [String: ColorDef] = [:] + result.reserveCapacity(styles.count) + + for style in styles { + guard + let node = nodes[style.nodeId], + let fill = node.document.fills.first(where: { $0.type == "SOLID" }) + else { + throw FigmaErrors.missingColor(style.name) + } + + if node.document.fills.count > 1 { + print("Warning: Multiple fills defined for \(style.name)") + } + + if fill.blendMode != "NORMAL" { + print("Warning: Blend mode \(fill.blendMode) used for \(style.name)") + } + + guard !result.keys.contains(style.name) else { + throw Errors.duplicateColor(style.name) + } + + result[style.name] = .color(Color(fill.color)) + } + + return result + } + + func request(_: T.Type = T.self, path: String, query: [URLQueryItem]? = nil) async throws -> T { + var components = URLComponents() + components.scheme = "https" + components.host = "api.figma.com" + components.path = path + components.queryItems = query + + guard let url = components.url else { + fatalError("Cannot create url. Components: \(components)") + } + + var request = URLRequest(url: url) + request.setValue(token, forHTTPHeaderField: "X-Figma-Token") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + fatalError("Non-HTTP-Response received: \(response)") + } + + guard response.statusCode == 200 else { + throw FigmaErrors.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: data) + } +} + +struct StylesResponse: Decodable { + var meta: Meta + struct Meta: Decodable { + var styles: [Style] + } + + struct Style: Decodable { + var nodeId: String + var styleType: String + var name: String + var description: String + } +} + +struct NodesResponse: Decodable { + var nodes: [String: Node] + + struct Node: Decodable { + var document: Document + } + + struct Document: Decodable { + var fills: [Fill] + } + + struct Fill: Decodable { + var blendMode: String + var type: String + var color: Color + } + + struct Color: Decodable { + var r, g, b, a: Float + } +} + +extension Color { + init(_ color: NodesResponse.Color) { + red = UInt8(truncatingIfNeeded: Int(color.r * 0xFF)) + green = UInt8(truncatingIfNeeded: Int(color.g * 0xFF)) + blue = UInt8(truncatingIfNeeded: Int(color.b * 0xFF)) + alpha = UInt8(truncatingIfNeeded: Int(color.a * 0xFF)) + } +} diff --git a/Sources/MakeColors/Importers/Importer.swift b/Sources/MakeColors/Importers/Importer.swift new file mode 100644 index 0000000..6c4d5d8 --- /dev/null +++ b/Sources/MakeColors/Importers/Importer.swift @@ -0,0 +1,15 @@ +protocol Importer { + init(source: String) throws + + func read() async throws -> [String: ColorDef] + + var outputName: String { get } + + static var option: String { get } +} + +extension Importer { + static var option: String { + String(describing: self).droppingSuffix("Importer").lowercased() + } +} diff --git a/Sources/MakeColors/Importers/List/ListImporter.swift b/Sources/MakeColors/Importers/List/ListImporter.swift new file mode 100644 index 0000000..ef291b1 --- /dev/null +++ b/Sources/MakeColors/Importers/List/ListImporter.swift @@ -0,0 +1,38 @@ +import Foundation + +struct ListImporter: Importer { + let input: String + var outputName: String + + init(source: String) { + input = source + outputName = URL(fileURLWithPath: source).deletingPathExtension().lastPathComponent + } + + func read() throws -> [String: ColorDef] { + let scanner = Scanner(string: try readInput()) + scanner.charactersToBeSkipped = .whitespaces + + return try scanner.colorList() + } + + func readInput() throws -> String { + if input == "-" { + return try readStdin() + } + + let url = URL(fileURLWithPath: input) + return try String(contentsOf: url) + } + + func readStdin() throws -> String { + guard + let data = try FileHandle.standardInput.readToEnd(), + let input = String(data: data, encoding: .utf8) + else { + throw Errors.cannotReadStdin + } + + return input + } +} diff --git a/Sources/LibMakeColors/Model/Scanner+ColorParser.swift b/Sources/MakeColors/Importers/List/Scanner+ColorParser.swift similarity index 55% rename from Sources/LibMakeColors/Model/Scanner+ColorParser.swift rename to Sources/MakeColors/Importers/List/Scanner+ColorParser.swift index 3d56246..b3d43d7 100644 --- a/Sources/LibMakeColors/Model/Scanner+ColorParser.swift +++ b/Sources/MakeColors/Importers/List/Scanner+ColorParser.swift @@ -19,17 +19,70 @@ extension Scanner { } } - if string("rgba"), string("("), let components = commaSeparated(), components.count == 4, string(")") { + if string("rgba"), let components = argumentList(4) { return Color(components) } - if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") { + if string("rgb"), let components = argumentList(3) { return Color(components) } + if string("white"), let arguments = argumentList(min: 1, max: 2) { + return Color(white: arguments) + } + + if string("hsva") { + return readHSV(alpha: true) + } + + if string("hsv") { + return readHSV(alpha: false) + } + return nil } + private func readHSV(alpha readAlpha: Bool) -> Color? { + guard + string("("), + let hue = degrees(), + string(","), + let saturation = component(), + string(","), + let value = component() + else { return nil } + + let alpha: UInt8 + + if readAlpha { + guard + string(","), + let value = component() + else { return nil } + alpha = value + } else { + alpha = 0xFF + } + + guard string(")") else { return nil } + + return Color(hue: hue, saturation: saturation, value: value, alpha: alpha) + } + + func degrees() -> Int? { + guard let int = scanInt() else { return nil } + + if string("%") { + guard 0...100 ~= int else { return nil } + return (360 * int) / 100 + } else if string("°") || string("deg") { + return int + } else { + guard 0...0xFF ~= int else { return nil } + return (360 * int) / 0xFF + } + } + func colorReference() -> String? { guard string("@") else { return nil } return name() @@ -57,7 +110,7 @@ extension Scanner { func colorLine() -> (String, ColorDef)? { guard - let name = self.name(), + let name = name(), let def = colorDef(), endOfLine() else { @@ -91,17 +144,50 @@ extension Scanner { return result } + // swiftlint:disable:next discouraged_optional_collection + func argumentList(_ count: Int) -> [UInt8]? { + argumentList(min: count, max: count) + } + + // swiftlint:disable:next discouraged_optional_collection + func argumentList(min: Int, max: Int? = nil) -> [UInt8]? { + let max = max ?? Int.max + guard + string("("), + let arguments = commaSeparated(), + string(")"), + (min...max) ~= arguments.count + else { + return nil + } + + return arguments + } + // swiftlint:disable:next discouraged_optional_collection func commaSeparated() -> [UInt8]? { var result: [UInt8] = [] repeat { - guard let int = scanInt(), let component = UInt8(exactly: int) else { + guard let component = component() else { return nil } result.append(component) } while string(",") return result } + + func component() -> UInt8? { + guard var int = scanInt() else { + return nil + } + + if string("%") { + guard 0...100 ~= int else { return nil } + int = int * 0xFF / 100 + } + + return UInt8(exactly: int) + } } private extension Scanner { @@ -111,7 +197,7 @@ private extension Scanner { } private extension CharacterSet { - static let hex = CharacterSet(charactersIn: "0123456789abcdef") + static let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF") static let name = alphanumerics.union(CharacterSet(charactersIn: "_/")) } diff --git a/Sources/MakeColors/MakeColors.swift b/Sources/MakeColors/MakeColors.swift new file mode 100644 index 0000000..8b88849 --- /dev/null +++ b/Sources/MakeColors/MakeColors.swift @@ -0,0 +1,150 @@ +import ArgumentParser +import Foundation + +private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { + static let allCases: [GeneratorOption] = [ + .init(type: AssetCatalogGenerator.self), + .init(type: AndroidGenerator.self), + .init(type: HTMLGenerator.self), + ] + + let type: Generator.Type + + var description: String { + type.option + } + + static func == (lhs: GeneratorOption, rhs: GeneratorOption) -> Bool { + lhs.type == rhs.type + } +} + +private struct ImporterOption: CaseIterable, ExpressibleByArgument, CustomStringConvertible { + static let allCases: [ImporterOption] = [ + .list, + .init(type: FigmaImporter.self), + ] + + static let list = ImporterOption(type: ListImporter.self) + + let type: Importer.Type + + init(type: Importer.Type) { + self.type = type + } + + init?(argument: String) { + guard + let found = Self.allCases + .first(where: { $0.description.caseInsensitiveCompare(argument) == .orderedSame }) + else { + return nil + } + self = found + } + + var description: String { + type.option + } + + static func == (lhs: ImporterOption, rhs: ImporterOption) -> Bool { + lhs.type == rhs.type + } +} + +enum Errors: Error { + case syntaxError + case duplicateColor(String) + case missingReference(String) + case cyclicReference(String) + case cannotWriteWrapperToStdout + case cannotReadStdin +} + +enum HelpTexts { + static let input = ArgumentHelp( + "The color list to process.", + discussion: """ + Use - to process the standard input. + """ + ) + + static let output = ArgumentHelp( + "Output file to write.", + discussion: """ + Use - for standard output. + Default is the input file name with the appropriate file extension. \ + If the input is - the default is standard output. + Note that asset catalogs cannot be written to standard output. + """ + ) +} + +@main +public final class MakeColors: AsyncParsableCommand, Context { + @Argument(help: HelpTexts.input) + var input: String + + @Flag(help: "The formatter to use.") + private var formatter = GeneratorOption.allCases[0] + + @Option(help: "The importer to use.") + private var importer = ImporterOption.list + + @Option(help: "Prefix for color names.") + var prefix: String? + + @Option(help: HelpTexts.output) + var output: String? + + @Flag(help: "List read colors on console.") + var dump = false + + public init() {} + + public func run() async throws { + let importer = try importer.type.init(source: input) + let data = try await importer.read() + + if dump { + try dump(data: data) + } + + let generator = formatter.type.init(context: self) + let fileWrapper = try generator.generate(data: data) + + try writeOutput(fileWrapper, name: output ?? "\(importer.outputName).\(formatter.type.defaultExtension)") + } + + func dump(data: [String: ColorDef]) throws { + for (key, color) in data.sorted() { + let resolved = try data.resolve(key) + switch color { + case .color: + print(key.insertCamelCaseSeparators(), resolved, separator: ": ") + + case let .reference(referenced): + print( + "\(key.insertCamelCaseSeparators()) (@\(referenced.insertCamelCaseSeparators()))", + resolved, + separator: ": " + ) + } + } + } + + func writeOutput(_ wrapper: FileWrapper, name: String) throws { + if shouldWriteToStdout { + guard wrapper.isRegularFile, let contents = wrapper.regularFileContents else { + throw Errors.cannotWriteWrapperToStdout + } + + FileHandle.standardOutput.write(contents) + } else { + let writeURL = URL(fileURLWithPath: name) + try wrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil) + } + } + + var shouldWriteToStdout: Bool { output == "-" || (input == "-" && output == nil) } +} diff --git a/Sources/MakeColors/Model/Color+HSV.swift b/Sources/MakeColors/Model/Color+HSV.swift new file mode 100644 index 0000000..9251c03 --- /dev/null +++ b/Sources/MakeColors/Model/Color+HSV.swift @@ -0,0 +1,45 @@ +extension Color { + init(hue: Int, saturation: UInt8, value: UInt8, alpha: UInt8 = 0xFF) { + let degrees = abs(hue % 360) + + let saturation = Double(saturation) / 0xFF + let value = Double(value) / 0xFF + + // swiftlint:disable identifier_name - Wish I knew what these actually mean. + let C = saturation * value + let X = C * (1 - abs((Double(degrees) / 60).truncatingRemainder(dividingBy: 2) - 1)) + let m = value - C + // swiftlint:enable identifier_name + + let result: (r: Double, g: Double, b: Double) + switch degrees { + case 0..<60: + result = (C, X, 0) + + case 60..<120: + result = (X, C, 0) + + case 120..<180: + result = (0, C, X) + + case 180..<240: + result = (0, X, C) + + case 240..<300: + result = (X, 0, C) + + case 300..<360: + result = (C, 0, X) + + default: + fatalError("Degrees out of range") + } + + self.init( + red: UInt8(((result.r + m) * 0xFF).rounded()), + green: UInt8(((result.g + m) * 0xFF).rounded()), + blue: UInt8(((result.b + m) * 0xFF).rounded()), + alpha: alpha + ) + } +} diff --git a/Sources/LibMakeColors/Model/Color.swift b/Sources/MakeColors/Model/Color.swift similarity index 89% rename from Sources/LibMakeColors/Model/Color.swift rename to Sources/MakeColors/Model/Color.swift index ef2030e..6e9740d 100644 --- a/Sources/LibMakeColors/Model/Color.swift +++ b/Sources/MakeColors/Model/Color.swift @@ -19,6 +19,14 @@ struct Color: CustomStringConvertible, Equatable { alpha = array.count == 4 ? array[3] : 0xFF } + init(white array: [UInt8]) { + precondition(array.count == 1 || array.count == 2) + red = array[0] + green = array[0] + blue = array[0] + alpha = array.count == 2 ? array[1] : 0xFF + } + var description: String { let alphaSuffix = alpha != 0xFF ? String(format: "%02X", alpha) : "" return String(format: "#%02X%02X%02X%@", red, green, blue, alphaSuffix) diff --git a/Sources/MakeColors/main.swift b/Sources/MakeColors/main.swift deleted file mode 100644 index db25b90..0000000 --- a/Sources/MakeColors/main.swift +++ /dev/null @@ -1,3 +0,0 @@ -import LibMakeColors - -MakeColors.main() diff --git a/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift b/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift new file mode 100644 index 0000000..d4f9f10 --- /dev/null +++ b/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift @@ -0,0 +1,26 @@ +@testable import MakeColors +import RBBJSON +import XCTest + +class AssetCatalogFormattingTest: XCTestCase { + func testColorProducedValidJSON() throws { + let color = Color(red: 0xFF, green: 0xF0, blue: 0x0F) + let data = Data(color.json().utf8) + + let json = try JSONDecoder().decode(RBBJSON.self, from: data) + + XCTAssertEqual(json.info.author, .string("de.5sw.MakeColors")) + XCTAssertEqual(json.info.version, .number(1)) + + XCTAssertEqual(Array(json.colors[any: .child]).count, 1) + + XCTAssertEqual(json.colors[0].idiom, .string("universal")) + + let jsonColor = json.colors[0].color + XCTAssertEqual(jsonColor["color-space"], .string("srgb")) + XCTAssertEqual(jsonColor.components.alpha, .string("1.0")) + XCTAssertEqual(jsonColor.components.red, .string("0xFF")) + XCTAssertEqual(jsonColor.components.green, .string("0xF0")) + XCTAssertEqual(jsonColor.components.blue, .string("0x0F")) + } +} diff --git a/Tests/MakeColorsTests/ColorHSVTest.swift b/Tests/MakeColorsTests/ColorHSVTest.swift new file mode 100644 index 0000000..9eba183 --- /dev/null +++ b/Tests/MakeColorsTests/ColorHSVTest.swift @@ -0,0 +1,39 @@ +@testable import MakeColors +import XCTest + +final class ColorHSVTest: XCTestCase { + func testHSV0Degrees() { + let color = Color(hue: 0, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0)) + } + + func testHSV60Degrees() { + let color = Color(hue: 60, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0xFF, green: 0xFF, blue: 0)) + } + + func testHSV120Degrees() { + let color = Color(hue: 120, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0, green: 0xFF, blue: 0)) + } + + func testHSV180Degrees() { + let color = Color(hue: 180, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0, green: 0xFF, blue: 0xFF)) + } + + func testHSV240Degrees() { + let color = Color(hue: 240, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0, green: 0, blue: 0xFF)) + } + + func testHSV300Degrees() { + let color = Color(hue: 300, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0xFF)) + } + + func testHSV360Degrees() { + let color = Color(hue: 360, saturation: 0xFF, value: 0xFF) + XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0)) + } +} diff --git a/Tests/MakeColorsTests/ColorParserTest.swift b/Tests/MakeColorsTests/ColorParserTest.swift index 86e8e4d..8c3756e 100644 --- a/Tests/MakeColorsTests/ColorParserTest.swift +++ b/Tests/MakeColorsTests/ColorParserTest.swift @@ -1,40 +1,123 @@ -@testable import LibMakeColors +@testable import MakeColors import XCTest final class ColorParserTest: XCTestCase { func testScanningThreeDigitColor() throws { - let scanner = Scanner(string: "#abc") - let color = scanner.color() + let color = scanColor("#abc") + XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC), color) + } + + func testScanningThreeDigitColorUppercase() throws { + let color = scanColor("#ABc") XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC), color) } func testScanningFourDigitColor() throws { - let scanner = Scanner(string: "#abcd") - let color = scanner.color() + let color = scanColor("#abcd") XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC, alpha: 0xDD), color) } func testScanningSixDigitColor() throws { - let scanner = Scanner(string: "#abcdef") - let color = scanner.color() + let color = scanColor("#abcdef") XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF), color) } func testScanningEightDigitColor() throws { - let scanner = Scanner(string: "#abcdef17") - let color = scanner.color() + let color = scanColor("#abcdef17") XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF, alpha: 0x17), color) } func testScanningRGBColor() throws { - let scanner = Scanner(string: "rgb(1,2,3)") - let color = scanner.color() + let color = scanColor("rgb(1,2,3)") XCTAssertEqual(Color(red: 1, green: 2, blue: 3), color) } func testScanningRGBAColor() throws { - let scanner = Scanner(string: "rgba(1,2,3,4)") - let color = scanner.color() + let color = scanColor("rgba(1,2,3,4)") XCTAssertEqual(Color(red: 1, green: 2, blue: 3, alpha: 4), color) } + + func testScanningWhite() throws { + let color = scanColor("white(255)") + XCTAssertEqual(Color(red: 255, green: 255, blue: 255, alpha: 255), color) + } + + func testScanningWhiteWithAlpha() throws { + let color = scanColor("white(255, 128)") + XCTAssertEqual(Color(red: 255, green: 255, blue: 255, alpha: 128), color) + } + + func testWhiteFailsWithoutArguments() throws { + let color = scanColor("white()") + XCTAssertNil(color) + } + + func testWhiteFailsWith3Arguments() throws { + let color = scanColor("white(1,2,3)") + XCTAssertNil(color) + } + + func testScanningColorWithPercentage() throws { + let color = scanColor("rgba(100%, 0, 50%, 100%)") + XCTAssertEqual(color, Color(red: 255, green: 0, blue: 127, alpha: 255)) + } + + func testReadingComponentAsByte() throws { + let scanner = Scanner(string: "128") + XCTAssertEqual(scanner.component(), 128) + XCTAssertTrue(scanner.isAtEnd) + } + + func testReadingComponentAs100Percent() throws { + let scanner = Scanner(string: "100%") + XCTAssertEqual(scanner.component(), 0xFF) + XCTAssertTrue(scanner.isAtEnd) + } + + func testReadingComponentAs0Percent() throws { + let scanner = Scanner(string: "0%") + XCTAssertEqual(scanner.component(), 0) + XCTAssertTrue(scanner.isAtEnd) + } + + func testReadingComponentAs50PercentRoundsDown() throws { + let scanner = Scanner(string: "50%") + XCTAssertEqual(scanner.component(), 127) + XCTAssertTrue(scanner.isAtEnd) + } + + func testScanningDegreesAsByte() throws { + let scanner = Scanner(string: "128") + XCTAssertEqual(scanner.degrees(), 180) + } + + func testScanningDegreesAsPercentage() throws { + let scanner = Scanner(string: "50%") + XCTAssertEqual(scanner.degrees(), 180) + } + + func testScanningDegrees() throws { + let scanner = Scanner(string: "120°") + XCTAssertEqual(scanner.degrees(), 120) + } + + func testScanningDegreesWithDegSuffix() throws { + let scanner = Scanner(string: "120 deg") + XCTAssertEqual(scanner.degrees(), 120) + } + + func testScanningHSVColor() throws { + XCTAssertEqual(scanColor("hsv(60°, 255, 100%)"), Color(red: 0xFF, green: 0xFF, blue: 0)) + } + + func testScanningHSVAColor() throws { + XCTAssertEqual(scanColor("hsva(60°, 50%, 255, 99)"), Color(red: 0xFF, green: 0xFF, blue: 128, alpha: 99)) + } + + private func scanColor(_ input: String) -> Color? { + let scanner = Scanner(string: input) + let result = scanner.color() + XCTAssertTrue(result == nil || scanner.isAtEnd) + return result + } }