|
| 1 | +import ArgumentParser |
| 2 | +import Cocoa |
| 3 | +import Foundation |
| 4 | + |
| 5 | +func warn(_ msg: String = String(), function: StaticString = #function, file: StaticString = #file, line: UInt = #line, column: UInt = #column) { |
| 6 | + FileHandle.standardError.write((msg + "\n").data(using: String.Encoding.utf8)!) |
| 7 | +} |
| 8 | + |
| 9 | +extension String { |
| 10 | + var fileURL: URL { |
| 11 | + return URL(fileURLWithPath: self) |
| 12 | + } |
| 13 | + var pathExtension: String { |
| 14 | + return fileURL.pathExtension |
| 15 | + } |
| 16 | + var lastPathComponent: String { |
| 17 | + return fileURL.lastPathComponent |
| 18 | + } |
| 19 | + var basename: String { |
| 20 | + return fileURL.deletingPathExtension().lastPathComponent |
| 21 | + } |
| 22 | +} |
| 23 | + |
| 24 | +extension Dictionary { |
| 25 | + var jsonData: Data? { |
| 26 | + return try? JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted]) |
| 27 | + } |
| 28 | + |
| 29 | + func toJSONString() -> String? { |
| 30 | + if let jsonData = jsonData { |
| 31 | + let jsonString = String(data: jsonData, encoding: .utf8) |
| 32 | + return jsonString |
| 33 | + } |
| 34 | + |
| 35 | + return nil |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +struct RuntimeError: Error, CustomStringConvertible { |
| 40 | + var description: String |
| 41 | + |
| 42 | + init(_ description: String) { |
| 43 | + self.description = description |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +enum IconIdiom: String { |
| 48 | + case mac |
| 49 | + case universal |
| 50 | +} |
| 51 | + |
| 52 | +struct IconOptions { |
| 53 | + var idiom: IconIdiom |
| 54 | + var scaleUp: Bool |
| 55 | + var basename: String |
| 56 | + var sizes: [Int: String] |
| 57 | + var scales: Array<Int> |
| 58 | +} |
| 59 | + |
| 60 | +enum OutputType: String, EnumerableFlag { |
| 61 | + case appiconset, iconset, imageset |
| 62 | +} |
| 63 | + |
| 64 | +// https://developer.apple.com/library/mac/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Optimizing/Optimizing.html#//apple_ref/doc/uid/TP40012302-CH7-SW4i |
| 65 | +let iconsetOpts = IconOptions( |
| 66 | + idiom: .mac, |
| 67 | + scaleUp: true, |
| 68 | + basename: "icon_", |
| 69 | + sizes: [ |
| 70 | + 16: "16x16", |
| 71 | + 32: "32x32", |
| 72 | + 128: "128x128", |
| 73 | + 256: "256x256", |
| 74 | + 512: "512x512" |
| 75 | + ], |
| 76 | + scales: [1, 2] |
| 77 | +) |
| 78 | + |
| 79 | +// https://developer.apple.com/library/ios/recipes/xcode_help-image_catalog-1.0/Recipe.html |
| 80 | +// https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/IconMatrix.html |
| 81 | +let appIconsetOpts = IconOptions( |
| 82 | + idiom: .universal, |
| 83 | + scaleUp: true, |
| 84 | + basename: "Icon-", |
| 85 | + sizes: [ // https://developer.apple.com/library/ios/qa/qa1686/_index.html |
| 86 | + // the TN says filenames don't matter but Xcode and actool disagree |
| 87 | + 25: "Small-50", |
| 88 | + 29: "Small", |
| 89 | + 40: "Small-40", |
| 90 | + 60: "60", |
| 91 | + 76: "76" |
| 92 | + //512: 'iTunesArtwork' // needs to be this with no basename or extension |
| 93 | + ], |
| 94 | + scales: [1, 2, 3] |
| 95 | +) |
| 96 | + |
| 97 | +var imagesetOpts = IconOptions( |
| 98 | + idiom: .universal, |
| 99 | + scaleUp: false, |
| 100 | + basename: "image", //doesn't matter |
| 101 | + sizes: [:], |
| 102 | + scales: [] |
| 103 | +) |
| 104 | + |
| 105 | +func setScalesFromImageSize(src: NSImage, options: inout IconOptions) { |
| 106 | + // find the scales that can be evenly-derived from the source image's nativeSize |
| 107 | + let nativeSize = src.size.width |
| 108 | + for scale in [3, 2, 1] { |
| 109 | + if ((nativeSize.truncatingRemainder(dividingBy: CGFloat(scale))) == 0) { |
| 110 | + let pts = Int(nativeSize / CGFloat(scale)) |
| 111 | + options.sizes[pts] = "\(pts)" |
| 112 | + for i in 1...scale { options.scales.append(i) } |
| 113 | + break |
| 114 | + } |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +func imageExplode(src: NSImage, outdir: String, options: IconOptions) -> Array<[String:String]> { |
| 119 | + warn("generating \(outdir)") |
| 120 | + try? FileManager.default.createDirectory(atPath: outdir, withIntermediateDirectories: true) |
| 121 | + var images: Array<[String:String]> = [] |
| 122 | + for size in options.sizes.keys.sorted() { |
| 123 | + for scale in options.scales { |
| 124 | + let pxs = size * scale |
| 125 | + if !options.scaleUp && (Float(src.size.width) < Float(pxs)) { |
| 126 | + continue |
| 127 | + } |
| 128 | + // do rescaling |
| 129 | + var imageName = options.basename |
| 130 | + let sizeVal = options.sizes[size] ?? "\(size)x\(size)" |
| 131 | + imageName.append(sizeVal) |
| 132 | + if (scale > 1) { |
| 133 | + imageName.append("@\(scale)x") |
| 134 | + } |
| 135 | + let imagePath = outdir + "/" + imageName + ".png" |
| 136 | + warn("\(size) @ \(scale)x => \(pxs)px => \(imagePath)") |
| 137 | + let newSize = NSSize(width: Int(pxs), height: Int(pxs)) |
| 138 | + let frame = NSRect(x: 0, y: 0, width: newSize.width, height: newSize.height) |
| 139 | + guard let representation = src.bestRepresentation(for: frame, context: nil, hints: nil) else { continue } |
| 140 | + let newImage = NSImage(size: newSize, flipped: false, drawingHandler: { (_) -> Bool in |
| 141 | + return representation.draw(in: frame) |
| 142 | + }) |
| 143 | + guard let newTiff = newImage.tiffRepresentation, let bmp = NSBitmapImageRep(data: newTiff) else { continue } |
| 144 | + bmp.hasAlpha = true |
| 145 | + guard let data = bmp.representation(using: .png, properties: [.compressionFactor: 0.85]) else { continue } |
| 146 | + try? data.write(to: imagePath.fileURL) |
| 147 | + images.append([ |
| 148 | + "filename": imagePath.lastPathComponent, |
| 149 | + "idiom": options.idiom.rawValue, |
| 150 | + "size": "\(size)x\(size)", |
| 151 | + "scale": "\(scale)x" |
| 152 | + ]) |
| 153 | + } |
| 154 | + } |
| 155 | + return images |
| 156 | +} |
| 157 | + |
| 158 | +@main |
| 159 | +struct Iconify: ParsableCommand { |
| 160 | + static let configuration = CommandConfiguration(abstract: "generate an .icns, .iconset, .xcasset, and .car from a single big bitmap icon.") |
| 161 | + |
| 162 | + @Flag(help: "types of icons to generate in the xcassetdir") |
| 163 | + var outputTypes: [OutputType] |
| 164 | + |
| 165 | + @Argument(help: "path of the source .png file") |
| 166 | + var sourcePath: String |
| 167 | + |
| 168 | + @Argument(help: "path of the output .xcassets directory") |
| 169 | + var xcassetPath: String |
| 170 | + |
| 171 | + mutating func run() throws { |
| 172 | + let iconName = sourcePath.basename |
| 173 | + guard let sourceImage = NSImage(contentsOfFile: sourcePath) else { |
| 174 | + throw RuntimeError("\(sourcePath) is unparseable as an NSImage") |
| 175 | + } |
| 176 | + for outputType in outputTypes { |
| 177 | + var outputPath: String |
| 178 | + var iconOptions: IconOptions |
| 179 | + switch (outputType) { |
| 180 | + case .appiconset: |
| 181 | + outputPath = "\(xcassetPath)/AppIcon.appiconset" |
| 182 | + iconOptions = appIconsetOpts |
| 183 | + case .iconset: |
| 184 | + outputPath = "\(xcassetPath)/Icon.iconset" |
| 185 | + iconOptions = iconsetOpts |
| 186 | + case .imageset: |
| 187 | + outputPath = "\(xcassetPath)/\(iconName).imageset" |
| 188 | + iconOptions = imagesetOpts |
| 189 | + setScalesFromImageSize(src: sourceImage, options: &iconOptions) |
| 190 | + } |
| 191 | + let imagesinfo = imageExplode(src: sourceImage, outdir: outputPath, options: iconOptions) |
| 192 | + |
| 193 | + switch (outputType) { |
| 194 | + case .iconset: |
| 195 | + // OSX icon, needs nothing |
| 196 | + break; |
| 197 | + case .appiconset, .imageset: |
| 198 | + // write imagesinfo |
| 199 | + var info: [String:Any] = [ |
| 200 | + "info": [ |
| 201 | + "version": 1, |
| 202 | + "author": "iconify" |
| 203 | + ], |
| 204 | + ] |
| 205 | + info["images"] = imagesinfo |
| 206 | + let manifest = "\(outputPath)/Contents.json" |
| 207 | + guard let jsonString = info.toJSONString() else { |
| 208 | + throw RuntimeError("could not serialize JSON for \(manifest)") |
| 209 | + } |
| 210 | + //warn("============= \(manifest) ==========\n\(jsonString)") |
| 211 | + try? jsonString.write(to: manifest.fileURL, atomically: true, encoding: String.Encoding.utf8) |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | +} |
| 216 | +// vim: filetype=swift |
0 commit comments