Skip to content

Commit 8552bff

Browse files
azchohfinmetulev
andauthored
Add SVG support for asset generation (#326)
## Description Adds the Svg library as a dependency, and detects svg files on manifest update-assets command, converting to a Bitmap to generate the new assets. ## Usage Example ```bash winapp manifest update-assets .\image.svg ``` ## Type of Change - ✨ New feature ## Checklist <!-- Delete the ones that do not apply to your changes --> - [X] New tests added for new functionality (if applicable) - [X] Tested locally on Windows - [ ] Main [README.md](../README.md) updated (if applicable) - [ ] [docs/usage.md](../docs/usage.md) updated (if CLI commands changed) - [ ] [Language-specific guides](../docs/guides) updated (if applicable) - [ ] [Sample projects updated](../samples) to reflect changes (if applicable) ## AI Description <!-- ai-description-start --> This PR adds support for SVG files in the asset generation process of the winapp CLI. It includes the Svg.Skia library as a dependency and allows the `manifest update-assets` command to convert SVG files into bitmap images when generating assets. This enhancement simplifies the process for developers looking to utilize SVG graphics in their applications. Usage example: ```bash winapp manifest update-assets .\image.svg ``` <!-- ai-description-end --> --------- Co-authored-by: Nikola Metulev <nmetulev@users.noreply.github.com> Co-authored-by: Nikola Metulev <711864+nmetulev@users.noreply.github.com>
1 parent c346ef6 commit 8552bff

File tree

5 files changed

+175
-20
lines changed

5 files changed

+175
-20
lines changed

src/winapp-CLI/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
99
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.2" />
1010
<PackageVersion Include="Microsoft.Telemetry.Inbox.Managed" Version="10.0.25148.1001-220626-1600.rs-fun-deploy-dev5" />
11+
<PackageVersion Include="Svg.Skia" Version="3.4.1" />
1112
<PackageVersion Include="System.Drawing.Common" Version="10.0.2" />
1213
</ItemGroup>
1314
</Project>

src/winapp-CLI/WinApp.Cli.Tests/ManifestUpdateAssetsCommandTests.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,86 @@ public async Task ManifestUpdateAssetsCommandShouldInferManifestFromCurrentDirec
258258
var assetsDir = Path.Combine(_tempDirectory.FullName, "Assets");
259259
Assert.IsTrue(Directory.Exists(assetsDir), "Assets directory should be created");
260260
}
261+
262+
[TestMethod]
263+
public async Task ManifestUpdateAssetsCommandShouldGenerateAssetsFromSvg()
264+
{
265+
// Arrange
266+
var svgImagePath = Path.Combine(_tempDirectory.FullName, "testlogo.svg");
267+
PngHelper.CreateTestSvgImage(svgImagePath);
268+
269+
var updateAssetsCommand = GetRequiredService<ManifestUpdateAssetsCommand>();
270+
var args = new[]
271+
{
272+
svgImagePath,
273+
"--manifest", _testManifestPath
274+
};
275+
276+
// Act
277+
var parseResult = updateAssetsCommand.Parse(args);
278+
var exitCode = await parseResult.InvokeAsync();
279+
280+
// Assert
281+
Assert.AreEqual(0, exitCode, "Update-assets command should complete successfully with SVG source");
282+
283+
// Verify Assets directory was created
284+
var assetsDir = Path.Combine(_tempDirectory.FullName, "Assets");
285+
Assert.IsTrue(Directory.Exists(assetsDir), "Assets directory should be created");
286+
287+
// Verify assets referenced in manifest were generated
288+
var expectedAssets = new[]
289+
{
290+
"Square44x44Logo.png",
291+
"Square150x150Logo.png",
292+
"Wide310x150Logo.png",
293+
"StoreLogo.png"
294+
};
295+
296+
foreach (var asset in expectedAssets)
297+
{
298+
var assetPath = Path.Combine(assetsDir, asset);
299+
Assert.IsTrue(File.Exists(assetPath), $"Asset {asset} should be generated from SVG source");
300+
}
301+
}
302+
303+
[TestMethod]
304+
public async Task ManifestUpdateAssetsCommandShouldGenerateCorrectSizesFromSvg()
305+
{
306+
// Arrange
307+
var svgImagePath = Path.Combine(_tempDirectory.FullName, "testlogo.svg");
308+
PngHelper.CreateTestSvgImage(svgImagePath);
309+
310+
var updateAssetsCommand = GetRequiredService<ManifestUpdateAssetsCommand>();
311+
var args = new[]
312+
{
313+
svgImagePath,
314+
"--manifest", _testManifestPath
315+
};
316+
317+
// Act
318+
var parseResult = updateAssetsCommand.Parse(args);
319+
var exitCode = await parseResult.InvokeAsync();
320+
321+
// Assert
322+
Assert.AreEqual(0, exitCode, "Update-assets command should complete successfully with SVG source");
323+
324+
var assetsDir = Path.Combine(_tempDirectory.FullName, "Assets");
325+
326+
// Verify scale-200 assets exist with correct dimensions (2x the base size)
327+
var scale200_44 = Path.Combine(assetsDir, "Square44x44Logo.scale-200.png");
328+
Assert.IsTrue(File.Exists(scale200_44), "Square44x44Logo.scale-200.png should exist when generated from SVG");
329+
using (var bmp44 = new System.Drawing.Bitmap(scale200_44))
330+
{
331+
Assert.AreEqual(88, bmp44.Width, "Square44x44Logo.scale-200 should be 88px wide (44 * 2)");
332+
Assert.AreEqual(88, bmp44.Height, "Square44x44Logo.scale-200 should be 88px tall (44 * 2)");
333+
}
334+
335+
var scale200_150 = Path.Combine(assetsDir, "Square150x150Logo.scale-200.png");
336+
Assert.IsTrue(File.Exists(scale200_150), "Square150x150Logo.scale-200.png should exist when generated from SVG");
337+
using (var bmp150 = new System.Drawing.Bitmap(scale200_150))
338+
{
339+
Assert.AreEqual(300, bmp150.Width, "Square150x150Logo.scale-200 should be 300px wide (150 * 2)");
340+
Assert.AreEqual(300, bmp150.Height, "Square150x150Logo.scale-200 should be 300px tall (150 * 2)");
341+
}
342+
}
261343
}

src/winapp-CLI/WinApp.Cli.Tests/PngHelper.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ internal static void CreateTestImage(string path)
2525
File.WriteAllBytes(path, pngData);
2626
}
2727

28+
internal static void CreateTestSvgImage(string path)
29+
{
30+
var svgContent = """
31+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
32+
<rect width="100" height="100" fill="blue"/>
33+
</svg>
34+
""";
35+
File.WriteAllText(path, svgContent);
36+
}
37+
2838
/// <summary>
2939
/// Verifies that all pixels in the image are fully transparent (alpha = 0).
3040
/// </summary>

src/winapp-CLI/WinApp.Cli/Services/ImageAssetService.cs

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using SkiaSharp;
5+
using Svg.Skia;
46
using System.Drawing;
57
using System.Drawing.Drawing2D;
68
using System.Drawing.Imaging;
@@ -47,15 +49,7 @@ public async Task GenerateAssetsAsync(FileInfo sourceImagePath, DirectoryInfo ou
4749
Bitmap sourceImage;
4850
try
4951
{
50-
if (sourceImagePath.Extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
51-
{
52-
using var icon = new Icon(sourceImagePath.FullName);
53-
sourceImage = icon.ToBitmap();
54-
}
55-
else
56-
{
57-
sourceImage = new Bitmap(sourceImagePath.FullName);
58-
}
52+
sourceImage = LoadSourceImage(sourceImagePath);
5953
}
6054
catch (Exception ex)
6155
{
@@ -123,15 +117,7 @@ public async Task GenerateAssetsFromManifestAsync(
123117
Bitmap sourceImage;
124118
try
125119
{
126-
if (sourceImagePath.Extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
127-
{
128-
using var icon = new Icon(sourceImagePath.FullName);
129-
sourceImage = icon.ToBitmap();
130-
}
131-
else
132-
{
133-
sourceImage = new Bitmap(sourceImagePath.FullName);
134-
}
120+
sourceImage = LoadSourceImage(sourceImagePath);
135121
}
136122
catch (Exception ex)
137123
{
@@ -214,6 +200,81 @@ public async Task GenerateAssetsFromManifestAsync(
214200
}
215201
}
216202

203+
private static Bitmap LoadSourceImage(FileInfo sourceImagePath)
204+
{
205+
if (sourceImagePath.Extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
206+
{
207+
using var icon = new Icon(sourceImagePath.FullName);
208+
return icon.ToBitmap();
209+
}
210+
211+
if (sourceImagePath.Extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
212+
{
213+
return LoadSvgAsBitmap(sourceImagePath);
214+
}
215+
216+
return new Bitmap(sourceImagePath.FullName);
217+
}
218+
219+
private static Bitmap LoadSvgAsBitmap(FileInfo sourceImagePath)
220+
{
221+
var svg = new SKSvg();
222+
using var stream = File.OpenRead(sourceImagePath.FullName);
223+
svg.Load(stream);
224+
225+
var picture = svg.Picture ?? throw new InvalidOperationException(
226+
$"Failed to render SVG image: {sourceImagePath.FullName}. The file may be corrupted or contain unsupported SVG features.");
227+
var bounds = picture.CullRect;
228+
229+
int width = (int)Math.Ceiling(bounds.Width);
230+
int height = (int)Math.Ceiling(bounds.Height);
231+
232+
if (width <= 0 || height <= 0)
233+
{
234+
throw new InvalidOperationException(
235+
$"SVG image has invalid dimensions ({width}x{height}): {sourceImagePath.FullName}. Ensure the SVG has a valid viewBox or width/height attributes.");
236+
}
237+
238+
// Render at a reasonable minimum size for quality when scaling down to asset sizes
239+
const float minRenderDimension = 1024f;
240+
float scaleFactor = 1f;
241+
if (width < minRenderDimension || height < minRenderDimension)
242+
{
243+
scaleFactor = Math.Max(minRenderDimension / width, minRenderDimension / height);
244+
width = (int)Math.Ceiling(bounds.Width * scaleFactor);
245+
height = (int)Math.Ceiling(bounds.Height * scaleFactor);
246+
}
247+
248+
// Render SVG to SKBitmap, then convert to System.Drawing.Bitmap
249+
using var skBitmap = new SKBitmap(width, height);
250+
using (var canvas = new SKCanvas(skBitmap))
251+
{
252+
canvas.Clear(SKColors.Transparent);
253+
254+
// Translate to handle non-zero origin bounds, then scale
255+
if (bounds.Left != 0 || bounds.Top != 0)
256+
{
257+
canvas.Translate(-bounds.Left * scaleFactor, -bounds.Top * scaleFactor);
258+
}
259+
260+
if (scaleFactor > 1f)
261+
{
262+
canvas.Scale(scaleFactor);
263+
}
264+
265+
canvas.DrawPicture(picture);
266+
canvas.Flush();
267+
}
268+
269+
using var image = SKImage.FromBitmap(skBitmap);
270+
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
271+
272+
// Create the Bitmap from a fresh MemoryStream that it will own.
273+
// Bitmap keeps a reference to the stream, so we must NOT dispose it.
274+
var ms = new MemoryStream(data.ToArray());
275+
return new Bitmap(ms);
276+
}
277+
217278
private static async Task GenerateAssetAsync(Bitmap sourceImage, string outputPath, int targetWidth, int targetHeight, CancellationToken cancellationToken)
218279
{
219280
await Task.Run(() =>

src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
@@ -26,7 +26,7 @@
2626
<TrimMode>full</TrimMode>
2727
<CsWin32RunAsBuildTask>true</CsWin32RunAsBuildTask>
2828
<DisableRuntimeMarshalling>true</DisableRuntimeMarshalling>
29-
29+
3030
<!-- Faster startup for single-file -->
3131
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
3232
<DebuggerSupport>false</DebuggerSupport>
@@ -54,6 +54,7 @@
5454
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
5555
</PackageReference>
5656
<PackageReference Include="Spectre.Console" />
57+
<PackageReference Include="Svg.Skia" />
5758
<PackageReference Include="System.CommandLine" />
5859
<PackageReference Include="System.Diagnostics.EventLog" />
5960
<PackageReference Include="System.Drawing.Common" />

0 commit comments

Comments
 (0)