diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9eb58cd..566ff3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added — page margins (2026-09-20)
+
+The margins were two constants nothing could reach: a host could pick the paper but not how much of it to
+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.
+- 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.
+
### Added — the keyboard shortcut table is public (2026-09-20)
A host building its own toolbar or menu could not read the gestures the editor acts on, so it had to write
diff --git a/README.ko.md b/README.ko.md
index 7b12073..fc47bc3 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`, `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
+ `PageOrientation`, `PageMargin`(네 변), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
- 페이지 설정은 **문서 단위로 저장**되고(`FlowDocument.PageSetup`) 불러올 때 다시 적용됩니다 —
워드프로세서와 같습니다
- **인쇄 및 PDF**: 페이지별 렌더링(`RenderPrintPage`, 300 DPI)과 글자를 선택·검색할 수 있는 PDF
diff --git a/README.md b/README.md
index 7428588..44dd891 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,8 @@ 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`, `ShowPageBoundaries`, line-boundary page breaks, headers/footers/page numbers
+ `PageOrientation`, `PageMargin` (four sides), `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
- **Print and PDF**: per-page rendering (`RenderPrintPage`, 300 DPI) and PDF export (`SavePdf`) with
diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md
index 6e205fc..2f76c35 100644
--- a/docs/DOCUMENT_FORMAT.md
+++ b/docs/DOCUMENT_FORMAT.md
@@ -63,11 +63,16 @@ FlowDocument
"ShowPageBoundaries": true,
"Header": null, // 머리글 텍스트(없으면 생략)
"Footer": null, // 바닥글 텍스트(없으면 생략)
- "ShowPageNumbers": false
+ "ShowPageNumbers": false,
+ "MarginLeft": 96, // 페이지 여백 px(DIP), 변마다 하나. 기본값이면 생략
+ "MarginTop": 80, // 기본 좌우 48 · 상하 40
+ "MarginRight": 96,
+ "MarginBottom": 80
}
}
```
+- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 변마다 하나이며 **기본값(좌우 48 · 상하 40)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다.
- **`PageSetup`(선택)**: 워드프로세서식 페이지 설정. 로드 시 에디터의 용지/방향/머리글·바닥글/쪽번호 속성에 적용되고, 이후 페이지 속성을 바꾸면 문서로 다시 캡처된다. **기본 상태(용지 `Continuous`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음.
#### 버전 이력
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
index acd3b91..72b1db3 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
@@ -49,6 +49,7 @@ private void RecordHostPageSetup(AvaloniaProperty changed)
else if (changed == PageHeaderProperty) _hostPageSetup.Header = PageHeader;
else if (changed == PageFooterProperty) _hostPageSetup.Footer = PageFooter;
else if (changed == ShowPageNumbersProperty) _hostPageSetup.ShowPageNumbers = ShowPageNumbers;
+ else if (changed == PageMarginProperty) _hostPageSetup.Margin = PageMargin;
}
// On Document change: a document that specifies a PageSetup drives the control's page properties
@@ -78,6 +79,7 @@ private void ApplyPageSetup(PageSetup ps)
PageHeader = ps.Header;
PageFooter = ps.Footer;
ShowPageNumbers = ps.ShowPageNumbers;
+ PageMargin = ps.Margin;
}
finally { _syncingPageSetup = false; }
}
@@ -98,6 +100,7 @@ private void CapturePageSetupToDocument()
Header = PageHeader,
Footer = PageFooter,
ShowPageNumbers = ShowPageNumbers,
+ Margin = PageMargin,
};
// Null — "no setup", read back as the HOST's — only when the host's defaults are plain too. Under a host
// that defaults to A4, a document switched to Continuous stored null, saved without a setup and reopened as
@@ -113,15 +116,45 @@ 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.
- internal const double PagePadX = Documents.PageSetup.MarginX;
- internal const double PagePadY = Documents.PageSetup.MarginY;
+ /// 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
+ /// 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,
+ coerce: CoercePageMargin);
+
+ /// Gets or sets the page margins (DIPs, four sides). Defaults to
+ /// .
+ public Thickness PageMargin
+ {
+ get => GetValue(PageMarginProperty);
+ set => SetValue(PageMarginProperty, value);
+ }
+
+ // A margin that leaves no content box (negative, NaN, or two sides swallowing the paper) would make
+ // 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)
+ {
+ var ed = (RichEditor)o;
+ var (w, h) = Documents.PageSetup.PaperDips(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;
// 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
// derived from this one constant, so changing it stays consistent.
internal const double PageGap = 3;
- internal const double A4ContentWidth = A4PageWidth - 2 * PagePadX; // 698
- internal const double A4ContentHeight = A4PageHeight - 2 * PagePadY; // 1043
+ // Instance properties since the margins became a document setting: 698 x 1043 at the default margins.
+ internal double A4ContentWidth => A4PageWidth - PagePadLeft - PagePadRight;
+ internal double A4ContentHeight => A4PageHeight - PagePadTop - PagePadBottom;
/// Paper size for the document. (the default, no
/// fixed paper) reflows the text column to the control width; any concrete size fixes the column to that
@@ -178,8 +211,8 @@ public RichEditorPageOrientation PageOrientation
internal double PaperWidth => PaperDims.w;
internal double PaperHeight => PaperDims.h;
- internal double PaperContentWidth => PaperWidth - 2 * PagePadX;
- internal double PaperContentHeight => PaperHeight - 2 * PagePadY;
+ internal double PaperContentWidth => PaperWidth - PagePadLeft - PagePadRight;
+ internal double PaperContentHeight => PaperHeight - PagePadTop - PagePadBottom;
/// The current paper's pixel size at 96 DPI (width × height), accounting for
/// . reports its A4 print
@@ -232,8 +265,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 x = right ? paper.X + PagePadX + PaperContentWidth - ft.Width : paper.X + PagePadX;
- double bandCenter = top ? paper.Y + PagePadY / 2 : paper.Bottom - PagePadY / 2;
+ 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));
}
if (!string.IsNullOrEmpty(PageHeader)) DrawSmall(PageHeader!, top: true, right: false);
@@ -257,9 +290,9 @@ private List EnsurePageBreaks()
private double NoChromeColX => Math.Max(0, (Bounds.Width - PaperContentWidth) / 2);
private double PageDeskX => Math.Max(0, (Bounds.Width - PaperWidth) / 2);
- private double PageContentOffsetX => PageDeskX + PagePadX;
+ private double PageContentOffsetX => PageDeskX + PagePadLeft;
private Rect PageRectView(int i) => new(PageDeskX, PageGap + i * (PaperHeight + PageGap), PaperWidth, PaperHeight);
- private double ContentTopView(int i) => PageGap + i * (PaperHeight + PageGap) + PagePadY;
+ private double ContentTopView(int i) => PageGap + i * (PaperHeight + PageGap) + PagePadTop;
// Bare-column mode (paged, no chrome) injects this much whitespace between pages, with the dashed
// separator centered in it — so consecutive pages read as separate without the full page chrome.
@@ -318,7 +351,7 @@ internal void DrawPrintPage(Avalonia.Media.DrawingContext ctx, int pageIndex, IR
double sliceTop = breaks[pageIndex];
double sliceBottom = pageIndex + 1 < breaks.Count ? breaks[pageIndex + 1] : double.PositiveInfinity;
// Same slice clip rule as the page-view render: end the clip where the slice ends.
- var clip = new Rect(PagePadX, PagePadY, contentW,
+ var clip = new Rect(PagePadLeft, PagePadTop, contentW,
Math.Min(contentH, sliceBottom - sliceTop));
// Print resolution for pictures on the page (the bitmap page, the vector PDF, the print dialog) —
// the screen's scale would print them at screen sharpness.
@@ -327,7 +360,7 @@ internal void DrawPrintPage(Avalonia.Media.DrawingContext ctx, int pageIndex, IR
try
{
using (ctx.PushClip(clip))
- using (ctx.PushTransform(Avalonia.Matrix.CreateTranslation(PagePadX, PagePadY - sliceTop)))
+ using (ctx.PushTransform(Avalonia.Matrix.CreateTranslation(PagePadLeft, PagePadTop - sliceTop)))
DrawDocumentBlocks(ctx, contentW, sliceTop, sliceBottom, chrome: false);
}
finally { _imagePixelScale = screenScale; }
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
index 94b32a5..61d837f 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
@@ -236,8 +236,8 @@ public override void Render(DrawingContext context)
context.FillRectangle(Brushes.White, paper);
context.DrawRectangle(null, GrayBorderPen, paper);
DrawPageMarginChrome(context, paper, i, breaks.Count);
- var contentBox = new Rect(paper.X + PagePadX, paper.Y + PagePadY,
- paper.Width - 2 * PagePadX, paper.Height - 2 * PagePadY);
+ var contentBox = new Rect(paper.X + PagePadLeft, paper.Y + PagePadTop,
+ paper.Width - PagePadLeft - PagePadRight, paper.Height - PagePadTop - PagePadBottom);
double sliceTop = breaks[i];
double sliceBottom = i + 1 < breaks.Count ? breaks[i + 1] : double.PositiveInfinity;
// The clip must end where the page's document slice ends, not at the full content
diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs
index 1e216e0..251a7d6 100644
--- a/src/AvaloniaRichEditor/Controls/RichEditor.cs
+++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs
@@ -355,7 +355,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
InvalidateVisual();
}
if (change.Property == PageSizeProperty || change.Property == ShowPageBoundariesProperty
- || change.Property == PageOrientationProperty)
+ || change.Property == PageOrientationProperty || change.Property == PageMarginProperty)
{
RecordHostPageSetup(change.Property); // a page property set by code is the host's default (see there)
CapturePageSetupToDocument(); // persist the page change into the document model
diff --git a/src/AvaloniaRichEditor/Documents/PageSetup.cs b/src/AvaloniaRichEditor/Documents/PageSetup.cs
index ec04913..c0bfca0 100644
--- a/src/AvaloniaRichEditor/Documents/PageSetup.cs
+++ b/src/AvaloniaRichEditor/Documents/PageSetup.cs
@@ -23,12 +23,30 @@ public class PageSetup
/// Whether "page / total" is drawn in the bottom margin.
public bool ShowPageNumbers { get; set; }
- /// The page margin, in DIPs, that the editor draws and that the header/footer band lives in.
- // Here rather than on the control because the RTF writer needs it too, and a formatter reaching for a
+ /// The page margins, in DIPs: the band between the paper's edge and the text, which the
+ /// header, the footer and the page number are drawn in. Four sides, as Word, HWP and RTF have them.
+ /// Defaults to .
+ public Avalonia.Thickness Margin { get; set; } = DefaultMargin;
+
+ /// The margins a document starts with: 48 DIP left and right, 40 top and bottom.
+ public static Avalonia.Thickness DefaultMargin { get; } = new(MarginX, MarginY, MarginX, MarginY);
+
+ // Here rather than on the control because the RTF writer needs them too, and a formatter reaching for a
// control's statics is how a headless formatter stops being headless.
internal const double MarginX = 48;
internal const double MarginY = 40;
+ // A margin a document (or an RTF from another word processor) states has to leave a content box to
+ // put text in: negative, NaN/infinite, or two sides that together swallow the paper all describe a
+ // page nothing can be laid out on. Such a value is dropped rather than clamped — a document that
+ // means "no margins" says 0, and silently halving someone's 300 DIP margin is its own surprise.
+ internal static bool IsUsableMargin(Avalonia.Thickness m, double paperW, double paperH)
+ {
+ foreach (double v in new[] { m.Left, m.Top, m.Right, m.Bottom })
+ if (double.IsNaN(v) || double.IsInfinity(v) || v < 0) return false;
+ return m.Left + m.Right < paperW && m.Top + m.Bottom < paperH;
+ }
+
/// Paper size in DIPs for a page size + orientation. Single source: the control's layout and
/// the RTF writer's tab stops must agree, and two copies of a table like this drift.
internal static (double W, double H) PaperDips(Controls.RichEditorPageSize size, Controls.RichEditorPageOrientation orientation)
@@ -56,6 +74,7 @@ internal static (double W, double H) PaperDips(Controls.RichEditorPageSize size,
Header = Header,
Footer = Footer,
ShowPageNumbers = ShowPageNumbers,
+ Margin = Margin,
};
/// True when the setup carries no real information (Continuous paper, no header/footer/page
@@ -67,5 +86,6 @@ internal static (double W, double H) PaperDips(Controls.RichEditorPageSize size,
PageSize == RichEditorPageSize.Continuous
&& string.IsNullOrEmpty(Header)
&& string.IsNullOrEmpty(Footer)
- && !ShowPageNumbers;
+ && !ShowPageNumbers
+ && Margin.Equals(DefaultMargin);
}
diff --git a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs
index 2c0d852..446c758 100644
--- a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs
+++ b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs
@@ -78,6 +78,10 @@ internal static FlowDocumentDto ToDto(FlowDocument document, Dictionary(psd.PageSize, out var sz) ? sz : Controls.RichEditorPageSize.Continuous;
+ var orient = Enum.TryParse(psd.Orientation, out var or) ? or : Controls.RichEditorPageOrientation.Portrait;
+ var (w, h) = PageSetup.PaperDips(size, orient);
+ return PageSetup.IsUsableMargin(m, w, h) ? m : d;
+ }
+
// ---- model -> dto ----
private static BlockDto BlockToDto(Block block, Dictionary pool)
@@ -564,6 +583,13 @@ internal class PageSetupDto
public string? Header { get; set; }
public string? Footer { get; set; }
public bool ShowPageNumbers { get; set; }
+ // Page margins in DIPs, one per side. Omitted when they are the default, so a document that never
+ // touched them keeps its bytes; a reader that predates them (or a file that omits one side) falls
+ // back to the default for that side.
+ public double? MarginLeft { get; set; }
+ public double? MarginTop { get; set; }
+ public double? MarginRight { get; set; }
+ public double? MarginBottom { get; set; }
}
internal class ImagePoolDto
diff --git a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs
index a84934d..6f81136 100644
--- a/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs
+++ b/src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs
@@ -484,6 +484,18 @@ private void Apply(string w, int? p)
case "landscape": case "lndscpsxn":
if (_st.Dest == Dest.Normal) { _landscape = true; MatchPaper(); } break;
+ // Page margins, in twips. Document level (Word) and section level (HWP) again, as the paper
+ // above. A file from another word processor carries its own margins; before this they were
+ // dropped and the document opened with ours, so its pagination was not what its author saw.
+ case "margl": case "marglsxn":
+ if (_st.Dest == Dest.Normal && p is int ml) { _marginTwips[0] = ml; ApplyMargins(); } break;
+ case "margt": case "margtsxn":
+ if (_st.Dest == Dest.Normal && p is int mt) { _marginTwips[1] = mt; ApplyMargins(); } break;
+ case "margr": case "margrsxn":
+ if (_st.Dest == Dest.Normal && p is int mr) { _marginTwips[2] = mr; ApplyMargins(); } break;
+ case "margb": case "margbsxn":
+ if (_st.Dest == Dest.Normal && p is int mb) { _marginTwips[3] = mb; ApplyMargins(); } break;
+
case "fonttbl": case "stylesheet": case "info": case "pntext": case "themedata":
case "datastore": case "xmlnstbl": case "rsidtbl": case "generator": case "listtable":
case "listoverridetable": case "revtbl":
@@ -606,6 +618,32 @@ private void StartPageChrome(bool header)
private int _paperW, _paperH;
private bool _landscape;
+ // Margins in twips, left/top/right/bottom, -1 until the file states one. They arrive as four separate
+ // control words in any order, and a file may state only some — the rest keep our default.
+ private readonly int[] _marginTwips = { -1, -1, -1, -1 };
+
+ // Each stated margin, once the paper is known well enough to check it against. The paper may still be
+ // Continuous here (a file that gives margins but no size, or a size we have no name for): its print
+ // fallback is A4, which is what such a document would be printed on anyway.
+ private void ApplyMargins()
+ {
+ double Side(int i, double fallback) => _marginTwips[i] >= 0 ? _marginTwips[i] / 15.0 : fallback;
+ var d = PageSetup.DefaultMargin;
+ var m = new Avalonia.Thickness(Side(0, d.Left), Side(1, d.Top), Side(2, d.Right), Side(3, d.Bottom));
+ var ps = _doc.PageSetup;
+ var (w, h) = PageSetup.PaperDips(ps?.PageSize ?? Controls.RichEditorPageSize.Continuous,
+ ps?.Orientation ?? Controls.RichEditorPageOrientation.Portrait);
+ if (!PageSetup.IsUsableMargin(m, w, h))
+ {
+ // Not "keep what we had": an earlier call may have accepted these margins against the A4
+ // fallback, and the paper the file went on to declare is smaller.
+ if (_doc.PageSetup is { } stale) stale.Margin = PageSetup.DefaultMargin;
+ return;
+ }
+ _doc.PageSetup ??= new PageSetup();
+ _doc.PageSetup.Margin = m;
+ }
+
// Turns the paper dimensions back into a named page size by asking PaperDips for each candidate —
// the same table the control lays out with, so a document written by this project comes back exactly.
// Tolerance is 2 twips: the dimensions are rounded DIPs on the way out.
@@ -624,6 +662,9 @@ private void MatchPaper()
_doc.PageSetup ??= new PageSetup();
_doc.PageSetup.PageSize = size;
_doc.PageSetup.Orientation = orientation;
+ // The margins may have arrived first and been checked against the A4 fallback; now that the
+ // real paper is known, check them against it (an A5 file's margins can fit A4 and not A5).
+ if (System.Array.Exists(_marginTwips, t => t >= 0)) ApplyMargins();
return;
}
}
@@ -1195,8 +1236,8 @@ private void WritePageChrome(StringBuilder sb, PageSetup? ps)
{
var (w, h) = PageSetup.PaperDips(ps.PageSize, ps.Orientation);
sb.Append($@"\paperw{(int)Math.Round(w * 15)}\paperh{(int)Math.Round(h * 15)}");
- sb.Append($@"\margl{(int)Math.Round(PageSetup.MarginX * 15)}\margr{(int)Math.Round(PageSetup.MarginX * 15)}");
- sb.Append($@"\margt{(int)Math.Round(PageSetup.MarginY * 15)}\margb{(int)Math.Round(PageSetup.MarginY * 15)}");
+ sb.Append($@"\margl{(int)Math.Round(ps.Margin.Left * 15)}\margr{(int)Math.Round(ps.Margin.Right * 15)}");
+ sb.Append($@"\margt{(int)Math.Round(ps.Margin.Top * 15)}\margb{(int)Math.Round(ps.Margin.Bottom * 15)}");
// \landscape is the document-level flag; PaperDips has already swapped the dimensions, so
// this only tells the reader how to present the page setup it was given.
if (ps.Orientation == Controls.RichEditorPageOrientation.Landscape) sb.Append(@"\landscape");
@@ -1205,6 +1246,8 @@ private void WritePageChrome(StringBuilder sb, PageSetup? ps)
// opened in HWP as Letter until they were here. Both are the same values by construction,
// so there is nothing to keep in sync beyond this line.
sb.Append($@"\sectd\pgwsxn{(int)Math.Round(w * 15)}\pghsxn{(int)Math.Round(h * 15)}");
+ sb.Append($@"\marglsxn{(int)Math.Round(ps.Margin.Left * 15)}\margrsxn{(int)Math.Round(ps.Margin.Right * 15)}");
+ sb.Append($@"\margtsxn{(int)Math.Round(ps.Margin.Top * 15)}\margbsxn{(int)Math.Round(ps.Margin.Bottom * 15)}");
if (ps.Orientation == Controls.RichEditorPageOrientation.Landscape) sb.Append(@"\lndscpsxn");
sb.Append('\n');
}
@@ -1223,7 +1266,7 @@ private void WritePageChrome(StringBuilder sb, PageSetup? ps)
if (hasFooter)
{
var (w, _) = PageSetup.PaperDips(ps.PageSize, ps.Orientation);
- int contentTwips = (int)Math.Round((w - 2 * PageSetup.MarginX) * 15);
+ int contentTwips = (int)Math.Round((w - ps.Margin.Left - ps.Margin.Right) * 15);
_body.Append(@"{\footer\pard\plain\ql");
if (ps.ShowPageNumbers) _body.Append(@"\tqr\tx").Append(contentTwips);
_body.Append(' ');
diff --git a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
index e381710..a73f726 100644
--- a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
+++ b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt
@@ -1,4 +1,6 @@
#nullable enable
+AvaloniaRichEditor.Controls.RichEditor.PageMargin.get -> Avalonia.Thickness
+AvaloniaRichEditor.Controls.RichEditor.PageMargin.set -> void
AvaloniaRichEditor.Controls.RichEditorShortcut
AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.get -> bool
AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.init -> void
@@ -59,6 +61,8 @@ AvaloniaRichEditor.Documents.CellVerticalAlignment
AvaloniaRichEditor.Documents.CellVerticalAlignment.Bottom = 2 -> AvaloniaRichEditor.Documents.CellVerticalAlignment
AvaloniaRichEditor.Documents.CellVerticalAlignment.Center = 1 -> AvaloniaRichEditor.Documents.CellVerticalAlignment
AvaloniaRichEditor.Documents.CellVerticalAlignment.Top = 0 -> AvaloniaRichEditor.Documents.CellVerticalAlignment
+AvaloniaRichEditor.Documents.PageSetup.Margin.get -> Avalonia.Thickness
+AvaloniaRichEditor.Documents.PageSetup.Margin.set -> void
AvaloniaRichEditor.Documents.TableCell.VerticalAlignment.get -> AvaloniaRichEditor.Documents.CellVerticalAlignment
AvaloniaRichEditor.Documents.TableCell.VerticalAlignment.set -> void
AvaloniaRichEditor.Documents.TextPointer.AtLineEnd.get -> bool
@@ -75,6 +79,8 @@ AvaloniaRichEditor.Controls.RichEditorView.ShowFindBar(bool withReplace) -> void
static AvaloniaRichEditor.Controls.RichEditorShortcuts.All.get -> System.Collections.Generic.IReadOnlyList!
static AvaloniaRichEditor.Controls.RichEditorShortcuts.Display(AvaloniaRichEditor.Controls.RichEditorShortcutId id) -> string!
static AvaloniaRichEditor.Controls.RichEditorShortcuts.Gesture(AvaloniaRichEditor.Controls.RichEditorShortcutId id) -> Avalonia.Input.KeyGesture?
+static AvaloniaRichEditor.Documents.PageSetup.DefaultMargin.get -> Avalonia.Thickness
+static readonly AvaloniaRichEditor.Controls.RichEditor.PageMarginProperty -> Avalonia.StyledProperty!
static readonly AvaloniaRichEditor.Controls.RichEditorView.ShowBuiltInFindBarProperty -> Avalonia.StyledProperty!
AvaloniaRichEditor.Controls.RichEditorIcon.Find = 47 -> AvaloniaRichEditor.Controls.RichEditorIcon
AvaloniaRichEditor.Controls.RichEditor.InsertTableRow(AvaloniaRichEditor.Documents.TableBlock! table, int at) -> bool
diff --git a/tests/AvaloniaRichEditor.Tests.Render/VectorPdfTests.cs b/tests/AvaloniaRichEditor.Tests.Render/VectorPdfTests.cs
index 5033ba4..1cdfe88 100644
--- a/tests/AvaloniaRichEditor.Tests.Render/VectorPdfTests.cs
+++ b/tests/AvaloniaRichEditor.Tests.Render/VectorPdfTests.cs
@@ -214,7 +214,7 @@ public void TheVectorPage_FitsThePaper()
finally { handle.Free(); }
var raster = InkColumns(buf, rw, rh);
- double margin = Geometry(ed, "PagePadX") * 72 / 96;
+ double margin = Geometry(ed, "PagePadRight") * 72 / 96;
Assert.True(raster.Right > 0 && vector.Right > 0, "nothing drawn");
Assert.True(vector.Right < W - margin + 2, $"ink runs to x={vector.Right}, past the right margin at {W - margin:0}");
Assert.True(Math.Abs(vector.Left - raster.Left) <= 2 && Math.Abs(vector.Right - raster.Right) <= 3,
diff --git a/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs b/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs
new file mode 100644
index 0000000..9820fff
--- /dev/null
+++ b/tests/AvaloniaRichEditor.Tests/PageMarginTests.cs
@@ -0,0 +1,182 @@
+using System.Linq;
+using Avalonia;
+using Avalonia.Headless.XUnit;
+using AvaloniaRichEditor.Controls;
+using AvaloniaRichEditor.Documents;
+using AvaloniaRichEditor.Formatters;
+using Xunit;
+
+namespace AvaloniaRichEditor.Tests;
+
+// Page margins were two constants (48 x 40 DIP) that nothing could reach: a host could pick the paper but
+// not how much of it to write on, and an RTF from Word or HWP had its margins dropped, so a document
+// opened here paginated differently from what its author saw. Now they are four sides on the document's
+// PageSetup, saved with it and applied on load — the same path the paper size takes.
+//
+// A margin is also the one page setting that can make the page unusable: negative, or two sides that add
+// up past the paper. That arrives from a file (untrusted) and from a binding (XAML), so both entries are
+// tested, as is the case that decides it — the editor keeps a page to write on either way.
+public class PageMarginTests
+{
+ private static readonly Thickness Wide = new(100, 80, 100, 80);
+
+ private static FlowDocument A4Doc(Thickness? margin = null)
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(TestHelpers.Para(new Run { Text = "page" }));
+ doc.PageSetup = new PageSetup { PageSize = RichEditorPageSize.A4, Margin = margin ?? PageSetup.DefaultMargin };
+ return doc;
+ }
+
+ [AvaloniaFact]
+ public void TheContentColumnFollowsTheMargins()
+ {
+ var ed = new RichEditor { Document = A4Doc(), PageSize = RichEditorPageSize.A4 };
+ double before = ed.ContentLayoutWidth;
+
+ ed.PageMargin = Wide;
+
+ // A4 is 794 DIP wide: 794 - 2*48 = 698 before, 794 - 2*100 = 594 after.
+ Assert.Equal(698, before, 1);
+ Assert.Equal(594, ed.ContentLayoutWidth, 1);
+ }
+
+ [AvaloniaFact]
+ public void ChangingTheMarginsRepaginates()
+ {
+ var blocks = Enumerable.Range(0, 40).Select(_ => (Block)TestHelpers.Para(new Run { Text = "line" })).ToArray();
+ var doc = new FlowDocument();
+ foreach (var b in blocks) doc.Blocks.Add(b);
+ var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.A4 };
+ ed.Measure(new Size(900, 1200));
+ int before = ed.GetPrintPageCount();
+
+ ed.PageMargin = new Thickness(48, 400, 48, 400); // a tall band top and bottom: less page to write on
+ ed.Measure(new Size(900, 1200));
+
+ Assert.True(ed.GetPrintPageCount() > before, $"{before} pages before, {ed.GetPrintPageCount()} after");
+ }
+
+ // The margins belong to the document, like the paper: set them on the editor and a save carries them.
+ [AvaloniaFact]
+ public void TheMarginsAreCapturedIntoTheDocument_AndRoundTripThroughJson()
+ {
+ var ed = new RichEditor { Document = A4Doc(), PageSize = RichEditorPageSize.A4 };
+
+ ed.PageMargin = Wide;
+
+ Assert.Equal(Wide, ed.Document!.PageSetup!.Margin);
+ var back = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(ed.Document));
+ Assert.Equal(Wide, back.PageSetup!.Margin);
+ }
+
+ // Opening a document applies its margins, as it applies its paper.
+ [AvaloniaFact]
+ public void OpeningADocumentAppliesItsMargins()
+ {
+ var ed = new RichEditor();
+
+ ed.Document = A4Doc(Wide);
+
+ Assert.Equal(Wide, ed.PageMargin);
+ }
+
+ // A document that never touched the margins must keep its bytes — the whole reason the fields are
+ // omitted at the default.
+ [Fact]
+ public void DefaultMarginsAreNotWrittenToJson()
+ {
+ string json = DocumentSerializer.Serialize(A4Doc());
+
+ // Paragraphs carry margins of their own (MarginTop/MarginRight/...), so the check has to look
+ // inside the page setup rather than at the whole string — the first version of this test didn't
+ // and failed on a block's fields.
+ var setup = System.Text.Json.JsonDocument.Parse(json).RootElement.GetProperty("PageSetup");
+ foreach (string side in new[] { "MarginLeft", "MarginTop", "MarginRight", "MarginBottom" })
+ Assert.False(setup.TryGetProperty(side, out _), $"{side} was written at its default");
+ }
+
+ [Fact]
+ public void MarginsRoundTripThroughRtf()
+ {
+ var doc = A4Doc(Wide);
+
+ var back = RtfDocumentFormatter.Parse(RtfDocumentFormatter.Write(doc));
+
+ Assert.Equal(Wide, back.PageSetup!.Margin);
+ }
+
+ // The point of reading them: a file from another word processor keeps its own margins. 1440 twips = 1
+ // inch = 96 DIP, the Word default; 720 = half an inch. (A4 here is 11910 x 16845 twips — the paper
+ // table's rounded DIPs, within the reader's 2-twip tolerance of Word's own 11906 x 16838.)
+ [Fact]
+ public void AnExternalRtfKeepsItsOwnMargins()
+ {
+ var doc = RtfDocumentFormatter.Parse(
+ @"{\rtf1\ansi\paperw11910\paperh16845\margl1440\margr720\margt1440\margb720 hello\par}");
+
+ Assert.Equal(new Thickness(96, 96, 48, 48), doc.PageSetup!.Margin);
+ }
+
+ // A file that states only some sides keeps ours for the rest, rather than falling to zero.
+ [Fact]
+ public void AnRtfThatStatesOneSideKeepsTheDefaultsForTheOthers()
+ {
+ var doc = RtfDocumentFormatter.Parse(@"{\rtf1\ansi\paperw11910\paperh16845\margl1440 hello\par}");
+
+ var d = PageSetup.DefaultMargin;
+ Assert.Equal(new Thickness(96, d.Top, d.Right, d.Bottom), doc.PageSetup!.Margin);
+ }
+
+ // ---- margins that leave no page ---------------------------------------------------------------
+
+ [AvaloniaTheory]
+ [InlineData(-10.0, 40.0)] // negative
+ [InlineData(500.0, 40.0)] // 2 x 500 > A4's 794 wide
+ [InlineData(48.0, 700.0)] // 2 x 700 > A4's 1123 tall
+ [InlineData(double.NaN, 40.0)]
+ [InlineData(double.PositiveInfinity, 40.0)]
+ public void AMarginThatLeavesNoPage_IsRefusedByTheProperty(double x, double y)
+ {
+ var ed = new RichEditor { Document = A4Doc(), PageSize = RichEditorPageSize.A4 };
+
+ ed.PageMargin = new Thickness(x, y, x, y);
+
+ Assert.Equal(PageSetup.DefaultMargin, ed.PageMargin); // kept the last usable value
+ Assert.True(ed.ContentLayoutWidth > 0);
+ }
+
+ [Fact]
+ public void AJsonFileWithAMarginThatLeavesNoPage_FallsBackToTheDefault()
+ {
+ string json = DocumentSerializer.Serialize(A4Doc(new Thickness(48, 40, 48, 40)))
+ .Replace("\"ShowPageNumbers\": false", "\"ShowPageNumbers\": false, \"MarginLeft\": 900");
+
+ var doc = DocumentSerializer.Deserialize(json);
+
+ Assert.Equal(PageSetup.DefaultMargin, doc.PageSetup!.Margin);
+ }
+
+ [Fact]
+ public void AnRtfWithAMarginThatLeavesNoPage_FallsBackToTheDefault()
+ {
+ // 20000 twips = 1333 DIP, wider than A4's 794 on its own.
+ var doc = RtfDocumentFormatter.Parse(
+ @"{\rtf1\ansi\paperw11910\paperh16845\margl20000\margr20000 hello\par}");
+
+ Assert.Equal(PageSetup.DefaultMargin, doc.PageSetup!.Margin);
+ }
+
+ // The margins can arrive before the paper does, and a margin that fits A4 (the fallback used while the
+ // paper is unknown) need not fit the A5 the file goes on to declare.
+ [Fact]
+ public void MarginsStatedBeforeASmallerPaper_AreRecheckedAgainstIt()
+ {
+ // 4400 twips = 293 DIP a side: 587 together fits A4 (794 wide), not A5 (559).
+ var doc = RtfDocumentFormatter.Parse(
+ @"{\rtf1\ansi\margl4400\margr4400\paperw8385\paperh11910 hello\par}");
+
+ Assert.Equal(RichEditorPageSize.A5, doc.PageSetup!.PageSize);
+ Assert.Equal(PageSetup.DefaultMargin, doc.PageSetup.Margin);
+ }
+}
diff --git a/tests/AvaloniaRichEditor.Tests/PaginationTests.cs b/tests/AvaloniaRichEditor.Tests/PaginationTests.cs
index 211e0eb..f3144e1 100644
--- a/tests/AvaloniaRichEditor.Tests/PaginationTests.cs
+++ b/tests/AvaloniaRichEditor.Tests/PaginationTests.cs
@@ -130,7 +130,7 @@ public void PageView_MapRoundTrips_AcrossPages()
Assert.Equal(30, back.X, 3);
}
// X gains the paper margin (desk centering is 0 while unmeasured: Bounds is empty).
- Assert.Equal(30 + RichEditor.PagePadX, ed.MapDocToView(new Avalonia.Point(30, 0)).X, 3);
+ Assert.Equal(30 + ed.PagePadLeft, ed.MapDocToView(new Avalonia.Point(30, 0)).X, 3);
}
[AvaloniaFact]
@@ -142,7 +142,7 @@ public void PageView_ClickInGap_ClampsToNearestPageContent()
var doc = ed.MapViewToDoc(new Avalonia.Point(100, gapY));
Assert.True(doc.Y < 1000 && doc.Y > 990, $"gap click should clamp to page 1's end, got {doc.Y:F1}");
// Click on the top paper margin of page 2 clamps to page 2's content start.
- double page2MarginY = RichEditor.PageGap * 2 + RichEditor.A4PageHeight + RichEditor.PagePadY / 2.0;
+ double page2MarginY = RichEditor.PageGap * 2 + RichEditor.A4PageHeight + ed.PagePadTop / 2.0;
var doc2 = ed.MapViewToDoc(new Avalonia.Point(100, page2MarginY));
Assert.Equal(1000, doc2.Y, 3);
}