Compare commits
42 commits
Author | SHA1 | Date | |
---|---|---|---|
28a901e6ae | |||
fa9da22633 | |||
028095f00e | |||
2271198f47 | |||
fe95a6fd35 | |||
4a489ccd20 | |||
ac708f4ae2 | |||
2b1f21dbab | |||
956c1f5d27 | |||
3adefbf70e | |||
0bb53ea949 | |||
012203a4f3 | |||
d7d34812cf | |||
dc00d87a32 | |||
02d2350b29 | |||
e28bee804f | |||
e6f44f5be2 | |||
ba7c0c04fa | |||
e8a02b0180 | |||
78fcdef196 | |||
404566c36b | |||
1c31c11f49 | |||
be2a655c40 | |||
8262760bc7 | |||
04943d8f63 | |||
75a3bc265d | |||
0b94872101 | |||
e65f684654 | |||
911db304ae | |||
90a3ed745f | |||
4406619c84 | |||
d660c40793 | |||
0991ca01ed | |||
b1adcb158e | |||
13f985c0a4 | |||
abc0ef910d | |||
01b564adb5 | |||
b657561e5c | |||
465c1c078f | |||
eb964e2e95 | |||
fb0934ae01 | |||
99d5c5ca74 |
28 changed files with 797 additions and 145 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -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}"
|
||||||
|
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
5.3
|
5.7
|
||||||
|
|
BIN
Docs/assetcatalog.png
Normal file
BIN
Docs/assetcatalog.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
Docs/html.png
Normal file
BIN
Docs/html.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
2
Example/.gitignore
vendored
Normal file
2
Example/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
Example.*
|
||||||
|
!Example.txt
|
10
Example/Example.txt
Normal file
10
Example/Example.txt
Normal 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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
76
README.md
76
README.md
|
@ -11,21 +11,61 @@ brew tap 5sw/makecolors
|
||||||
brew install make-colors
|
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 <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
|
||||||
|
|
|
@ -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`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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" : {
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
151
Sources/MakeColors/Importers/Figma/FigmaImporter.swift
Normal file
151
Sources/MakeColors/Importers/Figma/FigmaImporter.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
15
Sources/MakeColors/Importers/Importer.swift
Normal file
15
Sources/MakeColors/Importers/Importer.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
38
Sources/MakeColors/Importers/List/ListImporter.swift
Normal file
38
Sources/MakeColors/Importers/List/ListImporter.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: "_/"))
|
||||||
}
|
}
|
||||||
|
|
150
Sources/MakeColors/MakeColors.swift
Normal file
150
Sources/MakeColors/MakeColors.swift
Normal 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) }
|
||||||
|
}
|
45
Sources/MakeColors/Model/Color+HSV.swift
Normal file
45
Sources/MakeColors/Model/Color+HSV.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
@ -1,3 +0,0 @@
|
||||||
import LibMakeColors
|
|
||||||
|
|
||||||
MakeColors.main()
|
|
26
Tests/MakeColorsTests/AssetCatalogFormattingTest.swift
Normal file
26
Tests/MakeColorsTests/AssetCatalogFormattingTest.swift
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
39
Tests/MakeColorsTests/ColorHSVTest.swift
Normal file
39
Tests/MakeColorsTests/ColorHSVTest.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue