diff --git a/Example/example.config b/Example/example.config index 4e75db6..8a32856 100644 --- a/Example/example.config +++ b/Example/example.config @@ -9,10 +9,9 @@ "fontFile": "/System/Library/Fonts/HelveticaNeue.ttc", "textColor": "#FFF", "outputWholeImage": true, - "locales": "de", "deviceData": [ { - "outputSuffix": "iPhone X 5.8 inches", + "outputSuffixes": ["iPhone X 5.8 inches"], "screenshots": "Example/Screenshots/iPhone X/", "templateFile": "Example/Template Files/iPhone X TemplateFile.png", "screenshotData": [ @@ -91,7 +90,7 @@ ] }, { - "outputSuffix": "iPad Pro 12.9 inch", + "outputSuffixes": ["ipadPro", "ipadPro129"], "screenshots": "Example/Screenshots/iPad Pro/", "templateFile": "Example/Template Files/iPad Pro TemplateFile.png", "screenshotData": [ diff --git a/Package.resolved b/Package.resolved index 83d00d6..fd392ca 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "7255fd547f70468e19abbac5f7964f1ef309ad92", - "version": "0.2.1" + "revision": "15351c1cd009eba0b6e438bfef55ea9847a8dc4a", + "version": "0.3.0" } }, { diff --git a/Package.swift b/Package.swift index bbc2e5e..c096784 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .executable(name: "swiftframe", targets: ["SwiftFrame"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", from: "0.2.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "2.0.0") ], targets: [ diff --git a/README.md b/README.md index f13991c..1eff630 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ To use SwiftFrame, you need to pass it a configuration file (which is a plain JS * `clearDirectories`: **optional (default: true)** a boolean telling the application whether or not to clear the specified output directories before writing new files to it. This prevents random screenshots from being used in case you update your template file to include one less screenshot for example * `locales`: **optional** a regular expression that can be used to exlude (or include) certain locales during rendering. To only include `fr` and `de` locale for example, use `"fr|de"`. To exclude `ru` and `fr`, use something like `"^(?!ru|fr$)\\w*$"` * `deviceData`: an array containing device specific data about screenshot and text coordinates (this way you can frame screenshots for more than one device per config file) - * `outputSuffix`: a suffix to apply to the output files in addition to the locale identifier and index + * `outputSuffixes`: an array of suffixes to apply to the output files in addition to the locale identifier and index. Multiple suffixes can be used to render the same screenshots for different target devices (for example 2nd and 3rd 12.9 inch iPad Pro) * `screenshots`: a folder path containing a subfolder for each locale, which in turn contains all the screenshots for that device * `templateFile`: an image file that will be rendered above the screenshots to overlay device frames (e.g. see `Example/Template Files/iPhone X/TemplateFile.png`) **Note:** places where screenshots should go need to be transparent * `sliceSizeOverride`: **optional** A custom slice size override in cases where you want to use different size screenshots (for example iPhone X screenshots with a iPhone 8 template file) diff --git a/Sources/SwiftFrame/main.swift b/Sources/SwiftFrame/main.swift index 978a884..2c84537 100644 --- a/Sources/SwiftFrame/main.swift +++ b/Sources/SwiftFrame/main.swift @@ -9,7 +9,7 @@ struct SwiftFrame: ParsableCommand { static let configuration = CommandConfiguration( commandName: "swiftframe", abstract: "CLI application for speedy screenshot framing", - version: "3.1.1", + version: "4.0.0", helpNames: .shortAndLong) @Argument(help: "Read configuration values from the specified file", completion: .list(["config", "json", "yml", "yaml"])) diff --git a/Sources/SwiftFrameCore/Config/DeviceData.swift b/Sources/SwiftFrameCore/Config/DeviceData.swift index d1009c6..f3037e4 100644 --- a/Sources/SwiftFrameCore/Config/DeviceData.swift +++ b/Sources/SwiftFrameCore/Config/DeviceData.swift @@ -7,7 +7,7 @@ public struct DeviceData: Decodable, ConfigValidatable { private let kScreenshotExtensions = Set(["png", "jpg", "jpeg"]) - let outputSuffix: String + let outputSuffixes: [String] let templateImagePath: FileURL private let screenshotsPath: FileURL let sliceSizeOverride: DecodableSize? @@ -19,10 +19,14 @@ public struct DeviceData: Decodable, ConfigValidatable { private(set) var screenshotData = [ScreenshotData]() private(set) var textData = [TextData]() + private var suffixesStringRepresentation: String { + outputSuffixes.map { "\"\($0)\"" }.joined(separator: ", ") + } + // MARK: - Coding Keys enum CodingKeys: String, CodingKey { - case outputSuffix + case outputSuffixes case screenshotsPath = "screenshots" case templateImagePath = "templateFile" case sliceSizeOverride @@ -34,7 +38,7 @@ public struct DeviceData: Decodable, ConfigValidatable { // MARK: - Init internal init( - outputSuffix: String, + outputSuffixes: [String], templateImagePath: FileURL, screenshotsPath: FileURL, sliceSizeOverride: DecodableSize? = nil, @@ -44,7 +48,7 @@ public struct DeviceData: Decodable, ConfigValidatable { textData: [TextData] = [TextData](), gapWidth: Int = 0) { - self.outputSuffix = outputSuffix + self.outputSuffixes = outputSuffixes self.templateImagePath = templateImagePath self.screenshotsPath = screenshotsPath self.sliceSizeOverride = sliceSizeOverride @@ -79,7 +83,7 @@ public struct DeviceData: Decodable, ConfigValidatable { .sorted { $0.zIndex < $1.zIndex } return DeviceData( - outputSuffix: outputSuffix, + outputSuffixes: outputSuffixes, templateImagePath: templateImagePath, screenshotsPath: screenshotsPath, sliceSizeOverride: sliceSizeOverride, @@ -115,7 +119,7 @@ public struct DeviceData: Decodable, ConfigValidatable { // plus specified gap width in between if let screenshotSize = sliceSizeOverride?.cgSize ?? NSBitmapImageRep.ky_loadFromURL(screenshotsGroupedByLocale.first?.value.first?.value)?.ky_nativeSize { guard let templateImageSize = templateImage?.ky_nativeSize else { - throw NSError(description: "Template image for output suffix \"\(outputSuffix)\" could not be loaded for validation") + throw NSError(description: "Template image for output suffixes \(suffixesStringRepresentation) could not be loaded for validation") } try validateSize(templateImageSize, screenshotSize: screenshotSize) } @@ -138,7 +142,7 @@ public struct DeviceData: Decodable, ConfigValidatable { if gapWidth == 0 { guard remainingPixels == 0 else { throw NSError( - description: "Template image for output suffix \"\(outputSuffix)\" is not a multiple in width as associated screenshot width", + description: "Template image for output suffixes \(suffixesStringRepresentation) is not a multiple in width as associated screenshot width", expectation: "Width should be multiple of \(Int(screenshotSize.width))px", actualValue: "\(Int(screenshotSize.width))px") } @@ -146,7 +150,7 @@ public struct DeviceData: Decodable, ConfigValidatable { // Make sure there's at least one gap guard remainingPixels.truncatingRemainder(dividingBy: CGFloat(gapWidth)) == 0 && remainingPixels != 0 else { throw NSError( - description: "Template image for output suffix \"\(outputSuffix)\" is not a multiple in width as associated screenshot width", + description: "Template image for output suffixes \(suffixesStringRepresentation) is not a multiple in width as associated screenshot width", expectation: "Template image width should be = (x * screenshot width) + (x - 1) * gap width", actualValue: "Template image width: \(templateSize.width)px, screenshot width: \(screenshotSize.width), gap width: \(gapWidth)") } @@ -154,7 +158,7 @@ public struct DeviceData: Decodable, ConfigValidatable { } func printSummary(insetByTabs tabs: Int) { - CommandLineFormatter.printKeyValue("Ouput suffix", value: outputSuffix, insetBy: tabs) + CommandLineFormatter.printKeyValue("Ouput suffixes", value: outputSuffixes.joined(separator: ", "), insetBy: tabs) CommandLineFormatter.printKeyValue("Template file path", value: templateImagePath.path, insetBy: tabs) CommandLineFormatter.printKeyValue("Gap Width", value: gapWidth, insetBy: tabs) CommandLineFormatter.printKeyValue( diff --git a/Sources/SwiftFrameCore/Workers/ConfigProcessor.swift b/Sources/SwiftFrameCore/Workers/ConfigProcessor.swift index 4fade3a..5cf405d 100644 --- a/Sources/SwiftFrameCore/Workers/ConfigProcessor.swift +++ b/Sources/SwiftFrameCore/Workers/ConfigProcessor.swift @@ -108,10 +108,12 @@ public class ConfigProcessor { gapWidth: deviceData.gapWidth, outputWholeImage: data.outputWholeImage, locale: locale, - suffix: deviceData.outputSuffix, + suffixes: deviceData.outputSuffixes, format: data.outputFormat) - print("Finished \(locale)-\(deviceData.outputSuffix)") + deviceData.outputSuffixes.forEach { suffix in + print("Finished \(locale)-\(suffix)") + } group.leave() } diff --git a/Sources/SwiftFrameCore/Workers/ImageWriter.swift b/Sources/SwiftFrameCore/Workers/ImageWriter.swift index df4a510..7924556 100644 --- a/Sources/SwiftFrameCore/Workers/ImageWriter.swift +++ b/Sources/SwiftFrameCore/Workers/ImageWriter.swift @@ -12,13 +12,19 @@ public final class ImageWriter { gapWidth: Int, outputWholeImage: Bool, locale: String, - suffix: String, + suffixes: [String], format: FileFormat) throws { guard let image = context.cg.makeImage() else { throw NSError(description: "Could not render output image") } let slices = try sliceImage(image, with: sliceSize, gapWidth: gapWidth) + let fileNameConfiguration = OutputConfiguration( + outputPaths: outputPaths, + locale: locale, + suffixes: suffixes, + format: format + ) let workGroup = DispatchGroup() @@ -26,16 +32,14 @@ public final class ImageWriter { // Also, since we checked beforehand if the directory is writable, we can safely put of the rendering work to a different queue workGroup.enter() DispatchQueue.global(qos: .userInitiated).ky_asyncOrExit { - try ImageWriter.write(images: slices, to: outputPaths, locale: locale, suffix: suffix, format: format) + try ImageWriter.writeSlices(slices, with: fileNameConfiguration) workGroup.leave() } if outputWholeImage { workGroup.enter() DispatchQueue.global(qos: .userInitiated).ky_asyncOrExit { - try outputPaths.forEach { - try ImageWriter.write(image, to: $0.absoluteURL.appendingPathComponent(locale), fileName: "\(locale)-\(suffix)-big", format: format) - } + try ImageWriter.writeBigImage(image, with: fileNameConfiguration) workGroup.leave() } } @@ -60,35 +64,72 @@ public final class ImageWriter { // MARK: - Writing Images - static func write(images: [CGImage], to outputPaths: [FileURL], locale: String, suffix: String, format: FileFormat) throws { - try outputPaths.forEach { url in - try images.enumerated().forEach { tuple in - try write(tuple.element, to: url.path, locale: locale, deviceID: suffix, index: tuple.offset, format: format) - } + static func writeSlices(_ images: [CGImage], with configuration: OutputConfiguration) throws { + try images.enumerated().forEach { value in + let outputPaths = configuration.makeOutputPaths(for: value.offset) + try writeImage(value.element, to: outputPaths, format: configuration.format) } } - static func write(_ image: CGImage, to directoryPath: String, locale: String, deviceID: String, index: Int? = nil, format: FileFormat) throws { - let fileName: String - if let index = index { - fileName = "\(locale)-\(deviceID)-\(index)" - } else { - fileName = "\(locale)-\(deviceID)" - } - let directory = URL(fileURLWithPath: directoryPath).appendingPathComponent(locale) - try write(image, to: directory, fileName: fileName, format: format) + static func writeBigImage(_ image: CGImage, with configuration: OutputConfiguration) throws { + let outputPaths = configuration.makeBigImageOutputPaths() + try writeImage(image, to: outputPaths, format: configuration.format) } - static func write(_ image: CGImage, to directoryPath: URL, fileName: String, format: FileFormat) throws { + static func writeImage(_ image: CGImage, to urls: [URL], format: FileFormat) throws { let rep = NSBitmapImageRep(cgImage: image) guard let data = rep.representation(using: format, properties: [:]) else { - throw NSError(description: "Failed to convert composed image to PNG") + throw NSError(description: "Failed to convert image to \(format.fileExtension.uppercased())") + } + + try urls.forEach { + try data.ky_write(to: $0, options: .atomicWrite) + } + } + +} + +// MARK: - OutputConfiguration + +extension ImageWriter { + + struct OutputConfiguration { + + // MARK: - Properties + + let outputPaths: [FileURL] + let locale: String + let suffixes: [String] + let format: FileFormat + + // MARK: - Methods + + func makeBigImageOutputPaths() -> [URL] { + var urls = [URL]() + makeBasePaths().forEach { basePath in + suffixes.forEach { suffix in + let url = basePath.appendingPathComponent("\(locale)-\(suffix)-big").appendingPathExtension(format.fileExtension) + urls.append(url) + } + } + return urls + } + + func makeOutputPaths(for sliceIndex: Int) -> [URL] { + var urls = [URL]() + makeBasePaths().forEach { basePath in + suffixes.forEach { suffix in + let url = basePath.appendingPathComponent("\(locale)-\(suffix)-\(sliceIndex)").appendingPathExtension(format.fileExtension) + urls.append(url) + } + } + return urls + } + + private func makeBasePaths() -> [URL] { + outputPaths.map { $0.absoluteURL.appendingPathComponent(locale) } } - let targetURL = directoryPath - .appendingPathComponent(fileName) - .appendingPathExtension(format.fileExtension) - try data.ky_write(to: targetURL, options: .atomicWrite) } } diff --git a/Tests/SwiftFrameTests/ImageLoaderTests.swift b/Tests/SwiftFrameTests/ImageLoaderTests.swift index bd3ba6f..b015de4 100644 --- a/Tests/SwiftFrameTests/ImageLoaderTests.swift +++ b/Tests/SwiftFrameTests/ImageLoaderTests.swift @@ -9,7 +9,9 @@ class ImageLoaderTests: BaseTest { let rep = context.cg.makePlainWhiteImageRep() let cgImage = try ky_unwrap(rep.cgImage) - try ImageWriter.write(cgImage, to: "testing/", locale: "en", deviceID: "testing_device", format: .png) + let url = URL(fileURLWithPath: "testing/en/en-testing_device.png") + + try ImageWriter.writeImage(cgImage, to: [url], format: .png) XCTAssertNoThrow(try ImageLoader().loadImage(atPath: "testing/en/en-testing_device.png")) } diff --git a/Tests/SwiftFrameTests/Utility/DeviceDataFixtures.swift b/Tests/SwiftFrameTests/Utility/DeviceDataFixtures.swift index 7517556..ed31bd2 100644 --- a/Tests/SwiftFrameTests/Utility/DeviceDataFixtures.swift +++ b/Tests/SwiftFrameTests/Utility/DeviceDataFixtures.swift @@ -4,14 +4,14 @@ import Foundation extension DeviceData { static let goodData = DeviceData( - outputSuffix: "iPhone X", + outputSuffixes: ["iPhone X"], templateImagePath: FileURL(path: "testing/templatefile-debug_device1.png"), screenshotsPath: FileURL(path: "testing/screenshots/"), screenshotData: [.goodData], textData: [.goodData]) static let gapData = DeviceData( - outputSuffix: "iPhone X", + outputSuffixes: ["iPhone X"], templateImagePath: FileURL(path: "testing/templatefile-debug_device1.png"), screenshotsPath: FileURL(path: "testing/screenshots/"), screenshotData: [.goodData], @@ -19,14 +19,14 @@ extension DeviceData { gapWidth: 16) static let invalidData = DeviceData( - outputSuffix: "iPhone X", + outputSuffixes: ["iPhone X"], templateImagePath: FileURL(path: "testing/templatefile-debug_device1.png"), screenshotsPath: FileURL(path: "testing/screenshots/"), screenshotData: [.goodData], textData: [.invalidData]) static let mismatchingDeviceSizeData = DeviceData( - outputSuffix: "iPhone X", + outputSuffixes: ["iPhone X"], templateImagePath: FileURL(path: "testing/templatefile-debug_device1.png"), screenshotsPath: FileURL(path: "testing/screenshots/"), sliceSizeOverride: DecodableSize(width: 50, height: 100), @@ -34,7 +34,7 @@ extension DeviceData { textData: [.goodData]) static let faultyMismatchingDeviceSizeData = DeviceData( - outputSuffix: "iPhone X", + outputSuffixes: ["iPhone X"], templateImagePath: FileURL(path: "testing/templatefile-debug_device1.png"), screenshotsPath: FileURL(path: "testing/screenshots/"), sliceSizeOverride: DecodableSize(width: 50, height: 100), diff --git a/Tests/SwiftFrameTests/Utility/TestingUtility.swift b/Tests/SwiftFrameTests/Utility/TestingUtility.swift index 31ab290..38c6b01 100644 --- a/Tests/SwiftFrameTests/Utility/TestingUtility.swift +++ b/Tests/SwiftFrameTests/Utility/TestingUtility.swift @@ -11,8 +11,11 @@ struct TestingUtility { throw NSError(description: "Could not make CGImage from Bitmap") } - let url = URL(fileURLWithPath: "testing/screenshots/").appendingPathComponent(locale) - try ImageWriter.write(cgImage, to: url, fileName: deviceSuffix, format: .png) + let url = URL(fileURLWithPath: "testing/screenshots/") + .appendingPathComponent(locale) + .appendingPathComponent(deviceSuffix) + .appendingPathExtension(FileFormat.png.fileExtension) + try ImageWriter.writeImage(cgImage, to: [url], format: .png) } static func writeMockTemplateFile(deviceSuffix: String, gapWidth: Int) throws { @@ -21,8 +24,8 @@ struct TestingUtility { throw NSError(description: "Could not make CGImage from Bitmap") } - let url = URL(fileURLWithPath: "testing/") - try ImageWriter.write(cgImage, to: url, fileName: "templatefile-\(deviceSuffix)", format: .png) + let url = URL(fileURLWithPath: "testing/templatefile-\(deviceSuffix).png") + try ImageWriter.writeImage(cgImage, to: [url], format: .png) } static func setupMockDirectoryWithScreenshots(gapWidth: Int = 0) throws {