diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadInputKind.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadInputKind.cs index 3cf657484a..28c09dac99 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadInputKind.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadInputKind.cs @@ -13,4 +13,8 @@ internal enum IconLoadInputKind SpecializedAppIcon, GeneratedSwatch, GeneratedInitials, + SvgFile, + SvgInline, + ThemedSvgFile, + ThemedSvgInline, } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconProtocolRegistry.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconProtocolRegistry.cs index af8b3caa9f..9939a7d208 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconProtocolRegistry.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconProtocolRegistry.cs @@ -13,6 +13,7 @@ internal static class IconProtocolRegistry [ AppIconProtocolProcessor.Instance, GeneratedIconProtocolProcessor.Instance, + SvgIconProtocolProcessor.Instance, ]; static IconProtocolRegistry() diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgFileTextReader.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgFileTextReader.cs new file mode 100644 index 0000000000..67c44aa7fd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgFileTextReader.cs @@ -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 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 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 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 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'; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocol.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocol.cs new file mode 100644 index 0000000000..038e3d8060 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocol.cs @@ -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; + +/// +/// Resolves plain |Svg|payload and theme-aware +/// |ThemedSvg|[accent|]payload icon strings. A payload is either inline SVG +/// or the path to an SVG file. +/// +/// +/// Plain SVGs are passed through without placeholder expansion and share cache entries +/// across themes. Themed SVGs replace {{ThemeColor}} and {{AccentColor}} +/// 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 fill-opacity, +/// stroke-opacity, or opacity. +/// SVG files are treated as immutable while cached. +/// +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 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 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 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 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 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 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("", firstNonWhitespace + 5, StringComparison.Ordinal); + return declarationEnd >= 0 + ? template.Remove(firstNonWhitespace, (declarationEnd + 2) - firstNonWhitespace) + : template; + } + + internal enum Kind + { + None, + PlainFile, + PlainInline, + ThemedFile, + ThemedInline, + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocolProcessor.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocolProcessor.cs new file mode 100644 index 0000000000..abdd3bb946 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/SvgIconProtocolProcessor.cs @@ -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 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 PrepareAsync( + string value, + int targetSize, + ElementTheme theme) + { + _ = TryPrepareSynchronously(value, targetSize, theme, out var preparedIcon); + return ValueTask.FromResult(IconProtocolProcessingResult.FromPreparedIcon(preparedIcon)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/CachedIconSourceProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/CachedIconSourceProviderTests.cs index be1627c090..f1c3d44ec7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/CachedIconSourceProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/CachedIconSourceProviderTests.cs @@ -233,6 +233,56 @@ public partial class CachedIconSourceProviderTests 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|", + }; + + 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|", + }; + + 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] [Timeout(5_000)] public async Task FailedLoadIsRemovedAndCanBeRetried() diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconLoadDiagnosticsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconLoadDiagnosticsTests.cs index e19bdf76b4..b1682adf31 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconLoadDiagnosticsTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconLoadDiagnosticsTests.cs @@ -247,7 +247,11 @@ public class IconLoadDiagnosticsTests [DataTestMethod] [DataRow("|Swatch|#FF0067C0|", "GeneratedSwatch")] [DataRow("|Initials|CP|#FF005FB8|square|", "GeneratedInitials")] - public void GeneratedIconProtocolUsesSpecificInputKind(string icon, string expectedKind) + [DataRow("|Svg|C:\\Icons\\plain.svg", "SvgFile")] + [DataRow("|Svg|", "SvgInline")] + [DataRow("|ThemedSvg|warning|C:\\Icons\\themed.svg", "ThemedSvgFile")] + [DataRow("|ThemedSvg|#7A3E9D|", "ThemedSvgInline")] + public void SpecialIconProtocolsUseSpecificInputKind(string icon, string expectedKind) { IconLoadDiagnostics.Start(); var request = IconLoadDiagnostics.BeginRequest(IconRequestReason.SourceChanged, 1.0); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs index 0cf6538805..90a11287bc 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconPathConverterTests.cs @@ -78,4 +78,42 @@ public class IconPathConverterTests Assert.AreEqual(IconPathConverter.PreparedIconKind.Empty, prepared.Kind); } + + [TestMethod] + public void ThemedInlineSvgProtocolIsPreparedAsThemeSpecificSvgData() + { + const string Icon = "|ThemedSvg|warning|"; + + 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|"; + + 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); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconProtocolRegistryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconProtocolRegistryTests.cs index f4f68758b6..36e00329d1 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconProtocolRegistryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/IconProtocolRegistryTests.cs @@ -91,6 +91,39 @@ public class IconProtocolRegistryTests Assert.AreEqual(IconPathConverter.PreparedIconKind.SvgData, preparedIcon.Kind); } + [DataTestMethod] + [DataRow("|Svg|", "SvgInline")] + [DataRow("|Svg|C:\\Icons\\sample.svg", "SvgFile")] + [DataRow("|ThemedSvg|warning|", "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|"; + 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] [DataRow(null)] [DataRow("")] diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj index 3b4d1355fa..83fd792422 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/Microsoft.CmdPal.UI.UnitTests.csproj @@ -43,6 +43,9 @@ + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/SvgIconProtocolTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/SvgIconProtocolTests.cs new file mode 100644 index 0000000000..21f5419e2e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.UnitTests/SvgIconProtocolTests.cs @@ -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 = """ + + + + + """; + + private const string CurrentColorTemplate = """ + + + + + """; + + [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 = "Žluťoučký kůň"; + var value = $"|Svg|{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 = ""; + 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 = $"{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 = $"{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("" + + $"{title}" + + "" + + ""; + 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("{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|", "PlainInline")] + [DataRow("|ThemedSvg|C:\\Icons\\themed.svg", "ThemedFile")] + [DataRow("|ThemedSvg|", "ThemedInline")] + [DataRow("|ThemedSvg|warning|C:\\Icons\\themed.svg", "ThemedFile")] + [DataRow("|ThemedSvg|#7A3E9D|", "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|"; + + 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||")] + [DataRow("|ThemedSvg|unknown|")] + [DataRow("|ThemedSvg|UNKNOWN|")] + [DataRow("|ThemedSvg|#ggg|")] + [DataRow("|ThemedSvg|TRANSPARENT|")] + [DataRow("|ThemedSvg|")] + public void UnrecognizedAccentsAndSvgPayloadsKeepTheirCacheIdentity(string value) => + Assert.AreSame(value, SvgIconProtocol.GetCacheIdentity(value)); + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("|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|")] + [DataRow("|ThemedSvg|#12|")] + [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); + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs index 5f8d0bf30a..daf10c26f7 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs @@ -10,6 +10,24 @@ namespace SamplePagesExtension.Pages; internal sealed partial class SampleIconPage : ListPage { + private const string PlainSvgSample = """ + + + + + """; + + private const string ThemedSvgSample = """ + + + + + """; + private readonly IListItem[] _items = [ BuildIconItem( @@ -42,6 +60,16 @@ internal sealed partial class SampleIconPage : ListPage "Transparent initials avatar", "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: * - Every character has a code point (e.g., U+0041 = 'A').