Libs/SymbolModIconLib.psm1

<#
.SYNOPSIS
    SymbolMod icon extraction and bitmap utilities.

.DESCRIPTION
    Reads icon resources directly from PE files via LoadLibraryEx +
    EnumResourceNames + LoadResource so PNG-encoded 256x256 frames are
    preserved exactly as stored. Bitmap conversion goes through the
    Image.FromStream(saved-ico-bytes) trick which keeps full alpha gradients.

    Public functions:
      Get-SymbolModIconCount - Number of icon groups in a PE file
      Get-SymbolModIconRawData - Raw .ico file bytes per icon group
      Get-SymbolModIcon - Icon at a given group index
      Get-SymbolModAllIcons - All icons
      Get-SymbolModIconAtSize - Pick the highest-quality frame (or by index)
      Get-SymbolModBitmapAtSize - Same as Get-SymbolModIconAtSize but returns Bitmap
      Split-SymbolModIcon - Split a multi-size icon into single-size icons
      Get-SymbolModIconBitCount - Bits-per-pixel of an icon's first frame
      ConvertTo-SymbolModIconBitmap - Convert icon to bitmap, alpha preserved
      Resize-SymbolModImage - Resize an image with HighQualityBicubic

.NOTES
    (c) Andreas Nickel, 2024
    https://www.nick-it.de
#>


Add-Type -AssemblyName System.Drawing

# C# is needed only for the EnumResourceNames callback: a delegate invoked

if (-not ('SymbolMod.IconLib.IconReader' -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;

namespace SymbolMod.IconLib {
    [UnmanagedFunctionPointer(CallingConvention.Winapi, SetLastError = true, CharSet = CharSet.Unicode)]
    internal delegate bool ResNameProc(IntPtr m, IntPtr t, IntPtr n, IntPtr p);

    public static class IconReader {
        const uint LOAD_AS_DATA = 0x2;
        static readonly IntPtr RT_ICON = (IntPtr)3, RT_GROUP = (IntPtr)14;

        [DllImport("kernel32", CharSet = CharSet.Unicode)] static extern IntPtr LoadLibraryEx(string f, IntPtr h, uint d);
        [DllImport("kernel32")] static extern bool FreeLibrary(IntPtr m);
        [DllImport("kernel32", CharSet = CharSet.Unicode)] static extern bool EnumResourceNames(IntPtr m, IntPtr t, ResNameProc c, IntPtr p);
        [DllImport("kernel32", CharSet = CharSet.Unicode)] static extern IntPtr FindResource(IntPtr m, IntPtr n, IntPtr t);
        [DllImport("kernel32")] static extern IntPtr LoadResource(IntPtr m, IntPtr r);
        [DllImport("kernel32")] static extern IntPtr LockResource(IntPtr r);
        [DllImport("kernel32")] static extern uint SizeofResource(IntPtr m, IntPtr r);

        static byte[] ReadRes(IntPtr m, IntPtr t, IntPtr n) {
            var info = FindResource(m, n, t);
            if (info == IntPtr.Zero) throw new Win32Exception();
            var len = (int)SizeofResource(m, info);
            var buf = new byte[len];
            Marshal.Copy(LockResource(LoadResource(m, info)), buf, 0, len);
            return buf;
        }

        static byte[] BuildIco(IntPtr m, IntPtr name) {
            var dir = ReadRes(m, RT_GROUP, name);
            var count = BitConverter.ToUInt16(dir, 4);
            var total = 6 + 16 * count;
            for (int i = 0; i < count; i++) total += BitConverter.ToInt32(dir, 6 + 14 * i + 8);

            using (var ms = new MemoryStream(total))
            using (var bw = new BinaryWriter(ms)) {
                bw.Write(dir, 0, 6);
                int off = 6 + 16 * count;
                for (int i = 0; i < count; i++) {
                    ushort id = BitConverter.ToUInt16(dir, 6 + 14 * i + 12);
                    var pic = ReadRes(m, RT_ICON, (IntPtr)id);
                    bw.Seek(6 + 16 * i, SeekOrigin.Begin);
                    bw.Write(dir, 6 + 14 * i, 8);
                    bw.Write(pic.Length);
                    bw.Write(off);
                    bw.Seek(off, SeekOrigin.Begin);
                    bw.Write(pic, 0, pic.Length);
                    off += pic.Length;
                }
                return ms.ToArray();
            }
        }

        public static byte[][] Read(string fileName) {
            var m = LoadLibraryEx(fileName, IntPtr.Zero, LOAD_AS_DATA);
            if (m == IntPtr.Zero) throw new Win32Exception();
            try {
                var icos = new List<byte[]>();
                EnumResourceNames(m, RT_GROUP, (h, t, n, p) => {
                    icos.Add(BuildIco(h, n));
                    return true;
                }, IntPtr.Zero);
                return icos.ToArray();
            }
            finally { if (m != IntPtr.Zero) FreeLibrary(m); }
        }
    }
}
'@
 -Language CSharp
}

# --- Internal PowerShell helper ---------------------------------------------

# Reflects on the private Icon.iconData field so Save() preserves the original
# bytes (PNG frames in particular). Falls back to Save when the field is null
# (e.g. for icons created from HICON via FromHandle).
function Get-SymbolModIconDataInternal {
    param([Parameter(Mandatory)] [System.Drawing.Icon] $Icon)
    $fi = [System.Drawing.Icon].GetField('iconData',
        [System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::NonPublic)
    if ($null -ne $fi) {
        $data = $fi.GetValue($Icon)
        if ($null -ne $data) { return , [byte[]]$data }
    }
    $ms = New-Object System.IO.MemoryStream
    try {
        $Icon.Save($ms)
        return , $ms.ToArray()
    }
    finally {
        $ms.Dispose()
    }
}

# --- Public functions --------------------------------------------------------

function Get-SymbolModIconCount {
<#
.SYNOPSIS
    Returns the number of icon groups (RT_GROUP_ICON) in a PE file.
#>

    [CmdletBinding()]
    [OutputType([int])]
    param([Parameter(Mandatory = $true, Position = 0)] [string] $FileName)

    if (-not (Test-Path $FileName)) {
        Write-Error "File not found: $FileName"
        return 0
    }
    try {
        return [SymbolMod.IconLib.IconReader]::Read($FileName).Length
    }
    catch {
        Write-Error "Failed to read icons from '$FileName': $_"
        return 0
    }
}

function Get-SymbolModIconRawData {
<#
.SYNOPSIS
    Returns raw .ico bytes for one icon group from a PE file.
#>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)] [string] $FileName,
        [Parameter(Position = 1)] [int] $Index = 0
    )
    if (-not (Test-Path $FileName)) {
        Write-Error "File not found: $FileName"
        return $null
    }
    try {
        $all = [SymbolMod.IconLib.IconReader]::Read($FileName)
        if ($Index -lt 0 -or $Index -ge $all.Length) {
            Write-Error "Index $Index is out of range (count=$($all.Length))."
            return $null
        }
        return , $all[$Index]
    }
    catch {
        Write-Error "Failed to extract raw icon data: $_"
        return $null
    }
}

function Get-SymbolModIcon {
<#
.SYNOPSIS
    Extracts one icon group from a PE file as a System.Drawing.Icon.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Icon])]
    param(
        [Parameter(Mandatory = $true, Position = 0)] [string] $FileName,
        [Parameter(Position = 1)] [int] $Index = 0
    )
    $bytes = Get-SymbolModIconRawData -FileName $FileName -Index $Index
    if ($null -eq $bytes) { return $null }

    $ms = New-Object System.IO.MemoryStream(, $bytes)
    try {
        Write-Verbose "Built Icon from group index $Index of '$FileName'"
        return New-Object System.Drawing.Icon($ms)
    }
    finally {
        $ms.Dispose()
    }
}

function Get-SymbolModAllIcons {
<#
.SYNOPSIS
    Extracts every icon group from a PE file.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Icon[]])]
    param([Parameter(Mandatory = $true, Position = 0)] [string] $FileName)

    if (-not (Test-Path $FileName)) {
        Write-Error "File not found: $FileName"
        return @()
    }
    try {
        $all = [SymbolMod.IconLib.IconReader]::Read($FileName)
        $result = New-Object 'System.Collections.Generic.List[System.Drawing.Icon]'
        foreach ($bytes in $all) {
            $ms = New-Object System.IO.MemoryStream(, $bytes)
            try   { $result.Add((New-Object System.Drawing.Icon($ms))) | Out-Null }
            finally { $ms.Dispose() }
        }
        return , $result.ToArray()
    }
    catch {
        Write-Error "Failed to extract icons: $_"
        return @()
    }
}

function Split-SymbolModIcon {
<#
.SYNOPSIS
    Splits a multi-size icon into individual single-size icons.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Icon[]])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.Drawing.Icon] $Icon
    )
    process {
        try {
            $src = Get-SymbolModIconDataInternal -Icon $Icon
            $count = [BitConverter]::ToUInt16($src, 4)
            $result = New-Object 'System.Collections.Generic.List[System.Drawing.Icon]'

            for ($i = 0; $i -lt $count; $i++) {
                $length = [BitConverter]::ToInt32($src, 6 + 16 * $i + 8)
                $offset = [BitConverter]::ToInt32($src, 6 + 16 * $i + 12)

                $ms = New-Object System.IO.MemoryStream(6 + 16 + $length)
                $bw = New-Object System.IO.BinaryWriter($ms)
                try {
                    $bw.Write($src, 0, 4)                  # reserved + type
                    $bw.Write([UInt16]1)                   # count = 1
                    $bw.Write($src, 6 + 16 * $i, 12)       # ICONDIRENTRY (no offset)
                    $bw.Write([Int32]22)                   # dwImageOffset
                    $bw.Write($src, $offset, $length)      # payload
                    $ms.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
                    $result.Add((New-Object System.Drawing.Icon($ms))) | Out-Null
                }
                finally {
                    $bw.Dispose()
                }
            }
            return , $result.ToArray()
        }
        catch {
            Write-Error "Split failed: $_"
            return @()
        }
    }
}

function Get-SymbolModIconBitCount {
<#
.SYNOPSIS
    Returns bits-per-pixel of an icon's first frame (PNG-aware).
#>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.Drawing.Icon] $Icon
    )
    process {
        try {
            $data = Get-SymbolModIconDataInternal -Icon $Icon

            # Detect a PNG payload at offset 22 (signature 89 50 4E 47 0D 0A 1A 0A
            # followed by IHDR chunk header 00 00 00 0D 49 48 44 52).
            if ($data.Length -ge 51 -and
                $data[22] -eq 0x89 -and $data[23] -eq 0x50 -and $data[24] -eq 0x4E -and $data[25] -eq 0x47 -and
                $data[26] -eq 0x0D -and $data[27] -eq 0x0A -and $data[28] -eq 0x1A -and $data[29] -eq 0x0A -and
                $data[30] -eq 0x00 -and $data[31] -eq 0x00 -and $data[32] -eq 0x00 -and $data[33] -eq 0x0D -and
                $data[34] -eq 0x49 -and $data[35] -eq 0x48 -and $data[36] -eq 0x44 -and $data[37] -eq 0x52) {

                # IHDR: bit depth at byte 46, colour type at 47.
                switch ($data[47]) {
                    0       { return $data[46] }
                    2       { return $data[46] * 3 }
                    3       { return $data[46] }
                    4       { return $data[46] * 2 }
                    6       { return $data[46] * 4 }
                }
            }
            # ICONDIRENTRY.wBitCount at file offset 12.
            return [BitConverter]::ToUInt16($data, 12)
        }
        catch {
            Write-Error "GetBitCount failed: $_"
            return 0
        }
    }
}

function ConvertTo-SymbolModIconBitmap {
<#
.SYNOPSIS
    Converts an Icon to a Bitmap, preserving the alpha channel.
.DESCRIPTION
    Saves the icon to a memory stream and re-decodes it via Image.FromStream.
    Works correctly for PNG-encoded large frames and 32bpp DIBs as long as the
    Icon was constructed from raw .ico bytes (Get-SymbolModIcon does this).
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Bitmap])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.Drawing.Icon] $Icon
    )
    process {
        $ms = New-Object System.IO.MemoryStream
        try {
            $Icon.Save($ms)
            $img = [System.Drawing.Image]::FromStream($ms)
            try   { return New-Object System.Drawing.Bitmap($img) }
            finally { $img.Dispose() }
        }
        catch {
            Write-Error "ToBitmap failed: $_"
            return $null
        }
        finally {
            $ms.Dispose()
        }
    }
}

function Resize-SymbolModImage {
<#
.SYNOPSIS
    Resizes an image using HighQualityBicubic interpolation.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Bitmap])]
    param(
        [Parameter(Mandatory = $true, Position = 0)] [System.Drawing.Image] $Image,
        [Parameter(Mandatory = $true, Position = 1)] [System.Drawing.Size]  $Size,
        [Parameter(Position = 2)] [bool] $PreserveAspectRatio = $true
    )
    if ($PreserveAspectRatio) {
        $ratio = [Math]::Min(
            [double]$Size.Width  / $Image.Width,
            [double]$Size.Height / $Image.Height)
        $w = [int][Math]::Round($Image.Width  * $ratio)
        $h = [int][Math]::Round($Image.Height * $ratio)
    } else {
        $w = $Size.Width
        $h = $Size.Height
    }

    $bmp = New-Object System.Drawing.Bitmap($w, $h, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
    $g   = [System.Drawing.Graphics]::FromImage($bmp)
    try {
        $g.InterpolationMode  = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
        $g.SmoothingMode      = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
        $g.PixelOffsetMode    = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
        $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
        $g.DrawImage($Image, 0, 0, $w, $h)
    }
    finally {
        $g.Dispose()
    }
    return $bmp
}

function Get-SymbolModIconAtSize {
<#
.SYNOPSIS
    Picks the largest frame from an icon group, or one at a specific frame index.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Icon])]
    param(
        [Parameter(Mandatory = $true, Position = 0)] [string] $FileName,
        [Parameter(Position = 1)] [int] $GroupIndex = 0,
        [int] $FrameIndex = -1
    )
    $icon = Get-SymbolModIcon -FileName $FileName -Index $GroupIndex
    if ($null -eq $icon) { return $null }

    $frames = Split-SymbolModIcon -Icon $icon
    if ($null -eq $frames -or $frames.Count -eq 0) {
        Write-Verbose "No splittable frames - returning the original icon."
        return $icon
    }

    if ($FrameIndex -ge 0) {
        if ($FrameIndex -ge $frames.Count) {
            Write-Error "FrameIndex $FrameIndex exceeds frame count $($frames.Count)."
            return $null
        }
        return $frames[$FrameIndex]
    }

    $best = $frames | Sort-Object { [int]$_.Width } -Descending | Select-Object -First 1
    Write-Verbose "Selected largest frame: $($best.Width)x$($best.Height)"
    return $best
}

function Get-SymbolModBitmapAtSize {
<#
.SYNOPSIS
    Same as Get-SymbolModIconAtSize but returns the result already converted to Bitmap.
#>

    [CmdletBinding()]
    [OutputType([System.Drawing.Bitmap])]
    param(
        [Parameter(Mandatory = $true, Position = 0)] [string] $FileName,
        [Parameter(Position = 1)] [int] $GroupIndex = 0,
        [int] $FrameIndex = -1
    )
    $icon = Get-SymbolModIconAtSize -FileName $FileName -GroupIndex $GroupIndex -FrameIndex $FrameIndex
    if ($null -eq $icon) { return $null }
    try {
        return ConvertTo-SymbolModIconBitmap -Icon $icon
    }
    finally {
        $icon.Dispose()
    }
}

Export-ModuleMember -Function @(
    'Get-SymbolModIconCount',
    'Get-SymbolModIconRawData',
    'Get-SymbolModIcon',
    'Get-SymbolModAllIcons',
    'Get-SymbolModIconAtSize',
    'Get-SymbolModBitmapAtSize',
    'Split-SymbolModIcon',
    'Get-SymbolModIconBitCount',
    'ConvertTo-SymbolModIconBitmap',
    'Resize-SymbolModImage'
)