Skip to content

Commit 58b36a5

Browse files
st-grclaude
andcommitted
feat: Add configurable local image rendering to Markdown previewer
Add a "Show local images" toggle in PowerToys Settings (File Explorer > Markdown) that allows the preview handler to render images referenced via relative paths from the markdown file's directory. Implementation: - HTMLParsingExtension: conditionally resolve local markdown images via virtual host URL with path traversal protection - MarkdownHelper: regex-rewrite relative src attributes in HTML img tags - WebView2: use SetVirtualHostNameToFolderMapping to serve local images via https://localmdimages/ virtual host - Settings UI: toggle in PowerPreviewPage.xaml with ViewModel binding - Handler Settings: read EnableMdLocalImages via SettingsUtils Security: - Default: OFF (existing behavior preserved) - Only images under the markdown file's directory tree are allowed - Remote URLs (http/https/data/javascript) always blocked - Path traversal blocked via Path.GetFullPath + StartsWith check - WebView2 scripts remain disabled (IsScriptEnabled = false) Fixes: #40787 Fixes: #3713 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e193509 commit 58b36a5

9 files changed

Lines changed: 166 additions & 14 deletions

File tree

src/common/FilePreviewCommon/HTMLParsingExtension.cs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// The Microsoft Corporation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System;
6+
using System.IO;
7+
58
using Markdig;
69
using Markdig.Extensions.Figures;
710
using Markdig.Extensions.Tables;
@@ -43,6 +46,26 @@ public HTMLParsingExtension(ImagesBlockedCallBack imagesBlockedCallBack, string
4346
/// </summary>
4447
public string FilePath { get; set; }
4548

49+
/// <summary>
50+
/// Gets or sets a value indicating whether local images should be rendered.
51+
/// </summary>
52+
public bool AllowLocalImages { get; set; }
53+
54+
private static bool IsLocalImage(string url)
55+
{
56+
if (string.IsNullOrEmpty(url))
57+
{
58+
return false;
59+
}
60+
61+
if (url.Contains("://") && !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
62+
{
63+
return false;
64+
}
65+
66+
return true;
67+
}
68+
4669
/// <inheritdoc/>
4770
public void Setup(MarkdownPipelineBuilder pipeline)
4871
{
@@ -92,9 +115,29 @@ public void PipelineOnDocumentProcessed(MarkdownDocument document)
92115
{
93116
if (link.IsImage)
94117
{
95-
link.Url = "#";
96-
link.GetAttributes().AddClass("img-fluid");
97-
imagesBlockedCallBack();
118+
if (AllowLocalImages && IsLocalImage(link.Url))
119+
{
120+
string resolvedPath = Path.GetFullPath(Path.Combine(FilePath, link.Url));
121+
122+
if (resolvedPath.StartsWith(FilePath, StringComparison.OrdinalIgnoreCase))
123+
{
124+
string relativePath = resolvedPath.Substring(FilePath.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Replace('\\', '/');
125+
link.Url = "https://localmdimages/" + relativePath;
126+
link.GetAttributes().AddClass("img-fluid");
127+
}
128+
else
129+
{
130+
link.Url = "#";
131+
link.GetAttributes().AddClass("img-fluid");
132+
imagesBlockedCallBack();
133+
}
134+
}
135+
else
136+
{
137+
link.Url = "#";
138+
link.GetAttributes().AddClass("img-fluid");
139+
imagesBlockedCallBack();
140+
}
98141
}
99142
}
100143
}

src/common/FilePreviewCommon/MarkdownHelper.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System.IO;
6+
using System.Text.RegularExpressions;
67

78
using Markdig;
89

@@ -25,13 +26,14 @@ public static class MarkdownHelper
2526
/// </summary>
2627
private static readonly string HtmlFooter = "</div></body></html>";
2728

28-
public static string MarkdownHtml(string fileContent, string theme, string filePath, ImagesBlockedCallBack imagesBlockedCallBack)
29+
public static string MarkdownHtml(string fileContent, string theme, string filePath, ImagesBlockedCallBack imagesBlockedCallBack, bool allowLocalImages = false)
2930
{
3031
var htmlHeader = theme == "dark" ? HtmlDarkHeader : HtmlLightHeader;
3132

3233
// Extension to modify markdown AST.
3334
HTMLParsingExtension extension = new HTMLParsingExtension(imagesBlockedCallBack);
3435
extension.FilePath = Path.GetDirectoryName(filePath) ?? string.Empty;
36+
extension.AllowLocalImages = allowLocalImages;
3537

3638
// if you have a string with double space, some people view it as a new line.
3739
// while this is against spec, even GH supports this. Technically looks like GH just trims whitespace
@@ -46,6 +48,15 @@ public static string MarkdownHtml(string fileContent, string theme, string fileP
4648
MarkdownPipeline pipeline = pipelineBuilder.Build();
4749
string parsedMarkdown = Markdown.ToHtml(fileContent, pipeline);
4850

51+
if (allowLocalImages)
52+
{
53+
string virtualHost = "https://localmdimages/";
54+
parsedMarkdown = Regex.Replace(
55+
parsedMarkdown,
56+
@"src=""(?!https?://|data:|file://|#|javascript:)([^""]+)""",
57+
m => $"src=\"{virtualHost}{m.Groups[1].Value}\"");
58+
}
59+
4960
string markdownHTML = $"{htmlHeader}{parsedMarkdown}{HtmlFooter}";
5061
return markdownHTML;
5162
}

src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
6363
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
6464
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
65+
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
6566
<ProjectReference Include="..\common\PreviewHandlerCommon.csproj" />
6667
</ItemGroup>
6768

src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ public partial class MarkdownPreviewHandlerControl : FormHandlerControl
5656
/// </summary>
5757
private bool _infoBarDisplayed;
5858

59+
private string _markdownDirectory;
60+
private bool _allowLocalImages;
61+
5962
/// <summary>
6063
/// Gets the path of the current assembly.
6164
/// </summary>
@@ -116,14 +119,21 @@ public override void DoPreview<T>(T dataSource)
116119
throw new ArgumentException($"{nameof(dataSource)} for {nameof(MarkdownPreviewHandlerControl)} must be a string but was a '{typeof(T)}'");
117120
}
118121

122+
_allowLocalImages = Settings.GetLocalImagesEnabled();
123+
_markdownDirectory = Path.GetDirectoryName(filePath) ?? string.Empty;
124+
119125
string fileText = File.ReadAllText(filePath);
120-
Regex imageTagRegex = new Regex(@"<[ ]*img.*>");
121-
if (imageTagRegex.IsMatch(fileText))
126+
127+
if (!_allowLocalImages)
122128
{
123-
_infoBarDisplayed = true;
129+
Regex imageTagRegex = new Regex(@"<[ ]*img.*>");
130+
if (imageTagRegex.IsMatch(fileText))
131+
{
132+
_infoBarDisplayed = true;
133+
}
124134
}
125135

126-
string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack);
136+
string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack, _allowLocalImages);
127137

128138
_browser = new WebView2()
129139
{
@@ -143,6 +153,11 @@ public override void DoPreview<T>(T dataSource)
143153
_webView2Environment = webView2EnvironmentAwaiter.GetResult();
144154
await _browser.EnsureCoreWebView2Async(_webView2Environment).ConfigureAwait(true);
145155
_browser.CoreWebView2.SetVirtualHostNameToFolderMapping(VirtualHostName, AssemblyDirectory, CoreWebView2HostResourceAccessKind.Deny);
156+
if (_allowLocalImages)
157+
{
158+
_browser.CoreWebView2.SetVirtualHostNameToFolderMapping("localmdimages", _markdownDirectory, CoreWebView2HostResourceAccessKind.Allow);
159+
}
160+
146161
_browser.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false;
147162
_browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
148163
_browser.CoreWebView2.Settings.AreDevToolsEnabled = false;
@@ -152,15 +167,24 @@ public override void DoPreview<T>(T dataSource)
152167
_browser.CoreWebView2.Settings.IsScriptEnabled = false;
153168
_browser.CoreWebView2.Settings.IsWebMessageEnabled = false;
154169

155-
// Don't load any resources.
170+
// Don't load any resources except virtual host mapped ones.
156171
_browser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
157172
_browser.CoreWebView2.WebResourceRequested += (object sender, CoreWebView2WebResourceRequestedEventArgs e) =>
158173
{
159-
// Show local file we've saved with the markdown contents. Block all else.
160-
if (new Uri(e.Request.Uri) != _localFileURI)
174+
// Allow the local HTML file
175+
if (_localFileURI != null && new Uri(e.Request.Uri) == _localFileURI)
176+
{
177+
return;
178+
}
179+
180+
// Allow virtual host requests (localmdimages)
181+
if (_allowLocalImages && e.Request.Uri.StartsWith("https://localmdimages/", StringComparison.OrdinalIgnoreCase))
161182
{
162-
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null);
183+
return;
163184
}
185+
186+
// Block everything else
187+
e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null);
164188
};
165189

166190
_browser.CoreWebView2.ContextMenuRequested += (object sender, CoreWebView2ContextMenuRequestedEventArgs args) =>

src/modules/previewpane/MarkdownPreviewHandler/Settings.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
using System;
66
using System.Collections.Generic;
7+
using System.IO;
78
using System.Linq;
89
using System.Text;
910
using System.Threading.Tasks;
1011

12+
using Microsoft.PowerToys.Settings.UI.Library;
13+
1114
namespace Microsoft.PowerToys.PreviewHandler.Markdown
1215
{
1316
internal sealed class Settings
@@ -40,5 +43,22 @@ public static string GetTheme()
4043
{
4144
return Common.UI.ThemeManager.GetWindowsBaseColor().ToLowerInvariant();
4245
}
46+
47+
private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default;
48+
49+
/// <summary>
50+
/// Returns whether local images should be displayed in the Markdown preview.
51+
/// </summary>
52+
public static bool GetLocalImagesEnabled()
53+
{
54+
try
55+
{
56+
return ModuleSettings.GetSettings<PowerPreviewSettings>(PowerPreviewSettings.ModuleName).Properties.EnableMdLocalImages;
57+
}
58+
catch (FileNotFoundException)
59+
{
60+
return false;
61+
}
62+
}
4363
}
4464
}

src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ public bool EnableMdPreview
8181
}
8282
}
8383

84+
private bool enableMdLocalImages;
85+
86+
[JsonPropertyName("md-previewer-local-images-setting")]
87+
[JsonConverter(typeof(BoolPropertyJsonConverter))]
88+
public bool EnableMdLocalImages
89+
{
90+
get => enableMdLocalImages;
91+
set
92+
{
93+
if (value != enableMdLocalImages)
94+
{
95+
LogTelemetryEvent(value);
96+
enableMdLocalImages = value;
97+
}
98+
}
99+
}
100+
84101
private bool enableMonacoPreview = true;
85102

86103
[JsonPropertyName("monaco-previewer-toggle-setting")]

src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
</tkcontrols:SettingsExpander.Items>
133133
</tkcontrols:SettingsExpander>
134134

135-
<tkcontrols:SettingsCard
135+
<tkcontrols:SettingsExpander
136136
Name="FileExplorerPreviewToggleSwitchPreviewMD"
137137
x:Uid="FileExplorerPreview_ToggleSwitch_Preview_MD"
138138
HeaderIcon="{ui:FontIcon Glyph=&#xE943;}"
@@ -141,7 +141,16 @@
141141
x:Uid="ToggleSwitch"
142142
IsEnabled="{x:Bind ViewModel.MDRenderIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
143143
IsOn="{x:Bind ViewModel.MDRenderIsEnabled, Mode=TwoWay}" />
144-
</tkcontrols:SettingsCard>
144+
<tkcontrols:SettingsExpander.Items>
145+
<tkcontrols:SettingsCard
146+
x:Uid="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages"
147+
IsEnabled="{x:Bind ViewModel.MDRenderIsEnabled, Mode=OneWay}">
148+
<ToggleSwitch
149+
x:Uid="ToggleSwitch"
150+
IsOn="{x:Bind ViewModel.MDLocalImagesIsEnabled, Mode=TwoWay}" />
151+
</tkcontrols:SettingsCard>
152+
</tkcontrols:SettingsExpander.Items>
153+
</tkcontrols:SettingsExpander>
145154

146155
<tkcontrols:SettingsCard
147156
Name="FileExplorerPreviewToggleSwitchPreviewPDF"

src/settings-ui/Settings.UI/Strings/en-us/Resources.resw

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,12 @@ opera.exe</value>
10121012
<value>.md, .markdown, .mdown, .mkdn, .mkd, .mdwn, .mdtxt, .mdtext</value>
10131013
<comment>{Locked}</comment>
10141014
</data>
1015+
<data name="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages.Header" xml:space="preserve">
1016+
<value>Show local images</value>
1017+
</data>
1018+
<data name="FileExplorerPreview_ToggleSwitch_Preview_MD_LocalImages.Description" xml:space="preserve">
1019+
<value>Display images from the document's folder in Markdown preview. Only local files are allowed; remote URLs remain blocked.</value>
1020+
</data>
10151021
<data name="FileExplorerPreview_ToggleSwitch_Preview_Monaco.Header" xml:space="preserve">
10161022
<value>Source code files (Monaco)</value>
10171023
<comment>File type, do not translate</comment>

src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public PowerPreviewViewModel(ISettingsRepository<PowerPreviewSettings> moduleSet
7676
_mdRenderIsEnabled = Settings.Properties.EnableMdPreview;
7777
}
7878

79+
_mdLocalImagesEnabled = Settings.Properties.EnableMdLocalImages;
80+
7981
_monacoRenderEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredMonacoPreviewEnabledValue();
8082
if (_monacoRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _monacoRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
8183
{
@@ -254,6 +256,7 @@ public PowerPreviewViewModel(ISettingsRepository<PowerPreviewSettings> moduleSet
254256
private bool _mdRenderIsGpoEnabled;
255257
private bool _mdRenderIsGpoDisabled;
256258
private bool _mdRenderIsEnabled;
259+
private bool _mdLocalImagesEnabled;
257260

258261
private GpoRuleConfigured _monacoRenderEnabledGpoRuleConfiguration;
259262
private bool _monacoRenderEnabledStateIsGPOConfigured;
@@ -547,6 +550,24 @@ public bool MDRenderIsGpoDisabled
547550
}
548551
}
549552

553+
public bool MDLocalImagesIsEnabled
554+
{
555+
get
556+
{
557+
return _mdLocalImagesEnabled;
558+
}
559+
560+
set
561+
{
562+
if (_mdLocalImagesEnabled != value)
563+
{
564+
_mdLocalImagesEnabled = value;
565+
Settings.Properties.EnableMdLocalImages = value;
566+
RaisePropertyChanged();
567+
}
568+
}
569+
}
570+
550571
public bool MonacoRenderIsEnabled
551572
{
552573
get

0 commit comments

Comments
 (0)