From 4eac5933ea0c3c4f86c06f1bdd7e44cc367d94e3 Mon Sep 17 00:00:00 2001 From: centwon Date: Fri, 2 Oct 2026 16:16:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AA=A9=EC=9D=98=20=EA=B5=B5?= =?UTF-8?q?=EA=B2=8C=C2=B7=ED=81=AC=EA=B8=B0=EB=A5=BC=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=20=EC=86=8D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=E2=80=94=20WinUI=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=EC=99=80=20=EA=B0=99=EC=9D=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 에디터가 같은 파일을 다르게 보던 유일한 곳. 포트(2026-09-13~)는 제목 서식을 run에 쓰고 "HeadingFormat": 1로 표시하는데, 상류는 렌더러가 제목을 강제로 굵게·제목 크기로 그렸다. 포트에서 굵게를 끈 제목이 상류에선 굵게 보였고, 상류가 저장하면 표식이 빠져 포트가 다시 열 때 굵게를 실제로 되살렸다. - HeadingStyle: 제목 적용 시(같은 수준 재적용 포함) 모든 run에 굵게·수준 크기, 본문 복귀는 둘 다 뗌 - 표식 없는 옛 문서는 에디터가 받을 때 한 번 변환(Materialize), 표식은 Clone·저장으로 이어짐 - 렌더러·캐럿 보고·캐럿 높이·리스트 마커의 강제 규칙 제거, Ctrl+B가 제목에서 토글됨 - Enter 꼬리는 본문 서식, 빈 제목 타이핑은 제목 서식, 블록 삽입용 분할은 꼬리가 제목 서식 그대로(상류 전용 규칙) - HTML: 굵지 않은 제목 글자 font-weight:normal 출력·읽기, 제목 안 10pt 명시. RTF: 강제 \b 제거 - 툴바: 이미 선택된 수준을 다시 고르면 재적용(GUI 확인 필요) - HeadingStyleTests 13개(포트 12 + 블록 삽입), 기존 3개를 새 규칙으로. 반증 15/15 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++ Project_Roadmap.md | 7 +- docs/DOCUMENT_FORMAT.md | 6 +- .../Controls/RichEditor.DragBlock.cs | 6 +- .../Controls/RichEditor.Formatting.cs | 30 +- .../Controls/RichEditor.Input.cs | 8 +- src/AvaloniaRichEditor/Controls/RichEditor.cs | 75 ++-- .../Controls/RichEditorToolbar.cs | 16 + .../Documents/FlowDocument.cs | 6 +- .../Documents/HeadingStyle.cs | 80 +++++ src/AvaloniaRichEditor/Documents/TextRange.cs | 2 +- .../Formatters/DocumentSerializer.cs | 11 +- .../Formatters/HtmlDocumentFormatter.cs | 30 +- .../Formatters/RtfDocumentFormatter.cs | 24 +- .../CaretFormatReportTests.cs | 18 +- .../Fixtures/format-1.0-interchange.json | 2 +- .../HeadingStyleTests.cs | 328 ++++++++++++++++++ .../RichEditorHeadingTests.cs | 13 +- 18 files changed, 583 insertions(+), 99 deletions(-) create mode 100644 src/AvaloniaRichEditor/Documents/HeadingStyle.cs create mode 100644 tests/AvaloniaRichEditor.Tests/HeadingStyleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce83c9d..3f1ebfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, 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 diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 99be46d..af9aea8 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -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건** diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md index aed6253..236a52a 100644 --- a/docs/DOCUMENT_FORMAT.md +++ b/docs/DOCUMENT_FORMAT.md @@ -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부터 같은 표식을 쓴다. 이 필드를 모르는 구 판독기는 무시하고, 그 판독기로 저장하면 표식이 빠진다(다시 열면 제목의 굵게가 되살아난다). #### 버전 이력 @@ -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[]` 인스턴스를 공유**한다. diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs index dbaca44..50b1ec4 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs @@ -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++; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs index 19c52fb..8cd58c8 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs @@ -60,8 +60,7 @@ private void ApplyFormatPainterToSelection() /// Toggles bold on the current selection (or the caret run). 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); /// Toggles italic on the current selection (or the caret run). public void ToggleItalic() => ToggleCharacterFormat( r => r.FontStyle == FontStyle.Italic, (r, on) => r.FontStyle = on ? FontStyle.Italic : FontStyle.Normal); @@ -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.) @@ -409,16 +408,16 @@ Paragraph NewPara() /// 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 <h1>, 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. + /// 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. 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; }); /// Toggles blockquote styling (indented, with a quote bar) on every selected paragraph @@ -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; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs index cd55546..1902f09 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs @@ -1497,10 +1497,8 @@ 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; @@ -1508,7 +1506,7 @@ private double CaretTextHeight(Paragraph p, int offset) // 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); } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs index 5455f28..f338c22 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs @@ -365,6 +365,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang // A host's own model is trusted no more than a file: spans describing no grid crashed the editor or // hid cells (see TableBlock.EnsureSpanConsistency). Undo swaps in clones, consistent already. if (Document != null) NormalizeTableSpans(Document.Blocks); + // A document from before heading formats lived on their runs (an older file, a host's own model) gets + // them now, once — the flag rides along in undo snapshots and saved files, so text the user + // un-bolded is never re-bolded. See HeadingStyle.Materialize. + if (Document != null) HeadingStyle.Materialize(Document); if (Document != null) UpdateParents(Document); // A new document starts a new history. Undo and redo swap documents through this property too, and // only those keep it: a host that opened a file by assigning it left the previous file's edits @@ -779,9 +783,9 @@ public CaretFormat GetCaretFormat() bool heading = p is { HeadingLevel: >= 1 and <= 6 }; double headingSize = heading ? HeadingFontSize(p!.HeadingLevel) : 0; return new CaretFormat( - // As DRAWN (DrawnBold): a heading is drawn bold whatever its runs say. The raw weight showed the - // bold button off over a bold heading. (Underline: HasDeco already applies the link rule.) - run != null ? DrawnBold(run, heading) : heading, + // As DRAWN (DrawnBold). With no run at all, text typed into a heading takes the heading's preset + // (InsertText), so that is the report. (Underline: HasDeco already applies the link rule.) + run != null ? DrawnBold(run) : heading, run?.FontStyle == FontStyle.Italic, HasDeco(run, TextDecorationLocation.Underline), HasDeco(run, TextDecorationLocation.Strikethrough), @@ -789,7 +793,7 @@ public CaretFormat GetCaretFormat() // and IncreaseFontSize steps from it. The raw run size read 10 for unset text drawn at the // host's DefaultFontSize and for a heading's unstyled runs drawn at the heading size — and // "larger" then SHRANK both to 10.5. (Backported 2026-09-12 from the WinUI port.) - run != null ? DrawnRunSize(run, heading, headingSize, DefaultFontSize) + run != null ? DrawnRunSize(run, DefaultFontSize) : heading ? headingSize : DefaultFontSize, run?.FontFamily, p?.TextAlignment ?? TextAlignment.Left, @@ -1159,6 +1163,9 @@ private void TryInsertTextCore(Paragraph p, string text, int localIndex) return; } var run = src != null ? (Run)src.Clone() : new Run(); + // No text in the paragraph to take a format from: in a heading the typed text gets the heading's + // preset, which the caret report and the caret size already assume. + if (src == null && HeadingStyle.IsHeading(p.HeadingLevel)) HeadingStyle.ApplyPreset(run, p.HeadingLevel); run.Text = text; run.Parent = p; if (!link) run.NavigateUri = null; @@ -1294,8 +1301,7 @@ private sealed class ParagraphTextSource : Avalonia.Media.TextFormatting.ITextSo } } - // The body (non-heading) default font size, in points. Single source for the magic value that - // marks a run as "unstyled" so it inherits a heading paragraph's size (see RunSizeIsBodyDefault). + // The body (non-heading) default font size, in points — Run.FontSize's default (HeadingStyle.BodySize). internal const double BodyFontSizePt = 10; // Font sizes in the model / public API / serialization are points (pt). Avalonia's TextLayout and @@ -1307,27 +1313,20 @@ private sealed class ParagraphTextSource : Avalonia.Media.TextFormatting.ITextSo // Paragraph.LineSpacing is HWP's "글자에 따라" ratio: line box = largest font size × ratio. internal const double DefaultLineSpacing = 1.6; - // The layout font size (pt) for a heading paragraph (1–6 = h1–h6); 0/other = body. Applied at layout - // time to runs left at the body default, so the heading look never has to be baked into the model. - internal static double HeadingFontSize(int level) - => level switch { 1 => 20, 2 => 16, 3 => 14, 4 => 12, 5 => 11, 6 => 10, _ => BodyFontSizePt }; - - // A run is at the "body default" size (and so inherits a heading paragraph's size) when its size - // is unset (<=0) or the 10 pt model default. An explicitly-sized run keeps its own size. - private static bool RunSizeIsBodyDefault(Run r) => r.FontSize <= 0 || Math.Abs(r.FontSize - BodyFontSizePt) < 0.01; + // A heading's preset size (pt) for its level (1–6 = h1–h6); 0/other = body. Written onto the runs when a + // heading is set (HeadingStyle); used here only where there is no run to read (an empty heading). + internal static double HeadingFontSize(int level) => HeadingStyle.Size(level); // The size (pt) a run is DRAWN at — the one rule, used by BuildTextLayout to draw it, by // CaretTextHeight to size the caret, and by GetCaretFormat to report it, so the toolbar cannot show a - // size the text is not shown at (and - // IncreaseFontSize, which steps from the reported size, cannot shrink text it means to grow). In a - // heading an unstyled run takes the heading's size; otherwise an unset size (<= 0) falls back to - // DefaultFontSize. - private static double DrawnRunSize(Run r, bool heading, double headingSize, double defaultSize) - => heading && RunSizeIsBodyDefault(r) ? headingSize : r.FontSize <= 0 ? defaultSize : r.FontSize; + // size the text is not shown at (and IncreaseFontSize, which steps from the reported size, cannot shrink + // text it means to grow). An unset size (<= 0) falls back to DefaultFontSize. A heading no longer + // overrides it: its size is on its runs (HeadingStyle). + private static double DrawnRunSize(Run r, double defaultSize) => r.FontSize <= 0 ? defaultSize : r.FontSize; // Whether a run is DRAWN bold — same pairing as DrawnRunSize: BuildTextLayout draws with it and - // GetCaretFormat reports it. A heading is drawn bold whatever its runs say. - private static bool DrawnBold(Run r, bool heading) => heading || r.FontWeight == FontWeight.Bold; + // GetCaretFormat reports it. A heading's bold used to be forced; it is a run attribute now (HeadingStyle). + private static bool DrawnBold(Run r) => r.FontWeight == FontWeight.Bold; // Width reserved to the left of list-item text for its bullet/number marker. private const double ListMarkerWidth = 22; @@ -1412,11 +1411,11 @@ private void DrawListMarker(DrawingContext context, Paragraph p, int num, double double size = first is { FontSize: > 0 } ? first.FontSize : DefaultFontSize; var family = first != null && !string.IsNullOrEmpty(first.FontFamily) ? new FontFamily(first.FontFamily) : DefaultFontFamily; var weight = first?.FontWeight ?? FontWeight.Normal; - // Match the heading look applied to the text in BuildTextLayout, so a heading list item's - // bullet/number isn't left small and thin beside its enlarged text. - if (p.HeadingLevel is >= 1 and <= 6) + // The marker follows the first run, heading or not (a heading's format is on its runs). With no run + // yet, it takes the heading's preset — the format the first typed character will get. + if (first == null && p.HeadingLevel is >= 1 and <= 6) { - if (first == null || RunSizeIsBodyDefault(first)) size = HeadingFontSize(p.HeadingLevel); + size = HeadingFontSize(p.HeadingLevel); weight = FontWeight.Bold; } var brush = first?.Foreground ?? Brushes.Black; @@ -1586,7 +1585,8 @@ private Avalonia.Media.TextFormatting.TextLayout CreateTextLayout(Paragraph p, d var defaultProps = new Avalonia.Media.TextFormatting.GenericTextRunProperties( new Typeface(defaultFamily), PtToPx(defaultSize), null, Brushes.Black); - // Heading look (bigger + bold) is applied here, not baked into the runs (see SetHeading). + // A heading's look is on its runs (HeadingStyle); the preset here is only for text not typed yet — the + // composition fallback and the line height of an empty heading. bool heading = p.HeadingLevel is >= 1 and <= 6; double headingSize = heading ? HeadingFontSize(p.HeadingLevel) : 0; @@ -1597,11 +1597,11 @@ private Avalonia.Media.TextFormatting.TextLayout CreateTextLayout(Paragraph p, d if (inline is Run r && !string.IsNullOrEmpty(r.Text)) { var family = string.IsNullOrEmpty(r.FontFamily) ? defaultFamily : new FontFamily(r.FontFamily); - var weight = DrawnBold(r, heading) ? FontWeight.Bold : r.FontWeight; + var weight = r.FontWeight; var typeface = new Typeface(family, r.FontStyle, weight); TextDecorationCollection? decos = r.TextDecorations; if (decos == null && !string.IsNullOrEmpty(r.NavigateUri)) decos = TextDecorations.Underline; - double size = DrawnRunSize(r, heading, headingSize, defaultSize); // pt + double size = DrawnRunSize(r, defaultSize); // pt if (size > maxRunPt) maxRunPt = size; var props = new Avalonia.Media.TextFormatting.GenericTextRunProperties( typeface, @@ -2043,10 +2043,8 @@ private void InsertBlockAtCaret(Block b) 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; + SplitParagraphAtCaret(keepHeading: true); _caretPosition = new TextPointer(cp, GetParagraphLength(cp)); // the block goes after the head } else if (off == 0 && len > 0) before = true; @@ -2145,7 +2143,9 @@ public void InsertTable(int rows, int cols) // Splits the caret's (top-level) paragraph at the caret into a new following paragraph, which // inherits list/indent/alignment/background (not heading level). Used by Enter. - private void SplitParagraphAtCaret() + // `keepHeading`: the tail CONTINUES the paragraph (a block dropped or inserted mid-heading) — it keeps the + // level and its text the heading's format. Enter's "a heading's next line is body text" is a typing rule. + private void SplitParagraphAtCaret(bool keepHeading = false) { var p = _caretPosition.Paragraph; if (Document == null || p == null) return; @@ -2166,7 +2166,7 @@ private void SplitParagraphAtCaret() // a normal paragraph (core rule #3). var np = new Paragraph(); np.CopyFormatFrom(p); - np.HeadingLevel = 0; + np.HeadingLevel = keepHeading ? p.HeadingLevel : 0; while (p.Inlines.Count > insertAt) { var inl = p.Inlines[insertAt]; @@ -2174,8 +2174,11 @@ private void SplitParagraphAtCaret() inl.Parent = np; np.Inlines.Add(inl); } - if (np.Inlines.Count == 0) np.Inlines.Add(new Run { Text = "" }); - if (p.Inlines.Count == 0) p.Inlines.Add(new Run { Text = "" }); + // The text carried into the new body paragraph leaves the heading's format behind; a heading left + // empty keeps it on its empty run, so typing there still writes heading text. + if (!keepHeading) HeadingStyle.Retype(np, p.HeadingLevel, 0, DefaultFontSize); + if (np.Inlines.Count == 0) np.Inlines.Add(keepHeading ? HeadingStyle.EmptyRun(np.HeadingLevel) : new Run { Text = "" }); + if (p.Inlines.Count == 0) p.Inlines.Add(HeadingStyle.EmptyRun(p.HeadingLevel)); np.Parent = p.Parent; container.Insert(idx + 1, np); _caretPosition = new TextPointer(np, 0); diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs index 75534da..6d21a35 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs @@ -390,6 +390,22 @@ Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Dis if (_suppress || _headingCombo.SelectedIndex < 0) return; Target?.SetHeading(_headingCombo.SelectedIndex); }; + // Re-picking the level already shown re-applies it — that restores a heading's bold and size after the + // user changed them (HeadingStyle.Retype). SelectionChanged cannot see that pick: the selection does not + // change. The press records whether the item was ALREADY the selected one and the release applies only + // then, so picking a different level is applied once, by SelectionChanged. (As the WinUI port.) + bool reapply = false; + foreach (var item in System.Linq.Enumerable.OfType(_headingCombo.Items)) + { + item.AddHandler(PointerPressedEvent, (s, _) => reapply = ReferenceEquals(_headingCombo.SelectedItem, s), + Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true); + item.AddHandler(PointerReleasedEvent, (s, _) => + { + if (!reapply || s is not ComboBoxItem ci) return; + reapply = false; + Target?.SetHeading(_headingCombo.Items.IndexOf(ci)); + }, Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true); + } Add(_headingCombo); _alignCombo = Combo(Loc("Alignment")); diff --git a/src/AvaloniaRichEditor/Documents/FlowDocument.cs b/src/AvaloniaRichEditor/Documents/FlowDocument.cs index 7fe352a..37719b4 100644 --- a/src/AvaloniaRichEditor/Documents/FlowDocument.cs +++ b/src/AvaloniaRichEditor/Documents/FlowDocument.cs @@ -15,11 +15,15 @@ public class FlowDocument : AvaloniaObject /// editor on load and captured back from the control's page properties on change. public PageSetup? PageSetup { get; set; } + // True once headings carry their format on their runs (HeadingStyle.Materialize). A document built + // before that has it false and is converted once when the editor receives it. + internal bool HeadingFormatsApplied { get; set; } + /// Creates a deep clone of this document (all blocks and their children are cloned recursively). /// Image bytes are reference-shared (not copied) for efficiency. public FlowDocument Clone() { - var doc = new FlowDocument { PageSetup = PageSetup?.Clone() }; + var doc = new FlowDocument { PageSetup = PageSetup?.Clone(), HeadingFormatsApplied = HeadingFormatsApplied }; foreach (var block in Blocks) { var clone = block.Clone() as Block; diff --git a/src/AvaloniaRichEditor/Documents/HeadingStyle.cs b/src/AvaloniaRichEditor/Documents/HeadingStyle.cs new file mode 100644 index 0000000..459da03 --- /dev/null +++ b/src/AvaloniaRichEditor/Documents/HeadingStyle.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Media; + +namespace AvaloniaRichEditor.Documents; + +// A heading's look lives on its RUNS. Applying a level writes bold and the level's size onto the text; until +// the next application they are ordinary run attributes — bold can be turned off, any size set, and it shows. +// +// Until 2026-10-01 the renderer forced both instead: every run of a heading was drawn bold, and a run with no +// size of its own was drawn at the heading's size. So a heading could not be un-bolded and 10pt could not be +// shown in one. The WinUI port made this change on 2026-09-13 (user decision there); it is brought here so one +// file means one document in both editors — the port marks such files ("HeadingFormat": 1), and an editor +// that still forced the look showed them re-bolded and dropped the marker on save. +internal static class HeadingStyle +{ + // Run.FontSize's default, which the model has always read as "no size chosen". + internal const double BodySize = 10; + + internal static bool IsHeading(int level) => level is >= 1 and <= 6; + + internal static double Size(int level) + => level switch { 1 => 20, 2 => 16, 3 => 14, 4 => 12, 5 => 11, 6 => 10, _ => BodySize }; + + private static bool Near(double a, double b) => Math.Abs(a - b) < 0.01; + + private static bool IsUnstyledSize(double size) => size <= 0 || Near(size, BodySize); + + // The heading's preset on one run: what text typed into a heading with no text to copy a format from gets. + internal static void ApplyPreset(Run r, int level) + { + r.FontWeight = FontWeight.Bold; + r.FontSize = Size(level); + } + + // The empty run a paragraph is left holding (after an Enter that moved all its text away): in a heading + // it carries the preset, so typing there writes heading text and not plain body text. + internal static Run EmptyRun(int level) + { + var r = new Run { Text = "" }; + if (IsHeading(level)) ApplyPreset(r, level); + return r; + } + + // Rewrites a paragraph's runs for a level change `from` → `to` (0 = body). Applying a heading applies its + // FORMAT: every run gets the level's preset (bold, its size) — on first application, on a change of level, + // and on re-applying the same level, which is how the heading's look comes back after the user changed + // its bold or size. The user's changes stand until the next application. Back to body takes the preset + // off the same way: every run to normal weight and the body size (the host default, as ClearFormatting + // writes it). Body to body changes nothing — that would wipe the bold words of an ordinary paragraph. + internal static void Retype(Paragraph p, int from, int to, double hostDefault) + { + if (IsHeading(to)) + foreach (var r in p.Inlines.OfType()) ApplyPreset(r, to); + else if (IsHeading(from)) + foreach (var r in p.Inlines.OfType()) { r.FontWeight = FontWeight.Normal; r.FontSize = hostDefault; } + } + + // A document whose headings predate run-level heading formats — files written before 2026-10-01, a host's + // own model — gets them written on, exactly as the old renderer showed them: every heading run bold, an + // unsized run at the heading's size. Once: the flag travels with the document (Clone, the JSON marker), so + // text the user un-bolded is never re-bolded by an undo or a reload. + internal static void Materialize(FlowDocument doc) + { + if (doc.HeadingFormatsApplied) return; + doc.HeadingFormatsApplied = true; + var all = new List(); + TextRange.CollectParagraphs(doc.Blocks, all); + foreach (var p in all) + { + if (!IsHeading(p.HeadingLevel)) continue; + foreach (var r in p.Inlines.OfType()) + { + if (IsUnstyledSize(r.FontSize)) r.FontSize = Size(p.HeadingLevel); + r.FontWeight = FontWeight.Bold; + } + } + } +} diff --git a/src/AvaloniaRichEditor/Documents/TextRange.cs b/src/AvaloniaRichEditor/Documents/TextRange.cs index b93a68d..72b1ca0 100644 --- a/src/AvaloniaRichEditor/Documents/TextRange.cs +++ b/src/AvaloniaRichEditor/Documents/TextRange.cs @@ -554,7 +554,7 @@ private List GetAllParagraphsInOrder(FlowDocument doc) // Mirror of the control's ParagraphsInBlocks: fully recursive through table cells (nested tables) and // through inline tables hanging off a paragraph's inlines, using logical (anchor) cells only so the // index-based range loops agree with the control's order on merged tables. - private static void CollectParagraphs(IEnumerable blocks, List result) + internal static void CollectParagraphs(IEnumerable blocks, List result) { foreach (var block in blocks) { diff --git a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs index b2598ea..1edb556 100644 --- a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs +++ b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs @@ -74,7 +74,9 @@ internal static FlowDocumentDto ToDto(FlowDocument document, Dictionary(); foreach (var block in document.Blocks) blocks.Add(BlockToDto(block, images)); - var dto = new FlowDocumentDto { Version = CurrentSchemaVersion, Blocks = blocks }; + // The marker only for a document that really carries its heading formats: a host's own model that no + // editor has converted yet must stay "legacy" in the file, or it would load back with plain headings. + var dto = new FlowDocumentDto { Version = CurrentSchemaVersion, Blocks = blocks, HeadingFormat = document.HeadingFormatsApplied ? 1 : null }; // A document with no page setup is written without one, so plain documents keep their original format. // One that CARRIES a setup is written even when it looks default: an editor whose host defaults to A4 // keeps a chosen Continuous on the document, and dropping it here reopened the file as A4 (measured in @@ -169,7 +171,7 @@ internal static (FlowDocumentDto? Dto, Dictionary pool) { - var doc = new FlowDocument(); + var doc = new FlowDocument { HeadingFormatsApplied = dto?.HeadingFormat >= 1 }; if (dto?.Blocks != null) foreach (var bd in dto.Blocks) { @@ -677,6 +679,11 @@ internal class FlowDocumentDto public Dictionary? Images { get; set; } // Optional page setup; absent for plain (Continuous) documents, so the format is unchanged for them. public PageSetupDto? PageSetup { get; set; } + // 1 = heading formatting is on the runs (bold and the heading size, written when the heading was set). + // Absent in files written before 2026-10-01 (the port: 2026-09-12), whose renderers forced both; the EDITOR + // then writes them onto the runs once when it receives the document (HeadingStyle.Materialize) — the reader + // leaves the model as it was saved. Older readers ignore it. + public int? HeadingFormat { get; set; } } // Enums are serialized as their names (matching the rest of the format), so unknown future values degrade diff --git a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs index 16c6fae..e3f2ff1 100644 --- a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs +++ b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs @@ -164,6 +164,10 @@ private static FlowDocument BuildDocument(HtmlDocument doc, string html) // export then welds those neighbours back into a single text node, so importing our own export // twice gave two different run lists for the same text. TextRange.CoalesceAll(flowDoc); + // The reader writes every heading's bold and size onto its runs (HeadingSize, from the

..

it + // came from), so the document is marked as carrying them: the editor must not convert it again — + // that would re-bold heading text this library exported un-bolded (font-weight:normal). + flowDoc.HeadingFormatsApplied = true; return flowDoc; } @@ -891,10 +895,15 @@ private static void ApplyInlineStyle(string styleAttr, ref FontWeight weight, re if (fw.Success) { string v = fw.Groups[1].Value.Trim(); - if (v.Contains("bold") // bold / bolder - || (double.TryParse(v, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double n) && n >= 600)) + bool numeric = double.TryParse(v, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double n); + if (v.Contains("bold") || (numeric && n >= 600)) // bold / bolder weight = FontWeight.Bold; + // Normal UN-bolds what an enclosing element made bold — CSS does, and this library's own export + // relies on it for un-bolded heading text (

is bold). It was ignored: Google Docs wraps a + // paste in , and everything came in bold. + else if (v is "normal" or "lighter" || (numeric && n < 600)) + weight = FontWeight.Normal; } if (s.Contains("font-style:italic") || s.Contains("font-style: italic")) style = FontStyle.Italic; if (System.Text.RegularExpressions.Regex.IsMatch(s, "text-decoration[^;]*underline")) underline = true; @@ -1202,7 +1211,7 @@ private static void EmitParagraphElement(StringBuilder sb, Paragraph p, bool new if (p.Inlines.Count == 0) extraAttr += " data-are-empty=\"1\""; sb.Append($"<{tag}{extraAttr} style=\"{pStyle}\">"); for (int i = 0; i < p.Inlines.Count; i++) - EmitInline(sb, p.Inlines[i], i == 0, i == p.Inlines.Count - 1); + EmitInline(sb, p.Inlines[i], i == 0, i == p.Inlines.Count - 1, tag[0] == 'h' ? p.HeadingLevel : 0); // The marker alone told only THIS reader about the blank line. An element with no content has // zero height, so in a browser the author's blank line was invisible — measured: the gap // across it was the same 16px as between any two adjacent paragraphs. The
is what gives @@ -1347,7 +1356,7 @@ private static void EmitTable(StringBuilder sb, TableBlock tb, bool asInline = f // `opensParagraph`/`closesParagraph` mark the first and last inline of their paragraph. The first // drives the "this opened its own paragraph" marker on images and tables; the last gates the // trailing-space encoding, because HTML drops whitespace at the end of a block. - private static void EmitInline(StringBuilder sb, Inline inline, bool opensParagraph = false, bool closesParagraph = false) + private static void EmitInline(StringBuilder sb, Inline inline, bool opensParagraph = false, bool closesParagraph = false, int heading = 0) { if (inline is InlineImage im && (im.RawBytes != null || im.Image != null)) { @@ -1378,9 +1387,16 @@ private static void EmitInline(StringBuilder sb, Inline inline, bool opensParagr // and Word/HWP then drop the ENTIRE style declaration — taking size/colour with it. if (!string.IsNullOrEmpty(r.FontFamily)) styles.Add($"font-family:'{AttrEscape(r.FontFamily).Replace("'", "")}'"); // Size in pt, not px: Word/HWP clipboard import ignores px font-size (a well-known quirk) but - // honours pt. The model already stores pt, so emit it directly (skip the 10pt body default). - if (r.FontSize > 0 && System.Math.Abs(r.FontSize - 10) > 0.01) + // honours pt. The model already stores pt, so emit it directly — when it differs from what the + // reader assumes for this element: the body default, or inside

..

the heading's size, which + // the reader gives everything in it. 10pt in a heading would be dropped as "the default" and come + // back at the heading's size. + bool inHeading = HeadingStyle.IsHeading(heading); + double assumed = inHeading ? HeadingStyle.Size(heading) : 10; + if (r.FontSize > 0 && System.Math.Abs(r.FontSize - assumed) > 0.01) styles.Add($"font-size:{r.FontSize.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}pt"); + //

..

is bold to every reader (browsers, Word, this one), so un-bolded heading text says so. + if (inHeading && r.FontWeight != FontWeight.Bold) styles.Add("font-weight:normal"); if (r.Foreground is ISolidColorBrush fg) styles.Add($"color:{CssColor(fg.Color)}"); if (r.Background is ISolidColorBrush bg) styles.Add($"background-color:{CssColor(bg.Color)}"); diff --git a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs index 207e8de..b281179 100644 --- a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs +++ b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs @@ -1385,8 +1385,6 @@ private void WriteParagraph(Paragraph p, int ordered) if (p.ListType != ListKind.None) WriteListMarker(p, ordered); - bool heading = p.HeadingLevel is >= 1 and <= 6; - double headingSize = heading ? HeadingSize(p.HeadingLevel) : 0; foreach (var inline in p.Inlines) { // RTF has no inline table, so one splits its host paragraph: the text before it, then the @@ -1406,29 +1404,31 @@ private void WriteParagraph(Paragraph p, int ordered) _body.Append(@"\pard "); continue; } - WriteInline(inline, heading, headingSize); + WriteInline(inline); } _body.Append(@"\par").Append('\n'); } - private void WriteInline(Inline inline, bool heading, double headingSize) + private void WriteInline(Inline inline) { - if (inline is Run r && !string.IsNullOrEmpty(r.Text)) WriteRun(r, heading, headingSize); + if (inline is Run r && !string.IsNullOrEmpty(r.Text)) WriteRun(r); else if (inline is InlineImage img && (img.RawBytes != null || img.Image != null)) WritePict(img.RawBytes, img.MimeType, img.Image, img.Width, img.Height); } - private void WriteRun(Run r, bool heading, double headingSize) + // A heading's bold and size are on its runs (HeadingStyle), so a run is written as it is. This used to add + // \b to every heading run and the heading size to unsized ones, mirroring the renderer that forced them — + // which would now re-bold heading text the user un-bolded. + private void WriteRun(Run r) { _body.Append('{'); - if (r.FontWeight == FontWeight.Bold || heading) _body.Append(@"\b"); + if (r.FontWeight == FontWeight.Bold) _body.Append(@"\b"); if (r.FontStyle == FontStyle.Italic) _body.Append(@"\i"); if (HasDecoration(r.TextDecorations, TextDecorationLocation.Underline) || !string.IsNullOrEmpty(r.NavigateUri)) _body.Append(@"\ul"); if (HasDecoration(r.TextDecorations, TextDecorationLocation.Strikethrough)) _body.Append(@"\strike"); int f = FontIndex(r.FontFamily); if (f > 0) _body.Append($@"\f{f}"); double size = r.FontSize <= 0 ? 10 : r.FontSize; // pt; body default - if (heading && (r.FontSize <= 0 || Math.Abs(r.FontSize - 10) < 0.01)) size = headingSize; _body.Append($@"\fs{(int)Math.Round(size * 2)}"); // \fs is half-points; model size is already pt int c = ColorIndex(r.Foreground); if (c > 0) _body.Append($@"\cf{c}"); @@ -1560,8 +1560,6 @@ void CloseBeforeNested() // matter what it was — while the identical paragraph at the top level exported correctly. WriteParagraphPropsBody(cpara); if (cpara.ListType != ListKind.None) WriteListMarker(cpara, 1); - bool heading = cpara.HeadingLevel is >= 1 and <= 6; - double headingSize = heading ? HeadingSize(cpara.HeadingLevel) : 0; foreach (var inline in cpara.Inlines) { if (inline is InlineTable it) @@ -1571,7 +1569,7 @@ void CloseBeforeNested() wroteNested = true; ReopenCell(); // the rest of this paragraph belongs to THIS cell, not the inner table } - else WriteInline(inline, heading, headingSize); + else WriteInline(inline); } } else if (blk is TableBlock nested) @@ -1662,10 +1660,6 @@ private int ColorIndex(IBrush? brush) return i; } - // Heading sizes in points (pt), mirroring RichEditor.HeadingFontSize. - private static double HeadingSize(int level) - => level switch { 1 => 20, 2 => 16, 3 => 14, 4 => 12, 5 => 11, 6 => 10, _ => 10 }; - private static bool HasDecoration(TextDecorationCollection? decos, TextDecorationLocation loc) { if (decos == null) return false; diff --git a/tests/AvaloniaRichEditor.Tests/CaretFormatReportTests.cs b/tests/AvaloniaRichEditor.Tests/CaretFormatReportTests.cs index 966ca3e..c86af2d 100644 --- a/tests/AvaloniaRichEditor.Tests/CaretFormatReportTests.cs +++ b/tests/AvaloniaRichEditor.Tests/CaretFormatReportTests.cs @@ -274,26 +274,36 @@ public void AHeading_IsReportedBold() Assert.True(ed.GetCaretFormat().Bold); } + // A heading's bold is on its runs (HeadingStyle, as the WinUI port since 2026-09-13): written when the + // heading is set, an ordinary attribute after that. So Ctrl+B takes it off — with an undo step, and the + // report follows — and a second press puts it back. It used to do nothing: the renderer forced the bold. [AvaloniaFact] - public void CtrlB_OnAHeading_DoesNothing() + public void CtrlB_OnAHeading_TurnsItsBoldOff_AndBackOn() { var ed = Editor(Para(1, Plain("Title"))); var p = Paras(ed)[0]; Select(ed, p, 0, p, 5); ed.ToggleBold(); - Assert.False(ed.CanUndo); + Assert.True(ed.CanUndo); Assert.Equal(FontWeight.Normal, RunAt(Paras(ed)[0], 1).FontWeight); + Assert.False(ed.GetCaretFormat().Bold); + p = Paras(ed)[0]; + Select(ed, p, 0, p, 5); + ed.ToggleBold(); + Assert.Equal(FontWeight.Bold, RunAt(Paras(ed)[0], 1).FontWeight); } + // With no word at the caret the toggle arms a pending style, in a heading as anywhere: the next + // keystroke comes out un-bolded, and the heading's own text is left alone. [AvaloniaFact] - public void CtrlB_AtAnEmptyCaretInAHeading_ArmsNothing() + public void CtrlB_AtAnEmptyCaretInAHeading_UnboldsTheNextKeystroke() { var ed = Editor(Para(1, Plain("Title "))); Caret(ed, Paras(ed)[0], 6); ed.ToggleBold(); - Assert.Null(T.GetField("_pendingCaretStyles", NP)!.GetValue(ed)); ed.InsertText("Z"); Assert.Equal(FontWeight.Normal, RunAt(Paras(ed)[0], 6).FontWeight); + Assert.Equal(FontWeight.Bold, RunAt(Paras(ed)[0], 0).FontWeight); } [AvaloniaFact] diff --git a/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json index ca4696f..fed28a6 100644 --- a/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json +++ b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json @@ -1 +1 @@ -{"Version":"1.0","Blocks":[{"Inlines":[{"Text":"제목"}],"HeadingLevel":1},{"Inlines":[{"Text":"굵게","Bold":true,"Italic":true,"FontSize":12,"Foreground":"#FFFF0000","Background":"#8012ABCD","FontFamily":"Malgun Gothic","Underline":true,"Strikethrough":true},{"Text":"링크","NavigateUri":"https://example.com/"},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":16,"Height":16,"Alt":"아이콘"}],"TextAlignment":"Center","LineSpacing":2,"MarginTop":4,"MarginBottom":6,"MarginRight":8,"Background":"#FFFFFF00","Indent":20,"IsQuote":true},{"Inlines":[{"Text":"항목"}],"ListType":"Ordered","ListLevel":1},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":120,"Height":80,"Alt":"그림"},{"Type":"Table","ColumnWidths":[100,200],"RowHeights":[30,0],"Cells":[[{"Inlines":[{"Text":"병합"}],"VAlign":"Center"},{"Inlines":[{"Text":""}]}],[{"Type":"Cell","Blocks":[{"Inlines":[{"Text":"a"}]},{"Type":"Divider"},{"Inlines":[{"Text":"b"}]}],"VAlign":"Center"},{"Inlines":[{"Text":"d"}],"VAlign":"Bottom"}]],"ColSpans":[[2,0],[1,1]],"RowSpans":[[1,0],[1,1]]},{"Inlines":[{"Text":"앞"},{"Type":"Table","Table":{"Type":"Table","ColumnWidths":[50],"Cells":[[{"Inlines":[{"Text":"속"}]}]]}},{"Text":"뒤"}]},{"Type":"Divider","MarginBottom":10}],"Images":{"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5":{"Data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==","MimeType":"image/png"}},"PageSetup":{"PageSize":"A4","Orientation":"Landscape","ShowPageBoundaries":true,"Header":"머리","Footer":"꼬리","ShowPageNumbers":true,"MarginLeft":20}} \ No newline at end of file +{"Version":"1.0","Blocks":[{"Inlines":[{"Text":"제목"}],"HeadingLevel":1},{"Inlines":[{"Text":"굵게","Bold":true,"Italic":true,"FontSize":12,"Foreground":"#FFFF0000","Background":"#8012ABCD","FontFamily":"Malgun Gothic","Underline":true,"Strikethrough":true},{"Text":"링크","NavigateUri":"https://example.com/"},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":16,"Height":16,"Alt":"아이콘"}],"TextAlignment":"Center","LineSpacing":2,"MarginTop":4,"MarginBottom":6,"MarginRight":8,"Background":"#FFFFFF00","Indent":20,"IsQuote":true},{"Inlines":[{"Text":"항목"}],"ListType":"Ordered","ListLevel":1},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":120,"Height":80,"Alt":"그림"},{"Type":"Table","ColumnWidths":[100,200],"RowHeights":[30,0],"Cells":[[{"Inlines":[{"Text":"병합"}],"VAlign":"Center"},{"Inlines":[{"Text":""}]}],[{"Type":"Cell","Blocks":[{"Inlines":[{"Text":"a"}]},{"Type":"Divider"},{"Inlines":[{"Text":"b"}]}],"VAlign":"Center"},{"Inlines":[{"Text":"d"}],"VAlign":"Bottom"}]],"ColSpans":[[2,0],[1,1]],"RowSpans":[[1,0],[1,1]]},{"Inlines":[{"Text":"앞"},{"Type":"Table","Table":{"Type":"Table","ColumnWidths":[50],"Cells":[[{"Inlines":[{"Text":"속"}]}]]}},{"Text":"뒤"}]},{"Type":"Divider","MarginBottom":10}],"Images":{"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5":{"Data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==","MimeType":"image/png"}},"PageSetup":{"PageSize":"A4","Orientation":"Landscape","ShowPageBoundaries":true,"Header":"머리","Footer":"꼬리","ShowPageNumbers":true,"MarginLeft":20},"HeadingFormat":1} \ No newline at end of file diff --git a/tests/AvaloniaRichEditor.Tests/HeadingStyleTests.cs b/tests/AvaloniaRichEditor.Tests/HeadingStyleTests.cs new file mode 100644 index 0000000..4901aa0 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/HeadingStyleTests.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using AvaloniaRichEditor.Formatters; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +/// A heading's format lives on its runs (HeadingStyle, 2026-10-01 — the WinUI port's model +/// since 2026-09-13, brought here so one file is one document in both editors). +/// Applying a level writes bold and the level's size onto every run — each time, the same level again +/// included; in between they are ordinary run attributes. Until then the renderer forced both: in an H1 the bold +/// toggle did nothing and 10pt could not be shown, and a port file whose heading text the user had un-bolded +/// came back bold here and lost its marker on save. The contract pinned here is "what the user changes in a +/// heading shows, and survives": undo, save/load, HTML, and the level changes and splits that rewrite a heading's +/// runs. Ported from the port's ControlHeadingStyleTests, plus the split that keeps the heading (a block +/// inserted mid-heading), which only this editor has. +/// +public class HeadingStyleTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private static readonly Type T = typeof(RichEditor); + + private static Run Plain(string t) => new() { Text = t }; + + private static Paragraph Para(int heading, params Inline[] inlines) + { + var p = new Paragraph { HeadingLevel = heading }; + foreach (var i in inlines) p.Inlines.Add(i); + return p; + } + + private static RichEditor Editor(params Paragraph[] paras) + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + foreach (var p in paras) doc.Blocks.Add(p); + return new RichEditor { Document = doc }; + } + + private static List Paras(RichEditor ed) => ed.Document!.Blocks.OfType().ToList(); + + private static void Caret(RichEditor ed, Paragraph p, int offset) + { + foreach (var f in new[] { "_caretPosition", "_selectionStart", "_selectionEnd" }) + T.GetField(f, NP)!.SetValue(ed, new TextPointer(p, offset)); + } + + private static void Select(RichEditor ed, Paragraph p, int a, int b) + { + T.GetField("_selectionStart", NP)!.SetValue(ed, new TextPointer(p, a)); + T.GetField("_selectionEnd", NP)!.SetValue(ed, new TextPointer(p, b)); + T.GetField("_caretPosition", NP)!.SetValue(ed, new TextPointer(p, b)); + } + + private static void Enter(RichEditor ed) + => ed.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter }); + + // The run holding the character AT offset. + private static Run RunAt(Paragraph p, int offset) + { + int pos = 0; + foreach (var inl in p.Inlines) + { + int len = inl is Run r0 ? (r0.Text?.Length ?? 0) : 1; + if (inl is Run r && offset >= pos && offset < pos + len) return r; + pos += len; + } + throw new InvalidOperationException($"no run holds offset {offset}"); + } + + private static bool Bold(Run r) => r.FontWeight == FontWeight.Bold; + + // ---- the reported case ------------------------------------------------------------------------ + + // In a heading, bold can be turned off and 10pt shows — both on the model and in the report (which is + // the renderer's rule). Before, the toggle was a no-op and 10pt drew, and reported, at 20. + [AvaloniaFact] + public void InAHeading_BoldCanBeTurnedOff_AndTenPointShows() + { + var ed = Editor(Para(0, Plain("Title"))); + var p = Paras(ed)[0]; + Caret(ed, p, 2); + ed.SetHeading(1); + Assert.True(Bold(RunAt(p, 0))); // the preset, written once + Assert.Equal(20, RunAt(p, 0).FontSize); + + Select(ed, p, 0, 5); + ed.ToggleBold(); + ed.SetFontSize(10); + Caret(ed, p, 2); + var f = ed.GetCaretFormat(); + Assert.False(f.Bold); + Assert.Equal(10, f.FontSize); + } + + // Applying a heading writes its preset onto every run — a different level, and the SAME level again, which + // is how a heading's look comes back after the user changed its bold or size. In between, the user's + // changes stand. + [AvaloniaTheory] + [InlineData(2, 16.0)] + [InlineData(1, 20.0)] // the same level again + public void ApplyingAHeading_WritesItsPresetOnEveryRun_TheSameLevelToo(int again, double size) + { + var ed = Editor(Para(0, Plain("Title rest"))); + var p = Paras(ed)[0]; + Caret(ed, p, 2); + ed.SetHeading(1); + Select(ed, p, 0, 5); + ed.ToggleBold(); + ed.SetFontSize(12); + Assert.False(Bold(RunAt(p, 0))); // the changes stand... + Assert.Equal(12, RunAt(p, 0).FontSize); + + Caret(ed, p, 7); + ed.SetHeading(again); // ...until the heading is applied again + foreach (int off in new[] { 0, 7 }) + { + Assert.True(Bold(RunAt(p, off)), $"offset {off} is not bold after applying H{again}"); + Assert.Equal(size, RunAt(p, off).FontSize); + } + } + + // Back to body takes the preset off every run, a size the user chose in the heading included — the same + // "applying a style applies its format" rule, the other way. + [AvaloniaFact] + public void BackToBody_TakesThePresetOffEveryRun() + { + var ed = Editor(Para(0, Plain("Title"))); + var p = Paras(ed)[0]; + Caret(ed, p, 2); + ed.SetHeading(1); + Select(ed, p, 0, 2); + ed.SetFontSize(24); + Caret(ed, p, 3); + ed.SetHeading(0); + foreach (int off in new[] { 0, 3 }) + { + Assert.False(Bold(RunAt(p, off))); + Assert.Equal(10, RunAt(p, off).FontSize); + } + } + + // ---- it survives ------------------------------------------------------------------------------ + + // Undo restores a snapshot through the Document property, which is where old documents are converted. + // The flag rides in the snapshot, so the restore does not re-bold text the user un-bolded. + [AvaloniaFact] + public void UndoAndRedo_DoNotReBoldAnUnboldedHeading() + { + var ed = Editor(Para(1, Plain("Title"))); + Select(ed, Paras(ed)[0], 0, 5); + ed.ToggleBold(); + Assert.False(Bold(RunAt(Paras(ed)[0], 1))); + ed.Undo(); + Assert.True(Bold(RunAt(Paras(ed)[0], 1))); + ed.Redo(); + Assert.False(Bold(RunAt(Paras(ed)[0], 1))); + } + + // Twice, because a marker that is written but not read (or read but not written) shows on the SECOND + // load only. + [AvaloniaFact] + public void SaveAndLoad_KeepsAnUnboldedHeading_Twice() + { + var ed = Editor(Para(1, Plain("Title"))); + Select(ed, Paras(ed)[0], 0, 5); + ed.ToggleBold(); + string json = DocumentSerializer.Serialize(ed.Document!); + for (int i = 0; i < 2; i++) + { + var again = new RichEditor { Document = DocumentSerializer.Deserialize(json) }; + var r = RunAt(Paras(again)[0], 1); + Assert.False(Bold(r), $"load {i + 1}: the un-bolded heading came back bold"); + Assert.Equal(20, r.FontSize); + json = DocumentSerializer.Serialize(again.Document!); + } + } + + // A file from before this change has unstyled heading runs that the old renderer drew bold at the heading + // size; opened in the editor it must still look that way. Built from a host model no editor has seen, + // which also checks that such a model is saved WITHOUT the marker — and that the reader leaves it as it is + // (the native formats read back exactly what was saved; the editor converts). + [AvaloniaFact] + public void AnOldFile_OpensWithItsHeadingsBoldAtTheHeadingSize() + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + doc.Blocks.Add(Para(2, Plain("Old title"))); + string json = DocumentSerializer.Serialize(doc); + Assert.DoesNotContain("HeadingFormat", json); + + var loaded = DocumentSerializer.Deserialize(json); + Assert.False(Bold(RunAt(loaded.Blocks.OfType().First(), 1))); + + var ed = new RichEditor { Document = loaded }; + var r = RunAt(Paras(ed)[0], 1); + Assert.True(Bold(r)); + Assert.Equal(16, r.FontSize); + Assert.Contains("\"HeadingFormat\":1", DocumentSerializer.Serialize(ed.Document!)); // converted once, and says so + } + + // HTML:

is bold and heading-sized to every reader, so un-bolded text and a 10pt run inside one must + // say so. Twice, for the same reason as the JSON test. + [AvaloniaFact] + public void HtmlRoundTrip_KeepsUnboldedAndTenPointHeadingText_Twice() + { + var ed = Editor(Para(1, Plain("Title rest"))); + var p = Paras(ed)[0]; + Select(ed, p, 0, 5); + ed.ToggleBold(); + ed.SetFontSize(10); + string html = HtmlDocumentFormatter.ToHtml(ed.Document!); + for (int i = 0; i < 2; i++) + { + var again = new RichEditor { Document = HtmlDocumentFormatter.ParseHtml(html) }; + var hp = Paras(again)[0]; + Assert.Equal(1, hp.HeadingLevel); + Assert.False(Bold(RunAt(hp, 0)), $"pass {i + 1}: un-bolded heading text came back bold"); + Assert.Equal(10, RunAt(hp, 0).FontSize); + Assert.True(Bold(RunAt(hp, 7))); + Assert.Equal(20, RunAt(hp, 7).FontSize); + html = HtmlDocumentFormatter.ToHtml(again.Document!); + } + } + + // RTF has no headings to read back, so only the writer is at stake: it added \b to every heading run. + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public void Rtf_WritesAHeadingRunsBoldAsItIs(bool unbold) + { + var ed = Editor(Para(1, Plain("Title"))); + if (unbold) { Select(ed, Paras(ed)[0], 0, 5); ed.ToggleBold(); } + string rtf = RtfDocumentFormatter.Write(ed.Document!); + var group = Regex.Match(rtf, @"\{([^{}]*)Title").Groups[1].Value; + Assert.Equal(!unbold, Regex.IsMatch(group, @"\\b(?![a-z])")); + } + + // Parsing honours font-weight:normal, which the export above relies on — and which Google Docs puts on + // the it wraps every paste in; everything used to come in bold. + [AvaloniaFact] + public void Html_FontWeightNormal_UnboldsWhatAnEnclosingElementMadeBold() + { + var doc = HtmlDocumentFormatter.ParseHtml("

plain bold

"); + var p = doc.Blocks.OfType().First(); + Assert.False(Bold(RunAt(p, 0))); + Assert.True(Bold(RunAt(p, 6))); + } + + // ---- the edits that rewrite a heading's runs -------------------------------------------------- + + // Enter at a heading's end starts body text; in the middle the tail becomes body text and leaves the + // heading's format behind; at the start the heading keeps its format on the empty run it is left with. + [AvaloniaTheory] + [InlineData(5)] + [InlineData(2)] + public void Enter_InAHeading_TheBodyParagraphIsBodyText(int at) + { + var ed = Editor(Para(1, Plain("Title"))); + Caret(ed, Paras(ed)[0], at); + Enter(ed); + ed.InsertText("x"); + var ps = Paras(ed); + Assert.Equal(0, ps[1].HeadingLevel); + Assert.False(Bold(RunAt(ps[1], 0))); + Assert.Equal(10, RunAt(ps[1], 0).FontSize); + Assert.True(Bold(RunAt(ps[0], 0))); // the heading itself untouched + Assert.Equal(20, RunAt(ps[0], 0).FontSize); + } + + [AvaloniaFact] + public void Enter_AtAHeadingsStart_LeavesAHeadingThatStillTypesAsHeading() + { + var ed = Editor(Para(1, Plain("Title"))); + Caret(ed, Paras(ed)[0], 0); + Enter(ed); + var ps = Paras(ed); + Caret(ed, ps[0], 0); + ed.InsertText("N"); + Assert.True(Bold(RunAt(Paras(ed)[0], 0))); + Assert.Equal(20, RunAt(Paras(ed)[0], 0).FontSize); + } + + // A heading with no text to take a format from types with the heading's preset, as the report says. + [AvaloniaFact] + public void TypingIntoAnEmptyHeading_WritesHeadingText() + { + var ed = Editor(new Paragraph { HeadingLevel = 2 }); + var p = Paras(ed)[0]; + Caret(ed, p, 0); + var f = ed.GetCaretFormat(); + Assert.True(f.Bold); + Assert.Equal(16, f.FontSize); + ed.InsertText("Z"); + Assert.True(Bold(RunAt(Paras(ed)[0], 0))); + Assert.Equal(16, RunAt(Paras(ed)[0], 0).FontSize); + } + + // A block inserted mid-heading splits it, and the tail CONTINUES the heading: it keeps the level, and its + // text the heading's format (the user's un-bolding included). Taking the format off as Enter does, then + // restoring only the level, left a heading drawn as body text. Only this editor keeps the tail a heading. + [AvaloniaFact] + public void ABlockInsertedMidHeading_LeavesTheTailAHeadingInItsOwnFormat() + { + var ed = Editor(Para(1, Plain("Title"))); + var p = Paras(ed)[0]; + Select(ed, p, 3, 5); + ed.ToggleBold(); // "le" un-bolded + Caret(ed, Paras(ed)[0], 3); + ed.InsertDivider(); + var ps = Paras(ed); + var tail = ps[^1]; + Assert.Equal("le", string.Concat(tail.Inlines.OfType().Select(r => r.Text))); + Assert.Equal(1, tail.HeadingLevel); + Assert.False(Bold(RunAt(tail, 0))); // as the user left it + Assert.Equal(20, RunAt(tail, 0).FontSize); + Assert.True(Bold(RunAt(ps[0], 0))); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/RichEditorHeadingTests.cs b/tests/AvaloniaRichEditor.Tests/RichEditorHeadingTests.cs index 4765267..43969c9 100644 --- a/tests/AvaloniaRichEditor.Tests/RichEditorHeadingTests.cs +++ b/tests/AvaloniaRichEditor.Tests/RichEditorHeadingTests.cs @@ -21,10 +21,11 @@ private static RichEditor EditorWithCaretIn(Paragraph p) } [AvaloniaFact] - public void SetHeading_ResetsRunSizesToBodyDefault_ButNotWeights() + public void SetHeading_WritesTheHeadingsFormatOntoEveryRun() { - // Like applying a Word style: a size baked into the runs would pin the text, so the heading - // size (applied at layout to body-default runs) would never show. Weight is left alone. + // Like applying a Word style: every run gets the heading's look — bold and the level's size — + // whatever it had (HeadingStyle; it used to reset sizes to the body default for the renderer to + // enlarge, and leave weights to a renderer that forced bold). var p = TestHelpers.Para( new Run { Text = "a", FontSize = 14 }, new Run { Text = "big", FontSize = 30 }); @@ -33,9 +34,9 @@ public void SetHeading_ResetsRunSizesToBodyDefault_ButNotWeights() ed.SetHeading(1); Assert.Equal(1, p.HeadingLevel); - Assert.Equal(10, ((Run)p.Inlines[0]).FontSize, 3); - Assert.Equal(10, ((Run)p.Inlines[1]).FontSize, 3); - Assert.Equal(FontWeight.Normal, ((Run)p.Inlines[0]).FontWeight); + Assert.Equal(20, ((Run)p.Inlines[0]).FontSize, 3); + Assert.Equal(20, ((Run)p.Inlines[1]).FontSize, 3); + Assert.Equal(FontWeight.Bold, ((Run)p.Inlines[0]).FontWeight); } [AvaloniaFact]