Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed — found by the WinUIRichEditor port's audit (2026-09-24)

The port audited what it had taken in since its last release (these files among it); each defect below
was reproduced here with a failing test before it was fixed.

- **The public row/column commands left an object selected after removing it.** A nested table or a
picture selected in a row or column that `DeleteTableRow`/`DeleteTableColumn` removed stayed selected
outside the document, and Delete then edited the detached row — an undo step that changed nothing
visible — instead of acting at the caret. The selection now lets go of an object the edit took out;
one still in the document stays selected.
- **Margins saved to RTF came back a hair off.** RTF stores whole twips, so 15 mm went out as 850 twips
and came back as 14.993 mm: after a round trip none of the toolbar's five steps matched its own preset,
and the JSON stored a custom margin. A value that is a whole tenth of a millimetre and writes back to
the same twips is now read as that value; one between tenths (Word's 1.25 inch, 31.75 mm) keeps its
exact length.
- **Script links (`javascript:`, `vbscript:`, `data:`) were dropped only when reading HTML.** One in a
JSON or `.flow` file was kept and written back out in exported and clipboard HTML. The JSON reader now
drops it too, and the HTML writer applies the same check, so a link a host sets with `SetHyperlink`
does not leave as a script link either.
- **An empty list item came back as an item holding a line break.** The writer marks an empty item
`data-are-empty` and gives it a `<br>` for outside renderers, as it does a blank paragraph, but the list
reader honoured neither: the `<br>` was read as content. It now reads an empty item the way it reads a
blank paragraph; an unmarked empty item in foreign HTML is still dropped. (The port's reader dropped the
item outright, which let the items either side merge into one list and lose a marker — its fuzz found it.)

### Fixed — audit of the files no test reached (2026-09-23)

Round 34 read the files whose members no test named (context menu, tables, the toolbar's page controls,
Expand Down
7 changes: 7 additions & 0 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering
- 표 행·열 API(#48)·단축키 표 공개(#49) → [포트 PR #54](https://github.com/centwon/WinUIRichEditor/pull/54). `Gesture(id)`는 Avalonia 전용이라 포트엔 없다.
- 그림 손잡이 우선순위·`CtrlU_AtALinksEnd_LeavesTheLinkAlone`은 포트에 이미 들어와 있었다(포트 커밋 50a2905 등).
- 알려진 분기: 포트는 쪽 여백 DIP를 정수 반올림(Win2D 안티앨리어싱 클립), 새 문서 대입이 `IsModified`를 켠다.
- 포트 감사가 찾은 공통 결함 3건을 역으로 옮김(2026-09-24, 포트 [PR #56](https://github.com/centwon/WinUIRichEditor/pull/56)):
표 행·열 공개 API가 지운 행 안의 개체 선택을 남김 · RTF 여백 왕복이 mm를 틂(15 → 14.993, 픽커 5단계 불일치) ·
JSON 입력과 HTML 출력에 스크립트 링크 검사가 없음. ⚠ 기존 여백 테스트가 "1 twip 이내"를 계약으로 잡아 결함을
가렸다. 상류는 공통 `AfterEdit`가 없어 선택 정리를 행·열 명령 네 곳에만 걸었다(포트는 모든 편집 경로).
상류 RTF 읽기는 `HYPERLINK` 필드를 읽지 않아 그 경로는 해당 없음.
- 포트 퍼즈(페이지 설정까지 넓힘, 20,000시드)가 찾은 HTML 빈 목록 항목 결함도 같은 PR로 옮김 — 목록 읽기가
`data-are-empty`와 그 `<br>`을 보지 않아 빈 항목이 줄바꿈 든 항목으로 돌아왔다(포트는 항목이 통째로 사라짐).

**알려진 제한 (의도적으로 둠)**
- RTF 중첩 표의 열 너비는 가져올 때 기본값(`\cellx`가 무시되는 props 그룹 안에 있다).
Expand Down
28 changes: 28 additions & 0 deletions src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ private bool TableInsertRow(TableBlock tb, int at)
int ar = Math.Clamp(at, 0, tb.Rows - 1);
_caretPosition = new TextPointer(CellCaretTarget(tb, ar, 0), 0);
CollapseSelectionToCaret();
DropDetachedObjectSelection();
// A row/column changes the table's own height, so the document is taller/shorter than the last
// measure said. These four take neither ResetCaretBlink nor any other path that re-measures
// (unlike every other structural edit), so the ScrollViewer kept the pre-edit extent until some
Expand All @@ -731,6 +732,7 @@ private bool TableDeleteRow(TableBlock tb, int at)
int nr = Math.Clamp(at, 0, tb.Rows - 1);
_caretPosition = new TextPointer(CellCaretTarget(tb, nr, 0), 0);
CollapseSelectionToCaret();
DropDetachedObjectSelection();
InvalidateMeasure(); // see TableInsertRow
InvalidateVisual();
return true;
Expand All @@ -745,6 +747,7 @@ private bool TableInsertColumn(TableBlock tb, int at)
int ac = Math.Clamp(at, 0, tb.Columns - 1);
_caretPosition = new TextPointer(CellCaretTarget(tb, 0, ac), 0);
CollapseSelectionToCaret();
DropDetachedObjectSelection();
// See TableInsertRow. A column keeps its own width, so this one usually leaves the height alone
// (measure reports the AVAILABLE width, not the content's) — but paged mode recomputes the page
// breaks inside MeasureOverride, so it still has to run.
Expand All @@ -762,6 +765,7 @@ private bool TableDeleteColumn(TableBlock tb, int at)
int nc = Math.Clamp(at, 0, tb.Columns - 1);
_caretPosition = new TextPointer(CellCaretTarget(tb, 0, nc), 0);
CollapseSelectionToCaret();
DropDetachedObjectSelection();
InvalidateMeasure(); // see TableInsertRow
InvalidateVisual();
return true;
Expand Down Expand Up @@ -800,6 +804,30 @@ private bool TableIsInDocument(TableBlock table)
return false;
}

// Lets go of a selected object that the edit took out of the document. Pointer, key and menu paths clear the
// selection before they edit, but a host call does not: DeleteTableRow around a selected nested table or
// picture left it selected, and Delete then pushed an undo step for an edit of the detached row, so the next
// Undo did nothing visible (port audit, 2026-09-24). Checked by walking DOWN from the document: a deleted
// row's cells still name the table as their parent, so a parent chain would call them inside.
private void DropDetachedObjectSelection()
{
if (_selectedBlock == null && _selectedInline == null) return;
var paras = GetAllParagraphsInOrder();
if (_selectedBlock is { } blk && !BlockIsIn(blk)) _selectedBlock = null;
if (_selectedInline is { } si && !(paras.Contains(si.p) && si.p.Inlines.Contains(si.img))) _selectedInline = null;

// A table owns a cell paragraph (TableIsInDocument); any other block lives in the document's list or in
// a cell, and every cell that is in the document holds a paragraph that is too.
bool BlockIsIn(Block b)
{
if (b is TableBlock t) return TableIsInDocument(t);
if (Document!.Blocks.Contains(b)) return true;
foreach (var p in paras)
if (p.Parent is TableCell cell && cell.Blocks.Contains(b)) return true;
return false;
}
}

// Insertion accepts one past the end (append); deletion does not.
private bool EditableTable(TableBlock? table, int at, int count, bool insert)
=> table != null && Document != null && !IsReadOnly
Expand Down
12 changes: 12 additions & 0 deletions src/AvaloniaRichEditor/Documents/PageSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ internal static bool IsUsableMargin(PageMargins m, double paperWmm, double paper
internal const double TwipsPerMm = 1440.0 / 25.4;
internal static int MmToTwips(double mm) => (int)System.Math.Round(mm * TwipsPerMm);

// The way back. A twip is 0.0176 mm, so twips / TwipsPerMm is almost never the millimetres that were written:
// 15 mm goes out as 850 twips and came back as 14.993 - every step of the toolbar's picker missed its own
// preset after an RTF round trip, and the JSON then stored a "custom" margin (port audit, 2026-09-24). A value
// that is a whole tenth of a millimetre AND writes back to the same twips is taken as what was meant; anything
// else (Word's 1.25 inch = 1800 twips = 31.75 mm) keeps its exact length, since rounding it would move it.
internal static double TwipsToMm(int twips)
{
double mm = twips / TwipsPerMm;
double tenth = System.Math.Round(mm, 1);
return MmToTwips(tenth) == twips ? tenth : mm;
}

/// <summary>Paper size in millimetres for a page size + orientation.</summary>
public static (double W, double H) PaperMillimetres(Controls.RichEditorPageSize size, Controls.RichEditorPageOrientation orientation)
{
Expand Down
4 changes: 3 additions & 1 deletion src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,9 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary<string, (byte[] B
Foreground = StringToBrush(id.Foreground),
Background = StringToBrush(id.Background),
FontFamily = id.FontFamily,
NavigateUri = id.NavigateUri,
// A file is untrusted input: a script link is dropped as the HTML reader drops it
// (port audit, 2026-09-24).
NavigateUri = id.NavigateUri is { } uri ? HtmlDocumentFormatter.SafeHref(uri) : null,
TextDecorations = BuildDecorations(id.Underline, id.Strikethrough)
});
}
Expand Down
14 changes: 12 additions & 2 deletions src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,14 @@ private static void ParseList(HtmlNode listNode, FlowDocument flow, ListKind kin
int liHeading = child.GetAttributeValue("data-are-h", 0);
if (liHeading >= 1 && liHeading <= 6) p.HeadingLevel = liHeading;
ParseInlines(child, p, uri: linkUri, inLink: !string.IsNullOrEmpty(linkUri));
if (p.Inlines.Count > 0) flow.Blocks.Add(p);
// An empty item is dropped like any empty element — unless our export marked it as a blank item
// the author made (data-are-empty, as for paragraphs), and then the <br> it carries for outside
// renderers is rendering, not content. Neither was read here: a blank numbered item came back
// holding a line break, and in the port (which writes no <br>) it vanished, letting the items
// either side merge into one list and lose a marker (the port's fuzz, seed 8178, 2026-09-24).
bool markedEmpty = child.GetAttributeValue("data-are-empty", "") == "1";
if (markedEmpty) p.Inlines.Clear();
if (p.Inlines.Count > 0 || markedEmpty) flow.Blocks.Add(p);

// A sublist nested INSIDE the item (the shape most other producers emit) still follows it.
foreach (var nested in child.ChildNodes.Where(n => n.Name.Equals("ul", StringComparison.OrdinalIgnoreCase) || n.Name.Equals("ol", StringComparison.OrdinalIgnoreCase)))
Expand Down Expand Up @@ -1331,7 +1338,10 @@ private static void EmitInline(StringBuilder sb, Inline inline, bool opensParagr
if (HasDecoration(r.TextDecorations, TextDecorationLocation.Strikethrough)) t = $"<s>{t}</s>";
if (r.FontWeight == FontWeight.Bold) t = $"<b>{t}</b>";
if (r.FontStyle == FontStyle.Italic) t = $"<i>{t}</i>";
if (!string.IsNullOrEmpty(r.NavigateUri)) t = $"<a href=\"{AttrEscape(r.NavigateUri)}\">{t}</a>";
// The readers drop script links, but a host's SetHyperlink reaches here without passing one — the
// same check, so no script link leaves in exported or clipboard HTML whatever put it in the document.
if (!string.IsNullOrEmpty(r.NavigateUri) && SafeHref(r.NavigateUri) is { } href)
t = $"<a href=\"{AttrEscape(href)}\">{t}</a>";
sb.Append(t);
}

Expand Down
2 changes: 1 addition & 1 deletion src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ private void StartPageChrome(bool header)
// fallback is A4, which is what such a document would be printed on anyway.
private void ApplyMargins()
{
double Side(int i, double fallback) => _marginTwips[i] >= 0 ? _marginTwips[i] / PageSetup.TwipsPerMm : fallback;
double Side(int i, double fallback) => _marginTwips[i] >= 0 ? PageSetup.TwipsToMm(_marginTwips[i]) : fallback;
var d = PageSetup.DefaultMargin;
var m = new PageMargins(Side(0, d.Left), Side(1, d.Top), Side(2, d.Right), Side(3, d.Bottom));
var ps = _doc.PageSetup;
Expand Down
75 changes: 75 additions & 0 deletions tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,79 @@ public void AWebLink_IsKept(string href)
var run = doc.Blocks.OfType<Paragraph>().SelectMany(p => p.Inlines.OfType<Run>()).First(r => r.Text == "click");
Assert.Equal(href, run.NavigateUri);
}

// ---- the same rule on the other ways in (port audit, 2026-09-24) ----------------------------------------
// The HTML reader drops script links, but a JSON/.flow file carried them in untouched and the HTML writer
// sent them back out. A host's SetHyperlink reaches the writer too, so the writer is the backstop. (This
// RTF reader does not read HYPERLINK fields; the port's does, and filters them the same way.)

private static FlowDocument Linked(string href)
{
var doc = new FlowDocument();
doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "click", NavigateUri = href } } });
return doc;
}

private static Run Clicked(FlowDocument doc)
=> doc.Blocks.OfType<Paragraph>().SelectMany(p => p.Inlines.OfType<Run>()).First(r => r.Text?.Contains("click") == true);

[AvaloniaTheory]
[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);
}

[AvaloniaFact]
public void AScriptLinkSetByTheHost_IsNotWrittenToHtml()
{
string html = HtmlDocumentFormatter.ToHtml(Linked("javascript:alert(1)"));

Assert.DoesNotContain("javascript", html, System.StringComparison.OrdinalIgnoreCase);
Assert.Contains("click", html);
}

// The other half, so the guards cannot pass by dropping every link.
[AvaloniaFact]
public void AWebLink_SurvivesJsonAndTheHtmlWriter()
{
const string url = "https://example.com/a?b=1";

Assert.Equal(url, Clicked(DocumentSerializer.Deserialize(DocumentSerializer.Serialize(Linked(url)))).NavigateUri);
Assert.Contains("href=\"https://example.com/a?b=1\"", HtmlDocumentFormatter.ToHtml(Linked(url)).Replace("&amp;", "&"));
}
// An empty list item. The writer marks it data-are-empty as it marks a blank paragraph, but the list
// reader dropped every empty item regardless — a blank numbered item vanished on the first round trip,
// and the items either side could then merge into one list and lose a marker on the second (the port's
// fuzz, seed 8178 at 20000 seeds, 2026-09-24). Twice, as round trips are run here.
[AvaloniaFact]
public void AnEmptyListItem_RoundTrips_ButAForeignEmptyItemIsStillDropped()
{
static Paragraph Item(string? text)
{
var p = new Paragraph { ListType = ListKind.Ordered };
if (text != null) p.Inlines.Add(new Run { Text = text });
return p;
}
var doc = new FlowDocument();
doc.Blocks.Add(Item("a"));
doc.Blocks.Add(Item(null));
doc.Blocks.Add(Item("b"));

var once = HtmlDocumentFormatter.ParseHtml(HtmlDocumentFormatter.ToHtml(doc));
var twice = HtmlDocumentFormatter.ParseHtml(HtmlDocumentFormatter.ToHtml(once));

foreach (var back in new[] { once, twice })
{
var items = back.Blocks.OfType<Paragraph>().Where(p => p.ListType == ListKind.Ordered).ToList();
Assert.Equal(3, items.Count);
Assert.DoesNotContain(items[1].Inlines.OfType<Run>(), r => !string.IsNullOrEmpty(r.Text));
}

var foreign = HtmlDocumentFormatter.ParseHtml("<ol><li>a</li><li></li><li>b</li></ol>");
Assert.Equal(2, foreign.Blocks.OfType<Paragraph>().Count(p => p.ListType == ListKind.Ordered));
}
}
29 changes: 29 additions & 0 deletions tests/AvaloniaRichEditor.Tests/PageMarginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,35 @@ public void MarginsRoundTripThroughRtf_ToWithinATwip()
Assert.Equal(Wide.Bottom, m.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 and the JSON stored a custom margin (port audit, 2026-09-24). Twice, as round trips are run 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));
}

// The point of reading them: a file from another word processor keeps its own margins. 1440 twips = 1
// inch = 96 DIP, the Word default; 720 = half an inch. (A4 here is 11910 x 16845 twips — the paper
// table's rounded DIPs, within the reader's 2-twip tolerance of Word's own 11906 x 16838.)
Expand Down
Loading
Loading