diff --git a/CHANGELOG.md b/CHANGELOG.md
index 566ff3a..767c56f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/README.ko.md b/README.ko.md
index fc47bc3..bf7d074 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -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
diff --git a/README.md b/README.md
index 44dd891..405ccf4 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md
index 2f76c35..98a9597 100644
--- a/docs/DOCUMENT_FORMAT.md
+++ b/docs/DOCUMENT_FORMAT.md
@@ -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`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음.
#### 버전 이력
diff --git a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml
index 321c1c4..d8205cc 100644
--- a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml
+++ b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml
@@ -15,7 +15,17 @@
behaviour (right-click Copy on a table, the table-border click) can only be checked in a viewer. -->
-
+
+
+
+
+
+
+
+
+
diff --git a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs
index da24159..fb6cdd6 100644
--- a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs
+++ b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs
@@ -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);
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
index a82e49e..9caf589 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs
@@ -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;
@@ -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 —
@@ -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;
@@ -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);
@@ -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;
@@ -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;
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
index 0dfde2d..dfa13e0 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
@@ -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);
@@ -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.
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
index 72b1db3..cfb11da 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
@@ -116,17 +116,17 @@ private void CapturePageSetupToDocument()
// across paper sizes.
// Aliases of the shared page geometry (Documents.PageSetup): the RTF writer needs the same numbers
// for its footer tab stop and cannot read them off a control type without dragging the control in.
- /// The page margins in DIPs — the band between the paper's edge and the text, where the
- /// header, footer and page number are drawn. Four sides, as Word, HWP and RTF have them; it is part of
- /// the document's , so it is saved with the document and applied on
+ /// The page margins in MILLIMETRES — the band between the paper's edge and the text, where
+ /// the header, footer and page number are drawn. Four sides, as Word, HWP and RTF have them; it is part
+ /// of the document's , so it is saved with the document and applied on
/// load. Only meaningful for a concrete paper size (Continuous reflows to the control's width).
- public static readonly StyledProperty PageMarginProperty =
- AvaloniaProperty.Register(nameof(PageMargin), Documents.PageSetup.DefaultMargin,
+ public static readonly StyledProperty PageMarginProperty =
+ AvaloniaProperty.Register(nameof(PageMargin), Documents.PageSetup.DefaultMargin,
coerce: CoercePageMargin);
- /// Gets or sets the page margins (DIPs, four sides). Defaults to
+ /// Gets or sets the page margins (millimetres, four sides). Defaults to
/// .
- public Thickness PageMargin
+ public PageMargins PageMargin
{
get => GetValue(PageMarginProperty);
set => SetValue(PageMarginProperty, value);
@@ -136,17 +136,19 @@ public Thickness PageMargin
// the layout width zero or negative, and every page-view measurement divides by it. A styled property
// is reachable from XAML and from a binding, so the value is refused HERE rather than guarded at each
// of the dozen places that read it; the editor keeps the last usable margins.
- private static Thickness CoercePageMargin(AvaloniaObject o, Thickness value)
+ private static PageMargins CoercePageMargin(AvaloniaObject o, PageMargins value)
{
var ed = (RichEditor)o;
- var (w, h) = Documents.PageSetup.PaperDips(ed.PageSize, ed.PageOrientation);
+ var (w, h) = Documents.PageSetup.PaperMillimetres(ed.PageSize, ed.PageOrientation);
return Documents.PageSetup.IsUsableMargin(value, w, h) ? value : ed.PageMargin;
}
- internal double PagePadLeft => PageMargin.Left;
- internal double PagePadRight => PageMargin.Right;
- internal double PagePadTop => PageMargin.Top;
- internal double PagePadBottom => PageMargin.Bottom;
+ // Layout is in DIPs; the page setting is in millimetres. Every reader of the margins goes through
+ // these four, so the conversion lives in one place.
+ internal double PagePadLeft => PageMargin.LeftDips;
+ internal double PagePadRight => PageMargin.RightDips;
+ internal double PagePadTop => PageMargin.TopDips;
+ internal double PagePadBottom => PageMargin.BottomDips;
// Grey-desk gap above the first page and between consecutive pages in page-outline view. Kept thin
// (~2 pt) so pages sit close together with just a sliver of desk between them, rather than a wide
// grey band. The whole page-stack layout (MeasureOverride height, PageRectView, MapViewToDoc) is
@@ -258,6 +260,13 @@ public bool ShowPageNumbers
// Header/footer/page number, drawn inside the paper's margin bands (never the content box, so
// pagination is unaffected). `paper` is the page rect in the caller's coordinate space — the
// page-view loop passes view coordinates, RenderPrintPage passes the page at the origin.
+ //
+ // A band too thin to hold the line is left empty (user decision, 2026-09-20). The band was a constant
+ // 40 until the margins became a document setting, so the line always fitted; at a 12 DIP band an 11pt
+ // line is centred from -1 to 13 — drawn off the paper onto the desk at one end and over the body text
+ // at the other (seen in the demo). Skipping keeps the margins EXACTLY what was asked for, which
+ // pushing the body down (Word's answer) would not, and a header that is not there is visible enough to
+ // undo; centring inside a band that fits also keeps the line on the paper by construction.
private void DrawPageMarginChrome(DrawingContext ctx, Rect paper, int pageIndex, int pageCount)
{
var typeface = new Avalonia.Media.Typeface(DefaultFontFamily);
@@ -265,6 +274,8 @@ void DrawSmall(string text, bool top, bool right)
{
var ft = new Avalonia.Media.FormattedText(text, System.Globalization.CultureInfo.CurrentCulture,
Avalonia.Media.FlowDirection.LeftToRight, typeface, 11, Avalonia.Media.Brushes.Gray);
+ double band = top ? PagePadTop : PagePadBottom;
+ if (ft.Height > band) return;
double x = right ? paper.X + PagePadLeft + PaperContentWidth - ft.Width : paper.X + PagePadLeft;
double bandCenter = top ? paper.Y + PagePadTop / 2 : paper.Bottom - PagePadBottom / 2;
ctx.DrawText(ft, new Point(x, bandCenter - ft.Height / 2));
@@ -521,7 +532,7 @@ void PlaceAtom(double height)
foreach (var block in Document.Blocks)
{
- y += block.MarginTop;
+ y += TopGapOf(block);
// Block height + layout objects come from the single source (G1 BlockExtent), so the
// vertical advance here can never drift from MeasureContentHeight / the hit-tests. Only the
// *within-block* atom split (table rows, paragraph lines) is pagination-specific and stays.
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
index 61d837f..36846ec 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
@@ -43,7 +43,7 @@ private double MeasureContentHeight(double width)
double yOffset = 0;
foreach (var block in Document.Blocks)
{
- yOffset += block.MarginTop;
+ yOffset += TopGapOf(block);
double h = BlockExtent(block, width, yOffset, out _, out _);
// While the IME composes, the render walk advances by the caret paragraph's height WITH the
// preedit spliced in, so the extent has to as well — otherwise the scrollable range stays a
@@ -187,6 +187,7 @@ public override void Render(DrawingContext context)
{
// Continuous (Free): one walk at the control width.
(caretPoint, caretHeight, blockCaretRect) = DrawDocumentBlocks(context, Bounds.Width, visTop, visBottom);
+ FlushPictureChrome(context);
}
else if (!ShowPageBoundaries)
{
@@ -216,6 +217,11 @@ public override void Render(DrawingContext context)
if (cp != null) { caretPoint = cp; caretHeight = ch; }
if (bcr != null) blockCaretRect = bcr;
}
+ // No paper to bound it here: the page's own column, widened into half the gap on each side.
+ using (context.PushClip(new Rect(NoChromeColX - 4, viewTop - NoChromePageGap / 2,
+ PaperContentWidth + 8, clipH + NoChromePageGap)))
+ using (context.PushTransform(Matrix.CreateTranslation(NoChromeColX, viewTop - sliceTop)))
+ FlushPictureChrome(context);
}
}
else
@@ -253,6 +259,9 @@ public override void Render(DrawingContext context)
if (cp != null) { caretPoint = cp; caretHeight = ch; }
if (bcr != null) blockCaretRect = bcr;
}
+ using (context.PushClip(paper))
+ using (context.PushTransform(Matrix.CreateTranslation(dx, contentBox.Y - sliceTop)))
+ FlushPictureChrome(context);
}
}
@@ -270,6 +279,43 @@ public override void Render(DrawingContext context)
// The document block walk: selection highlights, table grids, paragraphs, images, dividers,
// resize-handle registration and caret geometry, all in continuous document coordinates.
+ // A 1px pen is centred on the rect it strokes, so a cell on the table's edge puts half its line OUTSIDE
+ // the table's own box. A page break lands exactly on that box, and the page's clip then cut the line in
+ // two — part of its weight on one page, the rest on the next (measured 2026-09-20: 67% of a whole line;
+ // reported from the demo). Pulling the edges that ARE the table's boundary half a pen inwards keeps all
+ // of a table's ink inside the box pagination knows about, and lands those lines on whole pixels, so they
+ // also come out crisper. Interior edges are shared by two neighbouring cells and stay centred —
+ // insetting those would draw each shared line twice, a pixel apart.
+ // The rect to stroke so a pen of this thickness lands just OUTSIDE box, touching none of its pixels.
+ // A pen is centred on the rect it strokes, so an outline drawn on a picture's own rect paints over the
+ // picture's outermost half-pen on every side — reported from the demo at a high zoom (2026-09-23) as the
+ // picture being "cut by a pixel or two". A table's borders go the other way (InsetTableEdges): there the
+ // line IS the table's own ink and has to stay inside the box pagination knows about, while a picture's
+ // outline is a marker drawn around content that must survive intact.
+ private static Rect Around(Rect box, double thickness)
+ => box.Inflate(thickness / 2);
+
+ // Picture outlines, selection borders and handles queued by a block walk, in its own coordinates. Drawn
+ // after it under a looser clip than the content's, because they lie OUTSIDE the picture — and a picture
+ // opening a page sits exactly on the page's content clip, which cut its top border off (2026-09-23).
+ private readonly List> _pictureChrome = new();
+
+ private void FlushPictureChrome(DrawingContext context)
+ {
+ foreach (var draw in _pictureChrome) draw(context);
+ _pictureChrome.Clear();
+ }
+
+ private static Rect InsetTableEdges(Rect cell, Rect table)
+ {
+ const double half = 0.5, eps = 0.01;
+ double left = cell.X + (Math.Abs(cell.X - table.X) < eps ? half : 0);
+ double top = cell.Y + (Math.Abs(cell.Y - table.Y) < eps ? half : 0);
+ double right = cell.Right - (Math.Abs(cell.Right - table.Right) < eps ? half : 0);
+ double bottom = cell.Bottom - (Math.Abs(cell.Bottom - table.Bottom) < eps ? half : 0);
+ return new Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top));
+ }
+
// Page view replays this once per visible page under a clip+translation, with the page's
// document slice as the cull window; the continuous mode calls it once with the viewport.
// chrome=false (print/export rendering) draws content only: no selection highlights, caret
@@ -315,7 +361,7 @@ public override void Render(DrawingContext context)
foreach (var block in Document!.Blocks)
{
- yOffset += block.MarginTop;
+ yOffset += TopGapOf(block);
// G1 P5: source each block's height + layout objects from the single BlockExtent pass — the
// same one measure/hit-tests/pagination consume — so the render walk can never drift from
// them on a block's height. Drawing, culling, caret/selection and the per-cell IME-preedit
@@ -346,6 +392,8 @@ public override void Render(DrawingContext context)
// (Excel/Word style) instead of the linear text run; otherwise fall back to text highlight.
var cellBlock = chrome ? SelectedCellRange(tb) : null;
+ var tableBox = new Rect(startX, tableTop, tl.TableWidth, tl.TotalHeight);
+
foreach (var (r, c, rect) in tl.AnchorRects)
{
var cell = tb.Cells[r][c];
@@ -353,7 +401,7 @@ public override void Render(DrawingContext context)
if (cell.Background != null)
context.FillRectangle(cell.Background, rect);
- context.DrawRectangle(null, GrayBorderPen, rect);
+ context.DrawRectangle(null, GrayBorderPen, InsetTableEdges(rect, tableBox));
// A cell is in "cell-selection mode" when it's part of a multi-cell drag block, or its
// whole content is selected (Tab focus / triple-click). Such cells show a fill and NO
@@ -520,26 +568,34 @@ public override void Render(DrawingContext context)
if (chrome)
{
bool imgSelected = ReferenceEquals(img, _selectedBlock);
- if (imgSelected)
+ // Selection: translucent overlay (inside the picture, so the page clip suits it).
+ if (imgSelected) context.FillRectangle(AccentFill60, imgRect);
+ // The outline, selection border and handles lie AROUND the picture, and a picture
+ // that opens a page sits right on the page's clip — which cut off its top border
+ // (reported from the demo, 2026-09-23). They are drawn after the page's content, under
+ // the paper's clip instead (DeferPictureChrome). Only from the replay whose slice holds
+ // the picture: a selected one is drawn by every page's replay, clipped away elsewhere.
+ if (imgRect.Bottom > visTop && imgRect.Top < visBottom)
{
- // Selection: translucent overlay + bold border.
- context.FillRectangle(AccentFill60, imgRect);
- context.DrawRectangle(null, AccentPen2, imgRect);
+ var handles = imgSelected ? PictureHandles(imgRect, 12).Select(h => h.knob).ToArray() : null;
+ _pictureChrome.Add(ctx =>
+ {
+ if (handles != null) ctx.DrawRectangle(null, AccentPen2, Around(imgRect, 2));
+ // A faint outline marks the picture as an object at all times; the resize
+ // handle appears only once it is SELECTED, the way Word and HWP do it. An
+ // always-on handle put a solid accent square on every picture — in a read-only
+ // viewer that cannot resize anything, and in any screenshot of the document.
+ ctx.DrawRectangle(null, AccentBorderPen, Around(imgRect, 1));
+ if (handles != null)
+ foreach (var knob in handles) ctx.FillRectangle(AccentHandleFill, knob);
+ });
}
- // A faint outline marks the picture as an object at all times; the resize handle
- // appears only once it is SELECTED, the way Word and HWP do it. An always-on
- // handle put a solid accent square on every picture — in a read-only viewer that
- // cannot resize anything, and in any screenshot of the document.
- context.DrawRectangle(null, AccentBorderPen, imgRect);
if (imgSelected)
{
// Registered with the drawn handles so there is never a grabbable area with
// nothing under the pointer to explain it. Slightly larger for easy grabbing.
- foreach (var (knob, grab, grip) in PictureHandles(imgRect, 12))
- {
- context.FillRectangle(AccentHandleFill, knob);
+ foreach (var (_, grab, grip) in PictureHandles(imgRect, 12))
_imageHandles.Add((grab, img, width, height, grip));
- }
}
}
@@ -679,9 +735,9 @@ private void DrawCellBlockList(
if (ReferenceEquals(cimg, _selectedBlock))
{
context.FillRectangle(AccentFill60, ir);
- context.DrawRectangle(null, AccentPen2, ir);
+ context.DrawRectangle(null, AccentPen2, Around(ir, 2));
}
- context.DrawRectangle(null, AccentBorderPen, ir);
+ context.DrawRectangle(null, AccentBorderPen, Around(ir, 1));
if (ReferenceEquals(cimg, _selectedBlock)) // handle on selection only
{
foreach (var (knob, grab, grip) in PictureHandles(ir, 12))
@@ -840,7 +896,7 @@ private void RegisterInlineImages(DrawingContext context, Paragraph p, TextLayou
_inlineImageRects.Add((ir, p, ii));
if (_selectedInline is { } sel && ReferenceEquals(sel.img, ii))
{
- context.DrawRectangle(null, AccentPen2, ir);
+ context.DrawRectangle(null, AccentPen2, Around(ir, 2));
foreach (var (knob, grab, grip) in PictureHandles(ir, 10))
{
context.FillRectangle(Brushes.White, knob);
diff --git a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
index 67d6b34..c7cd500 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs
@@ -115,6 +115,8 @@ public enum RichEditorIcon
FontSizeDecrease,
/// Find / replace (the toolbar's find button, from the WinUI port).
Find,
+ /// Page margins (the toolbar's margin picker).
+ PageMargin,
}
///
diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
index 98269cd..78b4f4a 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs
@@ -6,6 +6,7 @@
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
+using AvaloniaRichEditor.Documents;
using AvaloniaRichEditor.Formatters;
namespace AvaloniaRichEditor.Controls;
@@ -60,6 +61,9 @@ public ToolbarLevel ToolbarLevel
// ---- page / zoom ------------------------------------------------------
private ComboBox? _zoomCombo, _paperCombo, _orientCombo;
+ private Border? _marginBox;
+ private TextBlock? _marginLabel;
+ private readonly System.Collections.Generic.List