mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 03:07:56 +01:00
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:
1
.github/actions/spell-check/excludes.txt
vendored
1
.github/actions/spell-check/excludes.txt
vendored
@@ -131,3 +131,4 @@
|
||||
ignore$
|
||||
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
|
||||
^src/common/CalculatorEngineCommon/exprtk\.hpp$
|
||||
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
|
||||
|
||||
3
.github/actions/spell-check/expect.txt
vendored
3
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
<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
|
||||
x:Name="VerticalStackLayout"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
<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>
|
||||
</Page.Resources>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 30 KiB |
@@ -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:
|
||||
|
||||
- ``
|
||||
|
||||
- ``
|
||||
- ℹ️ Only absolute paths are supported.
|
||||
|
||||
- ``
|
||||
- ⚠️ Only for small amount of data. Parsing large data blocks the UI.
|
||||
|
||||
- ``
|
||||
- ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions.
|
||||
|
||||
- ``
|
||||
- ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions.
|
||||
|
||||
## Examples:
|
||||
|
||||
### Web URL
|
||||
```xml
|
||||

|
||||
```
|
||||

|
||||
|
||||
```xml
|
||||

|
||||
```
|
||||

|
||||
|
||||
### File URL (PNG):
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
### File URL (SVG):
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
```xml
|
||||

|
||||
```
|
||||

|
||||
|
||||
## Data URL (PNG):
|
||||
⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs.
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
### Data URL (SVG):
|
||||
⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs.
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

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

|
||||
```
|
||||
|
||||

|
||||
|
||||
### MS-APPDATA URL:
|
||||
⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions.
|
||||
```xml
|
||||

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

|
||||
```
|
||||
|
||||

|
||||
|
||||
### Scale to fit (scaling down only by default)
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
### Scale to fit (in both direction)
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
### Scale to exact width
|
||||
```xml
|
||||

|
||||
```
|
||||
|
||||

|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
@@ -81,6 +81,12 @@ public partial class SamplesListPage : ListPage
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user