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(" _decodeOptions = decodeOptions ?? new ImageSourceFactory.ImageDecodeOptions(); + + public bool ShouldUseThisProvider(string url) + { + if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri) + { + var scheme = uri.Scheme.ToLowerInvariant(); + return scheme is "file" or "ms-appx" or "ms-appdata"; + } + + return false; + } + + public async Task GetImageSource(string url) + { + if (!ShouldUseThisProvider(url)) + { + throw new ArgumentException("Not a local URL/path (file, ms-appx, ms-appdata).", nameof(url)); + } + + // Absolute URI? + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsAbsoluteUri) + { + var scheme = uri.Scheme.ToLowerInvariant(); + + var hints = ImageHints.ParseHintsFromUri(uri); + + if (scheme is "ms-appx" or "ms-appdata") + { + // Load directly from the package/appdata URI + var rasRef = RandomAccessStreamReference.CreateFromUri(uri); + using var ras = await rasRef.OpenReadAsync(); + var payload = new ImageSourceFactory.ImagePayload(ras, ImageContentTypeHelper.GuessFromPathOrUri(uri.AbsoluteUri), uri); + return new ImageSourceInfo(await ImageSourceFactory.CreateAsync(payload, _decodeOptions), hints); + } + + if (scheme is "file") + { + var path = uri.LocalPath; + return new ImageSourceInfo(await FromFilePathAsync(path, uri, _decodeOptions), hints); + } + } + + throw new InvalidOperationException("Unsupported local URL/path."); + } + + private static async Task FromFilePathAsync(string path, Uri sourceUri, ImageSourceFactory.ImageDecodeOptions decodeOptions) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true); + using var mem = new InMemoryRandomAccessStream(); + using var outStream = mem.AsStreamForWrite(); + await fs.CopyToAsync(outStream).ConfigureAwait(true); + await outStream.FlushAsync().ConfigureAwait(true); + + mem.Seek(0); + + var payload = new ImageSourceFactory.ImagePayload(mem, ImageContentTypeHelper.GuessFromPathOrUri(path), sourceUri); + return await ImageSourceFactory.CreateAsync(payload, decodeOptions).ConfigureAwait(true); + } + + private static class ImageContentTypeHelper + { + public static string? GuessFromPathOrUri(string? pathOrUri) + { + if (string.IsNullOrEmpty(pathOrUri)) + { + return null; + } + + // Try to get extension from path/uri + var ext = Path.GetExtension(pathOrUri); + if (string.IsNullOrEmpty(ext)) + { + // try query-less URI path portion + if (Uri.TryCreate(pathOrUri, UriKind.RelativeOrAbsolute, out var u)) + { + ext = Path.GetExtension(u.IsAbsoluteUri ? u.AbsolutePath : u.OriginalString); + } + } + + ext = ext?.Trim().ToLowerInvariant(); + return ext switch + { + ".svg" => "image/svg+xml", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" => "image/bmp", + ".ico" => "image/x-icon", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".avif" => "image/avif", + _ => null, + }; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs new file mode 100644 index 0000000000..3f0f027566 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs @@ -0,0 +1,274 @@ +// 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; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +/// +/// Creates a new image configured to behave well as an inline image in a RichTextBlock. +/// +internal static class RtbInlineImageFactory +{ + public sealed class InlineImageOptions + { + public double? WidthDip { get; init; } + + public double? HeightDip { get; init; } + + public double? MaxWidthDip { get; init; } + + public double? MaxHeightDip { get; init; } + + public bool FitColumnWidth { get; init; } = true; + + public bool DownscaleOnly { get; init; } = true; + + public Stretch Stretch { get; init; } = Stretch.None; + } + + internal static Image Create(ImageSource source, InlineImageOptions? options = null) + { + options ??= new InlineImageOptions(); + + var img = new Image + { + Source = source, + Stretch = options.Stretch, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + // Track host RTB and subscribe once + RichTextBlock? rtb = null; + long paddingToken = 0; + var paddingSubscribed = false; + + SizeChangedEventHandler? rtbSizeChangedHandler = null; + TypedEventHandler? xamlRootChangedHandler = null; + + // If Source is replaced later, recompute + var sourceToken = img.RegisterPropertyChangedCallback(Image.SourceProperty!, (_, __) => Update()); + + img.Loaded += OnLoaded; + img.Unloaded += OnUnloaded; + img.ImageOpened += (_, __) => Update(); + + return img; + + void OnLoaded(object? s, RoutedEventArgs e) + { + // store image initial Width and Height if they are force upon the image by + // MarkdownControl itself as result of parsing + // If user sets Width/Height in options, that takes precedence + img.Tag ??= (img.Width, img.Height); + + rtb ??= FindAncestor(img); + if (rtb != null && !paddingSubscribed) + { + rtbSizeChangedHandler ??= (_, __) => Update(); + rtb.SizeChanged += rtbSizeChangedHandler; + + paddingToken = rtb.RegisterPropertyChangedCallback(Control.PaddingProperty!, (_, __) => Update()); + paddingSubscribed = true; + } + + if (img.XamlRoot != null) + { + xamlRootChangedHandler ??= (_, __) => Update(); + img.XamlRoot.Changed += xamlRootChangedHandler; + } + + Update(); + } + + void OnUnloaded(object? s, RoutedEventArgs e) + { + if (rtb != null && rtbSizeChangedHandler is not null) + { + rtb.SizeChanged -= rtbSizeChangedHandler; + } + + if (rtb != null && paddingSubscribed) + { + rtb.UnregisterPropertyChangedCallback(Control.PaddingProperty!, paddingToken); + paddingSubscribed = false; + } + + if (img.XamlRoot != null && xamlRootChangedHandler is not null) + { + img.XamlRoot.Changed -= xamlRootChangedHandler; + } + + img.UnregisterPropertyChangedCallback(Image.SourceProperty!, sourceToken); + } + + void Update() + { + if (rtb is null) + { + return; + } + + double? externalWidth = null; + double? externalHeight = null; + if (img.Tag != null) + { + (externalWidth, externalHeight) = ((double, double))img.Tag; + } + + var pad = rtb.Padding; + var columnDip = Math.Max(0.0, rtb.ActualWidth - pad.Left - pad.Right); + var scale = img.XamlRoot?.RasterizationScale is double s1 and > 0 ? s1 : 1.0; + + var isSvg = img.Source is SvgImageSource; + var naturalPxW = GetNaturalPixelWidth(img.Source); + var naturalDipW = naturalPxW > 0 && naturalPxW != int.MaxValue ? naturalPxW / scale : double.PositiveInfinity; // SVG => ∞ + + double? desiredWidth = null; + if (externalWidth.HasValue && !double.IsNaN(externalWidth.Value)) + { + img.Width = externalWidth.Value; + desiredWidth = externalWidth.Value; + } + else + { + if (options.WidthDip is double forcedW) + { + desiredWidth = options.DownscaleOnly && naturalPxW != int.MaxValue + ? Math.Min(forcedW, naturalPxW) + : forcedW; + } + else if (options.FitColumnWidth) + { + desiredWidth = options.DownscaleOnly && naturalPxW != int.MaxValue + ? Math.Min(columnDip, naturalPxW) + : columnDip; + } + else + { + desiredWidth = naturalPxW; + } + + // Apply MaxWidth (never exceed column width by default) + double maxW; + var maxConstraint = options.FitColumnWidth ? columnDip : (isSvg ? 256 : double.PositiveInfinity); + if (options.MaxWidthDip.HasValue) + { + maxW = Math.Min(options.MaxWidthDip.Value, maxConstraint); + } + else if (options.DownscaleOnly) + { + maxW = Math.Min(naturalPxW, maxConstraint); + } + else + { + maxW = maxConstraint; + } + + // Commit sizes + if (desiredWidth.HasValue) + { + img.Width = Math.Max(0, desiredWidth.Value); + } + + img.MaxWidth = maxW > 0 ? maxW : maxConstraint; + } + + if (externalHeight.HasValue && !double.IsNaN(externalHeight.Value)) + { + img.Height = externalHeight.Value; + } + else + { + // ---- Height & MaxHeight ---- + var desiredHeight = options.HeightDip; + var maxH = options.MaxHeightDip; + + if (desiredHeight is double h) + { + img.Height = Math.Max(0, h); + } + + if (maxH is double mh && mh > 0) + { + img.MaxHeight = mh; + } + } + + if (options.FitColumnWidth + || options.WidthDip is not null + || options.HeightDip is not null + || options.MaxWidthDip is not null + || options.MaxHeightDip is not null + || externalWidth.HasValue + || externalHeight.HasValue) + { + img.Stretch = Stretch.Uniform; + } + else + { + img.Stretch = Stretch.None; + } + + // Decode/rasterization hints + if (isSvg && img.Source is SvgImageSource svg) + { + var targetW = desiredWidth ?? Math.Min(img.MaxWidth, columnDip); + var pxW = Math.Max(1, (int)Math.Round(targetW * scale)); + if ((int)svg.RasterizePixelWidth != pxW) + { + svg.RasterizePixelWidth = pxW; + } + + if (options.HeightDip is double forcedH) + { + var pxH = Math.Max(1, (int)Math.Round(forcedH * scale)); + if ((int)svg.RasterizePixelHeight != pxH) + { + svg.RasterizePixelHeight = pxH; + } + } + } + else if (img.Source is BitmapImage bi && naturalPxW > 0) + { + var widthToUse = desiredWidth ?? Math.Min(img.MaxWidth, columnDip); + if (widthToUse > 0) + { + var desiredPx = (int)Math.Round(Math.Min(naturalPxW, widthToUse * scale)); + if (desiredPx > 0 && bi.DecodePixelWidth != desiredPx) + { + bi.DecodePixelWidth = desiredPx; + } + } + } + } + } + + private static int GetNaturalPixelWidth(ImageSource? src) => src switch + { + BitmapSource bs when bs.PixelWidth > 0 => bs.PixelWidth, // raster + SvgImageSource sis => sis.RasterizePixelWidth > 0 ? (int)sis.RasterizePixelWidth : int.MaxValue, // vector => infinite + _ => 0, + }; + + private static T? FindAncestor(DependencyObject start) + where T : DependencyObject + { + var cur = (DependencyObject?)start; + while (cur != null) + { + cur = VisualTreeHelper.GetParent(cur); + if (cur is T hit) + { + return hit; + } + } + + return null; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index 5aecf7425a..dd7028a997 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -10,6 +10,8 @@ xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" + xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock" + xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" @@ -150,7 +152,11 @@ x:Key="DefaultMarkdownThemeConfig" H3FontSize="12" H3FontWeight="Normal" /> - + + diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg new file mode 100644 index 0000000000..60b2aba634 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs new file mode 100644 index 0000000000..3c8702a51d --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs @@ -0,0 +1,219 @@ +// 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. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage; + +namespace SamplePagesExtension.Pages; + +internal sealed partial class SampleMarkdownImagesPage : ContentPage +{ + private static readonly Task InitializationTask; + + private static string? _sampleMarkdownText; + + static SampleMarkdownImagesPage() + { + InitializationTask = Task.Run(static async () => + { + try + { + // prepare data files + // 1) prepare something in our AppData Temp Folder + var spaceFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/Space.png")); + var tempFile = await spaceFile!.CopyAsync(ApplicationData.Current!.TemporaryFolder!, "Space.png", NameCollisionOption.ReplaceExisting); + + // 2) and also get an SVG directly from the package + var svgChipmunkFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/FluentEmojiChipmunk.svg")); + + _sampleMarkdownText = GetContentMarkup( + new Uri(tempFile.Path!, UriKind.Absolute), + new Uri(svgChipmunkFile.Path!, UriKind.Absolute)); + } + catch (Exception ex) + { + ExtensionHost.LogMessage(ex.ToString()); + } + }); + return; + + static string GetContentMarkup(Uri path1, Uri path2) + { + return + $$""" + # Images in Markdown Content + + ## Available sources: + + - `![Alt Text](https://url)` + + - `![Alt Text](file://url)` + - ℹ️ Only absolute paths are supported. + + - `![Alt Text](data:;[base64,])` + - ⚠️ Only for small amount of data. Parsing large data blocks the UI. + + - `![Alt Text](ms-appx:///url)` + - ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + + - `![Alt Text](ms-appdata:///url)` + - ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + + ## Examples: + + ### Web URL + ```xml + ![painting](https://i.imgur.com/93XJSNh.png) + ``` + ![painting](https://i.imgur.com/93XJSNh.png) + + ```xml + ![painting](https://i.imgur.com/93XJSNh.png?--x-cmdpal-fit=fit) + ``` + ![painting](https://i.imgur.com/93XJSNh.png?--x-cmdpal-fit=fit) + + ### File URL (PNG): + ```xml + ![green rectangle]({{path1}}) + ``` + + ![green rectangle]({{path1}}) + + ### File URL (SVG): + ```xml + ![chipmunk]({{path2}}) + ``` + + ![chipmunk]({{path2}}) + + ```xml + ![chipmunk]({{path2}}?--x-cmdpal-maxwidth=400&--x-cmdpal-maxheight=400&--x-cmdpal-fit=fit) + ``` + + ![chipmunk]({{path2}}?--x-cmdpal-maxwidth=400&--x-cmdpal-maxheight=400&--x-cmdpal-fit=fit) + + ```xml + ![chipmunk]({{path2}}?--x-cmdpal-width=64) + ``` + ![chipmunk]({{path2}}?--x-cmdpal-width=64) + + ## Data URL (PNG): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + ![QR](data:image/png;base64,iVBORw0KGgoA...RU5ErkJggg==) + ``` + + ![QR](data:image/png;base64,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) + + ### Data URL (SVG): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + ![emoji](data:image/svg+xml;base64,PHN2ZyB4bWxucz0ia...NiAweiIvPjwvZz48L3N2Zz4=) + ``` + + ![emoji](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxnIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNGRkIwMkUiIGQ9Ik0xNS45OTkgMjkuOTk4YzkuMzM0IDAgMTMuOTk5LTYuMjY4IDEzLjk5OS0xNGMwLTcuNzMtNC42NjUtMTMuOTk4LTE0LTEzLjk5OEM2LjY2NSAyIDIgOC4yNjggMiAxNS45OTlzNC42NjQgMTMuOTk5IDEzLjk5OSAxMy45OTkiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAuNSAxOWE0LjUgNC41IDAgMSAwIDAtOWE0LjUgNC41IDAgMCAwIDAgOW0xMSAwYTQuNSA0LjUgMCAxIDAgMC05YTQuNSA0LjUgMCAwIDAgMCA5Ii8+PHBhdGggZmlsbD0iIzQwMkEzMiIgZD0iTTguMDcgNy45ODhjLS41OTQuNTYyLS45NTIgMS4yNC0xLjA5NiAxLjY3YS41LjUgMCAxIDEtLjk0OC0uMzE2Yy4xOS0uNTcuNjMtMS4zOTIgMS4zNTUtMi4wOEM4LjExMyA2LjU2NyA5LjE0OCA2IDEwLjUgNmEuNS41IDAgMCAxIDAgMWMtMS4wNDggMC0xLjg0Ni40MzMtMi40My45ODhNMTIgMTdhMiAyIDAgMSAwIDAtNGEyIDIgMCAwIDAgMCA0bTggMGEyIDIgMCAxIDAgMC00YTIgMiAwIDAgMCAwIDRtNS4wMjYtNy4zNDJjLS4xNDQtLjQzLS41MDMtMS4xMDgtMS4wOTUtMS42N0MyMy4zNDYgNy40MzMgMjIuNTQ4IDcgMjEuNSA3YS41LjUgMCAxIDEgMC0xYzEuMzUyIDAgMi4zODcuNTY3IDMuMTIgMS4yNjJjLjcyMy42ODggMS4xNjQgMS41MSAxLjM1NCAyLjA4YS41LjUgMCAxIDEtLjk0OC4zMTYiLz48cGF0aCBmaWxsPSIjQkIxRDgwIiBkPSJNMTMuMTcgMjJjLS4xMS4zMTMtLjE3LjY1LS4xNyAxdjJhMyAzIDAgMSAwIDYgMHYtMmMwLS4zNS0uMDYtLjY4Ny0uMTctMUwxNiAyMXoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTMuMTcgMjJhMy4wMDEgMy4wMDEgMCAwIDEgNS42NiAweiIvPjwvZz48L3N2Zz4=) + + ### Data URL (SVG 2): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + emoji 2 + ``` + + emoji 2 + + ### MS-APPX URL: + ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + ```xml + ![Swirl](ms-appx:///Assets/Square44x44Logo.png) + ``` + + ![Swirl](ms-appx:///Assets/Square44x44Logo.png) + + ### MS-APPDATA URL: + ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + ```xml + ![Space](ms-appdata:///temp/Space.png) + ``` + + --- + + # Scaling + + For URIs that support query parameters (file, http, ms-appx, ms-appdata), you can provide hints to control scaling + + - `--x-cmdpal-fit` + - `none`: no automatic scaling, provides image as is (default) + - `fit`: scale to fit the available space + - `--x-cmdpal-upscale` + - `true`: allow upscaling + - `false`: downscale only (default) + - `--x-cmdpal-width`: desired width in pixels + - `--x-cmdpal-height`: desired height in pixels + - `--x-cmdpal-maxwidth`: max width in pixels + - `--x-cmdpal-maxheight`: max height in pixels + + Currently no support for data: scheme as it doesn't support query parameters at all. + + ## Examples + + ### No scaling + ```xml + ![green rectangle]({{path1}}) + ``` + + ![green rectangle]({{path1}}) + + ### Scale to fit (scaling down only by default) + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit) + + ### Scale to fit (in both direction) + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit&--x-cmdpal-upscale=true) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit&--x-cmdpal-upscale=true) + + ### Scale to exact width + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-width=320) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-width=320) + """; + } + } + + private string _currentContent; + + public SampleMarkdownImagesPage() + { + Name = "Sample Markdown with Images Page"; + _currentContent = "Initializing..."; + IsLoading = true; + + _ = InitializationTask!.ContinueWith(_ => + { + _currentContent = _sampleMarkdownText!; + RaiseItemsChanged(); + IsLoading = false; + }); + } + + public override IContent[] GetContent() => [new MarkdownContent(_currentContent ?? string.Empty)]; +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 70c8dab589..f3fb5b5dc7 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -76,11 +76,17 @@ public partial class SamplesListPage : ListPage Title = "Markdown with multiple blocks", Subtitle = "A page with multiple blocks of rendered markdown", }, - new ListItem(new SampleMarkdownDetails()) + new ListItem(new SampleMarkdownDetails()) { Title = "Markdown with details", Subtitle = "A page with markdown and details", }, + new ListItem(new SampleMarkdownImagesPage()) + { + Title = "Markdown with images", + Subtitle = "A page with rendered markdown and images", + Icon = new IconInfo("\uee71"), + }, // Settings helpers new ListItem(new SampleSettingsPage())