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
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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,
formatting, images, find/replace, then rendering, pagination, hit-testing, object drag and the clipboard,
then keyboard/pointer input and the RTF and HTML formatters),
and every suspected defect was reproduced with a failing test before it was fixed.

- **Typing after a table left through its menu went nowhere.** Deleting a table from the right-click menu,
or turning an inline table back into a block, left the caret in a cell of the removed table.
- **A right-click kept a table held by its border.** After the border was clicked, a right-click elsewhere
moved the caret but the table stayed held, and a right-click in one of its cells greyed out the cell
items. Turning a held table into an inline one left the removed table held too.
- **Deleting an inline picture, or making it a block, put the caret past the end of the paragraph** when
the caret was after the picture.
- **A host setting the paper size from code had its choice overridden by the toolbar.** Page boundaries
were switched on, and a zoom that matched no preset jumped to fit-width.
- `InsertDivider`, `ReplaceAll` and `ReplaceNext` changed a read-only document; the other editing commands
already refuse.
- Commands that changed nothing no longer leave an undo step that undoes nothing, or mark the document
modified: outdent at 0, choosing the current alignment or line spacing, removing a list from text that
has none, choosing the current margin, "Original size" on a picture already at that size, and Replace
All with no match.
- **A picture that failed to decode drew the blocks after it too high.** They overlapped the space where
the picture should be, while clicks still went to the right places.
- **Quote bars and paragraph backgrounds were missing** on paragraphs in table cells, so Quote in a cell
looked like it did nothing. They were also missing on empty lines, which broke the quote bar at every
blank line.
- **Pasting a picture or spreadsheet cells over a selection** left the selected text in place instead of
replacing it, as every other paste does. The deletion and the insert are now one undo step.
- ↓ from a table held by its border did not enter a row when the caret's column had an inline table
inside a cell.
- Ctrl+drag copied a table or picture even with `AllowTables` / `AllowImages` off. Moving an existing one
is still allowed.
- The HTML put on the clipboard for other applications dropped the top and bottom margins of paragraphs.
- The raster PDF fallback swapped red and blue where the renderer produces RGBA pixels (Skia on macOS).
- **Security: pasted HTML could make the editor connect to another machine.** An `<img src>` pointing at
a file on a network share (`file://host/share/x.png`) was opened. On Windows that starts an SMB
connection, which offers the user's NTLM credentials to that host; the attempt also froze the paste for
about 20 seconds. Network shares are now never read, whatever `AllowLocalFileImages` says. That setting
only covers files on this machine.
- **Script links (`javascript:`, `vbscript:`, `data:`) in pasted or loaded HTML are dropped.** The text
stays and the link does not. Before, the link was kept and written back out into exported HTML and the
clipboard HTML that other applications receive. The editor itself never opened such links. Variants a
browser would still accept are caught too: any letter case, leading whitespace, a tab inside the scheme,
and HTML entities.
- Backspace/Delete on a selected inline picture, and Ctrl+X on one, left the caret past the end of its
paragraph when the caret was after the picture.

### Changed — margin menu and Tab (2026-09-23)

- The right-click margin menu's **Top** list for a table, picture or divider starts with **Auto (one
line)**, the default since the previous change. It is checked when the block uses it. Nothing else
could restore it once a size was picked.
- **A table, picture or divider goes where the caret is.** In the middle of a paragraph the paragraph
splits and the block goes between the two halves. At the start of a paragraph the block goes before it,
and at the end after it. This applies to paste, the insert buttons and dropped files. Before, every
block went after the caret's paragraph, so a picture pasted over a word mid-line landed below the whole
paragraph. Dragging an object already worked this way.
- **The toolbar has a Quote button** beside the lists (new icon slot `RichEditorIcon.Quote`). Before, the
default UI had no way to set a quote: the right-click item appeared only with `ShowFormattingMenu`, and
there was no shortcut.
- **Tab in a table's last cell adds a row to that table**, as in Word and HWP. Only the document's last
table used to grow; from any other table, Tab jumped into the next table below. In a nested table,
Tab still moves on to the next cell of the table around it.

### Added — page margins (2026-09-20)

The margins were two constants nothing could reach: a host could pick the paper but not how much of it to
Expand Down
39 changes: 32 additions & 7 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ WPF `RichTextBox`/`FlowDocument`를 **순수 C# + Avalonia `TextLayout`**로 바

---

## ✅ 현재 상태 (2026-09-20)
## ✅ 현재 상태 (2026-09-23)

- **게시**: `v1.2.1`(2026-09-08) — nuget.org. 이력: 0.9.0(07-12) → 1.0.0(07-31, API 동결) → 1.1.0(08-08) → 1.2.0/1.2.1(09-08).
- **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드33)와 **호스트 API 3건**이 전부 `CHANGELOG.md` `[Unreleased]`.
- **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드34), **호스트 API 3건**, 여백 실기 후속(PR #52)이 전부 `CHANGELOG.md` `[Unreleased]`.
공개 API **추가만**(`PublicAPI.Unshipped.txt`, 제거·시그니처 변경 없음) → 다음은 **minor(1.3.0)**.
- **검증**: 빌드 0 warn · unit **1151** + render **41** 그린 · 3-OS CI(Windows/Linux/macOS) 그린.
- **검증**: 빌드 0 warn · unit **1225** + render **51** 그린 · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준).
- **의존성**: Avalonia **12.1** 이상(12.0.x는 Shift+Enter 2회로 `TextLayout` 무한 루프 — 엔진 버그라 상향이 유일한 수정).

**기능 (전부 동작·테스트 그린)**
Expand All @@ -37,22 +37,43 @@ G2(문단 순회 워커 재귀화) · 1.0 준비 P1~P5(상호작용 테스트
## 🎯 남은 일

**1. 1.3.0 게시** — 계획했던 호스트 API 3건이 2026-09-20에 전부 들어왔다(표 행·열 · 단축키 표 공개 · 페이지 여백,
PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이다.
PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시 작업뿐인데, **시점은 사용자가 정한다**
(2026-09-23: "버전업은 천천히") — 먼저 제안하지 말 것.

게시 절차: `<Version>` → Unshipped를 Shipped로 이관 → CHANGELOG `[Unreleased]` → 버전 → README 배지(en/ko) → PR로 3-OS CI →
`v1.3.0` 태그 push(**사람이 실행**, Trusted Publishing) → **nuget.org 패키지 페이지 육안 확인**.

**2. 실기 확인 대기** — 페이지 여백(PR #50): 여백을 바꿨을 때 머리글·바닥글·쪽번호가 띠 안에 앉는지, 인쇄·PDF의
여백이 화면과 같은지. 헤드리스는 위치를 재기만 하고 눈으로는 못 본다.
**2. 라운드34 · 테스트가 닿지 않던 파일 감사 (2026-09-23, 진행 중, 미커밋)** — 선정 기준: 파일에 선언된 메서드 이름이
`tests/`에 몇 번 나오는지 + 로드맵에 파일명이 나오는지. 1순위 8개(ContextMenu·Tables·Toolbar.PageFile·Formatting·Images·
FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering·Pagination·HitTesting·DragBlock·Clipboard) 결함 **10건**.
전부 빨강 먼저 → 수정, 전체 반증(소스 되돌리면 새 테스트 unit 29 + render 4 빨강). 경과는 CHANGELOG.
테스트: `*AuditTests`(unit 8개 파일) + `RenderingAuditTests`(render).
- 사용자 결정(09-23): 여백 메뉴 위쪽에 **"자동(한 줄)"** 추가 · **Tab = 그 표의 마지막 셀에서 행 추가**(중첩 표는 바깥 표 다음 셀로) ·
**블록 삽입 = 캐럿 자리**(문단 중간이면 분할, 처음이면 앞, 끝이면 뒤 — `InsertBlockAtCaret`, 끌기와 같은 규칙) ·
**툴바 인용 버튼**(목록 옆, 새 아이콘 슬롯 `RichEditorIcon.Quote` — Unshipped 추가).
- FindBar·IncomingContent는 결함 없음. 1순위 실기 확인 완료(09-23, 사용자).
- 남긴 것(측정 안 함·미수정): 인용 문단 두 개가 소수점 y에서 만나는 줄은 막대가 반투명하게 겹친다(원래부터, 빈 문단과 무관).
- **다음**: 3순위(Input·RTF·HTML 포매터) 또는 커밋/PR — 사용자가 정함.
- 2순위 실기 확인(09-23, 사용자): 셀 안·빈 줄 인용 막대 OK. 선택 위 그림 붙여넣기에서 "문단 아래로 들어감" → 블록 삽입 규칙 변경으로 처리.
- 블록 삽입 위치·툴바 인용 버튼 실기 확인 완료(09-23, 사용자).
- 3순위(Input·RTF·HTML) 완료: 인라인 그림 키 삭제/잘라내기 캐럿(3경로), **UNC 그림 = SMB/NTLM 노출(보안, 21초 연결 실측)**,
스크립트 링크 읽을 때 버림(사용자 결정). RTF는 결함 없음 — 극단 값(10억 pt·음수 줄 간격)은 모델까지 들어가지만 레이아웃이 견딘다(가드 테스트).
사용자 결정: 편집 중 링크 **일반 클릭으로 열기 유지**(포트는 Ctrl+클릭 — 알려진 분기).
- **다음**: 커밋/PR — 사용자가 정함.

**3. 포트(WinUIRichEditor)로 넘길 것** (포트 쪽 반영 여부 확인 필요)
- **보안 우선**: HTML `file://host/share` 그림(UNC) 차단 — 포트는 %TEMP% 예외까지 있어 경로 검사를 먼저 볼 것. 스크립트 링크 버림.
- 라운드34 결함 29건 + 3순위 결함, 결정들(여백 "자동", Tab 행 추가, 블록 삽입 = 캐럿 자리, 툴바 인용 버튼)(여백 "자동", Tab 행 추가) — 포트에 같은 결함이 있는지 먼저 측정
- 그림 손잡이 vs 열 경계 우선순위: **선택된 그림 손잡이 우선**(여기 방식)으로 포트를 맞춘다 (사용자 결정 2026-09-19)
- 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`(라운드19에서 여기만 추가)
- PR #52의 실기 후속 전부: 여백 mm 단위·5단계 여백 픽커, 얇은 여백 띠의 머리글/바닥글 생략, 표 바깥 테두리 반 펜 안쪽(쪽 경계),
표·그림·구분선 위 자동 한 줄 간격(`Block.AutoTopMargin`=NaN), 그림 외곽선을 반 펜 바깥으로 + 쪽 클립 뒤에 그리기

**알려진 제한 (의도적으로 둠)**
- RTF 중첩 표의 열 너비는 가져올 때 기본값(`\cellx`가 무시되는 props 그룹 안에 있다).
- RTF는 비대칭: 인라인 표·대체 텍스트는 RTF 자리가 없음(인라인 표는 `{\*\arinline}`로 자체 왕복만 복원).
- 붙여넣기 세 경로(앱 내부·RTF·HTML)의 **배선**은 헤드리스에 실제 클립보드가 없어 테스트 밖(적응 로직은 `InsertHtml`로 검증).
- ~~붙여넣기 배선은 헤드리스에 클립보드가 없어 테스트 밖~~ — **틀린 기록이었다**(라운드34): 헤드리스 `TopLevel.Clipboard`가
`SetTextAsync`/`SetDataAsync`로 왕복하고 `PasteFromClipboardAsync`를 끝까지 태운다(`ClipboardAuditTests`). 세 경로 배선 테스트는 이제 쓸 수 있다.
- mac/Linux는 best-effort(3-OS CI 그린, 실기 동작은 리포트 주도).

---
Expand All @@ -67,6 +88,8 @@ PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이
| 표를 통째로 잡은 상태(전체 선택·블록 캐럿)의 Delete/잘라내기 = **표 삭제**. 일부 셀 블록은 셀만 비움 | 09-13 |
| 셀 블록 = 선택 두 끝에서 파생(모드 플래그 없음). 한 셀 블록은 `_cellBlockMark`. 포트와 통일 | 09-13 |
| 제목 적용 시 run 크기: **여기는 본문 기본값으로 초기화**, 포트는 직접 지정 크기 유지 — 알려진 분기 | 09-12 |
| 편집 중 링크 클릭: **여기는 일반 클릭으로 연다**, 포트는 Ctrl+클릭 — 알려진 분기(사용자 결정, 유지) | 09-23 |
| 붙여넣은/읽은 HTML의 스크립트 링크(`javascript:`·`vbscript:`·`data:`)는 읽을 때 버린다(글자는 남김). 네트워크 공유 `file://` 그림은 설정과 무관하게 안 읽는다 | 09-23 |
| WinUI의 "게이트 추출"(순수 함수화)은 백포트하지 않는다 — 여기는 `InteractionHost`로 실입력 구동이 이미 가능 | 08-06 |
| CI 퍼즈 시드는 20(병합 결함을 8/20이 잡음), 큰 시드(5000)는 손으로 | 09-07 |
| 벡터 PDF는 Skia PDF(여러 OS). 포트는 별도로 Windows "Print to PDF" 경로 | 09-12 |
Expand All @@ -80,6 +103,8 @@ PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이
- **포매터는 전부 붙여넣기로 닿는 신뢰 불가 입력이다.** 정상 왕복 말고 깨진·악성 입력을 넣을 것. 누적형 결함은 **반복 왕복(고정점)**으로만 보인다.
- **생성 쪽이 만들지 않는 형태는 조용히 0% 커버다.** 불변식을 늘리는 것과 그 불변식이 실행되게 하는 것은 다른 일(퍼즈에 병합이 없었다).
- **반증(수정을 되돌려 테스트가 빨개지는지)을 매번 한다.** 폴백이 "틀린 이유로 맞는 답"을 주는 공허한 테스트가 여러 번 잡혔다.
라운드34에서도 셋: 캐럿 없는 편집기(문단 명령이 조기 반환), 착지점이 인라인 표를 빗나간 ↓, 투명 배경을 흰색으로 가정한 픽셀 판정.
**"그 경우를 실제로 탔다"는 전제 단정**(예: 캐럿이 인라인 표 안에 떨어졌는지)을 테스트에 넣어 둘 것.
- **새 축을 열 땐 하네스부터 의심하라.** 안 끝나는/죽는 테스트는 재실행 말고 **프로세스를 소유해서 계측**(콘솔 앱 + `dotnet-stack`).
- `InvalidateMeasure` 누락 주장 전 체인 확인: `ResetCaretBlink()`→`NotifyStatus()`→`InvalidateMeasure()`, `PushUndo()`는 레이아웃 캐시를 버린다
(외부 감사 8건 오진의 원인).
Expand Down
29 changes: 15 additions & 14 deletions src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,17 @@ public async Task PasteFromClipboardAsync()
var (clipImage, clipBytes, clipMeta) = AllowImages
? await TryGetImageAsync(clipboard)
: ((Avalonia.Media.Imaging.Bitmap?)null, (byte[]?)null, (string?)null);
// Like every other paste, 4 and 5 REPLACE a selection — in the same undo step as the insert. They went in
// beside it and the selected text stayed (round 34).
if (clipImage != null)
{
var meta = ParseImageMeta(clipMeta);
PushUndo();
if (_selectionStart != _selectionEnd) DeleteSelection();
if (meta is { Inline: true } im && clipBytes != null)
{
PushUndo();
InsertInlineImageAtCaret(clipBytes, im.W, im.H);
}
else if (clipBytes != null) InsertImageBytes(clipBytes, meta?.W ?? 0, meta?.H ?? 0); // keep the original encoding
else InsertImage(Downscale(clipImage)); // raw Bitmap object: no bytes to keep
else if (clipBytes != null) InsertImageBytes(clipBytes, meta?.W ?? 0, meta?.H ?? 0, pushUndo: false); // keep the original encoding
else InsertBitmapBlock(Downscale(clipImage), pushUndo: false); // raw Bitmap object: no bytes to keep
ResetCaretBlink(); // image lands just after the caret block — scroll there
return;
}
Expand All @@ -123,6 +124,7 @@ public async Task PasteFromClipboardAsync()
if (AllowTables && !string.IsNullOrEmpty(text) && LooksTabular(text))
{
PushUndo();
if (_selectionStart != _selectionEnd) DeleteSelection();
InsertTableFromTsv(text);
ResetCaretBlink(); // table lands just after the caret block — scroll there
return;
Expand Down Expand Up @@ -221,8 +223,9 @@ private void OnDrop(object? sender, DragEventArgs e)
public void InsertImageBytes(byte[] bytes) => InsertImageBytes(bytes, 0, 0);

// Core insert with an optional display size (used by paste to restore the copied image's size;
// 0 = natural size).
private void InsertImageBytes(byte[] bytes, double displayW, double displayH)
// 0 = natural size). `pushUndo` false: the caller has taken the checkpoint (paste, which deletes the
// selection in the same step).
private void InsertImageBytes(byte[] bytes, double displayW, double displayH, bool pushUndo = true)
{
if (Document == null || IsReadOnly || !AllowImages) return;
Avalonia.Media.Imaging.Bitmap bmp;
Expand All @@ -249,7 +252,7 @@ private void InsertImageBytes(byte[] bytes, double displayW, double displayH)
ib.SetImageData(bytes, ImageMime.Detect(bytes));
}

PushUndo();
if (pushUndo) PushUndo();
InsertBlockAtCaret(ib);
InvalidateVisual();
}
Expand Down Expand Up @@ -349,12 +352,10 @@ private static async Task SetClipboardTextAndHtmlAsync(IClipboard clipboard, str
// whose single position falls inside the range) of one paragraph.
private static Paragraph CloneParagraphRange(Paragraph p, int from, int to)
{
var np = new Paragraph
{
ListType = p.ListType, ListMarker = p.ListMarker, ListLevel = p.ListLevel, HeadingLevel = p.HeadingLevel,
TextAlignment = p.TextAlignment, Indent = p.Indent, MarginRight = p.MarginRight,
IsQuote = p.IsQuote, Background = p.Background, LineHeight = p.LineHeight, LineSpacing = p.LineSpacing
};
// The one list of paragraph fields: a hand-picked copy here missed the top/bottom margins, which the HTML
// writer emits — a spaced paragraph pasted into Word lost its spacing.
var np = new Paragraph();
np.CopyFormatFrom(p);
int idx = 0;
foreach (var inl in p.Inlines)
{
Expand Down
Loading
Loading