Preface
Recently, I've been continuously improving my two desktop software projects for this year: the video editing tool Clipify and the AI article creation tool StarBlogPublisher.
Although the interfaces are basically complete, the icons are still the default ones, which looks unprofessional.
So I decided to create nice-looking icons for these two software projects.
Back in the VB6.0 era, I used an open-source ICO icon creation tool, but I can no longer find it.
Many ICO creation tools found online are designed for favicons.
Others are either too heavy or paid, so I turned my attention back to open-source tools.
I found one based on Node.js with zero dependencies (most depend on ImageMagick, a C++ image library), but unfortunately, it didn't work on my computer...
Goal
At that point, I remembered that I had previously developed an image format conversion tool in C#, utilizing the AOT feature of .NET 8, which allows developing cross-platform single executable files like Go.
So I decided to continue using C# to develop an icon generation tool that can achieve:
- Pure C# implementation, no external dependencies, cross-platform, single executable, AOT.
- Decompose a PNG image into multiple small images of different sizes (side lengths 16, 32, 48, 64, 128, 256, 512), then combine them into one ICO icon for a good visual experience on screens of various sizes.
- Support Inspect functionality to read and analyze ICO icons.
- Convenient distribution methods, supporting one-click installation via dotnet tool, scoop, brew, etc.
It's now complete and published to NuGet and Scoop. Next, I'll research how to publish it to brew.
Project homepage: https://github.com/star-plan/sharp-ico
Implementation
In SharpIco, the generation of .ico files does not rely on ImageMagick or any external image processing tools at all. Instead, it manually assembles the ICO binary structure according to the specification using pure C# code.
The core class for this part is IcoGenerator. I won't paste the specific code here; it's available in the project. Let me highlight a few key points.
Generating Multi‑size Images
Use ImageSharp to generate multi‑size images.
var clone = original.Clone(ctx => ctx.Resize(size, size));
clone.SaveAsPng(ms);
- Using ImageSharp's Resize and Clone functionality, generate multiple target sizes (e.g., 16×16, 32×32, 256×256) from the original high‑resolution PNG.
- Save to a memory stream as PNG format for subsequent writing into
.ico.
💡 ICO files support embedding PNG images (starting from Vista), which maintains smaller file sizes and better transparency.
Manual Construction of ICO File Header
Manually construct ICONDIR and ICONDIRENTRY according to the ICO file format.
The ICO file header consists of three parts:
ICONDIR(6 bytes): fixed structure.ICONDIRENTRY × N(each 16 bytes): describes the size and offset of each embedded image.Image Data × N: actual image binary data.
writer.Write((ushort)0); // Reserved
writer.Write((ushort)1); // Type = icon
writer.Write((ushort)images.Count); // Image count
- First write the ICO file header ICONDIR.
- Then iteratively write the description information for each image (width, height, color depth, data offset, etc.).
writer.Write((byte)(img.Width == 256 ? 0 : img.Width)); // 256 is represented as 0
writer.Write((ushort)32); // bits per pixel
writer.Write(image.Length);
writer.Write(offset);
In ICO files, a width/height field of 0 means 256. This is a special rule of the ICO format.
The ICO file format has a limitation when representing image dimensions: the width and height fields are each only one byte, with a value range of 0–255. When these fields are 0, it means 256 pixels per the specification. For sizes larger than 256 (e.g., 512×512 or 1024×1024), the header still shows 0 (i.e., 256), but the actual image data can contain larger dimensions.
Concatenating Images
Concatenate all PNG image data.
foreach (var image in images) {
writer.Write(image);
}
- After writing all description information, the image data itself is written immediately afterward.
- Since offsets have been pre‑calculated, each image data can be correctly identified and read by the system.
Extensibility
Support custom sizes.
public static void GenerateIcon(string sourcePng, string outputIco, int[] sizes)
- Default support for common sizes from 16 to 512.
- You can flexibly specify size combinations by passing parameters (e.g., only 32/256).
Inspect Functionality
In addition to icon generation, SharpIco also includes a built‑in icon content analysis tool IcoInspector that helps developers deeply understand the internal structure of .ico files and verify the actual layer image dimensions and color depth information, solving the problem of many icon tools generating non‑standard .ico files.
Here’s a brief overview of the implementation approach.
Reading the ICO File Header
Manually read the ICONDIR + ICONDIRENTRY structure.
The ICO file header consists of three parts:
ICONDIR(6 bytes): marks the file as an icon, records the number of images.ICONDIRENTRY × N(each 16 bytes): records metadata of each image (width, height, color depth, data offset, etc.).- Image data blocks: actual PNG or BMP images.
ushort reserved = reader.ReadUInt16(); // must be 0
ushort type = reader.ReadUInt16(); // 1 means icon
ushort count = reader.ReadUInt16(); // number of images
Then read each image entry sequentially and store them in memory for subsequent processing.
byte width = reader.ReadByte();
byte height = reader.ReadByte();
ushort bitCount = reader.ReadUInt16();
int sizeInBytes = reader.ReadInt32();
int imageOffset = reader.ReadInt32();
As mentioned earlier, the ICO header has limited space, only 8 bits. So if the width/height field is 0, according to the specification it means 256, or it could be larger than 256.
Extracting & Parsing Image Data
Extract image data and verify the actual resolution.
The width/height recorded in the ICO file may not be accurate, especially when embedding PNG formats. Therefore, parse each image genuinely using ImageSharp.
fs.Seek(entry.ImageOffset, SeekOrigin.Begin);
fs.Read(imageData, 0, dataSize);
Image.Load(imageData) → gets actual Width and Height
- Use the
GetImageDimensions()method to determine if it's PNG and load it withImageSharp. - If the format is wrong or reading fails, fall back to the header‑declared dimensions.
This can be used to detect certain "fake ICO" problems (e.g., dimensions inconsistent with image content).
Completing and Outputting Analysis Results
After reading and parsing all entries, output the content as structured information:
正在检查ICO文件: logo.ico
图标数量: 7
- 第1张图像: 16x16, 32bpp, 大小: 840字节, 偏移: 118
- 第2张图像: 32x32, 32bpp, 大小: 1939字节, 偏移: 958
- 第3张图像: 48x48, 32bpp, 大小: 3375字节, 偏移: 2897
- 第4张图像: 64x64, 32bpp, 大小: 4951字节, 偏移: 6272
- 第5张图像: 128x128, 32bpp, 大小: 13782字节, 偏移: 11223
- 第6张图像: 256x256, 32bpp, 大小: 37823字节, 偏移: 25005
- 第7张图像: 512x512, 32bpp, 大小: 114655字节, 偏移: 62828
注意: 文件头中指定的尺寸为256x256,但实际图像尺寸为512x512
This allows quick confirmation:
- How many layers are included in a
.icofile. - The actual dimensions and color depth of each layer.
- Whether there are format issues (dimension mismatch, size anomalies, etc.).
Command‑Line Interface Design
SharpIco is not just a code library; it also provides a complete command‑line tool, convenient for quick invocation in any development scenario, whether used manually or integrated into build scripts.
This part uses the modern .NET CLI building library System.CommandLine, implementing two main commands:
generate: Convert a PNG image to an ICO icon.inspect: Examine the structure and layer information of an ICO file.
Command‑line usage:
sharpico generate -i logo.png -o icon.ico --sizes 16 32 48 256
sharpico inspect icon.ico
🛠️ Generate Command generate
This command supports controlling the input, output path, and icon sizes via parameters:
sharpico generate --input logo.png --output app.ico --sizes 16 32 64 256
Parameter Description:
| Parameter | Short | Description |
|---|---|---|
--input |
-i |
Source PNG image path (required) |
--output |
-o |
Output ICO file path (required) |
--sizes |
-s |
List of icon sizes to generate (defaults to common 7 sizes) |
Multiple sizes can be generated simultaneously; internally calls
IcoGenerator.GenerateIcon()to create a multi‑layer.icofile.
Example:
sharpico generate -i logo.png -o icon.ico -s 32 64 256
The output will include three resolution layers.
🔍 Inspect Command inspect
Performs structural inspection and validation on any .ico file:
sharpico inspect icon.ico
This outputs for each layer in the icon:
- Width and height (declared vs. actual)
- Color depth (bpp)
- Data offset and size
- Potential inconsistencies between the header and the actual image size
Example output:
正在检查ICO文件: logo.ico
图标数量: 7
- 第1张图像: 16x16, 32bpp, 大小: 840字节, 偏移: 118
- 第2张图像: 32x32, 32bpp, 大小: 1939字节, 偏移: 958
- 第3张图像: 48x48, 32bpp, 大小: 3375字节, 偏移: 2897
- 第4张图像: 64x64, 32bpp, 大小: 4951字节, 偏移: 6272
- 第5张图像: 128x128, 32bpp, 大小: 13782字节, 偏移: 11223
- 第6张图像: 256x256, 32bpp, 大小: 37823字节, 偏移: 25005
- 第7张图像: 512x512, 32bpp, 大小: 114655字节, 偏移: 62828
注意: 文件头中指定的尺寸为256x256,但实际图像尺寸为512x512
Publishing
SharpIco is not just a library, nor a small tool that can only be used from source; it is a professional icon tool that can be installed and run across platforms via dotnet tool and supports AOT compilation to optimize performance and size.
This time, both AOT and traditional publishing are needed, so some configuration was added to the project file.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- AOT compilation settings moved to conditional property group -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- .NET Tool configuration -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>sharpico</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
<!-- Package information -->
<PackageId>SharpIco</PackageId>
<Version>1.0.0</Version>
<Authors>StarPlan</Authors>
<Description>SharpIco is a lightweight icon generation tool implemented in pure C# AOT, used to generate and inspect ICO icon files. It can convert a high-resolution PNG image into a standard Windows .ico icon file containing multiple sizes (16x16 to 512x512), and you can also customize sizes. In addition to icon generation, SharpIco also has a built-in icon structure analysis function to help you easily verify the layers and sizes contained in the .ico file.</Description>
<PackageTags>icon;ico;png;converter,DealiAxy,cli,tool,dotnet-tool,imagesharp</PackageTags>
<PackageProjectUrl>https://github.com/star-plan/sharp-ico</PackageProjectUrl>
<RepositoryUrl>https://github.com/star-plan/sharp-ico</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<!-- AOT release specific settings -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<PublishAot>true</PublishAot>
<TrimMode>full</TrimMode>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>
Key points:
Defined as a CLI Tool (dotnet tool)
<PackAsTool>true</PackAsTool>
<ToolCommandName>sharpico</ToolCommandName>
This allows SharpIco to be installed globally like any other .NET CLI tool:
dotnet tool install -g SharpIco --add-source ./nupkg
After installation, just run:
sharpico generate -i logo.png -o icon.ico
Supports AOT Compilation Publishing (native support in .NET 9)
<PublishAot>true</PublishAot>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<TrimMode>full</TrimMode>
By setting PublishAot=true, SharpIco can be compiled to a native executable.
No need for the .NET runtime; startup speed is extremely fast, suitable for build toolchains or integrated environments.
AOT build command example:
dotnet publish -c Release -r win-x64 /p:PublishAot=true
The generated sharpico.exe is a pure native Windows executable, no need to install .NET!
Besides Windows, it can also be published for
linux-x64,osx-arm64, and other cross-platform targets.
Automatic NuGet Publishing
SharpIco uses a complete GitHub Actions CI/CD pipeline to achieve one tag push, full multi‑platform build automation.
Just push a semantically versioned tag (e.g., v1.0.0), and the system will automatically:
- Build and publish the NuGet tool package.
- Compile native AOT executables for Windows / Linux / macOS.
- Automatically upload all artifacts to the GitHub Release page.
I have previously written about how to publish NuGet packages; see:
- Developing Modern .NET Core Console Programs (3) Publishing NuGet Packages to GitHub Packages
- Developing Modern .NET Core Console Programs (4) Using GitHub Actions to Automatically Build and Publish NuGet Packages
That part is straightforward.
However, this time there is a difference: previously I published class libraries and project templates; this time it's a dotnet tool.
This is similar to concepts like npx scripts, pip tools, etc.
It can be installed and invoked using the dotnet tool command.
But this approach cannot use AOT; it must be published as framework‑dependent.
NuGet publishing is relatively simple, also using dotnet commands.
dotnet pack -c Release
But the key is to use GitHub Actions for automated building and publishing, and this workflow is similar to before.
name: 发布SharpIco
run-name: ${{ github.actor }} 正在发布SharpIco 🚀
on:
push:
tags:
- "v*.*.*" # more explicit version format matching
# Set permissions for the entire workflow
permissions:
contents: write
id-token: write
issues: write
jobs:
# Step 1: Publish NuGet package
publish-nuget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # get all history for version number calculation
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: 缓存NuGet包
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: 提取版本号
id: get_version
shell: bash
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: 恢复依赖
run: dotnet restore ./SharpIco/SharpIco.csproj
- name: 运行测试
run: dotnet test --no-restore
- name: 构建项目
run: dotnet build --no-restore -c Release --nologo ./SharpIco/SharpIco.csproj -p:Version=${{ steps.get_version.outputs.VERSION }}
- name: 创建NuGet包
run: dotnet pack -c Release ./SharpIco/SharpIco.csproj -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --no-build --output ./nupkg
- name: 发布到NuGet Gallery
run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_GALLERY_TOKEN }} --source https://api.nuget.org/v3/index.json --skip-duplicate
Automatic Publishing to GitHub Release
This was the most troublesome part; I had to debug it more than ten times before it succeeded 😂
I have to say debugging GitHub Actions is not very user‑friendly.
Let me directly paste my final successful configuration.
name: 发布SharpIco
run-name: ${{ github.actor }} 正在发布SharpIco 🚀
on:
push:
tags:
- "v*.*.*" # more explicit version format matching
# Set permissions for the entire workflow
permissions:
contents: write
id-token: write
issues: write
jobs:
# Step 2: Compile executables for each platform
build-executables:
needs: publish-nuget # ensure it runs after NuGet package publishing
strategy:
fail-fast: false
matrix:
kind: ['windows', 'linux', 'macOS']
include:
- kind: windows
os: windows-latest
target: win-x64
extension: '.zip'
- kind: linux
os: ubuntu-latest
target: linux-x64
extension: '.tar.gz'
- kind: macOS
os: macos-latest
target: osx-x64
extension: '.tar.gz'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # get all history for version number calculation
- name: 提取版本号
id: get_version
shell: bash
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: 缓存NuGet包
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: 安装Linux依赖
if: matrix.kind == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev libkrb5-dev
- name: 设置Windows环境
if: matrix.kind == 'windows'
shell: pwsh
run: |
Write-Host "Setting up Windows build environment..."
# Ensure latest developer tools
choco install visualstudio2022buildtools -y --no-progress
- name: 恢复依赖
run: dotnet restore ./SharpIco/SharpIco.csproj
- name: AOT编译
run: |
echo "Starting AOT compilation for ${{ matrix.kind }} platform..."
dotnet publish ./SharpIco/SharpIco.csproj -c Release -r ${{ matrix.target }} --self-contained true -p:PublishAot=true -p:Version=${{ steps.get_version.outputs.VERSION }} -o ./publish/${{ matrix.kind }}
- name: 打包Windows可执行文件
if: matrix.kind == 'windows'
run: |
cd ./publish/${{ matrix.kind }}
7z a -tzip ../../SharpIco-${{ matrix.kind }}-${{ steps.get_version.outputs.VERSION }}${{ matrix.extension }} *
- name: 打包Linux/macOS可执行文件
if: matrix.kind != 'windows'
run: |
cd ./publish/${{ matrix.kind }}
tar -czvf ../../SharpIco-${{ matrix.kind }}-${{ steps.get_version.outputs.VERSION }}${{ matrix.extension }} *
# Upload build artifacts as workflow artifacts
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: SharpIco-${{ matrix.kind }}-${{ steps.get_version.outputs.VERSION }}
path: ./SharpIco-${{ matrix.kind }}-${{ steps.get_version.outputs.VERSION }}${{ matrix.extension }}
retention-days: 1
# Step 3: Upload all platform executables to GitHub Release
upload-to-release:
needs: build-executables
runs-on: ubuntu-latest
steps:
- name: 提取版本号
id: get_version
shell: bash
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
# Download all platform build artifacts
- name: 下载Windows构建产物
uses: actions/download-artifact@v4
with:
name: SharpIco-windows-${{ steps.get_version.outputs.VERSION }}
path: ./artifacts
- name: 下载Linux构建产物
uses: actions/download-artifact@v4
with:
name: SharpIco-linux-${{ steps.get_version.outputs.VERSION }}
path: ./artifacts
- name: 下载macOS构建产物
uses: actions/download-artifact@v4
with:
name: SharpIco-macOS-${{ steps.get_version.outputs.VERSION }}
path: ./artifacts
# List downloaded files to confirm
- name: 列出下载的文件
run: ls -la ./artifacts
# Upload to GitHub Release
- name: 上传所有文件到GitHub Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: ./artifacts/*
tag_name: ${{ github.ref }}
fail_on_unmatched_files: false
draft: false
name: SharpIco 版本 ${{ steps.get_version.outputs.VERSION }}
generate_release_notes: true
Workflow Overview
The entire workflow consists of three Jobs:
| Phase | Description |
|---|---|
publish-nuget |
Build the project and upload .nupkg to NuGet.org |
build-executables |
Compile AOT native executables for three platforms |
upload-to-release |
Upload all artifacts to the GitHub Release corresponding to the current tag |
The NuGet publishing part is relatively simple, so I won't repeat it here.
Compiling AOT Executables for Three Platforms (build-executables)
Using the GitHub Matrix build strategy, targeting:
win-x64(Windows executable, ZIP packaged)linux-x64(Linux ELF executable, tar.gz packaged)osx-x64(macOS executable, tar.gz packaged)
dotnet publish -c Release -r ${{ matrix.target }} --self-contained true -p:PublishAot=true
Each platform generates a corresponding archive, then uploaded for intermediate storage using upload-artifact.
Uploading to GitHub Release (upload-to-release)
This phase automatically downloads the previously built artifacts and packages them into the Release page of the current version:
uses: softprops/action-gh-release@v1
The release page automatically generates release notes (generate_release_notes: true), making it convenient for users to view version changes.
The final result is shown in the diagram:
🔖 v1.0.0
├── SharpIco-windows-v1.0.0.zip
├── SharpIco-linux-v1.0.0.tar.gz
└── SharpIco-macOS-v1.0.0.tar.gz
Pitfalls
The biggest pitfall here was that using actions/upload-artifact@v3 kept reporting errors. Later, I checked the issues and found that it had been deprecated.
Upgrading to v4 solved the problem.
Another issue was that publishing to GitHub Release kept causing conflicts, saying it already existed.
Even setting append didn't work...
Finally, I changed the approach to first upload for intermediate storage and then publishing all at once, and it finally succeeded 😂
Summary
SharpIco is a pure C# icon tool I built based on .NET 9 and AOT compilation capabilities, aiming to replace cumbersome dependencies and simplify the icon generation and verification process.
From ImageSharp image processing to manually assembling the ICO format with BinaryWriter; from building the command‑line experience with System.CommandLine to full process automation publishing with GitHub Actions; SharpIco is not only a practical tool but also a small yet beautiful engineering exploration.
It represents my pursuit of tool ideals: lightweight, pure, easy to integrate, cross‑platform, ready to use.
- No need to install Python, Node, or ImageMagick.
- Supports AOT compilation to generate native executables.
- Has both icon generation and structure analysis capabilities.
- One publish automatically produces build results for all platforms.
Welcome to Star / Fork / Issue / PR, and feel free to integrate it into your build system.
📦 GitHub project address: 👉 https://github.com/star-plan/sharp-ico