diff --git a/CHANGELOG.md b/CHANGELOG.md index 515397a..304119c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 상류가 테스트가 닿지 않던 파일을 감사해 고친 결함들을 이 포트에서 **먼저 측정**했다. 빨강이 된 것만 고쳤고, diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 9014283..3bc956d 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -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, 반증 완료 diff --git a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs index 09fa2d7..0bfe27b 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.BlockSelection.cs @@ -404,7 +404,21 @@ private IEnumerable 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> _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 @@ -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) }) diff --git a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs index 1887e30..6a205f4 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs @@ -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 get, Action 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 get, Action 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)); diff --git a/src/WinUIRichEditor/Controls/RichEditor.Pagination.cs b/src/WinUIRichEditor/Controls/RichEditor.Pagination.cs index 2e1bf13..37406b4 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Pagination.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Pagination.cs @@ -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 ------------------------------------------------------- @@ -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 -> @@ -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; } } @@ -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 @@ -273,6 +283,38 @@ public bool ShowPageNumbers set => SetValue(ShowPageNumbersProperty, value); } + /// 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 , 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. + public static readonly DependencyProperty PageMarginProperty = DependencyProperty.Register( + nameof(PageMargin), typeof(Documents.PageMargins), typeof(RichEditor), + new PropertyMetadata(Documents.PageSetup.DefaultMargin, OnPageMarginChanged)); + + /// Gets or sets the page margins (millimetres, four sides). Defaults to + /// . + 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); + } + /// True when a concrete (non-Continuous) paper size is set. internal bool IsPaged => PageSize != RichEditorPageSize.Continuous; @@ -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; /// The current paper's pixel size at 96 DPI (accounts for ). /// Continuous reports its A4 fallback. Useful for host fit-to-width and print math. @@ -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); @@ -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)); } @@ -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); diff --git a/src/WinUIRichEditor/Controls/RichEditor.Print.cs b/src/WinUIRichEditor/Controls/RichEditor.Print.cs index a0fd147..9454a58 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Print.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Print.cs @@ -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); diff --git a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs index a849e29..f93b7e7 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs @@ -71,6 +71,7 @@ private void DrawDocument(CanvasDrawingSession ds, Rect region) { // The region is physical (zoom-scaled); the walk works in logical doc space. ±1 avoids seams. DrawContentWalk(ds, region.Top / z - 1, region.Bottom / z + 1); + FlushOutsideChrome(ds); } DrawTableDrawRubberBand(ds); // "draw table" overlay, on top of content } @@ -210,8 +211,8 @@ private void DrawPagedDocument(CanvasDrawingSession ds, Rect region) ds.FillRectangle(pageRect, Colors.White); ds.DrawRectangle(pageRect, PageBorderColor, 1f); - double viewContentLeft = PageDeskX + PagePadX; - double viewContentTop = pageTop + PagePadY; + double viewContentLeft = PageDeskX + PagePadLeft; + double viewContentTop = pageTop + PagePadTop; var clip = new Rect(viewContentLeft, viewContentTop, PaperContentWidth, pageContentH); using (ds.CreateLayer(1f, clip)) { @@ -222,6 +223,17 @@ private void DrawPagedDocument(CanvasDrawingSession ds, Rect region) DrawContentWalk(ds, breaks[i] - 1, breaks[i] + pageContentH + 1); ds.Transform = saved; } + // A selected picture's or table's border lies OUTSIDE it, and one on the content box's edge lost that side + // to the content clip. Drawn here under the PAPER's clip instead (see FlushOutsideChrome). + if (_outsideChrome.Count > 0) + using (ds.CreateLayer(1f, pageRect)) + { + var saved = ds.Transform; + ds.Transform = System.Numerics.Matrix3x2.CreateTranslation( + (float)(viewContentLeft - DocContentLeft), (float)(viewContentTop - breaks[i])) * saved; + FlushOutsideChrome(ds); + ds.Transform = saved; + } DrawPageMarginChrome(ds, pageRect, i, pages); } } @@ -230,17 +242,23 @@ private void DrawPagedDocument(CanvasDrawingSession ds, Rect region) // Header/footer/page-number drawn in the page's margin bands (outside the content box, so pagination // is unaffected). `pageRect` is in view space. + // + // A band too thin to hold the line is left empty (upstream user decision, 2026-09-20): centred in a 12 DIP + // band an 11pt line runs off the paper at one end and over the body text at the other. Skipping keeps the + // margins EXACTLY what was asked for, which pushing the body down would not. private void DrawPageMarginChrome(CanvasDrawingSession ds, Rect pageRect, int pageIndex, int pageCount) { if (string.IsNullOrEmpty(PageHeader) && string.IsNullOrEmpty(PageFooter) && !ShowPageNumbers) return; using var fmt = new CanvasTextFormat { FontFamily = DefaultFontFamily, FontSize = 11f, WordWrapping = CanvasWordWrapping.NoWrap }; - double left = pageRect.X + PagePadX; + double left = pageRect.X + PagePadLeft; void DrawSmall(string text, bool top, bool right) { - using var tl = new CanvasTextLayout(ds, text, fmt, (float)PaperContentWidth, (float)PagePadY); + double band = top ? PagePadTop : PagePadBottom; + using var tl = new CanvasTextLayout(ds, text, fmt, (float)PaperContentWidth, (float)Math.Max(1, band)); + if (tl.LayoutBounds.Height > band) return; double x = right ? left + PaperContentWidth - tl.LayoutBounds.Width : left; - double bandCenter = top ? pageRect.Y + PagePadY / 2 : pageRect.Bottom - PagePadY / 2; + double bandCenter = top ? pageRect.Y + band / 2 : pageRect.Bottom - band / 2; ds.DrawTextLayout(tl, (float)x, (float)(bandCenter - tl.LayoutBounds.Height / 2), MarginChromeColor); } diff --git a/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs b/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs index 0a08ee2..01590c5 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.TableResize.cs @@ -350,6 +350,9 @@ private void DrawTableSelectionChrome(CanvasDrawingSession ds, TableBlock tb, do { if (_printMode || !ReferenceEquals(_selectedBlock, tb)) return; var r = new Rect(startX - 1.5, top - 1.5, tl.TableWidth + 3, tl.TotalHeight + 3); - ds.DrawRectangle(r, BlockSelBorder, 2.5f); + // A top-level table's border lies outside the page's content clip where the table meets it (left margin 0): + // drawn after the walk, under the paper's clip (see FlushOutsideChrome). A table in a cell stays inside it. + if (tb.Parent is FlowDocument) _outsideChrome.Add(d => d.DrawRectangle(r, BlockSelBorder, 2.5f)); + else ds.DrawRectangle(r, BlockSelBorder, 2.5f); } } diff --git a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs index 76dafa1..20bff62 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs @@ -249,6 +249,8 @@ private void DrawNestedTable(CanvasDrawingSession ds, TableBlock tb, double star DrawTableSelectionChrome(ds, tb, tl.ColX[0], top, tl); } var cellSel = _printMode ? null : _renderCellSel; // per-pass cache (see DrawDocument) + var tableBox = new Rect(tl.ColX[0], top, tl.TableWidth, tl.TotalHeight); + bool topLevel = tb.Parent is FlowDocument; foreach (var (r, c, rect) in tl.AnchorRects) { var cell = tb.Cells[r][c]; @@ -257,12 +259,27 @@ private void DrawNestedTable(CanvasDrawingSession ds, TableBlock tb, double star if (cellSel is { } cb && ReferenceEquals(cb.tb, tb) && r >= cb.r0 && r <= cb.r1 && c >= cb.c0 && c <= cb.c1) ds.FillRectangle(rect, SelectionFill); - ds.DrawRectangle(rect, GrayBorderColor, 1f); + ds.DrawRectangle(topLevel ? InsetTableEdges(rect, tableBox) : rect, GrayBorderColor, 1f); DrawCellBlockList(ds, cell.Blocks, rect.X + CellPad, rect.Y + CellPad + CellContentOffsetY(cell, rect), Math.Max(10, rect.Width - 2 * CellPad)); } } + // 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 box — and a page's content clip, which starts exactly there, cut it (upstream PR #52: at a page + // break, 67% of a line; measured here on the print clip's left edge: 34%). The edges that ARE the table's + // boundary are pulled half a pen inwards, so all of a table's ink is inside the box pagination knows about. + // Interior edges are shared by two cells and stay centred — insetting those would draw each line twice. + 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.X + cell.Width - (Math.Abs(cell.X + cell.Width - (table.X + table.Width)) < eps ? half : 0); + double bottom = cell.Y + cell.Height - (Math.Abs(cell.Y + cell.Height - (table.Y + table.Height)) < eps ? half : 0); + return new Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top)); + } + // Extra Y offset placing a cell's content per its vertical alignment: 0 for Top, half/all of the // slack (cell inner height − content height) for Center/Bottom. Shared by the render, hit-test and // caret-geometry walks so all three agree on where the content sits. diff --git a/src/WinUIRichEditor/Controls/RichEditor.cs b/src/WinUIRichEditor/Controls/RichEditor.cs index 66cba6f..5a34db3 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.cs @@ -38,6 +38,17 @@ public partial class RichEditor : ContentControl // device-independent pixels at a 96-DPI baseline (1pt = 4/3 px); convert only at this boundary. internal static double PtToPx(double pt) => pt * (4.0 / 3.0); + // The gap above a block, which every walker advances by before the block's own box starts: its MarginTop, + // or — when that is Block.AutoTopMargin (NaN) — ONE LINE GAP of body text: the white space a line break + // leaves between two lines. Without it a table or picture butts straight against the paragraph above, + // because paragraphs carry no bottom margin (HWP-style) and those blocks carried no top one. Sized from the + // DOCUMENT's default typography rather than from the block above (upstream PR #52, ported 2026-09-24). + 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)); + internal static double HeadingFontSize(int level) => HeadingStyle.Size(level); // The size (pt) a run is DRAWN at — the one rule, used by CreateLayout to draw it and by GetCaretFormat @@ -345,7 +356,7 @@ private void EvictLayouts() { foreach (var block in Document.Blocks) { - y += block.MarginTop; + y += TopGapOf(block); double h = BlockHeight(block, width); int orderedStart = OrderedStartFor(block, ref ordCounters); index[block] = map.Count; diff --git a/src/WinUIRichEditor/Controls/RichEditorIcons.cs b/src/WinUIRichEditor/Controls/RichEditorIcons.cs index 960a9ef..91226b6 100644 --- a/src/WinUIRichEditor/Controls/RichEditorIcons.cs +++ b/src/WinUIRichEditor/Controls/RichEditorIcons.cs @@ -116,6 +116,8 @@ public enum RichEditorIcon Find, /// Quote / blockquote toggle (the toolbar's quote button). Quote, + /// Page margins (the toolbar's margin picker). + PageMargin, } /// Host-pluggable icon factory for the built-in chrome (toolbar buttons and context menus). diff --git a/src/WinUIRichEditor/Controls/RichEditorToolbar.PageFile.cs b/src/WinUIRichEditor/Controls/RichEditorToolbar.PageFile.cs index fca182b..3044b1b 100644 --- a/src/WinUIRichEditor/Controls/RichEditorToolbar.PageFile.cs +++ b/src/WinUIRichEditor/Controls/RichEditorToolbar.PageFile.cs @@ -1,4 +1,5 @@ using System; +using WinUIRichEditor.Documents; using System.Collections.Generic; using System.IO; using System.Text; @@ -8,6 +9,8 @@ using Windows.Storage.Streams; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; using WinUIRichEditor.Formatters; namespace WinUIRichEditor.Controls; @@ -59,6 +62,8 @@ public ToolbarLevel ToolbarLevel // ---- page / zoom ------------------------------------------------------ private ComboBox? _zoom, _paper, _orient; + private Border? _marginBox; + private TextBlock? _marginLabel; private ComboBoxItem? _zoomFit; // the "Fit width" entry (disabled in Continuous mode) private static readonly int[] ZoomLevels = { 50, 75, 100, 125, 150, 200 }; @@ -118,6 +123,67 @@ private void BuildPageControls(ToolbarWrapPanel strip) t.EditDocumentPageSetup(() => t.PageOrientation = (RichEditorPageOrientation)ci.Tag); }; strip.Children.Add(_orient); + + // Page margins. Presets only, as Word and HWP lead with (upstream PR #52, ported 2026-09-24): a margin set + // by a host or carried by a document need not be one of them — then the box states the millimetres. + strip.Children.Add(BuildMarginControl()); + } + + // Five steps, narrowest first, in millimetres. The middle one is the editor's own default, so an untouched + // document shows "Normal". + private static readonly (string Label, PageMargins Margin)[] MarginPresets = + { + ("MarginNarrowest", new PageMargins(5)), + ("MarginNarrow", new PageMargins(10)), + ("MarginNormal", PageSetup.DefaultMargin), // 15 mm + ("MarginWide", new PageMargins(20)), + ("MarginWidest", new PageMargins(30)), + }; + + // Margins that match no step, written as short as they are regular. + private static string MarginText(PageMargins m) + { + if (m.Left == m.Right && m.Top == m.Bottom) + return m.Left == m.Top ? $"{m.Left:0.#}mm" : $"{m.Left:0.#} / {m.Top:0.#}mm"; + return $"{m.Left:0.#} {m.Top:0.#} {m.Right:0.#} {m.Bottom:0.#}mm"; + } + + // Built like the line-spacing box: the icon once, the current step, and a chevron dropping plain entries. + private Border BuildMarginControl() + { + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center }; + row.Children.Add(AsElement(IconOrText(RichEditorIcon.PageMargin, "▭"))); + _marginLabel = new TextBlock { FontSize = 12, MinWidth = 88, VerticalAlignment = VerticalAlignment.Center }; + row.Children.Add(_marginLabel); + + var menu = new MenuFlyout(); + ReturnsFocus(menu); + foreach (var (label, margin) in MarginPresets) + { + var m = margin; + var item = new MenuFlyoutItem { Text = Loc(label) }; + // The picker edits the OPEN DOCUMENT's setup, not the host's defaults (see EditDocumentPageSetup). + item.Click += (_, _) => { if (Target is { } t) t.EditDocumentPageSetup(() => t.PageMargin = m); Sync(); }; + menu.Items.Add(item); + } + var presets = new Button + { + Content = SmallChevron(), + Background = ClearBrush, BorderThickness = new Thickness(0), CornerRadius = new CornerRadius(3), + Padding = new Thickness(2, 0, 2, 0), MinWidth = 16, VerticalAlignment = VerticalAlignment.Center, + Flyout = menu, + }; + row.Children.Add(presets); + _marginBox = new Border + { + Child = row, + BorderBrush = new SolidColorBrush(Color.FromArgb(255, 0xDC, 0xDC, 0xDC)), + BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 0, 4, 0), + Height = CtlHeight, VerticalAlignment = VerticalAlignment.Center, + }; + ToolTipService.SetToolTip(_marginBox, Loc("MarginTip")); + return _marginBox; } private void OnPaperChanged() @@ -171,6 +237,17 @@ private void SyncPage() SelectByTag(_orient, Target.PageOrientation); _orient.IsEnabled = paged; // orientation is meaningless in Continuous } + if (_marginLabel != null && _marginBox != null) + { + string? step = null; + foreach (var (label, m) in MarginPresets) + if (m.Equals(Target.PageMargin)) { step = Loc(label); break; } + _marginLabel.Text = step ?? MarginText(Target.PageMargin); + // Margins are meaningless in Continuous, as orientation is. A Border has no IsEnabled: dim it and + // disable the chevron, which is the only thing in it that acts. + _marginBox.Opacity = paged ? 1 : 0.5; + if (((StackPanel)_marginBox.Child).Children[^1] is Button chevron) chevron.IsEnabled = paged; + } } // ---- file actions ----------------------------------------------------- diff --git a/src/WinUIRichEditor/Controls/ToolbarIcons.cs b/src/WinUIRichEditor/Controls/ToolbarIcons.cs index e7add31..4d5adee 100644 --- a/src/WinUIRichEditor/Controls/ToolbarIcons.cs +++ b/src/WinUIRichEditor/Controls/ToolbarIcons.cs @@ -100,6 +100,10 @@ or RichEditorIcon.Strikethrough or RichEditorIcon.CharacterFormat RichEditorIcon.IndentDecrease => Build(box, ("M4 6 H20 M4 18 H20 M11 12 H20", false), ("M8 9 L4 12 L8 15 Z", true)), + // A page with its content box inset — the margin band around it (same paths as upstream's). + RichEditorIcon.PageMargin => Build(box, + ("M6 3 H18 V21 H6 Z", false), + ("M9 7 H15 V17 H9 Z", false)), // The quote bar the editor draws, beside the lines it marks (same path as upstream's). RichEditorIcon.Quote => Build(box, ("M5 5 V19", false), diff --git a/src/WinUIRichEditor/Documents/Block.cs b/src/WinUIRichEditor/Documents/Block.cs index 05f68ac..e789463 100644 --- a/src/WinUIRichEditor/Documents/Block.cs +++ b/src/WinUIRichEditor/Documents/Block.cs @@ -7,8 +7,18 @@ public abstract class Block : TextElement /// Left indent in device-independent pixels. Shifts paragraph text right; shifts /// image/table blocks right (used by the "Space before a block" margin feature). Default: 0. public double Indent { get; set; } = 0; - /// Top margin in device-independent pixels (gap to the previous block). Default: 0. + /// Top margin in device-independent pixels (gap to the previous block). Default: 0 for a + /// , for the block kinds that are objects on the + /// page (, , ). public double MarginTop { get; set; } = 0; + + /// value meaning "let the editor choose": the block sits one line + /// gap below whatever precedes it — the white space a line break leaves between two lines of body + /// text, so it follows the document's font size and line spacing. Paragraphs carry no bottom margin + /// (HWP-style), so without this a table or picture butts straight against the text above it. + /// NaN as "unset" follows ; a plain 0 cannot say it, because + /// 0 is also a perfectly good margin to ask for — and pagination asks for it. + public static double AutoTopMargin => double.NaN; /// Bottom margin in device-independent pixels (gap to the next block). Default: 10 /// ( overrides to 0 — its height already includes spacing). public double MarginBottom { get; set; } = 10; diff --git a/src/WinUIRichEditor/Documents/DividerBlock.cs b/src/WinUIRichEditor/Documents/DividerBlock.cs index 182f91b..e184ff9 100644 --- a/src/WinUIRichEditor/Documents/DividerBlock.cs +++ b/src/WinUIRichEditor/Documents/DividerBlock.cs @@ -3,10 +3,12 @@ namespace WinUIRichEditor.Documents; /// A horizontal rule (<hr>): a thin full-width divider line occupying its own block. public class DividerBlock : Block { - /// Creates a divider. Its fixed height already includes vertical spacing, so the - /// default bottom margin is 0 (unlike other blocks). + /// Creates a divider. Its fixed height already includes vertical spacing, so the default + /// bottom margin is 0 (unlike other blocks); the top is , one line + /// gap below whatever precedes it, as for the other blocks that are objects on the page. public DividerBlock() { + MarginTop = AutoTopMargin; MarginBottom = 0; } diff --git a/src/WinUIRichEditor/Documents/ImageBlock.cs b/src/WinUIRichEditor/Documents/ImageBlock.cs index da13a33..cdfc237 100644 --- a/src/WinUIRichEditor/Documents/ImageBlock.cs +++ b/src/WinUIRichEditor/Documents/ImageBlock.cs @@ -6,6 +6,13 @@ namespace WinUIRichEditor.Documents; /// . Used for larger pictures; supports resize. public class ImageBlock : Block { + /// Creates a block picture. Its top margin is : one line + /// gap below whatever precedes it, since a paragraph carries no bottom margin of its own. + public ImageBlock() + { + MarginTop = AutoTopMargin; + } + /// Original encoded image bytes (JPEG/PNG/...). When present this is the data source /// of truth: serialization stores these bytes verbatim (no re-encoding) and /// is decoded from them by the render layer (which owns a Win2D device). diff --git a/src/WinUIRichEditor/Documents/PageMargins.cs b/src/WinUIRichEditor/Documents/PageMargins.cs new file mode 100644 index 0000000..1c99f9c --- /dev/null +++ b/src/WinUIRichEditor/Documents/PageMargins.cs @@ -0,0 +1,30 @@ +namespace WinUIRichEditor.Documents; + +/// The four page margins, in MILLIMETRES — the band between the paper's edge and the text, which +/// the header, the footer and the page number are drawn in. +/// Millimetres rather than device pixels because that is the unit a page is discussed in: Word and +/// HWP show margins in mm, paper sizes are defined in mm, and RTF carries them as twips of physical +/// length. A deliberate type rather than , which means device +/// pixels everywhere else in a UI framework — the same four numbers in a different unit is exactly the +/// mix-up worth making impossible. (Ported from AvaloniaRichEditor, 2026-09-23.) +/// Left margin in millimetres. +/// Top margin in millimetres. +/// Right margin in millimetres. +/// Bottom margin in millimetres. +public readonly record struct PageMargins(double Left, double Top, double Right, double Bottom) +{ + /// The same margin on all four sides. + public PageMargins(double allSides) : this(allSides, allSides, allSides, allSides) { } + + /// The same margin left and right, and another top and bottom. + public static PageMargins Symmetric(double sides, double topAndBottom) + => new(sides, topAndBottom, sides, topAndBottom); + + internal double LeftDips => Left * PageSetup.DipsPerMm; + internal double TopDips => Top * PageSetup.DipsPerMm; + internal double RightDips => Right * PageSetup.DipsPerMm; + internal double BottomDips => Bottom * PageSetup.DipsPerMm; + + /// "12.7 10.6 12.7 10.6 mm" — left, top, right, bottom. + public override string ToString() => $"{Left:0.#} {Top:0.#} {Right:0.#} {Bottom:0.#} mm"; +} diff --git a/src/WinUIRichEditor/Documents/PageSetup.cs b/src/WinUIRichEditor/Documents/PageSetup.cs index e6a024f..0f2d776 100644 --- a/src/WinUIRichEditor/Documents/PageSetup.cs +++ b/src/WinUIRichEditor/Documents/PageSetup.cs @@ -22,12 +22,48 @@ 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 touching any RichEditor + /// The page margins, in MILLIMETRES: 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 PageMargins Margin { get; set; } = DefaultMargin; + + /// The margins a document starts with: 15 mm on every side. + public static PageMargins DefaultMargin { get; } = new(DefaultMarginMm); + + // Here rather than on the control because the RTF writer needs them too, and touching any RichEditor // static from a formatter would run that type's static constructor — which registers dependency // properties and therefore requires the WinUI runtime. The formatters must stay headless. - internal const double MarginX = 48; - internal const double MarginY = 40; + // + // 15 mm, not the 12.7 x 10.6 this editor drew while the margins were two constants in device pixels + // (48 x 40 DIP): millimetres are the unit a page is set in, and the default should be a number someone + // would choose. It is also the middle step of the toolbar's picker, so a new document shows "Normal". + internal const double DefaultMarginMm = 15; + + // 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 80 mm margin is its own surprise. + internal static bool IsUsableMargin(PageMargins m, double paperWmm, double paperHmm) + { + 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 < paperWmm && m.Top + m.Bottom < paperHmm; + } + + /// Device-independent pixels per millimetre: this library lays out at 96 DPI, and page + /// geometry is stated in millimetres, so every conversion between the two goes through here. + public const double DipsPerMm = 96.0 / 25.4; + + // 1440 twips per inch, 25.4 mm per inch: page geometry is millimetres in the model and twips in RTF. + internal const double TwipsPerMm = 1440.0 / 25.4; + internal static int MmToTwips(double mm) => (int)System.Math.Round(mm * TwipsPerMm); + + /// Paper size in millimetres for a page size + orientation. + public static (double W, double H) PaperMillimetres(RichEditorPageSize size, RichEditorPageOrientation orientation) + { + var (w, h) = PaperDips(size, orientation); + return (w / DipsPerMm, h / DipsPerMm); + } /// 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. @@ -55,6 +91,7 @@ internal static (double W, double H) PaperDips(RichEditorPageSize size, RichEdit Header = Header, Footer = Footer, ShowPageNumbers = ShowPageNumbers, + Margin = Margin, }; /// True when the setup carries no real information (Continuous paper, no header/footer/page @@ -65,5 +102,6 @@ internal static (double W, double H) PaperDips(RichEditorPageSize size, RichEdit PageSize == RichEditorPageSize.Continuous && string.IsNullOrEmpty(Header) && string.IsNullOrEmpty(Footer) - && !ShowPageNumbers; + && !ShowPageNumbers + && Margin.Equals(DefaultMargin); } diff --git a/src/WinUIRichEditor/Documents/TableBlock.cs b/src/WinUIRichEditor/Documents/TableBlock.cs index 7a394bf..af0068f 100644 --- a/src/WinUIRichEditor/Documents/TableBlock.cs +++ b/src/WinUIRichEditor/Documents/TableBlock.cs @@ -29,12 +29,14 @@ public class TableBlock : Block /// Creates a 2×2 table. public TableBlock() { + MarginTop = AutoTopMargin; InitializeCells(Rows, Columns); } /// Creates a × table. public TableBlock(int rows, int cols) { + MarginTop = AutoTopMargin; Rows = rows; Columns = cols; InitializeCells(Rows, Columns); diff --git a/src/WinUIRichEditor/Formatters/DocumentSerializer.cs b/src/WinUIRichEditor/Formatters/DocumentSerializer.cs index da4d176..a5dc1e4 100644 --- a/src/WinUIRichEditor/Formatters/DocumentSerializer.cs +++ b/src/WinUIRichEditor/Formatters/DocumentSerializer.cs @@ -78,6 +78,10 @@ internal static FlowDocumentDto ToDto(FlowDocument document, Dictionary(tb.ColumnWidths), RowHeights = new List(tb.RowHeights), @@ -297,13 +309,27 @@ private static BlockDto ParagraphToDto(Paragraph p, Dictionary(psd.PageSize, out var sz) ? sz : RichEditorPageSize.Continuous; + var orient = Enum.TryParse(psd.Orientation, out var or) ? or : RichEditorPageOrientation.Portrait; + var (w, h) = PageSetup.PaperMillimetres(size, orient); + return PageSetup.IsUsableMargin(m, w, h) ? m : d; + } + private static Block? DtoToBlock(BlockDto? d, Dictionary pool) { if (d == null) return null; // a JSON null inside "Blocks" must not take the load down switch (d.Type) { case "Divider": - return new DividerBlock { MarginTop = d.MarginTop ?? 0, MarginBottom = d.MarginBottom ?? 0 }; + return new DividerBlock { MarginTop = d.MarginTop ?? Block.AutoTopMargin, MarginBottom = d.MarginBottom ?? 0 }; case "Image": { var ib = new ImageBlock @@ -312,7 +338,7 @@ private static BlockDto ParagraphToDto(Paragraph p, Dictionary _marginTwips[i] >= 0 ? _marginTwips[i] / PageSetup.TwipsPerMm : fallback; + var d = PageSetup.DefaultMargin; + var m = new PageMargins(Side(0, d.Left), Side(1, d.Top), Side(2, d.Right), Side(3, d.Bottom)); + var ps = _doc.PageSetup; + var (w, h) = PageSetup.PaperMillimetres(ps?.PageSize ?? RichEditorPageSize.Continuous, + ps?.Orientation ?? 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 here comes back exactly. Tolerance: 2 twips. + private void MatchPaper() + { + if (_paperW <= 0 || _paperH <= 0) return; + foreach (RichEditorPageSize size in Enum.GetValues()) + { + if (size == RichEditorPageSize.Continuous) continue; + var orientation = _landscape ? RichEditorPageOrientation.Landscape : RichEditorPageOrientation.Portrait; + var (w, h) = PageSetup.PaperDips(size, orientation); + if (Math.Abs((int)Math.Round(w * 15) - _paperW) <= 2 && Math.Abs((int)Math.Round(h * 15) - _paperH) <= 2) + { + _doc.PageSetup ??= new PageSetup(); + _doc.PageSetup.PageSize = size; + _doc.PageSetup.Orientation = orientation; + // Margins that arrived first were checked against the A4 fallback; check them against the real paper. + if (Array.Exists(_marginTwips, t => t >= 0)) ApplyMargins(); + return; + } + } + } + // ---- page chrome ({\header …} / {\footer …}) ---- private bool _chromeIsHeader; // which of the two the current group is @@ -1406,6 +1483,25 @@ private void WritePageChrome(StringBuilder sb, PageSetup? ps) if (ps == null) return; bool hasHeader = !string.IsNullOrEmpty(ps.Header); bool hasFooter = !string.IsNullOrEmpty(ps.Footer) || ps.ShowPageNumbers; + + // Paper size and margins (ported from upstream, 2026-09-24): without them a document set to A4 arrived on + // whatever paper the reader defaults to (Letter in a US install). Continuous has no paper to state. + if (ps.PageSize != RichEditorPageSize.Continuous) + { + var (pw, ph) = PageSetup.PaperDips(ps.PageSize, ps.Orientation); + sb.Append($@"\paperw{(int)Math.Round(pw * 15)}\paperh{(int)Math.Round(ph * 15)}"); + // Margins are millimetres; RTF wants twips (1440 per inch). + sb.Append($@"\margl{PageSetup.MmToTwips(ps.Margin.Left)}\margr{PageSetup.MmToTwips(ps.Margin.Right)}"); + sb.Append($@"\margt{PageSetup.MmToTwips(ps.Margin.Top)}\margb{PageSetup.MmToTwips(ps.Margin.Bottom)}"); + if (ps.Orientation == RichEditorPageOrientation.Landscape) sb.Append(@"\landscape"); + // The same numbers again at SECTION level: Word reads the document-level ones, HWP only these. + sb.Append($@"\sectd\pgwsxn{(int)Math.Round(pw * 15)}\pghsxn{(int)Math.Round(ph * 15)}"); + sb.Append($@"\marglsxn{PageSetup.MmToTwips(ps.Margin.Left)}\margrsxn{PageSetup.MmToTwips(ps.Margin.Right)}"); + sb.Append($@"\margtsxn{PageSetup.MmToTwips(ps.Margin.Top)}\margbsxn{PageSetup.MmToTwips(ps.Margin.Bottom)}"); + if (ps.Orientation == RichEditorPageOrientation.Landscape) sb.Append(@"\lndscpsxn"); + sb.Append('\n'); + } + if (!hasHeader && !hasFooter) return; // The writer emits into _body; borrow it so WriteEscaped can be reused, then move the result. @@ -1420,7 +1516,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 * 15) - PageSetup.MmToTwips(ps.Margin.Left) - PageSetup.MmToTwips(ps.Margin.Right); _body.Append(@"{\footer\pard\plain\ql"); if (ps.ShowPageNumbers) _body.Append(@"\tqr\tx").Append(contentTwips); _body.Append(' '); diff --git a/src/WinUIRichEditor/PublicAPI.Unshipped.txt b/src/WinUIRichEditor/PublicAPI.Unshipped.txt index 0d22aa6..f986230 100644 --- a/src/WinUIRichEditor/PublicAPI.Unshipped.txt +++ b/src/WinUIRichEditor/PublicAPI.Unshipped.txt @@ -1 +1,25 @@ WinUIRichEditor.Controls.RichEditorIcon.Quote = 49 -> WinUIRichEditor.Controls.RichEditorIcon +WinUIRichEditor.Controls.RichEditor.PageMargin.get -> WinUIRichEditor.Documents.PageMargins +WinUIRichEditor.Controls.RichEditor.PageMargin.set -> void +WinUIRichEditor.Documents.PageMargins +WinUIRichEditor.Documents.PageMargins.Bottom.get -> double +WinUIRichEditor.Documents.PageMargins.Bottom.init -> void +WinUIRichEditor.Documents.PageMargins.Left.get -> double +WinUIRichEditor.Documents.PageMargins.Left.init -> void +WinUIRichEditor.Documents.PageMargins.PageMargins() -> void +WinUIRichEditor.Documents.PageMargins.PageMargins(double allSides) -> void +WinUIRichEditor.Documents.PageMargins.PageMargins(double Left, double Top, double Right, double Bottom) -> void +WinUIRichEditor.Documents.PageMargins.Right.get -> double +WinUIRichEditor.Documents.PageMargins.Right.init -> void +WinUIRichEditor.Documents.PageMargins.Top.get -> double +WinUIRichEditor.Documents.PageMargins.Top.init -> void +WinUIRichEditor.Documents.PageSetup.Margin.get -> WinUIRichEditor.Documents.PageMargins +WinUIRichEditor.Documents.PageSetup.Margin.set -> void +const WinUIRichEditor.Documents.PageSetup.DipsPerMm = 3.7795275590551185 -> double +override WinUIRichEditor.Documents.PageMargins.ToString() -> string! +static WinUIRichEditor.Documents.PageMargins.Symmetric(double sides, double topAndBottom) -> WinUIRichEditor.Documents.PageMargins +static WinUIRichEditor.Documents.PageSetup.DefaultMargin.get -> WinUIRichEditor.Documents.PageMargins +static WinUIRichEditor.Documents.PageSetup.PaperMillimetres(WinUIRichEditor.Controls.RichEditorPageSize size, WinUIRichEditor.Controls.RichEditorPageOrientation orientation) -> (double W, double H) +static readonly WinUIRichEditor.Controls.RichEditor.PageMarginProperty -> Microsoft.UI.Xaml.DependencyProperty! +static WinUIRichEditor.Documents.Block.AutoTopMargin.get -> double +WinUIRichEditor.Controls.RichEditorIcon.PageMargin = 50 -> WinUIRichEditor.Controls.RichEditorIcon diff --git a/src/WinUIRichEditor/RichEditorLocalization.cs b/src/WinUIRichEditor/RichEditorLocalization.cs index fd725c9..ee24d36 100644 --- a/src/WinUIRichEditor/RichEditorLocalization.cs +++ b/src/WinUIRichEditor/RichEditorLocalization.cs @@ -87,6 +87,7 @@ public static class RichEditorLocalization ["IndentDecrease"] = "Decrease Indent", ["Margin"] = "Margin", ["MarginTop"] = "Top Margin", + ["MarginAuto"] = "Auto (one line)", ["MarginBottom"] = "Bottom Margin", ["MarginLeft"] = "Left Margin", ["MarginRight"] = "Right Margin", @@ -154,6 +155,12 @@ public static class RichEditorLocalization ["OrientPortrait"] = "Portrait", ["OrientLandscape"] = "Landscape", ["OrientationTip"] = "Page orientation", + ["MarginNarrowest"] = "Narrowest 5 mm", + ["MarginNarrow"] = "Narrow 10 mm", + ["MarginNormal"] = "Normal 15 mm", + ["MarginWide"] = "Wide 20 mm", + ["MarginWidest"] = "Widest 30 mm", + ["MarginTip"] = "Page margins, in millimetres (the band the header and footer sit in)", // File actions (RichEditorView) ["Export"] = "Export (JSON / .flow / HTML)", ["Import"] = "Import", @@ -229,6 +236,7 @@ public static class RichEditorLocalization ["IndentDecrease"] = "내어쓰기 -", ["Margin"] = "여백", ["MarginTop"] = "위 여백", + ["MarginAuto"] = "자동 (한 줄)", ["MarginBottom"] = "아래 여백", ["MarginLeft"] = "왼쪽 여백", ["MarginRight"] = "오른쪽 여백", @@ -296,6 +304,12 @@ public static class RichEditorLocalization ["OrientPortrait"] = "세로", ["OrientLandscape"] = "가로", ["OrientationTip"] = "용지 방향", + ["MarginNarrowest"] = "아주 좁게 5mm", + ["MarginNarrow"] = "좁게 10mm", + ["MarginNormal"] = "보통 15mm", + ["MarginWide"] = "넓게 20mm", + ["MarginWidest"] = "아주 넓게 30mm", + ["MarginTip"] = "쪽 여백 (mm, 머리글·바닥글이 앉는 띠)", // File actions (RichEditorView) ["Export"] = "내보내기 (JSON / .flow / HTML)", ["Import"] = "가져오기", diff --git a/tests/WinUIRichEditor.Tests/PageMarginTests.cs b/tests/WinUIRichEditor.Tests/PageMarginTests.cs new file mode 100644 index 0000000..18b5a78 --- /dev/null +++ b/tests/WinUIRichEditor.Tests/PageMarginTests.cs @@ -0,0 +1,294 @@ +using System.Linq; +using WinUIRichEditor.Controls; +using WinUIRichEditor.Documents; +using WinUIRichEditor.Formatters; +using Xunit; + +namespace WinUIRichEditor.Tests; + +/// Page margins, ported from upstream (AvaloniaRichEditor PR #50/#52, PageMarginTests) on +/// 2026-09-24. They 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. Now they are four sides, +/// in millimetres, on the document's PageSetup — saved with it and applied on load, as the paper is. +/// A margin is also the one page setting that can make the page unusable (negative, or two sides past the +/// paper). It arrives from a file (untrusted) and from a binding, so both entries are tested. +[Collection(UiTests.Collection)] +public class PageMarginTests +{ + private static readonly PageMargins Wide = new(25, 20, 25, 20); // mm + + private static FlowDocument A4Doc(PageMargins? margin = null, int paragraphs = 1) + { + var doc = new FlowDocument(); + for (int i = 0; i < paragraphs; i++) doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "line" } } }); + doc.PageSetup = new PageSetup { PageSize = RichEditorPageSize.A4, Margin = margin ?? PageSetup.DefaultMargin }; + return doc; + } + + [Fact] + public void TheContentColumnFollowsTheMargins() => UiThread.Run(() => + { + var ed = new RichEditor { Document = A4Doc() }; + double before = ed.PaperContentWidth; + + ed.PageMargin = Wide; + + // A4 is 794 DIP (210 mm) wide. 15 mm a side = 56.7 DIP; 25 mm = 94.5 DIP — each rounded to a whole DIP + // on the way in (see PagePadLeft), so the content box sits on whole pixels. + Assert.Equal(794 - 2 * System.Math.Round(15 * PageSetup.DipsPerMm), before, 3); + Assert.Equal(794 - 2 * System.Math.Round(25 * PageSetup.DipsPerMm), ed.PaperContentWidth, 3); + }); + + [Fact] + public void ChangingTheMarginsRepaginates() => UiThread.Run(() => + { + var ed = new RichEditor { Document = A4Doc(paragraphs: 60) }; + int before = ed.GetPrintPageCount(); + + ed.PageMargin = new PageMargins(12.7, 105, 12.7, 105); // a tall band top and bottom: less page to write on + + 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. + [Fact] + public void TheMarginsAreCapturedIntoTheDocument_AndRoundTripThroughJson() => UiThread.Run(() => + { + var ed = new RichEditor { Document = A4Doc() }; + + ed.PageMargin = Wide; + + Assert.Equal(Wide, ed.Document!.PageSetup!.Margin); + var once = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(ed.Document)); + var twice = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(once)); // round trips are run twice here + Assert.Equal(Wide, twice.PageSetup!.Margin); + }); + + [Fact] + public void OpeningADocumentAppliesItsMargins() => UiThread.Run(() => + { + var ed = new RichEditor(); + + ed.Document = A4Doc(Wide); + + Assert.Equal(Wide, ed.PageMargin); + }); + + // A document that never touched the margins keeps its bytes — the reason the fields are omitted at the default. + [Fact] + public void DefaultMarginsAreNotWrittenToJson() + { + string json = DocumentSerializer.Serialize(A4Doc()); + + 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"); + } + + // RTF carries whole twips, so a millimetre comes back to within a twip, not identically. + [Fact] + public void MarginsAndPaperRoundTripThroughRtf_ToWithinATwip() + { + var back = RtfDocumentFormatter.Parse(RtfDocumentFormatter.Write(A4Doc(Wide))).PageSetup!; + + Assert.Equal(RichEditorPageSize.A4, back.PageSize); // this reader dropped the paper before the port + const double twipMm = 25.4 / 1440; + Assert.Equal(Wide.Left, back.Margin.Left, twipMm); + Assert.Equal(Wide.Top, back.Margin.Top, twipMm); + Assert.Equal(Wide.Right, back.Margin.Right, twipMm); + Assert.Equal(Wide.Bottom, back.Margin.Bottom, twipMm); + } + + // A file from another word processor keeps its own margins. 1440 twips = 1 inch (Word's default), 720 = half. + [Fact] + public void AnExternalRtfKeepsItsOwnMargins() + { + var doc = RtfDocumentFormatter.Parse( + @"{\rtf1\ansi\paperw11910\paperh16845\margl1440\margr720\margt1440\margb720 hello\par}"); + + Assert.Equal(new PageMargins(25.4, 25.4, 12.7, 12.7), doc.PageSetup!.Margin); + } + + [Fact] + public void AnRtfThatStatesOneSideKeepsTheDefaultsForTheOthers() + { + var doc = RtfDocumentFormatter.Parse(@"{\rtf1\ansi\paperw11910\paperh16845\margl1440 hello\par}"); + + var d = PageSetup.DefaultMargin; + Assert.Equal(new PageMargins(25.4, d.Top, d.Right, d.Bottom), doc.PageSetup!.Margin); + } + + // ---- margins that leave no page --------------------------------------------------------------- + + [Theory] + [InlineData(-10.0, 10.0)] // negative + [InlineData(120.0, 10.0)] // 2 x 120 mm > A4's 210 mm across + [InlineData(12.7, 160.0)] // 2 x 160 mm > A4's 297 mm down + [InlineData(double.NaN, 10.0)] + [InlineData(double.PositiveInfinity, 10.0)] + public void AMarginThatLeavesNoPage_IsRefusedByTheProperty(double x, double y) => UiThread.Run(() => + { + var ed = new RichEditor { Document = A4Doc() }; + + ed.PageMargin = new PageMargins(x, y, x, y); + + Assert.Equal(PageSetup.DefaultMargin, ed.PageMargin); // kept the last usable value + Assert.True(ed.PaperContentWidth > 0); + }); + + [Fact] + public void AJsonFileWithAMarginThatLeavesNoPage_FallsBackToTheDefault() + { + var node = System.Text.Json.Nodes.JsonNode.Parse(DocumentSerializer.Serialize(A4Doc()))!; + node["PageSetup"]!["MarginLeft"] = 900; + + var doc = DocumentSerializer.Deserialize(node.ToJsonString()); + + Assert.Equal(PageSetup.DefaultMargin, doc.PageSetup!.Margin); + } + + [Fact] + public void AnRtfWithAMarginThatLeavesNoPage_FallsBackToTheDefault() + { + var doc = RtfDocumentFormatter.Parse(@"{\rtf1\ansi\paperw11910\paperh16845\margl20000\margr20000 hello\par}"); + + Assert.Equal(PageSetup.DefaultMargin, doc.PageSetup!.Margin); + } + + // Margins can arrive before the paper, and one that fits A4 (the fallback) need not fit the A5 declared later. + [Fact] + public void MarginsStatedBeforeASmallerPaper_AreRecheckedAgainstIt() + { + 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); + } + + // ---- the gap above an object block (Block.AutoTopMargin) ------------------------------------- + + // A table, picture or divider sits one line gap below the text above it; paragraphs carry no bottom margin, + // so it used to butt straight against that text. A stated margin, 0 included, is used as given. + [Fact] + public void ATableStartsOneLineGapBelowTheTextAbove_UnlessItStatesItsOwnMargin() => UiThread.Run(() => + { + var auto = new TableBlock(1, 1); + var zero = new TableBlock(1, 1) { MarginTop = 0 }; + Assert.True(double.IsNaN(auto.MarginTop)); // precondition: the new default + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "above" } } }); + doc.Blocks.Add(auto); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "between" } } }); + doc.Blocks.Add(zero); + var ed = new RichEditor { Document = doc }; + + var map = (System.Collections.IList)typeof(RichEditor).GetMethod("EnsureBlockLayout", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.Invoke(ed, new object[] { 600.0 })!; + double TopOf(int i) => (double)map[i]!.GetType().GetField("Item2")!.GetValue(map[i])!; + double BottomOf(int i) => TopOf(i) + (double)map[i]!.GetType().GetField("Item3")!.GetValue(map[i])!; + + Assert.Equal(ed.AutoBlockTopGap, TopOf(1) - BottomOf(0) - ((Paragraph)doc.Blocks[0]).MarginBottom, 3); + Assert.True(ed.AutoBlockTopGap > 0); + Assert.Equal(0, TopOf(3) - BottomOf(2) - ((Paragraph)doc.Blocks[2]).MarginBottom, 3); + }); + + // JSON has no NaN: "auto" goes out as no field and comes back as NaN; an explicit 0 stays 0. + [Fact] + public void TheAutoTopMarginRoundTripsThroughJson() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "x" } } }); + doc.Blocks.Add(new TableBlock(1, 1)); + doc.Blocks.Add(new DividerBlock { MarginTop = 0 }); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "y" } } }); + + var back = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(DocumentSerializer.Deserialize(DocumentSerializer.Serialize(doc)))); + + Assert.True(double.IsNaN(back.Blocks.OfType().Single().MarginTop)); + Assert.Equal(0, back.Blocks.OfType().Single().MarginTop); + } +} + +/// The toolbar's margin picker (upstream PR #52, ported 2026-09-24): five steps in millimetres, and a +/// margin that matches no step is stated in millimetres rather than as a step it is not. +[Collection(UiTests.Collection)] +public class ToolbarMarginPickerTests +{ + private const System.Reflection.BindingFlags NP = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + private static string LabelAfterSync(RichEditorToolbar tb) + { + typeof(RichEditorToolbar).GetMethod("Sync", NP)!.Invoke(tb, null); + var label = (Microsoft.UI.Xaml.Controls.TextBlock?)typeof(RichEditorToolbar).GetField("_marginLabel", NP)!.GetValue(tb); + Assert.NotNull(label); // precondition: the picker was built at this level + return label!.Text; + } + + [Theory] + [InlineData(15.0, "MarginNormal")] + [InlineData(5.0, "MarginNarrowest")] + [InlineData(30.0, "MarginWidest")] + public void TheBoxNamesTheStepInForce(double mm, string key) => UiThread.Run(() => + { + var ed = new RichEditor { Document = new FlowDocument(), PageSize = RichEditorPageSize.A4, PageMargin = new PageMargins(mm) }; + var tb = new RichEditorToolbar { Target = ed, ToolbarLevel = ToolbarLevel.Maximum }; + + Assert.Equal(RichEditorLocalization.GetString(key), LabelAfterSync(tb)); + }); + + [Fact] + public void AMarginThatIsNoStep_IsStatedInMillimetres() => UiThread.Run(() => + { + var ed = new RichEditor { Document = new FlowDocument(), PageSize = RichEditorPageSize.A4, PageMargin = PageMargins.Symmetric(25.4, 12.7) }; + var tb = new RichEditorToolbar { Target = ed, ToolbarLevel = ToolbarLevel.Maximum }; + + Assert.Equal("25.4 / 12.7mm", LabelAfterSync(tb)); + }); +} + +/// The right-click margin menu's Top list for an object block starts with "Auto (one line)" — the default +/// since Block.AutoTopMargin — checked while it is in force, and able to restore it (upstream round 34 decision). +[Collection(UiTests.Collection)] +public class MarginMenuAutoTests +{ + private const System.Reflection.BindingFlags NP = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + private static Microsoft.UI.Xaml.Controls.MenuFlyoutSubItem TopList(RichEditor ed, Block target) + { + var sub = (Microsoft.UI.Xaml.Controls.MenuFlyoutSubItem)typeof(RichEditor).GetMethod("MarginMenu", NP)!.Invoke(ed, new object[] { target })!; + return sub.Items.OfType() + .Single(s => s.Text == RichEditorLocalization.GetString("MarginTop")); + } + + [Fact] + public void ATablesTopList_LeadsWithAuto_CheckedByDefault_AndRestoresIt() => UiThread.Run(() => + { + var tb = new TableBlock(1, 1); + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "x" } } }); + doc.Blocks.Add(tb); + var ed = new RichEditor { Document = doc }; + + var first = (Microsoft.UI.Xaml.Controls.RadioMenuFlyoutItem)TopList(ed, tb).Items[0]; + Assert.Equal(RichEditorLocalization.GetString("MarginAuto"), first.Text); + Assert.True(first.IsChecked); + + tb.MarginTop = 10; + System.Func get = () => tb.MarginTop; + System.Action set = v => tb.MarginTop = v; + typeof(RichEditor).GetMethod("PickMargin", NP)!.Invoke(ed, new object[] { get, set, Block.AutoTopMargin }); + Assert.True(double.IsNaN(tb.MarginTop)); + }); + + [Fact] + public void AParagraphsTopList_HasNoAuto() => UiThread.Run(() => + { + var p = new Paragraph { Inlines = { new Run { Text = "x" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(p); + var ed = new RichEditor { Document = doc }; + + Assert.DoesNotContain(TopList(ed, p).Items.OfType(), + i => i.Text == RichEditorLocalization.GetString("MarginAuto")); + }); +} diff --git a/tests/WinUIRichEditor.Tests/RenderPixelTests.cs b/tests/WinUIRichEditor.Tests/RenderPixelTests.cs index 73d56a0..72e81c0 100644 --- a/tests/WinUIRichEditor.Tests/RenderPixelTests.cs +++ b/tests/WinUIRichEditor.Tests/RenderPixelTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Windows.UI; using WinUIRichEditor.Controls; @@ -89,6 +89,47 @@ public void AQuoteInsideACell_DrawsItsBar() Assert.True(bar > none + 20, $"expected a quote bar in the cell: with={bar}, without={none}"); } + // A 1px line is centred on the rect it strokes, so a table's OUTER edge put half its ink outside the table's + // box — and the page's content clip, which starts exactly at that box, cut it (upstream PR #52: at a page + // break a line came out at 67%). The table's own edges are drawn half a pen inside now; shared interior + // edges stay centred. Measured here on the LEFT edge, which sits on the print clip's left side: its darkness + // is compared with the interior line between the two columns, which no clip touches. + [Fact] + public void ATablesOuterEdge_IsDrawnWhole_NotHalvedByThePageClip() + { + var (outer, inner) = UiThread.Run(() => + { + var tb = new TableBlock(1, 2) { MarginTop = 0 }; + tb.ColumnWidths[0] = tb.ColumnWidths[1] = 150; + var doc = new FlowDocument(); + doc.Blocks.Add(P("above")); + doc.Blocks.Add(tb); + var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.A4 }; + using var rt = ed.RenderPrintPage(0, 96); + var px = rt.GetPixelColors(); + int w = (int)rt.SizeInPixels.Width; + // A row through the middle of the table: find the vertical border columns there. + int left = (int)Math.Round(ed.PagePadLeft); + int top = -1; + // The table's top border, under the first cell: past the "above" line (it sits in the first ~17 px). + for (int yy = (int)ed.PagePadTop + 18; yy < rt.SizeInPixels.Height && top < 0; yy++) + if (255 - px[yy * w + left + 75].G > 60) top = yy; + Assert.True(top > 0, "the table was not drawn"); + int row = top + 10; + double Dark(int x) => 255 - px[row * w + x].G; + // The darkest pixel across each line, as a share of the line's own colour (grey 128 on white = 127). + // Not a sum across pixels: a line split over two pixels blends darker in total than one whole pixel + // (measured 0x9F twice vs 0x80 once), which made a whole outer line look like 66% of an interior one. + double o = 0, i = 0; + for (int x = left - 2; x <= left + 2; x++) o = Math.Max(o, Dark(x) / 127.0); + for (int x = left + 148; x <= left + 152; x++) i = Math.Max(i, Dark(x) / 127.0); + return (o, i); + }); + + Assert.True(inner > 0.5, $"the interior line was not found ({inner:P0})"); // precondition + Assert.True(outer >= 0.9, $"the table's outer edge is drawn at {outer:P0} of its colour — clipped"); + } + // The top-level path was never broken; pinning it keeps a future refactor from "fixing" the cell path // by moving the fill out of the loop that still works. [Fact] diff --git a/tests/WinUIRichEditor.Tests/RtfPageChromeTests.cs b/tests/WinUIRichEditor.Tests/RtfPageChromeTests.cs index d49a59e..7fc2df2 100644 --- a/tests/WinUIRichEditor.Tests/RtfPageChromeTests.cs +++ b/tests/WinUIRichEditor.Tests/RtfPageChromeTests.cs @@ -79,8 +79,9 @@ public void Rtf_CarriesHeaderFooterAndPageNumbers() Assert.Contains("Confidential", rtf); // The page number sits at an explicit RIGHT tab stop on the content edge, which is where the // editor draws it. Without the stop it would land on whatever default tab the reader has. - // A4 = 794 DIPs wide, minus two 48-DIP margins, times 15 twips = 10470. - Assert.Contains(@"\tqr\tx10470", rtf); + // A4 = 794 DIPs = 11910 twips wide, minus two default 15 mm margins (850 twips each) = 10210 — the + // same number upstream writes. It was 10470 while the margins were two 48-DIP constants. + Assert.Contains(@"\tqr\tx10210", rtf); Assert.Contains(@"\chpgn", rtf); // Both destinations belong to the document area, BEFORE the body. int header = rtf.IndexOf(@"{\header", System.StringComparison.Ordinal);