screenshots-ios-compose.swift (3942B)
1 #!/usr/bin/env swift 2 3 import AppKit 4 import CoreGraphics 5 import CoreText 6 import Foundation 7 import ImageIO 8 9 guard CommandLine.arguments.count >= 4 else { 10 fputs( 11 "Usage: screenshots-ios-compose.swift <input.png> <output.png> <text> [width height]\n", 12 stderr) 13 exit(1) 14 } 15 16 let inputPath = CommandLine.arguments[1] 17 let outputPath = CommandLine.arguments[2] 18 let text = CommandLine.arguments[3] 19 20 // Output dimensions (default: 6.9" iPhone for App Store) 21 let canvasWidth: CGFloat = 22 CommandLine.arguments.count >= 5 ? CGFloat(Int(CommandLine.arguments[4]) ?? 1320) : 1320 23 let canvasHeight: CGFloat = 24 CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868 25 26 // Background: rgb(30, 16, 40) 27 let bgColor = CGColor(red: 30.0 / 255.0, green: 16.0 / 255.0, blue: 40.0 / 255.0, alpha: 1.0) 28 29 // Load framed device image 30 guard let dataProvider = CGDataProvider(filename: inputPath), 31 let deviceImage = CGImage( 32 pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, 33 intent: .defaultIntent) 34 else { 35 fputs("Failed to load image: \(inputPath)\n", stderr) 36 exit(1) 37 } 38 39 let deviceWidth = CGFloat(deviceImage.width) 40 let deviceHeight = CGFloat(deviceImage.height) 41 42 // Create canvas 43 guard 44 let context = CGContext( 45 data: nil, 46 width: Int(canvasWidth), 47 height: Int(canvasHeight), 48 bitsPerComponent: 8, 49 bytesPerRow: 0, 50 space: CGColorSpaceCreateDeviceRGB(), 51 bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 52 ) 53 else { 54 fputs("Failed to create graphics context\n", stderr) 55 exit(1) 56 } 57 58 // Fill background 59 context.setFillColor(bgColor) 60 context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight)) 61 62 // Scale device image to fit width with padding, leaving space for text 63 let isCompact = canvasWidth < 600 64 let isLandscape = canvasWidth > canvasHeight 65 let bottomPadding: CGFloat = isCompact ? -60 : isLandscape ? -350 : 60 66 let sidePadding: CGFloat = isCompact ? 20 : isLandscape ? 20 : 60 67 let topReserved: CGFloat = isCompact ? 90 : isLandscape ? 200 : 300 68 let maxDeviceWidth = canvasWidth - sidePadding * 2 69 let maxDeviceHeight = canvasHeight - bottomPadding - topReserved 70 let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0) 71 let scaledWidth = deviceWidth * scale 72 let scaledHeight = deviceHeight * scale 73 let deviceX = (canvasWidth - scaledWidth) / 2 74 let deviceY = bottomPadding // CG origin is bottom-left 75 76 context.draw( 77 deviceImage, in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight)) 78 79 // Draw text centered above device 80 let fontSize: CGFloat = isCompact ? canvasWidth * 0.08 : isLandscape ? canvasHeight * 0.06 : canvasWidth * 0.0545 81 let font = NSFont.systemFont(ofSize: fontSize, weight: .bold) 82 let attributes: [NSAttributedString.Key: Any] = [ 83 .font: font, 84 .foregroundColor: NSColor.white, 85 ] 86 let attrString = NSAttributedString(string: text, attributes: attributes) 87 let ctLine = CTLineCreateWithAttributedString(attrString) 88 let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds) 89 90 let textAreaTop = isLandscape ? canvasHeight - 40 : canvasHeight 91 let textAreaBottom = deviceY + scaledHeight 92 let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x 93 let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y 94 95 context.textPosition = CGPoint(x: textX, y: textY) 96 CTLineDraw(ctLine, context) 97 98 // Save 99 guard let resultImage = context.makeImage(), 100 let destination = CGImageDestinationCreateWithURL( 101 URL(fileURLWithPath: outputPath) as CFURL, 102 "public.png" as CFString, 103 1, 104 nil 105 ) 106 else { 107 fputs("Failed to create output\n", stderr) 108 exit(1) 109 } 110 111 CGImageDestinationAddImage(destination, resultImage, nil) 112 guard CGImageDestinationFinalize(destination) else { 113 fputs("Failed to write output\n", stderr) 114 exit(1) 115 }