diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8f6e0..41474f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ and follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 미게시 변경분 감사 — 결함 3건 (2026-09-24) + +1.2.0 이후 들어온 변경을 감사해 찾았다. 셋 다 상류에도 있어 같은 수정을 상류에 옮겼다. + +- **표 행·열 공개 API가 그 안에서 선택된 개체를 놓지 않았다.** 호스트가 `DeleteTableRow`/`DeleteTableColumn`으로 + 지운 행·열 안에 중첩 표·그림·인라인 표가 개체로 선택돼 있으면, 문서에서 빠진 뒤에도 선택된 채 남았다. 이어서 + Delete를 누르면 떨어져 나간 행을 고치는 되돌리기 단계가 생기고, 인라인 그림이면 **캐럿이 문서 밖 문단에** 놓였다. + 이제 편집 뒤에 문서에 없는 개체 선택은 풀린다(남아 있는 개체의 선택은 그대로). 포인터·키·메뉴 경로는 전부터 + 편집 전에 선택을 풀었고, 공개 API만 그러지 않았다. +- **RTF로 저장했다 연 여백이 원래 mm 값으로 돌아오지 않았다.** RTF는 twip(0.0176 mm) 정수라 15 mm가 850 twip으로 + 나갔다가 14.993 mm로 돌아왔다. 도구 모음 여백 픽커의 다섯 단계 **모두** 제자리로 돌아오지 않아 "보통" 대신 "15mm"가 + 보였고, JSON에는 사용자 지정 여백으로 저장됐다. 이제 0.1 mm 단위 값이고 같은 twip으로 다시 써지는 값이면 그 값으로 + 읽는다. Word의 1.25인치(1800 twip = 31.75 mm)처럼 그 사이 값은 정확한 길이를 그대로 둔다. +- **스크립트 링크(`javascript:`·`vbscript:`·`data:`) 차단이 HTML 입력에만 걸려 있었다.** RTF의 `HYPERLINK` + 필드(클립보드 RTF 붙여넣기)와 JSON/`.flow` 파일로 들어온 스크립트 링크는 그대로 남아, 내보낸 HTML과 클립보드 + HTML로 다시 나갔다. 이제 두 입력 경로도 같은 규칙으로 링크를 버린다(글자는 남는다). HTML 출력도 같은 검사를 + 하므로, 호스트가 `SetHyperlink`로 넣은 스크립트 링크도 HTML로는 나가지 않는다. +- **HTML 왕복에서 빈 목록 항목이 사라졌다.** 내보내기는 빈 항목에 빈 줄 표식을 붙였지만 목록 읽기가 표식을 보지 않고 + 빈 항목을 모두 버렸다. 그러면 양옆 항목이 한 목록으로 합쳐져, 두 번째 왕복에서 뒤 항목의 번호 모양(예: A, B, C)이 + 앞 항목의 것으로 바뀌었다. 문서 퍼즈를 페이지 설정까지 넓히자 20,000시드 중 하나에서 드러났다. 표식 없는 외부 HTML의 + 빈 항목은 전처럼 버린다. 상류에도 같은 결함이 있었다(상류는 빈 줄에 `
`을 넣어 줄바꿈이 든 항목으로 돌아왔다). + ### 클립보드가 잠깐 바쁠 때 복사·붙여넣기가 조용히 실패하던 것 (2026-09-24) - 클립보드 기록(Win+V)·클립보드 관리자처럼 클립보드를 지켜보는 프로그램이 있으면, 변경 직후 잠깐 클립보드가 diff --git a/Project_Roadmap.md b/Project_Roadmap.md index b02627f..ee98920 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -12,13 +12,13 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 --- -## ✅ 현재 상태 (2026-09-20 · `1.2.0` 게시) +## ✅ 현재 상태 (2026-09-24 · `1.2.0` 게시, 미게시 변경 누적 중) | | | |---|---| | 릴리스 | 1.0.0(07-31, **API 동결**) → 1.1.0(08-07) → 1.1.1(09-06, AOT 결함 수정) → **1.2.0(09-20 게시)** | | 빌드 | 라이브러리 0 warn / 0 err (테스트 프로젝트에 xUnit1031 1건 — 블로킹 대기, 기존) | -| 테스트 | **815** (1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 | +| 테스트 | **930**(2026-09-24, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 | | AOT | self-contained 게시 성공(2026-09-20): 네이티브 exe **15.2MB**, 게시 **74.6MB**(pdb 제외), CoreCLR·관리 dll 없음 | | 공개 표면 | **574** — `PublicAPI.Shipped.txt`로 추적, 1.2.0은 **추가 7**(breaking 없음) | @@ -62,12 +62,51 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 프레임워크의 라우팅·히트테스트·포커스뿐이고, 그건 여전히 `fault-sweep` + 실기의 몫이다. 2. **실기에만 있는 검증 항목**: AltGr 자판(처리기 배선은 `KeyRoutedEventArgs`를 만들 수 없어 자동 검증 밖), IME 조합, 포커스·캐럿 깜빡임. -3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조. 라운드34(상류 PR #53)는 2026-09-23 옮김(아래 절). - PR #50·#52(페이지 여백·여백 후속)는 2026-09-24 옮김 — CHANGELOG `[Unreleased]`. 목록에 있던 "그림 손잡이 vs 열 경계 - 우선순위"와 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`은 이미 들어와 있었다(`ControlImageResizeTests`· - `ControlCaretFormatTests`). PR #48(표 행·열 공개 API)·#49(단축키 표 공개)도 2026-09-24 옮김 — 상류 PR #53까지 - 대조 끝. (`Gesture(id)`는 Avalonia 전용이라 뺐다. enum 순서는 포트 것 유지.) -4. 아래 "알려진 한계"의 미수정 항목. +3. ~~**상류 백포트 잔여**~~ → **상류 PR #53까지 대조 끝**(2026-09-24). 라운드34(#53), 페이지 여백(#50·#52), + 표 행·열 공개 API(#48), 단축키 표 공개(#49) — CHANGELOG `[Unreleased]`. (`Gesture(id)`는 Avalonia 전용이라 뺐다. + enum 순서는 포트 것 유지.) +4. **다음 게시(1.3.0) 전에 남은 검증** — 아래 "미게시 변경분 감사" 절의 남은 항목. +5. 아래 "알려진 한계"의 미수정 항목. + +### 미게시 변경분 감사 (2026-09-24) — 결함 3(상류 공통), 테스트 910 → 930, 반증 완료 +1.2.0 이후 들어온 변경(PR #52~#55, 소스 약 1,250줄)을 **자기 변경분부터** 감사했다. 셋 다 상류에도 있어 같은 수정을 +상류에 옮겼다(상세는 CHANGELOG). +- **표 행·열 공개 API가 떨어져 나간 개체를 선택된 채 남겼다** → Delete가 떨어진 행을 고치고 캐럿이 문서 밖으로. + 포인터·키·메뉴는 편집 전에 선택을 풀지만 **공개 API는 거치지 않는다** — 새 공개 명령을 넣을 때마다 볼 자리. + 수정은 `AfterEdit`/`AfterStructuralEdit`의 `DropDetachedObjectSelection`(문서에서 **아래로** 걷는다. 지운 행의 셀도 + 표를 부모로 기억해 부모 사슬은 "안"이라고 한다). 반증 두 방향: 끄면 3건 빨강, 늘 지우면 "남은 개체 유지" 빨강. +- **RTF 여백 왕복이 mm를 틀었다**(15 → 14.993, 픽커 5단계 전부 불일치). ⚠ 기존 테스트가 "1 twip 이내"를 계약으로 + 잡아 두어 못 봤다 — **허용 오차가 결함을 계약으로 굳힌 사례.** 0.1 mm 단위이고 같은 twip으로 되돌아가는 값만 스냅. +- **스크립트 링크 차단이 HTML 입력에만** 있었다(RTF `HYPERLINK` 필드·JSON/.flow는 통과, HTML 출력으로 재방출). + 라운드34 결정("읽을 때 버린다")을 모든 입력 경로 + HTML 출력(호스트 `SetHyperlink` 대비)으로 넓혔다. +- 문제없음 확인: 클립보드 재시도(#55) — 잘라내기는 복사 내용을 동기로 잡고, 그림 복사도 바이트를 즉시 캡처한다. +- 퍼즈 **20,000시드** 통과(포매터 변경 후 레시피대로. 여백 백포트 때는 2,000시드였다). + +**후속(같은 날)**: +- **퍼즈가 페이지 설정·여백·블록 위 여백(`AutoTopMargin`)을 만들고 본다** — 연산 2개(`page setup`, `object top + margin`) + `Shape`에 `PAGE{…}`·`{mt…}`. 반증: JSON이 왼쪽 여백을 잃게 하면 446건, 구분선의 자동 간격을 0으로 읽게 + 하면 578건 빨강. 넓히자마자 **결함 1건**: HTML이 빈 목록 항목을 버려(표식을 안 봄) 양옆 항목이 합쳐지고 번호 모양이 + 바뀌었다(시드 8178, 20,000시드에서만). 상류 공통 — 고쳐 옮겼다. 수정 뒤 20,000시드 통과. +- **여백 AOT 프로브** — 데모 `--pageprobe=<파일>`(`PageMarginProbe`): 화면 밖 에디터로 여백 DP 설정·박싱된 값 되읽기· + 거부·문서 캡처·JSON/RTF 왕복·문서→DP 적재·툴바를 거쳐 한 줄씩 적는다. `fault-sweep.ps1`이 이것을 함께 켜고 + 출력한다(`.page.txt`). 키 입력은 여백에 닿지 않고, 툴바를 좌표로 누르면 좌표를 시험하게 된다. + **2026-09-24 실측: JIT와 AOT 출력 바이트 동일**, AOT 전용 결함은 `LineMetricsOf` 하나(알려진 폴백). + AOT 게시: 네이티브 exe 16.0MB(1.2.0 15.2MB), 게시 76MB(pdb 제외). + +**남은 항목**(1.3.0 게시 전): +- ~~**AOT 동등성 스윕 전체**~~ → **2026-09-24 실측 완료**: `control`·`toolbar` 두 페이지 모두 JIT/AOT의 되읽은 문서가 + **바이트 동일**(711자, 타이핑한 문장 포함 — 공허한 비교 아님), 여백 프로브도 동일. AOT 전용 결함은 `LineMetricsOf` + 하나(알려진 폴백, 1.2.0과 같음). +- ~~**실기 확인**~~ → **2026-09-24 사용자 확인, 문제 없음**: 여백 픽커 5단계(레이블·쪽 여백), 쪽 보기의 표 선택 + 왼쪽 선·그림 선택 테두리(바깥)·쪽 맨 위 그림 윗선, RTF 저장→열기 뒤 픽커 "보통". +- **상류와 어긋난 곳(새로 확인)**: 상류는 빈 줄을 HTML로 쓸 때 `
`을 넣어 브라우저에서 줄이 보이게 했다(상류 실측) + (`HtmlExternalRenderingTests`). 포트는 표식만 써서 **브라우저에서는 빈 줄이 안 보일 수 있다** — 옮길지 측정 후 결정. + +### 클립보드 경합 재시도 (2026-09-24, PR #55) +클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜 +붙여넣기 6%·복사 6%가 조용히 실패했다(개발 PC 실측). 그 오류만 **await**로 20 ms × 10회 재시도 — `Thread.Sleep`은 +안 된다(잠근 쪽이 우리 UI 스레드에 형식 렌더링을 요청 중). `ControlFeatureFlagTests`의 HTML 붙여넣기 간헐 실패의 +원인이었다. 결정적 테스트는 없다(같은 프로세스에서 잡은 클립보드는 실제 경합과 동작이 다르다). ### 상류 라운드34 백포트 (2026-09-23) — 테스트 832 → 862, 결함 14(보안 2 포함) + 동작 변경 3, 반증 완료 상류 결함 32건과 결정 4건을 **이 포트에서 먼저 측정**했다. 빨강이 된 것만 고쳤다(상세는 CHANGELOG). diff --git a/samples/WinUIRichEditor.Demo/MainWindow.xaml.cs b/samples/WinUIRichEditor.Demo/MainWindow.xaml.cs index cc5109b..ee4fb59 100644 --- a/samples/WinUIRichEditor.Demo/MainWindow.xaml.cs +++ b/samples/WinUIRichEditor.Demo/MainWindow.xaml.cs @@ -48,6 +48,9 @@ private void NavigateToStartPage() // because a WinExe has no console to print to. else if (arg.StartsWith("--roundtrip=", System.StringComparison.OrdinalIgnoreCase)) RunRoundTrip(arg["--roundtrip=".Length..]); + // `--pageprobe=`: page-margin readback for the JIT/AOT comparison (PageMarginProbe). + else if (arg.StartsWith("--pageprobe=", System.StringComparison.OrdinalIgnoreCase)) + PageMarginProbe.Run(arg["--pageprobe=".Length..]); } switch (page) diff --git a/samples/WinUIRichEditor.Demo/PageMarginProbe.cs b/samples/WinUIRichEditor.Demo/PageMarginProbe.cs new file mode 100644 index 0000000..43b7a14 --- /dev/null +++ b/samples/WinUIRichEditor.Demo/PageMarginProbe.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using WinUIRichEditor.Controls; +using WinUIRichEditor.Documents; +using WinUIRichEditor.Formatters; + +namespace WinUIRichEditor.Demo; + +/// --pageprobe=<path>: drives the page margins through every path a host reaches and writes +/// what came back, one line per step, for tools/fault-sweep.ps1 to diff between the JIT build and the +/// Native AOT publish. +/// Why a probe and not a keystroke phase: is the first +/// dependency property that holds a C# record struct — a value WinRT has no type for, boxed across the +/// projection on every SetValue/GetValue. AOT renders and still differs (1.1.0 shipped broken +/// line metrics that way), so the value has to be read back, not just set. The sweep's input is keyboard +/// and one click; no key reaches the margins, and clicking the toolbar picker by coordinates would test +/// the coordinates. It runs on an editor of its own, off screen, so the page the sweep types into keeps +/// the layout its click offsets assume. +internal static class PageMarginProbe +{ + internal static void Run(string path) + { + if (path.Length == 0) return; + var lines = new List(); + void Step(string name, Func read) + { + try { lines.Add($"{name}: {read()}"); } + catch (Exception ex) { lines.Add($"{name}: EXCEPTION {ex.GetType().Name}: {ex.Message}"); } + } + + RichEditor? ed = null; + Step("create", () => { ed = new RichEditor { Document = DemoContent.Sample(), PageSize = RichEditorPageSize.A4 }; return "ok"; }); + if (ed is null) { Write(path, lines); return; } + var margins = new PageMargins(20, 12.5, 25, 30.4); + + Step("default", () => ed.PageMargin); + Step("set", () => { ed.PageMargin = margins; return ed.PageMargin; }); + Step("boxed", () => ed.GetValue(RichEditor.PageMarginProperty) is PageMargins m ? $"PageMargins {m}" : "NOT a PageMargins"); + Step("document", () => ed.Document?.PageSetup?.Margin); + Step("paper", () => ed.GetPaperPixelSize()); + Step("pages", () => ed.GetPrintPageCount()); + // Refused: WinUI has no coerce callback, so the changed-callback puts the old value back with a + // second SetValue — a re-entrant round trip through the projection. + Step("refused", () => { ed.PageMargin = new PageMargins(-5); return ed.PageMargin; }); + Step("json", () => DocumentSerializer.Deserialize(DocumentSerializer.Serialize(ed.Document!)).PageSetup?.Margin); + Step("rtf", () => RtfDocumentFormatter.Parse(RtfDocumentFormatter.Write(ed.Document!)).PageSetup?.Margin); + // The model -> property direction: a loaded document's setup is pushed into the DP. + Step("load", () => + { + var reopened = new RichEditor { Document = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(ed.Document!)) }; + return reopened.PageMargin; + }); + // The toolbar reads Target.PageMargin in its sync and matches it against its presets. + Step("toolbar", () => { _ = new RichEditorToolbar { Target = ed }; ed.PageMargin = new PageMargins(10); return ed.PageMargin; }); + Write(path, lines); + } + + private static void Write(string path, List lines) + { + try { File.WriteAllLines(path, lines); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"pageprobe: {ex.Message}"); } + } +} diff --git a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs index 0bfe27b..ffc2b2a 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs @@ -60,6 +60,24 @@ internal enum ResizeGrip { None, Corner, Right, Bottom } private void ClearObjectSelection() { _selectedBlock = null; _selectedInline = null; _selectedInlineTable = null; } + // Lets go of a selected object that an 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 removed the inline picture from the detached row and parked the + // caret there — outside the document (2026-09-24). Checked by walking DOWN from the document: a detached + // node still names its old parent, and a deleted row's cells still name the table, so a parent chain would + // call it inside (see TableIsInDocument). + private void DropDetachedObjectSelection() + { + if (!HasBlockSelection) return; + if (Document == null) { ClearObjectSelection(); return; } + HashSet? inDoc = null; + bool InDoc(Block b) => (inDoc ??= new HashSet(BlockWalk.DocumentOrder(Document.Blocks), + ReferenceEqualityComparer.Instance)).Contains(b); + if (_selectedBlock is { } blk && !InDoc(blk)) _selectedBlock = null; + if (_selectedInline is { } si && !(InDoc(si.p) && si.p.Inlines.Contains(si.img))) _selectedInline = null; + if (_selectedInlineTable is { } st && !(InDoc(st.host) && st.host.Inlines.Contains(st.it))) _selectedInlineTable = null; + } + // Records an inline table's rect for hit-testing and draws its selection chrome if selected. private void TrackInlineTable(CanvasDrawingSession ds, Paragraph host, InlineTable it, Rect rect) { diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs index d32a6e8..f98c1aa 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs @@ -1385,6 +1385,7 @@ private void ApplyHistoryState(UndoState st) // After a content edit: re-measure and repaint (the changed paragraph reshapes via ParagraphSig). private void AfterEdit() { + DropDetachedObjectSelection(); InvalidateCaretTableMeasure(); RelayoutToViewport(); RestartBlink(); diff --git a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs index 77984b6..f63ff31 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs @@ -875,6 +875,7 @@ private void InvalidateTableMeasure(TableBlock tb) private void AfterStructuralEdit(TableBlock tb) { + DropDetachedObjectSelection(); // a host call edits without clearing it first (DeleteTableRow) InvalidateTableMeasure(tb); _coalesceKey = null; RelayoutToViewport(); diff --git a/src/WinUIRichEditor/Documents/PageSetup.cs b/src/WinUIRichEditor/Documents/PageSetup.cs index 0f2d776..ae403be 100644 --- a/src/WinUIRichEditor/Documents/PageSetup.cs +++ b/src/WinUIRichEditor/Documents/PageSetup.cs @@ -58,6 +58,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 (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(RichEditorPageSize size, RichEditorPageOrientation orientation) { diff --git a/src/WinUIRichEditor/Formatters/DocumentSerializer.cs b/src/WinUIRichEditor/Formatters/DocumentSerializer.cs index a5dc1e4..534a89d 100644 --- a/src/WinUIRichEditor/Formatters/DocumentSerializer.cs +++ b/src/WinUIRichEditor/Formatters/DocumentSerializer.cs @@ -469,7 +469,8 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary 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). Dropped regardless, a blank numbered item + // vanished on the first round trip, and the items either side could merge into one list on the + // second and lose a marker (fuzz seed 8178, 2026-09-24). + if (p.Inlines.Count > 0 || child.GetAttributeValue("data-are-empty", "") == "1") 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))) @@ -1306,7 +1310,10 @@ private static void EmitInline(StringBuilder sb, Inline inline, bool opensParagr if (r.TextDecorations.HasFlag(TextDecorationFlags.Strikethrough)) t = $"{t}"; if (r.FontWeight.IsBold()) 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/WinUIRichEditor/Formatters/RtfDocumentFormatter.cs b/src/WinUIRichEditor/Formatters/RtfDocumentFormatter.cs index 9a4a899..6179c88 100644 --- a/src/WinUIRichEditor/Formatters/RtfDocumentFormatter.cs +++ b/src/WinUIRichEditor/Formatters/RtfDocumentFormatter.cs @@ -607,7 +607,9 @@ private void FinalizeFieldInst() if (inst.Length == 0) return; var m = System.Text.RegularExpressions.Regex.Match(inst, "HYPERLINK\\s+(?:\"([^\"]+)\"|(\\S+))", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (m.Success) _pendingFieldUrl = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value; + // A script link (javascript:, vbscript:, data:) is dropped as the HTML reader drops it — RTF is a clipboard + // flavour, and the link would otherwise go back out in exported and clipboard HTML (2026-09-24). + if (m.Success) _pendingFieldUrl = HtmlDocumentFormatter.SafeHref(m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value); } // ---- font table ---- @@ -692,7 +694,7 @@ private void CloseColorEntry() // Continuous here (margins but no size, or a size with no name here): its print fallback is A4. 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/WinUIRichEditor.Tests/DocumentFuzzTests.cs b/tests/WinUIRichEditor.Tests/DocumentFuzzTests.cs index bff168b..9b89dd0 100644 --- a/tests/WinUIRichEditor.Tests/DocumentFuzzTests.cs +++ b/tests/WinUIRichEditor.Tests/DocumentFuzzTests.cs @@ -279,10 +279,43 @@ private static string ApplyRandomOp(FlowDocument doc, Random rng) var paras = AllParagraphs(doc).ToList(); var tables = AllTables(doc).ToList(); - // 23 ops, and `default` is still one of them (RunNormalizer.Compact) — a new case must WIDEN this + // 25 ops, and `default` is still one of them (RunNormalizer.Compact) — a new case must WIDEN this // bound, not take the last number, or the default branch silently stops running. - switch (rng.Next(23)) + switch (rng.Next(25)) { + case 23: + { + // The page setup — paper, orientation, header/footer, page numbers and, since the 1.3 cycle, the + // four margins in millimetres. None of it was generated (or observed, see PageFmt) until the + // 2026-09-24 audit, so the fields that cycle added to JSON/.flow and RTF were outside the fuzz. + var sizes = Enum.GetValues(); + var ps = doc.PageSetup ??= new PageSetup(); + ps.PageSize = sizes[rng.Next(sizes.Length)]; + ps.Orientation = (WinUIRichEditor.Controls.RichEditorPageOrientation)rng.Next(2); + if (rng.Next(3) == 0) ps.ShowPageBoundaries = rng.Next(2) == 0; + ps.Header = rng.Next(3) == 0 ? "머리 " + rng.Next(10) : null; + ps.Footer = rng.Next(3) == 0 ? "바닥 " + rng.Next(10) : null; + ps.ShowPageNumbers = rng.Next(3) == 0; + // Three kinds of millimetre: the picker's steps, tenths (what the RTF reader snaps back to), and + // values between tenths (which it must NOT move). All usable on A5 landscape, the smallest page. + double Mm() => rng.Next(3) switch + { + 0 => new[] { 5.0, 10, 15, 20, 30 }[rng.Next(5)], + 1 => rng.Next(351) / 10.0, + _ => Math.Round(rng.NextDouble() * 35, 3), + }; + ps.Margin = rng.Next(4) == 0 ? PageSetup.DefaultMargin : new PageMargins(Mm(), Mm(), Mm(), Mm()); + return "page setup"; + } + case 24: + { + // The gap above a table, picture or divider: NaN is "one line gap, the editor's choice" + // (Block.AutoTopMargin) and has no JSON spelling — it goes out as no field at all. + var objs = BlockWalk.DocumentOrder(doc.Blocks).Where(x => x is not Paragraph).ToList(); + if (objs.Count == 0) return "object top margin (skipped)"; + objs[rng.Next(objs.Count)].MarginTop = rng.Next(3) == 0 ? Block.AutoTopMargin : rng.Next(4) * 6; + return "object top margin"; + } case 18: doc.Blocks.Insert(rng.Next(doc.Blocks.Count + 1), new DividerBlock()); return "insert divider"; @@ -604,7 +637,7 @@ private static void CheckTable(TableBlock tb) internal static string Shape(FlowDocument doc) { - var sb = new StringBuilder(); + var sb = new StringBuilder(PageFmt(doc.PageSetup)); foreach (var b in doc.Blocks) ShapeBlock(sb, b); return sb.ToString(); } @@ -618,9 +651,9 @@ private static void ShapeBlock(StringBuilder sb, Block b) ShapeInlines(sb, p); sb.Append(']'); break; - case TableBlock tb: sb.Append("T"); ShapeTable(sb, tb); break; - case ImageBlock ib: sb.Append("IMGBLK").Append(ImgFmt(ib.Width, ib.Height, ib.AltText)); break; - case DividerBlock: sb.Append("HR"); break; + case TableBlock tb: sb.Append("T").Append(ObjFmt(tb)); ShapeTable(sb, tb); break; + case ImageBlock ib: sb.Append("IMGBLK").Append(ObjFmt(ib)).Append(ImgFmt(ib.Width, ib.Height, ib.AltText)); break; + case DividerBlock dv: sb.Append("HR").Append(ObjFmt(dv)); break; } } @@ -706,6 +739,25 @@ private static string ParaFmt(Paragraph p) return Wrap(parts); } + // The gap above an object block. NaN (Block.AutoTopMargin, a new block's default) is its own value, not + // "unset": the editor turns it into a line gap, while 0 means flush. + private static string ObjFmt(Block b) => double.IsNaN(b.MarginTop) ? "" : "{mt" + Num(b.MarginTop) + "}"; + + // A document with no setup and one whose setup says nothing (PageSetup.IsDefault) are the same document — + // a writer may drop the empty one. Margins go through Num like every other length (two decimals). + private static string PageFmt(PageSetup? ps) + { + if (ps == null || ps.IsDefault) return ""; + var parts = new List { ps.PageSize + "/" + ps.Orientation }; + if (!ps.ShowPageBoundaries) parts.Add("nobounds"); + if (!string.IsNullOrEmpty(ps.Header)) parts.Add("hd" + ps.Header); + if (!string.IsNullOrEmpty(ps.Footer)) parts.Add("ft" + ps.Footer); + if (ps.ShowPageNumbers) parts.Add("pn"); + var m = ps.Margin; + parts.Add("m" + Num(m.Left) + "/" + Num(m.Top) + "/" + Num(m.Right) + "/" + Num(m.Bottom)); + return "PAGE" + Wrap(parts); + } + private static string CellFmt(TableCell cell) { var parts = new List(); diff --git a/tests/WinUIRichEditor.Tests/FormatterRoundTripTests.cs b/tests/WinUIRichEditor.Tests/FormatterRoundTripTests.cs index 612963b..e21705f 100644 --- a/tests/WinUIRichEditor.Tests/FormatterRoundTripTests.cs +++ b/tests/WinUIRichEditor.Tests/FormatterRoundTripTests.cs @@ -565,6 +565,38 @@ public void Html_RoundTrips_BlankParagraph_ButStillDropsForeignEmptyElements() Assert.Equal("P[a]P[b]", Shape(foreign)); } + // The same marker on a list item. The writer put data-are-empty on an empty
  • too, but the list reader + // dropped every empty item regardless — so a blank numbered item vanished on the first round trip, and + // the items either side of it could then merge into one list and lose a marker on the second (fuzz seed + // 8178 at 20000 seeds, 2026-09-24). Twice, as round trips are run here. + [Fact] + public void Html_RoundTrips_AnEmptyListItem_ButStillDropsForeignEmptyItems() + { + 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)); + } + // 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