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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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`, `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
`PageOrientation`, `PageMargin`(네 변), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호
- 페이지 설정은 **문서 단위로 저장**되고(`FlowDocument.PageSetup`) 불러올 때 다시 적용됩니다 —
워드프로세서와 같습니다
- **인쇄 및 PDF**: 페이지별 렌더링(`RenderPrintPage`, 300 DPI)과 글자를 선택·검색할 수 있는 PDF
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/DOCUMENT_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음.

#### 버전 이력
Expand Down
57 changes: 45 additions & 12 deletions src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,7 @@ private void ApplyPageSetup(PageSetup ps)
PageHeader = ps.Header;
PageFooter = ps.Footer;
ShowPageNumbers = ps.ShowPageNumbers;
PageMargin = ps.Margin;
}
finally { _syncingPageSetup = false; }
}
Expand All @@ -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
Expand All @@ -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;
/// <summary>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 <see cref="Documents.PageSetup"/>, 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).</summary>
public static readonly StyledProperty<Thickness> PageMarginProperty =
AvaloniaProperty.Register<RichEditor, Thickness>(nameof(PageMargin), Documents.PageSetup.DefaultMargin,
coerce: CoercePageMargin);

/// <summary>Gets or sets the page margins (DIPs, four sides). Defaults to
/// <see cref="Documents.PageSetup.DefaultMargin"/>.</summary>
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;

/// <summary>Paper size for the document. <see cref="RichEditorPageSize.Continuous"/> (the default, no
/// fixed paper) reflows the text column to the control width; any concrete size fixes the column to that
Expand Down Expand Up @@ -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;

/// <summary>The current paper's pixel size at 96 DPI (width × height), accounting for
/// <see cref="PageOrientation"/>. <see cref="RichEditorPageSize.Continuous"/> reports its A4 print
Expand Down Expand Up @@ -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);
Expand All @@ -257,9 +290,9 @@ private List<double> 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.
Expand Down Expand Up @@ -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.
Expand All @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/AvaloniaRichEditor/Controls/RichEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions src/AvaloniaRichEditor/Documents/PageSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,30 @@ public class PageSetup
/// <summary>Whether "page / total" is drawn in the bottom margin.</summary>
public bool ShowPageNumbers { get; set; }

/// <summary>The page margin, in DIPs, that the editor draws and that the header/footer band lives in.</summary>
// Here rather than on the control because the RTF writer needs it too, and a formatter reaching for a
/// <summary>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 <see cref="DefaultMargin"/>.</summary>
public Avalonia.Thickness Margin { get; set; } = DefaultMargin;

/// <summary>The margins a document starts with: 48 DIP left and right, 40 top and bottom.</summary>
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;
}

/// <summary>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.</summary>
internal static (double W, double H) PaperDips(Controls.RichEditorPageSize size, Controls.RichEditorPageOrientation orientation)
Expand Down Expand Up @@ -56,6 +74,7 @@ internal static (double W, double H) PaperDips(Controls.RichEditorPageSize size,
Header = Header,
Footer = Footer,
ShowPageNumbers = ShowPageNumbers,
Margin = Margin,
};

/// <summary>True when the setup carries no real information (Continuous paper, no header/footer/page
Expand All @@ -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);
}
Loading
Loading