Skip to content

Commit fb59905

Browse files
CopilotMuyuanMSCopilot
authored
Fix Markdown preview crash on UTF-8 files with >2MB size and <1.5M characters (#47391)
`NavigateToString` throws `ArgumentException` when previewing Markdown files containing many multi-byte UTF-8 characters (e.g., CJK) — file size exceeds 2MB but character count stays under 1.5M, bypassing the guard. ## Summary of the Pull Request The size guard in `MarkdownPreviewHandlerControl` used `markdownHTML.Length` (character count / UTF-16 code units), but WebView2's `NavigateToString` limit is measured in **bytes**. A string with 700K CJK characters has only 700K `.Length` units but ~2.1MB of UTF-8 bytes — enough to crash the API while passing the old check. **Changes:** - **`MarkdownPreviewHandlerControl.cs`**: Replace character-count guard with UTF-8 byte count: ```csharp // Before if (markdownHTML.Length > 1_500_000) // After if (System.Text.Encoding.UTF8.GetByteCount(markdownHTML) > 1_500_000) ``` When the byte threshold is exceeded, content is written to a temp file and loaded via `_browser.Source` instead of `NavigateToString` — existing fallback path, now correctly triggered. - **`MarkdownPreviewHandlerTest.cs`**: Added 3 regression tests to prevent this class of bug from recurring: 1. Multi-byte UTF-8 content (CJK, <1.5M chars but >2MB bytes) → temp-file navigation path 2. Small ASCII content within both thresholds → `NavigateToString` path 3. Large ASCII content exceeding 1.5M chars → temp-file navigation path ## PR Checklist - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **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 ## Detailed Description of the Pull Request / Additional comments The existing fallback (write HTML to a temp file, navigate via `_browser.Source`) was already correct and handles arbitrarily large content safely. The only bug was in the guard condition that decides when to use it — it measured the wrong unit (characters vs. bytes). Single-byte ASCII content is unaffected; only multi-byte Unicode content was under-counted. The tests use reflection to read the private `_localFileURI` field. Since this field is set synchronously before `Controls.Add(_browser)`, the check is race-free: once the wait loop exits with `Controls.Count > 0`, `_localFileURI` is guaranteed to have its final value. ## Validation Steps Performed - Verified the fix by reasoning through the byte math: a file with 700K CJK characters → `Length` = 700K (passes old check) → UTF-8 bytes ≈ 2.1MB (fails new check → uses temp file path, no crash). - Added 3 targeted unit tests in `UnitTests-MarkdownPreviewHandler` covering the multi-byte threshold boundary; all 14 tests in the suite pass (14/14). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com> Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 38882fd commit fb59905

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandlerControl.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ public override void DoPreview<T>(T dataSource)
182182

183183
// WebView2.NavigateToString() limitation
184184
// See https://learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.navigatetostring?view=webview2-dotnet-1.0.864.35#remarks
185-
// While testing the limit, it turned out it is ~1.5MB, so to be on a safe side we go for 1.5m bytes
186-
if (markdownHTML.Length > 1_500_000)
185+
// While testing the limit, it turned out it is ~1.5MB of UTF-8 encoded content, so to be on the safe side we check the UTF-8 byte count.
186+
// Using character count (string.Length) is not sufficient because multi-byte UTF-8 characters (e.g. CJK) can cause the byte size to exceed the limit even when the character count is below it.
187+
if (System.Text.Encoding.UTF8.GetByteCount(markdownHTML) > 1_500_000)
187188
{
188189
string filename = _webView2UserDataFolder + "\\" + Guid.NewGuid().ToString() + ".html";
189190
File.WriteAllText(filename, markdownHTML);

src/modules/previewpane/UnitTests-MarkdownPreviewHandler/MarkdownPreviewHandlerTest.cs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
using System;
66
using System.Drawing;
7+
using System.IO;
8+
using System.Reflection;
9+
using System.Text;
710
using System.Threading;
811
using System.Windows.Forms;
912

@@ -16,6 +19,9 @@ namespace MarkdownPreviewHandlerUnitTests
1619
[STATestClass]
1720
public class MarkdownPreviewHandlerTest
1821
{
22+
private const int NavigateToStringUtf8LimitInBytes = 1_500_000;
23+
private const int OversizedUtf8FileThresholdInBytes = 2_000_000;
24+
1925
// A long timeout is needed. WebView2 can take a long time to load the first time in some CI systems.
2026
private static readonly int HardTimeoutInMilliseconds = 60000;
2127
private static readonly int SleepTimeInMilliseconds = 200;
@@ -161,5 +167,141 @@ public void MarkdownPreviewHandlerControlUpdateInfobarSettingsWhenDoPreviewIsCal
161167
Assert.AreEqual(true, ((RichTextBox)markdownPreviewHandlerControl.Controls[1]).Multiline);
162168
}
163169
}
170+
171+
[TestMethod]
172+
public void MarkdownPreviewHandlerControlUsesTempFileNavigationWhenUtf8ByteCountExceedsThresholdWithMultiByteCharacters()
173+
{
174+
string content = new string('漢', 700_000);
175+
176+
Assert.IsTrue(content.Length < NavigateToStringUtf8LimitInBytes);
177+
Assert.IsTrue(Encoding.UTF8.GetByteCount(content) > OversizedUtf8FileThresholdInBytes);
178+
179+
string filePath = CreateMarkdownFile(content);
180+
181+
try
182+
{
183+
using (var markdownPreviewHandlerControl = new MarkdownPreviewHandlerControl())
184+
{
185+
markdownPreviewHandlerControl.DoPreview(filePath);
186+
187+
WaitForBrowserControl(markdownPreviewHandlerControl);
188+
189+
AssertUsesTempFileNavigation(markdownPreviewHandlerControl);
190+
}
191+
}
192+
finally
193+
{
194+
DeleteFileIfExists(filePath);
195+
}
196+
}
197+
198+
[TestMethod]
199+
public void MarkdownPreviewHandlerControlUsesNavigateToStringWhenContentIsWithinCharacterAndUtf8ByteThresholds()
200+
{
201+
string content = new string('a', 10_000);
202+
203+
Assert.IsTrue(content.Length < NavigateToStringUtf8LimitInBytes);
204+
Assert.IsTrue(Encoding.UTF8.GetByteCount(content) < NavigateToStringUtf8LimitInBytes);
205+
206+
string filePath = CreateMarkdownFile(content);
207+
208+
try
209+
{
210+
using (var markdownPreviewHandlerControl = new MarkdownPreviewHandlerControl())
211+
{
212+
markdownPreviewHandlerControl.DoPreview(filePath);
213+
214+
WaitForBrowserControl(markdownPreviewHandlerControl);
215+
216+
AssertUsesNavigateToString(markdownPreviewHandlerControl);
217+
}
218+
}
219+
finally
220+
{
221+
DeleteFileIfExists(filePath);
222+
}
223+
}
224+
225+
[TestMethod]
226+
public void MarkdownPreviewHandlerControlUsesTempFileNavigationWhenAsciiContentExceedsCharacterThreshold()
227+
{
228+
string content = new string('a', 1_600_000);
229+
230+
Assert.IsTrue(content.Length > NavigateToStringUtf8LimitInBytes);
231+
Assert.IsTrue(Encoding.UTF8.GetByteCount(content) > NavigateToStringUtf8LimitInBytes);
232+
233+
string filePath = CreateMarkdownFile(content);
234+
235+
try
236+
{
237+
using (var markdownPreviewHandlerControl = new MarkdownPreviewHandlerControl())
238+
{
239+
markdownPreviewHandlerControl.DoPreview(filePath);
240+
241+
WaitForBrowserControl(markdownPreviewHandlerControl);
242+
243+
AssertUsesTempFileNavigation(markdownPreviewHandlerControl);
244+
}
245+
}
246+
finally
247+
{
248+
DeleteFileIfExists(filePath);
249+
}
250+
}
251+
252+
private static void WaitForBrowserControl(MarkdownPreviewHandlerControl markdownPreviewHandlerControl)
253+
{
254+
int beforeTick = Environment.TickCount;
255+
256+
while (markdownPreviewHandlerControl.Controls.Count == 0 && Environment.TickCount < beforeTick + HardTimeoutInMilliseconds)
257+
{
258+
Application.DoEvents();
259+
Thread.Sleep(SleepTimeInMilliseconds);
260+
}
261+
262+
Assert.AreEqual(1, markdownPreviewHandlerControl.Controls.Count);
263+
Assert.IsInstanceOfType(markdownPreviewHandlerControl.Controls[0], typeof(WebView2));
264+
}
265+
266+
private static void AssertUsesTempFileNavigation(MarkdownPreviewHandlerControl markdownPreviewHandlerControl)
267+
{
268+
Uri localFileUri = GetLocalFileUri(markdownPreviewHandlerControl);
269+
270+
Assert.IsNotNull(localFileUri);
271+
Assert.IsTrue(File.Exists(localFileUri.LocalPath));
272+
Assert.AreEqual(localFileUri, ((WebView2)markdownPreviewHandlerControl.Controls[0]).Source);
273+
}
274+
275+
private static void AssertUsesNavigateToString(MarkdownPreviewHandlerControl markdownPreviewHandlerControl)
276+
{
277+
Assert.IsNull(GetLocalFileUri(markdownPreviewHandlerControl));
278+
}
279+
280+
private static Uri GetLocalFileUri(MarkdownPreviewHandlerControl markdownPreviewHandlerControl)
281+
{
282+
FieldInfo localFileUriField = typeof(MarkdownPreviewHandlerControl).GetField("_localFileURI", BindingFlags.Instance | BindingFlags.NonPublic);
283+
284+
Assert.IsNotNull(localFileUriField);
285+
286+
return (Uri)localFileUriField.GetValue(markdownPreviewHandlerControl);
287+
}
288+
289+
private static string CreateMarkdownFile(string content)
290+
{
291+
string generatedFilesDirectory = Path.Combine(AppContext.BaseDirectory, "HelperFiles", "Generated");
292+
Directory.CreateDirectory(generatedFilesDirectory);
293+
294+
string filePath = Path.Combine(generatedFilesDirectory, $"{Guid.NewGuid():N}.md");
295+
File.WriteAllText(filePath, content, Encoding.UTF8);
296+
return filePath;
297+
}
298+
299+
private static void DeleteFileIfExists(string filePath)
300+
{
301+
if (File.Exists(filePath))
302+
{
303+
File.Delete(filePath);
304+
}
305+
}
164306
}
165307
}

0 commit comments

Comments
 (0)