Last active
July 28, 2026 10:42
-
-
Save scriptingstudio/cf863c8a02f8d1d41b4ae3f3a72eac80 to your computer and use it in GitHub Desktop.
Simple SVG to ICO converter
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 | |
| SVG to Windows ICO converter. | |
| .DESCRIPTION | |
| The script uses Inkscape app to convert SVG to PNG and then ensembles PNGs to a single ICO using .NET. | |
| Key features: | |
| - Multisize ICO | |
| - Just one Inkscape instance per multiple size conversions, i.e. a conversion time is independent on amount of sizes | |
| - Control over SVG exported area | |
| - Ability to replace colors in SVG | |
| - Ability to fetch SVG from internet | |
| - Ability to model an output file name | |
| - There are input and output integrity checks | |
| - Animated progress bar for Inkscape runtime | |
| - The image quality of the output ICO is notably better than any online converter can provide | |
| There are two editions: | |
| - .NET (preferred) : *minimal* ICO size, external assemblies (System.Drawing) | |
| - File (demo) : larger ICO size, no external assemblies, few lines of code less | |
| .PARAMETER Path | |
| Mandatory. Position is 0. Specifies a filepath to the SVG. Path can be a web-URL. SVG from internet is saved in the user's downloads. | |
| .PARAMETER Output | |
| Specifies a directory where the exports should be stored. If no directory is specified, ICO will be exported to the user's TEMP directory. To select the source's directory specify "*". | |
| .PARAMETER Rename | |
| Specifies an alternative name for the output file. If a new name starts with "+" it will join the input file base name. | |
| .PARAMETER Sizes | |
| Specifies a pixel size of the exported icon. The icon will be square, so if you want a 16x16 export, it would be -Sizes 16. | |
| Standard values are 16, 20, 24, 32, 40, 48, 64, 72, 96, 128, 192, and 256. To select the standard size range specify -1. To disable this filter specify 0 or leave it behind. | |
| .PARAMETER NoCrop | |
| Indicates that exported area is the *page*. Otherwise exported area is the *drawing* (not page), i.e. the bounding box of all objects of the SVG, and the exported PNG image will display all the visible objects of the SVG without margins or cropping. This is an Inkscape's option. | |
| .PARAMETER Margin | |
| Reserved. Extension of the extraction area; currently has no effect for PNG format. The size of the margin is specified in SVG units. 1 unit equals 1 bitmap pixel, at the default export resolution of 96 dpi. This is an Inkscape's option. | |
| .PARAMETER ReColor | |
| Specifies an array of substitution color pairs in RGB notation. For example, "aaaaaa=555555","ff22cc=eeeeef","029c0f", where the instance without "=" would replace the rest of colors in SVG. The character "#" is added automatically. | |
| As SVG is regular XML all colors found (in "fill" and "stroke" attributes) are replaced with the specified. A copy of the source SVG as resource for ICO is created. The source SVG stays intact. | |
| Note, that this way of recoloring is more preferred for flat and monochrome palettes. | |
| .PARAMETER Keep | |
| This parameter specifies not delete png files, but then open the user's temp folder in Windows Explorer. | |
| .PARAMETER NoProgress | |
| Indicates to disable showing a progress bar. | |
| .PARAMETER BarType | |
| Specifies the type of the progress bar. Valid values are growth, fill, bounce, tide. If not specified, random is selected. | |
| .PARAMETER BarColor | |
| Specifies the color of the progress bar. Default is none, i.e. current. Valid values are standard console colors. Run and see standards: [ConsoleColor]::GetValues([ConsoleColor]). | |
| .NOTES | |
| Requires : PowerShell 5+, Inkscape | |
| Version : 1.8.0 | |
| LastUpdate : 2026-July-28 | |
| .LINK | |
| https://gist.github.com/scriptingstudio/cf863c8a02f8d1d41b4ae3f3a72eac80 | |
| https://inkscape.org/doc/inkscape-man.html | |
| https://wiki.inkscape.org/wiki/Action | |
| #> | |
| # File edition | |
| # This version inspired by https://gist.github.com/takuyatsuchida/989423177c7991f20ea6191e6ababaff | |
| function Convert-SvgToIco { | |
| [CmdletBinding()] | |
| [Alias('ConvertTo-Ico','s2i')] | |
| param ( | |
| # file options | |
| [Parameter(Position=0,Mandatory)] | |
| [Alias('FilePath','LiteralPath','Fullname')] | |
| [string] $Path, | |
| [string] $Output, # output folder | |
| [alias('alter')][string] $Rename, # alternative name for output file | |
| # image options | |
| [int[]] $Sizes = (16,20,24,32,40,48,64,72,96,128,192,256), | |
| [Alias('notrim','noclip','nc')][switch] $NoCrop, # extraction area | |
| [int] $Margin, # reserved; extraction area, currently has no effect for PNG format | |
| [string[]] $ReColor, # replace colors in SVG | |
| [switch] $Keep, # don't delete png files and open temp folder in explorer | |
| # runtime options | |
| [switch] $Quiet, # experimental; mute diagnostic (and error?) messages | |
| [alias('np','nostatus')][switch] $NoProgress, | |
| [alias('type')][string] $BarType, | |
| [alias('color')][ConsoleColor] $BarColor | |
| ) | |
| function Measure-RunTime ([scriptblock]$command, [timespan]$timespan) { | |
| $diff = if ($timespan) {$timespan} | |
| elseif ($command) {Measure-Command -Expression $command -ErrorAction 0} | |
| else {return} | |
| $ms = $diff.TotalMilliseconds - [Math]::Truncate($diff.TotalMilliseconds) + $diff.Milliseconds | |
| if ($diff.Minutes) {'{0}m:{1}s:{2:N2}ms' -f $diff.Minutes,$diff.Seconds,$ms} | |
| elseif ($diff.Seconds) {'{0}s:{1:N2}ms' -f $diff.Seconds,$ms} | |
| else {'{0:N2}ms' -f $diff.TotalMilliseconds} | |
| } # END Measure-RunTime | |
| function Show-Progress { | |
| param ( | |
| [scriptblock] $scriptblock, | |
| [int] $width, | |
| [consolecolor] $color, | |
| [string] $type, # growth | fill | bounce | tide | |
| [switch] $passthru | |
| ) | |
| if (-not $scriptblock) {return} | |
| if ($width -lt 1) {$width = 10} | |
| if ([Console]::CursorLeft + $width -ge [Console]::WindowWidth) { | |
| return Measure-Command -Expression $scriptblock -ErrorAction 0 | |
| } | |
| if ($type -notmatch 'growth|fill|bounce|tide') {$type = 'growth','fill','bounce','tide' | Get-Random} | |
| $pipeUI = [hashtable]::Synchronized(@{}) | |
| $pipeUI.x,$pipeUI.y = [Console]::CursorLeft,[Console]::CursorTop | |
| $pipeUI.width = $width | |
| if ($null -ne $color) {$pipeUI.color = $color} | |
| $pipeUI.type = $type | |
| $rsjob = [runspacefactory]::CreateRunspace() | |
| $rsjob.ApartmentState = "STA" | |
| $rsjob.ThreadOptions = "ReuseThread" | |
| $rsjob.Open() | |
| $rsjob.SessionStateProxy.SetVariable("pipeUI", $pipeUI) | |
| $pipeUI.psThread = [powershell]::Create().AddScript({ | |
| $start = [datetime]::now | |
| $pipeUI.running = $true | |
| $width = $pipeUI.width | |
| $count = if ($pipeUI.type -eq 'tide') {0} else {1} | |
| $dir = 1 # from left to rigth | |
| $fill = '░' | |
| $cursor = '▓' | |
| $format = "{0,$(-$width-1)}" | |
| $sleep = if ($pipeUI.type -eq 'bounce') {100} else {120} | |
| [Console]::CursorVisible = $false | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $fg = [Console]::ForegroundColor | |
| if ($pipeUI.color) {[Console]::ForegroundColor = $pipeUI.color} | |
| while ($pipeUI.running) { | |
| Start-Sleep -Milliseconds $sleep | |
| if ($pipeUI.type -eq 'bounce') { | |
| if ($dir -eq 1) { | |
| [Console]::Write($fill*($count-1)) | |
| [Console]::Write($cursor) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count--; $dir = 0} | |
| } else { | |
| [Console]::Write($fill*($count-1)) | |
| [Console]::Write($cursor) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count-- | |
| if ($count -eq 0) {$count++; $dir = 1} | |
| } | |
| } elseif ($pipeUI.type -eq 'tide') { | |
| if ($dir -eq 1) { | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count--; $dir = 0} | |
| } else { | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count-- | |
| if ($count -eq -1) {$count++; $dir = 1} | |
| } | |
| } elseif ($pipeUI.type -eq 'growth') { | |
| [Console]::Write($format,$cursor*$count) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count = 1} | |
| } else { # fill | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count = 1} | |
| } | |
| } # while | |
| $pipeUI.runtime = [datetime]::now - $start | |
| # cleanup | |
| if ($pipeUI.type -eq 'growth') { | |
| [Console]::Write($format,' '*$width) | |
| } else { | |
| [Console]::Write(' '*$width) | |
| } | |
| if ([Console]::ForegroundColor -ne $fg) {[Console]::ForegroundColor = $fg} | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| [Console]::CursorVisible = $true | |
| }) | |
| $pipeUI.psThread.Runspace = $rsjob | |
| $pipeUI.handle = $pipeUI.psThread.BeginInvoke() | |
| $null = & $scriptblock # try {} catch {} | |
| $pipeUI.running = $false | |
| while (-not $pipeUI.handle.IsCompleted) {[System.Threading.Thread]::Sleep(40)} | |
| $null = $pipeUI.psThread.EndInvoke($pipeUI.handle) | |
| $pipeUI.psThread.Runspace.Close() | |
| $pipeUI.psThread.Runspace.Dispose() | |
| $pipeUI.psThread.Dispose() | |
| $pipeUI.psThread = $null | |
| if ($passthru) {$pipeUI.runtime} | |
| } # END Show-Progress | |
| function Replace-Color ($path,$recolor) { | |
| if ($null -eq $recolor) {return $path} | |
| $subst = @{} | |
| $recolor | Where-Object {$_} | ForEach-Object { | |
| $item = $_.replace('#','').trim() | |
| if ($item -match '=') { | |
| $old,$new = $item.split('=').trim() | |
| if ($old -ne '' -and $new -ne '') {$subst["#$old"] = "#$new"} | |
| } elseif ($item) { | |
| $subst['*'] = "#$item" | |
| } | |
| } | |
| if (-not $subst.count) {return $path} | |
| $dirty = $false | |
| $newsvg = Get-Content $path | ForEach-Object { | |
| $m = [regex]::Matches($_,'(stroke|fill)="(#?[^"no]+)"',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase') | |
| if ($m.count) { | |
| $l = $_ | |
| $m.groups.where({$_.name -eq 2 -and $_.success}).value | | |
| Sort-Object -Unique | ForEach-Object { | |
| $c = $subst[$_] | |
| if (-not $c) {$c = $subst['*']} | |
| if ($c) { | |
| $dirty = $true | |
| $l = $l.replace($_,$c) | |
| } | |
| } | |
| $l | |
| } else {$_} | |
| } | |
| if ($dirty) { | |
| $fn = [IO.Path]::GetFileNameWithoutExtension($path), 'edit.svg' -join '-' | |
| $newpath = [IO.Path]::GetDirectoryName($path),$fn -join '\' | |
| try {$newsvg | Out-File -FilePath $newpath -Encoding utf8 -Force} catch { | |
| Write-Host "ERROR: Failed to edit SVG. Check your write access to the source directory." -ForegroundColor Red | |
| return | |
| } | |
| $path = $newpath | |
| } | |
| $path | |
| } # END Replace-Color | |
| $error.Clear() | |
| if ($IsLinux -or $IsMacOS) {return} | |
| $Sizes = @($Sizes | Sort-Object -Descending -Unique | Where-Object {$_ -gt 7 -or $_ -lt 257}) | |
| if ($Sizes.length -eq 0) { | |
| Write-Host 'ERROR: Invalid sizes specified.' -ForegroundColor Red | |
| return | |
| } | |
| # SVG engine | |
| $inkscape = 'C:\Program Files\Inkscape\bin\inkscape.com' | |
| if (-not (Test-Path -LiteralPath $inkscape)) { | |
| Write-Warning "Trying to find Microsoft Store installation. Note, that there are problems to run MS Store Inkscape from command line." | |
| # check for Microsoft Store installation | |
| $inkscapeReg = Get-ChildItem -Path 'HKLM:\SOFTWARE\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\PackageRepository\Packages\*inkscape*' -ErrorAction 0 | |
| ###$inkscape = if ($inkscapeReg) {$inkscapeReg.GetValue('path'), 'VFS\ProgramFilesX64\Inkscape\bin\inkscape.exe' -join '\'} | |
| ###if (-not $inkscape -or -not (Test-Path -LiteralPath $inkscape)) { | |
| $inkscape = if ($inkscapeReg) {(Get-ChildItem -Path $inkscapeReg.GetValue('path') -Filter inkscape.exe -Recurse -ErrorAction 0).FullName} | |
| if (-not $inkscape) { | |
| Write-Host "ERROR: Could not found Inkscape app.`nInstall it with winget (winget install Inkscape.Inkscape) or Microsoft Store" -ForegroundColor Red | |
| return | |
| } else { | |
| try {Start-Process -FilePath $inkscape -Wait -NoNewWindow -ArgumentList '--version'} | |
| catch { | |
| Write-Host "ERROR: Unable to start Microsoft Store version of Inkscape app via command line.`nUninstall the current version and install it with winget: winget install Inkscape.Inkscape" -ForegroundColor Red | |
| return | |
| } | |
| } | |
| } # Inkscape | |
| # Resolve/validate input file | |
| if (([uri]$Path).Scheme -match '^http') { | |
| if ($Path -match '\.svg$') { | |
| $fn = $home,'downloads',[IO.Path]::GetFileName($Path) -join '\' | |
| if (Test-Path $fn) {Remove-Item $fn -Force -ErrorAction 0} | |
| $ProgressPreference = 'SilentlyContinue' | |
| $null = Invoke-WebRequest -Uri $Path -UseBasicParsing -OutFile $fn -ErrorAction 0 | |
| $ProgressPreference = 'Continue' | |
| $Path = $fn | |
| if (-not $output) {$output = "$home\downloads"} | |
| } else { | |
| Write-Warning "The file requested doesn't look like SVG. It must have .svg extension. Check URL and try again." | |
| return | |
| } | |
| } | |
| $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) | |
| if (-not (Test-Path $Path -PathType Leaf -ErrorAction 0)) { | |
| Write-Host 'ERROR: Input file not found. Check the file path and try again.' -ForegroundColor Red | |
| return | |
| } | |
| try {[xml]$xml = [System.IO.File]::ReadAllText($Path)} catch { | |
| Write-Host 'ERROR: Input file is not SVG or is corrupted. Check file name, content integrity and try again.' -ForegroundColor Red | |
| return | |
| } | |
| # Edit SVG | |
| $Path = Replace-Color $Path $ReColor | |
| if (-not $Path) {return} | |
| # Resolve file paths | |
| $svgFile = Get-Item -LiteralPath $Path -ErrorAction Stop | |
| $tempDirectory = Join-Path -Path $env:TEMP -ChildPath '_svgworkshop_' | |
| if (-not (Test-Path $tempDirectory)) { | |
| $null = New-Item -ItemType Directory -Path $tempDirectory -ErrorAction Stop | |
| } else { # cleanup svg desktop | |
| Remove-Item -Path $tempDirectory\*.* -Force -ErrorAction 0 | |
| } | |
| if ($Output) { | |
| $Output = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Output.TrimEnd('\')) | |
| } | |
| if (-not $Output -or -not (Test-Path $Output -PathType Container -ErrorAction 0)) {$Output = $svgFile.DirectoryName} | |
| $basename = if ($Rename) { # output file name | |
| if ($Rename -match '^+') { | |
| $svgFile.BaseName + $Rename.Substring(1) | |
| } else {$Rename} | |
| } else {$svgFile.BaseName} | |
| # Start converter, prepare data for ICO | |
| Write-Host "Preparing intermediate PNG ($($Sizes.length))... " -NoNewline | |
| $fnTemplate = '{0}\{1}_%sz%.png' -f $tempDirectory,$svgFile.BaseName | |
| $area = if ($NoCrop) {'export-area-page'} else {'export-area-drawing'} | |
| $imgmargin = if ($margin -gt 0) {"export-margin:$margin"} | |
| $szActions = $Sizes | ForEach-Object { | |
| $pngPath = $fnTemplate.replace('%sz%',$_) | |
| "export-width:$_", | |
| "export-height:$_", | |
| "export-filename:$pngPath", | |
| "export-do" | |
| } | |
| $actions = "export-overwrite","export-type:png","export-dpi:300",$area,$imgmargin + $szActions | Where-Object {$_} | |
| $iparam = ('--actions="{0}"' -f ($actions -join ';')), $svgFile | |
| $icmd = {Start-Process -FilePath $inkscape -Wait -NoNewWindow -ArgumentList $iparam -ErrorAction 0} | |
| if ($NoProgress) { | |
| Write-Host (Measure-RunTime -command $icmd) | |
| } else { | |
| $param = if ($BarColor) {@{Color=$BarColor}} else {@{}} | |
| Write-Host (Measure-RunTime -timespan (Show-Progress $icmd -type $BarType @param -passthru)) | |
| } | |
| # Import PNG as bytestream | |
| [array]$images = $Sizes | ForEach-Object { | |
| $pngPath = $fnTemplate.replace('%sz%',$_) | |
| # file bitmap | |
| [byte[]]$bitmap = try {[System.IO.File]::ReadAllBytes($pngPath)} catch {return} | |
| @{ | |
| Size = if ($_ -eq 256) {0} else {$_} # 0, trigger for a hack | |
| Bitmap = $bitmap | |
| } | |
| } # images | |
| if ($images.length -eq 0) {return} # something went wrong: invalid input or Inkscape's failure | |
| if ($images.length -ne $sizes.Length) {Write-Warning "Range of sizes was cut ($($images.length) of $($sizes.Length))."} | |
| # Normalize ico sizes for writing | |
| # 256 alone doesn't work, so we apply a pinpoint hack, 255 | |
| if ($images.length -eq 1 -and $images[0].size -eq 0) {$images[0].size = 255} | |
| Write-Host 'Writing ICO...' | |
| $icoPath = '{0}\{1}.ico' -f $Output,$basename | |
| $icoStream = try {[System.IO.File]::Open($icoPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)} catch {} | |
| if ($null -eq $icoStream) { | |
| Write-Host "ERROR: Failed to create output file. Check your write access to the output directory and try again." -ForegroundColor Red | |
| return | |
| } | |
| $icoWriter = [System.IO.BinaryWriter]::new($icoStream) | |
| # 0-1 reserved, 0 | |
| $icoWriter.Write([byte]0) | |
| $icoWriter.Write([byte]0) | |
| # 2-3 image type: 1 = icon, 2 = cursor | |
| $icoWriter.Write([int16]1) | |
| # 4-5 number of images | |
| $icoWriter.Write([int16]$images.length) | |
| $offset = 6 + (16 * $images.length) | |
| foreach ($item in $images) { | |
| # image entry 1 | |
| # 0 image width | |
| $icoWriter.Write([byte]$item.Size) | |
| # 1 image height | |
| $icoWriter.Write([byte]$item.Size) | |
| # 2 number of colors | |
| $icoWriter.Write([byte]0) | |
| # 3 reserved | |
| $icoWriter.Write([byte]0) | |
| # 4-5 color planes | |
| $icoWriter.Write([int16]0) | |
| # 6-7 bits per pixel | |
| $icoWriter.Write([int16]32) | |
| # 8-11 size of image data | |
| $icoWriter.Write([int]$item.Bitmap.Length) | |
| # 12-15 offset of image data | |
| $icoWriter.Write([int]$offset) | |
| $offset += $item.Bitmap.Length | |
| } | |
| # write image data | |
| # png data must contain the whole png data file | |
| foreach ($item in $images) { | |
| $icoWriter.Write($item.Bitmap) | |
| } | |
| $icoWriter.Flush() | |
| # Cleanup, finalizing | |
| $icoStream.Close() | |
| if ($icoWriter) { | |
| $icoWriter.Close() | |
| $icoWriter.Dispose() | |
| } | |
| if (-not $Keep -and (Test-Path -LiteralPath $tempDirectory)) { | |
| Remove-Item -Path $tempDirectory -Recurse -Force -ErrorAction 0 | |
| } elseif ($Keep) {Invoke-Item $tempDirectory} | |
| if ($ReColor) { | |
| Get-ChildItem ([IO.Path]::GetDirectoryName($Path)) -Filter '*-edit.svg' -ErrorAction 0 | Remove-Item -Force -ErrorAction 0 | |
| } | |
| # Validate ICO file | |
| if (Test-Path $icoPath) { | |
| $param = @{TotalCount = 4} | |
| if ($PSVersionTable.PSVersion.Major -lt 6) {$param['Encoding'] = 'Byte'} | |
| else {$param['AsByteStream'] = $true} | |
| $signature = Get-Content -LiteralPath $icoPath @param -ErrorAction 0 | |
| if ($signature -and ([System.BitConverter]::ToInt32($signature, 0)) -eq 0x10000) { | |
| Write-Host "ICO file successfully created in '$Output'" -ForegroundColor Green | |
| } else { | |
| Write-Host "ERROR: '$([System.IO.Path]::GetFileName($icoPath))' is not a valid ICO file." -ForegroundColor Red | |
| } | |
| } else { | |
| Write-Host "ERROR: ICO file is not created." -ForegroundColor Red | |
| } | |
| } # END Convert-SvgToIco | |
| # .NET edition | |
| function Convert-SvgToIco { | |
| [CmdletBinding()] | |
| [Alias('ConvertTo-Ico','s2i')] | |
| param ( | |
| # file options | |
| [Parameter(Position=0,Mandatory)] | |
| [Alias('FilePath','LiteralPath','Fullname')] | |
| [string] $Path, | |
| [string] $Output, # output folder | |
| [alias('alter')][string] $Rename, # alternative name for output file | |
| # image options | |
| [int[]] $Sizes = (16,20,24,32,40,48,64,72,96,128,192,256), | |
| [Alias('notrim','noclip','nc')][switch] $NoCrop, # extraction area | |
| [int] $Margin, # reserved; extraction area, currently has no effect for PNG format | |
| [string[]] $ReColor, # replace colors in SVG | |
| [switch] $Keep, # don't delete png files and open temp folder in explorer | |
| # runtime options | |
| [switch] $Quiet, # experimental; mute diagnostic (and error?) messages | |
| [alias('type')][string] $BarType, | |
| [alias('color')][ConsoleColor] $BarColor, | |
| [alias('np','nostatus')][switch] $NoProgress | |
| ) | |
| function Measure-RunTime ([scriptblock]$command, [timespan]$timespan) { | |
| $diff = if ($timespan) {$timespan} | |
| elseif ($command) {Measure-Command -Expression $command -ErrorAction 0} | |
| else {return} | |
| $ms = $diff.TotalMilliseconds - [Math]::Truncate($diff.TotalMilliseconds) + $diff.Milliseconds | |
| if ($diff.Minutes) {'{0}m:{1}s:{2:N2}ms' -f $diff.Minutes,$diff.Seconds,$ms} | |
| elseif ($diff.Seconds) {'{0}s:{1:N2}ms' -f $diff.Seconds,$ms} | |
| else {'{0:N2}ms' -f $diff.TotalMilliseconds} | |
| } # END Measure-RunTime | |
| function Show-Progress { | |
| param ( | |
| [scriptblock] $scriptblock, | |
| [int] $width, | |
| [consolecolor] $color, | |
| [string] $type, # growth | fill | bounce | tide | |
| [switch] $passthru | |
| ) | |
| if (-not $scriptblock) {return} | |
| if ($width -lt 1) {$width = 10} | |
| if ([Console]::CursorLeft + $width -ge [Console]::WindowWidth) { | |
| return Measure-Command -Expression $scriptblock -ErrorAction 0 | |
| } | |
| if ($type -notmatch 'growth|fill|bounce|tide') {$type = 'growth','fill','bounce','tide' | Get-Random} | |
| $pipeUI = [hashtable]::Synchronized(@{}) | |
| $pipeUI.x,$pipeUI.y = [Console]::CursorLeft,[Console]::CursorTop | |
| $pipeUI.width = $width | |
| if ($null -ne $color) {$pipeUI.color = $color} | |
| $pipeUI.type = $type | |
| $rsjob = [runspacefactory]::CreateRunspace() | |
| $rsjob.ApartmentState = "STA" | |
| $rsjob.ThreadOptions = "ReuseThread" | |
| $rsjob.Open() | |
| $rsjob.SessionStateProxy.SetVariable("pipeUI", $pipeUI) | |
| $pipeUI.psThread = [powershell]::Create().AddScript({ | |
| $start = [datetime]::now | |
| $pipeUI.running = $true | |
| $width = $pipeUI.width | |
| $count = if ($pipeUI.type -eq 'tide') {0} else {1} | |
| $dir = 1 # from left to rigth | |
| $fill = '░' | |
| $cursor = '▓' | |
| $format = "{0,$(-$width-1)}" | |
| $sleep = if ($pipeUI.type -eq 'bounce') {100} else {120} | |
| [Console]::CursorVisible = $false | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $fg = [Console]::ForegroundColor | |
| if ($pipeUI.color) {[Console]::ForegroundColor = $pipeUI.color} | |
| while ($pipeUI.running) { | |
| Start-Sleep -Milliseconds $sleep | |
| if ($pipeUI.type -eq 'bounce') { | |
| if ($dir -eq 1) { | |
| [Console]::Write($fill*($count-1)) | |
| [Console]::Write($cursor) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count--; $dir = 0} | |
| } else { | |
| [Console]::Write($fill*($count-1)) | |
| [Console]::Write($cursor) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count-- | |
| if ($count -eq 0) {$count++; $dir = 1} | |
| } | |
| } elseif ($pipeUI.type -eq 'tide') { | |
| if ($dir -eq 1) { | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count--; $dir = 0} | |
| } else { | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count-- | |
| if ($count -eq -1) {$count++; $dir = 1} | |
| } | |
| } elseif ($pipeUI.type -eq 'growth') { | |
| [Console]::Write($format,$cursor*$count) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count = 1} | |
| } else { # fill | |
| [Console]::Write($cursor*$count) | |
| [Console]::Write($fill*($width-$count)) | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| $count++ | |
| if ($count -gt $width) {$count = 1} | |
| } | |
| } # while | |
| $pipeUI.runtime = [datetime]::now - $start | |
| # cleanup | |
| if ($pipeUI.type -eq 'growth') { | |
| [Console]::Write($format,' '*$width) | |
| } else { | |
| [Console]::Write(' '*$width) | |
| } | |
| if ([Console]::ForegroundColor -ne $fg) {[Console]::ForegroundColor = $fg} | |
| [Console]::SetCursorPosition($pipeUI.x,$pipeUI.y) | |
| [Console]::CursorVisible = $true | |
| }) | |
| $pipeUI.psThread.Runspace = $rsjob | |
| $pipeUI.handle = $pipeUI.psThread.BeginInvoke() | |
| $null = & $scriptblock # try {} catch {} | |
| $pipeUI.running = $false | |
| while (-not $pipeUI.handle.IsCompleted) {[System.Threading.Thread]::Sleep(40)} | |
| $null = $pipeUI.psThread.EndInvoke($pipeUI.handle) | |
| $pipeUI.psThread.Runspace.Close() | |
| $pipeUI.psThread.Runspace.Dispose() | |
| $pipeUI.psThread.Dispose() | |
| $pipeUI.psThread = $null | |
| if ($passthru) {$pipeUI.runtime} | |
| } # END Show-Progress | |
| function Replace-Color ($path,$recolor) { | |
| if ($null -eq $recolor) {return $path} | |
| $subst = @{} | |
| $recolor | Where-Object {$_} | ForEach-Object { | |
| $item = $_.replace('#','').trim() | |
| if ($item -match '=') { | |
| $old,$new = $item.split('=').trim() | |
| if ($old -ne '' -and $new -ne '') {$subst["#$old"] = "#$new"} | |
| } elseif ($item) { | |
| $subst['*'] = "#$item" | |
| } | |
| } | |
| if (-not $subst.count) {return $path} | |
| $dirty = $false | |
| $newsvg = Get-Content $path | ForEach-Object { | |
| $m = [regex]::Matches($_,'(stroke|fill)="(#?[^"no]+)"',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase') | |
| if ($m.count) { | |
| $l = $_ | |
| $m.groups.where({$_.name -eq 2 -and $_.success}).value | | |
| Sort-Object -Unique | ForEach-Object { | |
| $c = $subst[$_] | |
| if (-not $c) {$c = $subst['*']} | |
| if ($c) { | |
| $dirty = $true | |
| $l = $l.replace($_,$c) | |
| } | |
| } | |
| $l | |
| } else {$_} | |
| } | |
| if ($dirty) { | |
| $fn = [IO.Path]::GetFileNameWithoutExtension($path), 'edit.svg' -join '-' | |
| $newpath = [IO.Path]::GetDirectoryName($path),$fn -join '\' | |
| try {$newsvg | Out-File -FilePath $newpath -Encoding utf8 -Force} catch { | |
| Write-Host "ERROR: Failed to edit SVG. Check your write access to the source directory." -ForegroundColor Red | |
| return | |
| } | |
| $path = $newpath | |
| } | |
| $path | |
| } # END Replace-Color | |
| $error.Clear() | |
| if ($IsLinux -or $IsMacOS) {return} | |
| $Sizes = @($Sizes | Sort-Object -Descending -Unique | Where-Object {$_ -gt 7 -or $_ -lt 257}) | |
| if ($Sizes.length -eq 0) { | |
| Write-Host 'ERROR: Invalid sizes specified.' -ForegroundColor Red | |
| return | |
| } | |
| try {$null = [System.Drawing.Icon]} | |
| catch {Add-Type -AssemblyName System.Drawing} | |
| # SVG engine | |
| $inkscape = 'C:\Program Files\Inkscape\bin\inkscape.com' | |
| if (-not (Test-Path -LiteralPath $inkscape)) { | |
| Write-Warning "Trying to find Microsoft Store installation. Note, that there are problems to run MS Store Inkscape from command line." | |
| # check for Microsoft Store installation | |
| $inkscapeReg = Get-ChildItem -Path 'HKLM:\SOFTWARE\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\PackageRepository\Packages\*inkscape*' -ErrorAction 0 | |
| ###$inkscape = if ($inkscapeReg) {$inkscapeReg.GetValue('path'), 'VFS\ProgramFilesX64\Inkscape\bin\inkscape.exe' -join '\'} | |
| ###if (-not $inkscape -or -not (Test-Path -LiteralPath $inkscape)) { | |
| $inkscape = if ($inkscapeReg) {(Get-ChildItem -Path $inkscapeReg.GetValue('path') -Filter inkscape.exe -Recurse -ErrorAction 0).FullName} | |
| if (-not $inkscape) { | |
| Write-Host "ERROR: Could not found Inkscape app.`nInstall it with winget (winget install Inkscape.Inkscape) or Microsoft Store" -ForegroundColor Red | |
| return | |
| } | |
| } # Inkscape | |
| # Resolve/validate input file | |
| if (([uri]$Path).Scheme -match '^http') { | |
| if ($Path -match '\.svg$') { | |
| $fn = $home,'downloads',[IO.Path]::GetFileName($Path) -join '\' | |
| if (Test-Path $fn) {Remove-Item $fn -Force -ErrorAction 0} | |
| $ProgressPreference = 'SilentlyContinue' | |
| $null = Invoke-WebRequest -Uri $Path -UseBasicParsing -OutFile $fn -ErrorAction 0 | |
| $ProgressPreference = 'Continue' | |
| $Path = $fn | |
| if (-not $output) {$output = "$home\downloads"} | |
| } else { | |
| Write-Warning "The file requested doesn't look like SVG. It must have .svg extension. Check URL and try again." | |
| return | |
| } | |
| } | |
| $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) | |
| if (-not (Test-Path $Path -PathType Leaf -ErrorAction 0)) { | |
| Write-Host 'ERROR: Input file not found. Check the file path and try again.' -ForegroundColor Red | |
| return | |
| } | |
| try {[xml]$xml = [System.IO.File]::ReadAllText($Path)} catch { | |
| Write-Host 'ERROR: Input file is not SVG or is corrupted. Check file name, content integrity and try again.' -ForegroundColor Red | |
| return | |
| } | |
| # Edit SVG | |
| $Path = Replace-Color $Path $ReColor | |
| if (-not $Path) {return} | |
| # Resolve file paths | |
| $svgFile = Get-Item -LiteralPath $Path -ErrorAction Stop | |
| $tempDirectory = Join-Path -Path $env:TEMP -ChildPath '_svgworkshop_' | |
| if (-not (Test-Path $tempDirectory)) { | |
| $null = New-Item -ItemType Directory -Path $tempDirectory -ErrorAction Stop | |
| } else { # cleanup svg desktop | |
| Remove-Item -Path $tempDirectory\*.* -Force -ErrorAction 0 | |
| } | |
| if ($Output) { | |
| $Output = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Output.TrimEnd('\')) | |
| } | |
| if (-not $Output -or -not (Test-Path $Output -PathType Container -ErrorAction 0)) {$Output = $svgFile.DirectoryName} | |
| $basename = if ($Rename) { # output file name | |
| if ($Rename -match '^+') { | |
| $svgFile.BaseName + $Rename.Substring(1) | |
| } else {$Rename} | |
| } else {$svgFile.BaseName} | |
| # Start converter, prepare data for ICO ⟳ ⏳ org.inkscape.color.replace-color | |
| Write-Host "Preparing intermediate PNG ($($Sizes.length))... " -NoNewline | |
| $fnTemplate = '{0}\{1}_%sz%.png' -f $tempDirectory,$svgFile.BaseName | |
| $area = if ($NoCrop) {'export-area-page'} else {'export-area-drawing'} | |
| $imgmargin = if ($margin -gt 0) {"export-margin:$margin"} | |
| $szActions = $Sizes | ForEach-Object { | |
| $pngPath = $fnTemplate.replace('%sz%',$_) | |
| "export-width:$_", | |
| "export-height:$_", | |
| "export-filename:$pngPath", | |
| "export-do" | |
| } | |
| $actions = "export-overwrite","export-type:png","export-dpi:300",$area,$imgmargin + $szActions | Where-Object {$_} | |
| $iparam = ('--actions="{0}"' -f ($actions -join ';')), $svgFile | |
| $icmd = {Start-Process -FilePath $inkscape -Wait -NoNewWindow -ArgumentList $iparam -ErrorAction 0} | |
| if ($NoProgress) { | |
| Write-Host (Measure-RunTime -command $icmd) | |
| } else { | |
| $param = if ($BarColor) {@{Color=$BarColor}} else {@{}} | |
| Write-Host (Measure-RunTime -timespan (Show-Progress $icmd -type $BarType @param -passthru)) | |
| } | |
| # Convert PNG to image object | |
| [array]$images = $Sizes | ForEach-Object { | |
| $pngPath = $fnTemplate.replace('%sz%',$_) | |
| # create output bitmap; .NET bitmap | |
| $inputBitmap = try{[System.Drawing.Image]::FromFile($pngPath)} catch {return} | |
| if ($null -eq $inputBitmap) {return} # system failure | |
| $size = [System.Drawing.Size]::new($inputBitmap.Width, $inputBitmap.Height) | |
| $outputBitmap = [System.Drawing.Bitmap]::new($inputBitmap, $size) | |
| $memoryStream = [System.IO.MemoryStream]::new() | |
| $outputBitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png) | |
| @{ | |
| Size = if ($_ -eq 256) {0} else {$_} # 0, trigger for a hack | |
| Bitmap = $memoryStream | |
| } | |
| # cleanup | |
| $outputBitmap.Dispose() | |
| $inputBitmap.Dispose() | |
| } # images | |
| if ($images.length -eq 0) {return} # something went wrong: invalid input or Inkscape's failure | |
| if ($images.length -ne $sizes.Length) {Write-Warning "Range of sizes was cut ($($images.length) of $($sizes.Length))."} | |
| # Normalize ico sizes for writing | |
| # 256 alone doesn't work, so we apply a pinpoint hack, 255 | |
| if ($images.length -eq 1 -and $images[0].size -eq 0) {$images[0].size = 255} | |
| Write-Host 'Writing ICO...' | |
| $icoPath = '{0}\{1}.ico' -f $Output,$basename | |
| $icoStream = try {[System.IO.File]::Open($icoPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)} catch {} | |
| if ($null -eq $icoStream) { | |
| Write-Host "ERROR: Failed to create output file. Check your write access to the output directory and try again." -ForegroundColor Red | |
| return | |
| } | |
| $icoWriter = [System.IO.BinaryWriter]::new($icoStream) | |
| # 0-1 reserved, 0 | |
| $icoWriter.Write([byte]0) | |
| $icoWriter.Write([byte]0) | |
| # 2-3 image type: 1 = icon, 2 = cursor | |
| $icoWriter.Write([int16]1) | |
| # 4-5 number of images | |
| $icoWriter.Write([int16]$images.length) | |
| $offset = 6 + (16 * $images.length) | |
| foreach ($item in $images) { | |
| # image entry 1 | |
| # 0 image width | |
| $icoWriter.Write([byte]$item.Size) | |
| # 1 image height | |
| $icoWriter.Write([byte]$item.Size) | |
| # 2 number of colors | |
| $icoWriter.Write([byte]0) | |
| # 3 reserved | |
| $icoWriter.Write([byte]0) | |
| # 4-5 color planes | |
| $icoWriter.Write([int16]0) | |
| # 6-7 bits per pixel | |
| $icoWriter.Write([int16]32) | |
| # 8-11 size of image data | |
| $icoWriter.Write([int]$item.Bitmap.Length) | |
| # 12-15 offset of image data | |
| $icoWriter.Write([int]$offset) | |
| $offset += $item.Bitmap.Length | |
| } | |
| # write image data | |
| # png data must contain the whole png data file | |
| foreach ($item in $images) { | |
| $icoWriter.Write($item.Bitmap.ToArray()) | |
| $item.Bitmap.Dispose() | |
| } | |
| $icoWriter.Flush() | |
| # Cleanup, finalizing | |
| $icoStream.Close() | |
| if ($icoWriter) { | |
| $icoWriter.Close() | |
| $icoWriter.Dispose() | |
| } | |
| if (-not $Keep -and (Test-Path -LiteralPath $tempDirectory)) { | |
| Remove-Item -Path $tempDirectory -Recurse -Force -ErrorAction 0 | |
| } elseif ($Keep) {Invoke-Item $tempDirectory} | |
| if ($ReColor) { | |
| Get-ChildItem ([IO.Path]::GetDirectoryName($Path)) -Filter '*-edit.svg' -ErrorAction 0 | Remove-Item -Force -ErrorAction 0 | |
| } | |
| # Validate ICO file | |
| if (Test-Path $icoPath) { | |
| $param = @{TotalCount = 4} | |
| if ($PSVersionTable.PSVersion.Major -lt 6) {$param['Encoding'] = 'Byte'} | |
| else {$param['AsByteStream'] = $true} | |
| $signature = Get-Content -LiteralPath $icoPath @param -ErrorAction 0 | |
| if ($signature -and ([System.BitConverter]::ToInt32($signature, 0)) -eq 0x10000) { | |
| Write-Host "ICO file successfully created in '$Output'" -ForegroundColor Green | |
| } else { | |
| Write-Host "ERROR: '$([System.IO.Path]::GetFileName($icoPath))' is not a valid ICO file." -ForegroundColor Red | |
| } | |
| } else { | |
| Write-Host "ERROR: ICO file is not created." -ForegroundColor Red | |
| } | |
| } # END Convert-SvgToIco |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment