Created
January 26, 2026 22:35
-
-
Save sombraguerrero/cf7c521d33987975f648cf11311ac9f8 to your computer and use it in GitHub Desktop.
Icon Encoder (Windows)
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
| <# | |
| .SYNOPSIS | |
| Convert a bitmap (BMP/PNG/JPG etc.) into a valid .ico file with proper ICO headers. | |
| .DESCRIPTION | |
| Builds a correct ICO structure: | |
| - ICONDIR (6 bytes) | |
| - N x ICONDIRENTRY (16 bytes each) | |
| - Image blobs, each consisting of: | |
| * BITMAPINFOHEADER (40 bytes, biHeight = height*2) | |
| * XOR bitmap (32bpp BGRA, bottom-up) | |
| * AND mask (1bpp, bottom-up, rows padded to 4-byte boundaries) | |
| If -Sizes is present, the script rescales the input image to multiple sizes and | |
| packs all sizes into a single .ico. If -Sizes is omitted, the original image | |
| size is used (single-entry ICO). | |
| .PARAMETER InputPath | |
| Path to the source bitmap (e.g., .bmp). Any format supported by System.Drawing is fine. | |
| .PARAMETER OutputPath | |
| Path to the output .ico. | |
| .PARAMETER Sizes | |
| Optional list of sizes (e.g., 16,32,48,64,128,256). If omitted, uses original image size. | |
| .EXAMPLE | |
| .\New-IcoFromBitmap.ps1 -InputPath .\logo.bmp -OutputPath .\logo.ico | |
| .EXAMPLE | |
| .\New-IcoFromBitmap.ps1 -InputPath .\logo.bmp -OutputPath .\logo_multi.ico -Sizes 16,32,48,64,128,256 | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory=$true)] | |
| [string]$InputPath, | |
| [Parameter(Mandatory=$true)] | |
| [string]$OutputPath, | |
| [int[]]$Sizes | |
| ) | |
| # --- Helpers to ensure System.Drawing is available on Windows | |
| try { | |
| Add-Type -AssemblyName System.Drawing -ErrorAction Stop | |
| } catch { | |
| throw "Failed to load System.Drawing. This script requires Windows with .NET Framework / System.Drawing." | |
| } | |
| # Dispose-safety helper | |
| function Use-Object([IDisposable]$obj, [ScriptBlock]$script) { | |
| try { & $script $obj } finally { if ($obj) { $obj.Dispose() } } | |
| } | |
| # Create a high-quality resized clone at 32bpp ARGB | |
| function New-ResizedBitmap32([System.Drawing.Bitmap]$src, [int]$size) { | |
| $bmp = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) | |
| $g = [System.Drawing.Graphics]::FromImage($bmp) | |
| try { | |
| $g.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy | |
| $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality | |
| $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic | |
| $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality | |
| $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality | |
| $g.DrawImage($src, 0, 0, $size, $size) | |
| } finally { | |
| $g.Dispose() | |
| } | |
| return $bmp | |
| } | |
| # Build a DIB blob for ICO (BITMAPINFOHEADER + XOR + AND) | |
| function Get-IcoDibBytes([System.Drawing.Bitmap]$bmp32) { | |
| # Ensure 32bpp surface | |
| if ($bmp32.PixelFormat -ne [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) { | |
| $tmp = New-Object System.Drawing.Bitmap($bmp32.Width, $bmp32.Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) | |
| $g = [System.Drawing.Graphics]::FromImage($tmp) | |
| try { | |
| $g.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy | |
| $g.DrawImage($bmp32, 0, 0, $bmp32.Width, $bmp32.Height) | |
| } finally { $g.Dispose() } | |
| $bmp32.Dispose() | |
| $bmp32 = $tmp | |
| } | |
| $w = $bmp32.Width | |
| $h = $bmp32.Height | |
| if ($w -gt 256 -or $h -gt 256) { | |
| throw "ICO entries must be <= 256x256 (got ${w}x${h}). Resize or use -Sizes." | |
| } | |
| $rect = [System.Drawing.Rectangle]::new(0,0,$w,$h) | |
| $lock = $bmp32.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) | |
| try { | |
| $stride = [Math]::Abs($lock.Stride) | |
| $srcBytes = New-Object byte[] ($stride * $h) | |
| [System.Runtime.InteropServices.Marshal]::Copy($lock.Scan0, $srcBytes, 0, $srcBytes.Length) | |
| } finally { | |
| $bmp32.UnlockBits($lock) | |
| } | |
| # XOR: 32bpp BGRA bottom-up, width*4 bytes per row (no extra padding beyond stride cropping) | |
| $xorRowLen = $w * 4 | |
| $xorBytes = New-Object byte[] ($xorRowLen * $h) | |
| for ($y = 0; $y -lt $h; $y++) { | |
| $srcRowOff = $y * $stride | |
| $dstRowOff = ($h - 1 - $y) * $xorRowLen | |
| [Array]::Copy($srcBytes, $srcRowOff, $xorBytes, $dstRowOff, $xorRowLen) | |
| } | |
| # AND: 1bpp mask bottom-up, 1 bit per pixel, rows padded to 32-bit boundary | |
| $andRowBytes = [int]([Math]::Ceiling($w / 32.0) * 4) # ((w + 31) / 32) * 4 | |
| $andBytes = New-Object byte[] ($andRowBytes * $h) | |
| for ($y = 0; $y -lt $h; $y++) { | |
| $dstRowOff = ($h - 1 - $y) * $andRowBytes | |
| # Pack bits: MSB first per byte, 1 = transparent, 0 = opaque | |
| for ($x = 0; $x -lt $w; $x++) { | |
| $srcIdx = ($y * $stride) + ($x * 4) | |
| $a = $srcBytes[$srcIdx + 3] # BGRA -> A | |
| $bit = if ($a -eq 0) { 1 } else { 0 } | |
| $byteIndex = [int]([Math]::Floor($x / 8)) | |
| $bitPos = 7 - ($x % 8) | |
| if ($bit -eq 1) { | |
| $andBytes[$dstRowOff + $byteIndex] = $andBytes[$dstRowOff + $byteIndex] -bor (1 -shl $bitPos) | |
| } | |
| } | |
| # Remaining bits in last byte are left as 0; overall row already padded to 4 bytes | |
| } | |
| # BITMAPINFOHEADER (40 bytes), with biHeight = imageHeight * 2 (XOR + AND) | |
| $ms = New-Object System.IO.MemoryStream | |
| $bw = New-Object System.IO.BinaryWriter($ms) | |
| # biSize | |
| $bw.Write([int]40) | |
| # biWidth | |
| $bw.Write([int]$w) | |
| # biHeight = h * 2 (ICO DIB: XOR+AND vertical stack) | |
| $bw.Write([int]($h * 2)) | |
| # biPlanes | |
| $bw.Write([int16]1) | |
| # biBitCount | |
| $bw.Write([int16]32) | |
| # biCompression = BI_RGB (0) | |
| $bw.Write([int]0) | |
| # biSizeImage (XOR only; can be 0 for BI_RGB, but we set it) | |
| $bw.Write([int]($xorBytes.Length)) | |
| # biXPelsPerMeter | |
| $bw.Write([int]0) | |
| # biYPelsPerMeter | |
| $bw.Write([int]0) | |
| # biClrUsed | |
| $bw.Write([int]0) | |
| # biClrImportant | |
| $bw.Write([int]0) | |
| # Append XOR then AND | |
| $bw.Write($xorBytes) | |
| $bw.Write($andBytes) | |
| $bw.Flush() | |
| $dib = $ms.ToArray() | |
| $bw.Dispose() | |
| $ms.Dispose() | |
| # Return DIB blob | |
| return ,@($dib, $w, $h) | |
| } | |
| # Load input image | |
| if (-not (Test-Path -LiteralPath $InputPath)) { | |
| throw "Input file not found: $InputPath" | |
| } | |
| $baseBmp = $null | |
| try { | |
| $baseBmp = [System.Drawing.Image]::FromFile($InputPath) | |
| } catch { | |
| throw "Unable to load image: $InputPath. Ensure it's a valid bitmap." | |
| } | |
| # Prepare list of bitmaps to pack into ICO | |
| $bitmaps = New-Object System.Collections.Generic.List[System.Drawing.Bitmap] | |
| try { | |
| if ($Sizes -and $Sizes.Count -gt 0) { | |
| $unique = $Sizes | Sort-Object -Unique | |
| foreach ($s in $unique) { | |
| if ($s -lt 1 -or $s -gt 256) { | |
| throw "Invalid size $s. ICO entries must be between 1 and 256." | |
| } | |
| $resized = New-ResizedBitmap32 -src $baseBmp -size $s | |
| $bitmaps.Add($resized) | Out-Null | |
| } | |
| } else { | |
| # Use original size, but ensure <=256 | |
| $w = $baseBmp.Width | |
| $h = $baseBmp.Height | |
| if ($w -gt 256 -or $h -gt 256) { | |
| Write-Warning "Source is ${w}x${h}. ICO limits entries to max 256. Will resize down to 256x256." | |
| $max = [Math]::Min([Math]::Max($w, $h), 256) | |
| $resized = New-ResizedBitmap32 -src $baseBmp -size $max | |
| $bitmaps.Add($resized) | Out-Null | |
| } else { | |
| # Ensure 32bpp clone | |
| $clone = New-ResizedBitmap32 -src $baseBmp -size $w | |
| $bitmaps.Add($clone) | Out-Null | |
| } | |
| } | |
| } finally { | |
| $baseBmp.Dispose() | |
| } | |
| # Build all DIBs | |
| $entries = @() | |
| $totalCount = $bitmaps.Count | |
| foreach ($bmp in $bitmaps) { | |
| $dibInfo = Get-IcoDibBytes -bmp32 $bmp | |
| $dib = $dibInfo[0] | |
| $w = [int]$dibInfo[1] | |
| $h = [int]$dibInfo[2] | |
| $entries += [PSCustomObject]@{ | |
| Width = $w | |
| Height = $h | |
| DibBytes = $dib | |
| } | |
| } | |
| # Write ICO: ICONDIR + N*ICONDIRENTRY + DIB blobs | |
| $fs = [System.IO.File]::Open($OutputPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) | |
| $bw = New-Object System.IO.BinaryWriter($fs) | |
| try { | |
| # ICONDIR | |
| $bw.Write([int16]0) # Reserved | |
| $bw.Write([int16]1) # Type = 1 (icon) | |
| $bw.Write([int16]$totalCount) # Count | |
| # Precompute offsets | |
| $offset = 6 + (16 * $totalCount) # header + all entries | |
| # Write ICONDIRENTRY for each | |
| foreach ($e in $entries) { | |
| $w = [byte]([Math]::Min($e.Width, 255)) | |
| $h = [byte]([Math]::Min($e.Height, 255)) | |
| # ICO stores 256 as 0 | |
| $bWidth = if ($e.Width -eq 256) { [byte]0 } else { $w } | |
| $bHeight = if ($e.Height -eq 256) { [byte]0 } else { $h } | |
| $bw.Write([byte]$bWidth) # bWidth | |
| $bw.Write([byte]$bHeight) # bHeight | |
| $bw.Write([byte]0) # bColorCount (0 for >= 8bpp) | |
| $bw.Write([byte]0) # bReserved | |
| $bw.Write([int16]1) # wPlanes | |
| $bw.Write([int16]32) # wBitCount | |
| $bw.Write([int]$e.DibBytes.Length) # dwBytesInRes | |
| $bw.Write([int]$offset) # dwImageOffset | |
| $offset += $e.DibBytes.Length | |
| } | |
| # Write all DIB blobs | |
| foreach ($e in $entries) { | |
| $bw.Write($e.DibBytes) | |
| } | |
| $bw.Flush() | |
| } finally { | |
| $bw.Dispose() | |
| $fs.Dispose() | |
| # Dispose bitmaps | |
| foreach ($b in $bitmaps) { $b.Dispose() } | |
| } | |
| Write-Host "ICO created: $OutputPath" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment