Add plain and themed SVG icon protocols

This commit is contained in:
Jiří Polášek
2026-08-12 07:02:22 +02:00
parent 5ee7131b26
commit e4bcc72bf3
12 changed files with 1124 additions and 1 deletions

View File

@@ -13,4 +13,8 @@ internal enum IconLoadInputKind
SpecializedAppIcon, SpecializedAppIcon,
GeneratedSwatch, GeneratedSwatch,
GeneratedInitials, GeneratedInitials,
SvgFile,
SvgInline,
ThemedSvgFile,
ThemedSvgInline,
} }

View File

@@ -13,6 +13,7 @@ internal static class IconProtocolRegistry
[ [
AppIconProtocolProcessor.Instance, AppIconProtocolProcessor.Instance,
GeneratedIconProtocolProcessor.Instance, GeneratedIconProtocolProcessor.Instance,
SvgIconProtocolProcessor.Instance,
]; ];
static IconProtocolRegistry() static IconProtocolRegistry()

View File

@@ -0,0 +1,229 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text;
namespace Microsoft.CmdPal.UI.Helpers;
internal static class SvgFileTextReader
{
private const int MaximumXmlDeclarationByteCount = 1024;
private const int ReaderBufferSize = 1024;
private static readonly Encoding Utf32LittleEndian = new UTF32Encoding(
bigEndian: false,
byteOrderMark: false);
private static readonly Encoding Utf32BigEndian = new UTF32Encoding(
bigEndian: true,
byteOrderMark: false);
public static bool TryRead(string path, out string text)
{
text = string.Empty;
using var stream = File.OpenRead(path);
// An XML declaration contains only version, encoding, and standalone.
// Bound the probe so a malformed file cannot grow stack or parsing work.
Span<byte> prefix = stackalloc byte[MaximumXmlDeclarationByteCount];
var prefixLength = stream.ReadAtLeast(
prefix,
prefix.Length,
throwOnEndOfStream: false);
if (!TryGetEncoding(prefix[..prefixLength], out var encoding))
{
return false;
}
stream.Position = 0;
using var reader = new StreamReader(
stream,
encoding,
detectEncodingFromByteOrderMarks: true,
bufferSize: ReaderBufferSize);
text = reader.ReadToEnd();
return true;
}
private static bool TryGetEncoding(ReadOnlySpan<byte> prefix, out Encoding encoding)
{
encoding = Encoding.UTF8;
// StreamReader handles BOMs. These signatures cover BOM-less UTF-16 and
// UTF-32, whose zero bytes prevent reading the declaration as ASCII.
if (prefix.Length >= 4)
{
if (prefix[0] == 0x00 && prefix[1] == 0x00 && prefix[2] == 0x00 && prefix[3] == 0x3C)
{
encoding = Utf32BigEndian;
return true;
}
if (prefix[0] == 0x3C && prefix[1] == 0x00 && prefix[2] == 0x00 && prefix[3] == 0x00)
{
encoding = Utf32LittleEndian;
return true;
}
if (prefix[0] == 0x00 && prefix[1] == 0x3C && prefix[2] == 0x00)
{
encoding = Encoding.BigEndianUnicode;
return true;
}
if (prefix[0] == 0x3C && prefix[1] == 0x00 && prefix[3] == 0x00)
{
encoding = Encoding.Unicode;
return true;
}
}
if (!TryGetDeclaredEncodingName(prefix, out var encodingName))
{
return false;
}
return encodingName is null || TryResolveEncoding(encodingName, out encoding);
}
private static bool TryGetDeclaredEncodingName(
ReadOnlySpan<byte> prefix,
out string? encodingName)
{
encodingName = null;
var declarationStart = 0;
while (declarationStart < prefix.Length && IsAsciiWhitespace(prefix[declarationStart]))
{
declarationStart++;
}
if (!StartsWithXmlDeclaration(prefix[declarationStart..]))
{
return true;
}
var declarationEnd = -1;
for (var index = declarationStart + 5; index + 1 < prefix.Length; index++)
{
if (prefix[index] == '?' && prefix[index + 1] == '>')
{
declarationEnd = index;
break;
}
}
if (declarationEnd < 0)
{
return false;
}
var declarationBytes = prefix[declarationStart..(declarationEnd + 2)];
foreach (var value in declarationBytes)
{
if (value > 0x7F)
{
return false;
}
}
var declaration = Encoding.ASCII.GetString(declarationBytes);
var searchStart = 5;
while (searchStart < declaration.Length)
{
var relativeIndex = declaration.AsSpan(searchStart)
.IndexOf("encoding", StringComparison.OrdinalIgnoreCase);
if (relativeIndex < 0)
{
return true;
}
var encodingIndex = searchStart + relativeIndex;
var valueStart = encodingIndex + "encoding".Length;
if (char.IsWhiteSpace(declaration[encodingIndex - 1])
&& (declaration[valueStart] == '=' || char.IsWhiteSpace(declaration[valueStart])))
{
while (valueStart < declaration.Length && char.IsWhiteSpace(declaration[valueStart]))
{
valueStart++;
}
if (valueStart == declaration.Length || declaration[valueStart++] != '=')
{
return false;
}
while (valueStart < declaration.Length && char.IsWhiteSpace(declaration[valueStart]))
{
valueStart++;
}
if (valueStart == declaration.Length || declaration[valueStart] is not ('\'' or '"'))
{
return false;
}
var quote = declaration[valueStart++];
var valueEnd = declaration.IndexOf(quote, valueStart);
if (valueEnd < 0 || valueEnd == valueStart)
{
return false;
}
encodingName = declaration[valueStart..valueEnd];
return true;
}
searchStart = valueStart;
}
return true;
}
private static bool TryResolveEncoding(string encodingName, out Encoding encoding)
{
try
{
encoding = Encoding.GetEncoding(encodingName);
return true;
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException)
{
try
{
// Query the provider directly instead of changing process-wide encoding behavior.
var codePageEncoding = CodePagesEncodingProvider.Instance.GetEncoding(encodingName);
if (codePageEncoding is not null)
{
encoding = codePageEncoding;
return true;
}
}
catch (Exception providerException) when (providerException is ArgumentException or NotSupportedException)
{
}
}
encoding = Encoding.UTF8;
return false;
}
private static bool StartsWithXmlDeclaration(ReadOnlySpan<byte> value) =>
value.Length >= 6
&& value[0] == '<'
&& value[1] == '?'
&& ToAsciiLower(value[2]) == 'x'
&& ToAsciiLower(value[3]) == 'm'
&& ToAsciiLower(value[4]) == 'l'
&& (IsAsciiWhitespace(value[5]) || value[5] == '?');
private static char ToAsciiLower(byte value) =>
value is >= (byte)'A' and <= (byte)'Z'
? (char)(value + ('a' - 'A'))
: (char)value;
private static bool IsAsciiWhitespace(byte value) =>
value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
}

View File

@@ -0,0 +1,376 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text;
using Microsoft.UI.Xaml;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Resolves plain <c>|Svg|payload</c> and theme-aware
/// <c>|ThemedSvg|[accent|]payload</c> icon strings. A payload is either inline SVG
/// or the path to an SVG file.
/// </summary>
/// <remarks>
/// Plain SVGs are passed through without placeholder expansion and share cache entries
/// across themes. Themed SVGs replace <c>{{ThemeColor}}</c> and <c>{{AccentColor}}</c>
/// and use distinct light- and dark-theme cache entries. Accent values may be opaque
/// SVG 1.1 hex colors or one of: danger, subtle, info, warning, success, neutral,
/// dark, or normal. Express transparency in the SVG template with <c>fill-opacity</c>,
/// <c>stroke-opacity</c>, or <c>opacity</c>.
/// SVG files are treated as immutable while cached.
/// </remarks>
internal static class SvgIconProtocol
{
private const string PlainPrefix = "|Svg|";
private const string ThemedPrefix = "|ThemedSvg|";
private static readonly string[] ProtocolPrefixValues = [PlainPrefix, ThemedPrefix];
private const string ThemeColorPlaceholder = "{{ThemeColor}}";
private const string AccentColorPlaceholder = "{{AccentColor}}";
private const string LightThemeColor = "#000000";
private const string DarkThemeColor = "#FFFFFF";
public static ReadOnlySpan<string> ProtocolPrefixes => ProtocolPrefixValues;
public static string GetCacheIdentity(string value)
{
if (!value.StartsWith(ThemedPrefix, StringComparison.Ordinal))
{
return value;
}
var untrimmed = value.AsSpan(ThemedPrefix.Length);
var remaining = untrimmed.TrimStart();
if (remaining.IsEmpty || remaining[0] == '<')
{
return value;
}
var separator = remaining.IndexOf('|');
if (separator < 0)
{
return value;
}
var accent = remaining[..separator];
if (accent.IsEmpty)
{
return value;
}
var isHex = accent[0] == '#';
// Canonical cache hits need only a casing scan. Validate an accent only
// when rewriting it, so malformed requests keep their original identity.
if (HasCanonicalAccentCasing(accent, isHex) || !IsSupportedSvgAccent(accent))
{
return value;
}
var accentStart = ThemedPrefix.Length + untrimmed.Length - remaining.Length;
return string.Create(
value.Length,
(Value: value, AccentStart: accentStart, AccentLength: accent.Length, IsHex: isHex),
static (destination, state) =>
{
state.Value.AsSpan().CopyTo(destination);
var normalizedAccent = destination.Slice(state.AccentStart, state.AccentLength);
for (var index = state.IsHex ? 1 : 0; index < normalizedAccent.Length; index++)
{
if (state.IsHex)
{
if (normalizedAccent[index] is >= 'a' and <= 'f')
{
normalizedAccent[index] = (char)(normalizedAccent[index] - ('a' - 'A'));
}
}
else if (normalizedAccent[index] is >= 'A' and <= 'Z')
{
normalizedAccent[index] = (char)(normalizedAccent[index] + ('a' - 'A'));
}
}
});
}
private static bool HasCanonicalAccentCasing(ReadOnlySpan<char> accent, bool isHex)
{
foreach (var character in isHex ? accent[1..] : accent)
{
if (isHex
? character is >= 'a' and <= 'f'
: character is >= 'A' and <= 'Z')
{
return false;
}
}
return true;
}
public static bool IsProtocol(string? value) =>
value?.StartsWith(PlainPrefix, StringComparison.Ordinal) == true
|| value?.StartsWith(ThemedPrefix, StringComparison.Ordinal) == true;
public static Kind Classify(string? value)
{
if (value?.StartsWith(PlainPrefix, StringComparison.Ordinal) == true)
{
return IsInline(value.AsSpan(PlainPrefix.Length)) ? Kind.PlainInline : Kind.PlainFile;
}
if (value?.StartsWith(ThemedPrefix, StringComparison.Ordinal) != true)
{
return Kind.None;
}
var payload = value.AsSpan(ThemedPrefix.Length).TrimStart();
if (!payload.IsEmpty && payload[0] != '<')
{
var separator = payload.IndexOf('|');
if (separator >= 0 && IsSupportedSvgAccent(payload[..separator]))
{
payload = payload[(separator + 1)..];
}
}
return IsInline(payload) ? Kind.ThemedInline : Kind.ThemedFile;
}
public static ElementTheme GetCacheTheme(string? value, ElementTheme theme) =>
value?.StartsWith(ThemedPrefix, StringComparison.Ordinal) == true
? theme == ElementTheme.Dark ? ElementTheme.Dark : ElementTheme.Light
: ElementTheme.Default;
public static bool TryCreateSvg(string? value, ElementTheme theme, out byte[] svg)
{
svg = [];
try
{
switch (Classify(value))
{
case Kind.PlainFile:
case Kind.PlainInline:
return TryCreatePlainSvg(value!, out svg);
case Kind.ThemedFile:
case Kind.ThemedInline:
return TryCreateThemedSvg(value!, theme, out svg);
default:
return false;
}
}
catch
{
svg = [];
return false;
}
}
private static bool TryCreatePlainSvg(string value, out byte[] svg)
{
svg = [];
var payload = value[PlainPrefix.Length..];
if (string.IsNullOrWhiteSpace(payload))
{
return false;
}
if (IsInline(payload))
{
// Inline strings have no source encoding; UTF-8 is the protocol encoding.
// Remove a declaration that would describe the caller's original bytes,
// not the UTF-8 bytes emitted by this protocol.
svg = Encoding.UTF8.GetBytes(RemoveXmlDeclaration(payload));
return true;
}
if (!IsSvgPath(payload))
{
return false;
}
// IconPathConverter.Prepare invokes this on an icon-loader worker, so
// filesystem access never blocks the WinUI STA thread. Reading bytes also
// preserves the file's original encoding and XML declaration exactly.
svg = File.ReadAllBytes(payload);
return svg.Length > 0;
}
private static bool TryCreateThemedSvg(string value, ElementTheme theme, out byte[] svg)
{
svg = [];
if (!TryParseThemedPayload(value, theme, out var payload, out var accentColor))
{
return false;
}
string template;
if (IsInline(payload))
{
template = payload;
}
else
{
if (!IsSvgPath(payload))
{
return false;
}
// This path is reached only from an icon-loader worker; see the plain
// SVG path above. Honor either a BOM or a BOM-less XML encoding
// declaration before the expanded result is re-encoded as UTF-8.
if (!SvgFileTextReader.TryRead(payload, out template))
{
return false;
}
}
if (string.IsNullOrWhiteSpace(template))
{
return false;
}
// A source file may declare a different encoding. Drop that now-stale
// declaration before emitting the expanded SVG as UTF-8.
template = RemoveXmlDeclaration(template);
var themeColor = theme == ElementTheme.Dark ? DarkThemeColor : LightThemeColor;
var resolved = template
.Replace(ThemeColorPlaceholder, themeColor, StringComparison.Ordinal)
.Replace(AccentColorPlaceholder, accentColor, StringComparison.Ordinal);
svg = Encoding.UTF8.GetBytes(resolved);
return true;
}
private static bool TryParseThemedPayload(
string value,
ElementTheme theme,
out string payload,
out string accentColor)
{
payload = string.Empty;
accentColor = SemanticIconColor.GetDefault(theme);
var remaining = value.AsSpan(ThemedPrefix.Length).TrimStart();
if (remaining.IsEmpty)
{
return false;
}
if (remaining[0] != '<')
{
var separator = remaining.IndexOf('|');
if (separator >= 0)
{
if (!TryResolveAccent(remaining[..separator], theme, out accentColor))
{
return false;
}
remaining = remaining[(separator + 1)..].TrimStart();
if (remaining.IsEmpty)
{
return false;
}
}
}
payload = remaining.ToString();
return true;
}
private static bool TryResolveAccent(
ReadOnlySpan<char> value,
ElementTheme theme,
out string accentColor)
{
if (!IsSupportedSvgAccent(value))
{
accentColor = string.Empty;
return false;
}
if (IsOpaqueSvgHexColor(value))
{
accentColor = value.ToString();
return true;
}
return SemanticIconColor.TryResolve(value, theme, out accentColor);
}
private static bool IsSupportedSvgAccent(ReadOnlySpan<char> value)
{
if (IsOpaqueSvgHexColor(value))
{
return true;
}
return SemanticIconColor.TryResolvePair(value, out var light, out var dark)
&& IsOpaqueSvgHexColor(light)
&& IsOpaqueSvgHexColor(dark);
}
private static bool IsOpaqueSvgHexColor(ReadOnlySpan<char> value)
{
if (value.IsEmpty || value[0] != '#' || value.Length is not (4 or 7))
{
return false;
}
for (var index = 1; index < value.Length; index++)
{
if (!Uri.IsHexDigit(value[index]))
{
return false;
}
}
return true;
}
private static bool IsSvgPath(string value) =>
Path.GetExtension(value).Equals(".svg", StringComparison.OrdinalIgnoreCase);
private static bool IsInline(string value) => IsInline(value.AsSpan());
private static bool IsInline(ReadOnlySpan<char> value)
{
value = value.TrimStart();
return !value.IsEmpty && value[0] == '<';
}
private static string RemoveXmlDeclaration(string template)
{
var firstNonWhitespace = 0;
while (firstNonWhitespace < template.Length && char.IsWhiteSpace(template[firstNonWhitespace]))
{
firstNonWhitespace++;
}
var candidate = template.AsSpan(firstNonWhitespace);
if (!candidate.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase)
|| candidate.Length <= 5
|| (!char.IsWhiteSpace(candidate[5]) && candidate[5] != '?'))
{
return template;
}
var declarationEnd = template.IndexOf("?>", firstNonWhitespace + 5, StringComparison.Ordinal);
return declarationEnd >= 0
? template.Remove(firstNonWhitespace, (declarationEnd + 2) - firstNonWhitespace)
: template;
}
internal enum Kind
{
None,
PlainFile,
PlainInline,
ThemedFile,
ThemedInline,
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
namespace Microsoft.CmdPal.UI.Helpers;
internal sealed class SvgIconProtocolProcessor : IIconProtocolProcessor
{
public static SvgIconProtocolProcessor Instance { get; } = new();
private SvgIconProtocolProcessor()
{
}
public IconCachePartition CachePartition => IconCachePartition.Other;
public ReadOnlySpan<string> ProtocolPrefixes => SvgIconProtocol.ProtocolPrefixes;
public string GetCacheIdentity(string value) => SvgIconProtocol.GetCacheIdentity(value);
public ElementTheme GetCacheTheme(string value, ElementTheme theme) =>
SvgIconProtocol.GetCacheTheme(value, theme);
public IconLoadInputKind ClassifyInput(string value) =>
SvgIconProtocol.Classify(value) switch
{
SvgIconProtocol.Kind.PlainFile => IconLoadInputKind.SvgFile,
SvgIconProtocol.Kind.PlainInline => IconLoadInputKind.SvgInline,
SvgIconProtocol.Kind.ThemedFile => IconLoadInputKind.ThemedSvgFile,
SvgIconProtocol.Kind.ThemedInline => IconLoadInputKind.ThemedSvgInline,
_ => IconLoadInputKind.String,
};
public bool TryPrepareSynchronously(
string value,
int targetSize,
ElementTheme theme,
out IconPathConverter.PreparedIcon preparedIcon)
{
preparedIcon = SvgIconProtocol.TryCreateSvg(value, theme, out var svg)
? IconPathConverter.PreparedIcon.FromSvgData(svg, targetSize)
: IconPathConverter.PreparedIcon.Empty();
return true;
}
public ValueTask<IconProtocolProcessingResult> PrepareAsync(
string value,
int targetSize,
ElementTheme theme)
{
_ = TryPrepareSynchronously(value, targetSize, theme, out var preparedIcon);
return ValueTask.FromResult(IconProtocolProcessingResult.FromPreparedIcon(preparedIcon));
}
}

View File

@@ -233,6 +233,56 @@ public partial class CachedIconSourceProviderTests
Assert.AreEqual(1, loader.EnqueueCount); Assert.AreEqual(1, loader.EnqueueCount);
} }
[TestMethod]
[Timeout(5_000)]
public async Task ThemedSvgProtocolUsesDistinctCacheEntriesAcrossThemes()
{
var loader = new ControllableIconLoader();
var provider = CreateProvider(loader);
var icon = new IconDataViewModel
{
Icon = "|ThemedSvg|<svg xmlns=\"http://www.w3.org/2000/svg\"><path fill=\"{{ThemeColor}}\"/></svg>",
};
var light = provider.GetIconSource(icon, 1.0, theme: ElementTheme.Light);
loader.CompleteNext(null);
await light;
Assert.IsTrue(SpinWait.SpinUntil(() => GetInFlightCount(provider) == 0, TimeSpan.FromSeconds(2)));
var dark = provider.GetIconSource(icon, 1.0, theme: ElementTheme.Dark);
Assert.AreNotSame(light, dark);
loader.CompleteNext(null);
await dark;
Assert.IsTrue(
SpinWait.SpinUntil(
() => GetInFlightCount(provider) == 0 && GetCacheCount(provider, "_otherCache") == 2,
TimeSpan.FromSeconds(2)));
Assert.AreSame(light, provider.GetIconSource(icon, 1.0, theme: ElementTheme.Light));
Assert.AreSame(dark, provider.GetIconSource(icon, 1.0, theme: ElementTheme.Dark));
Assert.AreEqual(2, loader.EnqueueCount);
}
[TestMethod]
[Timeout(5_000)]
public async Task PlainSvgProtocolSharesCacheEntryAcrossThemes()
{
var loader = new ControllableIconLoader();
var provider = CreateProvider(loader);
var icon = new IconDataViewModel
{
Icon = "|Svg|<svg xmlns=\"http://www.w3.org/2000/svg\"><path fill=\"#0067C0\"/></svg>",
};
var light = provider.GetIconSource(icon, 1.0, theme: ElementTheme.Light);
loader.CompleteNext(null);
await light;
Assert.IsTrue(SpinWait.SpinUntil(() => GetInFlightCount(provider) == 0, TimeSpan.FromSeconds(2)));
Assert.AreSame(light, provider.GetIconSource(icon, 1.0, theme: ElementTheme.Dark));
Assert.AreEqual(1, loader.EnqueueCount);
}
[TestMethod] [TestMethod]
[Timeout(5_000)] [Timeout(5_000)]
public async Task FailedLoadIsRemovedAndCanBeRetried() public async Task FailedLoadIsRemovedAndCanBeRetried()

View File

@@ -247,7 +247,11 @@ public class IconLoadDiagnosticsTests
[DataTestMethod] [DataTestMethod]
[DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch")] [DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch")]
[DataRow("|Initials|CP|#FF005FB8|square|", "GeneratedInitials")] [DataRow("|Initials|CP|#FF005FB8|square|", "GeneratedInitials")]
public void GeneratedIconProtocolUsesSpecificInputKind(string icon, string expectedKind) [DataRow("|Svg|C:\\Icons\\plain.svg", "SvgFile")]
[DataRow("|Svg|<svg xmlns=\"http://www.w3.org/2000/svg\"/>", "SvgInline")]
[DataRow("|ThemedSvg|warning|C:\\Icons\\themed.svg", "ThemedSvgFile")]
[DataRow("|ThemedSvg|#7A3E9D|<svg xmlns=\"http://www.w3.org/2000/svg\"/>", "ThemedSvgInline")]
public void SpecialIconProtocolsUseSpecificInputKind(string icon, string expectedKind)
{ {
IconLoadDiagnostics.Start(); IconLoadDiagnostics.Start();
var request = IconLoadDiagnostics.BeginRequest(IconRequestReason.SourceChanged, 1.0); var request = IconLoadDiagnostics.BeginRequest(IconRequestReason.SourceChanged, 1.0);

View File

@@ -78,4 +78,42 @@ public class IconPathConverterTests
Assert.AreEqual(IconPathConverter.PreparedIconKind.Empty, prepared.Kind); Assert.AreEqual(IconPathConverter.PreparedIconKind.Empty, prepared.Kind);
} }
[TestMethod]
public void ThemedInlineSvgProtocolIsPreparedAsThemeSpecificSvgData()
{
const string Icon = "|ThemedSvg|warning|<svg xmlns=\"http://www.w3.org/2000/svg\"><path fill=\"{{ThemeColor}}\"/><path fill=\"{{AccentColor}}\"/></svg>";
using var prepared = IconPathConverter.Prepare(Icon, null, 20, ElementTheme.Dark);
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, prepared.Kind);
Assert.AreEqual(20, prepared.TargetSize);
var svg = Encoding.UTF8.GetString(prepared.SvgData!);
StringAssert.Contains(svg, "fill=\"#FFFFFF\"");
StringAssert.Contains(svg, "fill=\"#FCE100\"");
}
[TestMethod]
public void PlainInlineSvgProtocolDoesNotExpandPlaceholders()
{
const string Icon = "|Svg|<svg xmlns=\"http://www.w3.org/2000/svg\"><path fill=\"{{ThemeColor}}\"/><path fill=\"{{AccentColor}}\"/></svg>";
using var prepared = IconPathConverter.Prepare(Icon, null, 20, ElementTheme.Dark);
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, prepared.Kind);
Assert.AreEqual(20, prepared.TargetSize);
var svg = Encoding.UTF8.GetString(prepared.SvgData!);
StringAssert.Contains(svg, "fill=\"{{ThemeColor}}\"");
StringAssert.Contains(svg, "fill=\"{{AccentColor}}\"");
}
[TestMethod]
public void InvalidSvgProtocolDoesNotFallThroughToGlyphParsing()
{
var missingPath = Path.Combine(Path.GetTempPath(), $"CmdPal-{Guid.NewGuid():N}.svg");
using var prepared = IconPathConverter.Prepare($"|Svg|{missingPath}", null, 20, ElementTheme.Light);
Assert.AreEqual(IconPathConverter.PreparedIconKind.Empty, prepared.Kind);
}
} }

View File

@@ -91,6 +91,39 @@ public class IconProtocolRegistryTests
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon.Kind); Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon.Kind);
} }
[DataTestMethod]
[DataRow("|Svg|<svg/>", "SvgInline")]
[DataRow("|Svg|C:\\Icons\\sample.svg", "SvgFile")]
[DataRow("|ThemedSvg|warning|<svg/>", "ThemedSvgInline")]
[DataRow("|ThemedSvg|warning|C:\\Icons\\sample.svg", "ThemedSvgFile")]
public void BuiltInRegistryFindsSvgIconProcessor(string value, string inputKind)
{
var processor = IconProtocolRegistry.Find(value);
Assert.IsNotNull(processor);
Assert.AreSame(SvgIconProtocolProcessor.Instance, processor);
Assert.AreEqual(IconCachePartition.Other, processor.CachePartition);
Assert.AreEqual(inputKind, processor.ClassifyInput(value).ToString());
}
[TestMethod]
public void InlineSvgProtocolPreparesSynchronously()
{
const string Value = "|ThemedSvg|warning|<svg/>";
var processor = IconProtocolRegistry.Find(Value);
Assert.IsNotNull(processor);
Assert.IsTrue(processor.TryPrepareSynchronously(
Value,
20,
ElementTheme.Light,
out var preparedIcon));
using (preparedIcon)
{
Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon.Kind);
}
}
[DataTestMethod] [DataTestMethod]
[DataRow(null)] [DataRow(null)]
[DataRow("")] [DataRow("")]

View File

@@ -43,6 +43,9 @@
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\GeneratedIconProtocolProcessor.cs" Link="Helpers\Icons\GeneratedIconProtocolProcessor.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\GeneratedIconProtocolProcessor.cs" Link="Helpers\Icons\GeneratedIconProtocolProcessor.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\InitialsTextRenderer.cs" Link="Helpers\Icons\InitialsTextRenderer.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\InitialsTextRenderer.cs" Link="Helpers\Icons\InitialsTextRenderer.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SemanticIconColor.cs" Link="Helpers\Icons\SemanticIconColor.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SemanticIconColor.cs" Link="Helpers\Icons\SemanticIconColor.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgFileTextReader.cs" Link="Helpers\Icons\SvgFileTextReader.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgIconProtocol.cs" Link="Helpers\Icons\SvgIconProtocol.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgIconProtocolProcessor.cs" Link="Helpers\Icons\SvgIconProtocolProcessor.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgPathDataReceiver.cs" Link="Helpers\Icons\SvgPathDataReceiver.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\SvgPathDataReceiver.cs" Link="Helpers\Icons\SvgPathDataReceiver.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconCachePartition.cs" Link="Helpers\Icons\IconCachePartition.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconCachePartition.cs" Link="Helpers\Icons\IconCachePartition.cs" />
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconLoadDemand.cs" Link="Helpers\Icons\IconLoadDemand.cs" /> <Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconLoadDemand.cs" Link="Helpers\Icons\IconLoadDemand.cs" />

View File

@@ -0,0 +1,301 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.UnitTests;
[TestClass]
public class SvgIconProtocolTests
{
private const string Template = """
<svg xmlns="http://www.w3.org/2000/svg">
<path id="theme" fill="{{ThemeColor}}" />
<path id="accent" fill="{{AccentColor}}" />
</svg>
""";
private const string CurrentColorTemplate = """
<svg xmlns="http://www.w3.org/2000/svg" color="{{ThemeColor}}">
<path id="base" fill="currentColor" />
<path id="overlay" fill="{{AccentColor}}" />
</svg>
""";
[TestMethod]
public void PlainInlineSvgIsNotTransformed()
{
var value = $"|Svg|{Template}";
Assert.AreEqual(SvgIconProtocol.Kind.PlainInline, SvgIconProtocol.Classify(value));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
var expected = Encoding.UTF8.GetBytes(Template);
CollectionAssert.AreEqual(expected, lightSvg);
CollectionAssert.AreEqual(expected, darkSvg);
}
[TestMethod]
public void PlainInlineSvgDropsAStaleEncodingDeclaration()
{
const string svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><title>Žluťoučký kůň</title></svg>";
var value = $"|Svg|<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>{svg}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var result));
CollectionAssert.AreEqual(Encoding.UTF8.GetBytes(svg), result);
}
[TestMethod]
public void PlainInlineSvgPreservesXmlStylesheetProcessingInstruction()
{
const string svg = "<?xml-stylesheet href=\"icon.css\"?><svg xmlns=\"http://www.w3.org/2000/svg\" />";
var value = $"|Svg|{svg}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var result));
CollectionAssert.AreEqual(Encoding.UTF8.GetBytes(svg), result);
}
[TestMethod]
public void PlainSvgFilePreservesOriginalBytes()
{
var path = Path.Combine(Path.GetTempPath(), $"CmdPal-{Guid.NewGuid():N}.svg");
try
{
var template = $"<?xml version=\"1.0\" encoding=\"utf-16\"?>{Template}";
var content = Encoding.Unicode.GetBytes(template);
var preamble = Encoding.Unicode.GetPreamble();
var original = new byte[preamble.Length + content.Length];
Buffer.BlockCopy(preamble, 0, original, 0, preamble.Length);
Buffer.BlockCopy(content, 0, original, preamble.Length, content.Length);
File.WriteAllBytes(path, original);
var value = $"|Svg|{path}";
Assert.AreEqual(SvgIconProtocol.Kind.PlainFile, SvgIconProtocol.Classify(value));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var svg));
CollectionAssert.AreEqual(original, svg);
}
finally
{
File.Delete(path);
}
}
[TestMethod]
public void ThemedInlineSvgReplacesThemeAndDefaultInfoAccent()
{
var value = $"|ThemedSvg|{Template}";
Assert.AreEqual(SvgIconProtocol.Kind.ThemedInline, SvgIconProtocol.Classify(value));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
var light = Encoding.UTF8.GetString(lightSvg);
var dark = Encoding.UTF8.GetString(darkSvg);
StringAssert.Contains(light, "id=\"theme\" fill=\"#000000\"");
StringAssert.Contains(dark, "id=\"theme\" fill=\"#FFFFFF\"");
StringAssert.Contains(light, "id=\"accent\" fill=\"#0067C0\"");
StringAssert.Contains(dark, "id=\"accent\" fill=\"#60CDFF\"");
Assert.IsFalse(light.Contains("{{", StringComparison.Ordinal));
Assert.IsFalse(dark.Contains("{{", StringComparison.Ordinal));
}
[TestMethod]
public void ThemedSvgCanSetInheritedCurrentColorWithoutRewritingKeyword()
{
var value = $"|ThemedSvg|success|{CurrentColorTemplate}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
var light = Encoding.UTF8.GetString(lightSvg);
var dark = Encoding.UTF8.GetString(darkSvg);
StringAssert.Contains(light, "color=\"#000000\"");
StringAssert.Contains(dark, "color=\"#FFFFFF\"");
StringAssert.Contains(light, "id=\"base\" fill=\"currentColor\"");
StringAssert.Contains(dark, "id=\"base\" fill=\"currentColor\"");
StringAssert.Contains(light, "id=\"overlay\" fill=\"#0F7B0F\"");
StringAssert.Contains(dark, "id=\"overlay\" fill=\"#6CCB5F\"");
}
[DataTestMethod]
[DataRow("danger", "#C42B1C", "#FF99A4")]
[DataRow("subtle", "#616161", "#C5C5C5")]
[DataRow("info", "#0067C0", "#60CDFF")]
[DataRow("warning", "#9D5D00", "#FCE100")]
[DataRow("success", "#0F7B0F", "#6CCB5F")]
[DataRow("neutral", "#8A8A8A", "#9D9D9D")]
[DataRow("dark", "#1B1A19", "#1B1A19")]
[DataRow("normal", "#000000", "#FFFFFF")]
public void SemanticAccentUsesLightAndDarkPalette(
string semanticAccent,
string expectedLight,
string expectedDark)
{
var value = $"|ThemedSvg|{semanticAccent}|{Template}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
StringAssert.Contains(Encoding.UTF8.GetString(lightSvg), $"id=\"accent\" fill=\"{expectedLight}\"");
StringAssert.Contains(Encoding.UTF8.GetString(darkSvg), $"id=\"accent\" fill=\"{expectedDark}\"");
}
[DataTestMethod]
[DataRow("#A4C")]
[DataRow("#7A3E9D")]
public void OpaqueCustomSvgHexAccentIsUsedVerbatim(string customAccent)
{
var value = $"|ThemedSvg|{customAccent}|{Template}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
StringAssert.Contains(Encoding.UTF8.GetString(lightSvg), $"id=\"accent\" fill=\"{customAccent}\"");
StringAssert.Contains(Encoding.UTF8.GetString(darkSvg), $"id=\"accent\" fill=\"{customAccent}\"");
}
[DataTestMethod]
[DataRow("transparent")]
[DataRow("#A4C8")]
[DataRow("#7A3E9DCC")]
[DataRow("unknown")]
public void UnsupportedAccentIsNotStrippedDuringClassification(string unsupportedAccent)
{
var value = $"|ThemedSvg|{unsupportedAccent}|{Template}";
Assert.AreEqual(SvgIconProtocol.Kind.ThemedFile, SvgIconProtocol.Classify(value));
Assert.IsFalse(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var lightSvg));
Assert.IsFalse(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var darkSvg));
Assert.AreEqual(0, lightSvg.Length);
Assert.AreEqual(0, darkSvg.Length);
}
[TestMethod]
public void ThemedSvgFileIsReadAndResolvedAsUtf8()
{
var path = Path.Combine(Path.GetTempPath(), $"CmdPal-{Guid.NewGuid():N}.svg");
try
{
var template = $"<?xml version=\"1.0\" encoding=\"utf-16\"?>{Template}";
File.WriteAllText(path, template, Encoding.Unicode);
var value = $"|ThemedSvg|success|{path}";
Assert.AreEqual(SvgIconProtocol.Kind.ThemedFile, SvgIconProtocol.Classify(value));
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var svg));
var resolved = Encoding.UTF8.GetString(svg);
Assert.IsFalse(resolved.Contains("<?xml", StringComparison.OrdinalIgnoreCase));
StringAssert.Contains(resolved, "id=\"theme\" fill=\"#FFFFFF\"");
StringAssert.Contains(resolved, "id=\"accent\" fill=\"#6CCB5F\"");
}
finally
{
File.Delete(path);
}
}
[TestMethod]
public void ThemedSvgFileHonorsBomlessXmlEncodingDeclaration()
{
var path = Path.Combine(Path.GetTempPath(), $"CmdPal-{Guid.NewGuid():N}.svg");
try
{
const string title = "Café déjà vu";
var template = $"<?xml version=\"1.0\" encoding=\"windows-1252\"?>" +
$"<svg xmlns=\"http://www.w3.org/2000/svg\"><title>{title}</title>" +
"<path id=\"theme\" fill=\"{{ThemeColor}}\" />" +
"<path id=\"accent\" fill=\"{{AccentColor}}\" /></svg>";
var sourceEncoding = CodePagesEncodingProvider.Instance.GetEncoding(1252);
Assert.IsNotNull(sourceEncoding);
File.WriteAllBytes(path, sourceEncoding.GetBytes(template));
var value = $"|ThemedSvg|success|{path}";
Assert.IsTrue(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Dark, out var svg));
var resolved = Encoding.UTF8.GetString(svg);
Assert.IsFalse(resolved.Contains("<?xml", StringComparison.OrdinalIgnoreCase));
StringAssert.Contains(resolved, $"<title>{title}</title>");
StringAssert.Contains(resolved, "id=\"theme\" fill=\"#FFFFFF\"");
StringAssert.Contains(resolved, "id=\"accent\" fill=\"#6CCB5F\"");
}
finally
{
File.Delete(path);
}
}
[DataTestMethod]
[DataRow("|Svg|C:\\Icons\\plain.svg", "PlainFile")]
[DataRow("|Svg|<svg />", "PlainInline")]
[DataRow("|ThemedSvg|C:\\Icons\\themed.svg", "ThemedFile")]
[DataRow("|ThemedSvg|<svg />", "ThemedInline")]
[DataRow("|ThemedSvg|warning|C:\\Icons\\themed.svg", "ThemedFile")]
[DataRow("|ThemedSvg|#7A3E9D|<svg />", "ThemedInline")]
public void SvgProtocolClassifiesContractAndPayload(string value, string expected) =>
Assert.AreEqual(expected, SvgIconProtocol.Classify(value).ToString());
[TestMethod]
public void OnlyThemedSvgUsesThemeInCacheIdentity()
{
var plain = $"|Svg|{Template}";
var themed = $"|ThemedSvg|danger|{Template}";
Assert.AreEqual(ElementTheme.Default, SvgIconProtocol.GetCacheTheme(plain, ElementTheme.Light));
Assert.AreEqual(ElementTheme.Default, SvgIconProtocol.GetCacheTheme(plain, ElementTheme.Dark));
Assert.AreEqual(ElementTheme.Light, SvgIconProtocol.GetCacheTheme(themed, ElementTheme.Default));
Assert.AreEqual(ElementTheme.Light, SvgIconProtocol.GetCacheTheme(themed, ElementTheme.Light));
Assert.AreEqual(ElementTheme.Dark, SvgIconProtocol.GetCacheTheme(themed, ElementTheme.Dark));
Assert.AreEqual(ElementTheme.Default, SvgIconProtocol.GetCacheTheme("ordinary.svg", ElementTheme.Dark));
}
[TestMethod]
public void ThemedSvgCacheIdentityCanonicalizesOnlyExplicitAccentCasing()
{
var semantic = $"|ThemedSvg|info|{Template}";
var semanticVariant = $"|ThemedSvg|INFO|{Template}";
var custom = $"|ThemedSvg|#A4C|{Template}";
var customVariant = $"|ThemedSvg|#a4c|{Template}";
var plain = $"|Svg|<svg><path id=\"INFO\" fill=\"#a4c\" /></svg>";
Assert.AreSame(semantic, SvgIconProtocol.GetCacheIdentity(semantic));
Assert.AreEqual(semantic, SvgIconProtocol.GetCacheIdentity(semanticVariant));
Assert.AreSame(custom, SvgIconProtocol.GetCacheIdentity(custom));
Assert.AreEqual(custom, SvgIconProtocol.GetCacheIdentity(customVariant));
Assert.AreSame(plain, SvgIconProtocol.GetCacheIdentity(plain));
}
[DataTestMethod]
[DataRow("|ThemedSvg||<svg />")]
[DataRow("|ThemedSvg|unknown|<svg />")]
[DataRow("|ThemedSvg|UNKNOWN|<svg />")]
[DataRow("|ThemedSvg|#ggg|<svg />")]
[DataRow("|ThemedSvg|TRANSPARENT|<svg />")]
[DataRow("|ThemedSvg|<svg><path id=\"A|B\" /></svg>")]
public void UnrecognizedAccentsAndSvgPayloadsKeepTheirCacheIdentity(string value) =>
Assert.AreSame(value, SvgIconProtocol.GetCacheIdentity(value));
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("|svg|<svg />")]
[DataRow("|Svg|")]
[DataRow("|Svg|not-an-svg-file.txt")]
[DataRow("|Svg|Z:\\this-file-should-not-exist\\icon.svg")]
[DataRow("|ThemedSvg|")]
[DataRow("|ThemedSvg|unknown|<svg />")]
[DataRow("|ThemedSvg|#12|<svg />")]
[DataRow("|ThemedSvg|not-an-svg-file.txt")]
public void InvalidSvgProtocolIsRejected(string? value)
{
Assert.IsFalse(SvgIconProtocol.TryCreateSvg(value, ElementTheme.Light, out var svg));
Assert.AreEqual(0, svg.Length);
}
}

View File

@@ -10,6 +10,24 @@ namespace SamplePagesExtension.Pages;
internal sealed partial class SampleIconPage : ListPage internal sealed partial class SampleIconPage : ListPage
{ {
private const string PlainSvgSample = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="1" y="1" width="30" height="30" rx="8" fill="#E8DEF8" />
<path d="M9 16l5 5 9-11" fill="none" stroke="#7A3E9D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
""";
private const string ThemedSvgSample = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" color="{{ThemeColor}}">
<path
fill="currentColor"
d="M10 18q.128 0 .254-.004a5.5 5.5 0 0 1-.698-1.083c-.536-.207-1.098-.793-1.578-1.821A9.3 9.3 0 0 1 7.42 13.5h1.672q.096-.52.284-1h-2.17A15 15 0 0 1 7 10c0-.883.073-1.725.206-2.5h5.588c.092.541.156 1.115.186 1.713q.48-.138.992-.188a16 16 0 0 0-.165-1.525h2.733c.251.656.406 1.36.448 2.094q.543.276 1.008.66A8 8 0 1 0 10 18M10 3c.657 0 1.407.59 2.022 1.908.217.466.406 1.002.559 1.592H7.419c.153-.59.342-1.126.56-1.592C8.592 3.59 9.342 3 10 3M7.072 4.485A10.5 10.5 0 0 0 6.389 6.5H3.936a7.02 7.02 0 0 1 3.778-3.118c-.241.33-.456.704-.642 1.103M6.192 7.5A16 16 0 0 0 6 10c0 .87.067 1.712.193 2.5H3.46A7 7 0 0 1 3 10c0-.88.163-1.724.46-2.5zm.197 6c.176.743.407 1.422.683 2.015c.186.399.401.773.642 1.103A7.02 7.02 0 0 1 3.936 13.5zm5.897-10.118A7.02 7.02 0 0 1 16.064 6.5H13.61a10.5 10.5 0 0 0-.683-2.015 6.6 6.6 0 0 0-.642-1.103" />
<path
fill="{{AccentColor}}"
d="M19 14.5a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0m-4.854-2.353-2 2a.5.5 0 0 0 .708.707L14 13.707V16.5a.5.5 0 0 0 1 0v-2.793l1.146 1.147a.5.5 0 0 0 .708-.708l-2-2a.5.5 0 0 0-.351-.146h-.006a.5.5 0 0 0-.348.144z" />
</svg>
""";
private readonly IListItem[] _items = private readonly IListItem[] _items =
[ [
BuildIconItem( BuildIconItem(
@@ -42,6 +60,16 @@ internal sealed partial class SampleIconPage : ListPage
"Transparent initials avatar", "Transparent initials avatar",
"Uses a transparent square background and a theme-aware foreground"), "Uses a transparent square background and a theme-aware foreground"),
BuildIconItem(
"|Svg|" + PlainSvgSample,
"Plain inline SVG",
"Passes SVG content through without theme expansion"),
BuildIconItem(
"|ThemedSvg|success|" + ThemedSvgSample,
"Themed inline SVG",
"Uses currentColor for the globe and a semantic success accent for the overlay"),
/* /*
* Quick intro to Unicode in source code: * Quick intro to Unicode in source code:
* - Every character has a code point (e.g., U+0041 = 'A'). * - Every character has a code point (e.g., U+0041 = 'A').