mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 11:17:53 +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$
|
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
|
||||||
|
|||||||
3
.github/actions/spell-check/expect.txt
vendored
3
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: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 |
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user