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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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의
빈 항목은 전처럼 버린다. 상류에도 같은 결함이 있었다(상류는 빈 줄에 `<br>`을 넣어 줄바꿈이 든 항목으로 돌아왔다).

### 클립보드가 잠깐 바쁠 때 복사·붙여넣기가 조용히 실패하던 것 (2026-09-24)

- 클립보드 기록(Win+V)·클립보드 관리자처럼 클립보드를 지켜보는 프로그램이 있으면, 변경 직후 잠깐 클립보드가
Expand Down
55 changes: 47 additions & 8 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 없음) |

Expand Down Expand Up @@ -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`이 이것을 함께 켜고
출력한다(`<log>.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로 쓸 때 `<br>`을 넣어 브라우저에서 줄이 보이게 했다(상류 실측)
(`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).
Expand Down
3 changes: 3 additions & 0 deletions samples/WinUIRichEditor.Demo/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<file>`: page-margin readback for the JIT/AOT comparison (PageMarginProbe).
else if (arg.StartsWith("--pageprobe=", System.StringComparison.OrdinalIgnoreCase))
PageMarginProbe.Run(arg["--pageprobe=".Length..]);
}

switch (page)
Expand Down
64 changes: 64 additions & 0 deletions samples/WinUIRichEditor.Demo/PageMarginProbe.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary><c>--pageprobe=&lt;path&gt;</c>: drives the page margins through every path a host reaches and writes
/// what came back, one line per step, for <c>tools/fault-sweep.ps1</c> to diff between the JIT build and the
/// Native AOT publish.
/// <para>Why a probe and not a keystroke phase: <see cref="RichEditor.PageMarginProperty"/> is the first
/// dependency property that holds a C# <c>record struct</c> — a value WinRT has no type for, boxed across the
/// projection on every <c>SetValue</c>/<c>GetValue</c>. 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.</para></summary>
internal static class PageMarginProbe
{
internal static void Run(string path)
{
if (path.Length == 0) return;
var lines = new List<string>();
void Step(string name, Func<object?> 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<string> lines)
{
try { File.WriteAllLines(path, lines); }
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"pageprobe: {ex.Message}"); }
}
}
18 changes: 18 additions & 0 deletions src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Block>? inDoc = null;
bool InDoc(Block b) => (inDoc ??= new HashSet<Block>(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)
{
Expand Down
1 change: 1 addition & 0 deletions src/WinUIRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/WinUIRichEditor/Controls/RichEditor.Tables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/WinUIRichEditor/Documents/PageSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>Paper size in millimetres for a page size + orientation.</summary>
public static (double W, double H) PaperMillimetres(RichEditorPageSize size, RichEditorPageOrientation orientation)
{
Expand Down
3 changes: 2 additions & 1 deletion src/WinUIRichEditor/Formatters/DocumentSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,8 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary<string, (byte[] B
Foreground = ColorUtil.Parse(id.Foreground),
Background = ColorUtil.Parse(id.Background),
FontFamily = id.FontFamily,
NavigateUri = id.NavigateUri,
// A file is untrusted input: a script link is dropped as the HTML reader drops it (2026-09-24).
NavigateUri = id.NavigateUri is { } uri ? HtmlDocumentFormatter.SafeHref(uri) : null,
TextDecorations = BuildDecorations(id.Underline, id.Strikethrough)
});
}
Expand Down
Loading
Loading