Skip to content

Commit

Permalink
added source
Browse files Browse the repository at this point in the history
  • Loading branch information
Wilhelm Oks committed Feb 23, 2020
1 parent 0e9f994 commit 06850bd
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
Package.resolved
29 changes: 29 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Appetizer",
products: [
.executable(name: "appetizer", targets: ["AppetizerCL"]),
],
dependencies: [
// used for command line argument parsing
.package(url: "https://github.com/apple/swift-package-manager.git", from: "0.3.0"),

// used to make NSColor from a hex string
.package(url: "https://github.com/WilhelmOks/HexColor.git", from: "1.0.1")
],
targets: [
.target(
name: "AppetizerCore",
dependencies: []),
.target(
name: "AppetizerCL",
dependencies: ["AppetizerCore", "SPMUtility", "HexNSColor"]),
.testTarget(
name: "AppetizerTests",
dependencies: ["AppetizerCore"]),
]
)
263 changes: 263 additions & 0 deletions Sources/AppetizerCL/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/**
* Appetizer
* Copyright (c) Wilhelm Oks 2020
* Licensed under the MIT license (see LICENSE file)
*/

import Foundation
import AppKit
import SPMUtility
import HexNSColor
import AppetizerCore

func main() {
let parser = ArgumentParser(usage: "<options>", overview: "")

let inputFilePathArgument = parser.add(positional: "file", kind: PathArgument.self, usage: "The source image file.", completion: .filename)
let sizeXArgument = parser.add(positional: "sizeX", kind: Int.self, usage: "The width of the target image, in pixels.")
let sizeYArgument = parser.add(positional: "sizeY", kind: Int.self, usage: "The height of the target image, in pixels.")
let paddingArgument = parser.add(option: "--padding", shortName: "-p", kind: Int.self, usage: "The amount of space to leave empty on the edges.")
let colorArgument = parser.add(option: "--color", shortName: "-c", kind: String.self, usage: "The hex color to apply to the image. Example: ff0000 for red.")
let iconNameArgument = parser.add(option: "--name", shortName: "-n", kind: String.self, usage: "The file name for the new icon. If omitted, the name of the source file will be used.")
let iosIconPathArgument = parser.add(option: "--iosIcon", shortName: "-i", kind: PathArgument.self, usage: "The path to a directory where to generate the iOS icon with different sizes.")
let iosAppIconPathArgument = parser.add(option: "--iosAppIcon", shortName: "-ia", kind: PathArgument.self, usage: "The path to a directory where to generate the iOS app icon with different sizes.")
let androidIconPathArgument = parser.add(option: "--androidIcon", shortName: "-a", kind: PathArgument.self, usage: "The path to a directory where to generate the Android icon with different sizes.")
let androidFolderPrefixArgument = parser.add(option: "--androidFolderPrefix", shortName: "-afp", kind: String.self, usage: "The folder prefix for the Android images. Example: 'mipmap' will generate folders like 'mipmap-mdpi' and 'mipmap-xhdpi'. Default is 'drawable'.")
let singleIconPathArgument = parser.add(option: "--singleIcon", shortName: "-si", kind: PathArgument.self, usage: "The path to a directory where to generate a single icon with the specified size.")

do {
let parsedArguments = try parser.parse(Array(ProcessInfo.processInfo.arguments.dropFirst()))

let inputFilePath = parsedArguments.get(inputFilePathArgument)!
let sizeX = parsedArguments.get(sizeXArgument)!
let sizeY = parsedArguments.get(sizeYArgument)!
let padding = parsedArguments.get(paddingArgument) ?? 0
let tintColor = parsedArguments.get(colorArgument)?.colorFromHex()
let iconName = parsedArguments.get(iconNameArgument) ?? ""
let iosIconPath = parsedArguments.get(iosIconPathArgument)
let iosAppIconPath = parsedArguments.get(iosAppIconPathArgument)
let androidIconPath = parsedArguments.get(androidIconPathArgument)
let androidFolderPrefix = parsedArguments.get(androidFolderPrefixArgument) ?? "drawable"
let singleIconPath = parsedArguments.get(singleIconPathArgument)

let inputFilePathString = inputFilePath.path.pathString

let bigImage = try NSImage.from(filePath: inputFilePathString, sizeX: sizeX, sizeY: sizeY)

let tintedImage = bigImage.tinted(withColor: tintColor)

let fileUrl = URL(fileURLWithPath: "file:///" + inputFilePathString)
let originalFileName = fileUrl.deletingPathExtension().lastPathComponent

guard iconName.isValidFileName else {
print("The icon name is not valid: \(iconName)")
return
}

var didSpecifyOutput = false

if let iosIconPathUrl = iosIconPath?.path.asURL {
didSpecifyOutput = true
let name = iconName.isEmpty ? originalFileName : iconName
makeIOSImages(from: tintedImage, to: iosIconPathUrl, name: name, sizeX: sizeX, sizeY: sizeY, padding: padding)
}

if let iosAppIconPathUrl = iosAppIconPath?.path.asURL {
didSpecifyOutput = true
makeIOSAppIconImages(from: tintedImage, to: iosAppIconPathUrl, name: iconName, padding: padding)
}

if let androidIconPathUrl = androidIconPath?.path.asURL {
didSpecifyOutput = true
let name = iconName.isEmpty ? originalFileName : iconName
makeAndroidImages(from: tintedImage, to: androidIconPathUrl, name: name, folderPrefix: androidFolderPrefix, sizeX: sizeX, sizeY: sizeY, padding: padding)
}

if let singleIconPathUrl = singleIconPath?.path.asURL {
didSpecifyOutput = true
let name = iconName.isEmpty ? originalFileName : iconName
makeSingleImage(from: tintedImage, to: singleIconPathUrl, name: name, sizeX: sizeX, sizeY: sizeY, padding: padding)
}

if !didSpecifyOutput {
print("No output generated. Please set an argument to generate iOS or Android icons.")
}
} catch let error {
print(error)
}
}

fileprivate extension String {
func colorFromHex() -> NSColor {
if self.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "white" {
return .white
} else {
return NSColor.fromHexString(self) ?? .white
}
}
}

fileprivate extension String {
var isValidFileName: Bool {
return !self.contains("/")
}
}

private struct DpiInfo {
let name: String
let scale: Double
}

private struct SizeDpiInfo {
let size: Double
let scale: Int
}

private func scaleAndRound(_ double: Double, scale: Double) -> Int {
return Int(round(double * scale))
}

private func scaleAndRound(_ double: Double, scale: Int) -> Int {
return Int(round(double * Double(scale)))
}

private func scaleAndRound(_ integer: Int, scale: Double) -> Int {
return Int(round(Double(integer) * scale))
}

private func makeAndroidImages(from image: NSImage, to destinationFolderUrl: Foundation.URL, name: String, folderPrefix: String, sizeX: Int, sizeY: Int, padding: Int) {
let dpiInfos: [DpiInfo] = [
.init(name: "mdpi", scale: 1),
.init(name: "hdpi", scale: 1.5),
.init(name: "xhdpi", scale: 2),
.init(name: "xxhdpi", scale: 3),
.init(name: "xxxhdpi", scale: 4),
]

let outputFileName = name + ".png"

do {
for dpiInfo in dpiInfos {
let resX = scaleAndRound(sizeX, scale: dpiInfo.scale)
let resY = scaleAndRound(sizeY, scale: dpiInfo.scale)

let scaledPadding = scaleAndRound(padding, scale: dpiInfo.scale)

let scaledImage = image.scaled(toSize: .init(width: resX, height: resY), padding: scaledPadding)

let outputDpiDirectoryUrl = destinationFolderUrl.appendingPathComponent("\(folderPrefix)-\(dpiInfo.name)")
try? FileManager.default.createDirectory(atPath: outputDpiDirectoryUrl.path, withIntermediateDirectories: true, attributes: nil)
let outputFileUrl = outputDpiDirectoryUrl.appendingPathComponent(outputFileName)

try scaledImage.saveAsPng(fileUrl: outputFileUrl)
}
} catch {
print(error)
}
}

private func makeIOSImages(from image: NSImage, to destinationFolderUrl: Foundation.URL, name: String, sizeX: Int, sizeY: Int, padding: Int) {
let dpiInfos: [DpiInfo] = [
.init(name: "", scale: 1),
.init(name: "@2x", scale: 2),
.init(name: "@3x", scale: 3),
]

do {
try FileManager.default.createDirectory(atPath: destinationFolderUrl.path, withIntermediateDirectories: true, attributes: nil)

for dpiInfo in dpiInfos {
let resX = scaleAndRound(sizeX, scale: dpiInfo.scale)
let resY = scaleAndRound(sizeY, scale: dpiInfo.scale)

let scaledPadding = scaleAndRound(padding, scale: dpiInfo.scale)

let scaledImage = image.scaled(toSize: .init(width: resX, height: resY), padding: scaledPadding)

let outputFileName = name + dpiInfo.name + ".png"
let outputFileUrl = destinationFolderUrl.appendingPathComponent(outputFileName)

try scaledImage.saveAsPng(fileUrl: outputFileUrl)
}
} catch {
print(error)
}
}

private func makeIOSAppIconImages(from image: NSImage, to destinationFolderUrl: Foundation.URL, name: String, padding: Int) {
let sizes: [SizeDpiInfo] = [
.init(size: 20, scale: 2),
.init(size: 20, scale: 3),
.init(size: 29, scale: 2),
.init(size: 29, scale: 3),
.init(size: 40, scale: 2),
.init(size: 40, scale: 3),
.init(size: 60, scale: 2),
.init(size: 60, scale: 3),

.init(size: 20, scale: 1),
//.init(size: 20, scale: 2),
.init(size: 29, scale: 1),
//.init(size: 29, scale: 2),
.init(size: 40, scale: 1),
//.init(size: 40, scale: 2),
.init(size: 76, scale: 1),
.init(size: 76, scale: 2),

.init(size: 83.5, scale: 2),

.init(size: 1024, scale: 1),
]

do {
try FileManager.default.createDirectory(atPath: destinationFolderUrl.path, withIntermediateDirectories: true, attributes: nil)

let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
formatter.usesGroupingSeparator = false
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.numberStyle = .decimal

for sizeInfo in sizes {
let res = scaleAndRound(sizeInfo.size, scale: sizeInfo.scale)

let scaledPadding = padding * sizeInfo.scale

let scaledImage = image.scaled(toSize: .init(width: res, height: res), padding: scaledPadding)

let sizeName = formatter.string(from: NSNumber(value: sizeInfo.size)) ?? "0"
let scaleName = sizeInfo.scale == 1 ? "" : "@\(sizeInfo.scale)x"

let outputFileName = "\(name)\(sizeName)\(scaleName).png"
let outputFileUrl = destinationFolderUrl.appendingPathComponent(outputFileName)

try scaledImage.saveAsPng(fileUrl: outputFileUrl)
}
} catch {
print(error)
}
}

private func makeSingleImage(from image: NSImage, to destinationFolderUrl: Foundation.URL, name: String, sizeX: Int, sizeY: Int, padding: Int) {

do {
try FileManager.default.createDirectory(atPath: destinationFolderUrl.path, withIntermediateDirectories: true, attributes: nil)

let resX = sizeX
let resY = sizeY

let scaledPadding = padding

let scaledImage = image.scaled(toSize: .init(width: resX, height: resY), padding: scaledPadding)

let outputFileName = name + ".png"
let outputFileUrl = destinationFolderUrl.appendingPathComponent(outputFileName)

try scaledImage.saveAsPng(fileUrl: outputFileUrl)
} catch {
print(error)
}
}

main()
66 changes: 66 additions & 0 deletions Sources/AppetizerCore/NSImageExtension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Appetizer
* Copyright (c) Wilhelm Oks 2020
* Licensed under the MIT license (see LICENSE file)
*/

import Foundation
import AppKit

public extension NSImage {
enum ImageFileReadError : Error {
case invalidImageFile(_ filePath: String)
case invalidSvgFile(_ filePath: String)
case svgParsingError(_ filePath: String)
}

enum ImageFileWriteError : Error {
case dataRepresentationError
}

static func from(filePath path: String, sizeX: Int, sizeY: Int) throws -> NSImage {
guard let image = NSImage(contentsOfFile: path) else {
throw ImageFileReadError.invalidImageFile(path)
}
return image
}

func scaled(toSize size: CGSize, padding: Int) -> NSImage {
guard let bitmapRep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0) else {
fatalError("Could not create NSBitmapImageRep")
}
bitmapRep.size = size
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmapRep)
self.draw(in: NSRect(x: CGFloat(0 + padding), y: CGFloat(0 + padding), width: size.width - CGFloat(padding * 2), height: size.height - CGFloat(padding * 2)), from: .zero, operation: .copy, fraction: 1.0)
NSGraphicsContext.restoreGraphicsState()

let resizedImage = NSImage(size: size)
resizedImage.addRepresentation(bitmapRep)
return resizedImage
}

func tinted(withColor tint: NSColor?) -> NSImage {
guard let copy = self.copy() as? NSImage else { return self }
guard let tint = tint else { return copy }
copy.lockFocus()
tint.set()
let imageRect = NSRect(origin: NSZeroPoint, size: self.size)
imageRect.fill(using: .sourceAtop)
copy.unlockFocus()
return copy
}

func saveAsPng(fileUrl url: URL) throws {
guard let imageData = self.tiffRepresentation else {
throw ImageFileWriteError.dataRepresentationError
}
guard let imageRep = NSBitmapImageRep(data: imageData) else {
throw ImageFileWriteError.dataRepresentationError
}
guard let pngImage = imageRep.representation(using: .png, properties: [:]) else {
throw ImageFileWriteError.dataRepresentationError
}
try pngImage.write(to: url)
}
}
12 changes: 12 additions & 0 deletions Tests/AppetizerTests/SPM_ProjectTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import XCTest

final class SPM_ProjectTests: XCTestCase {
func testExample() throws {

XCTAssert(true)
}

static var allTests = [
("testExample", testExample),
]
}
9 changes: 9 additions & 0 deletions Tests/AppetizerTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import XCTest

#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(SPM_ProjectTests.allTests),
]
}
#endif
7 changes: 7 additions & 0 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import XCTest

import SPM_ProjectTests

var tests = [XCTestCaseEntry]()
tests += SPM_ProjectTests.allTests()
XCTMain(tests)

0 comments on commit 06850bd

Please sign in to comment.