Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
yarn.lock
.build
11 changes: 11 additions & 0 deletions build-swift.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash

# Build the Swift package for composing icons
echo "Building Swift package..."
cd imageComposition
swift build --configuration release --product compose-icon

# Copy the built executable to the root directory for easy access
cp .build/release/compose-icon ../compose-icon

echo "Swift executable built successfully: compose-icon"
Binary file added compose-icon
Binary file not shown.
54 changes: 18 additions & 36 deletions compose-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {execa} from 'execa';
import {temporaryFile} from 'tempy';
import baseGm from 'gm';
import icns from 'icns-lib';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const gm = baseGm.subClass({imageMagick: true});
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

Expand All @@ -22,48 +20,32 @@ const baseDiskIconPath = `${__dirname}/disk-icon.icns`;
const biggestPossibleIconType = 'ic10';

async function baseComposeIcon(type, appIcon, mountIcon, composedIcon) {
mountIcon = gm(mountIcon);
appIcon = gm(appIcon);

const [appIconSize, mountIconSize] = await Promise.all([
promisify(appIcon.size.bind(appIcon))(),
promisify(appIcon.size.bind(mountIcon))(),
]);

// Change the perspective of the app icon to match the mount drive icon
appIcon = appIcon.out('-matte').out('-virtual-pixel', 'transparent').out('-distort', 'Perspective', `1,1 ${appIconSize.width * 0.08},1 ${appIconSize.width},1 ${appIconSize.width * 0.92},1 1,${appIconSize.height} 1,${appIconSize.height} ${appIconSize.width},${appIconSize.height} ${appIconSize.width},${appIconSize.height}`);

// Resize the app icon to fit it inside the mount icon, aspect ration should not be kept to create the perspective illution
appIcon = appIcon.resize(mountIconSize.width / 1.58, mountIconSize.height / 1.82, '!');

// Write app and mount icons to temporary PNG files
const temporaryAppIconPath = temporaryFile({extension: 'png'});
await promisify(appIcon.write.bind(appIcon))(temporaryAppIconPath);

// Compose the two icons
const iconGravityFactor = mountIconSize.height * 0.063;
mountIcon = mountIcon.composite(temporaryAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`);
const temporaryMountIconPath = temporaryFile({extension: 'png'});
const temporaryOutputPath = temporaryFile({extension: 'png'});

composedIcon[type] = await promisify(mountIcon.toBuffer.bind(mountIcon))();
}
await writeFile(temporaryAppIconPath, appIcon);
await writeFile(temporaryMountIconPath, mountIcon);

const hasGm = async () => {
// Use Swift executable for image processing
const swiftExecutablePath = path.join(__dirname, 'compose-icon');

try {
await execa('gm', ['-version']);
return true;
await execa(swiftExecutablePath, [
temporaryAppIconPath,
temporaryMountIconPath,
temporaryOutputPath
]);

// Read the composed image back
composedIcon[type] = await readFile(temporaryOutputPath);
} catch (error) {
if (error.code === 'ENOENT') {
return false;
}

throw error;
throw new Error(`Swift image processing failed: ${error.message}`);
}
};
}

export default async function composeIcon(appIconPath) {
if (!await hasGm()) {
return baseDiskIconPath;
}

const baseDiskIcons = filterMap(icns.parse(await readFile(baseDiskIconPath)), ([key]) => icns.isImageType(key));
const appIcon = filterMap(icns.parse(await readFile(appIconPath)), ([key]) => icns.isImageType(key));

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>ComposeIcon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
22 changes: 22 additions & 0 deletions imageComposition/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// swift-tools-version: 5.7

import PackageDescription

let package = Package(
name: "ComposeIcon",
platforms: [
.macOS(.v11)
],
products: [
.executable(
name: "compose-icon",
targets: ["ComposeIcon"]
)
],
targets: [
.executableTarget(
name: "ComposeIcon",
dependencies: []
)
]
)
165 changes: 165 additions & 0 deletions imageComposition/Sources/ComposeIcon/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import CoreGraphics
import CoreImage
import Foundation
import CoreImage.CIFilterBuiltins
import ImageIO
import UniformTypeIdentifiers

func cgContext(width: Int, height: Int) -> CGContext? {
CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
}

func perspectiveTransform(image: CGImage, width: Int, height: Int) -> CGImage? {
// Apply perspective transformation directly to the image
let ciImage = CIImage(cgImage: image)
let filter = CIFilter.perspectiveTransform()

let w = CGFloat(width)
let h = CGFloat(height)

// From original JS transformation: top gets narrower by 8% on each side
// CIFilter uses bottom-left origin
filter.setValue(ciImage, forKey: kCIInputImageKey)
filter.setValue(CIVector(x: w * 0.08, y: h), forKey: "inputTopLeft") // Top-left: inset 8%
filter.setValue(CIVector(x: w * 0.92, y: h), forKey: "inputTopRight") // Top-right: inset to 92%
filter.setValue(CIVector(x: 0, y: 0), forKey: "inputBottomLeft") // Bottom-left: no change
filter.setValue(CIVector(x: w, y: 0), forKey: "inputBottomRight") // Bottom-right: no change
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point of using the typed filter is to use the typed properties.


guard let outputImage = filter.outputImage else { return nil }

// Create context for the final image
guard let ctx = cgContext(width: width, height: height) else { return nil }

ctx.clear(CGRect(x: 0, y: 0, width: width, height: height))

let ciContext = CIContext()
guard let finalCGImage = ciContext.createCGImage(outputImage, from: outputImage.extent) else { return nil }

// Crop to original size if needed
if finalCGImage.width == width && finalCGImage.height == height {
return finalCGImage
} else {
let sourceRect = CGRect(x: 0, y: 0, width: width, height: height)
return finalCGImage.cropping(to: sourceRect)
}
}

func resizeImage(image: CGImage, width: Int, height: Int) -> CGImage? {
guard let ctx = cgContext(width: width, height: height) else { return nil }

ctx.clear(CGRect(x: 0, y: 0, width: width, height: height))
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))

return ctx.makeImage()
}

func compositeImages(baseImage: CGImage, overlayImage: CGImage, offsetY: CGFloat) -> CGImage? {
let width = baseImage.width
let height = baseImage.height

guard let ctx = cgContext(width: width, height: height) else { return nil }

// Draw base image
ctx.draw(baseImage, in: CGRect(x: 0, y: 0, width: width, height: height))

// Calculate centered position with offset (matching GraphicsMagick's gravity('Center').geometry('+0-offset'))
let overlayWidth = overlayImage.width
let overlayHeight = overlayImage.height

// Center horizontally and vertically, then apply upward offset
// CoreGraphics uses bottom-left origin, so we need to flip Y coordinate
let x = CGFloat(width - overlayWidth) / 2.0
let centerY = CGFloat(height - overlayHeight) / 2.0
let y = centerY + offsetY // In CoreGraphics, positive Y moves up from bottom-left origin


// Draw overlay image
ctx.draw(overlayImage, in: CGRect(x: x, y: y, width: CGFloat(overlayWidth), height: CGFloat(overlayHeight)))

return ctx.makeImage()
}

func loadImage(from path: String) -> CGImage? {
guard let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil) else {
return nil
}

return CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
}

func saveImage(_ image: CGImage, to path: String) -> Bool {
guard let destination = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else {
return false
}

CGImageDestinationAddImage(destination, image, nil)
return CGImageDestinationFinalize(destination)
}

// Main program
guard CommandLine.arguments.count == 4 else {
print("Usage: compose-icon <app-icon-path> <mount-icon-path> <output-path>")
exit(1)
}

let appIconPath = CommandLine.arguments[1]
let mountIconPath = CommandLine.arguments[2]
let outputPath = CommandLine.arguments[3]

guard let appImage = loadImage(from: appIconPath),
let mountImage = loadImage(from: mountIconPath) else {
print("Error: Could not load input images")
exit(1)
}

let appIconSize = (width: appImage.width, height: appImage.height)
let mountIconSize = (width: mountImage.width, height: mountImage.height)

// Apply perspective transformation
guard let transformedAppImage = perspectiveTransform(
image: appImage,
width: appIconSize.width,
height: appIconSize.height
) else {
print("Error: Could not apply perspective transformation")
exit(1)
}

// Resize app icon to fit inside mount icon (from JS: width / 1.58, height / 1.82)
let resizedWidth = Int(Double(mountIconSize.width) / 1.58)
let resizedHeight = Int(Double(mountIconSize.height) / 1.82)

guard let resizedAppImage = resizeImage(
image: transformedAppImage,
width: resizedWidth,
height: resizedHeight
) else {
print("Error: Could not resize app image")
exit(1)
}

// Composite images with offset (from JS: mountIconSize.height * 0.063)
let offsetY = CGFloat(mountIconSize.height) * 0.063

guard let composedImage = compositeImages(
baseImage: mountImage,
overlayImage: resizedAppImage,
offsetY: offsetY
) else {
print("Error: Could not composite images")
exit(1)
}

// Save result
guard saveImage(composedImage, to: outputPath) else {
print("Error: Could not save output image")
exit(1)
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
"node": ">=18"
},
"scripts": {
"test": "ava"
"test": "ava",
"build": "./build-swift.sh",
"prepublishOnly": "npm run build"
},
"files": [
"cli.js",
"compose-icon.js",
"compose-icon",
"assets",
"disk-icon.icns",
"sla.js",
Expand All @@ -44,7 +47,6 @@
"dependencies": {
"appdmg": "^0.6.6",
"execa": "^8.0.1",
"gm": "^1.25.0",
"icns-lib": "^1.0.1",
"meow": "^13.1.0",
"ora": "^8.0.1",
Expand Down
Loading