mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
Add plain and themed SVG icon protocols
This commit is contained in:
@@ -13,4 +13,8 @@ internal enum IconLoadInputKind
|
||||
SpecializedAppIcon,
|
||||
GeneratedSwatch,
|
||||
GeneratedInitials,
|
||||
SvgFile,
|
||||
SvgInline,
|
||||
ThemedSvgFile,
|
||||
ThemedSvgInline,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class IconProtocolRegistry
|
||||
[
|
||||
AppIconProtocolProcessor.Instance,
|
||||
GeneratedIconProtocolProcessor.Instance,
|
||||
SvgIconProtocolProcessor.Instance,
|
||||
];
|
||||
|
||||
static IconProtocolRegistry()
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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|<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]
|
||||
[Timeout(5_000)]
|
||||
public async Task FailedLoadIsRemovedAndCanBeRetried()
|
||||
|
||||
@@ -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|<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();
|
||||
var request = IconLoadDiagnostics.BeginRequest(IconRequestReason.SourceChanged, 1.0);
|
||||
|
||||
@@ -78,4 +78,42 @@ public class IconPathConverterTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,39 @@ public class IconProtocolRegistryTests
|
||||
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]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
|
||||
@@ -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\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\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\IconCachePartition.cs" Link="Helpers\Icons\IconCachePartition.cs" />
|
||||
<Compile Include="..\..\Microsoft.CmdPal.UI\Helpers\Icons\IconLoadDemand.cs" Link="Helpers\Icons\IconLoadDemand.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 = """
|
||||
<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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,24 @@ namespace SamplePagesExtension.Pages;
|
||||
|
||||
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 =
|
||||
[
|
||||
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').
|
||||
|
||||
Reference in New Issue
Block a user