Compare commits

...

42 commits
v0.1.1 ... main

Author SHA1 Message Date
28a901e6ae
Merge pull request #4 from 5sw/figma
Import colors from Figma library
2022-10-08 13:14:23 +02:00
fa9da22633 Remove setup-xcode, should not be needed any more 2022-10-08 13:09:40 +02:00
028095f00e Use macos-12 image 2022-10-08 13:08:03 +02:00
2271198f47 Select Xcode 14 2022-10-08 13:04:59 +02:00
fe95a6fd35 Need Xcode 14 to build 2022-10-08 13:00:03 +02:00
4a489ccd20 Update .swift-version 2022-10-08 12:59:18 +02:00
ac708f4ae2 Let importer determine output file name. 2022-10-08 12:50:55 +02:00
2b1f21dbab Quick and dirty figma importer 2022-10-08 12:41:02 +02:00
956c1f5d27 Make importer async 2022-10-08 11:48:33 +02:00
3adefbf70e Update argument parser 2022-10-08 11:47:32 +02:00
0bb53ea949 Remove library target 2022-10-08 11:46:52 +02:00
012203a4f3 Lowercase importer names 2022-10-08 11:42:42 +02:00
d7d34812cf Format 2022-10-08 11:42:02 +02:00
dc00d87a32 Add option to select importer. 2022-10-08 11:20:00 +02:00
02d2350b29 Fix warnings 2022-10-08 11:04:24 +02:00
e28bee804f Make importer protocol. 2022-10-08 11:03:28 +02:00
e6f44f5be2 Fix lint warnings 2021-01-02 22:43:54 +01:00
ba7c0c04fa
Update README.md 2021-01-02 22:35:45 +01:00
e8a02b0180
Add info on HSV colors to README 2021-01-02 22:33:52 +01:00
78fcdef196 Update asset catalog image 2021-01-02 22:22:35 +01:00
404566c36b Add example file 2021-01-02 22:20:55 +01:00
1c31c11f49 Create unit test for color JSON 2021-01-02 22:03:41 +01:00
be2a655c40 Match formatting used by Xcode. Turns out Xcode requires two digits for hex colors values. Single digits get repeated. 2021-01-02 21:17:41 +01:00
8262760bc7 Limit percent value from 0 to 100. 2021-01-01 15:18:16 +01:00
04943d8f63 Use simplified argument list parser 2021-01-01 15:17:50 +01:00
75a3bc265d Parse hsv colors 2021-01-01 13:49:44 +01:00
0b94872101 Add HSV conversion 2021-01-01 13:33:11 +01:00
e65f684654 Support giving color values as percentage 2021-01-01 12:52:20 +01:00
911db304ae Add flag to dump read colors to console. Not very helpful if you want to use this in a pipeline 2020-12-31 20:26:32 +01:00
90a3ed745f Document grayscale colors in README 2020-12-31 20:19:51 +01:00
4406619c84 Simplify color parser tests 2020-12-31 20:16:41 +01:00
d660c40793 Implement parsing grayscale colors as white(value) or white(value, alpha) 2020-12-31 20:11:53 +01:00
0991ca01ed Test scanning upper case hex digits 2020-12-31 19:49:04 +01:00
b1adcb158e Accept upper-case hex digits for colors 2020-12-31 19:47:55 +01:00
13f985c0a4 Improve usage message and add to README 2020-12-31 19:44:26 +01:00
abc0ef910d Allow reading input from stdin 2020-12-31 19:31:42 +01:00
01b564adb5 Fix android output in readme file 2020-12-31 19:05:36 +01:00
b657561e5c Crop asset catalog screenshot 2020-12-31 19:03:05 +01:00
465c1c078f Improve example in README.md 2020-12-31 19:01:35 +01:00
eb964e2e95 Add info about installing via makefile. 2020-12-31 18:55:04 +01:00
fb0934ae01 Output alpha as hex value as well to asset catalogs 2020-12-31 18:47:37 +01:00
99d5c5ca74 Fix asset catalog extension 2020-12-31 18:41:25 +01:00
28 changed files with 797 additions and 145 deletions

View file

@ -24,7 +24,7 @@ jobs:
head "https://github.com/${{ github.repository }}.git" head "https://github.com/${{ github.repository }}.git"
license "MIT" license "MIT"
depends_on :xcode => ["12.0", :build] depends_on :xcode => ["14.0", :build]
def install def install
system "make", "install", "prefix=#{prefix}" system "make", "install", "prefix=#{prefix}"

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
BuildAndTest: BuildAndTest:
runs-on: macos-latest runs-on: macos-12
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View file

@ -1 +1 @@
5.3 5.7

BIN
Docs/assetcatalog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
Docs/html.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

2
Example/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
Example.*
!Example.txt

10
Example/Example.txt Normal file
View file

@ -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

View file

@ -1,16 +1,23 @@
{ {
"object": { "pins" : [
"pins": [ {
{ "identity" : "rbbjson",
"package": "swift-argument-parser", "kind" : "remoteSourceControl",
"repositoryURL": "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/robb/RBBJSON",
"state": { "state" : {
"branch": null, "branch" : "main",
"revision": "92646c0cdbaca076c8d3d0207891785b3379cbff", "revision" : "102c970283e105d7c5be2e29630db29c808c20eb"
"version": "0.3.1"
}
} }
] },
}, {
"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
} }

View file

@ -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. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,27 +6,25 @@ import PackageDescription
let package = Package( let package = Package(
name: "MakeColors", name: "MakeColors",
platforms: [ platforms: [
.macOS(.v10_15), .macOS("12.0"),
], ],
dependencies: [ 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: [ targets: [
.target( .executableTarget(
name: "MakeColors", name: "MakeColors",
dependencies: [
"LibMakeColors",
]
),
.target(
name: "LibMakeColors",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
] ]
), ),
.testTarget( .testTarget(
name: "MakeColorsTests", name: "MakeColorsTests",
dependencies: ["LibMakeColors"] dependencies: [
"MakeColors",
.product(name: "RBBJSON", package: "RBBJSON"),
]
), ),
] ]
) )

View file

@ -11,21 +11,61 @@ brew tap 5sw/makecolors
brew install make-colors brew install make-colors
``` ```
If you dont 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 <input> [--ios] [--android] [--html] [--prefix <prefix>] [--output <output>] [--dump]
ARGUMENTS:
<input> The color list to process.
Use - to process the standard input.
OPTIONS:
--ios/--android/--html The formatter to use. (default: ios)
--prefix <prefix> Prefix for color names.
--output <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 ## 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: 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 Base/Green #8fd151
Color/Color2 #abcdeff0 Base/PaleGreen #d0f9a9
Color/Color3 rgb(12, 13, 53) Base/Red rgb(249, 39, 7)
Color/Color4 rgba(250, 250, 250, 128) 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: 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 ## Output format
@ -34,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. 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`) ### 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 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:
```
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="base_green">#8FD151</color>
<color name="base_pale_green">#D0F9A9</color>
<color name="base_red">#F92707</color>
<color name="base_yellow">#FFFF00</color>
<color name="transparent_red">#FF000080</color>
<color name="error">@color/base_red</color>
<color name="good">@color/base_green</color>
<color name="warning">@color/base_yellow</color>
</resources>
</xml>
```
### HTML preview (`--html`) ### HTML preview (`--html`)
Generates a simple HTML table with the color names, values and a sample. Generates a simple HTML table with the color names, values and a sample.
The generated HTML looks like this:
![](Docs/html.png)
## Future work ## Future work
- Support other color formats (HSV, ...)
- Calculate derived colors (blend, change hue/saturation/brightness/alpha) - Calculate derived colors (blend, change hue/saturation/brightness/alpha)
- Support for dark/light mode - Support for dark/light mode
- Improved error reporting in the parser - Improved error reporting in the parser

View file

@ -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`)
}
}
}

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
final class AssetCatalogGenerator: Generator { final class AssetCatalogGenerator: Generator {
static let defaultExtension = "xcasset" static let defaultExtension = "xcassets"
static let option = "ios" static let option = "ios"
let context: Context let context: Context
@ -58,7 +58,7 @@ private let infoTag = """
} }
""" """
private extension Color { extension Color {
func json() -> String { func json() -> String {
""" """
{ {
@ -67,10 +67,10 @@ private extension Color {
"color" : { "color" : {
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "\(Float(alpha) / 256)", "alpha" : "\(Double(alpha) / 0xFF)",
"blue" : "0x\(String(blue, radix: 16))", "blue" : "0x\(blue, radix: 16, width: 2)",
"green" : "0x\(String(green, radix: 16))", "green" : "0x\(green, radix: 16, width: 2)",
"red" : "0x\(String(red, radix: 16))" "red" : "0x\(red, radix: 16, width: 2)"
} }
}, },
"idiom" : "universal" "idiom" : "universal"
@ -88,6 +88,22 @@ private extension Color {
} }
} }
extension String.StringInterpolation {
mutating func appendInterpolation<I: BinaryInteger>(
_ 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 = """ private let group = """
{ {
"properties" : { "properties" : {

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
protocol Generator: class { protocol Generator: AnyObject {
static var defaultExtension: String { get } static var defaultExtension: String { get }
static var option: String { get } static var option: String { get }
@ -9,7 +9,7 @@ protocol Generator: class {
func generate(data: [String: ColorDef]) throws -> FileWrapper func generate(data: [String: ColorDef]) throws -> FileWrapper
} }
protocol Context: class { protocol Context: AnyObject {
var prefix: String? { get } var prefix: String? { get }
} }

View file

@ -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: Decodable>(_: 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))
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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) return Color(components)
} }
if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") { if string("rgb"), let components = argumentList(3) {
return Color(components) 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 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? { func colorReference() -> String? {
guard string("@") else { return nil } guard string("@") else { return nil }
return name() return name()
@ -57,7 +110,7 @@ extension Scanner {
func colorLine() -> (String, ColorDef)? { func colorLine() -> (String, ColorDef)? {
guard guard
let name = self.name(), let name = name(),
let def = colorDef(), let def = colorDef(),
endOfLine() endOfLine()
else { else {
@ -91,17 +144,50 @@ extension Scanner {
return result 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 // swiftlint:disable:next discouraged_optional_collection
func commaSeparated() -> [UInt8]? { func commaSeparated() -> [UInt8]? {
var result: [UInt8] = [] var result: [UInt8] = []
repeat { repeat {
guard let int = scanInt(), let component = UInt8(exactly: int) else { guard let component = component() else {
return nil return nil
} }
result.append(component) result.append(component)
} while string(",") } while string(",")
return result 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 { private extension Scanner {
@ -111,7 +197,7 @@ private extension Scanner {
} }
private extension CharacterSet { private extension CharacterSet {
static let hex = CharacterSet(charactersIn: "0123456789abcdef") static let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
static let name = alphanumerics.union(CharacterSet(charactersIn: "_/")) static let name = alphanumerics.union(CharacterSet(charactersIn: "_/"))
} }

View file

@ -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) }
}

View file

@ -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
)
}
}

View file

@ -19,6 +19,14 @@ struct Color: CustomStringConvertible, Equatable {
alpha = array.count == 4 ? array[3] : 0xFF 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 { var description: String {
let alphaSuffix = alpha != 0xFF ? String(format: "%02X", alpha) : "" let alphaSuffix = alpha != 0xFF ? String(format: "%02X", alpha) : ""
return String(format: "#%02X%02X%02X%@", red, green, blue, alphaSuffix) return String(format: "#%02X%02X%02X%@", red, green, blue, alphaSuffix)

View file

@ -1,3 +0,0 @@
import LibMakeColors
MakeColors.main()

View file

@ -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"))
}
}

View file

@ -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))
}
}

View file

@ -1,40 +1,123 @@
@testable import LibMakeColors @testable import MakeColors
import XCTest import XCTest
final class ColorParserTest: XCTestCase { final class ColorParserTest: XCTestCase {
func testScanningThreeDigitColor() throws { func testScanningThreeDigitColor() throws {
let scanner = Scanner(string: "#abc") let color = scanColor("#abc")
let color = scanner.color() 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) XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC), color)
} }
func testScanningFourDigitColor() throws { func testScanningFourDigitColor() throws {
let scanner = Scanner(string: "#abcd") let color = scanColor("#abcd")
let color = scanner.color()
XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC, alpha: 0xDD), color) XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC, alpha: 0xDD), color)
} }
func testScanningSixDigitColor() throws { func testScanningSixDigitColor() throws {
let scanner = Scanner(string: "#abcdef") let color = scanColor("#abcdef")
let color = scanner.color()
XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF), color) XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF), color)
} }
func testScanningEightDigitColor() throws { func testScanningEightDigitColor() throws {
let scanner = Scanner(string: "#abcdef17") let color = scanColor("#abcdef17")
let color = scanner.color()
XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF, alpha: 0x17), color) XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF, alpha: 0x17), color)
} }
func testScanningRGBColor() throws { func testScanningRGBColor() throws {
let scanner = Scanner(string: "rgb(1,2,3)") let color = scanColor("rgb(1,2,3)")
let color = scanner.color()
XCTAssertEqual(Color(red: 1, green: 2, blue: 3), color) XCTAssertEqual(Color(red: 1, green: 2, blue: 3), color)
} }
func testScanningRGBAColor() throws { func testScanningRGBAColor() throws {
let scanner = Scanner(string: "rgba(1,2,3,4)") let color = scanColor("rgba(1,2,3,4)")
let color = scanner.color()
XCTAssertEqual(Color(red: 1, green: 2, blue: 3, alpha: 4), color) 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
}
} }