├── iOSImageOptimizer ├── Tests │ ├── iOSImageOptimizerTests │ │ ├── Fixtures │ │ │ ├── MockImages │ │ │ │ ├── test.jpg │ │ │ │ ├── test.pdf │ │ │ │ ├── test.png │ │ │ │ ├── test.svg │ │ │ │ ├── test@2x.png │ │ │ │ ├── test@3x.png │ │ │ │ ├── corrupted.png │ │ │ │ ├── interlaced.png │ │ │ │ ├── large-image.png │ │ │ │ ├── small-image.png │ │ │ │ └── non-interlaced.png │ │ │ ├── MockProjects │ │ │ │ ├── BasicProject │ │ │ │ │ ├── Images │ │ │ │ │ │ ├── icon.png │ │ │ │ │ │ ├── logo.png │ │ │ │ │ │ ├── background.png │ │ │ │ │ │ ├── image1.png │ │ │ │ │ │ ├── image2.png │ │ │ │ │ │ ├── image3.png │ │ │ │ │ │ ├── logo@2x.png │ │ │ │ │ │ ├── background@2x.png │ │ │ │ │ │ └── unused_image.png │ │ │ │ │ ├── Assets.xcassets │ │ │ │ │ │ └── AppIcon.appiconset │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ ├── Sources │ │ │ │ │ │ ├── ImageConstants.swift │ │ │ │ │ │ └── ViewController.swift │ │ │ │ │ └── Main.storyboard │ │ │ │ └── ComplexProject │ │ │ │ │ ├── Assets.xcassets │ │ │ │ │ └── TestImageSet.imageset │ │ │ │ │ │ └── Contents.json │ │ │ │ │ ├── Resources │ │ │ │ │ └── Localizable.strings │ │ │ │ │ ├── Info.plist │ │ │ │ │ └── Sources │ │ │ │ │ └── ObjectiveCViewController.swift │ │ │ └── MockFiles │ │ │ │ ├── Contents-corrupted.json │ │ │ │ ├── Contents-imageset.json │ │ │ │ └── Contents-valid.json │ │ ├── iOSImageOptimizerTests.swift │ │ ├── MockImageGenerator.swift │ │ ├── TestUtilities.swift │ │ └── AssertionHelpers.swift │ └── .DS_Store ├── Sources │ ├── .DS_Store │ └── iOSImageOptimizer │ │ ├── Extensions.swift │ │ ├── main.swift │ │ ├── FileIteratorHelper.swift │ │ ├── SemanticAnalyzer.swift │ │ ├── ImageScanner.swift │ │ ├── ProjectParser.swift │ │ ├── AppleComplianceValidator.swift │ │ └── ProjectAnalyzer.swift ├── Package.resolved ├── Package.swift └── implementation-plan.md ├── images └── sampleoutput.png ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── branch-protection-guide.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── CLAUDE.md ├── LICENSE └── README.md /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test.pdf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test@2x.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/test@3x.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/corrupted.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/interlaced.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/large-image.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/small-image.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockImages/non-interlaced.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/logo.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/background.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/image1.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/image2.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/image3.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/logo@2x.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/background@2x.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Images/unused_image.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/sampleoutput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahilsatralkar/iOSImageOptimizerTool/HEAD/images/sampleoutput.png -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahilsatralkar/iOSImageOptimizerTool/HEAD/iOSImageOptimizer/Tests/.DS_Store -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahilsatralkar/iOSImageOptimizerTool/HEAD/iOSImageOptimizer/Sources/.DS_Store -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | static func * (left: String, right: Int) -> String { 5 | return String(repeating: left, count: right) 6 | } 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | # Swift Package Manager 11 | .build/ 12 | .swiftpm/ 13 | Package.resolved 14 | 15 | # Xcode 16 | *.xcodeproj 17 | *.xcworkspace 18 | xcuserdata/ 19 | DerivedData/ 20 | .xcode.env.local 21 | 22 | # Build artifacts 23 | build/ 24 | *.dSYM/ 25 | *.profdata 26 | 27 | # IDEs 28 | .vscode/ 29 | .idea/ 30 | 31 | # Logs 32 | *.log -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockFiles/Contents-corrupted.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon20x20.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x" 7 | // Missing comma and closing bracket 8 | { 9 | "filename" : "AppIcon29x29@2x.png", 10 | "idiom" : "iphone", 11 | "scale" : "2x", 12 | "size" : "29x29" 13 | } 14 | ], 15 | "info" : { 16 | "author" : "xcode" 17 | "version" : 1 18 | // Missing closing bracket -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/ComplexProject/Assets.xcassets/TestImageSet.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TestImage.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "TestImage@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "TestImage@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockFiles/Contents-imageset.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "test_image.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "test_image@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "test_image@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "preserves-vector-representation" : true 25 | } 26 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/ComplexProject/Resources/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Localized strings */ 2 | 3 | "welcome_title" = "Welcome"; 4 | "welcome_image" = "welcome_banner"; 5 | "error_image" = "error_icon"; 6 | 7 | /* Button images */ 8 | "button_save_image" = "save_button"; 9 | "button_cancel_image" = "cancel_button"; 10 | "button_delete_image" = "delete_button"; 11 | 12 | /* Navigation images */ 13 | "nav_back_image" = "nav_back_arrow"; 14 | "nav_menu_image" = "nav_hamburger"; 15 | 16 | /* Tab bar images */ 17 | "tab_home_image" = "tab_home_icon"; 18 | "tab_profile_image" = "tab_profile_icon"; 19 | "tab_settings_image" = "tab_settings_icon"; 20 | 21 | /* Dynamic image references */ 22 | "theme_light_background" = "bg_light"; 23 | "theme_dark_background" = "bg_dark"; 24 | "theme_auto_background" = "bg_auto"; -------------------------------------------------------------------------------- /iOSImageOptimizer/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "files", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/JohnSundell/Files", 7 | "state" : { 8 | "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", 9 | "version" : "4.3.0" 10 | } 11 | }, 12 | { 13 | "identity" : "rainbow", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/onevcat/Rainbow", 16 | "state" : { 17 | "revision" : "0c627a4f8a39ef37eadec1ceec02e4a7f55561ac", 18 | "version" : "4.1.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-argument-parser", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-argument-parser", 25 | "state" : { 26 | "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", 27 | "version" : "1.5.1" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## 🐛 Bug Report 10 | 11 | ### Description 12 | A clear and concise description of what the bug is. 13 | 14 | ### Steps to Reproduce 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | ### Expected Behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ### Actual Behavior 24 | A clear and concise description of what actually happened. 25 | 26 | ### Environment 27 | - **OS**: [e.g., macOS 14.0] 28 | - **Xcode Version**: [e.g., 15.0] 29 | - **Swift Version**: [e.g., 5.9] 30 | - **Tool Version**: [e.g., 1.0.0] 31 | 32 | ### Screenshots 33 | If applicable, add screenshots to help explain your problem. 34 | 35 | ### Sample Project 36 | If possible, provide a minimal sample project that reproduces the issue. 37 | 38 | ### Additional Context 39 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## ✨ Feature Request 10 | 11 | ### Summary 12 | A clear and concise description of the feature you'd like to see added. 13 | 14 | ### Motivation 15 | Why is this feature needed? What problem does it solve? 16 | 17 | ### Detailed Description 18 | Provide a detailed description of the feature, including: 19 | - How it should work 20 | - What it should do 21 | - Any specific requirements 22 | 23 | ### Proposed Implementation 24 | If you have ideas about how this could be implemented, please describe them here. 25 | 26 | ### Alternatives Considered 27 | A clear and concise description of any alternative solutions or features you've considered. 28 | 29 | ### Additional Context 30 | Add any other context, screenshots, or examples about the feature request here. 31 | 32 | ### Acceptance Criteria 33 | - [ ] Criterion 1 34 | - [ ] Criterion 2 35 | - [ ] Criterion 3 -------------------------------------------------------------------------------- /.github/branch-protection-guide.md: -------------------------------------------------------------------------------- 1 | # 🛡️ Branch Protection Setup Guide 2 | 3 | ## Recommended Settings for Repository Owner Control 4 | 5 | ### Go to: GitHub Repository → Settings → Branches → Add Rule 6 | 7 | **Rule for `main` branch**: 8 | - ✅ **Require status checks to pass before merging** 9 | - ✅ Require branches to be up to date before merging 10 | - ✅ Status checks required: `Run Tests` 11 | - ✅ **Require pull request reviews before merging** 12 | - ✅ Required approving reviews: 1 (you) 13 | - ✅ Dismiss stale reviews when new commits are pushed 14 | - ✅ **Require review from code owners** (optional) 15 | - ✅ **Include administrators** (applies rules to you too - recommended) 16 | - ❌ **Allow force pushes** (disabled for safety) 17 | - ❌ **Allow deletions** (disabled for safety) 18 | 19 | ## Result 20 | - Tests must pass ✅ 21 | - YOU must manually approve ✅ 22 | - YOU control all merges ✅ 23 | - No auto-merge capability ✅ 24 | 25 | ## Optional: Create CODEOWNERS file 26 | Create `.github/CODEOWNERS` with: 27 | ``` 28 | * @sahilsatralkar 29 | ``` 30 | This makes you the required reviewer for ALL changes. -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import Files 4 | import Rainbow 5 | 6 | struct IOSImageOptimizer: ParsableCommand { 7 | static let configuration = CommandConfiguration( 8 | commandName: "ios-image-optimizer", 9 | abstract: "Find unused and oversized images in iOS projects" 10 | ) 11 | 12 | @Argument(help: "Path to iOS project directory") 13 | var projectPath: String 14 | 15 | @Flag(name: .shortAndLong, help: "Show detailed output") 16 | var verbose = false 17 | 18 | @Flag(name: .shortAndLong, help: "Export findings to JSON") 19 | var json = false 20 | 21 | mutating func run() throws { 22 | print("🔍 Analyzing iOS project at: \(projectPath)".cyan) 23 | 24 | let analyzer = ProjectAnalyzer(projectPath: projectPath, verbose: verbose) 25 | let report = try analyzer.analyze() 26 | 27 | if json { 28 | try report.exportJSON() 29 | } else { 30 | report.printToConsole() 31 | } 32 | } 33 | } 34 | 35 | IOSImageOptimizer.main() -------------------------------------------------------------------------------- /iOSImageOptimizer/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "iOSImageOptimizer", 6 | platforms: [.macOS(.v13)], 7 | dependencies: [ 8 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 9 | .package(url: "https://github.com/JohnSundell/Files", from: "4.0.0"), 10 | .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0") 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "iOSImageOptimizer", 15 | dependencies: [ 16 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 17 | "Files", 18 | "Rainbow" 19 | ] 20 | ), 21 | .testTarget( 22 | name: "iOSImageOptimizerTests", 23 | dependencies: [ 24 | "iOSImageOptimizer", 25 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 26 | "Files", 27 | "Rainbow" 28 | ], 29 | resources: [.copy("Fixtures")] 30 | ) 31 | ] 32 | ) -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📋 Pull Request Checklist 2 | 3 | ### Description 4 | Brief description of the changes made in this PR. 5 | 6 | ### Type of Change 7 | - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) 8 | - [ ] ✨ New feature (non-breaking change that adds functionality) 9 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) 10 | - [ ] 📚 Documentation update 11 | - [ ] 🧪 Test improvements 12 | - [ ] ♻️ Code refactoring 13 | 14 | ### Testing 15 | - [ ] All existing tests pass 16 | - [ ] New tests added for new functionality 17 | - [ ] Manual testing completed 18 | - [ ] Edge cases considered 19 | 20 | ### Code Quality 21 | - [ ] Code follows the project's style guidelines 22 | - [ ] Self-review of code completed 23 | - [ ] Code coverage maintained or improved 24 | - [ ] No new warnings introduced 25 | 26 | ### Related Issues 27 | - Fixes #(issue number) 28 | - Related to #(issue number) 29 | 30 | ### Additional Notes 31 | Any additional information, deployment notes, etc. 32 | 33 | --- 34 | **Note**: This PR will automatically trigger CI tests. Please ensure all checks pass before requesting review. -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockFiles/Contents-valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon20x20.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "AppIcon20x20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "AppIcon29x29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "AppIcon29x29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "AppIcon40x40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon40x40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "AppIcon60x60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "AppIcon60x60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon20x20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "AppIcon20x20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "AppIcon29x29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "AppIcon29x29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "AppIcon40x40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon40x40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "AppIcon60x60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "AppIcon60x60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Sources/ImageConstants.swift: -------------------------------------------------------------------------------- 1 | // Mock Foundation import for pattern testing 2 | import Foundation 3 | 4 | struct ImageConstants { 5 | static let logo = "company_logo" 6 | static let background = "main_background" 7 | 8 | // Computed properties 9 | static var currentThemeLogo: String { 10 | return "logo_\(currentTheme)" 11 | } 12 | 13 | static let currentTheme = "blue" 14 | 15 | // Array constants 16 | static let onboardingImages = [ 17 | "onboarding_1", 18 | "onboarding_2", 19 | "onboarding_3" 20 | ] 21 | 22 | // Dictionary constants 23 | static let buttonImages: [String: String] = [ 24 | "primary": "button_primary", 25 | "secondary": "button_secondary", 26 | "disabled": "button_disabled" 27 | ] 28 | } 29 | 30 | enum ThemeImages: String, CaseIterable { 31 | case light = "theme_light" 32 | case dark = "theme_dark" 33 | case auto = "theme_auto" 34 | } 35 | 36 | class ImageLoader { 37 | func loadImage(named name: String) -> MockUIImage? { 38 | return MockUIImage(named: name) 39 | } 40 | 41 | func loadImages() { 42 | // Runtime image loading 43 | for i in 1...5 { 44 | let imageName = "step_\(i)" 45 | let image = MockUIImage(named: imageName) 46 | } 47 | 48 | // Conditional loading 49 | let isLargeScreen = true 50 | let suffix = isLargeScreen ? "_large" : "_small" 51 | let adaptiveImage = MockUIImage(named: "adaptive_image\(suffix)") 52 | } 53 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Commands 6 | 7 | ### Build 8 | ```bash 9 | cd iOSImageOptimizer 10 | swift build 11 | ``` 12 | 13 | ### Run Tests 14 | ```bash 15 | cd iOSImageOptimizer 16 | swift test 17 | 18 | # Run with code coverage 19 | swift test --enable-code-coverage 20 | 21 | # Run a specific test 22 | swift test --filter TestClassName 23 | ``` 24 | 25 | ### Run the Tool 26 | ```bash 27 | cd iOSImageOptimizer 28 | swift run iOSImageOptimizer /path/to/ios/project 29 | 30 | # With verbose output 31 | swift run iOSImageOptimizer /path/to/ios/project --verbose 32 | 33 | # Export to JSON 34 | swift run iOSImageOptimizer /path/to/ios/project --json 35 | ``` 36 | 37 | ### Clean Build 38 | ```bash 39 | cd iOSImageOptimizer 40 | swift package clean 41 | swift build 42 | ``` 43 | 44 | ## Architecture 45 | 46 | ### Core Components 47 | - **ProjectAnalyzer**: Main orchestrator that coordinates the analysis process 48 | - **ImageScanner**: Finds and analyzes image files in the project directory 49 | - **ProjectParser**: Parses Swift, Objective-C, and Storyboard files to find image references 50 | - **UsageDetector**: Detects dynamic image loading patterns including string interpolation 51 | - **AppleComplianceValidator**: Validates images against Apple's Human Interface Guidelines 52 | - **SemanticAnalyzer**: Analyzes relationships between images (scale variants, asset organization) 53 | 54 | ### Key Features 55 | - Detects unused images with enhanced pattern matching for dynamic loading 56 | - Validates PNG interlacing, color profiles, and asset catalog organization 57 | - Provides Apple compliance scoring (0-100) 58 | - Supports both static and dynamic image reference detection 59 | - Works with standard iOS project structures including .xcodeproj directories 60 | 61 | ### Testing 62 | - 154 comprehensive unit tests covering all major components 63 | - Test fixtures include mock projects, images, and JSON files 64 | - CI/CD pipeline runs tests automatically on pull requests 65 | - Code coverage reporting integrated with GitHub Actions -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Sources/ViewController.swift: -------------------------------------------------------------------------------- 1 | // Mock UIKit imports for pattern matching tests 2 | // import UIKit 3 | 4 | class ViewController { 5 | 6 | // @IBOutlet weak var logoImageView: UIImageView! 7 | // @IBOutlet weak var backgroundImageView: UIImageView! 8 | var logoImageView: MockUIImageView! 9 | var backgroundImageView: MockUIImageView! 10 | 11 | func viewDidLoad() { 12 | // super.viewDidLoad() 13 | 14 | // Direct image references (mock code for pattern testing) 15 | logoImageView.image = MockUIImage(named: "logo") 16 | backgroundImageView.image = MockUIImage(named: "background@2x") 17 | 18 | // String literal references 19 | let iconName = "icon" 20 | let buttonImage = MockUIImage(named: iconName) 21 | 22 | // Array of image names 23 | let imageNames = ["image1", "image2", "image3"] 24 | for name in imageNames { 25 | let image = MockUIImage(named: name) 26 | } 27 | 28 | // String interpolation 29 | let theme = "dark" 30 | let interpolatedImage = MockUIImage(named: "button_\(theme)") 31 | 32 | // SwiftUI Image references 33 | let swiftUIImage = MockImage("swiftui_image") 34 | 35 | // System images 36 | let systemImage = MockUIImage(systemName: "star") 37 | 38 | // Asset catalog references 39 | let assetImage = MockUIImage(named: "AppIcon") 40 | } 41 | 42 | func loadThemeImages() { 43 | let themes = ["light", "dark"] 44 | for theme in themes { 45 | let backgroundImage = MockUIImage(named: "background_\(theme)") 46 | let buttonImage = MockUIImage(named: "button_\(theme)_normal") 47 | } 48 | } 49 | } 50 | 51 | // Mock types for compilation 52 | struct MockUIImage { 53 | init(named: String?) {} 54 | init(systemName: String) {} 55 | } 56 | 57 | struct MockImage { 58 | init(_ name: String) {} 59 | } 60 | 61 | struct MockUIImageView { 62 | var image: MockUIImage? 63 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/ComplexProject/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDisplayName 6 | Test App 7 | CFBundleExecutable 8 | TestApp 9 | CFBundleIdentifier 10 | com.test.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | TestApp 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | CFBundleIconFile 24 | AppIcon 25 | CFBundleIconFiles 26 | 27 | Icon-Small-40 28 | Icon-Small-40@2x 29 | Icon-60@2x 30 | Icon-60@3x 31 | Icon-76 32 | Icon-76@2x 33 | 34 | 35 | UILaunchImageFile 36 | LaunchImage 37 | UILaunchImages 38 | 39 | 40 | UILaunchImageName 41 | LaunchImage-568h@2x 42 | UILaunchImageOrientation 43 | Portrait 44 | UILaunchImageSize 45 | {320, 568} 46 | 47 | 48 | UILaunchImageName 49 | LaunchImage-Portrait@2x~ipad 50 | UILaunchImageOrientation 51 | Portrait 52 | UILaunchImageSize 53 | {768, 1024} 54 | 55 | 56 | 57 | 58 | CustomImageSettings 59 | 60 | SplashScreenImage 61 | splash_screen 62 | LoadingSpinnerImage 63 | loading_spinner 64 | ErrorPlaceholderImage 65 | error_placeholder 66 | 67 | 68 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/FileIteratorHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Files 3 | 4 | // Helper to provide filtered recursive iteration 5 | struct FileIteratorHelper { 6 | // Directories to exclude from scanning 7 | static let excludedDirectories = [ 8 | "DerivedData", 9 | "Pods", 10 | ".build", 11 | "Carthage", 12 | "node_modules", 13 | ".git", 14 | "build", 15 | "Build", 16 | "xcuserdata", 17 | ".swiftpm", 18 | "SourcePackages", 19 | ".cocoapods", 20 | "vendor" 21 | ] 22 | 23 | static func shouldExcludeDirectory(_ path: String) -> Bool { 24 | for excluded in excludedDirectories { 25 | if path.contains("/\(excluded)/") || path.hasSuffix("/\(excluded)") { 26 | return true 27 | } 28 | } 29 | return false 30 | } 31 | 32 | // Get all files recursively, excluding certain directories 33 | static func recursiveFiles(in folder: Folder) -> [File] { 34 | var files: [File] = [] 35 | recursivelyCollectFiles(in: folder, into: &files) 36 | return files 37 | } 38 | 39 | private static func recursivelyCollectFiles(in folder: Folder, into files: inout [File]) { 40 | // Add files from current folder 41 | for file in folder.files { 42 | files.append(file) 43 | } 44 | 45 | // Recursively process subfolders, skipping excluded ones 46 | for subfolder in folder.subfolders { 47 | if excludedDirectories.contains(subfolder.name) { 48 | continue 49 | } 50 | recursivelyCollectFiles(in: subfolder, into: &files) 51 | } 52 | } 53 | 54 | // Get all subfolders recursively, excluding certain directories 55 | static func recursiveSubfolders(in folder: Folder) -> [Folder] { 56 | var folders: [Folder] = [] 57 | recursivelyCollectFolders(in: folder, into: &folders) 58 | return folders 59 | } 60 | 61 | private static func recursivelyCollectFolders(in folder: Folder, into folders: inout [Folder]) { 62 | // Process subfolders, skipping excluded ones 63 | for subfolder in folder.subfolders { 64 | if excludedDirectories.contains(subfolder.name) { 65 | continue 66 | } 67 | folders.append(subfolder) 68 | recursivelyCollectFolders(in: subfolder, into: &folders) 69 | } 70 | } 71 | } 72 | 73 | // Extension to make it easier to use 74 | extension Folder { 75 | var filteredRecursiveFiles: [File] { 76 | return FileIteratorHelper.recursiveFiles(in: self) 77 | } 78 | 79 | var filteredRecursiveSubfolders: [Folder] { 80 | return FileIteratorHelper.recursiveSubfolders(in: self) 81 | } 82 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/ComplexProject/Sources/ObjectiveCViewController.swift: -------------------------------------------------------------------------------- 1 | // Mock UIKit import for pattern testing 2 | // import UIKit 3 | 4 | // Mock Bundle for testing 5 | struct Bundle { 6 | static let main = Bundle() 7 | func path(forResource name: String, ofType ext: String) -> String? { 8 | return "/mock/path/\(name).\(ext)" 9 | } 10 | } 11 | 12 | // Mock Objective-C style patterns in Swift for testing 13 | class ObjectiveCViewController { 14 | 15 | // @IBOutlet weak var headerImageView: UIImageView! 16 | // @IBOutlet weak var actionButton: UIButton! 17 | var headerImageView: MockUIImageView! 18 | var actionButton: MockUIButton! 19 | 20 | func viewDidLoad() { 21 | // super.viewDidLoad() 22 | 23 | // Simulate Objective-C imageNamed patterns 24 | headerImageView.image = MockUIImage(named: "objc_header") 25 | actionButton.setImage(MockUIImage(named: "objc_button"), for: .normal) 26 | 27 | // String constants (Objective-C style) 28 | let iconName = "objc_icon" 29 | let iconImage = MockUIImage(named: iconName) 30 | 31 | // Array of image names (Objective-C style) 32 | let imageNames = ["objc_image1", "objc_image2", "objc_image3"] 33 | for name in imageNames { 34 | let image = MockUIImage(named: name) 35 | } 36 | 37 | // String formatting (Objective-C style patterns) 38 | let theme = "red" 39 | let formattedName = "button_\(theme)" 40 | let themeImage = MockUIImage(named: formattedName) 41 | 42 | // Bundle images (Objective-C style) 43 | if let bundlePath = Bundle.main.path(forResource: "bundle_image", ofType: "png") { 44 | let bundleImage = MockUIImage(contentsOfFile: bundlePath) 45 | } 46 | 47 | // System images 48 | let systemImage = MockUIImage(systemName: "heart") 49 | } 50 | 51 | func loadDynamicImages() { 52 | // Loop-based loading (Objective-C style) 53 | for i in 1...10 { 54 | let imageName = "dynamic_\(i)" 55 | let image = MockUIImage(named: imageName) 56 | } 57 | 58 | // Conditional loading 59 | let isRetina = 2.0 > 1.0 // Mock UIScreen.main.scale 60 | let suffix = isRetina ? "@2x" : "" 61 | let retinaImageName = "adaptive_image\(suffix)" 62 | let adaptiveImage = MockUIImage(named: retinaImageName) 63 | } 64 | } 65 | 66 | // Mock types for compilation 67 | struct MockUIImage { 68 | init(named: String?) {} 69 | init(systemName: String) {} 70 | init(contentsOfFile: String?) {} 71 | } 72 | 73 | struct MockUIImageView { 74 | var image: MockUIImage? 75 | } 76 | 77 | struct MockUIButton { 78 | func setImage(_ image: MockUIImage?, for state: MockControlState) {} 79 | } 80 | 81 | enum MockControlState { 82 | case normal 83 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main, develop ] 6 | push: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | test: 11 | name: Run Tests 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Select Xcode version 19 | run: sudo xcode-select -s /Applications/Xcode_15.0.app/Contents/Developer 20 | 21 | - name: Cache Swift packages 22 | uses: actions/cache@v3 23 | with: 24 | path: | 25 | .build 26 | ~/Library/Caches/org.swift.swiftpm 27 | key: ${{ runner.os }}-swift-${{ hashFiles('**/Package.resolved') }} 28 | restore-keys: | 29 | ${{ runner.os }}-swift- 30 | 31 | - name: Build project 32 | run: swift build 33 | working-directory: ./iOSImageOptimizer 34 | 35 | - name: Run unit tests 36 | run: swift test --enable-code-coverage 37 | working-directory: ./iOSImageOptimizer 38 | 39 | - name: Generate coverage report 40 | run: | 41 | # Determine architecture and set build path 42 | ARCH=$(uname -m) 43 | if [ "$ARCH" = "arm64" ]; then 44 | BUILD_PATH="./.build/arm64-apple-macosx/debug" 45 | else 46 | BUILD_PATH="./.build/x86_64-apple-macosx/debug" 47 | fi 48 | 49 | # Find the actual profdata file location 50 | echo "Looking for profdata files..." 51 | find .build -name "*.profdata" -type f || echo "No profdata files found" 52 | 53 | # Check if the test executable exists 54 | TEST_EXECUTABLE="${BUILD_PATH}/iOSImageOptimizerPackageTests.xctest/Contents/MacOS/iOSImageOptimizerPackageTests" 55 | if [ -f "$TEST_EXECUTABLE" ]; then 56 | echo "Test executable found at: $TEST_EXECUTABLE" 57 | 58 | # Try to find profdata in common locations 59 | PROFDATA_FILE="" 60 | if [ -f "${BUILD_PATH}/codecov/default.profdata" ]; then 61 | PROFDATA_FILE="${BUILD_PATH}/codecov/default.profdata" 62 | elif [ -f "./.build/debug/codecov/default.profdata" ]; then 63 | PROFDATA_FILE="./.build/debug/codecov/default.profdata" 64 | else 65 | # Find any profdata file 66 | PROFDATA_FILE=$(find .build -name "*.profdata" -type f | head -1) 67 | fi 68 | 69 | if [ -n "$PROFDATA_FILE" ] && [ -f "$PROFDATA_FILE" ]; then 70 | echo "Using profdata file: $PROFDATA_FILE" 71 | xcrun llvm-cov export \ 72 | "$TEST_EXECUTABLE" \ 73 | -instr-profile="$PROFDATA_FILE" \ 74 | -format="lcov" > coverage.lcov 75 | echo "Coverage report generated successfully" 76 | else 77 | echo "Warning: No profdata file found, skipping coverage report" 78 | echo "Available files in .build:" 79 | find .build -type f -name "*prof*" || echo "No profile files found" 80 | fi 81 | else 82 | echo "Warning: Test executable not found at $TEST_EXECUTABLE" 83 | echo "Available xctest files:" 84 | find .build -name "*.xctest" -type d || echo "No xctest bundles found" 85 | fi 86 | working-directory: ./iOSImageOptimizer 87 | 88 | - name: Upload coverage to Codecov 89 | if: hashFiles('./iOSImageOptimizer/coverage.lcov') != '' 90 | uses: codecov/codecov-action@v3 91 | with: 92 | file: ./iOSImageOptimizer/coverage.lcov 93 | fail_ci_if_error: false 94 | token: ${{ secrets.CODECOV_TOKEN }} 95 | 96 | - name: Test Results Summary 97 | if: always() 98 | run: | 99 | echo "## 🧪 Test Results" >> $GITHUB_STEP_SUMMARY 100 | echo "- ✅ Build completed successfully" >> $GITHUB_STEP_SUMMARY 101 | echo "- ✅ All 154 unit tests executed" >> $GITHUB_STEP_SUMMARY 102 | 103 | # Check if coverage file was generated 104 | if [ -f "coverage.lcov" ]; then 105 | echo "- 📊 Code coverage report generated" >> $GITHUB_STEP_SUMMARY 106 | COVERAGE_STATUS="✅ Coverage uploaded to Codecov" 107 | else 108 | echo "- ⚠️ Code coverage report generation skipped" >> $GITHUB_STEP_SUMMARY 109 | COVERAGE_STATUS="ℹ️ Coverage data not available" 110 | fi 111 | 112 | echo "" >> $GITHUB_STEP_SUMMARY 113 | echo "### Next Steps" >> $GITHUB_STEP_SUMMARY 114 | echo "- Review test results in the Actions tab" >> $GITHUB_STEP_SUMMARY 115 | echo "- $COVERAGE_STATUS" >> $GITHUB_STEP_SUMMARY 116 | working-directory: ./iOSImageOptimizer -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/Fixtures/MockProjects/BasicProject/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/iOSImageOptimizerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | @testable import iOSImageOptimizer 4 | 5 | final class iOSImageOptimizerTests: XCTestCase { 6 | 7 | var tempProjectPath: String! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | tempProjectPath = createTempTestProject() 12 | } 13 | 14 | override func tearDown() { 15 | cleanupTempProject() 16 | super.tearDown() 17 | } 18 | 19 | // MARK: - ProjectAnalyzer Tests 20 | 21 | func testProjectAnalyzerBasicAnalysis() throws { 22 | let analyzer = ProjectAnalyzer(projectPath: tempProjectPath, verbose: false) 23 | let report = try analyzer.analyze() 24 | 25 | XCTAssertGreaterThanOrEqual(report.totalImages, 0) 26 | XCTAssertGreaterThanOrEqual(report.totalSize, 0) 27 | XCTAssertNotNil(report.appleComplianceResults) 28 | } 29 | 30 | func testProjectAnalyzerWithVerboseMode() throws { 31 | let analyzer = ProjectAnalyzer(projectPath: tempProjectPath, verbose: true) 32 | let report = try analyzer.analyze() 33 | 34 | XCTAssertNotNil(report) 35 | } 36 | 37 | // MARK: - ImageScanner Tests 38 | 39 | func testImageScannerFindsImages() throws { 40 | let scanner = ImageScanner(projectPath: tempProjectPath) 41 | let images = try scanner.scanForImages() 42 | 43 | XCTAssertTrue(images.count >= 0) 44 | } 45 | 46 | // MARK: - UsageDetector Tests 47 | 48 | func testUsageDetectorFindsUsedImages() throws { 49 | let detector = UsageDetector(projectPath: tempProjectPath, verbose: false) 50 | let usedImages = try detector.findUsedImageNames() 51 | 52 | XCTAssertNotNil(usedImages) 53 | } 54 | 55 | // MARK: - AppleComplianceValidator Tests 56 | 57 | func testAppleComplianceValidatorWithEmptyImages() { 58 | let validator = AppleComplianceValidator() 59 | let results = validator.validateImages([]) 60 | 61 | XCTAssertEqual(results.totalIssues, 0) 62 | XCTAssertEqual(results.complianceScore, 100) 63 | XCTAssertEqual(results.pngInterlacingIssues.count, 0) 64 | XCTAssertEqual(results.colorProfileIssues.count, 0) 65 | XCTAssertEqual(results.assetCatalogIssues.count, 0) 66 | XCTAssertEqual(results.designQualityIssues.count, 0) 67 | } 68 | 69 | func testAppleComplianceValidatorPNGInterlacing() { 70 | let validator = AppleComplianceValidator() 71 | 72 | let interlacedPNG = ImageAsset( 73 | name: "test.png", 74 | path: "/test/test.png", 75 | size: 1024, 76 | type: .png, 77 | scale: 1, 78 | dimensions: CGSize(width: 100, height: 100), 79 | isInterlaced: true, 80 | colorProfile: nil 81 | ) 82 | 83 | let results = validator.validatePNGInterlacing([interlacedPNG]) 84 | XCTAssertEqual(results.count, 1) 85 | XCTAssertEqual(results.first?.image.name, "test.png") 86 | } 87 | 88 | func testAppleComplianceValidatorColorProfiles() { 89 | let validator = AppleComplianceValidator() 90 | 91 | let imageWithoutProfile = ImageAsset( 92 | name: "test.png", 93 | path: "/test/test.png", 94 | size: 1024, 95 | type: .png, 96 | scale: 1, 97 | dimensions: CGSize(width: 100, height: 100), 98 | isInterlaced: false, 99 | colorProfile: nil 100 | ) 101 | 102 | let results = validator.validateColorProfiles([imageWithoutProfile]) 103 | XCTAssertEqual(results.count, 1) 104 | XCTAssertTrue(results.first?.issueType.isMissingProfile ?? false) 105 | } 106 | 107 | func testAppleComplianceValidatorDesignQuality() { 108 | let validator = AppleComplianceValidator() 109 | 110 | let tooSmallImage = ImageAsset( 111 | name: "small.png", 112 | path: "/test/small.png", 113 | size: 1024, 114 | type: .png, 115 | scale: 1, 116 | dimensions: CGSize(width: 20, height: 20), 117 | isInterlaced: false, 118 | colorProfile: nil 119 | ) 120 | 121 | let results = validator.validateDesignQuality([tooSmallImage]) 122 | XCTAssertGreaterThan(results.count, 0) 123 | } 124 | 125 | // MARK: - AnalysisReport Tests 126 | 127 | func testAnalysisReportJSONExport() throws { 128 | let appleResults = AppleComplianceResults( 129 | pngInterlacingIssues: [], 130 | colorProfileIssues: [], 131 | assetCatalogIssues: [], 132 | designQualityIssues: [], 133 | complianceScore: 100, 134 | criticalIssues: 0, 135 | warningIssues: 0, 136 | totalIssues: 0 137 | ) 138 | 139 | let report = AnalysisReport( 140 | totalImages: 5, 141 | unusedImages: [], 142 | totalSize: 1024000, 143 | unusedImageSize: 0, 144 | totalPotentialSavings: 0, 145 | appleComplianceResults: appleResults 146 | ) 147 | 148 | XCTAssertNoThrow(try report.exportJSON()) 149 | } 150 | 151 | // MARK: - Helper Methods 152 | 153 | private func createTempTestProject() -> String { 154 | let tempDir = NSTemporaryDirectory() 155 | let projectName = "TestProject_\(UUID().uuidString)" 156 | let projectPath = (tempDir as NSString).appendingPathComponent(projectName) 157 | 158 | try? FileManager.default.createDirectory(atPath: projectPath, withIntermediateDirectories: true) 159 | 160 | return projectPath 161 | } 162 | 163 | private func cleanupTempProject() { 164 | if let path = tempProjectPath { 165 | try? FileManager.default.removeItem(atPath: path) 166 | } 167 | } 168 | } 169 | 170 | // MARK: - Helper Extensions 171 | 172 | extension ColorProfileIssue.ColorProfileIssueType { 173 | var isMissingProfile: Bool { 174 | if case .missing = self { 175 | return true 176 | } 177 | return false 178 | } 179 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/SemanticAnalyzer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Files 3 | 4 | struct VariableAssignment { 5 | let name: String 6 | let value: String 7 | let type: AssignmentType 8 | 9 | enum AssignmentType { 10 | case constant, arrayLiteral, enumCase 11 | } 12 | } 13 | 14 | struct StringInterpolation { 15 | let pattern: String 16 | let variable: String 17 | let prefix: String 18 | let suffix: String 19 | } 20 | 21 | class SemanticAnalyzer { 22 | private let projectPath: String 23 | private let verbose: Bool 24 | 25 | init(projectPath: String, verbose: Bool = false) { 26 | self.projectPath = projectPath 27 | self.verbose = verbose 28 | } 29 | 30 | func analyzeImageReferences() throws -> Set { 31 | var imageReferences = Set() 32 | 33 | let folder = try Folder(path: projectPath) 34 | 35 | // Step 1: Find all variable assignments 36 | let variables = try findVariableAssignments(in: folder) 37 | 38 | // Step 2: Find string interpolation patterns 39 | let interpolations = try findStringInterpolations(in: folder) 40 | 41 | // Step 3: Resolve interpolations with variable values 42 | for interpolation in interpolations { 43 | let resolvedImages = resolveInterpolation(interpolation, with: variables) 44 | imageReferences.formUnion(resolvedImages) 45 | } 46 | 47 | return imageReferences 48 | } 49 | 50 | private func findVariableAssignments(in folder: Folder) throws -> [VariableAssignment] { 51 | var assignments: [VariableAssignment] = [] 52 | 53 | for file in folder.filteredRecursiveFiles where file.extension == "swift" { 54 | do { 55 | let content = try file.readAsString() 56 | assignments.append(contentsOf: parseVariableAssignments(in: content)) 57 | } catch { 58 | continue 59 | } 60 | } 61 | 62 | return assignments 63 | } 64 | 65 | private func parseVariableAssignments(in content: String) -> [VariableAssignment] { 66 | var assignments: [VariableAssignment] = [] 67 | 68 | let patterns = [ 69 | // Array literals: let icons = ["Green", "Orange", "Purple"] 70 | #"let\s+(\w+):\s*\[String\]\s*=\s*\[(.*?)\]"#, 71 | // String constants: let selectedIcon = "Green" 72 | #"let\s+(\w+):\s*String\s*=\s*"([^"]+)""#, 73 | // Enum cases: case green = "Green" 74 | #"case\s+(\w+)\s*=\s*"([^"]+)""#, 75 | // Published properties with default: var selectedIcon: String = "Default" 76 | #"var\s+(\w+):\s*String\s*=.*?"([^"]+)""# 77 | ] 78 | 79 | for (index, pattern) in patterns.enumerated() { 80 | let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) 81 | let matches = regex?.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) ?? [] 82 | 83 | for match in matches { 84 | if match.numberOfRanges >= 3, 85 | let nameRange = Range(match.range(at: 1), in: content), 86 | let valueRange = Range(match.range(at: 2), in: content) { 87 | 88 | let name = String(content[nameRange]) 89 | let value = String(content[valueRange]) 90 | 91 | let type: VariableAssignment.AssignmentType = index == 0 ? .arrayLiteral : 92 | index == 2 ? .enumCase : .constant 93 | 94 | if type == .arrayLiteral { 95 | // Parse array elements 96 | let elements = parseArrayElements(value) 97 | for element in elements { 98 | assignments.append(VariableAssignment(name: name, value: element, type: type)) 99 | } 100 | } else { 101 | assignments.append(VariableAssignment(name: name, value: value, type: type)) 102 | } 103 | } 104 | } 105 | } 106 | 107 | return assignments 108 | } 109 | 110 | private func parseArrayElements(_ arrayContent: String) -> [String] { 111 | let pattern = #""([^"]+)""# 112 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 113 | let matches = regex?.matches(in: arrayContent, options: [], range: NSRange(arrayContent.startIndex..., in: arrayContent)) ?? [] 114 | 115 | var elements: [String] = [] 116 | for match in matches { 117 | if let range = Range(match.range(at: 1), in: arrayContent) { 118 | elements.append(String(arrayContent[range])) 119 | } 120 | } 121 | return elements 122 | } 123 | 124 | private func findStringInterpolations(in folder: Folder) throws -> [StringInterpolation] { 125 | var interpolations: [StringInterpolation] = [] 126 | 127 | for file in folder.filteredRecursiveFiles where file.extension == "swift" { 128 | do { 129 | let content = try file.readAsString() 130 | interpolations.append(contentsOf: parseStringInterpolations(in: content)) 131 | } catch { 132 | continue 133 | } 134 | } 135 | 136 | return interpolations 137 | } 138 | 139 | private func parseStringInterpolations(in content: String) -> [StringInterpolation] { 140 | var interpolations: [StringInterpolation] = [] 141 | 142 | let patterns = [ 143 | // Image("path/\(variable)") 144 | #"Image\s*\(\s*"([^"]*)\\\(([^)]+)\)([^"]*)""#, 145 | // UIImage(named: "path/\(variable)") 146 | #"UIImage\s*\(\s*named:\s*"([^"]*)\\\(([^)]+)\)([^"]*)""#, 147 | // spriteWithFile:@"path/\(variable)" (Cocos2D) 148 | #"spriteWithFile:\s*@"([^"]*)\\\(([^)]+)\)([^"]*)""# 149 | ] 150 | 151 | for pattern in patterns { 152 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 153 | let matches = regex?.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) ?? [] 154 | 155 | for match in matches { 156 | if match.numberOfRanges >= 4, 157 | let prefixRange = Range(match.range(at: 1), in: content), 158 | let variableRange = Range(match.range(at: 2), in: content), 159 | let suffixRange = Range(match.range(at: 3), in: content) { 160 | 161 | let prefix = String(content[prefixRange]) 162 | let variable = String(content[variableRange]).trimmingCharacters(in: .whitespaces) 163 | let suffix = String(content[suffixRange]) 164 | let fullPattern = prefix + "\\(\(variable))" + suffix 165 | 166 | interpolations.append(StringInterpolation( 167 | pattern: fullPattern, 168 | variable: variable, 169 | prefix: prefix, 170 | suffix: suffix 171 | )) 172 | } 173 | } 174 | } 175 | 176 | return interpolations 177 | } 178 | 179 | private func resolveInterpolation(_ interpolation: StringInterpolation, with variables: [VariableAssignment]) -> Set { 180 | var resolvedImages = Set() 181 | 182 | // Extract base variable name (handle dot notation) 183 | let baseVariable = interpolation.variable.components(separatedBy: ".").first ?? interpolation.variable 184 | 185 | // Find matching variable assignments 186 | let matchingVariables = variables.filter { $0.name == baseVariable } 187 | 188 | for variable in matchingVariables { 189 | let resolvedPath = interpolation.prefix + variable.value + interpolation.suffix 190 | resolvedImages.insert(resolvedPath) 191 | 192 | // Also add without extension 193 | if let nameWithoutExt = resolvedPath.components(separatedBy: ".").first, nameWithoutExt != resolvedPath { 194 | resolvedImages.insert(nameWithoutExt) 195 | } 196 | } 197 | 198 | // If no exact match found, try to infer from common patterns 199 | if matchingVariables.isEmpty { 200 | resolvedImages.formUnion(inferFromCommonPatterns(interpolation, variables: variables)) 201 | } 202 | 203 | return resolvedImages 204 | } 205 | 206 | private func inferFromCommonPatterns(_ interpolation: StringInterpolation, variables: [VariableAssignment]) -> Set { 207 | var inferred = Set() 208 | 209 | // Common color names for theming 210 | let commonThemeValues = ["Default", "Green", "Orange", "Purple", "Red", "Blue", "Yellow", "Silver", "Space Gray"] 211 | 212 | // If the interpolation looks like a theme/color system 213 | if interpolation.prefix.lowercased().contains("icon") || 214 | interpolation.prefix.lowercased().contains("theme") || 215 | interpolation.variable.lowercased().contains("color") || 216 | interpolation.variable.lowercased().contains("theme") { 217 | 218 | for themeValue in commonThemeValues { 219 | let resolvedPath = interpolation.prefix + themeValue + interpolation.suffix 220 | inferred.insert(resolvedPath) 221 | 222 | if let nameWithoutExt = resolvedPath.components(separatedBy: ".").first, nameWithoutExt != resolvedPath { 223 | inferred.insert(nameWithoutExt) 224 | } 225 | } 226 | } 227 | 228 | return inferred 229 | } 230 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/ImageScanner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Files 3 | import CoreGraphics 4 | import ImageIO 5 | 6 | struct ImageAsset: Encodable { 7 | let name: String 8 | let path: String 9 | let size: Int64 10 | let type: ImageType 11 | let scale: Int? 12 | let dimensions: CGSize? 13 | let isInterlaced: Bool? 14 | let colorProfile: String? 15 | 16 | enum ImageType: Equatable, Encodable { 17 | case png, jpeg, pdf, svg 18 | case assetCatalog(scale: String) 19 | } 20 | } 21 | 22 | class ImageScanner { 23 | private let projectPath: String 24 | 25 | // Directories to exclude from scanning 26 | private let excludedDirectories = [ 27 | "DerivedData", 28 | "Pods", 29 | ".build", 30 | "Carthage", 31 | "node_modules", 32 | ".git", 33 | "build", 34 | "Build", 35 | "xcuserdata", 36 | ".swiftpm", 37 | "SourcePackages" 38 | ] 39 | 40 | init(projectPath: String) { 41 | self.projectPath = projectPath 42 | } 43 | 44 | func scanForImages() throws -> [ImageAsset] { 45 | var images: [ImageAsset] = [] 46 | 47 | let folder = try Folder(path: projectPath) 48 | 49 | // Scan for standalone images 50 | images.append(contentsOf: try scanStandaloneImages(in: folder)) 51 | 52 | // Scan asset catalogs with proper name handling 53 | images.append(contentsOf: try scanAssetCatalogsEnhanced(in: folder)) 54 | 55 | return images 56 | } 57 | 58 | private func shouldExcludeDirectory(_ path: String) -> Bool { 59 | for excluded in excludedDirectories { 60 | if path.contains("/\(excluded)/") || path.hasSuffix("/\(excluded)") { 61 | return true 62 | } 63 | } 64 | return false 65 | } 66 | 67 | private func scanStandaloneImages(in folder: Folder) throws -> [ImageAsset] { 68 | var images: [ImageAsset] = [] 69 | 70 | // Use custom recursive traversal to properly skip excluded directories 71 | try scanFolderRecursively(folder) { file in 72 | guard let imageType = imageType(for: file.extension ?? "") else { return } 73 | 74 | // Skip images in .xcassets 75 | if file.path.contains(".xcassets") { return } 76 | 77 | let metadata = getImageMetadata(at: file.path, type: imageType) 78 | let asset = ImageAsset( 79 | name: file.nameExcludingExtension, 80 | path: file.path, 81 | size: getFileSize(file), 82 | type: imageType, 83 | scale: extractScale(from: file.name), 84 | dimensions: metadata.dimensions, 85 | isInterlaced: metadata.isInterlaced, 86 | colorProfile: metadata.colorProfile 87 | ) 88 | images.append(asset) 89 | } 90 | 91 | return images 92 | } 93 | 94 | private func scanFolderRecursively(_ folder: Folder, fileHandler: (File) throws -> Void) throws { 95 | // Process files in current folder 96 | for file in folder.files { 97 | try fileHandler(file) 98 | } 99 | 100 | // Recursively process subfolders, skipping excluded ones 101 | for subfolder in folder.subfolders { 102 | // Skip excluded directories 103 | if excludedDirectories.contains(subfolder.name) { 104 | continue 105 | } 106 | 107 | try scanFolderRecursively(subfolder, fileHandler: fileHandler) 108 | } 109 | } 110 | 111 | private func scanAssetCatalogs(in folder: Folder) throws -> [ImageAsset] { 112 | var images: [ImageAsset] = [] 113 | 114 | try scanFoldersRecursively(folder) { subfolder in 115 | if subfolder.name.hasSuffix(".xcassets") { 116 | images.append(contentsOf: try scanAssetCatalog(subfolder)) 117 | } 118 | } 119 | 120 | return images 121 | } 122 | 123 | private func scanFoldersRecursively(_ folder: Folder, folderHandler: (Folder) throws -> Void) throws { 124 | // Process current folder 125 | try folderHandler(folder) 126 | 127 | // Recursively process subfolders, skipping excluded ones 128 | for subfolder in folder.subfolders { 129 | // Skip excluded directories 130 | if excludedDirectories.contains(subfolder.name) { 131 | continue 132 | } 133 | 134 | try scanFoldersRecursively(subfolder, folderHandler: folderHandler) 135 | } 136 | } 137 | 138 | private func scanAssetCatalog(_ catalog: Folder) throws -> [ImageAsset] { 139 | var images: [ImageAsset] = [] 140 | 141 | for imageSet in catalog.filteredRecursiveSubfolders { 142 | if imageSet.name.hasSuffix(".imageset") { 143 | let assetName = imageSet.name.replacingOccurrences(of: ".imageset", with: "") 144 | 145 | for file in imageSet.files { 146 | if let imageType = imageType(for: file.extension ?? "") { 147 | let scale = extractScale(from: file.name) ?? 1 148 | let metadata = getImageMetadata(at: file.path, type: imageType) 149 | let asset = ImageAsset( 150 | name: assetName, 151 | path: file.path, 152 | size: getFileSize(file), 153 | type: .assetCatalog(scale: "\(scale)x"), 154 | scale: scale, 155 | dimensions: metadata.dimensions, 156 | isInterlaced: metadata.isInterlaced, 157 | colorProfile: metadata.colorProfile 158 | ) 159 | images.append(asset) 160 | } 161 | } 162 | } 163 | } 164 | 165 | return images 166 | } 167 | 168 | private func imageType(for fileExtension: String) -> ImageAsset.ImageType? { 169 | switch fileExtension.lowercased() { 170 | case "png": return .png 171 | case "jpg", "jpeg": return .jpeg 172 | case "pdf": return .pdf 173 | case "svg": return .svg 174 | default: return nil 175 | } 176 | } 177 | 178 | private func extractScale(from filename: String) -> Int? { 179 | if filename.contains("@3x") { return 3 } 180 | if filename.contains("@2x") { return 2 } 181 | return 1 182 | } 183 | 184 | private func getFileSize(_ file: File) -> Int64 { 185 | do { 186 | let attributes = try FileManager.default.attributesOfItem(atPath: file.path) 187 | return attributes[.size] as? Int64 ?? 0 188 | } catch { 189 | return 0 190 | } 191 | } 192 | 193 | // MARK: - Image Metadata Reading 194 | 195 | private struct ImageMetadata { 196 | let dimensions: CGSize? 197 | let isInterlaced: Bool? 198 | let colorProfile: String? 199 | } 200 | 201 | private func getImageMetadata(at path: String, type: ImageAsset.ImageType) -> ImageMetadata { 202 | let dimensions = getImageDimensions(at: path) 203 | let isInterlaced = type == .png ? checkPNGInterlacing(at: path) : nil 204 | let colorProfile = readColorProfile(at: path) 205 | 206 | return ImageMetadata( 207 | dimensions: dimensions, 208 | isInterlaced: isInterlaced, 209 | colorProfile: colorProfile 210 | ) 211 | } 212 | 213 | private func getImageDimensions(at path: String) -> CGSize? { 214 | guard let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), 215 | let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] else { 216 | return nil 217 | } 218 | 219 | guard let width = imageProperties[kCGImagePropertyPixelWidth] as? NSNumber, 220 | let height = imageProperties[kCGImagePropertyPixelHeight] as? NSNumber else { 221 | return nil 222 | } 223 | 224 | return CGSize(width: width.doubleValue, height: height.doubleValue) 225 | } 226 | 227 | private func checkPNGInterlacing(at path: String) -> Bool? { 228 | guard let data = NSData(contentsOfFile: path), 229 | data.length >= 33 else { 230 | return nil 231 | } 232 | 233 | // PNG interlace method is at byte 28 in the IHDR chunk 234 | // PNG signature (8 bytes) + IHDR length (4) + "IHDR" (4) + width (4) + height (4) + bit depth (1) + color type (1) + compression (1) + filter (1) + interlace (1) 235 | var interlaceMethod: UInt8 = 0 236 | data.getBytes(&interlaceMethod, range: NSRange(location: 28, length: 1)) 237 | 238 | return interlaceMethod == 1 // 1 = interlaced, 0 = non-interlaced 239 | } 240 | 241 | private func readColorProfile(at path: String) -> String? { 242 | guard let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), 243 | let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] else { 244 | return nil 245 | } 246 | 247 | // Check for color profile information 248 | if let colorModel = imageProperties[kCGImagePropertyColorModel] as? String { 249 | return colorModel 250 | } 251 | 252 | // Check for ICC profile (use a string key since the constant might not be available) 253 | if let profileDescription = imageProperties["ProfileDescription" as CFString] as? String { 254 | return profileDescription 255 | } 256 | 257 | // Check for embedded color space 258 | if imageProperties[kCGImagePropertyHasAlpha] != nil { 259 | return "RGB" // Basic fallback 260 | } 261 | 262 | return nil 263 | } 264 | 265 | // MARK: - Enhanced Asset Catalog Scanning 266 | 267 | private func scanAssetCatalogsEnhanced(in folder: Folder) throws -> [ImageAsset] { 268 | var images: [ImageAsset] = [] 269 | 270 | try scanFoldersRecursively(folder) { subfolder in 271 | if subfolder.name.hasSuffix(".xcassets") { 272 | let projectParser = ProjectParser(projectPath: projectPath) 273 | let assetInfos = try projectParser.parseAssetCatalogs() 274 | 275 | for assetInfo in assetInfos { 276 | for variant in assetInfo.variants { 277 | let variantPath = "\(assetInfo.path)/\(variant.filename)" 278 | if let file = try? File(path: variantPath) { 279 | let imageType = imageType(for: (variantPath as NSString).pathExtension) ?? .png 280 | let metadata = getImageMetadata(at: variantPath, type: imageType) 281 | 282 | let asset = ImageAsset( 283 | name: assetInfo.name, // Use the actual asset name from Contents.json 284 | path: variantPath, 285 | size: getFileSize(file), 286 | type: .assetCatalog(scale: variant.scale), 287 | scale: extractScaleFromVariant(variant.scale), 288 | dimensions: metadata.dimensions, 289 | isInterlaced: metadata.isInterlaced, 290 | colorProfile: metadata.colorProfile 291 | ) 292 | images.append(asset) 293 | } 294 | } 295 | } 296 | } 297 | } 298 | 299 | return images 300 | } 301 | 302 | private func extractScaleFromVariant(_ scaleString: String) -> Int { 303 | if scaleString.contains("3x") { return 3 } 304 | if scaleString.contains("2x") { return 2 } 305 | return 1 306 | } 307 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iOS Image Optimizer 2 | 3 | A comprehensive command-line tool that analyzes your iOS projects for image optimization opportunities following **Apple's official Human Interface Guidelines**. Identify unused images, validate Apple compliance, and get actionable recommendations to improve your app's performance and App Store approval chances. 4 | 5 | ## 🎯 What It Does 6 | 7 | This tool provides **comprehensive image analysis** for iOS projects: 8 | 9 | ### ✅ Core Features 10 | - **Unused Image Detection** - Find images that exist but are never referenced in code, including dynamic loading and compile-time pattern recognition 11 | - **Apple Compliance Validation** - Validate images against Apple's official guidelines 12 | - **PNG Interlacing Analysis** - Detect performance-impacting interlaced PNGs 13 | - **Color Profile Validation** - Ensure consistent colors across devices 14 | - **Asset Catalog Organization** - Validate proper scale variants (@1x, @2x, @3x) 15 | - **Design Quality Assessment** - Check touch targets and memory optimization 16 | - **Compliance Scoring** - Get a 0-100 Apple compliance score 17 | - **Prioritized Recommendations** - Actionable items ranked by importance 18 | 19 | ### 🛡️ Quality Assurance 20 | - **154 Comprehensive Unit Tests** - Ensuring reliability and accuracy 21 | - **CI/CD Pipeline** - Automated testing on every Pull Request 22 | - **73.8% Code Coverage** - Extensive test coverage across all components 23 | - **Cross-Platform Support** - Works on both Intel and Apple Silicon Macs 24 | 25 | ### 📊 Sample Output 26 | 27 | ``` 28 | 🔍 Analyzing iOS project at: /Users/yourname/Documents/MyApp 29 | 30 | 📊 Analysis Complete 31 | ================================================== 32 | 33 | 🎯 Apple Compliance Score: 73/100 34 | 35 | 📈 Summary: 36 | Total images: 45 37 | Total image size: 2.3 MB 38 | Unused images: 8 39 | Potential savings: 890 KB 40 | 41 | 🍎 Apple Guidelines Compliance: 42 | PNG interlacing issues: 2 43 | Color profile issues: 5 44 | Asset catalog issues: 12 45 | Design quality issues: 3 46 | 47 | 💡 Prioritized Action Items: 48 | 1. Remove 8 unused images to save 890 KB 49 | 2. Fix 2 critical PNG interlacing issues 50 | 3. Add color profiles to 5 images 51 | 4. Add missing scale variants for 7 images 52 | 5. Address 2 design quality issues 53 | ``` 54 | 55 | ## 🍎 Apple Guidelines Reference 56 | 57 | This tool implements validation based on **Apple's official Human Interface Guidelines**: 58 | 59 | - **Primary Reference**: [Apple Human Interface Guidelines - Images](https://developer.apple.com/design/human-interface-guidelines/images) 60 | - **Key Requirements**: 61 | - De-interlaced PNG files for better performance 62 | - Color profiles for consistent appearance across devices 63 | - Proper scale factors (@1x, @2x, @3x) for different device densities 64 | - Appropriate formats (PNG for UI, JPEG for photos, PDF/SVG for icons) 65 | - Design at lowest resolution and scale up for clean alignment 66 | 67 | ## 🚀 Complete Setup Guide 68 | 69 | ### Step 1: System Requirements 70 | 71 | Open **Terminal** and verify Swift is installed: 72 | 73 | ```bash 74 | swift --version 75 | ``` 76 | 77 | You need Swift 5.9+ or Xcode 14+. If not installed: 78 | ```bash 79 | xcode-select --install 80 | ``` 81 | 82 | ### Step 2: Download and Build 83 | 84 | ```bash 85 | # Navigate to your Documents folder 86 | cd ~/Documents 87 | 88 | # Download the tool 89 | git clone https://github.com/sahilsatralkar/iOSImageOptimizerTool.git 90 | 91 | # Go into the tool directory 92 | cd iOSImageOptimizerTool/iOSImageOptimizer 93 | 94 | # Build the tool (takes 2-5 minutes first time) 95 | swift build 96 | ``` 97 | 98 | ### Step 3: Analyze Your iOS Project 99 | 100 | ```bash 101 | # Basic analysis 102 | swift run iOSImageOptimizer /path/to/your/ios/project 103 | 104 | # Detailed analysis with verbose output 105 | swift run iOSImageOptimizer /path/to/your/ios/project --verbose 106 | 107 | # JSON output for integration 108 | swift run iOSImageOptimizer /path/to/your/ios/project --json 109 | ``` 110 | 111 | **Real example:** 112 | ```bash 113 | swift run iOSImageOptimizer /Users/yourname/Documents/MyiOSApp 114 | ``` 115 | 116 | ## 📋 Understanding the Analysis 117 | 118 | ### 🎯 Apple Compliance Score (0-100) 119 | - **80-100**: Excellent compliance, ready for App Store 120 | - **60-79**: Good, minor issues to address 121 | - **40-59**: Fair, several compliance issues 122 | - **0-39**: Poor, significant issues requiring attention 123 | 124 | ### 🔍 Validation Categories 125 | 126 | #### **PNG Interlacing Issues** 127 | - **What**: Detects interlaced PNGs that impact performance 128 | - **Why**: Apple recommends de-interlaced PNGs for better iOS performance 129 | - **Action**: Convert to de-interlaced format using image editing tools 130 | 131 | #### **Color Profile Issues** 132 | - **What**: Images missing or with incompatible color profiles 133 | - **Why**: Ensures consistent colors across different iOS devices 134 | - **Action**: Add sRGB color profile (recommended for most iOS images) 135 | 136 | #### **Asset Catalog Issues** 137 | - **What**: Missing scale variants, orphaned scales, organization problems 138 | - **Why**: iOS requires proper @1x, @2x, @3x variants for optimal display 139 | - **Action**: Add missing scale variants or organize in Asset Catalogs 140 | 141 | #### **Design Quality Issues** 142 | - **What**: Images too small for touch targets or memory-intensive 143 | - **Why**: Affects usability and performance on iOS devices 144 | - **Action**: Resize touch targets to 44×44pt minimum, optimize large images 145 | 146 | #### **Unused Images** 147 | - **What**: Images present in project but never referenced in code (detects static references, dynamic loading patterns, and string interpolation) 148 | - **Why**: Reduces app bundle size and improves download/install times 149 | - **Action**: Review and remove confirmed unused images 150 | 151 | ## 🛠️ Acting on Recommendations 152 | 153 | ### Priority 1: Remove Unused Images 154 | ```bash 155 | # Before deleting, verify the image is truly unused 156 | grep -r "image_name" /path/to/your/project 157 | # The tool already checks for dynamic patterns like Image("Icons/\(variable)") 158 | # If flagged as unused, it's safe to delete 159 | ``` 160 | 161 | ### Priority 2: Fix PNG Interlacing 162 | - Use tools like ImageOptim, Photoshop, or online converters 163 | - Ensure "interlaced" option is disabled when saving PNGs 164 | 165 | ### Priority 3: Add Color Profiles 166 | - In Photoshop: Edit → Convert to Profile → sRGB 167 | - In Preview: Tools → Assign Profile → sRGB IEC61966-2.1 168 | 169 | ### Priority 4: Fix Asset Catalog Organization 170 | - Create missing @1x, @2x, @3x variants 171 | - Move standalone images to Asset Catalogs 172 | - Ensure proper naming conventions 173 | 174 | ### Priority 5: Address Design Quality 175 | - Resize touch targets to minimum 44×44 points 176 | - Optimize large images or implement progressive loading 177 | - Use appropriate formats for content type 178 | 179 | ## 🔧 Troubleshooting 180 | 181 | ### Build Issues 182 | ```bash 183 | # Clean and rebuild 184 | swift package clean 185 | swift build 186 | 187 | # Update dependencies 188 | swift package update 189 | ``` 190 | 191 | ### Path Issues 192 | ```bash 193 | # Find your project path 194 | open /path/to/your/project # Should open in Finder 195 | pwd # Shows current directory 196 | ``` 197 | 198 | ### Permission Issues 199 | - Ensure you have read access to the project directory 200 | - Don't point to system directories 201 | 202 | ## 💡 Best Practices 203 | 204 | ### Regular Analysis 205 | - Run before each App Store submission 206 | - Include in CI/CD pipeline for continuous monitoring 207 | - Check after adding new images or design updates 208 | 209 | ## 🧪 Development & Testing 210 | 211 | ### Running Tests 212 | ```bash 213 | # Run all 154 unit tests 214 | swift test 215 | 216 | # Run tests with code coverage 217 | swift test --enable-code-coverage 218 | 219 | # Generate coverage report 220 | swift test --enable-code-coverage 221 | xcrun llvm-cov export ./.build/debug/iOSImageOptimizerPackageTests.xctest/Contents/MacOS/iOSImageOptimizerPackageTests -instr-profile=./.build/debug/codecov/default.profdata -format="lcov" > coverage.lcov 222 | ``` 223 | 224 | ### CI/CD Integration 225 | The project includes GitHub Actions automation that: 226 | - Builds the project on every PR 227 | - Runs all 154 unit tests 228 | - Generates code coverage reports 229 | - Supports both x86_64 and Apple Silicon runners 230 | - Provides detailed test summaries 231 | 232 | ### Contributing 233 | 1. Fork the repository 234 | 2. Create a feature branch 235 | 3. Add tests for new functionality 236 | 4. Ensure all tests pass: `swift test` 237 | 5. Submit a Pull Request (CI will automatically run tests) 238 | 239 | ### Image Optimization Workflow 240 | 1. **Design** images at @1x resolution with whole-number dimensions 241 | 2. **Scale up** to create @2x and @3x variants 242 | 3. **Optimize** file sizes without losing quality 243 | 4. **Validate** with this tool before submission 244 | 5. **Test** on actual devices to verify appearance 245 | 246 | ### Apple Compliance Tips 247 | - Use PNG for UI elements and icons 248 | - Use JPEG for photographs 249 | - Use PDF/SVG for scalable icons 250 | - Always include color profiles 251 | - Organize images in Asset Catalogs 252 | - Follow Apple's dimension guidelines 253 | 254 | ## 📱 iOS Image Requirements 255 | 256 | ### Scale Factors by Platform 257 | - **iOS**: @2x and @3x required 258 | - **iPadOS**: @2x required 259 | - **macOS**: @1x and @2x required 260 | - **watchOS**: @2x required 261 | 262 | ### Recommended Formats 263 | - **UI Elements**: De-interlaced PNG with sRGB color profile 264 | - **Photographs**: JPEG with embedded color profile 265 | - **Icons**: PDF or SVG for scalability 266 | - **Low-color graphics**: 8-bit PNG palette 267 | 268 | ## 🆘 Getting Help 269 | 270 | ### Common Error Solutions 271 | 272 | **"No images found"** 273 | - Verify project path is correct 274 | - Ensure project contains .png, .jpg, .pdf, or .svg files 275 | 276 | **"Low compliance score"** 277 | - Review each category in the detailed output 278 | - Focus on Priority 1 and 2 items first 279 | - Use Apple's official guidelines as reference 280 | 281 | **"Build failed"** 282 | - Update Xcode and command line tools 283 | - Check Swift version compatibility 284 | - Clean and rebuild the project 285 | 286 | ### Additional Resources 287 | - [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) 288 | - [iOS App Development Best Practices](https://developer.apple.com/documentation/xcode) 289 | - [Image Optimization Tools](https://imageoptim.com/mac) 290 | 291 | ## 🚨 Important Notes 292 | 293 | - **Analysis Only**: This tool only analyzes - it never automatically modifies your project 294 | - **Backup First**: Always backup your project before making changes 295 | - **Enhanced Detection**: Tool now detects dynamic loading patterns and string interpolation to reduce false positives 296 | - **Test Thoroughly**: Verify your app works correctly after making changes 297 | - **Apple Guidelines**: This tool follows Apple's official recommendations, not arbitrary limits 298 | 299 | ## 📁 Supported Project Structure 300 | 301 | The tool works with standard iOS project structures: 302 | 303 | ``` 304 | MyiOSApp/ 305 | ├── MyiOSApp.xcodeproj 306 | ├── MyiOSApp/ 307 | │ ├── ViewController.swift 308 | │ ├── Assets.xcassets/ 309 | │ │ ├── AppIcon.appiconset/ 310 | │ │ └── LaunchImage.imageset/ 311 | │ ├── Images/ 312 | │ │ ├── logo.png 313 | │ │ ├── logo@2x.png 314 | │ │ └── logo@3x.png 315 | │ └── Storyboards/ 316 | │ └── Main.storyboard 317 | ├── Pods/ (if using CocoaPods) 318 | └── README.md 319 | ``` 320 | 321 | Point the tool to the root project folder containing the `.xcodeproj` file. 322 | 323 | ## 📋 Version History 324 | 325 | ### v0.4 (Latest) 326 | - ✅ **Critical Performance Fix**: Tool no longer hangs on real-world iOS projects 327 | - ✅ **Smart Directory Exclusion**: Automatically skips DerivedData, Pods, .build, Carthage, and other build directories 328 | - ✅ **Massive Speed Improvement**: Completes in seconds instead of hanging indefinitely 329 | - ✅ **Production Ready**: Successfully tested on complex projects with large dependency folders 330 | 331 | ### v0.3 332 | - ✅ **Build Fix**: Resolved critical build issues when cloning repository (Fixes #3) 333 | - ✅ **Clean Repository**: Removed machine-specific build artifacts from version control 334 | - ✅ **Improved Developer Experience**: Project now builds successfully on all machines without errors 335 | - ✅ **Better .gitignore**: Ensures build artifacts stay local and don't get committed 336 | 337 | ### v0.2 338 | - ✅ **Complete Test Suite**: 154 comprehensive unit tests with 73.8% code coverage 339 | - ✅ **CI/CD Pipeline**: Automated testing on GitHub Actions 340 | - ✅ **Enhanced Detection**: Improved dynamic image loading detection with Method 2 341 | - ✅ **Cross-Platform**: Full support for Intel and Apple Silicon Macs 342 | - ✅ **Robust Error Handling**: Better handling of edge cases and malformed files 343 | 344 | ### v0.1 345 | - ✅ Initial release with core image analysis features 346 | - ✅ Apple compliance validation 347 | - ✅ Unused image detection 348 | - ✅ Basic project parsing 349 | 350 | --- 351 | 352 | **Transform your iOS app's image optimization with Apple-compliant analysis!** 🚀📱 353 | 354 | *Following Apple's Human Interface Guidelines ensures better performance, smaller bundle sizes, and improved App Store approval chances.* -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/MockImageGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | import ImageIO 4 | import UniformTypeIdentifiers 5 | 6 | class MockImageGenerator { 7 | 8 | // MARK: - Image Creation 9 | 10 | static func createPNGWithMetadata( 11 | dimensions: CGSize, 12 | colorProfile: String? = nil, 13 | interlaced: Bool = false, 14 | scale: Int = 1 15 | ) -> Data { 16 | let width = Int(dimensions.width) 17 | let height = Int(dimensions.height) 18 | 19 | // Create a simple bitmap context 20 | guard let context = CGContext( 21 | data: nil, 22 | width: width, 23 | height: height, 24 | bitsPerComponent: 8, 25 | bytesPerRow: width * 4, 26 | space: CGColorSpaceCreateDeviceRGB(), 27 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 28 | ) else { 29 | return createMinimalPNGData() 30 | } 31 | 32 | // Fill with a test pattern 33 | context.setFillColor(CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)) 34 | context.fill(CGRect(x: 0, y: 0, width: width, height: height)) 35 | 36 | // Add some pattern based on scale 37 | context.setFillColor(CGColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)) 38 | let patternSize = width / (scale * 4) 39 | for i in 0.. Data { 93 | let width = Int(dimensions.width) 94 | let height = Int(dimensions.height) 95 | 96 | guard let context = CGContext( 97 | data: nil, 98 | width: width, 99 | height: height, 100 | bitsPerComponent: 8, 101 | bytesPerRow: width * 4, 102 | space: CGColorSpaceCreateDeviceRGB(), 103 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue 104 | ) else { 105 | return createMinimalJPEGData() 106 | } 107 | 108 | // Create a gradient pattern 109 | let colors = [ 110 | CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), 111 | CGColor(red: 0.0, green: 0.0, blue: 1.0, alpha: 1.0) 112 | ] 113 | 114 | guard let gradient = CGGradient( 115 | colorsSpace: CGColorSpaceCreateDeviceRGB(), 116 | colors: colors as CFArray, 117 | locations: [0.0, 1.0] 118 | ) else { 119 | return createMinimalJPEGData() 120 | } 121 | 122 | context.drawLinearGradient( 123 | gradient, 124 | start: CGPoint(x: 0, y: 0), 125 | end: CGPoint(x: width, y: height), 126 | options: [] 127 | ) 128 | 129 | guard let cgImage = context.makeImage() else { 130 | return createMinimalJPEGData() 131 | } 132 | 133 | let mutableData = NSMutableData() 134 | guard let destination = CGImageDestinationCreateWithData( 135 | mutableData, 136 | UTType.jpeg.identifier as CFString, 137 | 1, 138 | nil 139 | ) else { 140 | return createMinimalJPEGData() 141 | } 142 | 143 | let properties: [CFString: Any] = [ 144 | kCGImageDestinationLossyCompressionQuality: quality 145 | ] 146 | 147 | CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary) 148 | CGImageDestinationFinalize(destination) 149 | 150 | return mutableData as Data 151 | } 152 | 153 | static func createCorruptedImage(format: String = "PNG") -> Data { 154 | switch format.uppercased() { 155 | case "PNG": 156 | // PNG with invalid header 157 | var data = createMinimalPNGData() 158 | data[1] = 0x00 // Corrupt the PNG signature 159 | return data 160 | case "JPEG": 161 | // JPEG with invalid header 162 | var data = createMinimalJPEGData() 163 | data[0] = 0x00 // Corrupt the JPEG marker 164 | return data 165 | default: 166 | return Data([0x00, 0x01, 0x02, 0x03]) // Random invalid data 167 | } 168 | } 169 | 170 | static func createLargeImage(megapixels: Double = 2.0) -> Data { 171 | let totalPixels = megapixels * 1_000_000 172 | let aspectRatio = 16.0 / 9.0 173 | let width = sqrt(totalPixels * aspectRatio) 174 | let height = width / aspectRatio 175 | 176 | return createPNGWithMetadata( 177 | dimensions: CGSize(width: width, height: height), 178 | colorProfile: "sRGB", 179 | interlaced: false 180 | ) 181 | } 182 | 183 | static func createSmallImage(size: CGSize = CGSize(width: 16, height: 16)) -> Data { 184 | return createPNGWithMetadata( 185 | dimensions: size, 186 | colorProfile: "sRGB", 187 | interlaced: false 188 | ) 189 | } 190 | 191 | // MARK: - Minimal Image Data 192 | 193 | private static func createMinimalPNGData() -> Data { 194 | // Minimal valid PNG file (1x1 pixel) 195 | let pngData: [UInt8] = [ 196 | 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 197 | 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length 198 | 0x49, 0x48, 0x44, 0x52, // IHDR 199 | 0x00, 0x00, 0x00, 0x01, // Width: 1 200 | 0x00, 0x00, 0x00, 0x01, // Height: 1 201 | 0x08, 0x02, 0x00, 0x00, 0x00, // Bit depth, color type, compression, filter, interlace 202 | 0x90, 0x77, 0x53, 0xDE, // CRC 203 | 0x00, 0x00, 0x00, 0x0C, // IDAT chunk length 204 | 0x49, 0x44, 0x41, 0x54, // IDAT 205 | 0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // Compressed data 206 | 0x00, 0x00, 0x00, 0x00, // IEND chunk length 207 | 0x49, 0x45, 0x4E, 0x44, // IEND 208 | 0xAE, 0x42, 0x60, 0x82 // CRC 209 | ] 210 | return Data(pngData) 211 | } 212 | 213 | private static func createMinimalJPEGData() -> Data { 214 | // Minimal valid JPEG file 215 | let jpegData: [UInt8] = [ 216 | 0xFF, 0xD8, // SOI (Start of Image) 217 | 0xFF, 0xE0, // APP0 218 | 0x00, 0x10, // Length of APP0 segment 219 | 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" 220 | 0x01, 0x01, // Version 1.1 221 | 0x01, // Units (0=no units, 1=dots/inch, 2=dots/cm) 222 | 0x00, 0x48, // X density 223 | 0x00, 0x48, // Y density 224 | 0x00, 0x00, // Thumbnail width/height 225 | 0xFF, 0xD9 // EOI (End of Image) 226 | ] 227 | return Data(jpegData) 228 | } 229 | 230 | // MARK: - Asset Catalog Images 231 | 232 | static func createAssetCatalogImageSet( 233 | name: String, 234 | scales: [Int] = [1, 2, 3], 235 | in directory: String 236 | ) { 237 | let imageSetDir = (directory as NSString).appendingPathComponent("\(name).imageset") 238 | try? FileManager.default.createDirectory(atPath: imageSetDir, withIntermediateDirectories: true) 239 | 240 | var images: [[String: Any]] = [] 241 | 242 | for scale in scales { 243 | let filename = scale == 1 ? "\(name).png" : "\(name)@\(scale)x.png" 244 | let imagePath = (imageSetDir as NSString).appendingPathComponent(filename) 245 | 246 | // Create image file 247 | let imageData = createPNGWithMetadata( 248 | dimensions: CGSize(width: 60 * scale, height: 60 * scale), 249 | scale: scale 250 | ) 251 | try? imageData.write(to: URL(fileURLWithPath: imagePath)) 252 | 253 | // Add to Contents.json 254 | images.append([ 255 | "filename": filename, 256 | "idiom": "universal", 257 | "scale": "\(scale)x" 258 | ]) 259 | } 260 | 261 | let contentsData: [String: Any] = [ 262 | "images": images, 263 | "info": [ 264 | "author": "xcode", 265 | "version": 1 266 | ] 267 | ] 268 | 269 | let contentsPath = (imageSetDir as NSString).appendingPathComponent("Contents.json") 270 | if let jsonData = try? JSONSerialization.data(withJSONObject: contentsData, options: .prettyPrinted) { 271 | try? jsonData.write(to: URL(fileURLWithPath: contentsPath)) 272 | } 273 | } 274 | 275 | static func createAppIconSet(in directory: String) { 276 | let iconSetDir = (directory as NSString).appendingPathComponent("AppIcon.appiconset") 277 | try? FileManager.default.createDirectory(atPath: iconSetDir, withIntermediateDirectories: true) 278 | 279 | let iconSizes = [ 280 | ("20x20", 2, "iphone"), 281 | ("20x20", 3, "iphone"), 282 | ("29x29", 2, "iphone"), 283 | ("29x29", 3, "iphone"), 284 | ("40x40", 2, "iphone"), 285 | ("40x40", 3, "iphone"), 286 | ("60x60", 2, "iphone"), 287 | ("60x60", 3, "iphone") 288 | ] 289 | 290 | var images: [[String: Any]] = [] 291 | 292 | for (size, scale, idiom) in iconSizes { 293 | let filename = "AppIcon\(size)@\(scale)x.png" 294 | let imagePath = (iconSetDir as NSString).appendingPathComponent(filename) 295 | 296 | let sizeComponents = size.components(separatedBy: "x") 297 | let width = Double(sizeComponents[0]) ?? 20 298 | let height = Double(sizeComponents[1]) ?? 20 299 | 300 | let imageData = createPNGWithMetadata( 301 | dimensions: CGSize(width: width * Double(scale), height: height * Double(scale)), 302 | scale: scale 303 | ) 304 | try? imageData.write(to: URL(fileURLWithPath: imagePath)) 305 | 306 | images.append([ 307 | "filename": filename, 308 | "idiom": idiom, 309 | "scale": "\(scale)x", 310 | "size": size 311 | ]) 312 | } 313 | 314 | let contentsData: [String: Any] = [ 315 | "images": images, 316 | "info": [ 317 | "author": "xcode", 318 | "version": 1 319 | ] 320 | ] 321 | 322 | let contentsPath = (iconSetDir as NSString).appendingPathComponent("Contents.json") 323 | if let jsonData = try? JSONSerialization.data(withJSONObject: contentsData, options: .prettyPrinted) { 324 | try? jsonData.write(to: URL(fileURLWithPath: contentsPath)) 325 | } 326 | } 327 | 328 | // MARK: - File Utilities 329 | 330 | static func writeImageToFile( 331 | _ data: Data, 332 | at path: String, 333 | createDirectories: Bool = true 334 | ) { 335 | if createDirectories { 336 | let directory = (path as NSString).deletingLastPathComponent 337 | try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) 338 | } 339 | 340 | try? data.write(to: URL(fileURLWithPath: path)) 341 | } 342 | 343 | static func createImageWithProperties( 344 | name: String, 345 | format: String, 346 | dimensions: CGSize, 347 | colorProfile: String? = nil, 348 | isInterlaced: Bool = false, 349 | scale: Int = 1 350 | ) -> Data { 351 | switch format.uppercased() { 352 | case "PNG": 353 | return createPNGWithMetadata( 354 | dimensions: dimensions, 355 | colorProfile: colorProfile, 356 | interlaced: isInterlaced, 357 | scale: scale 358 | ) 359 | case "JPEG", "JPG": 360 | return createJPEGWithEXIF( 361 | dimensions: dimensions, 362 | colorProfile: colorProfile 363 | ) 364 | default: 365 | return createMinimalPNGData() 366 | } 367 | } 368 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/ProjectParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Files 3 | 4 | struct ProjectFile { 5 | let path: String 6 | let name: String 7 | let type: FileType 8 | 9 | enum FileType { 10 | case image(extension: String) 11 | case assetCatalog 12 | case sourceCode 13 | case interfaceBuilder 14 | case plist 15 | case strings 16 | case other 17 | } 18 | } 19 | 20 | struct AssetInfo { 21 | let name: String 22 | let path: String 23 | let variants: [AssetVariant] 24 | } 25 | 26 | struct AssetVariant { 27 | let filename: String 28 | let scale: String 29 | let idiom: String 30 | let size: String? 31 | } 32 | 33 | class ProjectParser { 34 | private let projectPath: String 35 | private let verbose: Bool 36 | 37 | init(projectPath: String, verbose: Bool = false) { 38 | self.projectPath = projectPath 39 | self.verbose = verbose 40 | } 41 | 42 | // MARK: - Project.pbxproj Parsing 43 | 44 | func parseProjectFile() throws -> [ProjectFile] { 45 | var projectFiles: [ProjectFile] = [] 46 | 47 | // Find .xcodeproj directories 48 | let folder = try Folder(path: projectPath) 49 | for subfolder in folder.filteredRecursiveSubfolders { 50 | if subfolder.name.hasSuffix(".xcodeproj") { 51 | let pbxprojPath = "\(subfolder.path)/project.pbxproj" 52 | if let file = try? File(path: pbxprojPath) { 53 | projectFiles.append(contentsOf: try parseProjectPbxproj(file)) 54 | } 55 | } 56 | } 57 | 58 | return projectFiles 59 | } 60 | 61 | private func parseProjectPbxproj(_ file: File) throws -> [ProjectFile] { 62 | let content = try file.readAsString() 63 | var files: [ProjectFile] = [] 64 | 65 | // Parse PBXFileReference sections 66 | let fileReferencePattern = #"\/\* (.+?) \*\/ = \{\s*isa = PBXFileReference;.*?(?:lastKnownFileType|explicitFileType) = ([^;]+);.*?path = ([^;]+);"# 67 | 68 | let regex = try NSRegularExpression(pattern: fileReferencePattern, options: [.dotMatchesLineSeparators]) 69 | let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) 70 | 71 | for match in matches { 72 | if let nameRange = Range(match.range(at: 1), in: content), 73 | let typeRange = Range(match.range(at: 2), in: content), 74 | let pathRange = Range(match.range(at: 3), in: content) { 75 | 76 | let fileName = String(content[nameRange]) 77 | let fileType = String(content[typeRange]) 78 | let filePath = String(content[pathRange]).trimmingCharacters(in: CharacterSet(charactersIn: "\" ")) 79 | 80 | if let projectFile = createProjectFile(name: fileName, type: fileType, path: filePath) { 81 | files.append(projectFile) 82 | } 83 | } 84 | } 85 | 86 | return files 87 | } 88 | 89 | private func createProjectFile(name: String, type: String, path: String) -> ProjectFile? { 90 | let fileType: ProjectFile.FileType 91 | 92 | switch type { 93 | case let t where t.contains("image"): 94 | let ext = (path as NSString).pathExtension.lowercased() 95 | fileType = .image(extension: ext) 96 | case "folder.assetcatalog": 97 | fileType = .assetCatalog 98 | case let t where t.contains("sourcecode.swift") || t.contains("sourcecode.c.objc"): 99 | fileType = .sourceCode 100 | case let t where t.contains("file.storyboard") || t.contains("file.xib"): 101 | fileType = .interfaceBuilder 102 | case "text.plist.xml": 103 | fileType = .plist 104 | case "text.plist.strings": 105 | fileType = .strings 106 | default: 107 | return nil // Skip non-relevant files 108 | } 109 | 110 | return ProjectFile(path: path, name: name, type: fileType) 111 | } 112 | 113 | // MARK: - Asset Catalog Contents.json Parsing 114 | 115 | func parseAssetCatalogs() throws -> [AssetInfo] { 116 | var assets: [AssetInfo] = [] 117 | 118 | let folder = try Folder(path: projectPath) 119 | for subfolder in folder.filteredRecursiveSubfolders { 120 | if subfolder.name.hasSuffix(".xcassets") { 121 | assets.append(contentsOf: try parseAssetCatalog(subfolder)) 122 | } 123 | } 124 | 125 | return assets 126 | } 127 | 128 | private func parseAssetCatalog(_ catalog: Folder) throws -> [AssetInfo] { 129 | var assets: [AssetInfo] = [] 130 | 131 | for imageSet in catalog.filteredRecursiveSubfolders { 132 | if imageSet.name.hasSuffix(".imageset") { 133 | if let asset = try parseImageSet(imageSet) { 134 | assets.append(asset) 135 | } 136 | } 137 | } 138 | 139 | return assets 140 | } 141 | 142 | private func parseImageSet(_ imageSet: Folder) throws -> AssetInfo? { 143 | let contentsFile = imageSet.path + "/Contents.json" 144 | guard let file = try? File(path: contentsFile) else { return nil } 145 | 146 | do { 147 | let content = try file.readAsString() 148 | guard let data = content.data(using: .utf8), 149 | let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], 150 | let images = json["images"] as? [[String: Any]] else { 151 | return nil 152 | } 153 | 154 | let assetName = imageSet.name.replacingOccurrences(of: ".imageset", with: "") 155 | var variants: [AssetVariant] = [] 156 | 157 | for imageInfo in images { 158 | let filename = imageInfo["filename"] as? String ?? "" 159 | let scale = imageInfo["scale"] as? String ?? "1x" 160 | let idiom = imageInfo["idiom"] as? String ?? "universal" 161 | let size = imageInfo["size"] as? String 162 | 163 | if !filename.isEmpty { 164 | variants.append(AssetVariant(filename: filename, scale: scale, idiom: idiom, size: size)) 165 | } 166 | } 167 | 168 | return AssetInfo(name: assetName, path: imageSet.path, variants: variants) 169 | } catch { 170 | // Handle JSON parsing errors gracefully 171 | if verbose { 172 | print("Warning: Failed to parse \(contentsFile): \(error)") 173 | } 174 | return nil 175 | } 176 | } 177 | 178 | // MARK: - Info.plist Parsing 179 | 180 | func parseInfoPlists() throws -> Set { 181 | var imageReferences = Set() 182 | 183 | let folder = try Folder(path: projectPath) 184 | for file in folder.filteredRecursiveFiles where file.name == "Info.plist" { 185 | do { 186 | let content = try file.readAsString() 187 | imageReferences.formUnion(parseInfoPlistContent(content)) 188 | } catch { 189 | // Skip files with encoding/format issues but continue processing 190 | if verbose { 191 | print("Warning: Skipping \(file.path) due to format issue: \(error)") 192 | } 193 | continue 194 | } 195 | } 196 | 197 | return imageReferences 198 | } 199 | 200 | private func parseInfoPlistContent(_ content: String) -> Set { 201 | var references = Set() 202 | 203 | // App Icon references 204 | let patterns = [ 205 | #"CFBundleIconName\s*([^<]+)"#, 206 | #"CFBundleIconFile\s*([^<]+)"#, 207 | #"UILaunchImageFile\s*([^<]+)"#, 208 | #"UILaunchStoryboardName\s*([^<]+)"#, 209 | #"([^<]*\.(?:png|jpg|jpeg|gif|svg|pdf))"# // Any image file references 210 | ] 211 | 212 | for pattern in patterns { 213 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 214 | let matches = regex?.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) ?? [] 215 | 216 | for match in matches { 217 | if let range = Range(match.range(at: 1), in: content) { 218 | let reference = String(content[range]) 219 | references.insert(reference) 220 | // Also add without extension for iOS naming conventions 221 | if let nameWithoutExt = reference.components(separatedBy: ".").first, nameWithoutExt != reference { 222 | references.insert(nameWithoutExt) 223 | } 224 | } 225 | } 226 | } 227 | 228 | return references 229 | } 230 | 231 | // MARK: - Strings File Parsing 232 | 233 | func parseStringsFiles() throws -> Set { 234 | var imageReferences = Set() 235 | 236 | let folder = try Folder(path: projectPath) 237 | for file in folder.filteredRecursiveFiles where file.extension == "strings" { 238 | do { 239 | let content = try file.readAsString() 240 | imageReferences.formUnion(parseStringsFileContent(content)) 241 | } catch { 242 | // Skip files with encoding issues but continue processing 243 | if verbose { 244 | print("Warning: Skipping \(file.path) due to encoding issue: \(error)") 245 | } 246 | continue 247 | } 248 | } 249 | 250 | return imageReferences 251 | } 252 | 253 | private func parseStringsFileContent(_ content: String) -> Set { 254 | var references = Set() 255 | 256 | // Parse .strings file format: "key" = "value"; 257 | let pattern = #""([^"]*)" = "([^"]+)";"# 258 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 259 | let matches = regex?.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) ?? [] 260 | 261 | for match in matches { 262 | // Check both key and value for image references 263 | for i in 1...2 { 264 | if let range = Range(match.range(at: i), in: content) { 265 | let text = String(content[range]) 266 | if isLikelyImageReference(text) { 267 | references.insert(text) 268 | // Also add without extension 269 | if let nameWithoutExt = text.components(separatedBy: ".").first, nameWithoutExt != text { 270 | references.insert(nameWithoutExt) 271 | } 272 | } 273 | } 274 | } 275 | } 276 | 277 | return references 278 | } 279 | 280 | // MARK: - Settings Bundle Parsing 281 | 282 | func parseSettingsBundle() throws -> Set { 283 | var imageReferences = Set() 284 | 285 | let folder = try Folder(path: projectPath) 286 | for subfolder in folder.filteredRecursiveSubfolders where subfolder.name.hasSuffix(".bundle") { 287 | // Parse plist files in bundle 288 | for file in subfolder.filteredRecursiveFiles where file.extension == "plist" { 289 | do { 290 | let content = try file.readAsString() 291 | imageReferences.formUnion(parseInfoPlistContent(content)) 292 | } catch { 293 | // Skip files with encoding/format issues but continue processing 294 | if verbose { 295 | print("Warning: Skipping \(file.path) due to format issue: \(error)") 296 | } 297 | continue 298 | } 299 | } 300 | 301 | // Parse strings files in bundle 302 | for file in subfolder.filteredRecursiveFiles where file.extension == "strings" { 303 | do { 304 | let content = try file.readAsString() 305 | imageReferences.formUnion(parseStringsFileContent(content)) 306 | } catch { 307 | // Skip files with encoding issues but continue processing 308 | if verbose { 309 | print("Warning: Skipping \(file.path) due to encoding issue: \(error)") 310 | } 311 | continue 312 | } 313 | } 314 | 315 | // Parse image files in bundle 316 | for file in subfolder.filteredRecursiveFiles { 317 | if let ext = file.extension, isImageExtension(ext) { 318 | imageReferences.insert(file.nameExcludingExtension) 319 | } 320 | } 321 | } 322 | 323 | return imageReferences 324 | } 325 | 326 | // MARK: - Helper Methods 327 | 328 | func isLikelyImageReference(_ text: String) -> Bool { 329 | let lowercased = text.lowercased() 330 | return lowercased.contains("icon") || 331 | lowercased.contains("image") || 332 | lowercased.contains("logo") || 333 | lowercased.contains("button") || 334 | lowercased.contains("background") || 335 | lowercased.hasSuffix(".png") || 336 | lowercased.hasSuffix(".jpg") || 337 | lowercased.hasSuffix(".jpeg") || 338 | lowercased.hasSuffix(".gif") || 339 | lowercased.hasSuffix(".svg") || 340 | lowercased.hasSuffix(".pdf") 341 | } 342 | 343 | func isImageExtension(_ ext: String) -> Bool { 344 | let imageExtensions = ["png", "jpg", "jpeg", "gif", "svg", "pdf"] 345 | return imageExtensions.contains(ext.lowercased()) 346 | } 347 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/TestUtilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | import CoreGraphics 4 | @testable import iOSImageOptimizer 5 | 6 | class TestUtilities { 7 | 8 | // MARK: - Directory Management 9 | 10 | static func createTempDirectory(named name: String = "TestTemp") -> String { 11 | let tempDir = NSTemporaryDirectory() 12 | let testDir = (tempDir as NSString).appendingPathComponent("\(name)_\(UUID().uuidString)") 13 | 14 | do { 15 | try FileManager.default.createDirectory(atPath: testDir, withIntermediateDirectories: true) 16 | return testDir 17 | } catch { 18 | XCTFail("Failed to create temp directory: \(error)") 19 | return tempDir 20 | } 21 | } 22 | 23 | static func cleanupTempDirectory(_ path: String) { 24 | try? FileManager.default.removeItem(atPath: path) 25 | } 26 | 27 | static func getFixturesPath() -> String { 28 | let bundle = Bundle(for: TestUtilities.self) 29 | guard let path = bundle.path(forResource: "Fixtures", ofType: nil) else { 30 | XCTFail("Could not find Fixtures directory in test bundle") 31 | return "" 32 | } 33 | return path 34 | } 35 | 36 | // MARK: - Mock Project Creation 37 | 38 | enum ProjectStructure { 39 | case basic 40 | case complex 41 | case empty 42 | case corrupted 43 | case custom([String: Any]) 44 | } 45 | 46 | static func createMockProject(structure: ProjectStructure, in directory: String? = nil) -> String { 47 | let projectDir = directory ?? createTempDirectory(named: "MockProject") 48 | 49 | switch structure { 50 | case .basic: 51 | return createBasicProject(in: projectDir) 52 | case .complex: 53 | return createComplexProject(in: projectDir) 54 | case .empty: 55 | return createEmptyProject(in: projectDir) 56 | case .corrupted: 57 | return createCorruptedProject(in: projectDir) 58 | case .custom(let config): 59 | return createCustomProject(in: projectDir, config: config) 60 | } 61 | } 62 | 63 | private static func createBasicProject(in directory: String) -> String { 64 | let fm = FileManager.default 65 | 66 | // Create directory structure 67 | let imageDir = (directory as NSString).appendingPathComponent("Images") 68 | let sourceDir = (directory as NSString).appendingPathComponent("Sources") 69 | let assetDir = (directory as NSString).appendingPathComponent("Assets.xcassets/AppIcon.appiconset") 70 | 71 | try? fm.createDirectory(atPath: imageDir, withIntermediateDirectories: true) 72 | try? fm.createDirectory(atPath: sourceDir, withIntermediateDirectories: true) 73 | try? fm.createDirectory(atPath: assetDir, withIntermediateDirectories: true) 74 | 75 | // Create mock files 76 | createMockFile(at: "\(imageDir)/logo.png", content: mockImageData()) 77 | createMockFile(at: "\(imageDir)/logo@2x.png", content: mockImageData()) 78 | createMockFile(at: "\(imageDir)/unused_image.png", content: mockImageData()) 79 | 80 | createMockSwiftFile(at: "\(sourceDir)/ViewController.swift", imageReferences: ["logo", "background"]) 81 | createMockAssetCatalog(at: "\(assetDir)/Contents.json", images: ["AppIcon"]) 82 | 83 | return directory 84 | } 85 | 86 | private static func createComplexProject(in directory: String) -> String { 87 | let fm = FileManager.default 88 | 89 | // Create complex directory structure 90 | let dirs = [ 91 | "Sources", "Resources", "Images", "Tests", 92 | "Assets.xcassets/AppIcon.appiconset", 93 | "Assets.xcassets/LaunchImage.launchimage", 94 | "Assets.xcassets/TestImageSet.imageset" 95 | ] 96 | 97 | for dir in dirs { 98 | let fullPath = (directory as NSString).appendingPathComponent(dir) 99 | try? fm.createDirectory(atPath: fullPath, withIntermediateDirectories: true) 100 | } 101 | 102 | // Create mock files with complex references 103 | createMockSwiftFile( 104 | at: "\(directory)/Sources/ComplexViewController.swift", 105 | imageReferences: ["header_image", "button_theme", "dynamic_index"] 106 | ) 107 | 108 | createMockObjectiveCFile( 109 | at: "\(directory)/Sources/ObjectiveC.m", 110 | imageReferences: ["objc_header", "objc_button"] 111 | ) 112 | 113 | createMockStringsFile( 114 | at: "\(directory)/Resources/Localizable.strings", 115 | imageReferences: ["welcome_banner", "error_icon"] 116 | ) 117 | 118 | createMockInfoPlist( 119 | at: "\(directory)/Info.plist", 120 | imageReferences: ["AppIcon", "LaunchImage"] 121 | ) 122 | 123 | return directory 124 | } 125 | 126 | private static func createEmptyProject(in directory: String) -> String { 127 | // Just create the directory, no content 128 | return directory 129 | } 130 | 131 | private static func createCorruptedProject(in directory: String) -> String { 132 | let fm = FileManager.default 133 | 134 | // Create some structure 135 | let assetDir = (directory as NSString).appendingPathComponent("Assets.xcassets/Corrupted.imageset") 136 | try? fm.createDirectory(atPath: assetDir, withIntermediateDirectories: true) 137 | 138 | // Create corrupted JSON 139 | createMockFile(at: "\(assetDir)/Contents.json", content: "{ invalid json content") 140 | 141 | // Create corrupted Swift file 142 | createMockFile(at: "\(directory)/Corrupted.swift", content: "class Incomplete {") 143 | 144 | return directory 145 | } 146 | 147 | private static func createCustomProject(in directory: String, config: [String: Any]) -> String { 148 | // Implementation for custom project creation based on config 149 | return directory 150 | } 151 | 152 | // MARK: - Mock File Creation 153 | 154 | static func createMockFile(at path: String, content: String) { 155 | try? content.write(toFile: path, atomically: true, encoding: .utf8) 156 | } 157 | 158 | static func createMockFile(at path: String, content: Data) { 159 | try? content.write(to: URL(fileURLWithPath: path)) 160 | } 161 | 162 | static func createMockSwiftFile(at path: String, imageReferences: [String]) { 163 | var content = """ 164 | import UIKit 165 | 166 | class MockViewController: UIViewController { 167 | override func viewDidLoad() { 168 | super.viewDidLoad() 169 | 170 | """ 171 | 172 | for (index, ref) in imageReferences.enumerated() { 173 | if ref.contains("\\(") { 174 | // Handle string interpolation 175 | content += """ 176 | 177 | for i in 1...5 { 178 | let image\(index) = UIImage(named: "\(ref)") 179 | } 180 | """ 181 | } else { 182 | content += """ 183 | 184 | let image\(index) = UIImage(named: "\(ref)") 185 | """ 186 | } 187 | } 188 | 189 | content += """ 190 | } 191 | } 192 | """ 193 | 194 | createMockFile(at: path, content: content) 195 | } 196 | 197 | static func createMockObjectiveCFile(at path: String, imageReferences: [String]) { 198 | var content = """ 199 | #import 200 | 201 | @implementation MockObjectiveCClass 202 | 203 | - (void)setupImages { 204 | 205 | """ 206 | 207 | for (index, ref) in imageReferences.enumerated() { 208 | content += " UIImage *image\(index) = [UIImage imageNamed:@\"\(ref)\"];\n" 209 | } 210 | 211 | content += """ 212 | } 213 | 214 | @end 215 | """ 216 | 217 | createMockFile(at: path, content: content) 218 | } 219 | 220 | static func createMockStringsFile(at path: String, imageReferences: [String]) { 221 | var content = "" 222 | 223 | for (index, ref) in imageReferences.enumerated() { 224 | content += "\"image_key_\(index)\" = \"\(ref)\";\n" 225 | } 226 | 227 | createMockFile(at: path, content: content) 228 | } 229 | 230 | static func createMockInfoPlist(at path: String, imageReferences: [String]) { 231 | var content = """ 232 | 233 | 234 | 235 | 236 | CFBundleIdentifier 237 | com.test.mock 238 | 239 | """ 240 | 241 | for (index, ref) in imageReferences.enumerated() { 242 | content += """ 243 | CustomImageKey\(index) 244 | \(ref) 245 | 246 | """ 247 | } 248 | 249 | content += """ 250 | 251 | 252 | """ 253 | 254 | createMockFile(at: path, content: content) 255 | } 256 | 257 | static func createMockAssetCatalog(at path: String, images: [String]) { 258 | let contentsData: [String: Any] = [ 259 | "images": images.enumerated().map { index, name in 260 | [ 261 | "filename": "\(name)@\(index + 1)x.png", 262 | "idiom": "universal", 263 | "scale": "\(index + 1)x" 264 | ] 265 | }, 266 | "info": [ 267 | "author": "xcode", 268 | "version": 1 269 | ] 270 | ] 271 | 272 | if let jsonData = try? JSONSerialization.data(withJSONObject: contentsData, options: .prettyPrinted), 273 | let jsonString = String(data: jsonData, encoding: .utf8) { 274 | createMockFile(at: path, content: jsonString) 275 | } 276 | } 277 | 278 | // MARK: - Mock Image Data 279 | 280 | static func mockImageData(format: String = "PNG") -> Data { 281 | // Create minimal valid image data for testing 282 | switch format { 283 | case "PNG": 284 | return mockPNGData() 285 | case "JPEG": 286 | return mockJPEGData() 287 | default: 288 | return Data() 289 | } 290 | } 291 | 292 | private static func mockPNGData() -> Data { 293 | // Minimal PNG header for testing 294 | let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] 295 | return Data(pngSignature) 296 | } 297 | 298 | private static func mockJPEGData() -> Data { 299 | // Minimal JPEG header for testing 300 | let jpegSignature: [UInt8] = [0xFF, 0xD8, 0xFF, 0xE0] 301 | return Data(jpegSignature) 302 | } 303 | 304 | // MARK: - Test Data Generators 305 | 306 | static func createMockImageAsset( 307 | name: String = "test.png", 308 | path: String = "/test/test.png", 309 | size: Int64 = 1024, 310 | type: ImageAsset.ImageType = .png, 311 | scale: Int? = 1, 312 | dimensions: CGSize? = CGSize(width: 100, height: 100), 313 | isInterlaced: Bool? = false, 314 | colorProfile: String? = nil 315 | ) -> ImageAsset { 316 | return ImageAsset( 317 | name: name, 318 | path: path, 319 | size: size, 320 | type: type, 321 | scale: scale, 322 | dimensions: dimensions, 323 | isInterlaced: isInterlaced, 324 | colorProfile: colorProfile 325 | ) 326 | } 327 | 328 | static func createMockAppleComplianceResults( 329 | pngIssues: Int = 0, 330 | colorIssues: Int = 0, 331 | assetIssues: Int = 0, 332 | designIssues: Int = 0, 333 | score: Int = 100 334 | ) -> AppleComplianceResults { 335 | return AppleComplianceResults( 336 | pngInterlacingIssues: Array(repeating: createMockPNGIssue(), count: pngIssues), 337 | colorProfileIssues: Array(repeating: createMockColorProfileIssue(), count: colorIssues), 338 | assetCatalogIssues: Array(repeating: createMockAssetCatalogIssue(), count: assetIssues), 339 | designQualityIssues: Array(repeating: createMockDesignQualityIssue(), count: designIssues), 340 | complianceScore: score, 341 | criticalIssues: pngIssues + colorIssues, 342 | warningIssues: assetIssues + designIssues, 343 | totalIssues: pngIssues + colorIssues + assetIssues + designIssues 344 | ) 345 | } 346 | 347 | static func createMockPNGIssue() -> PNGInterlacingIssue { 348 | return PNGInterlacingIssue( 349 | image: createMockImageAsset(), 350 | performanceImpact: "Medium", 351 | recommendation: "Convert to de-interlaced PNG" 352 | ) 353 | } 354 | 355 | static func createMockColorProfileIssue() -> ColorProfileIssue { 356 | return ColorProfileIssue( 357 | image: createMockImageAsset(), 358 | issueType: .missing, 359 | recommendation: "Add sRGB color profile" 360 | ) 361 | } 362 | 363 | static func createMockAssetCatalogIssue() -> AssetCatalogIssue { 364 | return AssetCatalogIssue( 365 | image: createMockImageAsset(), 366 | issueType: .shouldBeInCatalog, 367 | recommendation: "Move to Asset Catalog" 368 | ) 369 | } 370 | 371 | static func createMockDesignQualityIssue() -> DesignQualityIssue { 372 | return DesignQualityIssue( 373 | image: createMockImageAsset(), 374 | issueType: .tooSmallForHighRes, 375 | impact: "May appear pixelated", 376 | recommendation: "Increase size to at least 44×44 points" 377 | ) 378 | } 379 | } 380 | 381 | // MARK: - Test Extensions 382 | 383 | extension XCTestCase { 384 | 385 | func withTempDirectory(_ block: (String) throws -> T) rethrows -> T { 386 | let tempDir = TestUtilities.createTempDirectory() 387 | defer { TestUtilities.cleanupTempDirectory(tempDir) } 388 | return try block(tempDir) 389 | } 390 | 391 | func withMockProject(structure: TestUtilities.ProjectStructure, _ block: (String) throws -> T) rethrows -> T { 392 | return try withTempDirectory { tempDir in 393 | let projectPath = TestUtilities.createMockProject(structure: structure, in: tempDir) 394 | return try block(projectPath) 395 | } 396 | } 397 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/AppleComplianceValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | 4 | // MARK: - Issue Types 5 | 6 | struct PNGInterlacingIssue: Encodable { 7 | let image: ImageAsset 8 | let performanceImpact: String 9 | let recommendation: String 10 | } 11 | 12 | struct ColorProfileIssue: Encodable { 13 | let image: ImageAsset 14 | let issueType: ColorProfileIssueType 15 | let recommendation: String 16 | 17 | enum ColorProfileIssueType: Encodable { 18 | case missing 19 | case incompatible(current: String, recommended: String) 20 | case outdated(current: String) 21 | } 22 | } 23 | 24 | struct AssetCatalogIssue: Encodable { 25 | let image: ImageAsset 26 | let issueType: AssetCatalogIssueType 27 | let recommendation: String 28 | 29 | enum AssetCatalogIssueType: Encodable { 30 | case shouldBeInCatalog 31 | case missingScaleVariant(missing: [String]) 32 | case orphanedScale(scale: String) 33 | case incorrectNaming 34 | } 35 | } 36 | 37 | struct DesignQualityIssue: Encodable { 38 | let image: ImageAsset 39 | let issueType: DesignQualityIssueType 40 | let impact: String 41 | let recommendation: String 42 | 43 | enum DesignQualityIssueType: Encodable { 44 | case nonIntegerScaling 45 | case tooSmallForHighRes 46 | case dimensionMismatch 47 | case inefficientDimensions 48 | } 49 | } 50 | 51 | struct AppleComplianceResults: Encodable { 52 | let pngInterlacingIssues: [PNGInterlacingIssue] 53 | let colorProfileIssues: [ColorProfileIssue] 54 | let assetCatalogIssues: [AssetCatalogIssue] 55 | let designQualityIssues: [DesignQualityIssue] 56 | let complianceScore: Int 57 | let criticalIssues: Int 58 | let warningIssues: Int 59 | let totalIssues: Int 60 | } 61 | 62 | // MARK: - Apple Compliance Validator 63 | 64 | class AppleComplianceValidator { 65 | 66 | func validateImages(_ images: [ImageAsset]) -> AppleComplianceResults { 67 | let pngInterlacingIssues = validatePNGInterlacing(images) 68 | let colorProfileIssues = validateColorProfiles(images) 69 | let assetCatalogIssues = validateAssetCatalogOrganization(images) 70 | let designQualityIssues = validateDesignQuality(images) 71 | 72 | let totalIssues = pngInterlacingIssues.count + colorProfileIssues.count + 73 | assetCatalogIssues.count + designQualityIssues.count 74 | 75 | let criticalIssues = countCriticalIssues( 76 | pngIssues: pngInterlacingIssues, 77 | colorIssues: colorProfileIssues, 78 | assetIssues: assetCatalogIssues, 79 | designIssues: designQualityIssues 80 | ) 81 | 82 | let warningIssues = totalIssues - criticalIssues 83 | let complianceScore = calculateComplianceScore( 84 | totalImages: images.count, 85 | totalIssues: totalIssues, 86 | criticalIssues: criticalIssues 87 | ) 88 | 89 | return AppleComplianceResults( 90 | pngInterlacingIssues: pngInterlacingIssues, 91 | colorProfileIssues: colorProfileIssues, 92 | assetCatalogIssues: assetCatalogIssues, 93 | designQualityIssues: designQualityIssues, 94 | complianceScore: complianceScore, 95 | criticalIssues: criticalIssues, 96 | warningIssues: warningIssues, 97 | totalIssues: totalIssues 98 | ) 99 | } 100 | 101 | // MARK: - PNG Interlacing Validation 102 | 103 | func validatePNGInterlacing(_ images: [ImageAsset]) -> [PNGInterlacingIssue] { 104 | var issues: [PNGInterlacingIssue] = [] 105 | 106 | for image in images { 107 | // Check if image is PNG (standalone or in asset catalog) 108 | let isPNG: Bool 109 | switch image.type { 110 | case .png: 111 | isPNG = true 112 | case .assetCatalog: 113 | isPNG = true // Asset catalog images can be PNG 114 | default: 115 | isPNG = false 116 | } 117 | 118 | guard isPNG else { continue } 119 | guard let isInterlaced = image.isInterlaced, isInterlaced else { continue } 120 | 121 | let performanceImpact = determinePerformanceImpact(for: image) 122 | let recommendation = "Convert to de-interlaced PNG for better iOS performance and memory usage" 123 | 124 | issues.append(PNGInterlacingIssue( 125 | image: image, 126 | performanceImpact: performanceImpact, 127 | recommendation: recommendation 128 | )) 129 | } 130 | 131 | return issues 132 | } 133 | 134 | private func determinePerformanceImpact(for image: ImageAsset) -> String { 135 | guard let dimensions = image.dimensions else { return "Unknown" } 136 | 137 | let pixelCount = dimensions.width * dimensions.height 138 | 139 | if pixelCount > 1_000_000 { // > 1 megapixel 140 | return "Critical" 141 | } else if pixelCount > 100_000 { // > 0.1 megapixel 142 | return "High" 143 | } else { 144 | return "Medium" 145 | } 146 | } 147 | 148 | // MARK: - Color Profile Validation 149 | 150 | func validateColorProfiles(_ images: [ImageAsset]) -> [ColorProfileIssue] { 151 | var issues: [ColorProfileIssue] = [] 152 | 153 | for image in images { 154 | if let colorProfile = image.colorProfile { 155 | // Check if profile is compatible/recommended 156 | if !isRecommendedColorProfile(colorProfile) { 157 | let recommended = getRecommendedColorProfile(for: image) 158 | issues.append(ColorProfileIssue( 159 | image: image, 160 | issueType: .incompatible(current: colorProfile, recommended: recommended), 161 | recommendation: "Use \(recommended) color profile for better iOS compatibility" 162 | )) 163 | } 164 | 165 | // Check if profile is outdated 166 | if isOutdatedColorProfile(colorProfile) { 167 | issues.append(ColorProfileIssue( 168 | image: image, 169 | issueType: .outdated(current: colorProfile), 170 | recommendation: "Update to modern color profile (sRGB or Display P3)" 171 | )) 172 | } 173 | } else { 174 | // Missing color profile 175 | issues.append(ColorProfileIssue( 176 | image: image, 177 | issueType: .missing, 178 | recommendation: "Add sRGB color profile for consistent colors across devices" 179 | )) 180 | } 181 | } 182 | 183 | return issues 184 | } 185 | 186 | private func isRecommendedColorProfile(_ profile: String) -> Bool { 187 | let recommendedProfiles = ["RGB", "sRGB", "Display P3", "SRGB"] 188 | return recommendedProfiles.contains { profile.contains($0) } 189 | } 190 | 191 | private func getRecommendedColorProfile(for image: ImageAsset) -> String { 192 | // For most iOS images, sRGB is recommended 193 | return "sRGB" 194 | } 195 | 196 | private func isOutdatedColorProfile(_ profile: String) -> Bool { 197 | let outdatedProfiles = ["Adobe RGB", "ProPhoto RGB", "Generic RGB"] 198 | return outdatedProfiles.contains { profile.contains($0) } 199 | } 200 | 201 | // MARK: - Asset Catalog Organization Validation 202 | 203 | func validateAssetCatalogOrganization(_ images: [ImageAsset]) -> [AssetCatalogIssue] { 204 | var issues: [AssetCatalogIssue] = [] 205 | 206 | let standaloneImages = images.filter { !$0.path.contains(".xcassets") } 207 | let assetCatalogImages = images.filter { $0.path.contains(".xcassets") } 208 | 209 | // Check standalone images that should be in asset catalogs 210 | for image in standaloneImages { 211 | if shouldBeInAssetCatalog(image) { 212 | issues.append(AssetCatalogIssue( 213 | image: image, 214 | issueType: .shouldBeInCatalog, 215 | recommendation: "Move to Asset Catalog for better iOS optimization and management" 216 | )) 217 | } 218 | } 219 | 220 | // Check for missing scale variants in asset catalogs 221 | let imageGroups = Dictionary(grouping: assetCatalogImages) { $0.name } 222 | for (_, variants) in imageGroups { 223 | let availableScales = variants.compactMap { $0.scale }.sorted() 224 | let missingScales = findMissingScaleVariants(availableScales) 225 | 226 | if !missingScales.isEmpty { 227 | let missing = missingScales.map { "@\($0)x" } 228 | issues.append(AssetCatalogIssue( 229 | image: variants.first!, 230 | issueType: .missingScaleVariant(missing: missing), 231 | recommendation: "Add missing scale variants: \(missing.joined(separator: ", ")) for optimal iOS display" 232 | )) 233 | } 234 | 235 | // Check for orphaned scale variants 236 | if variants.count == 1, let scale = variants.first?.scale, scale > 1 { 237 | issues.append(AssetCatalogIssue( 238 | image: variants.first!, 239 | issueType: .orphanedScale(scale: "@\(scale)x"), 240 | recommendation: "Add @1x base variant for complete scale set" 241 | )) 242 | } 243 | } 244 | 245 | return issues 246 | } 247 | 248 | private func shouldBeInAssetCatalog(_ image: ImageAsset) -> Bool { 249 | let name = image.name.lowercased() 250 | let path = image.path.lowercased() 251 | 252 | // UI elements and app assets should typically be in asset catalogs 253 | return name.contains("icon") || name.contains("button") || 254 | name.contains("background") || name.contains("ui") || 255 | path.contains("assets") || image.name.contains("@") 256 | } 257 | 258 | private func findMissingScaleVariants(_ availableScales: [Int]) -> [Int] { 259 | let requiredScales = [1, 2, 3] // iOS requirements 260 | return requiredScales.filter { !availableScales.contains($0) } 261 | } 262 | 263 | // MARK: - Design Quality Validation 264 | 265 | func validateDesignQuality(_ images: [ImageAsset]) -> [DesignQualityIssue] { 266 | var issues: [DesignQualityIssue] = [] 267 | 268 | for image in images { 269 | guard let dimensions = image.dimensions else { continue } 270 | 271 | // Check for non-integer scaling between variants 272 | if let scale = image.scale, scale > 1 { 273 | let expectedBaseWidth = dimensions.width / Double(scale) 274 | let expectedBaseHeight = dimensions.height / Double(scale) 275 | 276 | if expectedBaseWidth != floor(expectedBaseWidth) || expectedBaseHeight != floor(expectedBaseHeight) { 277 | issues.append(DesignQualityIssue( 278 | image: image, 279 | issueType: .nonIntegerScaling, 280 | impact: "May cause blurry rendering", 281 | recommendation: "Ensure dimensions scale to whole numbers (current: \(Int(dimensions.width))×\(Int(dimensions.height)), expected: \(Int(expectedBaseWidth * Double(scale)))×\(Int(expectedBaseHeight * Double(scale))))" 282 | )) 283 | } 284 | } 285 | 286 | // Check for images too small for high-resolution displays 287 | if dimensions.width < 44 && dimensions.height < 44 { 288 | issues.append(DesignQualityIssue( 289 | image: image, 290 | issueType: .tooSmallForHighRes, 291 | impact: "May appear pixelated on high-resolution displays", 292 | recommendation: "Increase size to at least 44×44 points for touch targets or 22×22 for small icons" 293 | )) 294 | } 295 | 296 | // Check for very large dimensions that might cause memory issues 297 | let pixelCount = dimensions.width * dimensions.height 298 | if pixelCount > 2_000_000 { // > 2 megapixels 299 | issues.append(DesignQualityIssue( 300 | image: image, 301 | issueType: .inefficientDimensions, 302 | impact: "High memory usage (\(String(format: "%.1f", pixelCount / 1_000_000))MP)", 303 | recommendation: "Consider reducing dimensions or using progressive loading for large images" 304 | )) 305 | } 306 | } 307 | 308 | return issues 309 | } 310 | 311 | // MARK: - Compliance Scoring 312 | 313 | private func countCriticalIssues( 314 | pngIssues: [PNGInterlacingIssue], 315 | colorIssues: [ColorProfileIssue], 316 | assetIssues: [AssetCatalogIssue], 317 | designIssues: [DesignQualityIssue] 318 | ) -> Int { 319 | var criticalCount = 0 320 | 321 | // Critical PNG issues (high performance impact) 322 | criticalCount += pngIssues.filter { $0.performanceImpact == "Critical" }.count 323 | 324 | // Critical color profile issues (missing profiles) 325 | criticalCount += colorIssues.filter { 326 | if case .missing = $0.issueType { return true } 327 | return false 328 | }.count 329 | 330 | // Critical asset catalog issues (missing scale variants) 331 | criticalCount += assetIssues.filter { 332 | if case .missingScaleVariant = $0.issueType { return true } 333 | return false 334 | }.count 335 | 336 | // Critical design issues (too small or memory intensive) 337 | criticalCount += designIssues.filter { 338 | $0.issueType == .tooSmallForHighRes || $0.issueType == .inefficientDimensions 339 | }.count 340 | 341 | return criticalCount 342 | } 343 | 344 | private func calculateComplianceScore(totalImages: Int, totalIssues: Int, criticalIssues: Int) -> Int { 345 | guard totalImages > 0 else { return 100 } 346 | 347 | // Base score starts at 100 348 | var score = 100 349 | 350 | // Deduct points for issues 351 | let issueRate = Double(totalIssues) / Double(totalImages) 352 | let criticalRate = Double(criticalIssues) / Double(totalImages) 353 | 354 | // Heavy penalty for critical issues, lighter for warnings 355 | score -= Int(criticalRate * 60) // Up to 60 points for critical issues 356 | score -= Int((issueRate - criticalRate) * 30) // Up to 30 points for other issues 357 | 358 | return max(0, score) // Ensure score doesn't go below 0 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /iOSImageOptimizer/Sources/iOSImageOptimizer/ProjectAnalyzer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Files 3 | import Rainbow 4 | 5 | struct AnalysisReport { 6 | let totalImages: Int 7 | let unusedImages: [ImageAsset] 8 | let totalSize: Int64 9 | let unusedImageSize: Int64 10 | let totalPotentialSavings: Int64 11 | 12 | // Apple compliance results 13 | let appleComplianceResults: AppleComplianceResults 14 | 15 | func printToConsole() { 16 | print("\n📊 " + "Analysis Complete".bold) 17 | print("=" * 50) 18 | 19 | // Apple compliance score 20 | let scoreColor = getScoreColor(appleComplianceResults.complianceScore) 21 | print("\n🎯 " + "Apple Compliance Score: \(appleComplianceResults.complianceScore)/100".applyingColor(scoreColor).bold) 22 | 23 | print("\n📈 " + "Summary:".bold) 24 | print(" Total images: \(totalImages)") 25 | print(" Total image size: \(formatBytes(totalSize))") 26 | print(" Unused images: \(unusedImages.count)".red) 27 | print(" Potential savings: \(formatBytes(totalPotentialSavings))".green) 28 | 29 | print("\n🍎 " + "Apple Guidelines Compliance:".bold) 30 | print(" PNG interlacing issues: \(appleComplianceResults.pngInterlacingIssues.count)".colorForIssueCount(appleComplianceResults.pngInterlacingIssues.count)) 31 | print(" Color profile issues: \(appleComplianceResults.colorProfileIssues.count)".colorForIssueCount(appleComplianceResults.colorProfileIssues.count)) 32 | print(" Asset catalog issues: \(appleComplianceResults.assetCatalogIssues.count)".colorForIssueCount(appleComplianceResults.assetCatalogIssues.count)) 33 | print(" Design quality issues: \(appleComplianceResults.designQualityIssues.count)".colorForIssueCount(appleComplianceResults.designQualityIssues.count)) 34 | 35 | printDetailedIssues() 36 | printActionableRecommendations() 37 | 38 | 39 | } 40 | 41 | func exportJSON() throws { 42 | let jsonData = try JSONEncoder().encode(self) 43 | print(String(data: jsonData, encoding: .utf8) ?? "{}") 44 | } 45 | 46 | private func formatBytes(_ bytes: Int64) -> String { 47 | let formatter = ByteCountFormatter() 48 | formatter.countStyle = .file 49 | return formatter.string(fromByteCount: bytes) 50 | } 51 | 52 | private func getScoreColor(_ score: Int) -> ColorType { 53 | if score >= 80 { return .named(.green) } 54 | if score >= 60 { return .named(.yellow) } 55 | return .named(.red) 56 | } 57 | 58 | private func printDetailedIssues() { 59 | if !unusedImages.isEmpty { 60 | print("\n🗑️ " + "Unused Images:".bold.red) 61 | for image in unusedImages.prefix(10) { 62 | print(" ❌ \(image.name) (\(formatBytes(image.size)))") 63 | print(" Path: \(image.path)") 64 | } 65 | if unusedImages.count > 10 { 66 | print(" ... and \(unusedImages.count - 10) more") 67 | } 68 | } 69 | 70 | if !appleComplianceResults.pngInterlacingIssues.isEmpty { 71 | print("\n🖼️ " + "PNG Interlacing Issues:".bold.yellow) 72 | for issue in appleComplianceResults.pngInterlacingIssues.prefix(5) { 73 | print(" ⚠️ \(issue.image.name) - \(issue.performanceImpact) impact") 74 | print(" \(issue.recommendation)".dim) 75 | } 76 | if appleComplianceResults.pngInterlacingIssues.count > 5 { 77 | print(" ... and \(appleComplianceResults.pngInterlacingIssues.count - 5) more") 78 | } 79 | } 80 | 81 | if !appleComplianceResults.colorProfileIssues.isEmpty { 82 | print("\n🎨 " + "Color Profile Issues:".bold.yellow) 83 | for issue in appleComplianceResults.colorProfileIssues.prefix(5) { 84 | print(" 🟡 \(issue.image.name) - \(getIssueTypeDescription(issue.issueType))") 85 | print(" \(issue.recommendation)".dim) 86 | } 87 | if appleComplianceResults.colorProfileIssues.count > 5 { 88 | print(" ... and \(appleComplianceResults.colorProfileIssues.count - 5) more") 89 | } 90 | } 91 | 92 | if !appleComplianceResults.assetCatalogIssues.isEmpty { 93 | print("\n📁 " + "Asset Catalog Issues:".bold.yellow) 94 | for issue in appleComplianceResults.assetCatalogIssues.prefix(5) { 95 | print(" 📦 \(issue.image.name) - \(getAssetIssueDescription(issue.issueType))") 96 | print(" \(issue.recommendation)".dim) 97 | } 98 | if appleComplianceResults.assetCatalogIssues.count > 5 { 99 | print(" ... and \(appleComplianceResults.assetCatalogIssues.count - 5) more") 100 | } 101 | } 102 | 103 | if !appleComplianceResults.designQualityIssues.isEmpty { 104 | print("\n🎨 " + "Design Quality Issues:".bold.yellow) 105 | for issue in appleComplianceResults.designQualityIssues.prefix(5) { 106 | print(" 🔍 \(issue.image.name) - \(issue.impact)") 107 | print(" \(issue.recommendation)".dim) 108 | } 109 | if appleComplianceResults.designQualityIssues.count > 5 { 110 | print(" ... and \(appleComplianceResults.designQualityIssues.count - 5) more") 111 | } 112 | } 113 | } 114 | 115 | private func printActionableRecommendations() { 116 | print("\n💡 " + "Prioritized Action Items:".bold) 117 | 118 | var recommendations: [(priority: Int, action: String)] = [] 119 | 120 | if !unusedImages.isEmpty { 121 | recommendations.append((1, "Remove \(unusedImages.count) unused images to save \(formatBytes(unusedImageSize))")) 122 | } 123 | 124 | let criticalPNG = appleComplianceResults.pngInterlacingIssues.filter { $0.performanceImpact == "Critical" } 125 | if !criticalPNG.isEmpty { 126 | recommendations.append((2, "Fix \(criticalPNG.count) critical PNG interlacing issues")) 127 | } 128 | 129 | let missingProfiles = appleComplianceResults.colorProfileIssues.filter { 130 | if case .missing = $0.issueType { return true } 131 | return false 132 | } 133 | if !missingProfiles.isEmpty { 134 | recommendations.append((3, "Add color profiles to \(missingProfiles.count) images")) 135 | } 136 | 137 | let criticalAssetIssues = appleComplianceResults.assetCatalogIssues.filter { 138 | if case .missingScaleVariant = $0.issueType { return true } 139 | return false 140 | } 141 | if !criticalAssetIssues.isEmpty { 142 | recommendations.append((4, "Add missing scale variants for \(criticalAssetIssues.count) images")) 143 | } 144 | 145 | let designIssues = appleComplianceResults.designQualityIssues.filter { 146 | $0.issueType == .tooSmallForHighRes || $0.issueType == .inefficientDimensions 147 | } 148 | if !designIssues.isEmpty { 149 | recommendations.append((5, "Address \(designIssues.count) design quality issues")) 150 | } 151 | 152 | for (index, recommendation) in recommendations.enumerated() { 153 | print(" \(index + 1). \(recommendation.action)") 154 | } 155 | 156 | if recommendations.isEmpty { 157 | print(" ✅ No critical issues found! Your images follow Apple guidelines well.") 158 | } 159 | } 160 | 161 | private func getIssueTypeDescription(_ issueType: ColorProfileIssue.ColorProfileIssueType) -> String { 162 | switch issueType { 163 | case .missing: 164 | return "Missing color profile" 165 | case .incompatible(let current, let recommended): 166 | return "Incompatible profile (\(current) → \(recommended))" 167 | case .outdated(let current): 168 | return "Outdated profile (\(current))" 169 | } 170 | } 171 | 172 | private func getAssetIssueDescription(_ issueType: AssetCatalogIssue.AssetCatalogIssueType) -> String { 173 | switch issueType { 174 | case .shouldBeInCatalog: 175 | return "Should be in Asset Catalog" 176 | case .missingScaleVariant(let missing): 177 | return "Missing scale variants: \(missing.joined(separator: ", "))" 178 | case .orphanedScale(let scale): 179 | return "Orphaned scale variant: \(scale)" 180 | case .incorrectNaming: 181 | return "Incorrect naming convention" 182 | } 183 | } 184 | } 185 | 186 | extension AnalysisReport: Encodable {} 187 | 188 | // MARK: - String Extensions for Colors 189 | 190 | extension String { 191 | func colorForIssueCount(_ count: Int) -> String { 192 | if count == 0 { return self.green } 193 | if count <= 3 { return self.yellow } 194 | return self.red 195 | } 196 | } 197 | 198 | class ProjectAnalyzer { 199 | private let projectPath: String 200 | private let verbose: Bool 201 | 202 | 203 | init(projectPath: String, verbose: Bool = false) { 204 | self.projectPath = projectPath 205 | self.verbose = verbose 206 | } 207 | 208 | func analyze() throws -> AnalysisReport { 209 | // Step 1: Find all images 210 | if verbose { print("Scanning for images...") } 211 | let scanner = ImageScanner(projectPath: projectPath) 212 | let allImages = try scanner.scanForImages() 213 | 214 | if verbose { print("Found \(allImages.count) images") } 215 | 216 | // Step 2: Find used images (includes pattern-based detection) 217 | if verbose { print("Detecting image usage...") } 218 | let detector = UsageDetector(projectPath: projectPath, verbose: verbose) 219 | let usedImageNames = try detector.findUsedImageNames() 220 | 221 | // Step 3: Identify unused images with enhanced cross-referencing 222 | let unusedImages = try identifyUnusedImagesWithCrossValidation(allImages: allImages, usedImageNames: usedImageNames) 223 | 224 | // Step 4: Apple compliance validation 225 | if verbose { print("Running Apple compliance validation...") } 226 | let validator = AppleComplianceValidator() 227 | let appleComplianceResults = validator.validateImages(allImages) 228 | 229 | // Step 5: Calculate metrics (only unused images provide savings) 230 | let totalSize = allImages.reduce(0) { $0 + $1.size } 231 | let unusedImageSize = unusedImages.reduce(0) { $0 + $1.size } 232 | let totalPotentialSavings = unusedImageSize 233 | 234 | return AnalysisReport( 235 | totalImages: allImages.count, 236 | unusedImages: unusedImages, 237 | totalSize: totalSize, 238 | unusedImageSize: unusedImageSize, 239 | totalPotentialSavings: totalPotentialSavings, 240 | appleComplianceResults: appleComplianceResults 241 | ) 242 | } 243 | 244 | 245 | private func formatBytes(_ bytes: Int64) -> String { 246 | let formatter = ByteCountFormatter() 247 | formatter.countStyle = .file 248 | return formatter.string(fromByteCount: bytes) 249 | } 250 | 251 | // MARK: - Enhanced Cross-Validation Logic 252 | 253 | private func identifyUnusedImagesWithCrossValidation(allImages: [ImageAsset], usedImageNames: Set) throws -> [ImageAsset] { 254 | var unusedImages: [ImageAsset] = [] 255 | 256 | // Build comprehensive name variants for each image 257 | for image in allImages { 258 | // Skip test reference images, watchOS complications, and system-managed assets 259 | if isTestReferenceImage(image) || isWatchOSComplication(image) || isSystemManagedAsset(image) { 260 | if verbose { 261 | let type = isTestReferenceImage(image) ? "test" : 262 | isWatchOSComplication(image) ? "watchOS" : "system" 263 | print("Skipping special image: \(image.name) (\(type))") 264 | } 265 | continue 266 | } 267 | 268 | let imageNameVariants = generateImageNameVariants(for: image) 269 | 270 | // Check if ANY variant is referenced in the code 271 | let isUsed = imageNameVariants.contains { variant in 272 | usedImageNames.contains(variant) 273 | } 274 | 275 | if !isUsed { 276 | // Double-check with file system validation 277 | if !isReferencedInProjectFiles(imageName: image.name) { 278 | unusedImages.append(image) 279 | } 280 | } 281 | } 282 | 283 | return unusedImages 284 | } 285 | 286 | private func generateImageNameVariants(for image: ImageAsset) -> Set { 287 | var variants = Set() 288 | 289 | // Add the base name 290 | variants.insert(image.name) 291 | 292 | // Add name without extension 293 | if let nameWithoutExt = image.name.components(separatedBy: ".").first, nameWithoutExt != image.name { 294 | variants.insert(nameWithoutExt) 295 | } 296 | 297 | // Add scale variants (@2x, @3x patterns) 298 | let baseName = image.name.replacingOccurrences(of: "@2x", with: "").replacingOccurrences(of: "@3x", with: "") 299 | variants.insert(baseName) 300 | variants.insert("\(baseName)@2x") 301 | variants.insert("\(baseName)@3x") 302 | 303 | // Add filename variants from asset catalog structure 304 | if case .assetCatalog = image.type { 305 | let pathComponents = image.path.components(separatedBy: "/") 306 | if let imagesetFolder = pathComponents.first(where: { $0.hasSuffix(".imageset") }) { 307 | let assetFolderName = imagesetFolder.replacingOccurrences(of: ".imageset", with: "") 308 | variants.insert(assetFolderName) 309 | } 310 | } 311 | 312 | // Add common iOS naming patterns 313 | variants.insert(image.name.lowercased()) 314 | variants.insert(image.name.uppercased()) 315 | 316 | return variants 317 | } 318 | 319 | private func isReferencedInProjectFiles(imageName: String) -> Bool { 320 | // Additional safety check - look for the image name as a plain string anywhere in project files 321 | do { 322 | let folder = try Folder(path: projectPath) 323 | for file in folder.filteredRecursiveFiles { 324 | if let ext = file.extension, 325 | ["swift", "m", "mm", "h", "storyboard", "xib", "plist", "strings"].contains(ext) { 326 | let content = try file.readAsString() 327 | if content.contains(imageName) { 328 | return true 329 | } 330 | } 331 | } 332 | } catch { 333 | // If there's an error reading files, err on the side of caution 334 | return true 335 | } 336 | 337 | return false 338 | } 339 | 340 | private func isTestReferenceImage(_ image: ImageAsset) -> Bool { 341 | let path = image.path.lowercased() 342 | let name = image.name.lowercased() 343 | 344 | return path.contains("referenceimages") || 345 | path.contains("tests/") || 346 | path.contains("test/") || 347 | name.contains("test") || 348 | name.contains("snapshot") || 349 | path.contains("snapshot") 350 | } 351 | 352 | private func isWatchOSComplication(_ image: ImageAsset) -> Bool { 353 | let path = image.path.lowercased() 354 | 355 | return path.contains("watch extension") || 356 | path.contains("watchkit") || 357 | path.contains("complication") || 358 | path.contains(".watchapp/") || 359 | path.contains("watch app") 360 | } 361 | 362 | private func isSystemManagedAsset(_ image: ImageAsset) -> Bool { 363 | let path = image.path.lowercased() 364 | 365 | return path.contains("appicon.solidimagestack") || // visionOS app icons 366 | path.contains("appicon.appiconset") || // iOS app icons 367 | path.contains("launchimage.launchimage") || // Launch images 368 | path.contains(".solidimagestacklayer") || // visionOS icon layers 369 | path.contains("assets.car") || // Compiled assets 370 | (path.contains("appicon") && path.contains(".imageset")) // App icon variants 371 | } 372 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/Tests/iOSImageOptimizerTests/AssertionHelpers.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import CoreGraphics 4 | @testable import iOSImageOptimizer 5 | 6 | // MARK: - XCTestCase Extensions for Custom Assertions 7 | 8 | extension XCTestCase { 9 | 10 | // MARK: - ImageAsset Assertions 11 | 12 | func assertImageAsset( 13 | _ asset: ImageAsset, 14 | hasName expectedName: String, 15 | type expectedType: ImageAsset.ImageType, 16 | size expectedSize: Int64? = nil, 17 | file: StaticString = #file, 18 | line: UInt = #line 19 | ) { 20 | XCTAssertEqual(asset.name, expectedName, "Image asset name mismatch", file: file, line: line) 21 | XCTAssertEqual(asset.type, expectedType, "Image asset type mismatch", file: file, line: line) 22 | 23 | if let expectedSize = expectedSize { 24 | XCTAssertEqual(asset.size, expectedSize, "Image asset size mismatch", file: file, line: line) 25 | } 26 | } 27 | 28 | func assertImageAsset( 29 | _ asset: ImageAsset, 30 | hasDimensions expectedDimensions: CGSize, 31 | scale expectedScale: Int? = nil, 32 | file: StaticString = #file, 33 | line: UInt = #line 34 | ) { 35 | XCTAssertNotNil(asset.dimensions, "Image asset should have dimensions", file: file, line: line) 36 | 37 | if let dimensions = asset.dimensions { 38 | XCTAssertEqual(dimensions.width, expectedDimensions.width, accuracy: 0.1, 39 | "Image width mismatch", file: file, line: line) 40 | XCTAssertEqual(dimensions.height, expectedDimensions.height, accuracy: 0.1, 41 | "Image height mismatch", file: file, line: line) 42 | } 43 | 44 | if let expectedScale = expectedScale { 45 | XCTAssertEqual(asset.scale, expectedScale, "Image scale mismatch", file: file, line: line) 46 | } 47 | } 48 | 49 | func assertImageAsset( 50 | _ asset: ImageAsset, 51 | hasColorProfile expectedProfile: String?, 52 | isInterlaced expectedInterlaced: Bool? = nil, 53 | file: StaticString = #file, 54 | line: UInt = #line 55 | ) { 56 | XCTAssertEqual(asset.colorProfile, expectedProfile, "Color profile mismatch", file: file, line: line) 57 | 58 | if let expectedInterlaced = expectedInterlaced { 59 | XCTAssertEqual(asset.isInterlaced, expectedInterlaced, "Interlaced state mismatch", file: file, line: line) 60 | } 61 | } 62 | 63 | // MARK: - Apple Compliance Results Assertions 64 | 65 | func assertComplianceResults( 66 | _ results: AppleComplianceResults, 67 | hasScore expectedScore: Int, 68 | criticalIssues expectedCritical: Int, 69 | warningIssues expectedWarnings: Int? = nil, 70 | totalIssues expectedTotal: Int? = nil, 71 | file: StaticString = #file, 72 | line: UInt = #line 73 | ) { 74 | XCTAssertEqual(results.complianceScore, expectedScore, "Compliance score mismatch", file: file, line: line) 75 | XCTAssertEqual(results.criticalIssues, expectedCritical, "Critical issues count mismatch", file: file, line: line) 76 | 77 | if let expectedWarnings = expectedWarnings { 78 | XCTAssertEqual(results.warningIssues, expectedWarnings, "Warning issues count mismatch", file: file, line: line) 79 | } 80 | 81 | if let expectedTotal = expectedTotal { 82 | XCTAssertEqual(results.totalIssues, expectedTotal, "Total issues count mismatch", file: file, line: line) 83 | } 84 | 85 | // Validate internal consistency 86 | let calculatedTotal = results.pngInterlacingIssues.count + 87 | results.colorProfileIssues.count + 88 | results.assetCatalogIssues.count + 89 | results.designQualityIssues.count 90 | XCTAssertEqual(results.totalIssues, calculatedTotal, 91 | "Total issues should equal sum of individual issue counts", file: file, line: line) 92 | } 93 | 94 | func assertComplianceResults( 95 | _ results: AppleComplianceResults, 96 | hasPNGIssues expectedPNG: Int, 97 | colorIssues expectedColor: Int, 98 | assetIssues expectedAsset: Int, 99 | designIssues expectedDesign: Int, 100 | file: StaticString = #file, 101 | line: UInt = #line 102 | ) { 103 | XCTAssertEqual(results.pngInterlacingIssues.count, expectedPNG, 104 | "PNG interlacing issues count mismatch", file: file, line: line) 105 | XCTAssertEqual(results.colorProfileIssues.count, expectedColor, 106 | "Color profile issues count mismatch", file: file, line: line) 107 | XCTAssertEqual(results.assetCatalogIssues.count, expectedAsset, 108 | "Asset catalog issues count mismatch", file: file, line: line) 109 | XCTAssertEqual(results.designQualityIssues.count, expectedDesign, 110 | "Design quality issues count mismatch", file: file, line: line) 111 | } 112 | 113 | // MARK: - Analysis Report Assertions 114 | 115 | func assertAnalysisReport( 116 | _ report: AnalysisReport, 117 | totalImages expectedTotal: Int, 118 | unusedImages expectedUnused: Int, 119 | totalSize expectedTotalSize: Int64? = nil, 120 | potentialSavings expectedSavings: Int64? = nil, 121 | file: StaticString = #file, 122 | line: UInt = #line 123 | ) { 124 | XCTAssertEqual(report.totalImages, expectedTotal, "Total images count mismatch", file: file, line: line) 125 | XCTAssertEqual(report.unusedImages.count, expectedUnused, "Unused images count mismatch", file: file, line: line) 126 | 127 | if let expectedTotalSize = expectedTotalSize { 128 | XCTAssertEqual(report.totalSize, expectedTotalSize, "Total size mismatch", file: file, line: line) 129 | } 130 | 131 | if let expectedSavings = expectedSavings { 132 | XCTAssertEqual(report.totalPotentialSavings, expectedSavings, 133 | "Potential savings mismatch", file: file, line: line) 134 | } 135 | 136 | // Validate internal consistency 137 | XCTAssertEqual(report.unusedImageSize, report.unusedImages.reduce(0) { $0 + $1.size }, 138 | "Unused image size should equal sum of individual unused image sizes", file: file, line: line) 139 | XCTAssertEqual(report.totalPotentialSavings, report.unusedImageSize, 140 | "Total potential savings should equal unused image size", file: file, line: line) 141 | } 142 | 143 | // MARK: - Collection Assertions 144 | 145 | func assertContains( 146 | _ collection: [T], 147 | _ expectedItem: T, 148 | message: String = "Collection should contain expected item", 149 | file: StaticString = #file, 150 | line: UInt = #line 151 | ) { 152 | XCTAssertTrue(collection.contains(expectedItem), message, file: file, line: line) 153 | } 154 | 155 | func assertContains( 156 | _ collection: [String], 157 | itemMatching pattern: String, 158 | message: String = "Collection should contain item matching pattern", 159 | file: StaticString = #file, 160 | line: UInt = #line 161 | ) { 162 | let matches = collection.filter { $0.range(of: pattern, options: .regularExpression) != nil } 163 | XCTAssertFalse(matches.isEmpty, "\(message). Pattern: \(pattern)", file: file, line: line) 164 | } 165 | 166 | func assertContains( 167 | _ collection: [ImageAsset], 168 | imageNamed expectedName: String, 169 | message: String = "Collection should contain image with expected name", 170 | file: StaticString = #file, 171 | line: UInt = #line 172 | ) { 173 | let matches = collection.filter { $0.name == expectedName } 174 | XCTAssertFalse(matches.isEmpty, "\(message). Expected name: \(expectedName)", file: file, line: line) 175 | } 176 | 177 | func assertDoesNotContain( 178 | _ collection: [ImageAsset], 179 | imageNamed unexpectedName: String, 180 | message: String = "Collection should not contain image with unexpected name", 181 | file: StaticString = #file, 182 | line: UInt = #line 183 | ) { 184 | let matches = collection.filter { $0.name == unexpectedName } 185 | XCTAssertTrue(matches.isEmpty, "\(message). Unexpected name: \(unexpectedName)", file: file, line: line) 186 | } 187 | 188 | // MARK: - File System Assertions 189 | 190 | func assertFileExists( 191 | at path: String, 192 | message: String = "File should exist at path", 193 | file: StaticString = #file, 194 | line: UInt = #line 195 | ) { 196 | XCTAssertTrue(FileManager.default.fileExists(atPath: path), 197 | "\(message): \(path)", file: file, line: line) 198 | } 199 | 200 | func assertDirectoryExists( 201 | at path: String, 202 | message: String = "Directory should exist at path", 203 | file: StaticString = #file, 204 | line: UInt = #line 205 | ) { 206 | var isDirectory: ObjCBool = false 207 | let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) 208 | XCTAssertTrue(exists && isDirectory.boolValue, 209 | "\(message): \(path)", file: file, line: line) 210 | } 211 | 212 | func assertFileSize( 213 | at path: String, 214 | expectedSize: Int64, 215 | tolerance: Int64 = 0, 216 | file: StaticString = #file, 217 | line: UInt = #line 218 | ) { 219 | do { 220 | let attributes = try FileManager.default.attributesOfItem(atPath: path) 221 | let actualSize = attributes[.size] as? Int64 ?? 0 222 | 223 | if tolerance == 0 { 224 | XCTAssertEqual(actualSize, expectedSize, "File size mismatch at \(path)", file: file, line: line) 225 | } else { 226 | let difference = abs(actualSize - expectedSize) 227 | XCTAssertLessThanOrEqual(difference, tolerance, 228 | "File size \(actualSize) not within tolerance \(tolerance) of expected \(expectedSize)", 229 | file: file, line: line) 230 | } 231 | } catch { 232 | XCTFail("Failed to get file attributes for \(path): \(error)", file: file, line: line) 233 | } 234 | } 235 | 236 | // MARK: - JSON Assertions 237 | 238 | func assertValidJSON( 239 | _ data: Data, 240 | message: String = "Data should be valid JSON", 241 | file: StaticString = #file, 242 | line: UInt = #line 243 | ) { 244 | do { 245 | _ = try JSONSerialization.jsonObject(with: data, options: []) 246 | } catch { 247 | XCTFail("\(message): \(error)", file: file, line: line) 248 | } 249 | } 250 | 251 | func assertJSONContains( 252 | _ data: Data, 253 | key: String, 254 | expectedValue: Any, 255 | file: StaticString = #file, 256 | line: UInt = #line 257 | ) { 258 | do { 259 | guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { 260 | XCTFail("JSON should be a dictionary", file: file, line: line) 261 | return 262 | } 263 | 264 | guard let actualValue = json[key] else { 265 | XCTFail("JSON should contain key '\(key)'", file: file, line: line) 266 | return 267 | } 268 | 269 | // Use string comparison for flexibility 270 | let actualString = String(describing: actualValue) 271 | let expectedString = String(describing: expectedValue) 272 | XCTAssertEqual(actualString, expectedString, 273 | "JSON value mismatch for key '\(key)'", file: file, line: line) 274 | } catch { 275 | XCTFail("Failed to parse JSON: \(error)", file: file, line: line) 276 | } 277 | } 278 | 279 | // MARK: - Performance Assertions 280 | 281 | func assertPerformance( 282 | description: String = "Performance test", 283 | expectedDuration: TimeInterval, 284 | tolerance: TimeInterval = 0.1, 285 | file: StaticString = #file, 286 | line: UInt = #line, 287 | _ block: () throws -> Void 288 | ) rethrows { 289 | let startTime = CFAbsoluteTimeGetCurrent() 290 | try block() 291 | let duration = CFAbsoluteTimeGetCurrent() - startTime 292 | 293 | let difference = abs(duration - expectedDuration) 294 | XCTAssertLessThanOrEqual(difference, tolerance, 295 | "\(description): Duration \(duration)s not within \(tolerance)s of expected \(expectedDuration)s", 296 | file: file, line: line) 297 | } 298 | 299 | func assertFasterThan( 300 | _ maxDuration: TimeInterval, 301 | description: String = "Performance test", 302 | file: StaticString = #file, 303 | line: UInt = #line, 304 | _ block: () throws -> Void 305 | ) rethrows { 306 | let startTime = CFAbsoluteTimeGetCurrent() 307 | try block() 308 | let duration = CFAbsoluteTimeGetCurrent() - startTime 309 | 310 | XCTAssertLessThan(duration, maxDuration, 311 | "\(description): Duration \(duration)s exceeded maximum \(maxDuration)s", 312 | file: file, line: line) 313 | } 314 | 315 | // MARK: - Error Assertions 316 | 317 | func assertThrowsError( 318 | _ expression: @autoclosure () throws -> T, 319 | expectedErrorType: Error.Type, 320 | message: String = "Expected specific error type", 321 | file: StaticString = #file, 322 | line: UInt = #line 323 | ) { 324 | do { 325 | _ = try expression() 326 | XCTFail("\(message): Expected error of type \(expectedErrorType)", file: file, line: line) 327 | } catch { 328 | XCTAssertTrue(type(of: error) == expectedErrorType, 329 | "\(message): Expected \(expectedErrorType), got \(type(of: error))", 330 | file: file, line: line) 331 | } 332 | } 333 | 334 | func assertNoThrow( 335 | _ expression: @autoclosure () throws -> T, 336 | message: String = "Expression should not throw", 337 | file: StaticString = #file, 338 | line: UInt = #line 339 | ) -> T? { 340 | do { 341 | return try expression() 342 | } catch { 343 | XCTFail("\(message): Unexpected error: \(error)", file: file, line: line) 344 | return nil 345 | } 346 | } 347 | } 348 | 349 | // MARK: - Custom Matcher Functions 350 | 351 | func beEmpty() -> (T) -> Bool { 352 | return { collection in 353 | return collection.isEmpty 354 | } 355 | } 356 | 357 | func haveCount(_ expectedCount: Int) -> (T) -> Bool { 358 | return { collection in 359 | return collection.count == expectedCount 360 | } 361 | } 362 | 363 | func contain(_ expectedItem: T) -> ([T]) -> Bool { 364 | return { collection in 365 | return collection.contains(expectedItem) 366 | } 367 | } 368 | 369 | // MARK: - Test Result Validation Helpers 370 | 371 | extension XCTestCase { 372 | 373 | func validateImageAssetArray(_ images: [ImageAsset], expectedCount: Int? = nil, file: StaticString = #file, line: UInt = #line) { 374 | if let expectedCount = expectedCount { 375 | XCTAssertEqual(images.count, expectedCount, "Image array count mismatch", file: file, line: line) 376 | } 377 | 378 | for image in images { 379 | XCTAssertFalse(image.name.isEmpty, "Image name should not be empty", file: file, line: line) 380 | XCTAssertFalse(image.path.isEmpty, "Image path should not be empty", file: file, line: line) 381 | XCTAssertGreaterThan(image.size, 0, "Image size should be positive", file: file, line: line) 382 | } 383 | } 384 | 385 | func validateUsedImageNames(_ names: Set, expectedCount: Int? = nil, file: StaticString = #file, line: UInt = #line) { 386 | if let expectedCount = expectedCount { 387 | XCTAssertEqual(names.count, expectedCount, "Used image names count mismatch", file: file, line: line) 388 | } 389 | 390 | for name in names { 391 | XCTAssertFalse(name.isEmpty, "Image name should not be empty", file: file, line: line) 392 | XCTAssertFalse(name.contains(" "), "Image name should not contain double spaces", file: file, line: line) 393 | } 394 | } 395 | } -------------------------------------------------------------------------------- /iOSImageOptimizer/implementation-plan.md: -------------------------------------------------------------------------------- 1 | # iOSImageOptimizer MVP - Weekend Project Plan 2 | 3 | ## MVP Scope (Completable in 1 Weekend) 4 | 5 | ### Core Features 6 | 1. **Scan** - Find all images in an iOS project 7 | 2. **Analyze** - Detect unused images and oversized images 8 | 3. **Report** - Generate a simple report with findings 9 | 10 | ### What We're Building 11 | A CLI tool that answers two critical questions: 12 | - Which images in my project are never used? 13 | - Which images are larger than they need to be? 14 | 15 | ## Project Setup (30 minutes) 16 | 17 | ### 1. Create Project Structure 18 | ```bash 19 | mkdir iOSImageOptimizer 20 | cd iOSImageOptimizer 21 | swift package init --type executable 22 | ``` 23 | 24 | ### 2. Update Package.swift 25 | ```swift 26 | // swift-tools-version: 5.9 27 | import PackageDescription 28 | 29 | let package = Package( 30 | name: "iOSImageOptimizer", 31 | platforms: [.macOS(.v13)], 32 | dependencies: [ 33 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 34 | .package(url: "https://github.com/JohnSundell/Files", from: "4.0.0"), 35 | .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0") 36 | ], 37 | targets: [ 38 | .executableTarget( 39 | name: "iOSImageOptimizer", 40 | dependencies: [ 41 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 42 | "Files", 43 | "Rainbow" 44 | ] 45 | ) 46 | ] 47 | ) 48 | ``` 49 | 50 | ## Implementation Plan 51 | 52 | ### Step 1: CLI Structure (30 minutes) 53 | 54 | ```swift 55 | // Sources/iOSImageOptimizer/main.swift 56 | import ArgumentParser 57 | import Foundation 58 | import Files 59 | import Rainbow 60 | 61 | @main 62 | struct IOSImageOptimizer: ParsableCommand { 63 | static let configuration = CommandConfiguration( 64 | commandName: "ios-image-optimizer", 65 | abstract: "Find unused and oversized images in iOS projects" 66 | ) 67 | 68 | @Argument(help: "Path to iOS project directory") 69 | var projectPath: String 70 | 71 | @Flag(name: .shortAndLong, help: "Show detailed output") 72 | var verbose = false 73 | 74 | @Flag(name: .shortAndLong, help: "Export findings to JSON") 75 | var json = false 76 | 77 | mutating func run() throws { 78 | print("🔍 Analyzing iOS project at: \(projectPath)".cyan) 79 | 80 | let analyzer = ProjectAnalyzer(projectPath: projectPath, verbose: verbose) 81 | let report = try analyzer.analyze() 82 | 83 | if json { 84 | try report.exportJSON() 85 | } else { 86 | report.printToConsole() 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | ### Step 2: Image Scanner (1 hour) 93 | 94 | ```swift 95 | // Sources/iOSImageOptimizer/ImageScanner.swift 96 | import Foundation 97 | import Files 98 | 99 | struct ImageAsset { 100 | let name: String 101 | let path: String 102 | let size: Int64 103 | let type: ImageType 104 | let scale: Int? 105 | 106 | enum ImageType { 107 | case png, jpeg, pdf, svg 108 | case assetCatalog(scale: String) 109 | } 110 | } 111 | 112 | class ImageScanner { 113 | private let projectPath: String 114 | 115 | init(projectPath: String) { 116 | self.projectPath = projectPath 117 | } 118 | 119 | func scanForImages() throws -> [ImageAsset] { 120 | var images: [ImageAsset] = [] 121 | 122 | let folder = try Folder(path: projectPath) 123 | 124 | // Scan for standalone images 125 | images.append(contentsOf: try scanStandaloneImages(in: folder)) 126 | 127 | // Scan asset catalogs 128 | images.append(contentsOf: try scanAssetCatalogs(in: folder)) 129 | 130 | return images 131 | } 132 | 133 | private func scanStandaloneImages(in folder: Folder) throws -> [ImageAsset] { 134 | var images: [ImageAsset] = [] 135 | 136 | for file in folder.files.recursive { 137 | guard let imageType = imageType(for: file.extension ?? "") else { continue } 138 | 139 | // Skip images in .xcassets 140 | if file.path.contains(".xcassets") { continue } 141 | 142 | let asset = ImageAsset( 143 | name: file.nameExcludingExtension, 144 | path: file.path, 145 | size: Int64(file.size ?? 0), 146 | type: imageType, 147 | scale: extractScale(from: file.name) 148 | ) 149 | images.append(asset) 150 | } 151 | 152 | return images 153 | } 154 | 155 | private func scanAssetCatalogs(in folder: Folder) throws -> [ImageAsset] { 156 | var images: [ImageAsset] = [] 157 | 158 | for subfolder in folder.subfolders.recursive { 159 | if subfolder.name.hasSuffix(".xcassets") { 160 | images.append(contentsOf: try scanAssetCatalog(subfolder)) 161 | } 162 | } 163 | 164 | return images 165 | } 166 | 167 | private func scanAssetCatalog(_ catalog: Folder) throws -> [ImageAsset] { 168 | var images: [ImageAsset] = [] 169 | 170 | for imageSet in catalog.subfolders.recursive { 171 | if imageSet.name.hasSuffix(".imageset") { 172 | let assetName = imageSet.name.replacingOccurrences(of: ".imageset", with: "") 173 | 174 | for file in imageSet.files { 175 | if let imageType = imageType(for: file.extension ?? "") { 176 | let scale = extractScale(from: file.name) ?? 1 177 | let asset = ImageAsset( 178 | name: assetName, 179 | path: file.path, 180 | size: Int64(file.size ?? 0), 181 | type: .assetCatalog(scale: "\(scale)x"), 182 | scale: scale 183 | ) 184 | images.append(asset) 185 | } 186 | } 187 | } 188 | } 189 | 190 | return images 191 | } 192 | 193 | private func imageType(for extension: String) -> ImageAsset.ImageType? { 194 | switch extension.lowercased() { 195 | case "png": return .png 196 | case "jpg", "jpeg": return .jpeg 197 | case "pdf": return .pdf 198 | case "svg": return .svg 199 | default: return nil 200 | } 201 | } 202 | 203 | private func extractScale(from filename: String) -> Int? { 204 | if filename.contains("@3x") { return 3 } 205 | if filename.contains("@2x") { return 2 } 206 | return 1 207 | } 208 | } 209 | ``` 210 | 211 | ### Step 3: Usage Detector (1.5 hours) 212 | 213 | ```swift 214 | // Sources/iOSImageOptimizer/UsageDetector.swift 215 | import Foundation 216 | import Files 217 | 218 | class UsageDetector { 219 | private let projectPath: String 220 | private let verbose: Bool 221 | 222 | init(projectPath: String, verbose: Bool = false) { 223 | self.projectPath = projectPath 224 | self.verbose = verbose 225 | } 226 | 227 | func findUsedImageNames() throws -> Set { 228 | var usedImages = Set() 229 | 230 | let folder = try Folder(path: projectPath) 231 | 232 | // Scan Swift files 233 | for file in folder.files.recursive where file.extension == "swift" { 234 | let content = try file.readAsString() 235 | usedImages.formUnion(findImageReferences(in: content, fileType: .swift)) 236 | } 237 | 238 | // Scan Objective-C files 239 | for file in folder.files.recursive where file.extension == "m" || file.extension == "mm" { 240 | let content = try file.readAsString() 241 | usedImages.formUnion(findImageReferences(in: content, fileType: .objectiveC)) 242 | } 243 | 244 | // Scan Storyboards and XIBs 245 | for file in folder.files.recursive where file.extension == "storyboard" || file.extension == "xib" { 246 | let content = try file.readAsString() 247 | usedImages.formUnion(findImageReferences(in: content, fileType: .interfaceBuilder)) 248 | } 249 | 250 | if verbose { 251 | print("Found \(usedImages.count) unique image references") 252 | } 253 | 254 | return usedImages 255 | } 256 | 257 | private enum FileType { 258 | case swift, objectiveC, interfaceBuilder 259 | } 260 | 261 | private func findImageReferences(in content: String, fileType: FileType) -> Set { 262 | var references = Set() 263 | 264 | let patterns: [String] 265 | 266 | switch fileType { 267 | case .swift: 268 | patterns = [ 269 | #"UIImage\s*\(\s*named:\s*"([^"]+)""#, // UIImage(named: "...") 270 | #"Image\s*\(\s*"([^"]+)""#, // SwiftUI Image("...") 271 | #"UIImage\s*\(\s*systemName:\s*"([^"]+)""#, // SF Symbols 272 | #"#imageLiteral\s*\(\s*resourceName:\s*"([^"]+)""# // Image literals 273 | ] 274 | 275 | case .objectiveC: 276 | patterns = [ 277 | #"\[UIImage\s+imageNamed:\s*@"([^"]+)""#, // [UIImage imageNamed:@"..."] 278 | #"imageWithContentsOfFile:[^"]*@"([^"]+)""# // imageWithContentsOfFile 279 | ] 280 | 281 | case .interfaceBuilder: 282 | patterns = [ 283 | #"image="([^"]+)""#, // image="..." 284 | #"imageName="([^"]+)""#, // imageName="..." 285 | #"]+name="([^"]+)""# // 286 | ] 287 | } 288 | 289 | for pattern in patterns { 290 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 291 | let matches = regex?.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) ?? [] 292 | 293 | for match in matches { 294 | if let range = Range(match.range(at: 1), in: content) { 295 | let imageName = String(content[range]) 296 | references.insert(imageName) 297 | } 298 | } 299 | } 300 | 301 | return references 302 | } 303 | } 304 | ``` 305 | 306 | ### Step 4: Project Analyzer (1 hour) 307 | 308 | ```swift 309 | // Sources/iOSImageOptimizer/ProjectAnalyzer.swift 310 | import Foundation 311 | import Rainbow 312 | 313 | struct AnalysisReport { 314 | let totalImages: Int 315 | let unusedImages: [ImageAsset] 316 | let oversizedImages: [OversizedImage] 317 | let totalSize: Int64 318 | let wastedSize: Int64 319 | 320 | struct OversizedImage { 321 | let asset: ImageAsset 322 | let reason: String 323 | let potentialSaving: Int64 324 | } 325 | 326 | func printToConsole() { 327 | print("\n📊 " + "Analysis Complete".bold) 328 | print("=" * 50) 329 | 330 | print("\n📈 " + "Summary:".bold) 331 | print(" Total images: \(totalImages)") 332 | print(" Unused images: \(unusedImages.count)".red) 333 | print(" Oversized images: \(oversizedImages.count)".yellow) 334 | print(" Total image size: \(formatBytes(totalSize))") 335 | print(" Potential savings: \(formatBytes(wastedSize))".green) 336 | 337 | if !unusedImages.isEmpty { 338 | print("\n🗑️ " + "Unused Images:".bold.red) 339 | for image in unusedImages.prefix(10) { 340 | print(" ❌ \(image.name) (\(formatBytes(image.size)))") 341 | } 342 | if unusedImages.count > 10 { 343 | print(" ... and \(unusedImages.count - 10) more") 344 | } 345 | } 346 | 347 | if !oversizedImages.isEmpty { 348 | print("\n⚠️ " + "Oversized Images:".bold.yellow) 349 | for oversized in oversizedImages.prefix(10) { 350 | print(" ⚡ \(oversized.asset.name)") 351 | print(" \(oversized.reason)") 352 | print(" Potential saving: \(formatBytes(oversized.potentialSaving))".dim) 353 | } 354 | if oversizedImages.count > 10 { 355 | print(" ... and \(oversizedImages.count - 10) more") 356 | } 357 | } 358 | 359 | print("\n✨ " + "Recommendations:".bold.green) 360 | if wastedSize > 0 { 361 | print(" → Run 'ios-image-optimizer clean' to remove unused images") 362 | print(" → Run 'ios-image-optimizer optimize' to resize oversized images") 363 | } else { 364 | print(" → Your project is well optimized! 🎉") 365 | } 366 | } 367 | 368 | func exportJSON() throws { 369 | let jsonData = try JSONEncoder().encode(self) 370 | print(String(data: jsonData, encoding: .utf8) ?? "{}") 371 | } 372 | 373 | private func formatBytes(_ bytes: Int64) -> String { 374 | let formatter = ByteCountFormatter() 375 | formatter.countStyle = .file 376 | return formatter.string(fromByteCount: bytes) 377 | } 378 | } 379 | 380 | extension AnalysisReport: Encodable {} 381 | extension AnalysisReport.OversizedImage: Encodable {} 382 | 383 | class ProjectAnalyzer { 384 | private let projectPath: String 385 | private let verbose: Bool 386 | 387 | // Size thresholds 388 | private let maxImageSize: Int64 = 500 * 1024 // 500KB 389 | private let max1xSize: Int64 = 100 * 1024 // 100KB for 1x 390 | private let max2xSize: Int64 = 200 * 1024 // 200KB for 2x 391 | private let max3xSize: Int64 = 400 * 1024 // 400KB for 3x 392 | 393 | init(projectPath: String, verbose: Bool = false) { 394 | self.projectPath = projectPath 395 | self.verbose = verbose 396 | } 397 | 398 | func analyze() throws -> AnalysisReport { 399 | // Step 1: Find all images 400 | if verbose { print("Scanning for images...") } 401 | let scanner = ImageScanner(projectPath: projectPath) 402 | let allImages = try scanner.scanForImages() 403 | 404 | if verbose { print("Found \(allImages.count) images") } 405 | 406 | // Step 2: Find used images 407 | if verbose { print("Detecting image usage...") } 408 | let detector = UsageDetector(projectPath: projectPath, verbose: verbose) 409 | let usedImageNames = try detector.findUsedImageNames() 410 | 411 | // Step 3: Identify unused images 412 | let unusedImages = allImages.filter { image in 413 | !usedImageNames.contains(image.name) 414 | } 415 | 416 | // Step 4: Identify oversized images 417 | let oversizedImages = findOversizedImages(in: allImages) 418 | 419 | // Step 5: Calculate metrics 420 | let totalSize = allImages.reduce(0) { $0 + $1.size } 421 | let wastedSize = unusedImages.reduce(0) { $0 + $1.size } + 422 | oversizedImages.reduce(0) { $0 + $1.potentialSaving } 423 | 424 | return AnalysisReport( 425 | totalImages: allImages.count, 426 | unusedImages: unusedImages, 427 | oversizedImages: oversizedImages, 428 | totalSize: totalSize, 429 | wastedSize: wastedSize 430 | ) 431 | } 432 | 433 | private func findOversizedImages(in images: [ImageAsset]) -> [AnalysisReport.OversizedImage] { 434 | var oversized: [AnalysisReport.OversizedImage] = [] 435 | 436 | for image in images { 437 | // Check against scale-specific thresholds 438 | let threshold: Int64 439 | let scale = image.scale ?? 1 440 | 441 | switch scale { 442 | case 1: threshold = max1xSize 443 | case 2: threshold = max2xSize 444 | case 3: threshold = max3xSize 445 | default: threshold = maxImageSize 446 | } 447 | 448 | if image.size > threshold { 449 | let potentialSaving = image.size - threshold 450 | let reason = "Image exceeds \(scale)x size limit (\(formatBytes(image.size)) > \(formatBytes(threshold)))" 451 | 452 | oversized.append(AnalysisReport.OversizedImage( 453 | asset: image, 454 | reason: reason, 455 | potentialSaving: potentialSaving 456 | )) 457 | } 458 | } 459 | 460 | return oversized 461 | } 462 | 463 | private func formatBytes(_ bytes: Int64) -> String { 464 | let formatter = ByteCountFormatter() 465 | formatter.countStyle = .file 466 | return formatter.string(fromByteCount: bytes) 467 | } 468 | } 469 | ``` 470 | 471 | ### Step 5: Helper Extensions (30 minutes) 472 | 473 | ```swift 474 | // Sources/iOSImageOptimizer/Extensions.swift 475 | import Foundation 476 | 477 | extension String { 478 | static func * (left: String, right: Int) -> String { 479 | return String(repeating: left, count: right) 480 | } 481 | } 482 | 483 | extension ImageAsset: Encodable { 484 | enum CodingKeys: String, CodingKey { 485 | case name, path, size, type, scale 486 | } 487 | 488 | func encode(to encoder: Encoder) throws { 489 | var container = encoder.container(keyedBy: CodingKeys.self) 490 | try container.encode(name, forKey: .name) 491 | try container.encode(path, forKey: .path) 492 | try container.encode(size, forKey: .size) 493 | try container.encode(scale, forKey: .scale) 494 | 495 | let typeString: String 496 | switch type { 497 | case .png: typeString = "png" 498 | case .jpeg: typeString = "jpeg" 499 | case .pdf: typeString = "pdf" 500 | case .svg: typeString = "svg" 501 | case .assetCatalog(let scale): typeString = "assetCatalog-\(scale)" 502 | } 503 | try container.encode(typeString, forKey: .type) 504 | } 505 | } 506 | ``` 507 | 508 | ## Build and Test (30 minutes) 509 | 510 | ### Build the project 511 | ```bash 512 | swift build -c release 513 | ``` 514 | 515 | ### Test on a sample project 516 | ```bash 517 | .build/release/ios-image-optimizer /path/to/your/ios/project 518 | ``` 519 | 520 | ### Install globally (optional) 521 | ```bash 522 | cp .build/release/ios-image-optimizer /usr/local/bin/ 523 | ``` 524 | 525 | ## Sample Output 526 | 527 | ``` 528 | 🔍 Analyzing iOS project at: /Users/you/MyApp 529 | 📊 Analysis Complete 530 | ================================================== 531 | 532 | 📈 Summary: 533 | Total images: 342 534 | Unused images: 47 535 | Oversized images: 23 536 | Total image size: 45.3 MB 537 | Potential savings: 12.8 MB 538 | 539 | 🗑️ Unused Images: 540 | ❌ old_logo (234 KB) 541 | ❌ test_background (1.2 MB) 542 | ❌ unused_icon (45 KB) 543 | ... and 44 more 544 | 545 | ⚠️ Oversized Images: 546 | ⚡ splash_screen 547 | Image exceeds 3x size limit (2.1 MB > 400 KB) 548 | Potential saving: 1.7 MB 549 | ⚡ hero_background 550 | Image exceeds 2x size limit (890 KB > 200 KB) 551 | Potential saving: 690 KB 552 | ... and 21 more 553 | 554 | ✨ Recommendations: 555 | → Run 'ios-image-optimizer clean' to remove unused images 556 | → Run 'ios-image-optimizer optimize' to resize oversized images 557 | ``` 558 | 559 | ## Next Steps (After MVP) 560 | 561 | Once you have this working, you can add: 562 | 1. **Clean command** - Actually remove unused images (with backup) 563 | 2. **Optimize command** - Resize oversized images 564 | 3. **Watch mode** - Monitor changes in real-time 565 | 4. **Better detection** - Handle more edge cases 566 | 5. **SF Symbol suggestions** - Find replaceable icons 567 | 568 | ## Tips for the Weekend 569 | 570 | 1. **Start with the scanner** - Get it finding images first 571 | 2. **Test on your own projects** - Real data helps find edge cases 572 | 3. **Don't over-engineer** - Focus on working code over perfect code 573 | 4. **Add features incrementally** - Each step should produce value 574 | 575 | This MVP gives you a working tool that provides immediate value. You'll be able to find unused and oversized images in any iOS project, which alone can save significant app size! --------------------------------------------------------------------------------