().Count(p => p.ListType == ListKind.Ordered));
+ }
+
// A holding nothing but an image is walked as a block (an
is block-or-media), so there is
// no pending paragraph on import and the image used to rejoin the PRECEDING one — a picture on its
// own line jumped up into the paragraph above it on every second round trip.
diff --git a/tests/WinUIRichEditor.Tests/PageMarginTests.cs b/tests/WinUIRichEditor.Tests/PageMarginTests.cs
index 18b5a78..a1a76b3 100644
--- a/tests/WinUIRichEditor.Tests/PageMarginTests.cs
+++ b/tests/WinUIRichEditor.Tests/PageMarginTests.cs
@@ -99,6 +99,35 @@ public void MarginsAndPaperRoundTripThroughRtf_ToWithinATwip()
Assert.Equal(Wide.Bottom, back.Margin.Bottom, twipMm);
}
+ // ...but a margin set in tenths of a millimetre comes back as exactly that. Within a twip was not enough: 15 mm
+ // went out as 850 twips and came back 14.993, so after an RTF round trip no step of the toolbar's picker
+ // matched ("15mm" instead of "Normal") and the JSON stored a custom margin (2026-09-24). Twice, as always here.
+ [Theory]
+ [InlineData(5.0)] [InlineData(10.0)] [InlineData(15.0)] [InlineData(20.0)] [InlineData(30.0)] // the picker's steps
+ [InlineData(12.7, 17.3, 25.4, 0.1)] [InlineData(0.0, 33.3, 8.8, 19.9)]
+ public void AMarginInTenthsOfAMillimetre_RoundTripsThroughRtfExactly(double l, double t = double.NaN,
+ double r = double.NaN, double b = double.NaN)
+ {
+ var sides = double.IsNaN(t) ? new PageMargins(l) : new PageMargins(l, t, r, b);
+
+ var once = RtfDocumentFormatter.Parse(RtfDocumentFormatter.Write(A4Doc(sides)));
+ var twice = RtfDocumentFormatter.Parse(RtfDocumentFormatter.Write(once));
+
+ Assert.Equal(sides, once.PageSetup!.Margin);
+ Assert.Equal(sides, twice.PageSetup!.Margin);
+ }
+
+ // The snapping must not move a margin that is NOT a tenth of a millimetre. Word's 1.25 inch (1800 twips) is
+ // 31.75 mm; rounded to 31.8 it would go back out as 1803 twips.
+ [Fact]
+ public void AnRtfMarginBetweenTenths_KeepsItsExactLength()
+ {
+ var doc = RtfDocumentFormatter.Parse(@"{\rtf1\ansi\paperw11910\paperh16845\margl1800 hello\par}");
+
+ Assert.Equal(31.75, doc.PageSetup!.Margin.Left, 9);
+ Assert.Contains(@"\margl1800\", RtfDocumentFormatter.Write(doc));
+ }
+
// A file from another word processor keeps its own margins. 1440 twips = 1 inch (Word's default), 720 = half.
[Fact]
public void AnExternalRtfKeepsItsOwnMargins()
diff --git a/tests/WinUIRichEditor.Tests/Round34BackportTests.cs b/tests/WinUIRichEditor.Tests/Round34BackportTests.cs
index c2513b1..491e0f3 100644
--- a/tests/WinUIRichEditor.Tests/Round34BackportTests.cs
+++ b/tests/WinUIRichEditor.Tests/Round34BackportTests.cs
@@ -58,6 +58,68 @@ public void AWebLink_IsKept(string href)
var run = doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text == "click");
Assert.Equal(href, run.NavigateUri);
}
+
+ // ---- the same rule on the other ways in (2026-09-24) ---------------------------------------------------
+ // Round 34 dropped script links in the HTML reader only. An RTF HYPERLINK field (RTF is a clipboard flavour,
+ // so a paste) and a JSON/.flow file carried them in untouched, and the HTML writer sent them back out in
+ // exported and clipboard HTML. A host's SetHyperlink reaches the writer too, so it is the backstop.
+
+ private static Run Clicked(FlowDocument doc)
+ => doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text?.Contains("click") == true);
+
+ private static FlowDocument Linked(string href)
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "click", NavigateUri = href } } });
+ return doc;
+ }
+
+ [Theory]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("JavaScript:alert(1)")]
+ [InlineData("vbscript:msgbox(1)")]
+ [InlineData("data:text/html,x")]
+ public void AScriptLinkInAnRtfField_IsNotCarriedIntoTheDocument(string href)
+ {
+ var doc = RtfDocumentFormatter.Parse(
+ $@"{{\rtf1\ansi {{\field{{\*\fldinst HYPERLINK ""{href}""}}{{\fldrslt click}}}}\par}}");
+
+ Assert.Null(Clicked(doc).NavigateUri); // the text stays, the link goes
+ }
+
+ [Theory]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("vbscript:msgbox(1)")]
+ public void AScriptLinkInAJsonFile_IsNotCarriedIntoTheDocument(string href)
+ {
+ var doc = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(Linked(href)));
+
+ Assert.Null(Clicked(doc).NavigateUri);
+ }
+
+ [Fact]
+ public void AScriptLinkSetByTheHost_IsNotWrittenToHtml()
+ {
+ string html = HtmlDocumentFormatter.ToHtml(Linked("javascript:alert(1)"));
+
+ Assert.DoesNotContain("javascript", html, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("click", html);
+ }
+
+ // The other half, so the guards cannot pass by dropping every link.
+ [Fact]
+ public void AWebLink_SurvivesRtfJsonAndTheHtmlWriter()
+ {
+ const string url = "https://example.com/a?b=1";
+
+ var rtf = RtfDocumentFormatter.Parse(
+ $@"{{\rtf1\ansi {{\field{{\*\fldinst HYPERLINK ""{url}""}}{{\fldrslt click}}}}\par}}");
+ var json = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(Linked(url)));
+
+ Assert.Equal(url, Clicked(rtf).NavigateUri);
+ Assert.Equal(url, Clicked(json).NavigateUri);
+ Assert.Contains("href=\"https://example.com/a?b=1\"", HtmlDocumentFormatter.ToHtml(Linked(url)).Replace("&", "&"));
+ }
}
/// Round 34's control-level cases, measured in this port (see ).
diff --git a/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs b/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs
index dfb5e7c..c34d221 100644
--- a/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs
+++ b/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs
@@ -285,4 +285,104 @@ public void AnInlineTable_IsEditableToo() => UiThread.Run(() =>
Assert.Equal(3, it.Table.Rows);
});
+
+ // ---- an object selected inside what the command removes (2026-09-24) ---------------------------
+ // Pointer, key and menu paths let go of a selected object before they edit; a host call does not. A
+ // nested table or picture selected in a row the host deletes stayed selected after it left the document,
+ // and Delete on the inline picture then edited the detached row and parked the caret there.
+
+ private static void SetField(RichEditor ed, string name, object? value)
+ => typeof(RichEditor).GetField(name, NP)!.SetValue(ed, value);
+
+ private static object? Field(RichEditor ed, string name)
+ => typeof(RichEditor).GetField(name, NP)!.GetValue(ed);
+
+ // By walking the document (AllParagraphs), not the parent chain: a detached row's paragraphs still
+ // name their old cell, whose Parent is still the table (the round-34 lesson).
+ private static bool CaretInDocument(RichEditor ed)
+ {
+ var caret = (TextPointer)Field(ed, "_caret")!;
+ var all = (System.Collections.IEnumerable)typeof(RichEditor)
+ .GetMethod("AllParagraphs", NP, System.Type.EmptyTypes)!.Invoke(ed, null)!;
+ return all.Cast().Any(p => ReferenceEquals(p, caret.Paragraph));
+ }
+
+ private static void DeleteSelectedObject(RichEditor ed)
+ => typeof(RichEditor).GetMethod("DeleteSelectedObject", NP)!.Invoke(ed, null);
+
+ [Fact]
+ public void DeletingTheRowAroundASelectedNestedTable_LetsGoOfIt() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(2, 1);
+ built.Cells[1][0].Blocks.Add(new TableBlock(1, 1));
+ var ed = Editor(built);
+ var tb = Table(ed);
+ var nested = tb.Cells[1][0].Blocks.OfType().Single();
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+ SetField(ed, "_selectedBlock", nested);
+
+ Assert.True(ed.DeleteTableRow(tb, 1));
+
+ Assert.False(ed.HasBlockSelection);
+ });
+
+ [Fact]
+ public void DeletingTheColumnAroundASelectedInlinePicture_ThenDelete_KeepsTheCaretInTheDocument() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(1, 2);
+ var host = built.Cells[0][1].Para;
+ var img = new InlineImage { Width = 10, Height = 10 };
+ host.Inlines.Add(img);
+ var ed = Editor(built);
+ var tb = Table(ed);
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+ SetField(ed, "_selectedInline", ((Paragraph, InlineImage)?)(host, img));
+
+ Assert.True(ed.DeleteTableColumn(tb, 1));
+ Assert.False(ed.HasBlockSelection);
+
+ DeleteSelectedObject(ed); // what the Delete key does with an object selection
+ Assert.True(CaretInDocument(ed));
+ });
+
+ [Fact]
+ public void DeletingTheRowAroundASelectedInlineTable_LetsGoOfIt() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(2, 1);
+ var host = built.Cells[1][0].Para;
+ var it = new InlineTable { Table = new TableBlock(1, 1) };
+ host.Inlines.Add(it);
+ var ed = Editor(built);
+ var tb = Table(ed);
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+ SetField(ed, "_selectedInlineTable", ((Paragraph, InlineTable)?)(host, it));
+
+ Assert.True(ed.DeleteTableRow(tb, 1));
+
+ Assert.False(ed.HasBlockSelection);
+ });
+
+ // The other half: an object the edit did NOT remove stays selected — the table held by its border while
+ // a row is added to it, or a picture in a row that survives. Without this the fix could simply clear
+ // every selection on every edit.
+ [Fact]
+ public void AnObjectTheEditLeavesInPlace_StaysSelected() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(2, 1);
+ var host = built.Cells[0][0].Para;
+ var img = new InlineImage { Width = 10, Height = 10 };
+ host.Inlines.Add(img);
+ var ed = Editor(built);
+ var tb = Table(ed);
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+ SetField(ed, "_selectedInline", ((Paragraph, InlineImage)?)(host, img));
+
+ Assert.True(ed.DeleteTableRow(tb, 1));
+ Assert.True(ed.HasBlockSelection);
+
+ SetField(ed, "_selectedInline", null);
+ SetField(ed, "_selectedBlock", tb);
+ Assert.True(ed.InsertTableRow(tb, 0));
+ Assert.Same(tb, Field(ed, "_selectedBlock"));
+ });
}
diff --git a/tools/fault-sweep.ps1 b/tools/fault-sweep.ps1
index c73498c..029daf1 100644
--- a/tools/fault-sweep.ps1
+++ b/tools/fault-sweep.ps1
@@ -100,11 +100,16 @@ public static class Sweep
}
'@
+# The page-margin probe (the demo's --pageprobe) writes one line per step next to the log. Page margins are
+# the first dependency property holding a C# record struct, boxed through WinRT on every get/set, and no
+# keystroke below reaches them - so the probe reads them back and the two runs are diffed on that file too.
+$pageProbe = "$Log.page.txt"
if (Test-Path $Log) { Remove-Item $Log -Force }
+if (Test-Path $pageProbe) { Remove-Item $pageProbe -Force }
Get-Process -Name "WinUIRichEditor.Demo" -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Milliseconds 400
-$proc = Start-Process -FilePath $Exe -ArgumentList "--page=$Page", "--faultlog=$Log" -PassThru
+$proc = Start-Process -FilePath $Exe -ArgumentList "--page=$Page", "--faultlog=$Log", "--pageprobe=$pageProbe" -PassThru
Start-Sleep -Seconds 4
$proc.Refresh()
$h = $proc.MainWindowHandle
@@ -196,6 +201,13 @@ Start-Sleep -Seconds 1
$proc | Stop-Process -Force
Start-Sleep -Milliseconds 400
+if (Test-Path $pageProbe) {
+ "--- page margin probe ($pageProbe) ---"
+ Get-Content $pageProbe
+} else {
+ "WARNING: the page margin probe wrote nothing - the demo predates --pageprobe, or it failed before writing"
+}
+
if (Test-Path $Log) {
"--- faults ---"
Get-Content $Log