diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt
index 7ad88bde19..d29b18e9ce 100644
--- a/.github/actions/spell-check/excludes.txt
+++ b/.github/actions/spell-check/excludes.txt
@@ -131,3 +131,4 @@
ignore$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/common/CalculatorEngineCommon/exprtk\.hpp$
+src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index de4478cbc7..2839f08318 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -393,6 +393,7 @@ DNLEN
DONOTROUND
DONTVALIDATEPATH
dotnet
+downscale
DPICHANGED
DPIs
DPSAPI
@@ -1379,7 +1380,7 @@ QUNS
RAII
RAlt
randi
-Rasterization
+rasterization
Rasterize
RAWINPUTDEVICE
RAWINPUTHEADER
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml
index 098e170193..a5b4d47dba 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml
@@ -12,6 +12,7 @@
xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI"
+ xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
@@ -24,7 +25,11 @@
x:Key="DefaultMarkdownThemeConfig"
H3FontSize="12"
H3FontWeight="Normal" />
-
+
+
GetImageSource(string url)
+ {
+ var provider = _imageProviders.FirstOrDefault(p => p.ShouldUseThisProvider(url));
+ if (provider == null)
+ {
+ throw new NotSupportedException($"No image provider found for URL: {url}");
+ }
+
+ return provider.GetImageSource(url);
+ }
+
+ public bool ShouldUseThisProvider(string url)
+ {
+ return _imageProviders.Any(provider => provider.ShouldUseThisProvider(url));
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs
new file mode 100644
index 0000000000..73d8fc96e4
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs
@@ -0,0 +1,74 @@
+// 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 Windows.Storage.Streams;
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+internal sealed partial class DataImageSourceProvider : IImageSourceProvider
+{
+ private readonly ImageSourceFactory.ImageDecodeOptions _decodeOptions;
+
+ public DataImageSourceProvider(ImageSourceFactory.ImageDecodeOptions? decodeOptions = null)
+ => _decodeOptions = decodeOptions ?? new ImageSourceFactory.ImageDecodeOptions();
+
+ public bool ShouldUseThisProvider(string url)
+ => url.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
+
+ public async Task GetImageSource(string url)
+ {
+ if (!ShouldUseThisProvider(url))
+ {
+ throw new ArgumentException("URL is not a data: URI.", nameof(url));
+ }
+
+ // data:[][;base64],
+ var comma = url.IndexOf(',');
+ if (comma < 0)
+ {
+ throw new FormatException("Invalid data URI: missing comma separator.");
+ }
+
+ var header = url[5..comma]; // after "data:"
+ var payload = url[(comma + 1)..]; // after comma
+
+ // Parse header
+ string? contentType = null;
+ var isBase64 = false;
+
+ if (!string.IsNullOrEmpty(header))
+ {
+ var parts = header.Split(';');
+
+ // first token may be media type
+ if (!string.IsNullOrWhiteSpace(parts[0]) && parts[0].Contains('/'))
+ {
+ contentType = parts[0];
+ }
+
+ isBase64 = parts.Any(static p => p.Equals("base64", StringComparison.OrdinalIgnoreCase));
+ }
+
+ var bytes = isBase64
+ ? Convert.FromBase64String(payload)
+ : Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload));
+
+ var mem = new InMemoryRandomAccessStream();
+ using (var writer = new DataWriter(mem.GetOutputStreamAt(0)))
+ {
+ writer.WriteBytes(bytes);
+ await writer.StoreAsync()!;
+ }
+
+ mem.Seek(0);
+
+ var imagePayload = new ImageSourceFactory.ImagePayload(mem, contentType, null);
+ var imageSource = await ImageSourceFactory.CreateAsync(imagePayload, _decodeOptions);
+ return new ImageSourceInfo(imageSource, new ImageHints
+ {
+ DownscaleOnly = true,
+ });
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.cs
new file mode 100644
index 0000000000..357b55c7bd
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.cs
@@ -0,0 +1,73 @@
+// 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.Runtime.InteropServices.WindowsRuntime;
+using Windows.Storage.Streams;
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+///
+/// Implementation of IImageProvider to handle http/https images, but adds
+/// a new functionality to handle image scaling.
+///
+internal sealed partial class HttpImageSourceProvider : IImageSourceProvider
+{
+ private readonly HttpClient _http;
+
+ public HttpImageSourceProvider(HttpClient? httpClient = null)
+ => _http = httpClient ?? SharedHttpClient.Instance;
+
+ public bool ShouldUseThisProvider(string url)
+ => Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+
+ public async Task GetImageSource(string url)
+ {
+ if (!ShouldUseThisProvider(url))
+ {
+ throw new ArgumentException("URL must be absolute http/https.", nameof(url));
+ }
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+
+ req.Headers.TryAddWithoutValidation("Accept", "image/*,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8");
+
+ using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
+ resp.EnsureSuccessStatusCode();
+
+ var contentType = resp.Content.Headers.ContentType?.MediaType;
+
+ using var mem = new InMemoryRandomAccessStream();
+ await CopyToRandomAccessStreamAsync(resp, mem);
+
+ var hints = ImageHints.ParseHintsFromUri(new Uri(url));
+ var imageSource = await ImageSourceFactory.CreateAsync(
+ new ImageSourceFactory.ImagePayload(mem, contentType, new Uri(url)),
+ new ImageSourceFactory.ImageDecodeOptions { SniffContent = true });
+
+ return new ImageSourceInfo(imageSource, hints);
+ }
+
+ private static async Task CopyToRandomAccessStreamAsync(HttpResponseMessage resp, InMemoryRandomAccessStream mem)
+ {
+ var data = await resp.Content.ReadAsByteArrayAsync();
+ await mem.WriteAsync(data.AsBuffer());
+ mem.Seek(0);
+ }
+
+ private static class SharedHttpClient
+ {
+ public static readonly HttpClient Instance = Create();
+
+ private static HttpClient Create()
+ {
+ var c = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30),
+ };
+ c.DefaultRequestHeaders.UserAgent.ParseAdd("CommandPalette/1.0");
+ return c;
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.cs
new file mode 100644
index 0000000000..f7aa48ff35
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.cs
@@ -0,0 +1,12 @@
+// 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.
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+internal interface IImageSourceProvider
+{
+ Task GetImageSource(string url);
+
+ bool ShouldUseThisProvider(string url);
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs
new file mode 100644
index 0000000000..223ce94965
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs
@@ -0,0 +1,79 @@
+// 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.
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+internal sealed class ImageHints
+{
+ public static ImageHints Empty { get; } = new();
+
+ public double? DesiredPixelWidth { get; init; }
+
+ public double? DesiredPixelHeight { get; init; }
+
+ public double? MaxPixelWidth { get; init; }
+
+ public double? MaxPixelHeight { get; init; }
+
+ public bool? DownscaleOnly { get; init; }
+
+ public string? FitMode { get; init; } // fit=fit
+
+ public static ImageHints ParseHintsFromUri(Uri? uri)
+ {
+ if (uri is null || string.IsNullOrEmpty(uri.Query))
+ {
+ return Empty;
+ }
+
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var p in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var kv = p.Split('=', 2);
+ var k = Uri.UnescapeDataString(kv[0]);
+ var v = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
+ dict[k] = v;
+ }
+
+ return new ImageHints
+ {
+ DesiredPixelWidth = GetInt("--x-cmdpal-width"),
+ DesiredPixelHeight = GetInt("--x-cmdpal-height"),
+ MaxPixelWidth = GetInt("--x-cmdpal-maxwidth"),
+ MaxPixelHeight = GetInt("--x-cmdpal-maxheight"),
+ DownscaleOnly = GetBool("--x-cmdpal-downscaleOnly") ?? (GetBool("--x-cmdpal-upscale") is bool u ? !u : (bool?)null),
+ FitMode = dict.TryGetValue("--x-cmdpal-fit", out var f) ? f : null,
+ };
+
+ int? GetInt(params string[] keys)
+ {
+ foreach (var k in keys)
+ {
+ if (dict.TryGetValue(k, out var v) && int.TryParse(v, out var n))
+ {
+ return n;
+ }
+ }
+
+ return null;
+ }
+
+ bool? GetBool(params string[] keys)
+ {
+ foreach (var k in keys)
+ {
+ if (dict.TryGetValue(k, out var v) && (v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1"))
+ {
+ return true;
+ }
+ else if (dict.TryGetValue(k, out var v2) && (v2.Equals("false", StringComparison.OrdinalIgnoreCase) || v2 == "0"))
+ {
+ return false;
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs
new file mode 100644
index 0000000000..6f3d3b446c
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs
@@ -0,0 +1,43 @@
+// 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 CommunityToolkit.WinUI.Controls;
+using ManagedCommon;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+internal sealed partial class ImageProvider : IImageProvider
+{
+ private readonly CompositeImageSourceProvider _compositeProvider = new();
+
+ public async Task GetImage(string url)
+ {
+ try
+ {
+ ImageSourceFactory.Initialize();
+
+ var imageSource = await _compositeProvider.GetImageSource(url);
+ return RtbInlineImageFactory.Create(imageSource.ImageSource, new RtbInlineImageFactory.InlineImageOptions
+ {
+ DownscaleOnly = imageSource.Hints.DownscaleOnly ?? true,
+ FitColumnWidth = imageSource.Hints.FitMode == "fit",
+ MaxWidthDip = imageSource.Hints.MaxPixelWidth,
+ MaxHeightDip = imageSource.Hints.MaxPixelHeight,
+ WidthDip = imageSource.Hints.DesiredPixelWidth,
+ HeightDip = imageSource.Hints.DesiredPixelHeight,
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError($"Failed to provide an image from URI '{url}'", ex);
+ return null!;
+ }
+ }
+
+ public bool ShouldUseThisProvider(string url)
+ {
+ return _compositeProvider.ShouldUseThisProvider(url);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs
new file mode 100644
index 0000000000..61a596f221
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs
@@ -0,0 +1,145 @@
+// 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.Globalization;
+using System.Xml.Linq;
+using CommunityToolkit.WinUI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Windows.Foundation;
+using Windows.Storage.Streams;
+
+namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
+
+///
+/// Creates a new image source.
+///
+internal static class ImageSourceFactory
+{
+ private static DispatcherQueue? _dispatcherQueue;
+
+ public static void Initialize()
+ {
+ _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
+ }
+
+ internal sealed record ImagePayload(
+ IRandomAccessStream Stream,
+ string? ContentType,
+ Uri? SourceUri);
+
+ internal sealed class ImageDecodeOptions
+ {
+ public bool SniffContent { get; init; } = true;
+
+ public int? DecodePixelWidth { get; init; }
+
+ public int? DecodePixelHeight { get; init; }
+ }
+
+ public static async Task CreateAsync(ImagePayload payload, ImageDecodeOptions? options = null)
+ {
+ options ??= new ImageDecodeOptions();
+
+ var isSvg =
+ IsSvgByHeaderOrUrl(payload.ContentType, payload.SourceUri) ||
+ (options.SniffContent && SniffSvg(payload.Stream));
+
+ payload.Stream.Seek(0);
+
+ return await _dispatcherQueue!.EnqueueAsync(async () =>
+ {
+ if (isSvg)
+ {
+ var size = GetSvgSize(payload.Stream.AsStreamForRead());
+ payload.Stream.Seek(0);
+
+ var svg = new SvgImageSource();
+ await svg.SetSourceAsync(payload.Stream);
+ svg.RasterizePixelWidth = size.Width;
+ svg.RasterizePixelHeight = size.Height;
+ return svg;
+ }
+ else
+ {
+ var bmp = new BitmapImage();
+ if (options.DecodePixelWidth is int w and > 0)
+ {
+ bmp.DecodePixelWidth = w;
+ }
+
+ if (options.DecodePixelHeight is int h and > 0)
+ {
+ bmp.DecodePixelHeight = h;
+ }
+
+ await bmp.SetSourceAsync(payload.Stream);
+ return (ImageSource)bmp;
+ }
+ });
+ }
+
+ public static Size GetSvgSize(Stream stream)
+ {
+ // Parse the SVG string as an XML document
+ var svgDocument = XDocument.Load(stream);
+
+ // Get the root element of the document
+#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
+ var svgElement = svgDocument.Root;
+
+ // Get the height and width attributes of the root element
+#pragma warning disable CS8602 // Dereference of a possibly null reference.
+ var heightAttribute = svgElement.Attribute("height");
+#pragma warning restore CS8602 // Dereference of a possibly null reference.
+ var widthAttribute = svgElement.Attribute("width");
+#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.
+
+ // Convert the attribute values to double
+ double.TryParse(heightAttribute?.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var height);
+ double.TryParse(widthAttribute?.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var width);
+
+ // Return the height and width as a tuple
+ return new(width, height);
+ }
+
+ private static bool IsSvgByHeaderOrUrl(string? contentType, Uri? uri)
+ {
+ if (!string.IsNullOrEmpty(contentType) &&
+ contentType.StartsWith("image/svg+xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var s = uri?.ToString();
+ return !string.IsNullOrEmpty(s) && s.Contains(".svg", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool SniffSvg(IRandomAccessStream ras)
+ {
+ try
+ {
+ const int maxProbe = 1024;
+ ras.Seek(0);
+ var s = ras.AsStreamForRead();
+ var toRead = (int)Math.Min(ras.Size, maxProbe);
+ var buf = new byte[toRead];
+ var read = s.Read(buf, 0, toRead);
+ if (read <= 0)
+ {
+ return false;
+ }
+
+ var head = System.Text.Encoding.UTF8.GetString(buf, 0, read);
+ ras.Seek(0);
+ return head.Contains("