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

## [Unreleased]

### Changed — a heading's bold and size are on its text, as in the WinUI port (2026-10-01)

⚠ **Behaviour change.** A heading was drawn bold and at its level's size whatever its runs said, so its bold could
not be turned off and 10 pt could not be shown in one. Now applying a heading writes bold and the level's size onto
its text — each time, re-applying the same level included — and in between they are ordinary character formats.
The port has worked this way since 2026-09-13, and the two editors disagreed on the same file: a port document whose
heading text the user had un-bolded showed bold here, and saving it dropped the port's `"HeadingFormat": 1` marker,
so the port then re-bolded it for real.
- Files without the marker (everything written before) look as before: the editor writes the old look onto the
runs once when it receives the document. The marker then travels with it (saved files, undo), so text the user
un-bolded stays un-bolded. The readers themselves load a file as it was saved.
- Ctrl+B in a heading now toggles; ClearFormatting there returns to the heading's look; Enter's body paragraph
leaves the heading's format behind; text typed into an empty heading gets it; picking the level already shown in
the toolbar re-applies it. A block inserted mid-heading leaves the tail a heading in its own format.
- HTML: un-bolded heading text is written `font-weight:normal`, and the reader now honours `font-weight:normal`
(Google Docs wraps a paste in `<b style="font-weight:normal">`, and everything came in bold). A 10 pt run inside a
heading is written with its size. RTF: heading runs are written as they are (no forced `\b`).
- Tests: `HeadingStyleTests` (the port's 12 plus the mid-heading insert), three older tests changed to the new rule.
Each of 15 changes undone on its own turns its test red.

### Changed — one document is the same bytes in both editors (2026-10-01)

- JSON and `.flow` loads join equal-format neighbouring runs, as the HTML and RTF readers already did and as the port
Expand Down
7 changes: 6 additions & 1 deletion Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시
**1b. JSON 작성기 경량화 (2026-10-01)** — 스키마 그대로, 작성기만: 기본값 필드 생략·한글 비이스케이프·들여쓰기 없음.
corpus 240.6 → 37.7 KB, 3,043 → 378 KB. **구 판독기 호환은 구 트리로 직접 확인**(18개 문서, JSON·.flow 바이트 동일).
메이저 버전이 더 높은 문서는 로드 경로가 거부, 모르는 `Type`은 진단 보고. 명세(`docs/DOCUMENT_FORMAT.md`)의 어긋남도 정정.
사용자 결정: 들여쓰기 옵션 없이 제거, 메이저 거부 도입. 포트도 같은 형식이라 같이 옮길 것.
사용자 결정: 들여쓰기 옵션 없이 제거, 메이저 거부 도입. 포트도 같은 형식이라 같이 옮길 것 → 포트 PR #63.
**형식 일치화(같은 날)**: 대조해 보니 의미가 갈린 곳 1건 — 제목 서식(포트는 run 속성+`HeadingFormat` 표식, 상류는 렌더러 강제;
상류가 포트 파일을 저장하면 표식이 빠져 사용자가 끈 굵게가 되살아났다). 사용자 결정으로 상류가 포트 모델을 따른다(`HeadingStyle`).
바이트 차이 3건(로드 시 run 합치기·색 표기·`Alt`/`VAlign` 순서)과 행 없는 표 규칙도 맞췄다. 두 레포에 같은 정본 파일
`format-1.0-interchange.json`과 바이트 동일 테스트. 반증: 상류 제목 15/15, 바이트 4/4, 포트 3/3.
**GUI 확인 필요**: 툴바 제목 콤보에서 이미 선택된 수준을 다시 골랐을 때 재적용되는지(헤드리스로 못 잼).
포트 대조(라운드35)는 끝났다 — WinUIRichEditor PR #62(14건 백포트, 상류 픽셀 상한은 포트에 불필요).

**1a. 라운드35 · 전수 감사 (2026-10-01, PR #59 머지)** — `src` 약 19,500줄을 모델 → 포매터 → 컨트롤 순으로 읽었다. 결함 **16건**
Expand Down
6 changes: 4 additions & 2 deletions docs/DOCUMENT_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,15 @@ FlowDocument
"MarginTop": 20, // 기본 사방 15mm
"MarginRight": 25,
"MarginBottom": 20
}
},
"HeadingFormat": 1 // 선택 — 1이면 제목의 굵게·크기가 이미 런에 적혀 있다
}
```

- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 단위는 **밀리미터**다 — 용지 크기가 mm로 정의되고 Word·아래한글도 mm로 보여 주며, RTF는 물리 길이(twips)로 싣는다(렌더는 96dpi DIP로 환산: 1mm = 96/25.4 ≈ 3.7795px). 변마다 하나이며 **기본값(사방 15mm)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다.
- RTF는 정수 twips라 mm가 정확히 왕복되지 않는다. 0.1mm 단위 값은 그대로 돌아오고(15mm → 850 → 15), 그 밖의 값은 정확한 길이를 유지한다(Word의 1.25인치 = 31.75mm).
- **`PageSetup`(선택)**: 워드프로세서식 페이지 설정. 로드 시 에디터의 용지/방향/머리글·바닥글/쪽번호 속성에 적용되고, 이후 페이지 속성을 바꾸면 문서로 다시 캡처된다. **기본 상태(용지 `Continuous`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음.
- **`HeadingFormat`(선택, 2026-10-01)**: `1`은 제목의 굵게·제목 크기가 **런의 글자 속성으로 저장돼 있다**는 표시다. 이 표식이 없는 파일은 렌더러가 제목을 강제로 굵게·제목 크기로 그리던 시절의 것이라, **에디터가 받을 때 한 번** 런에 적어 넣는다(`HeadingStyle.Materialize`: 제목의 모든 run을 굵게, 크기가 없거나 10pt인 run은 제목 크기로). **판독기는 변환하지 않는다** — 저장된 모델 그대로 읽는다. 에디터를 거치지 않은 호스트 모델은 표식 없이 저장된다(그래야 다시 열 때 변환된다). WinUI 포트는 2026-09-13부터 같은 표식을 쓴다. 이 필드를 모르는 구 판독기는 무시하고, 그 판독기로 저장하면 표식이 빠진다(다시 열면 제목의 굵게가 되살아난다).

#### 버전 이력

Expand All @@ -95,7 +97,7 @@ FlowDocument
| `2` | 문서 수준 `Images` 풀 도입. 블록은 `ImageRef`(SHA-256 hex 키)로 참조. 동일 이미지 1회 저장 | v1 필드(`ImageBase64`, `IsListItem`)는 읽기 폴백 유지 |
| `"1.0"` (현재) | 안정 기준선. 정수→SemVer 표기 전환 + 이미지 풀 + **글자 크기 pt** + **비례 줄 간격(`LineSpacing`)** | 레거시 정수 버전 문서를 그대로 읽음 |
| `"1.0"` (2026-10-01 작성기) | **스키마 변경 없음.** 작성기가 기본값 필드·기본 `Type`·1뿐인 병합 격자·`Rows`/`Columns`를 생략하고, 들여쓰지 않고, 한글을 이스케이프하지 않는다. 같은 문서가 4~12분의 1(corpus 실측 240.6 → 37.7 KB, 3,043 → 378 KB) | 1.0 이후 모든 판독기가 그대로 읽음 |
| `"1.0"` (2026-10-01, 같은 날 뒤) | **스키마 변경 없음.** 로드 시 같은 서식의 run을 합침. 색을 `#AARRGGBB` 대문자로 씀(이전 판은 알려진 색을 이름 `"Red"`으로, 나머지를 소문자로 썼다). `Alt`·`VAlign` 필드 순서를 포트와 맞춤 | 1.0 이후 모든 판독기가 그대로 읽음 |
| `"1.0"` (2026-10-01, 같은 날 뒤) | **스키마 변경 없음.** 선택 필드 `HeadingFormat` 추가(제목 서식이 run에 있음). 로드 시 같은 서식의 run을 합침. 색을 `#AARRGGBB` 대문자로 씀(이전 판은 알려진 색을 이름 `"Red"`으로, 나머지를 소문자로 썼다). `Alt`·`VAlign` 필드 순서를 포트와 맞춤 | 1.0 이후 모든 판독기가 그대로 읽음 |

- 풀 키 = **원본 인코딩 바이트의 SHA-256, 대문자 16진 문자열** (`Convert.ToHexString`).
- 로드 시 풀 항목은 한 번만 디코드되고, 같은 키를 참조하는 모든 블록이 **동일한 `byte[]` 인스턴스를 공유**한다.
Expand Down
6 changes: 3 additions & 3 deletions src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ private bool DropBlock(Block blk, Paragraph p, int offset, bool copy)
if (off > 0 && off < len)
{
_caretPosition = new TextPointer(p, off);
SplitParagraphAtCaret(); // head stays p, its tail right after it
// The tail continues p: Enter's "a heading's next line is body text" is a typing rule.
if (target[at + 1] is Paragraph tail) tail.HeadingLevel = p.HeadingLevel;
// Head stays p, its tail right after it. The tail continues p: Enter's "a heading's next line is
// body text" is a typing rule.
SplitParagraphAtCaret(keepHeading: true);
at++;
}
else if (off == len) at++;
Expand Down
30 changes: 15 additions & 15 deletions src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ private void ApplyFormatPainterToSelection()

/// <summary>Toggles bold on the current selection (or the caret run).</summary>
public void ToggleBold() => ToggleCharacterFormat(
r => r.FontWeight == FontWeight.Bold, (r, on) => r.FontWeight = on ? FontWeight.Bold : FontWeight.Normal,
forced: (_, p) => p.HeadingLevel is >= 1 and <= 6); // a heading is drawn bold (DrawnBold)
r => r.FontWeight == FontWeight.Bold, (r, on) => r.FontWeight = on ? FontWeight.Bold : FontWeight.Normal);
/// <summary>Toggles italic on the current selection (or the caret run).</summary>
public void ToggleItalic() => ToggleCharacterFormat(
r => r.FontStyle == FontStyle.Italic, (r, on) => r.FontStyle = on ? FontStyle.Italic : FontStyle.Normal);
Expand All @@ -70,8 +69,8 @@ public void ToggleItalic() => ToggleCharacterFormat(
// character already has it, otherwise on for all. Flipping run by run turned "normal BOLD normal"
// into "BOLD normal BOLD".
//
// `forced`: where the renderer draws the format whatever the run says (a heading's bold, a plain
// link's underline). Such text counts as having it — the toggle judges what is SHOWN, as the toolbar
// `forced`: where the renderer draws the format whatever the run says (a plain link's underline — a
// heading's bold was one until it became a run attribute, see HeadingStyle). Such text counts as having it — the toggle judges what is SHOWN, as the toolbar
// reports it — and when every targeted character is forced the toggle does nothing and records no undo
// step: no change it could make would show. It used to flip a hidden flag in a heading (bold on, bold
// off, the screen unchanged), and push an empty undo step on a link. (Backported from the WinUI port.)
Expand Down Expand Up @@ -409,16 +408,16 @@ Paragraph NewPara()

/// <summary>Sets the heading level of every selected paragraph (1–6 = h1–h6, 0 = body); the caret
/// paragraph alone when nothing is selected.
/// The heading's larger, bold look is applied at layout time to runs at the body default size, so
/// applying a heading (1–6) resets its runs to that default, like applying a Word style: a size
/// baked into the runs (an imported &lt;h1&gt;, an earlier manual size) would otherwise pin the text
/// and switching Heading 1 → Heading 2 would not change it. Reverting to body (0) touches no run.</summary>
/// Setting a level writes the heading's format onto its text, like applying a Word style: bold and the
/// level's size on every run — on first application, on a level change, and on re-applying the same
/// level (which restores the look after the text was changed). After that they are ordinary run
/// attributes: bold can be turned off, any size set. Back to body (0) takes both off again; body to body
/// touches no run.</summary>
public void SetHeading(int level)
=> ApplyToSelectedParagraphs(p =>
{
HeadingStyle.Retype(p, p.HeadingLevel, level, DefaultFontSize);
p.HeadingLevel = level;
if (level is >= 1 and <= 6)
foreach (var inl in p.Inlines) if (inl is Run r) r.FontSize = BodyFontSizePt;
});

/// <summary>Toggles blockquote styling (indented, with a quote bar) on every selected paragraph
Expand Down Expand Up @@ -507,12 +506,13 @@ private void ClearFormatting()
{
ApplyStyleToSelection(r =>
{
r.FontWeight = FontWeight.Normal;
// Back to the paragraph's own look: body text to the host default, heading text to the heading's
// preset (bold, its size) — the format the heading applied when it was set (HeadingStyle). Writing
// DefaultFontSize in a heading shrank an H1 from 20 to 14 with a host default of 14.
int level = r.Parent is Paragraph hp && HeadingStyle.IsHeading(hp.HeadingLevel) ? hp.HeadingLevel : 0;
r.FontWeight = level > 0 ? FontWeight.Bold : FontWeight.Normal;
r.FontStyle = FontStyle.Normal;
// "Unstyled": in a heading that is the body-default size, which draws at the heading's size
// (DrawnRunSize). DefaultFontSize there was an explicit size — with a host default of 14,
// clearing an H1 shrank it from 20 to 14. (Backported from the WinUI port.)
r.FontSize = r.Parent is Paragraph { HeadingLevel: >= 1 and <= 6 } ? BodyFontSizePt : DefaultFontSize;
r.FontSize = level > 0 ? HeadingStyle.Size(level) : DefaultFontSize;
r.Foreground = Brushes.Black;
r.Background = null;
r.FontFamily = null;
Expand Down
8 changes: 3 additions & 5 deletions src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,18 +1497,16 @@ private Rect GetCaretRectangle()

// Caret bar height from the font size at the caret (not the line height), so the caret stays
// glyph-sized and baseline-anchored next to tall inline content. 1.4 ≈ line height per em.
// The size is the one the text is DRAWN at (DrawnRunSize). The raw run size ignored headings: a
// heading's unstyled runs are stored at 10 pt and drawn at the heading size, so every heading got the
// same 10 pt caret — measured 19.0 in an H1 whose 20 pt text gets 29.1 in body text (and 19.0 in an H2,
// 23.3). Body text hid it: there the caret is clamped to the line box, which is shorter than 1.4 em.
// The size is the one the text is DRAWN at (DrawnRunSize) — a heading's is on its runs (HeadingStyle); with
// no run, the preset the first typed character will get.
private double CaretTextHeight(Paragraph p, int offset)
{
bool heading = p.HeadingLevel is >= 1 and <= 6;
double headingSize = heading ? HeadingFontSize(p.HeadingLevel) : 0;
// The run the text typed here takes its format from (TypingSource) — beside an image the raw lookup
// found no run and fell back to the default size.
var run = TypingSource(p, offset).Run;
double fs = run != null ? DrawnRunSize(run, heading, headingSize, DefaultFontSize)
double fs = run != null ? DrawnRunSize(run, DefaultFontSize)
: heading ? headingSize : DefaultFontSize; // pt
return Math.Ceiling(PtToPx(fs) * 1.4);
}
Expand Down
Loading
Loading