├── 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!
--------------------------------------------------------------------------------