diff --git a/CHANGELOG.md b/CHANGELOG.md
index 767c56f..18a5479 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` 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
diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index f950e25..7a0b115 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -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` 무한 루프 — 엔진 버그라 상향이 유일한 수정).
**기능 (전부 동작·테스트 그린)**
@@ -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: "버전업은 천천히") — 먼저 제안하지 말 것.
게시 절차: `` → 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 그린, 실기 동작은 리포트 주도).
---
@@ -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 |
@@ -80,6 +103,8 @@ PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이
- **포매터는 전부 붙여넣기로 닿는 신뢰 불가 입력이다.** 정상 왕복 말고 깨진·악성 입력을 넣을 것. 누적형 결함은 **반복 왕복(고정점)**으로만 보인다.
- **생성 쪽이 만들지 않는 형태는 조용히 0% 커버다.** 불변식을 늘리는 것과 그 불변식이 실행되게 하는 것은 다른 일(퍼즈에 병합이 없었다).
- **반증(수정을 되돌려 테스트가 빨개지는지)을 매번 한다.** 폴백이 "틀린 이유로 맞는 답"을 주는 공허한 테스트가 여러 번 잡혔다.
+ 라운드34에서도 셋: 캐럿 없는 편집기(문단 명령이 조기 반환), 착지점이 인라인 표를 빗나간 ↓, 투명 배경을 흰색으로 가정한 픽셀 판정.
+ **"그 경우를 실제로 탔다"는 전제 단정**(예: 캐럿이 인라인 표 안에 떨어졌는지)을 테스트에 넣어 둘 것.
- **새 축을 열 땐 하네스부터 의심하라.** 안 끝나는/죽는 테스트는 재실행 말고 **프로세스를 소유해서 계측**(콘솔 앱 + `dotnet-stack`).
- `InvalidateMeasure` 누락 주장 전 체인 확인: `ResetCaretBlink()`→`NotifyStatus()`→`InvalidateMeasure()`, `PushUndo()`는 레이아웃 캐시를 버린다
(외부 감사 8건 오진의 원인).
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs
index 1a8c7f7..6af9096 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs
@@ -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;
}
@@ -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;
@@ -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;
@@ -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();
}
@@ -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)
{
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs
index e44731c..99a9f01 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs
@@ -128,19 +128,22 @@ void Highlight(int hr, int hc)
// around images/tables, so a right margin would be invisible there.
private MenuItem MarginMenu(Block target)
{
- Control[] Presets(Func get, Action set)
+ // `auto`: lead with Block.AutoTopMargin (NaN, one line gap) — the default top of a table, picture or divider,
+ // which no px preset shows checked and nothing else could restore once a preset was picked.
+ Control[] Presets(Func get, Action set, bool auto = false)
{
var items = new List();
- foreach (double v in new[] { 0d, 5, 10, 20, 30 })
+ foreach (double v in auto ? new[] { Block.AutoTopMargin, 0d, 5, 10, 20, 30 } : new[] { 0d, 5, 10, 20, 30 })
{
var mi = new MenuItem
{
- Header = $"{v:0} px",
+ Header = double.IsNaN(v) ? Loc("MarginAuto") : $"{v:0} px",
ToggleType = MenuItemToggleType.Radio,
- IsChecked = Math.Abs(get() - v) < 0.5,
+ IsChecked = double.IsNaN(v) ? double.IsNaN(get()) : Math.Abs(get() - v) < 0.5,
};
mi.Click += (_, _) =>
{
+ if (get().Equals(v)) return; // already in force (NaN included): no undo step that undoes nothing (as the cell v-align radio)
if (Document != null) PushUndo();
set(v);
NotifyStatus(); // content size changed -> re-measure scroll extent
@@ -152,7 +155,7 @@ Control[] Presets(Func get, Action set)
}
var sides = new List
{
- Sub(Loc("MarginTop"), Presets(() => target.MarginTop, v => target.MarginTop = v)),
+ Sub(Loc("MarginTop"), Presets(() => target.MarginTop, v => target.MarginTop = v, auto: target is not Paragraph)),
Sub(Loc("MarginBottom"), Presets(() => target.MarginBottom, v => target.MarginBottom = v)),
Sub(Loc("MarginLeft"), Presets(() => target.Indent, v => target.Indent = v)),
};
@@ -190,6 +193,9 @@ private void CollapseSelectionToCaret()
private void ShowContextMenu(Point point)
{
if (Document == null) return;
+ // A left press clears the block caret first thing (OnPointerPressed); the right-click leaves before that
+ // line, so it is cleared here. Only the border branch below holds a table again.
+ _caretBlock = null;
bool hasSelection = HasTextOrCellSelection;
@@ -464,7 +470,7 @@ private MenuItem HeadingRadio(string header, int level, int current)
/// Inserts a horizontal rule () at the caret position.
public void InsertDivider()
{
- if (Document == null) return;
+ if (Document == null || IsReadOnly) return;
PushUndo();
InsertBlockAtCaret(new DividerBlock());
InvalidateVisual();
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs
index 00a256a..dbaca44 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs
@@ -59,7 +59,7 @@ internal void DragObjectMoved(Point docPt, bool copy)
_trustLayoutCache = true; // hit-testing only — never mutates, as on the drag-select path
try { tp = GetPositionFromPoint(docPt); }
finally { _trustLayoutCache = false; }
- _dropPreview = CanDropObject(_dragObject, tp, copy) ? tp : null;
+ _dropPreview = CanDropObject(_dragObject, tp, copy) && (!copy || MayCreate(_dragObject)) ? tp : null;
Cursor = _dropPreview != null ? ArrowCursor : NoDropCursor;
InvalidateVisual();
}
@@ -119,6 +119,7 @@ internal static bool CanDropObject(object obj, TextPointer at, bool copy)
internal bool DropObject(object obj, TextPointer at, bool copy)
{
if (Document == null || at.Paragraph is not { } p || !CanDropObject(obj, at, copy)) return false;
+ if (copy && !MayCreate(obj)) return false;
return obj switch
{
ImageBlock or TableBlock => DropBlock((Block)obj, p, at.Offset, copy),
@@ -127,6 +128,15 @@ internal bool DropObject(object obj, TextPointer at, bool copy)
};
}
+ // A copy CREATES a table or picture, which AllowTables / AllowImages forbid (as they do for the menu and for
+ // paste); a move only relocates one the document already has, as the row/column commands edit one.
+ private bool MayCreate(object obj) => obj switch
+ {
+ TableBlock or InlineTable => AllowTables,
+ ImageBlock or InlineImage => AllowImages,
+ _ => true,
+ };
+
private static IList? BlockListOf(TextElement e) => e.Parent switch
{
FlowDocument d => d.Blocks,
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs b/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs
index 5b36577..4470a13 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs
@@ -124,7 +124,8 @@ public bool ReplaceNext(string query, string replacement, bool matchCase)
{
if (!AllowFindReplace || Document == null || string.IsNullOrEmpty(query)) return false;
var cmp = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
- bool selMatches = _selectionStart.Paragraph != null && _selectionStart.CompareTo(_selectionEnd) != 0
+ // Read-only: only the find half (the bar checks this too, but a host's own find UI calls in directly).
+ bool selMatches = !IsReadOnly && _selectionStart.Paragraph != null && _selectionStart.CompareTo(_selectionEnd) != 0
&& string.Equals(new TextRange(_selectionStart, _selectionEnd).GetText(), query, cmp);
if (selMatches)
{
@@ -139,9 +140,11 @@ public bool ReplaceNext(string query, string replacement, bool matchCase)
/// Returns the number of replacements made.
public int ReplaceAll(string query, string replacement, bool matchCase)
{
- if (!AllowFindReplace || Document == null || string.IsNullOrEmpty(query)) return 0;
+ if (!AllowFindReplace || Document == null || IsReadOnly || string.IsNullOrEmpty(query)) return 0;
var paras = GetAllParagraphsInOrder();
- if (paras.Count == 0) return 0;
+ var cmp = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
+ // Nothing to replace: no undo step and no "modified" flag (both came from the checkpoint below).
+ if (!paras.Exists(p => BuildPlain(p).Contains(query, cmp))) return 0;
PushUndo();
_caretPosition = new TextPointer(paras[0], 0);
CollapseSelectionToCaret();
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs
index 37f8cd4..12c0279 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs
@@ -179,12 +179,15 @@ private void StepFontSize(int dir)
// directly: selecting several paragraphs and clicking "center" only aligned the one the caret
// happened to land on, while the list commands on the same toolbar already applied to the whole
// selection. NotifyStatus because indent/spacing/heading all change block heights.
- private void ApplyToSelectedParagraphs(Action action)
+ // `unchanged`: true when the action would leave that paragraph as it is. When it holds for every target, nothing
+ // is recorded — outdenting at 0 or re-picking the current alignment left an undo step that undid nothing.
+ private void ApplyToSelectedParagraphs(Action action, Func? unchanged = null)
{
if (_caretPosition.Paragraph == null || IsReadOnly) return;
- if (Document != null) PushUndo();
var targets = SelectedParagraphsInOrder();
if (targets.Count == 0) targets = new List { _caretPosition.Paragraph };
+ if (unchanged != null && targets.All(unchanged)) return;
+ if (Document != null) PushUndo();
foreach (var p in targets) action(p);
InvalidateVisual();
NotifyStatus();
@@ -193,20 +196,21 @@ private void ApplyToSelectedParagraphs(Action action)
/// Adjusts the indent of every selected paragraph by pixels
/// (each clamped 0–400); the caret paragraph alone when nothing is selected.
public void Indent(double delta)
- => ApplyToSelectedParagraphs(p => p.Indent = Math.Clamp(p.Indent + delta, 0, 400));
+ => ApplyToSelectedParagraphs(p => p.Indent = Math.Clamp(p.Indent + delta, 0, 400),
+ p => Math.Clamp(p.Indent + delta, 0, 400) == p.Indent);
/// Sets the text alignment of every selected paragraph (the caret paragraph when nothing
/// is selected).
public void SetTextAlignment(TextAlignment align)
- => ApplyToSelectedParagraphs(p => p.TextAlignment = align);
+ => ApplyToSelectedParagraphs(p => p.TextAlignment = align, p => p.TextAlignment == align);
/// Sets the absolute line-box height (px) of every selected paragraph ("exactly" spacing).
/// Prefer for proportional spacing that scales with font size.
public void SetLineHeight(double height)
- => ApplyToSelectedParagraphs(p => p.LineHeight = height);
+ => ApplyToSelectedParagraphs(p => p.LineHeight = height, p => p.LineHeight.Equals(height)); // Equals: NaN matches NaN
/// Sets proportional line spacing on every selected paragraph as HWP % ÷ 100 — line box =
/// largest font size × (1.6 = 160%). clears it
/// (back to the HWP default 160%).
public void SetLineSpacing(double multiplier)
- => ApplyToSelectedParagraphs(p => p.LineSpacing = multiplier);
+ => ApplyToSelectedParagraphs(p => p.LineSpacing = multiplier, p => p.LineSpacing.Equals(multiplier));
/// Toggles a bullet list on the selected paragraphs.
public void ToggleBullet() { SetListType(ListKind.Bullet); }
/// Toggles a numbered list on the selected paragraphs.
@@ -221,10 +225,12 @@ public void SetLineSpacing(double multiplier)
public void RemoveList()
{
if (_caretPosition.Paragraph == null || Document == null || IsReadOnly) return;
- PushUndo();
// Any depth: clearing a list needs no block splicing, so cell paragraphs are cleared too.
var targets = SelectedParagraphsInOrder();
if (targets.Count == 0) targets = new List { _caretPosition.Paragraph };
+ // No list anywhere in it: no undo step that undoes nothing (see ApplyToSelectedParagraphs).
+ if (targets.All(p => p.ListType == ListKind.None && p.ListMarker == ListMarkerStyle.Default && p.ListLevel == 0)) return;
+ PushUndo();
foreach (var p in targets)
{
p.ListType = ListKind.None;
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
index 9caf589..9ee0eb9 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
@@ -80,18 +80,10 @@ public partial class RichEditor
return null;
}
- // True when paragraph p lives anywhere inside table tb, including inside a nested table (P4-2b).
- private static bool IsCellOf(TableBlock tb, Paragraph p)
- {
- for (int r = 0; r < tb.Rows; r++)
- for (int c = 0; c < tb.Columns; c++)
- foreach (var b in tb.Cells[r][c].Blocks)
- {
- if (ReferenceEquals(b, p)) return true;
- if (b is TableBlock nt && IsCellOf(nt, p)) return true;
- }
- return false;
- }
+ // True when paragraph p lives anywhere inside table tb, at any depth — nested tables and inline tables in a
+ // cell's paragraph included. Through the parent chain: the cell walk it replaced skipped inline tables, so
+ // ↓ from a table's block caret into a column holding one was judged "not entered" (round 34).
+ private static bool IsCellOf(TableBlock tb, Paragraph p) => IsWithin(p, tb);
// Pixel geometry of one table. Anchor cells get a rect spanning their merged columns/rows;
// covered cells are absorbed into their anchor and never appear here.
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs
index 14ae742..9782642 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs
@@ -104,6 +104,7 @@ private static void DrawPicture(Avalonia.Media.DrawingContext context, Avalonia.
private void ResetImageSize(ImageBlock img)
{
if (Document == null || NaturalSize(img) is not { } natural) return;
+ if (img.Width == natural.Width && img.Height == natural.Height) return; // already: no undo step that undoes nothing
PushUndo();
img.Width = natural.Width;
img.Height = natural.Height;
@@ -260,7 +261,7 @@ internal void ConvertInlineImageToBlock(Paragraph p, InlineImage im)
var ib = new ImageBlock { Width = im.Width, Height = im.Height };
if (im.RawBytes != null) ib.SetImageData(im.RawBytes, im.MimeType, im.CachedBitmap);
else ib.Image = im.Image;
- p.Inlines.Remove(im);
+ RemoveInlineImageCharacter(p, im);
Document.Blocks.Insert(idx + 1, ib);
UpdateParents(Document);
_selectedInline = null;
@@ -273,14 +274,29 @@ private void DeleteInlineImage(Paragraph p, InlineImage img)
{
if (Document == null) return;
PushUndo();
- p.Inlines.Remove(img);
+ RemoveInlineImageCharacter(p, img);
if (_selectedInline is { } s && ReferenceEquals(s.img, img)) _selectedInline = null;
+ InvalidateMeasure(); // the line box loses the picture's height
InvalidateVisual();
}
+ // Takes a picture's one character out of its paragraph, pulling back the caret and selection ends that were
+ // past it. They kept their offsets, so the caret after a trailing picture sat one past the paragraph's end and
+ // the next keys went nowhere.
+ private void RemoveInlineImageCharacter(Paragraph p, InlineImage img)
+ {
+ int off = OffsetOfInline(p, img);
+ p.Inlines.Remove(img);
+ TextPointer Back(TextPointer t) => ReferenceEquals(t.Paragraph, p) && t.Offset > off ? new TextPointer(p, t.Offset - 1) : t;
+ _caretPosition = Back(_caretPosition);
+ _selectionStart = Back(_selectionStart);
+ _selectionEnd = Back(_selectionEnd);
+ }
+
private void ResetInlineImageSize(InlineImage img)
{
if (Document == null || NaturalSize(img) is not { } natural) return;
+ if (img.Width == natural.Width && img.Height == natural.Height) return; // see ResetImageSize
PushUndo();
img.Width = natural.Width;
img.Height = natural.Height;
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
index dfa13e0..655103b 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
@@ -1070,8 +1070,9 @@ or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin
{
_ = CopyImageToClipboardAsync(xi.img.RawBytes, xi.img.RawBytes == null ? xi.img.Image : null, inline: true, xi.img.Width, xi.img.Height);
PushUndo();
- xi.p.Inlines.Remove(xi.img);
+ RemoveInlineImageCharacter(xi.p, xi.img); // pulls back a caret that sat after it
_selectedInline = null;
+ InvalidateMeasure();
ResetCaretBlink(); InvalidateVisual(); e.Handled = true; return;
}
}
@@ -1295,9 +1296,10 @@ or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin
}
else if ((e.Key == Key.Back || e.Key == Key.Delete) && _selectedInline is { } selInl && Document != null)
{
- // A selected inline image deletes as a unit (state was pushed above).
- selInl.p.Inlines.Remove(selInl.img);
+ // A selected inline image deletes as a unit (state was pushed above), pulling back a caret after it.
+ RemoveInlineImageCharacter(selInl.p, selInl.img);
_selectedInline = null;
+ InvalidateMeasure();
ResetCaretBlink(); e.Handled = true;
return;
}
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
index cfb11da..cc9086f 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
@@ -444,10 +444,12 @@ internal void DrawVectorPage(SkiaSharp.SKCanvas canvas, int pageIndex, IReadOnly
render.GetAwaiter().GetResult();
}
- // BGRA8888 (RenderTargetBitmap's format) -> packed top-down RGB24. Alpha is always 255 here:
- // print pages start from an opaque white fill.
+ // BGRA8888 or RGBA8888 -> packed top-down RGB24. Alpha is always 255 here: print pages start from an
+ // opaque white fill. The order is the bitmap's own: Skia renders RGBA on macOS, and reading that as BGRA
+ // swapped red and blue in every raster page.
private static (int width, int height, byte[] rgb) BitmapToRgb24(Avalonia.Media.Imaging.Bitmap bmp)
{
+ bool rgbaOrder = bmp.Format == Avalonia.Platform.PixelFormat.Rgba8888;
var ps = bmp.PixelSize;
int stride = ps.Width * 4;
var bgra = new byte[stride * ps.Height];
@@ -459,11 +461,12 @@ private static (int width, int height, byte[] rgb) BitmapToRgb24(Avalonia.Media.
finally { handle.Free(); }
var rgb = new byte[ps.Width * ps.Height * 3];
+ int r = rgbaOrder ? 0 : 2, b = rgbaOrder ? 2 : 0;
for (int i = 0, j = 0; i < bgra.Length; i += 4, j += 3)
{
- rgb[j] = bgra[i + 2];
+ rgb[j] = bgra[i + r];
rgb[j + 1] = bgra[i + 1];
- rgb[j + 2] = bgra[i];
+ rgb[j + 2] = bgra[i + b];
}
return (ps.Width, ps.Height, rgb);
}
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
index 36846ec..acec113 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
@@ -458,6 +458,9 @@ private static Rect InsetTableEdges(Rect cell, Rect table)
if (fullText == "" && !hasPreedit)
{
+ // An empty line inside a quote (or a shaded run of paragraphs) keeps the bar and the fill —
+ // returning before them broke the quote bar at every blank line.
+ DrawParagraphDecor(context, paragraph, px, yOffset, ParagraphWrapWidth(paragraph, maxWidth), beHeight, 0);
if (paragraph.ListType != ListKind.None)
DrawListMarker(context, paragraph, paragraph.ListType == ListKind.Ordered ? ++orderedIndex : 0, px, yOffset);
if (chrome && _caretPosition != null && _caretPosition.Paragraph == paragraph)
@@ -509,11 +512,7 @@ private static Rect InsetTableEdges(Rect cell, Rect table)
continue;
}
- if (paragraph.Background != null)
- context.FillRectangle(paragraph.Background, new Rect(px, yOffset, pWidth, layout.Height));
-
- if (paragraph.IsQuote)
- context.FillRectangle(Brushes.Silver, new Rect(Math.Max(0, px - 10), yOffset, 3, layout.Height));
+ DrawParagraphDecor(context, paragraph, px, yOffset, pWidth, layout.Height, 0);
if (selectedParagraphs?.Contains(paragraph) == true)
{
@@ -598,9 +597,10 @@ private static Rect InsetTableEdges(Rect cell, Rect table)
_imageHandles.Add((grab, img, width, height, grip));
}
}
-
- yOffset += height + img.MarginBottom;
}
+ // Outside the draw: a picture that does not decode still takes its height, as measure and the
+ // hit-tests give it — skipping the advance drew everything after it that much too high.
+ yOffset += height + img.MarginBottom;
}
else if (block is DividerBlock dv)
{
@@ -651,6 +651,16 @@ private Rect CaretRectIn(Avalonia.Media.TextFormatting.TextLayout layout, Paragr
return new Rect(prev.X + prev.Width, prev.Y, 0, prev.Height);
}
+ // A paragraph's background fill and quote bar, shared by the top-level and cell walks. The bar sits 10px left
+ // of the text, never left of `minBarX`.
+ private static void DrawParagraphDecor(DrawingContext context, Paragraph p, double px, double y, double width, double height, double minBarX)
+ {
+ if (p.Background != null)
+ context.FillRectangle(p.Background, new Rect(px, y, width, height));
+ if (p.IsQuote)
+ context.FillRectangle(Brushes.Silver, new Rect(Math.Max(minBarX, px - 10), y, 3, height));
+ }
+
private void DrawCellBlockList(
DrawingContext context, System.Collections.Generic.IList blocks,
double ox, double oy, double innerW, bool chrome,
@@ -690,6 +700,10 @@ private void DrawCellBlockList(
}
}
+ // A cell paragraph's fill and quote bar: the cell walk drew neither, so 인용 in a cell changed
+ // nothing on screen. The bar stays inside the cell's padding (`ox - 4`), clear of its border.
+ DrawParagraphDecor(context, para, px, blkY, pw, layout.Height, ox - 4);
+
if (!cellSelected && !cellRangeActive && selectedParagraphs?.Contains(para) == true)
{
int cellLen = GetParagraphLength(para);
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs
index 696c176..c7c2a69 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs
@@ -176,13 +176,7 @@ private bool RemoveTableHeldWhole()
int idx = container.IndexOf(tb);
RemoveBlockAnywhere(tb);
UpdateParents(Document); // a paragraph now borders the gap (top level); a cell keeps one
- Paragraph? landing = null;
- for (int i = Math.Max(0, idx); i < container.Count && landing == null; i++)
- if (container[i] is Paragraph p) landing = p;
- for (int i = Math.Min(idx, container.Count) - 1; i >= 0 && landing == null; i--)
- if (container[i] is Paragraph p) landing = p;
- landing ??= GetAllParagraphsInOrder().FirstOrDefault();
- _caretPosition = new TextPointer(landing, 0);
+ PlaceCaretAtGap(container, idx);
}
CollapseSelectionToCaret();
InvalidateMeasure();
@@ -489,20 +483,23 @@ private void HandleTab(bool shift)
int idx = all.IndexOf(current);
if (idx < 0) return;
+ // The outermost table around the caret (through nested and in-cell inline tables); nested tables don't grow
+ // via Tab — use the right-click menu.
+ var top = tb;
+ while (EnclosingTableOf(top) is { } up) top = up;
+
if (shift)
{
if (idx > 0) FocusCell(all[idx - 1].Para); // else: first cell of the document -> no-op
}
- else if (idx + 1 < all.Count)
+ else if (idx + 1 < all.Count && IsWithin(all[idx + 1], top))
{
FocusCell(all[idx + 1].Para);
}
else
{
- // Past the document's last cell: add a row to the TOP-LEVEL table (nested tables don't grow
- // via Tab — use the right-click menu), walking up the parent chain if the last cell is nested.
- var top = tb;
- while (top.Parent is TableCell pcell && pcell.Parent is TableBlock gp) top = gp;
+ // Past this table's last cell: add a row to it (Word/HWP). Only the document's last table used to grow —
+ // from any other, Tab jumped into the next table below (user decision, 2026-09-23).
if (Document != null) PushUndo();
top.InsertRow(top.Rows);
if (Document != null) UpdateParents(Document);
@@ -616,6 +613,7 @@ internal void ConvertTableBlockToInline(TableBlock tb)
if (atEnd) anchor.Inlines.Add(it);
else anchor.Inlines.Insert(0, it);
if (ReferenceEquals(_selectedBlock, tb)) _selectedBlock = null;
+ if (ReferenceEquals(_caretBlock, tb)) _caretBlock = null; // held by its border: the block caret named the removed table
UpdateParents(Document);
int off = 0;
@@ -638,9 +636,19 @@ internal void ConvertInlineTableToBlock(Paragraph host, InlineTable it)
PushUndo();
var tb = (TableBlock)it.Table.Clone();
+ // The caret (and a whole-table selection) may be in the inline original's cells, which leave the document
+ // with it — typing went nowhere. It goes where the table was, as DeleteInlineTable puts it.
+ bool caretInside = IsWithin(_caretPosition.Paragraph, it.Table)
+ || IsWithin(_selectionStart.Paragraph, it.Table) || IsWithin(_selectionEnd.Paragraph, it.Table);
+ int off = OffsetOfInline(host, it);
host.Inlines.Remove(it);
Document.Blocks.Insert(idx + 1, tb);
UpdateParents(Document);
+ if (caretInside)
+ {
+ _caretPosition = new TextPointer(host, off);
+ CollapseSelectionToCaret();
+ }
_selectedBlock = tb;
ResetCaretBlink();
InvalidateMeasure();
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs
index 251a7d6..6eafade 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs
@@ -1846,17 +1846,34 @@ private void InsertBlocksAfterCaretBlock(System.Collections.Generic.IReadOnlyLis
return (Document!.Blocks, ti >= 0 ? ti + 1 : Document.Blocks.Count);
}
- // Inserts a top-level block immediately after the caret's current block (or at the end
- // when the caret isn't in a normal paragraph), instead of always appending to the document.
+ // Inserts a block where the caret is, in the caret paragraph's own block list (the document, or a cell's):
+ // in the middle of the paragraph it splits and the block goes between the halves; at its start the block goes
+ // before it; at its end (or in an empty one) after it — the rule the object drag has (DropBlock). Every
+ // insert used to go AFTER the paragraph, so a picture pasted over a word mid-line landed below the whole
+ // paragraph (user decision, 2026-09-23).
private void InsertBlockAtCaret(Block b)
{
if (Document == null) return;
- // A block image / divider / nested table (P4-2b) inserts inside the current cell, after the
- // caret's paragraph, when the caret is in a cell.
+ bool before = false;
+ if (_caretPosition.Paragraph is { Parent: FlowDocument or TableCell } cp)
+ {
+ int len = GetParagraphLength(cp);
+ int off = Math.Clamp(_caretPosition.Offset, 0, len);
+ if (off > 0 && off < len)
+ {
+ int heading = cp.HeadingLevel;
+ SplitParagraphAtCaret();
+ // The tail continues cp: Enter's "a heading's next line is body text" is a typing rule (as DropBlock).
+ _caretPosition.Paragraph!.HeadingLevel = heading;
+ _caretPosition = new TextPointer(cp, GetParagraphLength(cp)); // the block goes after the head
+ }
+ else if (off == 0 && len > 0) before = true;
+ }
+ // A block image / divider / nested table (P4-2b) inserts inside the current cell when the caret is in one.
if (_caretPosition.Paragraph?.Parent is TableCell tc)
{
int pi = tc.Blocks.IndexOf(_caretPosition.Paragraph);
- int at = pi >= 0 ? pi + 1 : tc.Blocks.Count;
+ int at = pi < 0 ? tc.Blocks.Count : before ? pi : pi + 1;
tc.Blocks.Insert(at, b);
// Guarantee a paragraph after the block so the caret has somewhere to land and type.
if (at + 1 >= tc.Blocks.Count || tc.Blocks[at + 1] is not Paragraph)
@@ -1878,7 +1895,7 @@ private void InsertBlockAtCaret(Block b)
if (caretBlock != null)
{
int i = Document.Blocks.IndexOf(caretBlock);
- if (i >= 0) insertIndex = i + 1;
+ if (i >= 0) insertIndex = before && ReferenceEquals(caretBlock, _caretPosition.Paragraph) ? i : i + 1;
}
Document.Blocks.Insert(insertIndex, b);
UpdateParents(Document); // NormalizeBlocks guarantees a paragraph exists after b
@@ -1899,10 +1916,13 @@ private void InsertBlockAtCaret(Block b)
/// Inserts a block image from a at the caret.
/// When the encoded bytes are available, prefer to avoid re-encoding.
- public void InsertImage(Avalonia.Media.Imaging.Bitmap image)
+ public void InsertImage(Avalonia.Media.Imaging.Bitmap image) => InsertBitmapBlock(image, pushUndo: true);
+
+ // `pushUndo` false: the caller took the checkpoint (see InsertImageBytes).
+ private void InsertBitmapBlock(Avalonia.Media.Imaging.Bitmap image, bool pushUndo)
{
if (Document == null || IsReadOnly || !AllowImages) return;
- PushUndo();
+ if (pushUndo) PushUndo();
var (w, h) = CapToContentWidth(image.Size.Width, image.Size.Height);
var ib = new ImageBlock { Image = image, Width = w, Height = h };
InsertBlockAtCaret(ib);
@@ -2315,13 +2335,46 @@ private void DeleteBlock(Block b)
{
if (Document == null) return;
PushUndo();
+ IList container = b.Parent is TableCell cell ? cell.Blocks : Document.Blocks;
+ int idx = container.IndexOf(b);
+ // A table deleted from its menu usually holds the caret (the right-click put it in a cell) or the selection
+ // (its border held it whole). Left there, the keys typed next went into a paragraph no longer in the document.
+ bool caretInside = IsWithin(_caretPosition.Paragraph, b)
+ || IsWithin(_selectionStart.Paragraph, b) || IsWithin(_selectionEnd.Paragraph, b);
RemoveBlockAnywhere(b);
_selectedBlock = null;
+ if (ReferenceEquals(_caretBlock, b)) _caretBlock = null;
UpdateParents(Document); // NormalizeBlocks (top level) + re-wire; a cell keeps its paragraph invariant
+ if (caretInside)
+ {
+ PlaceCaretAtGap(container, idx);
+ CollapseSelectionToCaret();
+ }
InvalidateMeasure(); // a cell shrank -> the table's row height reflows
InvalidateVisual();
}
+ // Whether `e` is `ancestor` or lies inside it (a cell paragraph of a table, at any depth).
+ private static bool IsWithin(TextElement? e, TextElement ancestor)
+ {
+ for (object? cur = e; cur != null; cur = (cur as TextElement)?.Parent)
+ if (ReferenceEquals(cur, ancestor)) return true;
+ return false;
+ }
+
+ // The caret after a block left `container` at `idx`: the paragraph now in its place, else the nearest one
+ // before it, else the document's first.
+ private void PlaceCaretAtGap(IList container, int idx)
+ {
+ Paragraph? landing = null;
+ for (int i = Math.Max(0, idx); i < container.Count && landing == null; i++)
+ if (container[i] is Paragraph p) landing = p;
+ for (int i = Math.Min(idx, container.Count) - 1; i >= 0 && landing == null; i--)
+ if (container[i] is Paragraph p) landing = p;
+ landing ??= GetAllParagraphsInOrder().FirstOrDefault();
+ _caretPosition = new TextPointer(landing, 0);
+ }
+
// Removes a block from whichever container holds it: the document's top-level list or an enclosing
// table cell (searching recursively through nested tables — P4-2b). Returns true if removed.
private bool RemoveBlockAnywhere(Block b)
diff --git a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
index c7cd500..87d8676 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
@@ -117,6 +117,8 @@ public enum RichEditorIcon
Find,
/// Page margins (the toolbar's margin picker).
PageMargin,
+ /// Quote / blockquote toggle (the toolbar's quote button).
+ Quote,
}
///
diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
index 78b4f4a..47372c2 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
@@ -235,12 +235,7 @@ private static string MarginText(PageMargins m)
/// Reflects the host's current zoom / fit-width and the editor's paper state onto the built-in
/// page controls. Call after changing view-level zoom (which the toolbar can't observe directly).
- public void RefreshPageControls()
- {
- _suppress = true;
- try { SyncPage(); }
- finally { _suppress = false; }
- }
+ public void RefreshPageControls() => SyncPage();
private void OnPaperChanged()
{
@@ -254,8 +249,19 @@ private void OnPaperChanged()
SyncPage();
}
- // Reflect the editor's page/zoom state onto the built-in controls (called from Sync()).
+ // Reflect the editor's page/zoom state onto the built-in controls (called from Sync()). Guarded here, not by
+ // each caller: it is also called straight from the target's PageSize change and after a paper pick, and there
+ // setting the combos fired their handlers — a host's paper was answered as a picked one (boundaries forced on),
+ // and emptying the zoom combo for an off-grid zoom read as "Fit".
private void SyncPage()
+ {
+ bool was = _suppress;
+ _suppress = true;
+ try { SyncPageCore(); }
+ finally { _suppress = was; }
+ }
+
+ private void SyncPageCore()
{
if (Target == null) return;
bool paged = Target.PageSize != RichEditorPageSize.Continuous;
diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs
index ab06805..75534da 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs
@@ -57,7 +57,7 @@ public RichEditor? Target
private static readonly IBrush ActiveBrush = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Color.Parse("#90CAF9"));
// Controls that reflect caret state (assigned in Build).
- private Button? _boldBtn, _italicBtn, _underlineBtn, _strikeBtn, _bulletBtn, _numberBtn, _undoBtn, _redoBtn;
+ private Button? _boldBtn, _italicBtn, _underlineBtn, _strikeBtn, _bulletBtn, _numberBtn, _quoteBtn, _undoBtn, _redoBtn;
private ComboBox? _fontCombo, _sizeCombo, _headingCombo, _alignCombo;
private TextBox? _spacingBox; // editable line-spacing %, reflects/sets the caret paragraph
private TextBlock? _bulletPreview, _numberPreview; // current list marker shown in the list combo boxes
@@ -289,7 +289,7 @@ Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Dis
};
// Reset reflected controls to null so Sync() null-guards whatever subset this level builds.
- _undoBtn = _redoBtn = _boldBtn = _italicBtn = _underlineBtn = _strikeBtn = _bulletBtn = _numberBtn = null;
+ _undoBtn = _redoBtn = _boldBtn = _italicBtn = _underlineBtn = _strikeBtn = _bulletBtn = _numberBtn = _quoteBtn = null;
_fontCombo = _sizeCombo = _headingCombo = _alignCombo = null;
_spacingBox = null; _bulletPreview = _numberPreview = null;
_tableBtn = _imageBtn = _dividerBtn = _findBtn = null;
@@ -422,7 +422,10 @@ Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Dis
(ListMarkerStyle.LowerRoman, "i)"));
_numberBtn = number.Icon; _numberPreview = number.Preview;
Add(number.Box);
- // Quote (blockquote) is available via the right-click List menu and ToggleQuote(); no toolbar button.
+ // Quote (blockquote): beside the lists — without it the default UI had no way to set one (the right-click
+ // item exists only with ShowFormattingMenu, and there is no shortcut). User decision, 2026-09-23.
+ _quoteBtn = Btn("❝", Loc("Quote"), () => Target?.ToggleQuote(), RichEditorIcon.Quote);
+ Add(_quoteBtn);
Add(Btn("→|", Loc("IndentIncrease"), () => Target?.Indent(20), RichEditorIcon.IndentIncrease));
Add(Btn("|←", Loc("IndentDecrease"), () => Target?.Indent(-20), RichEditorIcon.IndentDecrease));
Add(Div());
@@ -914,6 +917,7 @@ static void SetActive(Button? b, bool active)
SetActive(_strikeBtn, f.Strike);
SetActive(_bulletBtn, f.List == ListKind.Bullet);
SetActive(_numberBtn, f.List == ListKind.Ordered);
+ SetActive(_quoteBtn, f.Quote);
// List combo previews show the caret paragraph's current marker; dimmed (inactive) when the
// caret isn't in that list kind, full-ink (active) when it is.
if (_bulletPreview != null)
diff --git a/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs b/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs
index f183423..56849ce 100644
--- a/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs
+++ b/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs
@@ -122,6 +122,11 @@ private static Control Build(double box, params (string Data, bool Fill)[] layer
("M6 3 H18 V21 H6 Z", false),
("M9 7 H15 V17 H9 Z", false)),
+ // The quote bar the editor draws, beside the lines it marks.
+ RichEditorIcon.Quote => Build(20,
+ ("M5 5 V19", false),
+ ("M9 7 H20 M9 12 H20 M9 17 H16", false)),
+
_ => null,
};
diff --git a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs
index 96f43ae..be8aa27 100644
--- a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs
+++ b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs
@@ -189,7 +189,7 @@ void TakeSpace()
if (name == "a")
{
var href = child.GetAttributeValue("href", "");
- if (!string.IsNullOrEmpty(href)) childLink = href;
+ if (!string.IsNullOrEmpty(href)) childLink = SafeHref(href);
}
bool hasLink = !string.IsNullOrEmpty(childLink);
@@ -669,7 +669,13 @@ private static (byte[]?, Avalonia.Media.Imaging.Bitmap?, double, double, string?
else if (src.StartsWith("file:"))
{
if (_blockLocalFileImages) return (null, null, 0, 0, null);
- var path = new Uri(src).LocalPath;
+ var uri = new Uri(src);
+ // A file on ANOTHER machine (file://host/share/…) is a UNC path, and on Windows touching it
+ // opens an SMB connection that offers the user's NTLM credentials to that host — from a
+ // paste, with an
the copied page chose. AllowLocalFileImages is about this
+ // machine's files; a network share is never read (measured: a 21 s connect attempt).
+ if (uri.IsUnc) return (null, null, 0, 0, null);
+ var path = uri.LocalPath;
if (System.IO.File.Exists(path)) bytes = System.IO.File.ReadAllBytes(path);
}
if (bytes == null) return (null, null, 0, 0, null);
@@ -690,6 +696,25 @@ private static (byte[]?, Avalonia.Media.Imaging.Bitmap?, double, double, string?
catch (Exception ex) { RichEditorDiagnostics.Report(ex); return (null, null, 0, 0, null); }
}
+ // A link's address, or null for a script link (javascript:, vbscript:, data:). The text stays; only the
+ // link goes. Kept, a pasted page's script link was written back out as an — into exported HTML
+ // and the clipboard HTML other applications receive — although the editor itself never launches it
+ // (user decision, 2026-09-23: drop on read). Checked the way a browser reads a scheme: entities decoded,
+ // case ignored, whitespace and control characters (a tab inside "java\tscript:") not counted.
+ internal static string? SafeHref(string href)
+ {
+ var sb = new StringBuilder();
+ foreach (char ch in HtmlEntity.DeEntitize(href))
+ {
+ if (ch <= ' ') continue;
+ if (ch == ':') break;
+ sb.Append(char.ToLowerInvariant(ch));
+ if (sb.Length > 16) break; // longer than any scheme below
+ }
+ string scheme = sb.ToString();
+ return scheme is "javascript" or "vbscript" or "data" ? null : href;
+ }
+
private static double ReadPx(HtmlNode node, string attr, string cssProp)
{
var a = node.GetAttributeValue(attr, "");
@@ -739,7 +764,7 @@ private static void ParseInlines(HtmlNode node, Paragraph p, FontWeight weight =
if (name == "a")
{
var href = child.GetAttributeValue("href", "");
- if (!string.IsNullOrEmpty(href)) cu = href;
+ if (!string.IsNullOrEmpty(href)) cu = SafeHref(href);
}
bool childOwnColor = ownColor || child.GetAttributeValue("data-are-fg", "") == "1";
diff --git a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
index 091980e..fa179ca 100644
--- a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
+++ b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
@@ -2,6 +2,7 @@
AvaloniaRichEditor.Controls.RichEditor.PageMargin.get -> AvaloniaRichEditor.Documents.PageMargins
AvaloniaRichEditor.Controls.RichEditor.PageMargin.set -> void
AvaloniaRichEditor.Controls.RichEditorIcon.PageMargin = 48 -> AvaloniaRichEditor.Controls.RichEditorIcon
+AvaloniaRichEditor.Controls.RichEditorIcon.Quote = 49 -> AvaloniaRichEditor.Controls.RichEditorIcon
AvaloniaRichEditor.Controls.RichEditorShortcut
AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.get -> bool
AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.init -> void
diff --git a/src/AvaloniaRichEditor/RichEditorLocalization.cs b/src/AvaloniaRichEditor/RichEditorLocalization.cs
index 54c76e7..af1445c 100644
--- a/src/AvaloniaRichEditor/RichEditorLocalization.cs
+++ b/src/AvaloniaRichEditor/RichEditorLocalization.cs
@@ -85,6 +85,7 @@ public static class RichEditorLocalization
["IndentDecrease"] = "Decrease Indent",
["Margin"] = "Margin",
["MarginTop"] = "Top Margin",
+ ["MarginAuto"] = "Auto (one line)",
["MarginBottom"] = "Bottom Margin",
["MarginLeft"] = "Left Margin",
["MarginRight"] = "Right Margin",
@@ -231,6 +232,7 @@ public static class RichEditorLocalization
["IndentDecrease"] = "내어쓰기 -",
["Margin"] = "여백",
["MarginTop"] = "위 여백",
+ ["MarginAuto"] = "자동 (한 줄)",
["MarginBottom"] = "아래 여백",
["MarginLeft"] = "왼쪽 여백",
["MarginRight"] = "오른쪽 여백",
diff --git a/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs b/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs
new file mode 100644
index 0000000..17f8814
--- /dev/null
+++ b/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Headless.XUnit;
+using Avalonia.Media.Imaging;
+using AvaloniaRichEditor.Controls;
+using AvaloniaRichEditor.Documents;
+using Xunit;
+
+namespace AvaloniaRichEditor.Tests.Render;
+
+// Audit of RichEditor.Rendering.cs (2026-09-23). Real Skia: a picture that fails to decode, and what the
+// renderer paints, can only be seen with a live codec and rasteriser.
+public class RenderingAuditTests
+{
+ private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
+ private const int W = 400, H = 300;
+
+ private static byte[] Pixels(RichEditor ed)
+ {
+ ed.Measure(new Size(W, double.PositiveInfinity));
+ ed.Arrange(new Rect(0, 0, W, H));
+ using var rtb = new RenderTargetBitmap(new PixelSize(W, H));
+ rtb.Render(ed);
+ var buf = new byte[W * 4 * H];
+ var handle = GCHandle.Alloc(buf, GCHandleType.Pinned);
+ try { rtb.CopyPixels(new PixelRect(0, 0, W, H), handle.AddrOfPinnedObject(), buf.Length, W * 4); }
+ finally { handle.Free(); }
+ return buf;
+ }
+
+ // A picture whose bytes do not decode drew nothing — and did not advance the walk either, so everything
+ // after it was drawn its height too high, over where the picture should be, while clicks and the caret
+ // (which measure it) stayed where they belong.
+ [AvaloniaFact]
+ public void APictureThatDoesNotDecode_StillTakesItsHeight()
+ {
+ var img = new ImageBlock { Width = 200, Height = 150, MarginTop = 0, MarginBottom = 0 };
+ img.SetImageData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, "image/png");
+ var after = new Paragraph { Inlines = { new Run { Text = "after" } } };
+ var doc = new FlowDocument();
+ doc.Blocks.Add(img);
+ doc.Blocks.Add(after);
+ var ed = new RichEditor { Document = doc };
+ typeof(RichEditor).GetField("_caretPosition", NP)!.SetValue(ed, new TextPointer(after, 0));
+
+ Pixels(ed);
+
+ var caret = (Point)typeof(RichEditor).GetField("_lastCaretPoint", NP)!.GetValue(ed)!;
+ Assert.True(caret.Y >= 150, $"the paragraph after a 150px picture was drawn at y={caret.Y}");
+ }
+
+ // The raster PDF fallback read every page's pixels as BGRA. Skia hands back RGBA on macOS (the lesson from
+ // round 33's CI), so there a red page came out blue. The bitmap's own format decides now.
+ [AvaloniaTheory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void RasterPdfPixels_FollowTheBitmapsChannelOrder(bool rgba)
+ {
+ var fmt = rgba ? Avalonia.Platform.PixelFormat.Rgba8888 : Avalonia.Platform.PixelFormat.Bgra8888;
+ using var wb = new WriteableBitmap(new PixelSize(2, 2), new Vector(96, 96), fmt, Avalonia.Platform.AlphaFormat.Opaque);
+ using (var fb = wb.Lock())
+ {
+ var red = rgba ? new byte[] { 255, 0, 0, 255 } : new byte[] { 0, 0, 255, 255 };
+ for (int i = 0; i < 4; i++)
+ Marshal.Copy(red, 0, fb.Address + (i / 2) * fb.RowBytes + (i % 2) * 4, 4);
+ }
+
+ var (_, _, rgb) = ((int, int, byte[]))typeof(RichEditor)
+ .GetMethod("BitmapToRgb24", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, new object[] { wb })!;
+
+ Assert.Equal(new byte[] { 255, 0, 0 }, rgb[..3]);
+ }
+
+ // Brushes.Silver, the quote bar's colour — distinct from the table's gray border (128) and the text.
+ private static bool IsSilver(byte[] px, int x, int y)
+ {
+ int o = (y * W + x) * 4;
+ return Math.Abs(px[o] - 192) < 6 && Math.Abs(px[o + 1] - 192) < 6 && Math.Abs(px[o + 2] - 192) < 6;
+ }
+
+ private static int SilverPixels(byte[] px)
+ {
+ int n = 0;
+ for (int y = 0; y < H; y++)
+ for (int x = 0; x < W; x++)
+ if (IsSilver(px, x, y)) n++;
+ return n;
+ }
+
+ // Quote on a paragraph in a table cell changed nothing on screen: the cell walk drew no bar (nor a
+ // paragraph background), so 인용 in a cell looked like it did nothing.
+ [AvaloniaFact]
+ public void AQuoteInACell_DrawsItsBar()
+ {
+ var tb = new TableBlock(1, 1) { MarginTop = 0 };
+ tb.ColumnWidths[0] = 300;
+ tb.Cells[0][0].Para.Inlines.Clear();
+ tb.Cells[0][0].Para.Inlines.Add(new Run { Text = "quoted" });
+ tb.Cells[0][0].Para.IsQuote = true; // no indent, as ToggleQuote leaves it
+ var doc = new FlowDocument();
+ doc.Blocks.Add(tb);
+
+ Assert.True(SilverPixels(Pixels(new RichEditor { Document = doc })) > 10, "no quote bar in the cell");
+ }
+
+ // An EMPTY paragraph in a quote broke the bar: the empty-paragraph path returned before drawing it.
+ [AvaloniaFact]
+ public void AnEmptyQuotedParagraph_KeepsTheBar()
+ {
+ Paragraph Q(string t) => new() { IsQuote = true, Inlines = { new Run { Text = t } } };
+ var doc = new FlowDocument();
+ doc.Blocks.Add(Q("first"));
+ doc.Blocks.Add(Q(""));
+ doc.Blocks.Add(Q("third"));
+ var px = Pixels(new RichEditor { Document = doc });
+
+ // The bar's column, and its run of rows from the top of the first to the bottom of the third.
+ int bx = -1, top = -1, bottom = -1;
+ for (int x = 0; x < W && bx < 0; x++)
+ for (int y = 0; y < H; y++)
+ if (IsSilver(px, x, y)) { bx = x; break; }
+ Assert.True(bx >= 0, "no quote bar at all");
+ for (int y = 0; y < H; y++)
+ if (IsSilver(px, bx, y)) { if (top < 0) top = y; bottom = y; }
+ // A gap is nothing painted: the control has no background, so alpha 0 (index 3 in both BGRA and RGBA).
+ // Where two paragraphs' bars meet at a fractional y the seam row is only part-covered (it is there between
+ // any two quoted lines, empty or not), which is no gap. Before the fix the empty line's rows read 0.
+ for (int y = top; y <= bottom; y++)
+ Assert.True(px[(y * W + bx) * 4 + 3] > 50, $"the quote bar has a gap at y={y}");
+ }
+}
diff --git a/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs b/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs
new file mode 100644
index 0000000..576676c
--- /dev/null
+++ b/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs
@@ -0,0 +1,96 @@
+using System.Linq;
+using Avalonia;
+using Avalonia.Headless.XUnit;
+using AvaloniaRichEditor.Controls;
+using AvaloniaRichEditor.Documents;
+using Xunit;
+
+namespace AvaloniaRichEditor.Tests;
+
+// Where a block (table, picture, divider) goes when the caret is in a paragraph (user decision, 2026-09-23): the
+// rule the object drag already had. In the middle the paragraph splits and the block goes between; at its start
+// the block goes before it; at its end (or in an empty one) after it. Every insert used to go AFTER the caret's
+// paragraph, so a picture pasted over a word in the middle of a line landed below the whole paragraph.
+public class BlockInsertPlacementTests
+{
+ private static string Text(Block b) => b is Paragraph p ? string.Concat(p.Inlines.OfType().Select(r => r.Text)) : b.GetType().Name;
+
+ private static string[] Shape(RichEditor ed) => ed.Document!.Blocks.Select(Text).ToArray();
+
+ private static InteractionHost Host(params Block[] blocks)
+ {
+ var doc = new FlowDocument();
+ foreach (var b in blocks) doc.Blocks.Add(b);
+ var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous });
+ host.Render();
+ return host;
+ }
+
+ private static void CaretAt(InteractionHost host, Paragraph p, int off)
+ {
+ var f = typeof(RichEditor).GetField("_caretPosition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+ f.SetValue(host.Editor, new TextPointer(p, off));
+ typeof(RichEditor).GetMethod("CollapseSelectionToCaret", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
+ .Invoke(host.Editor, null);
+ }
+
+ [AvaloniaTheory]
+ [InlineData(4, new[] { "abcd", "DividerBlock", "efgh" })] // middle: split, between
+ [InlineData(0, new[] { "DividerBlock", "abcdefgh" })] // start: before (NormalizeBlocks adds one ahead)
+ [InlineData(8, new[] { "abcdefgh", "DividerBlock", "" })] // end: after, as before
+ public void ADividerGoesWhereTheCaretIs(int offset, string[] expected)
+ {
+ var p = new Paragraph { Inlines = { new Run { Text = "abcdefgh" } } };
+ var host = Host(p);
+ CaretAt(host, p, offset);
+
+ host.Editor.InsertDivider();
+
+ var shape = Shape(host.Editor);
+ // At the start, the document's first block may be a paragraph NormalizeBlocks put ahead of the divider.
+ if (offset == 0) shape = shape.SkipWhile(s => s == "").ToArray();
+ Assert.Equal(expected, shape);
+ }
+
+ // The same in a table cell: the cell's block list splits.
+ [AvaloniaFact]
+ public void InACell_TheCellParagraphSplits()
+ {
+ var tb = new TableBlock(1, 1);
+ var cp = tb.Cells[0][0].Para;
+ ((Run)cp.Inlines[0]).Text = "abcdefgh";
+ var host = Host(new Paragraph { Inlines = { new Run { Text = "top" } } }, tb);
+ CaretAt(host, cp, 4);
+
+ host.Editor.InsertDivider();
+
+ Assert.Equal(new[] { "abcd", "DividerBlock", "efgh" }, tb.Cells[0][0].Blocks.Select(Text).ToArray());
+ }
+
+ // A split heading stays a heading on both sides — Enter's "the next line is body text" is a typing rule.
+ [AvaloniaFact]
+ public void SplittingAHeading_KeepsBothHalvesHeadings()
+ {
+ var p = new Paragraph { HeadingLevel = 2, Inlines = { new Run { Text = "abcdefgh" } } };
+ var host = Host(p);
+ CaretAt(host, p, 4);
+
+ host.Editor.InsertDivider();
+
+ Assert.All(host.Editor.Document!.Blocks.OfType().Where(x => Text(x).Length > 0), x => Assert.Equal(2, x.HeadingLevel));
+ }
+
+ // One undo step brings the paragraph back whole.
+ [AvaloniaFact]
+ public void Undo_RejoinsTheParagraph()
+ {
+ var p = new Paragraph { Inlines = { new Run { Text = "abcdefgh" } } };
+ var host = Host(p);
+ CaretAt(host, p, 4);
+
+ host.Editor.InsertDivider();
+ host.Editor.Undo();
+
+ Assert.Equal(new[] { "abcdefgh" }, Shape(host.Editor));
+ }
+}
diff --git a/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs
new file mode 100644
index 0000000..dae8754
--- /dev/null
+++ b/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs
@@ -0,0 +1,94 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using AvaloniaRichEditor.Controls;
+using AvaloniaRichEditor.Documents;
+using Xunit;
+
+namespace AvaloniaRichEditor.Tests;
+
+// Audit of RichEditor.Clipboard.cs (2026-09-23).
+public class ClipboardAuditTests
+{
+ // Pasting over a selection replaces it — the text, rich and inline-picture paths all delete it first. The
+ // spreadsheet (TSV -> table) path did not: the table went in after the caret's paragraph and the selected
+ // text stayed.
+ [AvaloniaFact]
+ public async Task PastingSpreadsheetCells_OverASelection_ReplacesIt()
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "keep REPLACE keep" } } });
+ var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous });
+ host.Render();
+ var clipboard = TopLevel.GetTopLevel(host.Editor)?.Clipboard;
+ Assert.NotNull(clipboard); // precondition: the headless platform has one
+ await clipboard!.SetTextAsync("a\tb\nc\td");
+ Assert.Equal("a\tb\nc\td", await clipboard.TryGetTextAsync()); // precondition: it round-trips
+
+ host.Editor.FindNext("REPLACE", true); // selects the word
+ await host.Editor.PasteFromClipboardAsync();
+
+ Assert.Single(host.Editor.Document!.Blocks.OfType()); // precondition: it became a table
+ Assert.DoesNotContain("REPLACE", host.Editor.GetPlainText());
+ host.Editor.Undo(); // one step back to the text, selection deleted and table inserted together
+ Assert.Contains("keep REPLACE keep", host.Editor.GetPlainText());
+ Assert.Empty(host.Editor.Document!.Blocks.OfType());
+ }
+
+ // What a copy hands other applications (CF_HTML) is built from a trimmed copy of the selected paragraphs.
+ // That copy took a hand-picked list of paragraph fields and missed the margins, which the HTML writer does
+ // emit — so a spaced paragraph pasted into Word lost its spacing. Paragraph.CopyFormatFrom is the one list.
+ [AvaloniaFact]
+ public void TheCopyForOtherApps_KeepsParagraphMargins()
+ {
+ var p = new Paragraph { MarginTop = 20, MarginBottom = 12, Inlines = { new Run { Text = "spaced" } } };
+ var doc = new FlowDocument();
+ doc.Blocks.Add(p);
+ var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous });
+ host.Render();
+ host.Editor.FindNext("spaced", true);
+
+ var copy = (FlowDocument)typeof(RichEditor).GetMethod("BuildSelectionDocument",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.Invoke(host.Editor, null)!;
+
+ var cp = Assert.IsType(Assert.Single(copy.Blocks));
+ Assert.Equal((20.0, 12.0), (cp.MarginTop, cp.MarginBottom));
+ }
+
+ // A decodable 2x2 PNG — big enough (the in-app image format aside) that a clipboard picture pastes as a block.
+ private static readonly byte[] Png = System.Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
+
+ // The same for a picture on the clipboard (a screenshot): it went in beside the selection, which stayed.
+ [AvaloniaFact]
+ public async Task PastingAPicture_OverASelection_ReplacesIt()
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "keep REPLACE keep" } } });
+ var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous });
+ host.Render();
+ var clipboard = TopLevel.GetTopLevel(host.Editor)!.Clipboard!;
+ var item = DataTransferItem.Create(DataFormat.CreateBytesPlatformFormat("PNG"), Png);
+ var dt = new DataTransfer();
+ dt.Add(item);
+ await clipboard.SetDataAsync(dt);
+
+ host.Editor.FindNext("REPLACE", true);
+ await host.Editor.PasteFromClipboardAsync();
+
+ Assert.Single(host.Editor.Document!.Blocks.OfType()); // precondition: the picture went in
+ Assert.DoesNotContain("REPLACE", host.Editor.GetPlainText());
+ // Where the selection was — between its two halves (user decision 2026-09-23), not below the paragraph.
+ var blocks = host.Editor.Document!.Blocks;
+ int ii = blocks.IndexOf(blocks.OfType().Single());
+ Assert.Equal("keep ", string.Concat(((Paragraph)blocks[ii - 1]).Inlines.OfType().Select(r => r.Text)));
+ Assert.Equal(" keep", string.Concat(((Paragraph)blocks[ii + 1]).Inlines.OfType().Select(r => r.Text)));
+ host.Editor.Undo();
+ Assert.Contains("keep REPLACE keep", host.Editor.GetPlainText());
+ Assert.Empty(host.Editor.Document!.Blocks.OfType());
+ }
+}
diff --git a/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs
new file mode 100644
index 0000000..b5e1361
--- /dev/null
+++ b/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs
@@ -0,0 +1,324 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using AvaloniaRichEditor.Controls;
+using AvaloniaRichEditor.Documents;
+using Xunit;
+
+namespace AvaloniaRichEditor.Tests;
+
+// Audit of RichEditor.ContextMenu.cs (2026-09-23): the file had no test naming its own members. Each case was
+// measured red before its fix.
+public class ContextMenuAuditTests
+{
+ private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
+
+ private static string L(string key) => RichEditorLocalization.GetString(key);
+
+ private static List