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
44 changes: 42 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,54 @@ The margins were two constants nothing could reach: a host could pick the paper
write on, and an RTF from Word or HWP had its own margins dropped, so a document opened here paginated
differently from what its author saw.

- `RichEditor.PageMargin` (a `Thickness`, DIPs, four sides) and `PageSetup.Margin`, defaulting to
`PageSetup.DefaultMargin` — 48 left and right, 40 top and bottom, the previous constants.
- `RichEditor.PageMargin` and `PageSetup.Margin`, four sides in **millimetres** (`PageMargins`), defaulting
to `PageSetup.DefaultMargin` — 15 mm on every side. Millimetres because that is the unit a page is discussed in: paper sizes
are defined in mm, Word and HWP show margins in mm, and RTF carries them as physical length. A dedicated
type rather than Avalonia's `Thickness`, which means device pixels everywhere else in a UI framework.
- They belong to the document: saved in JSON/`.flow` (omitted at the default, so a document that never
touched them keeps its bytes) and applied on load, like the paper size.
- RTF writes them (`\margl`/`\margr`/`\margt`/`\margb`, and the section-level pair HWP reads) and now
**reads** them, so a file from another word processor keeps its own margins.
- A margin that would leave no page to write on — negative, NaN, or two sides adding up past the paper — is
refused: the property keeps its last usable value, and a file falls back to the default.
- A margin band too thin for the header, footer or page number leaves it undrawn, rather than centring the
line half off the paper and half over the body text. The margins stay exactly what was asked for.
- The toolbar's page controls gained a **margin picker** beside paper and orientation, so the person using
an app built on `RichEditorView` can change them too: five steps in millimetres (5 / 10 / 15 / 20 / 30)
in a box built like the line-spacing control — the margin icon, the current step, a chevron for the list. Margins that match no preset (a host's, or a document's) select nothing rather
than showing one that is not the page's. New icon slot `RichEditorIcon.PageMargin`.

### Changed — a table, picture or divider sits one line gap below the text above it (2026-09-21)

Reported from the demo: a table sat flush against the paragraph above, with none of the air the line
spacing gives between two lines of text. Both ends were at zero — paragraphs carry no bottom margin
(HWP-style) and these blocks carried no top one.

- `Block.AutoTopMargin` (NaN) is the new default `MarginTop` for `TableBlock`, `ImageBlock` and
`DividerBlock`: the editor resolves it to one line gap of body text, so it follows the document's font
size and line spacing. A stated margin, **0 included**, is used as given.
- JSON writes nothing for it and reads a missing top margin back as auto, so a file that never expressed
an opinion — every file written before the field existed — gains the gap.

### Fixed — a picture's own outline was painting over its edge (2026-09-23)

Reported from the demo: at a high zoom, a picture looked cut by a pixel or two. Nothing clipped it — a
picture carries a faint outline marking it as an object, and a pen is centred on the rect it strokes, so
half of that line lay on the picture and replaced its outermost half-pen on every side. The bold border of
a selected picture, and the one around a picture inside a table cell or a selected inline icon, did the
same with twice the weight. All of them now sit half a pen outside the picture, touching none of its
pixels. (A table's borders go the other way — see below — because there the line is the table's own ink.)
That moved the top line of a picture opening a page just above the page's content clip, which cut it off;
the outline, selection border and handles are now drawn after the page's content, bounded by the paper.

### Fixed — a table's outline was cut where a page break crossed it (2026-09-20)

Reported from the demo. A 1px pen is centred on the rect it strokes, so a cell on the table's edge put half
its line outside the table's own box; a page break lands on that box, and the page's clip cut the line in
two — part of its weight at the bottom of one page, the rest at the top of the next (measured: 67% of a
whole line). The edges that are the table's boundary are now drawn half a pen inwards, which keeps a
table's ink inside the box pagination knows about — and lands those lines on whole pixels, so they come out
crisper as well. Interior borders, shared by two cells, are unchanged.

### Added — the keyboard shortcut table is public (2026-09-20)

Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Editor.FontFamilyChoices = new[] { "Segoe UI", "Arial", "맑은 고딕" }; //

- 인라인 및 블록 **이미지** — 삽입, 크기 조절(모서리 또는 가로·세로 한 변 손잡이), 교체, 저장, 대체 텍스트
- 워드 스타일 **페이지 뷰**: `PageSize`(기본 Continuous, 또는 A4/A3/A5/B4/B5/Letter/Legal/Tabloid),
`PageOrientation`, `PageMargin`(네 변), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
`PageOrientation`, `PageMargin`(네 변, mm), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
- 페이지 설정은 **문서 단위로 저장**되고(`FlowDocument.PageSetup`) 불러올 때 다시 적용됩니다 —
워드프로세서와 같습니다
- **인쇄 및 PDF**: 페이지별 렌더링(`RenderPrintPage`, 300 DPI)과 글자를 선택·검색할 수 있는 PDF
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ for a full editor host.

- Inline and block **images** — insert, resize (corner or single-edge handles), replace, save, alt text
- Word-style **page view**: `PageSize` (Continuous by default, or A4/A3/A5/B4/B5/Letter/Legal/Tabloid),
`PageOrientation`, `PageMargin` (four sides), `ShowPageBoundaries`, line-boundary page breaks,
`PageOrientation`, `PageMargin` (four sides, in mm), `ShowPageBoundaries`, line-boundary page breaks,
headers/footers/page numbers
- Page setup is **persisted per document** (`FlowDocument.PageSetup`) and re-applied on load, like a word
processor
Expand Down
11 changes: 6 additions & 5 deletions docs/DOCUMENT_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,16 @@ FlowDocument
"Header": null, // 머리글 텍스트(없으면 생략)
"Footer": null, // 바닥글 텍스트(없으면 생략)
"ShowPageNumbers": false,
"MarginLeft": 96, // 페이지 여백 px(DIP), 변마다 하나. 기본값이면 생략
"MarginTop": 80, // 기본 좌우 48 · 상하 40
"MarginRight": 96,
"MarginBottom": 80
"MarginLeft": 25, // 페이지 여백 **mm**, 변마다 하나. 기본값이면 생략
"MarginTop": 20, // 기본 좌우 12.7mm(=0.5인치) · 상하 10.6mm
"MarginRight": 25,
"MarginBottom": 20
}
}
```

- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 변마다 하나이며 **기본값(좌우 48 · 상하 40)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다.
- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 단위는 **밀리미터**다 — 용지 크기가 mm로 정의되고 Word·아래한글도 mm로 보여 주며, RTF는 물리 길이(twips)로 싣는다(렌더는 96dpi DIP로 환산: 1mm = 96/25.4 ≈ 3.7795px). 변마다 하나이며 **기본값(좌우 12.7 · 상하 10.6mm)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다.
- RTF는 정수 twips라 mm가 정확히 왕복되지 않는다(25mm → 1417 → 24.994). 오차는 최대 1 twip(≈0.018mm).
- **`PageSetup`(선택)**: 워드프로세서식 페이지 설정. 로드 시 에디터의 용지/방향/머리글·바닥글/쪽번호 속성에 적용되고, 이후 페이지 속성을 바꾸면 문서로 다시 캡처된다. **기본 상태(용지 `Continuous`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음.

#### 버전 이력
Expand Down
12 changes: 11 additions & 1 deletion samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@
behaviour (right-click Copy on a table, the table-border click) can only be checked in a viewer. -->
<DockPanel>
<Border DockPanel.Dock="Top" Padding="8,2" BorderThickness="0,0,0,1" BorderBrush="#DDDDDD">
<CheckBox x:Name="ReadOnlyToggle" Content="읽기 전용 (IsReadOnly)"/>
<StackPanel Orientation="Horizontal" Spacing="16">
<CheckBox x:Name="ReadOnlyToggle" Content="읽기 전용 (IsReadOnly)"/>
<!-- The view's toolbar has paper and orientation but no margins; without this the margin
bands (header/footer/page numbers, print) could not be checked in the demo at all. -->
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="여백 (PageMargin)" VerticalAlignment="Center"/>
<!-- SelectedIndex is set in code, AFTER ItemsSource: setting it here runs first and the
box comes up blank. -->
<ComboBox x:Name="MarginPicker" MinWidth="220"/>
</StackPanel>
</StackPanel>
</Border>
<controls:RichEditorView x:Name="EditorView"/>
</DockPanel>
Expand Down
21 changes: 21 additions & 0 deletions samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,29 @@ public MainWindow()
Editor.IsReadOnly = ReadOnlyToggle.IsChecked == true;
Editor.ShowFormattingMenu = !Editor.IsReadOnly;
};

// Page margins (1.3.0). The view's toolbar has paper and orientation but no margins, and the
// margin band is where the header, the footer and the page number are drawn — the asymmetric
// preset is the one that tells "four sides" apart from "two", on screen and in print.
MarginPicker.ItemsSource = MarginPresets.ConvertAll(p => p.Label);
MarginPicker.SelectedIndex = 0;
MarginPicker.SelectionChanged += (_, _) =>
{
int i = MarginPicker.SelectedIndex;
if (i >= 0 && i < MarginPresets.Count) Editor.PageMargin = MarginPresets[i].Margin;
};
}

// Millimetres, like the toolbar's own picker — these exist to reach shapes the toolbar's five steps
// do not: a band too thin for the header, and an asymmetric one (which tells "four sides" from "two").
private static readonly System.Collections.Generic.List<(string Label, AvaloniaRichEditor.Documents.PageMargins Margin)> MarginPresets =
[
("기본 12.7 / 10.6mm", AvaloniaRichEditor.Documents.PageSetup.DefaultMargin),
("아주 좁게 4 / 3mm", new AvaloniaRichEditor.Documents.PageMargins(4, 3, 4, 3)),
("아주 넓게 40 / 30mm", new AvaloniaRichEditor.Documents.PageMargins(40, 30, 40, 30)),
("비대칭 좌42 상6 우8 하31mm", new AvaloniaRichEditor.Documents.PageMargins(42, 6, 8, 31)),
];

protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Expand Down
28 changes: 23 additions & 5 deletions src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public partial class RichEditor
double yOffset = 0, maxWidth = ContentLayoutWidth;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out var ft, out var tl);
yOffset += h + block.MarginBottom;
Expand Down Expand Up @@ -260,6 +260,24 @@ private static TableLayout AssembleTableLayout(TableBlock tb, double[] colX, dou
return new TableLayout(colX, rowY, colX[cols] - startX, rowY[rows] - top, anchors);
}

// The gap above a block, which every walker advances by before the block's own box starts.
//
// Normally the block's MarginTop. A table whose margin is unset (NaN — TableBlock.AutoMarginTop) gets
// ONE LINE GAP instead: the white space a line break leaves between two lines of body text. Without it
// a table butts straight against the paragraph above, because paragraphs carry no bottom margin
// (HWP-style, round 24) and a table carried no top one, so the two met at zero (reported from the
// demo, 2026-09-21). An explicit margin, including 0, is used as given.
//
// Sized from the DOCUMENT's default typography rather than from the block above: every walker below
// iterates blocks without tracking a predecessor, and several `continue` past culled ones, so a
// "previous block" would go stale in exactly the walkers that skip — the drift class G1/G2 cleaned up.
// The cost is that a table under a big heading gets a body-text gap, not a heading-sized one.
internal double TopGapOf(Block block)
=> double.IsNaN(block.MarginTop) ? AutoBlockTopGap : block.MarginTop;

// One line gap of body text: the line box (font size x spacing) minus the text it holds.
internal double AutoBlockTopGap => Math.Max(0, PtToPx(DefaultFontSize) * (DefaultLineSpacing - 1));

// G1 — single source of a block's vertical extent (height, EXCLUDING MarginTop/MarginBottom) at the
// given top, plus the layout objects the walkers reuse (a paragraph's TextLayout / a table's
// TableLayout; null otherwise). Every read-only document walk — measure, hit-tests, block-at-y —
Expand Down Expand Up @@ -308,7 +326,7 @@ private double BlockExtent(Block block, double maxWidth, double top,
double yOffset = 0, listIndent = 10, maxWidth = ContentLayoutWidth;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out _, out var tl);
yOffset += h + block.MarginBottom;
Expand All @@ -332,7 +350,7 @@ private double BlockExtent(Block block, double maxWidth, double top,
double yOffset = 0, maxWidth = ContentLayoutWidth;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out _, out var tl);
if (block == target && tl is { } t) return (top, t);
Expand Down Expand Up @@ -381,7 +399,7 @@ private bool IsOnTableLeftOrTopBorder(TableBlock tb, Point p)
const double m = 4;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out _, out var tl);
yOffset += h + block.MarginBottom;
Expand Down Expand Up @@ -662,7 +680,7 @@ private TextPointer GetPositionFromPoint(Point p)

foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out var ft, out var tl);
yOffset += h + block.MarginBottom;
Expand Down
4 changes: 2 additions & 2 deletions src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1803,7 +1803,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow)
bool found = false;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
if (found) return yOffset;
double h = BlockExtent(block, maxWidth, yOffset, out _, out _);
found = ReferenceEquals(block, p);
Expand All @@ -1818,7 +1818,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow)
double yOffset = 0, maxWidth = ContentLayoutWidth;
foreach (var block in Document.Blocks)
{
yOffset += block.MarginTop;
yOffset += TopGapOf(block);
double top = yOffset;
double h = BlockExtent(block, maxWidth, top, out _, out _);
// Only image/table blocks are "entered" by Up/Down arrow navigation.
Expand Down
Loading
Loading