From ad9649395f5d06493013697cf504357a87ff0886 Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 24 Sep 2026 08:29:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=8F=AC=ED=8A=B8=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EC=97=AD=EB=B0=B1=ED=8F=AC=ED=8A=B8=20=E2=80=94=20?= =?UTF-8?q?=EB=96=A8=EC=96=B4=EC=A7=84=20=EA=B0=9C=EC=B2=B4=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=C2=B7=20RTF=20=EC=97=AC=EB=B0=B1=20=EC=99=95?= =?UTF-8?q?=EB=B3=B5=20=C2=B7=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20JSON/HTML=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WinUIRichEditor 포트가 미게시 변경분을 감사해 찾은 결함 중 상류에도 있는 3건(포트 PR #56). 각각 상류에서 실패하는 테스트로 먼저 재현했다. - 표 행·열 공개 API: 지운 행·열 안의 중첩 표·그림이 선택된 채 남아, Delete가 떨어진 행을 고치는 빈 되돌리기 단계를 만들었다. 네 Table* 본문에서 DropDetachedObjectSelection(문서에서 아래로 걷는다). - RTF 여백 왕복: 15 mm → 850 twip → 14.993 mm. PageSetup.TwipsToMm이 0.1 mm 단위이고 같은 twip으로 되돌아가는 값만 스냅. Word 1800 twip(31.75 mm)은 그대로. - 스크립트 링크: JSON 읽기에 SafeHref, HTML 출력에도 같은 검사(호스트 SetHyperlink 대비). 이 RTF 읽기는 HYPERLINK 필드를 읽지 않아 해당 없음. 테스트 1240 전체 통과. 반증: 선택 정리를 끄면 2건 / 늘 지우면 1건 빨강, 포매터 수정 되돌리면 10건 빨강. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++ Project_Roadmap.md | 5 ++ .../Controls/RichEditor.Tables.cs | 28 ++++++++ src/AvaloniaRichEditor/Documents/PageSetup.cs | 12 ++++ .../Formatters/DocumentSerializer.cs | 4 +- .../Formatters/HtmlDocumentFormatter.cs | 5 +- .../Formatters/RtfDocumentFormatter.cs | 2 +- .../HtmlAuditTests.cs | 44 +++++++++++++ .../PageMarginTests.cs | 29 +++++++++ .../TableStructureApiTests.cs | 65 +++++++++++++++++++ 10 files changed, 211 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a5479..0611c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ 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. + ### 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, diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 0be0698..3c6fef7 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -64,6 +64,11 @@ 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` 필드를 읽지 않아 그 경로는 해당 없음. **알려진 제한 (의도적으로 둠)** - RTF 중첩 표의 열 너비는 가져올 때 기본값(`\cellx`가 무시되는 props 그룹 안에 있다). diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs index c7c2a69..3c0cefa 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs @@ -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 @@ -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; @@ -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. @@ -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; @@ -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 diff --git a/src/AvaloniaRichEditor/Documents/PageSetup.cs b/src/AvaloniaRichEditor/Documents/PageSetup.cs index 66b5780..ad13030 100644 --- a/src/AvaloniaRichEditor/Documents/PageSetup.cs +++ b/src/AvaloniaRichEditor/Documents/PageSetup.cs @@ -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; + } + /// Paper size in millimetres for a page size + orientation. public static (double W, double H) PaperMillimetres(Controls.RichEditorPageSize size, Controls.RichEditorPageOrientation orientation) { diff --git a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs index 4245c9a..2f2ea0a 100644 --- a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs +++ b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs @@ -483,7 +483,9 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary{t}"; if (r.FontWeight == FontWeight.Bold) t = $"{t}"; if (r.FontStyle == FontStyle.Italic) t = $"{t}"; - if (!string.IsNullOrEmpty(r.NavigateUri)) t = $"{t}"; + // 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 = $"{t}"; sb.Append(t); } diff --git a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs index 574e2b3..eabe25b 100644 --- a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs +++ b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs @@ -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; diff --git a/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs index cd4492f..5f0170d 100644 --- a/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs +++ b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs @@ -56,4 +56,48 @@ 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 (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().SelectMany(p => p.Inlines.OfType()).First(r => r.Text.Contains("click")); + + [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("&", "&")); + } } diff --git a/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs b/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs index b704778..4e29f07 100644 --- a/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs +++ b/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs @@ -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.) diff --git a/tests/AvaloniaRichEditor.Tests/TableStructureApiTests.cs b/tests/AvaloniaRichEditor.Tests/TableStructureApiTests.cs index 4d3d103..33dc3b6 100644 --- a/tests/AvaloniaRichEditor.Tests/TableStructureApiTests.cs +++ b/tests/AvaloniaRichEditor.Tests/TableStructureApiTests.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Reflection; using Avalonia.Headless.XUnit; +using Avalonia.Input; using AvaloniaRichEditor.Controls; using AvaloniaRichEditor.Documents; using Xunit; @@ -264,4 +265,68 @@ public void AnInlineTable_IsEditableToo() Assert.Equal(3, it.Table.Rows); } + + // ---- an object selected inside what the command removes (port audit, 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 then pushed an undo step for an edit of the detached row and left the document as it was. + + private static void Press(RichEditor ed, Key key) + => ed.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key }); + + private static object? Field(RichEditor ed, string name) => typeof(RichEditor).GetField(name, NP)!.GetValue(ed); + private static void SetField(RichEditor ed, string name, object? v) => typeof(RichEditor).GetField(name, NP)!.SetValue(ed, v); + + private const string NestedInRow2 = + "
a
in
"; + + [AvaloniaFact] + public void DeletingTheRowAroundASelectedNestedTable_LetsGoOfIt() + { + var ed = Editor(NestedInRow2); + 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.Null(Field(ed, "_selectedBlock")); + } + + [AvaloniaFact] + public void DeletingTheRowAroundASelectedPicture_ThenDelete_ActsAtTheCaret() + { + var ed = Editor("
a
b
"); + var tb = Table(ed); + var host = tb.Cells[1][0].Para; + var img = new InlineImage { Width = 10, Height = 10 }; + host.Inlines.Add(img); + PlaceCaret(ed, tb.Cells[0][0].Para); + SetField(ed, "_selectedInline", ((Paragraph, InlineImage)?)(host, img)); + + Assert.True(ed.DeleteTableRow(tb, 1)); + Assert.Null(Field(ed, "_selectedInline")); + + // What a user does next: the key goes to the caret (it deletes "a"), not to an invisible edit of the + // detached row that pushed an undo step and left the document as it was. + Press(ed, Key.Delete); + Assert.Equal("", string.Concat(Table(ed).Cells[0][0].Para.Inlines.OfType().Select(r => r.Text))); + } + + // The other half: an object the edit did NOT remove stays selected, so the fix cannot simply clear every + // selection on every command. + [AvaloniaFact] + public void AnObjectTheEditLeavesInPlace_StaysSelected() + { + var ed = Editor(NestedInRow2); + 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.InsertTableRow(tb, 0)); + + Assert.Same(nested, Field(ed, "_selectedBlock")); + } } From c1972eff56c2cf807b9e3bb4caa1e8a335df6bcb Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 24 Sep 2026 10:13:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20HTML=20=EB=B9=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=95=AD=EB=AA=A9=EC=9D=B4=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EB=93=A0=20=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EC=98=A4=EB=8D=98=20=EA=B2=83=20(=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=8D=BC=EC=A6=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포트가 문서 퍼즈를 페이지 설정까지 넓히자 20,000시드 중 시드 8178에서 드러난 HTML 결함의 상류판. 쓰기는 빈 항목에 data-are-empty와 외부 렌더러용
을 넣는데, 목록 읽기는 둘 다 보지 않았다 —
이 내용으로 읽혀 빈 항목이 줄바꿈 든 항목이 됐다(포트는 항목이 통째로 사라져 양옆 항목이 합쳐지고 번호 모양이 바뀌었다). 빈 문단과 같은 규칙으로 읽는다. 외부 HTML의 표식 없는 빈 항목은 여전히 버린다. 수정 전 빨강 확인. 테스트 전체 통과. 새 테스트의 경고(CS8602) 정리. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ Project_Roadmap.md | 2 ++ .../Formatters/HtmlDocumentFormatter.cs | 9 ++++- .../HtmlAuditTests.cs | 33 ++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0611c07..57e4be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ was reproduced here with a failing test before it was fixed. 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 `
` for outside renderers, as it does a blank paragraph, but the list + reader honoured neither: the `
` 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) diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 3c6fef7..f323e44 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -69,6 +69,8 @@ FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering JSON 입력과 HTML 출력에 스크립트 링크 검사가 없음. ⚠ 기존 여백 테스트가 "1 twip 이내"를 계약으로 잡아 결함을 가렸다. 상류는 공통 `AfterEdit`가 없어 선택 정리를 행·열 명령 네 곳에만 걸었다(포트는 모든 편집 경로). 상류 RTF 읽기는 `HYPERLINK` 필드를 읽지 않아 그 경로는 해당 없음. +- 포트 퍼즈(페이지 설정까지 넓힘, 20,000시드)가 찾은 HTML 빈 목록 항목 결함도 같은 PR로 옮김 — 목록 읽기가 + `data-are-empty`와 그 `
`을 보지 않아 빈 항목이 줄바꿈 든 항목으로 돌아왔다(포트는 항목이 통째로 사라짐). **알려진 제한 (의도적으로 둠)** - RTF 중첩 표의 열 너비는 가져올 때 기본값(`\cellx`가 무시되는 props 그룹 안에 있다). diff --git a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs index 9c478f2..5c2641c 100644 --- a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs +++ b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs @@ -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
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
) 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))) diff --git a/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs index 5f0170d..af9bab7 100644 --- a/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs +++ b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs @@ -70,7 +70,7 @@ private static FlowDocument Linked(string href) } private static Run Clicked(FlowDocument doc) - => doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text.Contains("click")); + => doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text?.Contains("click") == true); [AvaloniaTheory] [InlineData("javascript:alert(1)")] @@ -100,4 +100,35 @@ public void AWebLink_SurvivesJsonAndTheHtmlWriter() 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("&", "&")); } + // 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().Where(p => p.ListType == ListKind.Ordered).ToList(); + Assert.Equal(3, items.Count); + Assert.DoesNotContain(items[1].Inlines.OfType(), r => !string.IsNullOrEmpty(r.Text)); + } + + var foreign = HtmlDocumentFormatter.ParseHtml("
  1. a
  2. b
"); + Assert.Equal(2, foreign.Blocks.OfType().Count(p => p.ListType == ListKind.Ordered)); + } }