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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ and follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### 페이지 여백 + 상류 PR #50·#52 백포트 (2026-09-24)

**공개 API 추가**: `PageMargins`(mm 단위 네 변), `PageSetup.Margin`·`DefaultMargin`·`DipsPerMm`·`PaperMillimetres`,
`RichEditor.PageMargin`(+`PageMarginProperty`), `Block.AutoTopMargin`, 아이콘 슬롯 `RichEditorIcon.PageMargin`.

- **페이지 여백을 문서 설정으로.** 전에는 48×40 DIP 상수라 호스트가 바꿀 수 없었다. 이제 mm 단위 네 변으로
문서의 `PageSetup`에 저장되고(JSON·`.flow`, 기본값이면 필드 생략) 열 때 적용된다. 기본은 **15 mm**다.
쓸 수 없는 여백(음수, NaN, 두 변 합이 용지 초과)은 거부하고 마지막으로 쓸 수 있던 값을 유지한다.
- **RTF가 용지와 여백을 읽고 쓴다.** 이 포트의 RTF는 용지 크기조차 다루지 않아, A4 문서가 읽는 쪽 기본 용지로
열리고 다른 워드프로세서 파일의 여백도 버려졌다. Word가 읽는 문서 수준 값과 HWP가 읽는 구역 수준 값을 모두 쓴다.
- 툴바 페이지 컨트롤에 **5단계 여백 픽커**(5/10/15/20/30 mm). 단계에 없는 여백은 mm 값을 그대로 표시한다.
- **여백 띠가 얇으면 머리글·바닥글·쪽 번호를 그리지 않는다.** 전에는 종이 밖이나 본문 위로 넘쳐 그렸다.
- **표·그림·구분선은 위 텍스트에서 한 줄 간격만큼 떨어진다**(`Block.AutoTopMargin` = NaN, 새 블록의 기본값).
문단에 아래 여백이 없어(HWP 방식) 표가 윗줄에 딱 붙어 있었다. 적어 둔 값은 0을 포함해 그대로 쓴다.
우클릭 여백 메뉴의 위쪽 목록 맨 앞에 **"자동(한 줄)"**을 두어 되돌릴 수 있다.
- **표 바깥 테두리를 반 펜 안쪽으로 그린다.** 1 px 선이 표 상자 바깥으로 반쯤 나가 쪽 클립에 잘렸다
(인쇄 경로 실측: 34%). 셀 사이 안쪽 선은 그대로 가운데에 그린다.
- **그림 선택 테두리를 그림 바깥으로, 그리고 쪽 내용 클립 뒤에 그린다.** 테두리가 그림 가장자리를 덮었고,
쪽 맨 위 그림에서는 윗선이 잘렸다. **표 선택 테두리도 같다**: 왼쪽 여백 0인 표를 쪽 보기에서 선택하면
왼쪽 선이 통째로 안 보였다(실기 보고, 2026-09-24 — 포트 전용, 상류는 문제없음).
- 쪽 여백은 DIP로 바꿀 때 **정수로 반올림**한다(최대 0.13 mm, 파일에는 정확한 mm). 15 mm = 56.69 DIP라
내용 상자와 Win2D의 안티앨리어싱 클립이 소수점 픽셀에 걸려 가장자리 선이 흐려졌다(실측).


### 상류 라운드34 백포트 (2026-09-23, AvaloniaRichEditor PR #53)

상류가 테스트가 닿지 않던 파일을 감사해 고친 결함들을 이 포트에서 **먼저 측정**했다. 빨강이 된 것만 고쳤고,
Expand Down
5 changes: 3 additions & 2 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
2. **실기에만 있는 검증 항목**: AltGr 자판(처리기 배선은 `KeyRoutedEventArgs`를 만들 수 없어 자동 검증 밖),
IME 조합, 포커스·캐럿 깜빡임.
3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조. 라운드34(상류 PR #53)는 2026-09-23 옮김(아래 절).
**남은 것**: PR #50~#52의 여백 후속(`Block.AutoTopMargin` 등 — 들어오면 여백 메뉴의 "자동(한 줄)" 항목도 함께),
그림 손잡이 vs 열 경계 우선순위, 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`.
PR #50·#52(페이지 여백·여백 후속)는 2026-09-24 옮김 — CHANGELOG `[Unreleased]`. 목록에 있던 "그림 손잡이 vs 열 경계
우선순위"와 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`은 이미 들어와 있었다(`ControlImageResizeTests`·
`ControlCaretFormatTests`). **남은 것**: 상류 PR #48(표 행·열 공개 API)·#49(단축키 표 공개) 대조.
4. 아래 "알려진 한계"의 미수정 항목.

### 상류 라운드34 백포트 (2026-09-23) — 테스트 832 → 862, 결함 14(보안 2 포함) + 동작 변경 3, 반증 완료
Expand Down
22 changes: 20 additions & 2 deletions src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,21 @@ private IEnumerable<Rect> BlockImageHandleRects(ImageBlock target)
private void DrawBlockImageChrome(CanvasDrawingSession ds, ImageBlock img, Rect rect)
{
if (_printMode || !ReferenceEquals(_selectedBlock, img)) return;
DrawSelectionChrome(ds, rect);
// Queued, not drawn: the border lies outside the picture and the walk runs under the page's content clip
// (see DrawPagedDocument).
_outsideChrome.Add(d => DrawSelectionChrome(d, rect));
}

// Selection chrome that lies OUTSIDE its object — a top-level picture's or table's border — queued by the walk
// in its coordinates and drawn after it: in page view the walk runs under the page's content clip, and an
// object on the content box's edge lost that side of its border (a table at left margin 0: all of its left
// line — reported from the demo, 2026-09-24; a picture opening a page: its top line, upstream PR #52).
private readonly System.Collections.Generic.List<Action<CanvasDrawingSession>> _outsideChrome = new();

private void FlushOutsideChrome(CanvasDrawingSession ds)
{
foreach (var draw in _outsideChrome) draw(ds);
_outsideChrome.Clear();
}

// Records a cell-hosted block image's DRAWN rect for hit-testing and draws its selection chrome if
Expand All @@ -427,7 +441,11 @@ private void TrackInlineImage(CanvasDrawingSession ds, Paragraph p, InlineImage

private void DrawSelectionChrome(CanvasDrawingSession ds, Rect rect)
{
ds.DrawRectangle(rect, BlockSelBorder, 2.5f);
// Half a pen OUTSIDE the picture: a pen is centred on the rect it strokes, so a border drawn on the
// picture's own rect painted over its outermost 1.25 px (upstream PR #52 — "the picture is cut by a
// pixel or two", seen at a high zoom). The handles still sit on the picture's edges, as Word and HWP draw them.
const float pen = 2.5f;
ds.DrawRectangle(new Rect(rect.X - pen / 2, rect.Y - pen / 2, rect.Width + pen, rect.Height + pen), BlockSelBorder, pen);
if (IsReadOnly) return; // a viewer selects pictures but cannot resize them
// The handles GripAt finds: corner, middle of the right edge, middle of the bottom edge.
foreach (var (cx, cy) in new[] { (rect.Right, rect.Bottom), (rect.Right, rect.Top + rect.Height / 2), (rect.Left + rect.Width / 2, rect.Bottom) })
Expand Down
14 changes: 10 additions & 4 deletions src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -464,20 +464,26 @@ void Highlight(int rr, int cc)
// right exists for paragraphs only (nothing flows around images/tables).
private MenuFlyoutSubItem MarginMenu(Block target)
{
MenuFlyoutSubItem Side(string label, Func<double> get, Action<double> set)
// `auto`: lead with Block.AutoTopMargin (NaN, one line gap) — the default top of a table, picture or divider,
// which no px preset shows checked and nothing else could restore once a preset was picked (upstream).
MenuFlyoutSubItem Side(string label, Func<double> get, Action<double> set, bool auto = false)
{
var s = new MenuFlyoutSubItem { Text = label, FontSize = MenuFontSize };
foreach (double v in new[] { 0d, 5, 10, 20, 30 })
foreach (double v in auto ? new[] { Block.AutoTopMargin, 0d, 5, 10, 20, 30 } : new[] { 0d, 5, 10, 20, 30 })
{
double vv = v;
var ri = new RadioMenuFlyoutItem { Text = $"{vv:0} px", GroupName = label, IsChecked = Math.Abs(get() - vv) < 0.5, FontSize = MenuFontSize };
var ri = new RadioMenuFlyoutItem
{
Text = double.IsNaN(vv) ? Loc("MarginAuto") : $"{vv:0} px", GroupName = label,
IsChecked = double.IsNaN(vv) ? double.IsNaN(get()) : Math.Abs(get() - vv) < 0.5, FontSize = MenuFontSize,
};
ri.Click += (_, _) => PickMargin(get, set, vv);
s.Items.Add(ri);
}
return s;
}
var sub = new MenuFlyoutSubItem { Text = Loc("Margin"), FontSize = MenuFontSize };
sub.Items.Add(Side(Loc("MarginTop"), () => target.MarginTop, v => target.MarginTop = v));
sub.Items.Add(Side(Loc("MarginTop"), () => target.MarginTop, v => target.MarginTop = v, auto: target is not Paragraph));
sub.Items.Add(Side(Loc("MarginBottom"), () => target.MarginBottom, v => target.MarginBottom = v));
sub.Items.Add(Side(Loc("MarginLeft"), () => target.Indent, v => target.Indent = v));
if (target is Paragraph mp) sub.Items.Add(Side(Loc("MarginRight"), () => mp.MarginRight, v => mp.MarginRight = v));
Expand Down
66 changes: 54 additions & 12 deletions src/WinUIRichEditor/Controls/RichEditor.Pagination.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ public partial class RichEditor
{
internal const double A4PageWidth = 794;
internal const double A4PageHeight = 1123;
// Aliases of the shared page geometry (Documents.PageSetup): the RTF writer needs the same numbers
// for its footer tab stop, and it cannot read them off this type without triggering the dependency-
// property static constructor, which requires the WinUI runtime.
internal const double PagePadX = Documents.PageSetup.MarginX; // left/right page margin
internal const double PagePadY = Documents.PageSetup.MarginY; // top/bottom page margin
// The page margins in DIPs, from PageMargin (millimetres). Every reader of the margins goes through these
// four, so the conversion lives in one place. They were two constants (48 x 40 DIP) until the margins
// became a document setting (upstream PR #50/#52, ported 2026-09-24).
//
// Rounded to whole DIPs (at most 0.13 mm off; the document keeps the exact millimetres): 15 mm is 56.69 DIP,
// which puts the content box — and the page clip, which Win2D antialiases — on a fractional pixel. A table's
// outer border there came out at 60% of an interior line even drawn inside its box (measured). Every walker,
// the clip, the transforms and the hit-tests read these four, so the rounding cannot make them disagree.
internal double PagePadLeft => Math.Round(PageMargin.LeftDips);
internal double PagePadRight => Math.Round(PageMargin.RightDips);
internal double PagePadTop => Math.Round(PageMargin.TopDips);
internal double PagePadBottom => Math.Round(PageMargin.BottomDips);
internal const double PageGap = 14; // grey desk gap between stacked pages (page view)

// ---- visual zoom -------------------------------------------------------
Expand Down Expand Up @@ -147,6 +154,7 @@ private void RecordHostPageSetup(DependencyProperty 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 DPs (model ->
Expand Down Expand Up @@ -176,6 +184,7 @@ private void ApplyPageSetup(Documents.PageSetup ps)
PageHeader = ps.Header;
PageFooter = ps.Footer;
ShowPageNumbers = ps.ShowPageNumbers;
PageMargin = ps.Margin;
}
finally { _syncingPageSetup = false; }
}
Expand All @@ -195,6 +204,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 Down Expand Up @@ -273,6 +283,38 @@ public bool ShowPageNumbers
set => SetValue(ShowPageNumbersProperty, value);
}

/// <summary>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; 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. A margin that leaves no page to write on (negative, NaN, or two
/// sides swallowing the paper) is refused: the editor keeps the last usable margins.</summary>
public static readonly DependencyProperty PageMarginProperty = DependencyProperty.Register(
nameof(PageMargin), typeof(Documents.PageMargins), typeof(RichEditor),
new PropertyMetadata(Documents.PageSetup.DefaultMargin, OnPageMarginChanged));

/// <summary>Gets or sets the page margins (millimetres, four sides). Defaults to
/// <see cref="Documents.PageSetup.DefaultMargin"/>.</summary>
public Documents.PageMargins PageMargin
{
get => (Documents.PageMargins)GetValue(PageMarginProperty);
set => SetValue(PageMarginProperty, value);
}

// WinUI has no coerce callback, so an unusable value is put back here — the layout width would be zero or
// negative, and every page-view measurement divides by it. A DP is reachable from XAML and bindings, so the
// refusal lives here rather than at the dozen places that read it.
private static void OnPageMarginChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var ed = (RichEditor)d;
var (w, h) = Documents.PageSetup.PaperMillimetres(ed.PageSize, ed.PageOrientation);
if (!Documents.PageSetup.IsUsableMargin((Documents.PageMargins)e.NewValue, w, h))
{
ed.SetValue(PageMarginProperty, e.OldValue); // re-enters with the old (usable) value
return;
}
OnLayoutAffectingChanged(d, e);
}

/// <summary>True when a concrete (non-Continuous) paper size is set.</summary>
internal bool IsPaged => PageSize != RichEditorPageSize.Continuous;

Expand All @@ -282,8 +324,8 @@ public bool ShowPageNumbers

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 (accounts for <see cref="PageOrientation"/>).
/// Continuous reports its A4 fallback. Useful for host fit-to-width and print math.</summary>
Expand Down Expand Up @@ -365,7 +407,7 @@ void PlaceAtom(double height)

foreach (var block in Document.Blocks)
{
y += block.MarginTop;
y += TopGapOf(block);
if (block is Paragraph p)
{
double px = ParaLeft(p);
Expand Down Expand Up @@ -423,8 +465,8 @@ private Point ViewToDoc(Point v)
var br = EnsurePageBreaks();
double stride = PaperHeight + PageGap;
int i = System.Math.Clamp((int)((v.Y - PageGap) / System.Math.Max(1, stride)), 0, br.Count - 1);
double viewContentLeft = PageDeskX + PagePadX;
double viewContentTop = PageGap + i * stride + PagePadY;
double viewContentLeft = PageDeskX + PagePadLeft;
double viewContentTop = PageGap + i * stride + PagePadTop;
return new Point(v.X - (viewContentLeft - DocContentLeft), br[i] + (v.Y - viewContentTop));
}

Expand All @@ -440,8 +482,8 @@ private Point DocToView(Point d)
var br = EnsurePageBreaks();
double stride = PaperHeight + PageGap;
int i = PageOfDocY(d.Y);
double viewContentLeft = PageDeskX + PagePadX;
double viewContentTop = PageGap + i * stride + PagePadY;
double viewContentLeft = PageDeskX + PagePadLeft;
double viewContentTop = PageGap + i * stride + PagePadTop;
lv = new Point(d.X + (viewContentLeft - DocContentLeft), viewContentTop + (d.Y - br[i]));
}
return new Point(lv.X * z, lv.Y * z);
Expand Down
4 changes: 2 additions & 2 deletions src/WinUIRichEditor/Controls/RichEditor.Print.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ private void DrawPrintPage(CanvasDrawingSession ds, int pageIndex, int pageCount
var paperToDevice = ds.Transform;
var breaks = EnsurePageBreaks();
double docTop = breaks[Math.Clamp(pageIndex, 0, breaks.Count - 1)];
using (ds.CreateLayer(1f, new Rect(PagePadX, PagePadY, PaperContentWidth, PaperContentHeight)))
using (ds.CreateLayer(1f, new Rect(PagePadLeft, PagePadTop, PaperContentWidth, PaperContentHeight)))
{
// Document space → paper, then the caller's paper → device (row vectors: left applies first).
ds.Transform = Matrix3x2.CreateTranslation((float)(PagePadX - DocContentLeft), (float)(PagePadY - docTop)) * paperToDevice;
ds.Transform = Matrix3x2.CreateTranslation((float)(PagePadLeft - DocContentLeft), (float)(PagePadTop - docTop)) * paperToDevice;
// _printMode gates all geometry recording, so this walk can't pollute hit-test caches.
// Clip to this page's doc range so printing/PDF renders O(page), not O(document) per page.
DrawContentWalk(ds, docTop - 1, docTop + PaperContentHeight + 1);
Expand Down
Loading
Loading