Created
March 7, 2025 06:19
-
-
Save lukemmtt/8ef5100d58fbd970065cb47709eb89ee to your computer and use it in GitHub Desktop.
Generate windows store assets and ICO
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import os | |
| import sys | |
| import subprocess | |
| import io | |
| import json | |
| import math | |
| from pathlib import Path | |
| from PIL import Image, ImageDraw, ImageFilter | |
| import numpy as np | |
| # Icon specifications for Windows Store | |
| # Format: name, size, required, apply_rounding | |
| ASSETS = [ | |
| # Required assets | |
| # Square44x44Logo (App icon) | |
| {"name": "Square44x44Logo.scale-100.png", "size": (44, 44), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.scale-125.png", "size": (55, 55), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.scale-150.png", "size": (66, 66), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.scale-200.png", "size": (88, 88), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.scale-400.png", "size": (176, 176), "required": True, "apply_rounding": True}, | |
| # Target size variants | |
| {"name": "Square44x44Logo.targetsize-16.png", "size": (16, 16), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.targetsize-24.png", "size": (24, 24), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.targetsize-32.png", "size": (32, 32), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.targetsize-48.png", "size": (48, 48), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.targetsize-256.png", "size": (256, 256), "required": True, "apply_rounding": True}, | |
| # Unplated variants (still with blue background for brand consistency) | |
| {"name": "Square44x44Logo.altform-unplated_targetsize-16.png", "size": (16, 16), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-unplated_targetsize-24.png", "size": (24, 24), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-unplated_targetsize-32.png", "size": (32, 32), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-unplated_targetsize-48.png", "size": (48, 48), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-unplated_targetsize-256.png", "size": (256, 256), "required": True, "apply_rounding": True}, | |
| # Light unplated variants (still with blue background for brand consistency) | |
| {"name": "Square44x44Logo.altform-lightunplated_targetsize-16.png", "size": (16, 16), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-lightunplated_targetsize-24.png", "size": (24, 24), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-lightunplated_targetsize-32.png", "size": (32, 32), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-lightunplated_targetsize-48.png", "size": (48, 48), "required": True, "apply_rounding": True}, | |
| {"name": "Square44x44Logo.altform-lightunplated_targetsize-256.png", "size": (256, 256), "required": True, "apply_rounding": True}, | |
| # Square150x150Logo (Medium Tile) | |
| {"name": "Square150x150Logo.scale-100.png", "size": (150, 150), "required": True, "apply_rounding": True}, | |
| {"name": "Square150x150Logo.scale-125.png", "size": (188, 188), "required": True, "apply_rounding": True}, | |
| {"name": "Square150x150Logo.scale-150.png", "size": (225, 225), "required": True, "apply_rounding": True}, | |
| {"name": "Square150x150Logo.scale-200.png", "size": (300, 300), "required": True, "apply_rounding": True}, | |
| {"name": "Square150x150Logo.scale-400.png", "size": (600, 600), "required": True, "apply_rounding": True}, | |
| # StoreLogo | |
| {"name": "StoreLogo.scale-100.png", "size": (50, 50), "required": True, "apply_rounding": True}, | |
| {"name": "StoreLogo.scale-125.png", "size": (63, 63), "required": True, "apply_rounding": True}, | |
| {"name": "StoreLogo.scale-150.png", "size": (75, 75), "required": True, "apply_rounding": True}, | |
| {"name": "StoreLogo.scale-200.png", "size": (100, 100), "required": True, "apply_rounding": True}, | |
| {"name": "StoreLogo.scale-400.png", "size": (200, 200), "required": True, "apply_rounding": True}, | |
| # Recommended assets | |
| # SmallTile (71x71) | |
| {"name": "SmallTile.scale-100.png", "size": (71, 71), "required": False, "apply_rounding": True}, | |
| {"name": "SmallTile.scale-125.png", "size": (89, 89), "required": False, "apply_rounding": True}, | |
| {"name": "SmallTile.scale-150.png", "size": (107, 107), "required": False, "apply_rounding": True}, | |
| {"name": "SmallTile.scale-200.png", "size": (142, 142), "required": False, "apply_rounding": True}, | |
| {"name": "SmallTile.scale-400.png", "size": (284, 284), "required": False, "apply_rounding": True}, | |
| # Wide310x150Logo (Wide Tile) | |
| {"name": "Wide310x150Logo.scale-100.png", "size": (310, 150), "required": False, "apply_rounding": True}, | |
| {"name": "Wide310x150Logo.scale-125.png", "size": (388, 188), "required": False, "apply_rounding": True}, | |
| {"name": "Wide310x150Logo.scale-150.png", "size": (465, 225), "required": False, "apply_rounding": True}, | |
| {"name": "Wide310x150Logo.scale-200.png", "size": (620, 300), "required": False, "apply_rounding": True}, | |
| {"name": "Wide310x150Logo.scale-400.png", "size": (1240, 600), "required": False, "apply_rounding": True}, | |
| # LargeTile (310x310) | |
| {"name": "LargeTile.scale-100.png", "size": (310, 310), "required": False, "apply_rounding": True}, | |
| {"name": "LargeTile.scale-125.png", "size": (388, 388), "required": False, "apply_rounding": True}, | |
| {"name": "LargeTile.scale-150.png", "size": (465, 465), "required": False, "apply_rounding": True}, | |
| {"name": "LargeTile.scale-200.png", "size": (620, 620), "required": False, "apply_rounding": True}, | |
| {"name": "LargeTile.scale-400.png", "size": (1240, 1240), "required": False, "apply_rounding": True}, | |
| # SplashScreen (620x300) | |
| {"name": "SplashScreen.scale-100.png", "size": (620, 300), "required": False, "apply_rounding": False}, | |
| {"name": "SplashScreen.scale-125.png", "size": (775, 375), "required": False, "apply_rounding": False}, | |
| {"name": "SplashScreen.scale-150.png", "size": (930, 450), "required": False, "apply_rounding": False}, | |
| {"name": "SplashScreen.scale-200.png", "size": (1240, 600), "required": False, "apply_rounding": False}, | |
| {"name": "SplashScreen.scale-400.png", "size": (2480, 1200), "required": False, "apply_rounding": False}, | |
| # BadgeLogo (24x24) | |
| {"name": "BadgeLogo.scale-100.png", "size": (24, 24), "required": False, "apply_rounding": True}, | |
| {"name": "BadgeLogo.scale-125.png", "size": (30, 30), "required": False, "apply_rounding": True}, | |
| {"name": "BadgeLogo.scale-150.png", "size": (36, 36), "required": False, "apply_rounding": True}, | |
| {"name": "BadgeLogo.scale-200.png", "size": (48, 48), "required": False, "apply_rounding": True}, | |
| {"name": "BadgeLogo.scale-400.png", "size": (96, 96), "required": False, "apply_rounding": True}, | |
| ] | |
| # Standard sizes for Windows ICO file (Microsoft best practices) | |
| ICO_SIZES = [16, 32, 48, 64, 128, 256] | |
| # TimeFinder background color (light blue) | |
| BACKGROUND_COLOR = (0x85, 0xEB, 0xFF, 0xFF) # #85EBFF | |
| def create_consistent_rounded_mask(size, radius): | |
| """ | |
| Create a mask with smooth rounded corners using PIL's ImageDraw. | |
| This creates aesthetically pleasing rounded corners with anti-aliasing. | |
| Args: | |
| size: Tuple of (width, height) | |
| radius: Corner radius in pixels | |
| Returns: | |
| A mask image with rounded corners | |
| """ | |
| width, height = size | |
| # Create a white background (fully opaque) | |
| mask = Image.new("L", size, 0) | |
| draw = ImageDraw.Draw(mask) | |
| # Draw a rounded rectangle with the specified radius | |
| # The rectangle is drawn from (0, 0) to (width-1, height-1) | |
| draw.rounded_rectangle( | |
| [(0, 0), (width-1, height-1)], | |
| radius=radius, | |
| fill=255 | |
| ) | |
| return mask | |
| def create_asset( | |
| source_image_path, | |
| target_size, | |
| output_path=None, | |
| corner_radius=None, | |
| padding=0, | |
| background_color=None | |
| ): | |
| """ | |
| Create an asset from a source image, resizing it to the target size and applying | |
| rounded corners if specified. | |
| Args: | |
| source_image_path: Path to the source image | |
| target_size: Tuple of (width, height) for the target size | |
| output_path: Path to save the resulting image (optional) | |
| corner_radius: Radius of the rounded corners (optional) | |
| padding: Padding to add around the image (optional) | |
| background_color: Background color to use (optional) | |
| Returns: | |
| The resulting image and a dictionary with information about the image | |
| """ | |
| # Open the source image | |
| source = Image.open(source_image_path).convert("RGBA") | |
| # Get the target dimensions | |
| target_width, target_height = target_size | |
| # Calculate the size to resize the source image to | |
| resize_width = target_width - (padding * 2) | |
| resize_height = target_height - (padding * 2) | |
| # Resize the source image while maintaining aspect ratio | |
| source_width, source_height = source.size | |
| source_aspect = source_width / source_height | |
| target_aspect = resize_width / resize_height | |
| if source_aspect > target_aspect: | |
| # Source is wider than target | |
| new_width = resize_width | |
| new_height = int(resize_width / source_aspect) | |
| else: | |
| # Source is taller than target | |
| new_height = resize_height | |
| new_width = int(resize_height * source_aspect) | |
| resized = source.resize((new_width, new_height), Image.LANCZOS) | |
| # Create a new image with the target size | |
| if background_color: | |
| result = Image.new("RGBA", target_size, background_color) | |
| else: | |
| result = Image.new("RGBA", target_size, (0, 0, 0, 0)) | |
| # Calculate the position to paste the resized image | |
| paste_x = (target_width - new_width) // 2 | |
| paste_y = (target_height - new_height) // 2 | |
| # Paste the resized image onto the result | |
| result.paste(resized, (paste_x, paste_y), resized) | |
| # If no corner radius is specified, return the result | |
| if corner_radius == 0: | |
| if output_path: | |
| result.save(output_path) | |
| return { | |
| "size": target_size, | |
| "corner_radius": 0, | |
| "path": output_path | |
| } | |
| return result, {"size": target_size, "corner_radius": 0} | |
| # Calculate corner radius based on Windows guidelines if not specified | |
| if corner_radius is None: | |
| # Scale based on Microsoft's guideline of 2px radius for 48x48 (exterior curves) | |
| scale_factor = min(target_width, target_height) / 48.0 | |
| # Use round() instead of int() to properly round the value instead of truncating | |
| corner_radius = max(1, round(2 * scale_factor)) | |
| # Create a mask with consistent rounded corners | |
| mask = create_consistent_rounded_mask(target_size, corner_radius) | |
| # Apply the mask to the result | |
| final = Image.new("RGBA", target_size, (0, 0, 0, 0)) | |
| final.paste(result, (0, 0), mask) | |
| # Save the resulting image if output path is provided | |
| if output_path: | |
| final.save(output_path) | |
| return { | |
| "size": target_size, | |
| "corner_radius": corner_radius, | |
| "path": output_path | |
| } | |
| # Otherwise return the image and info | |
| return final, {"size": target_size, "corner_radius": corner_radius} | |
| def create_ico_file(source_icon, output_path): | |
| """ | |
| Create a Windows ICO file with multiple sizes according to Microsoft best practices. | |
| Args: | |
| source_icon: Path to the source icon | |
| output_path: Path to save the ICO file | |
| Returns: | |
| Dictionary with information about the created ICO file | |
| """ | |
| print(f"\nGenerating Windows ICO file with {len(ICO_SIZES)} sizes...") | |
| # Create a list to store all the images for the ICO file | |
| ico_images = [] | |
| # Create each size for the ICO file using the same asset creation function | |
| for size in ICO_SIZES: | |
| print(f" - Processing {size}x{size} icon size") | |
| # Use the create_asset function to generate the image with consistent styling | |
| image, info = create_asset( | |
| source_icon, | |
| (size, size), | |
| corner_radius=(0 if size < 32 else None), | |
| background_color=BACKGROUND_COLOR | |
| ) | |
| # Add the image to our list | |
| ico_images.append(image) | |
| # Create the directory if it doesn't exist | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| # Save the ICO file using a more reliable approach | |
| # We'll save each size as a separate image in memory and then combine them | |
| # First, create a list of (size, image_bytes) tuples | |
| size_images = [] | |
| for img in ico_images: | |
| img_byte_arr = io.BytesIO() | |
| img.save(img_byte_arr, format='PNG') | |
| size_images.append((img.size, img_byte_arr.getvalue())) | |
| # Now write the ICO file manually | |
| with open(output_path, 'wb') as ico_file: | |
| # Write ICO header (6 bytes) | |
| # 0-1: Reserved (0) | |
| # 2-3: Image type (1 for ICO) | |
| # 4-5: Number of images | |
| ico_file.write(bytes([0, 0, 1, 0, len(size_images), 0])) | |
| # Calculate offset to image data | |
| # Header (6 bytes) + (16 bytes per directory entry) | |
| offset = 6 + (16 * len(size_images)) | |
| # Write directory entries (16 bytes each) | |
| for i, (size, image_bytes) in enumerate(size_images): | |
| width, height = size | |
| # Width (1 byte, 0 for 256) | |
| ico_file.write(bytes([width if width < 256 else 0])) | |
| # Height (1 byte, 0 for 256) | |
| ico_file.write(bytes([height if height < 256 else 0])) | |
| # Color palette (1 byte, 0 for no palette) | |
| ico_file.write(bytes([0])) | |
| # Reserved (1 byte, 0) | |
| ico_file.write(bytes([0])) | |
| # Color planes (2 bytes, 1 for PNG) | |
| ico_file.write(bytes([1, 0])) | |
| # Bits per pixel (2 bytes, 32 for RGBA PNG) | |
| ico_file.write(bytes([32, 0])) | |
| # Image size in bytes (4 bytes, little-endian) | |
| size_bytes = len(image_bytes).to_bytes(4, byteorder='little') | |
| ico_file.write(size_bytes) | |
| # Image offset from start of file (4 bytes, little-endian) | |
| offset_bytes = offset.to_bytes(4, byteorder='little') | |
| ico_file.write(offset_bytes) | |
| # Update offset for next image | |
| offset += len(image_bytes) | |
| # Write image data | |
| for _, image_bytes in size_images: | |
| ico_file.write(image_bytes) | |
| print(f"✓ Created ICO file with {len(ICO_SIZES)} sizes") | |
| print(f" Saved to: {output_path}") | |
| return { | |
| "sizes": [size for size in ICO_SIZES], | |
| "path": output_path | |
| } | |
| def generate_store_assets(source_icon, assets_dir): | |
| """ | |
| Generate all Windows Store assets from the source icon. | |
| Args: | |
| source_icon: Path to the source icon | |
| assets_dir: Directory to save the assets | |
| """ | |
| # Count total assets | |
| total_assets = len(ASSETS) | |
| required_assets = sum(1 for asset in ASSETS if asset["required"]) | |
| print(f"\nGenerating {total_assets} assets ({required_assets} required, {total_assets - required_assets} recommended)") | |
| # Generate each asset | |
| for i, asset in enumerate(ASSETS, 1): | |
| output_path = assets_dir / asset["name"] | |
| size_str = f"{asset['size'][0]}x{asset['size'][1]}" | |
| required_str = "Required" if asset["required"] else "Recommended" | |
| print(f"\n[{i}/{total_assets}] Generating {asset['name']} ({size_str}) - {required_str}") | |
| # Create the asset | |
| try: | |
| # Set corner radius to 0 if rounding should not be applied | |
| corner_radius = 0 if not asset.get('apply_rounding', True) else None | |
| result = create_asset( | |
| source_icon, | |
| asset['size'], | |
| output_path, | |
| corner_radius=corner_radius, | |
| background_color=BACKGROUND_COLOR if not asset.get('transparent', False) else None | |
| ) | |
| print(f"✓ Created {asset['name']}") | |
| print(f" Size: {result['size'][0]}x{result['size'][1]}px") | |
| if corner_radius != 0: | |
| print(f" Corner radius: {result['corner_radius']}px") | |
| print(f" Saved to: {result['path']}") | |
| except Exception as e: | |
| print(f"Error generating {asset['name']}: {e}") | |
| if asset["required"]: | |
| print("Warning: Failed to generate a required asset!") | |
| def generate_ico_file(source_icon, output_path): | |
| """ | |
| Generate an ICO file with all required sizes. | |
| Args: | |
| source_icon: Path to the source icon | |
| output_path: Path to save the ICO file | |
| Returns: | |
| True if successful, False otherwise | |
| """ | |
| try: | |
| print(f"Generating Windows ICO file with {len(ICO_SIZES)} sizes...") | |
| # Create a list to store the images for each size | |
| images = [] | |
| # Process each size | |
| for size in ICO_SIZES: | |
| print(f" - Processing {size}x{size} icon size") | |
| # Create the asset for this size | |
| img, info = create_asset( | |
| source_icon, | |
| (size, size), | |
| corner_radius=(0 if size < 32 else None), | |
| background_color=BACKGROUND_COLOR | |
| ) | |
| # Add the image to the list | |
| images.append(img) | |
| # Save the ICO file with all sizes | |
| images[0].save( | |
| output_path, | |
| format="ICO", | |
| sizes=[(img.width, img.height) for img in images], | |
| append_images=images[1:] | |
| ) | |
| print(f"ICO file saved to: {output_path}") | |
| return True | |
| except Exception as e: | |
| print(f"Error generating ICO file: {e}") | |
| return False | |
| def generate_assets(source_icon, assets_dir, resources_dir): | |
| """ | |
| Generate all required and recommended assets for Windows Store. | |
| Args: | |
| source_icon: Path to the source icon | |
| assets_dir: Directory to save the assets | |
| resources_dir: Directory to save the resources | |
| Returns: | |
| True if all required assets were generated, False otherwise | |
| """ | |
| # Create the directories if they don't exist | |
| os.makedirs(assets_dir, exist_ok=True) | |
| os.makedirs(resources_dir, exist_ok=True) | |
| # Generate the ICO file | |
| ico_path = os.path.join(resources_dir, "app_icon.ico") | |
| ico_success = generate_ico_file(source_icon, ico_path) | |
| if not ico_success: | |
| print("Warning: Failed to generate the Windows application icon!") | |
| # Count the number of required and recommended assets | |
| required_count = sum(1 for asset in ASSETS if asset.get("required", False)) | |
| recommended_count = len(ASSETS) - required_count | |
| print(f"\nGenerating {len(ASSETS)} assets ({required_count} required, {recommended_count} recommended)") | |
| print() | |
| # Track if all required assets were generated | |
| all_required_generated = True | |
| # Generate each asset | |
| for i, asset in enumerate(ASSETS, 1): | |
| asset_name = asset["name"] | |
| asset_size = f"{asset['size'][0]}x{asset['size'][1]}" | |
| asset_type = "Required" if asset.get("required", False) else "Recommended" | |
| print(f"[{i}/{len(ASSETS)}] Generating {asset_name} ({asset_size}) - {asset_type}") | |
| # Create the output path | |
| output_path = os.path.join(assets_dir, asset_name) | |
| try: | |
| # Create the asset | |
| corner_radius = None | |
| if asset.get("no_rounding", False): | |
| corner_radius = 0 | |
| result = create_asset( | |
| source_icon, | |
| asset['size'], | |
| output_path, | |
| corner_radius=corner_radius, | |
| background_color=BACKGROUND_COLOR if not asset.get("transparent", False) else None | |
| ) | |
| if isinstance(result, dict): | |
| print(f" - Saved to: {result['path']}") | |
| print(f" - Size: {result['size'][0]}x{result['size'][1]}") | |
| print(f" - Corner radius: {result['corner_radius']}") | |
| except Exception as e: | |
| print(f"Error generating {asset_name}: {e}") | |
| if asset.get("required", False): | |
| all_required_generated = False | |
| print("Warning: Failed to generate a required asset!") | |
| print("\nAssets generation complete!") | |
| print(f"All assets saved to: {assets_dir}") | |
| if ico_success: | |
| print(f"Windows application icon (ICO) saved to: {resources_dir}") | |
| print("\nYour app now has all the necessary assets for Windows Store submission!") | |
| print("The Windows application icon (ICO) has been generated according to Microsoft's best practices.") | |
| return all_required_generated and ico_success | |
| def main(): | |
| # Hardcode the path to the source icon | |
| source_icon = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "app_icon", "complete_icon.png") | |
| # Validate the source icon exists | |
| if not os.path.exists(source_icon): | |
| print(f"Error: Source icon '{source_icon}' not found.") | |
| return 1 | |
| # Define the assets directory using absolute path | |
| assets_dir = Path("/Users/lukemmtt/Developer/timefinder_app/app/windows/runner/Assets") | |
| # Define the resources directory for the ICO file | |
| resources_dir = Path("/Users/lukemmtt/Developer/timefinder_app/app/windows/runner/resources") | |
| # Create the directories if they don't exist | |
| os.makedirs(assets_dir, exist_ok=True) | |
| os.makedirs(resources_dir, exist_ok=True) | |
| print(f"Assets directory: {assets_dir}") | |
| print(f"Resources directory: {resources_dir}") | |
| # Generate the ICO file | |
| ico_path = resources_dir / "app_icon.ico" | |
| try: | |
| create_ico_file(source_icon, ico_path) | |
| except Exception as e: | |
| print(f"Error generating ICO file: {e}") | |
| print("Warning: Failed to generate the Windows application icon!") | |
| # Generate all Windows Store assets | |
| generate_store_assets(source_icon, assets_dir) | |
| print("\nAssets generation complete!") | |
| print(f"All assets saved to: {assets_dir}") | |
| print(f"Windows application icon (ICO) saved to: {resources_dir}") | |
| print("\nYour app now has all the necessary assets for Windows Store submission!") | |
| print("The Windows application icon (ICO) has been generated according to Microsoft's best practices.") | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment