diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52366af --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +xcuserdata/ + diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme new file mode 100644 index 0000000..34cf953 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MakeColors.xcscheme @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..228d9c6 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", + "state": { + "branch": null, + "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff", + "version": "0.3.1" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..475bbaf --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MakeColors", + platforms: [ + .macOS(.v10_15), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.1")), + ], + targets: [ + .target( + name: "MakeColors", + dependencies: [ + "LibMakeColors" + ]), + .target( + name: "LibMakeColors", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ]), + .testTarget( + name: "MakeColorsTests", + dependencies: ["LibMakeColors"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d8471c --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# MakeColors + +Converts a simple list of color definitions to asset catalogs for Xcode, resource XML for Android or an HTML preview. + diff --git a/Sources/LibMakeColors/Color.swift b/Sources/LibMakeColors/Color.swift new file mode 100644 index 0000000..c96c8c2 --- /dev/null +++ b/Sources/LibMakeColors/Color.swift @@ -0,0 +1,27 @@ +struct Color: CustomStringConvertible, Equatable { + var description: String { + return a != 0xFF ? String(format: "#%02X%02X%02X%02X", r, g, b, a): String(format: "#%02X%02X%02X", r, g, b) + } + + let r, g, b, a: UInt8 + + init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 0xFF) { + self.r = r + self.g = g + self.b = b + self.a = a + } + + init?(_ array: [UInt8]) { + guard array.count >= 3 else { return nil } + r = array[0] + g = array[1] + b = array[2] + a = array.count >= 4 ? array[3] : 0xFF + } +} + +enum ColorDef { + case reference(String) + case color(Color) +} diff --git a/Sources/LibMakeColors/MakeColors.swift b/Sources/LibMakeColors/MakeColors.swift new file mode 100644 index 0000000..89090b4 --- /dev/null +++ b/Sources/LibMakeColors/MakeColors.swift @@ -0,0 +1,328 @@ +import ArgumentParser +import Foundation + +enum Formatter: String, EnumerableFlag { + case ios + case android + case html +} + +enum Errors: Error { + case syntaxError + case duplicateColor(String) + case missingReference(String) + case cyclicReference(String) +} + +public struct MakeColors: ParsableCommand { + @Argument(help: "The color list to proces") + var input: String + + @Flag(help: "The formatter to use") + var formatter = Formatter.ios + + @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() + + print(data) + + switch formatter { + case .ios: + try writeAssetCatalog(data: data) + + case .android: + try writeAndroidXML(data: data) + + case .html: + try writeHtmlPreview(data: data) + } + } + + func outputURL(extension: String) -> URL { + if let output = output { + return URL(fileURLWithPath: output) + } else { + return URL(fileURLWithPath: input).deletingPathExtension().appendingPathExtension(`extension`) + } + } + + func mapColorName(_ name: String) -> String { + mapSpaceColorName(name, separator: "_") + .replacingOccurrences(of: "/", with: "_") + .lowercased() + } + + func mapSpaceColorName(_ name: String, separator: String = " ") -> String { + name.replacingOccurrences( + of: "(?<=[a-z0-9])([A-Z])", + with: "\(separator)$1", + options: .regularExpression, + range: nil + ) + } + + func writeAndroidXML(data: [String: ColorDef]) throws { + var xml = """ + + + + """ + + let prefix = self.prefix.map { mapColorName($0) + "_" } ?? "" + + for (key, color) in data.sorted(by: compare) { + _ = try data.resolve(key) + + let value: String + switch color { + case .color(let colorValue): value = colorValue.description + case .reference(let ref): value = "@color/\(prefix)\(mapColorName(ref))" + } + + xml += """ + \(value) + + """ + } + + xml += """ + + + + """ + + try xml.write(to: outputURL(extension: "xml"), atomically: true, encoding: .utf8) + } + + func writeHtmlPreview(data: [String: ColorDef]) throws { + var html = """ + + + + + + + + + + + + + + + """ + + for (key, color) in data.sorted(by: compare) { + let actualColor = try data.resolve(key) + let value: String + + switch color { + case let .reference(name): value = """ + \(mapSpaceColorName(name))
\(actualColor) + """ + case .color: value = actualColor.description + } + + html += """ + + + + + + + """ + } + + html += """ +
 NameValue
  \(mapSpaceColorName(key))\(value)
+ + + + """ + + try html.write(to: outputURL(extension: "html"), atomically: true, encoding: .utf8) + } + + func writeAssetCatalog(data: [String: ColorDef]) throws { + let root = FileWrapper(directoryWithFileWrappers: ["Contents.json" : FileWrapper(catalog)]) + let colorRoot: FileWrapper + + if let prefix = prefix { + let name = mapSpaceColorName(prefix) + colorRoot = FileWrapper(directoryWithFileWrappers: ["Contents.json": FileWrapper(group)]) + colorRoot.filename = name + colorRoot.preferredFilename = name + root.addFileWrapper(colorRoot) + } else { + colorRoot = root + } + + for key in data.keys { + var path = mapSpaceColorName(key).split(separator: "/").map(\.capitalizeFirst) + let colorSet = path.removeLast() + + var current = colorRoot + for pathSegment in path { + if let next = current.fileWrappers?[pathSegment] { + current = next + } else { + let next = FileWrapper(directoryWithFileWrappers: ["Contents.json": FileWrapper(group)]) + next.filename = pathSegment + next.preferredFilename = pathSegment + _ = current.addFileWrapper(next) + current = next + } + } + + let colorWrapper = try data.resolve(key).fileWrapper() + colorWrapper.filename = "\(colorSet).colorset" + colorWrapper.preferredFilename = "\(colorSet).colorset" + + current.addFileWrapper(colorWrapper) + } + + let outputUrl = outputURL(extension: "xcassets") + + try root.write(to: outputUrl, options: .atomic, originalContentsURL: nil) + } +} + +extension StringProtocol { + var capitalizeFirst: String { + guard !isEmpty else { + return String(self) + } + + return prefix(1).uppercased() + dropFirst() + } +} + +func compareDef(_ a: ColorDef, _ b: ColorDef) -> ComparisonResult { + switch (a, b) { + case (.color, .reference): return .orderedAscending + case (.reference, .color): return .orderedDescending + case (.color, .color), (.reference, .reference): return .orderedSame + } +} + +func compare(_ a: (String, ColorDef), b: (String, ColorDef)) -> Bool { + switch (compareDef(a.1, b.1)) { + case .orderedAscending: return true + case .orderedDescending: return false + case .orderedSame: return a.0.localizedStandardCompare(b.0) == .orderedAscending + } +} + + + + +extension Dictionary where Key == String, Value == ColorDef { + func resolve(_ name: String, visited: Set = []) throws -> Color { + var visited = visited + guard visited.insert(name).inserted else { + throw Errors.cyclicReference(name) + } + + switch self[name] { + case nil: + throw Errors.missingReference(name) + + case .color(let color): + return color + + case .reference(let referenced): + return try resolve(referenced, visited: visited) + } + } + +} + +extension Color { + func json() -> String { + return """ + { + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "\(Float(a) / 256)", + "blue" : "0x\(String(b, radix: 16))", + "green" : "0x\(String(g, radix: 16))", + "red" : "0x\(String(r, radix: 16))" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } + } + """ + } + + func fileWrapper() -> FileWrapper { + FileWrapper(directoryWithFileWrappers: [ + "Contents.json": FileWrapper(json()) + ]) + } +} + +extension FileWrapper { + convenience init(_ string: String) { + self.init(regularFileWithContents: Data(string.utf8)) + } +} + +let group = """ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} +""" + +let catalog = """ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} +""" + diff --git a/Sources/LibMakeColors/Parser.swift b/Sources/LibMakeColors/Parser.swift new file mode 100644 index 0000000..9cce424 --- /dev/null +++ b/Sources/LibMakeColors/Parser.swift @@ -0,0 +1,126 @@ +import Foundation + + +extension CharacterSet { + static let hex = CharacterSet(charactersIn: "0123456789abcdef") + static let name = alphanumerics.union(CharacterSet.init(charactersIn: "_/")) +} + +extension Collection { + func chunks(size: Int) -> UnfoldSequence { + sequence(state: startIndex) { state -> SubSequence? in + guard state != endIndex else { return nil } + let next = index(state, offsetBy: size, limitedBy: endIndex) ?? endIndex + defer { state = next } + return self[state.. Bool { + return scanString(s) != nil + } + + func color() -> Color? { + if string("#"), let digits = scanCharacters(from: .hex) { + switch digits.count { + case 3, 4: //rgb(a) + let digits = digits.chunks(size: 1) + .compactMap { UInt8($0, radix: 16) } + .map { $0 << 4 | $0 } + return Color(digits) + + case 6, 8: //rrggbb(aa) + let digits = digits.chunks(size: 2).compactMap { UInt8($0, radix: 16) } + return Color(digits) + + default: return nil + } + } + + if string("rgba"), string("("), let components = commaSeparated(), components.count == 4, string(")") { + return Color(components) + } + + if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") { + return Color(components) + } + + return nil + } + + func colorReference() -> String? { + guard string("@") else { return nil } + return name() + } + + func name() -> String? { + guard let name = scanCharacters(from: .name), !name.isEmpty else { + return nil + } + + return name + } + + func colorDef() -> ColorDef? { + if let color = color() { + return .color(color) + } + + if let ref = colorReference() { + return .reference(ref) + } + + return nil + } + + func colorLine() -> (String, ColorDef)? { + guard let name = self.name(), + let def = colorDef(), + endOfLine() else { + return nil + } + return (name, def) + } + + func endOfLine() -> Bool { + guard isAtEnd || string("\n") else { + return false + } + _ = scanCharacters(from: .whitespacesAndNewlines) + return true + } + + + func colorList() throws -> [String: ColorDef] { + var result: [String: ColorDef] = [:] + while !isAtEnd { + guard let (name, def) = colorLine() else { + throw Errors.syntaxError + } + + guard !result.keys.contains(name) else { + throw Errors.duplicateColor(name) + } + + result[name] = def + } + + return result + } + + func commaSeparated() -> [UInt8]? { + var result: [UInt8] = [] + repeat { + guard let int = scanInt(), let component = UInt8(exactly: int) else { + return nil + } + result.append(component) + } while string(",") + return result + } +} + + diff --git a/Sources/MakeColors/main.swift b/Sources/MakeColors/main.swift new file mode 100644 index 0000000..db25b90 --- /dev/null +++ b/Sources/MakeColors/main.swift @@ -0,0 +1,3 @@ +import LibMakeColors + +MakeColors.main() diff --git a/Tests/MakeColorsTests/ColorParserTest.swift b/Tests/MakeColorsTests/ColorParserTest.swift new file mode 100644 index 0000000..2aa39f9 --- /dev/null +++ b/Tests/MakeColorsTests/ColorParserTest.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import LibMakeColors + +final class ColorParserTest: XCTestCase { + func testScanningThreeDigitColor() throws { + let scanner = Scanner(string: "#abc") + let color = scanner.color() + XCTAssertEqual(Color(r: 0xaa, g: 0xbb, b: 0xcc), color) + } + + func testScanningFourDigitColor() throws { + let scanner = Scanner(string: "#abcd") + let color = scanner.color() + XCTAssertEqual(Color(r: 0xaa, g: 0xbb, b: 0xcc, a: 0xDD), color) + } + + + func testScanningSixDigitColor() throws { + let scanner = Scanner(string: "#abcdef") + let color = scanner.color() + XCTAssertEqual(Color(r: 0xab, g: 0xcd, b: 0xef), color) + } + + func testScanningEightDigitColor() throws { + let scanner = Scanner(string: "#abcdef17") + let color = scanner.color() + XCTAssertEqual(Color(r: 0xab, g: 0xcd, b: 0xef, a: 0x17), color) + } + + func testScanningRGBColor() throws { + let scanner = Scanner(string: "rgb(1,2,3)") + let color = scanner.color() + XCTAssertEqual(Color(r: 1, g: 2, b: 3), color) + } + + func testScanningRGBAColor() throws { + let scanner = Scanner(string: "rgba(1,2,3,4)") + let color = scanner.color() + XCTAssertEqual(Color(r: 1, g: 2, b: 3, a: 4), color) + } + +}