Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
### Fixed
- Fixed icon sizing bug where output images were created at 2× the requested dimensions (e.g., 512px request would produce 1024px output) ([#3](https://github.com/macadmins/icongrabber/issues/3))
- Corrected bitmap creation to use explicit pixel dimensions instead of relying on NSImage.size which was affected by Retina scaling

### Changed
- Improved test suite with pixel dimension verification using `sips` to ensure icons are created at exact requested sizes
- Added 4 new dimension verification tests covering standard icon sizes (64×64, 256×256, 512×512, 1024×1024)

---

Expand Down
40 changes: 25 additions & 15 deletions icongrabber-cli/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,36 +234,46 @@ func extractIcon(from appPath: String, size: Int) throws -> NSImage {
// Get the icon for the application
let icon = NSWorkspace.shared.icon(forFile: url.path)

// Set the size
let iconSize = NSSize(width: size, height: size)
icon.size = iconSize

return icon
}

func saveIconAsPNG(_ icon: NSImage, to outputPath: String, size: Int) throws {
let url = URL(fileURLWithPath: outputPath)
let iconSize = NSSize(width: size, height: size)

// Create a bitmap representation
guard let tiffData = icon.tiffRepresentation,
let _ = NSBitmapImageRep(data: tiffData) else {
// Create a bitmap representation with exact pixel dimensions
guard let bitmapRep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: size,
pixelsHigh: size,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
throw CLIError.exportFailed("Could not create bitmap representation")
}

// Resize to exact dimensions
let resizedImage = NSImage(size: iconSize)
resizedImage.lockFocus()
// Draw the icon into the bitmap context at exact pixel dimensions
NSGraphicsContext.saveGraphicsState()
guard let context = NSGraphicsContext(bitmapImageRep: bitmapRep) else {
throw CLIError.exportFailed("Could not create graphics context")
}
NSGraphicsContext.current = context

// Draw icon scaled to exact size
icon.draw(in: NSRect(origin: .zero, size: iconSize),
from: NSRect(origin: .zero, size: icon.size),
from: NSRect.zero,
operation: .copy,
fraction: 1.0)
resizedImage.unlockFocus()

NSGraphicsContext.restoreGraphicsState()

// Convert to PNG
guard let resizedTiff = resizedImage.tiffRepresentation,
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
let pngData = resizedBitmap.representation(using: .png, properties: [:]) else {
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
throw CLIError.exportFailed("Could not create PNG data")
}

Expand Down
47 changes: 47 additions & 0 deletions tests/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,25 @@ assert_exit_code() {
fi
}

assert_image_dimensions() {
local file=$1
local expected_width=$2
local expected_height=$3
if [ -f "$file" ]; then
# Use sips to get actual pixel dimensions
local width=$(sips -g pixelWidth "$file" 2>/dev/null | grep pixelWidth | awk '{print $2}')
local height=$(sips -g pixelHeight "$file" 2>/dev/null | grep pixelHeight | awk '{print $2}')

if [ "$width" = "$expected_width" ] && [ "$height" = "$expected_height" ]; then
pass_test
else
fail_test "Expected ${expected_width}x${expected_height}, got ${width}x${height}"
fi
else
fail_test "File $file does not exist"
fi
}

assert_file_size_under() {
local file=$1
local max_kb=$2
Expand Down Expand Up @@ -145,16 +164,28 @@ print_test 2 "Custom size extraction (256x256)"
$CLI "$TEST_APP" -s 256 -o test_256.png -f
assert_file_exists "test_256.png"

# Test 2a: Verify 256x256 dimensions
print_test "2a" "Verify 256x256 pixel dimensions"
assert_image_dimensions "test_256.png" 256 256

# Test 3: Small size (64x64)
print_test 3 "Small icon extraction (64x64)"
$CLI "$TEST_APP" -s 64 -o test_64.png -f
assert_file_exists "test_64.png"

# Test 3a: Verify 64x64 dimensions
print_test "3a" "Verify 64x64 pixel dimensions"
assert_image_dimensions "test_64.png" 64 64

# Test 4: Large size (1024x1024)
print_test 4 "Large icon extraction (1024x1024)"
$CLI "$TEST_APP" -s 1024 -o test_1024.png -f
assert_file_exists "test_1024.png"

# Test 4a: Verify 1024x1024 dimensions
print_test "4a" "Verify 1024x1024 pixel dimensions"
assert_image_dimensions "test_1024.png" 1024 1024

# Test 5: File size validation (should be reasonable)
print_test 5 "Output file size validation"
assert_file_size "test_basic.png" 1000
Expand Down Expand Up @@ -232,6 +263,22 @@ else
fail_test "Some size extractions failed"
fi

# Test 13a: Verify all standard size dimensions are correct
print_test "13a" "Verify all standard sizes have correct dimensions"
all_correct=true
for size in "${sizes[@]}"; do
width=$(sips -g pixelWidth "test_${size}.png" 2>/dev/null | grep pixelWidth | awk '{print $2}')
height=$(sips -g pixelHeight "test_${size}.png" 2>/dev/null | grep pixelHeight | awk '{print $2}')
if [ "$width" != "$size" ] || [ "$height" != "$size" ]; then
fail_test "Size ${size}: Expected ${size}x${size}, got ${width}x${height}"
all_correct=false
break
fi
done
if [ "$all_correct" = true ]; then
pass_test
fi

# Test 14: Force flag to overwrite without prompt
print_test 14 "Force flag overwrites without prompting"
$CLI "$TEST_APP" -o test_overwrite.png -s 128
Expand Down
Loading