CmdPal: Enable loading local images in MarkdownContent (#41754)

Add a new image provider for `MarkdownTextBlock` that allows loading
images from additional sources:

- **file scheme**  
  - Enables loading images using the `file:` scheme.  
- Intentionally restricts file URIs to absolute paths to ensure correct
resolution when passed through the CmdPal extension/host boundary. (In
most cases, 3rd-party extensions will provide the paths, but the CmdPal
host performs the actual loading and would otherwise resolve paths
relative to itself.)

- **data scheme**  
- Enables loading images from URIs with the `data:` scheme (both Base64
and URL-encoded forms).
- Note: the Markdown control itself cannot handle large input and may
hang before the code introduced in this PR is invoked.

- **ms-appx scheme**  
  - This scheme is now supported for loading images.  
- However, since the Command Palette host performs the loading,
`ms-appx:` resolution applies to the host and not the extensions, which
limits its usefulness.

- **ms-appdata scheme**  
  - This scheme is now supported for loading images.  
- Similar to `ms-appx:`, resolution applies to the host, not the
extensions, limiting its usefulness.

---

Additionally, this PR introduces the concept of **_image source
hints_**, implemented as query string parameters piggy-backed on the
original URI.
These hints allow users to influence the behavior of images within
Markdown content.

- `--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 

--- 

Since `MarkdownTextBlock` requires conforming to the `IImageProvider`
interface—which accepts only a raw URI and must return an `Image`
control—this PR also introduces a new class `RtbInlineImageFactory`.

The factory hooks into the root text block upon loading and listens for
events related to **layout** and **DPI changes**, ensuring that images
adapt correctly to the control’s environment.

```csharp
public interface IImageProvider
{
    Task<Image> GetImage(string url);
    bool ShouldUseThisProvider(string url);
}
```

---
Pictures? Videos!

Loading images from new schemes:


https://github.com/user-attachments/assets/e0f4308d-30b2-4c81-86db-353048c708c1

New image source scaling options:


https://github.com/user-attachments/assets/ec5b007d-3140-4f0a-b163-7b278233ad40


<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41752
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Michael Jolley <mike@baldbeardedbuilder.com>
This commit is contained in:
Jiří Polášek
2025-09-26 17:59:00 +02:00
committed by GitHub
parent a1c8541d8b
commit 7b7bae2889
17 changed files with 1096 additions and 4 deletions

View File

@@ -131,3 +131,4 @@
ignore$ ignore$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$ ^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/common/CalculatorEngineCommon/exprtk\.hpp$ ^src/common/CalculatorEngineCommon/exprtk\.hpp$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs

View File

@@ -393,6 +393,7 @@ DNLEN
DONOTROUND DONOTROUND
DONTVALIDATEPATH DONTVALIDATEPATH
dotnet dotnet
downscale
DPICHANGED DPICHANGED
DPIs DPIs
DPSAPI DPSAPI
@@ -1379,7 +1380,7 @@ QUNS
RAII RAII
RAlt RAlt
randi randi
Rasterization rasterization
Rasterize Rasterize
RAWINPUTDEVICE RAWINPUTDEVICE
RAWINPUTHEADER RAWINPUTHEADER

View File

@@ -12,6 +12,7 @@
xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI" xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
@@ -24,7 +25,11 @@
x:Key="DefaultMarkdownThemeConfig" x:Key="DefaultMarkdownThemeConfig"
H3FontSize="12" H3FontSize="12"
H3FontWeight="Normal" /> H3FontWeight="Normal" />
<tkcontrols:MarkdownConfig x:Key="DefaultMarkdownConfig" Themes="{StaticResource DefaultMarkdownThemeConfig}" /> <markdownImageProviders:ImageProvider x:Key="ImageProvider" />
<tkcontrols:MarkdownConfig
x:Key="DefaultMarkdownConfig"
ImageProvider="{StaticResource ImageProvider}"
Themes="{StaticResource DefaultMarkdownThemeConfig}" />
<StackLayout <StackLayout
x:Name="VerticalStackLayout" x:Name="VerticalStackLayout"

View File

@@ -0,0 +1,31 @@
// 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 partial class CompositeImageSourceProvider : IImageSourceProvider
{
private readonly IImageSourceProvider[] _imageProviders =
[
new HttpImageSourceProvider(),
new LocalImageSourceProvider(),
new DataImageSourceProvider()
];
public Task<ImageSourceInfo> 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));
}
}

View File

@@ -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<ImageSourceInfo> GetImageSource(string url)
{
if (!ShouldUseThisProvider(url))
{
throw new ArgumentException("URL is not a data: URI.", nameof(url));
}
// data:[<media type>][;base64],<data>
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,
});
}
}

View File

@@ -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;
/// <summary>
/// Implementation of IImageProvider to handle http/https images, but adds
/// a new functionality to handle image scaling.
/// </summary>
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<ImageSourceInfo> 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;
}
}
}

View File

@@ -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<ImageSourceInfo> GetImageSource(string url);
bool ShouldUseThisProvider(string url);
}

View File

@@ -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<string, string>(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;
}
}
}

View File

@@ -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<Image> 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);
}
}

View File

@@ -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;
/// <summary>
/// Creates a new image source.
/// </summary>
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<ImageSource> 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("<svg", StringComparison.OrdinalIgnoreCase);
}
catch
{
ras.Seek(0);
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
// 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.Media;
namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
internal sealed record ImageSourceInfo(ImageSource ImageSource, ImageHints Hints);

View File

@@ -0,0 +1,113 @@
// 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.Media;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders;
internal sealed partial class LocalImageSourceProvider : IImageSourceProvider
{
private readonly ImageSourceFactory.ImageDecodeOptions _decodeOptions;
public LocalImageSourceProvider(ImageSourceFactory.ImageDecodeOptions? decodeOptions = null)
=> _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<ImageSourceInfo> 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<ImageSource> 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,
};
}
}
}

View File

@@ -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;
/// <summary>
/// Creates a new image configured to behave well as an inline image in a RichTextBlock.
/// </summary>
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<XamlRoot, XamlRootChangedEventArgs>? 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 <img width="123" height="123" />
// If user sets Width/Height in options, that takes precedence
img.Tag ??= (img.Width, img.Height);
rtb ??= FindAncestor<RichTextBlock>(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<T>(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;
}
}

View File

@@ -10,6 +10,8 @@
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers" 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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
@@ -150,7 +152,11 @@
x:Key="DefaultMarkdownThemeConfig" x:Key="DefaultMarkdownThemeConfig"
H3FontSize="12" H3FontSize="12"
H3FontWeight="Normal" /> H3FontWeight="Normal" />
<tkcontrols:MarkdownConfig x:Key="DefaultMarkdownConfig" Themes="{StaticResource DefaultMarkdownThemeConfig}" /> <markdownImageProviders:ImageProvider x:Key="ImageProvider" />
<tkcontrols:MarkdownConfig
x:Key="DefaultMarkdownConfig"
ImageProvider="{StaticResource ImageProvider}"
Themes="{StaticResource DefaultMarkdownThemeConfig}" />
</ResourceDictionary> </ResourceDictionary>
</Page.Resources> </Page.Resources>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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:<mime>;[base64,]<data>)`
- ⚠️ 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
<img alt="emoji 2"
width="48"
height="48"
src="data:image/svg+xml;base64,PHN2ZyB....iIvPjwvZz48L3N2Zz4=" />
```
<img alt="emoji 2"
width="48"
height="48"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxnIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNGRkIwMkUiIGQ9Ik0xNS45OTkgMjkuOTk4YzkuMzM0IDAgMTMuOTk5LTYuMjY4IDEzLjk5OS0xNGMwLTcuNzMtNC42NjUtMTMuOTk4LTE0LTEzLjk5OEM2LjY2NSAyIDIgOC4yNjggMiAxNS45OTlzNC42NjQgMTMuOTk5IDEzLjk5OSAxMy45OTkiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAuNSAxOWE0LjUgNC41IDAgMSAwIDAtOWE0LjUgNC41IDAgMCAwIDAgOW0xMSAwYTQuNSA0LjUgMCAxIDAgMC05YTQuNSA0LjUgMCAwIDAgMCA5Ii8+PHBhdGggZmlsbD0iIzQwMkEzMiIgZD0iTTguMDcgNy45ODhjLS41OTQuNTYyLS45NTIgMS4yNC0xLjA5NiAxLjY3YS41LjUgMCAxIDEtLjk0OC0uMzE2Yy4xOS0uNTcuNjMtMS4zOTIgMS4zNTUtMi4wOEM4LjExMyA2LjU2NyA5LjE0OCA2IDEwLjUgNmEuNS41IDAgMCAxIDAgMWMtMS4wNDggMC0xLjg0Ni40MzMtMi40My45ODhNMTIgMTdhMiAyIDAgMSAwIDAtNGEyIDIgMCAwIDAgMCA0bTggMGEyIDIgMCAxIDAgMC00YTIgMiAwIDAgMCAwIDRtNS4wMjYtNy4zNDJjLS4xNDQtLjQzLS41MDMtMS4xMDgtMS4wOTUtMS42N0MyMy4zNDYgNy40MzMgMjIuNTQ4IDcgMjEuNSA3YS41LjUgMCAxIDEgMC0xYzEuMzUyIDAgMi4zODcuNTY3IDMuMTIgMS4yNjJjLjcyMy42ODggMS4xNjQgMS41MSAxLjM1NCAyLjA4YS41LjUgMCAxIDEtLjk0OC4zMTYiLz48cGF0aCBmaWxsPSIjQkIxRDgwIiBkPSJNMTMuMTcgMjJjLS4xMS4zMTMtLjE3LjY1LS4xNyAxdjJhMyAzIDAgMSAwIDYgMHYtMmMwLS4zNS0uMDYtLjY4Ny0uMTctMUwxNiAyMXoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTMuMTcgMjJhMy4wMDEgMy4wMDEgMCAwIDEgNS42NiAweiIvPjwvZz48L3N2Zz4=" />
### 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)];
}

View File

@@ -76,11 +76,17 @@ public partial class SamplesListPage : ListPage
Title = "Markdown with multiple blocks", Title = "Markdown with multiple blocks",
Subtitle = "A page with multiple blocks of rendered markdown", Subtitle = "A page with multiple blocks of rendered markdown",
}, },
new ListItem(new SampleMarkdownDetails()) new ListItem(new SampleMarkdownDetails())
{ {
Title = "Markdown with details", Title = "Markdown with details",
Subtitle = "A page with markdown and 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 // Settings helpers
new ListItem(new SampleSettingsPage()) new ListItem(new SampleSettingsPage())