-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Wilhelm Oks
committed
Feb 23, 2020
1 parent
0e9f994
commit 06850bd
Showing
7 changed files
with
392 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.DS_Store | ||
/.build | ||
/Packages | ||
/*.xcodeproj | ||
xcuserdata/ | ||
Package.resolved |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |