diff --git a/CHANGELOG.md b/CHANGELOG.md index 566ff3a..767c56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,54 @@ The margins were two constants nothing could reach: a host could pick the paper write on, and an RTF from Word or HWP had its own margins dropped, so a document opened here paginated differently from what its author saw. -- `RichEditor.PageMargin` (a `Thickness`, DIPs, four sides) and `PageSetup.Margin`, defaulting to - `PageSetup.DefaultMargin` — 48 left and right, 40 top and bottom, the previous constants. +- `RichEditor.PageMargin` and `PageSetup.Margin`, four sides in **millimetres** (`PageMargins`), defaulting + to `PageSetup.DefaultMargin` — 15 mm on every side. Millimetres because that is the unit a page is discussed in: paper sizes + are defined in mm, Word and HWP show margins in mm, and RTF carries them as physical length. A dedicated + type rather than Avalonia's `Thickness`, which means device pixels everywhere else in a UI framework. - They belong to the document: saved in JSON/`.flow` (omitted at the default, so a document that never touched them keeps its bytes) and applied on load, like the paper size. - RTF writes them (`\margl`/`\margr`/`\margt`/`\margb`, and the section-level pair HWP reads) and now **reads** them, so a file from another word processor keeps its own margins. - A margin that would leave no page to write on — negative, NaN, or two sides adding up past the paper — is refused: the property keeps its last usable value, and a file falls back to the default. +- A margin band too thin for the header, footer or page number leaves it undrawn, rather than centring the + line half off the paper and half over the body text. The margins stay exactly what was asked for. +- The toolbar's page controls gained a **margin picker** beside paper and orientation, so the person using + an app built on `RichEditorView` can change them too: five steps in millimetres (5 / 10 / 15 / 20 / 30) + in a box built like the line-spacing control — the margin icon, the current step, a chevron for the list. Margins that match no preset (a host's, or a document's) select nothing rather + than showing one that is not the page's. New icon slot `RichEditorIcon.PageMargin`. + +### Changed — a table, picture or divider sits one line gap below the text above it (2026-09-21) + +Reported from the demo: a table sat flush against the paragraph above, with none of the air the line +spacing gives between two lines of text. Both ends were at zero — paragraphs carry no bottom margin +(HWP-style) and these blocks carried no top one. + +- `Block.AutoTopMargin` (NaN) is the new default `MarginTop` for `TableBlock`, `ImageBlock` and + `DividerBlock`: the editor resolves it to one line gap of body text, so it follows the document's font + size and line spacing. A stated margin, **0 included**, is used as given. +- JSON writes nothing for it and reads a missing top margin back as auto, so a file that never expressed + an opinion — every file written before the field existed — gains the gap. + +### Fixed — a picture's own outline was painting over its edge (2026-09-23) + +Reported from the demo: at a high zoom, a picture looked cut by a pixel or two. Nothing clipped it — a +picture carries a faint outline marking it as an object, and a pen is centred on the rect it strokes, so +half of that line lay on the picture and replaced its outermost half-pen on every side. The bold border of +a selected picture, and the one around a picture inside a table cell or a selected inline icon, did the +same with twice the weight. All of them now sit half a pen outside the picture, touching none of its +pixels. (A table's borders go the other way — see below — because there the line is the table's own ink.) +That moved the top line of a picture opening a page just above the page's content clip, which cut it off; +the outline, selection border and handles are now drawn after the page's content, bounded by the paper. + +### Fixed — a table's outline was cut where a page break crossed it (2026-09-20) + +Reported from the demo. A 1px pen is centred on the rect it strokes, so a cell on the table's edge put half +its line outside the table's own box; a page break lands on that box, and the page's clip cut the line in +two — part of its weight at the bottom of one page, the rest at the top of the next (measured: 67% of a +whole line). The edges that are the table's boundary are now drawn half a pen inwards, which keeps a +table's ink inside the box pagination knows about — and lands those lines on whole pixels, so they come out +crisper as well. Interior borders, shared by two cells, are unchanged. ### Added — the keyboard shortcut table is public (2026-09-20) diff --git a/README.ko.md b/README.ko.md index fc47bc3..bf7d074 100644 --- a/README.ko.md +++ b/README.ko.md @@ -123,7 +123,7 @@ Editor.FontFamilyChoices = new[] { "Segoe UI", "Arial", "맑은 고딕" }; // - 인라인 및 블록 **이미지** — 삽입, 크기 조절(모서리 또는 가로·세로 한 변 손잡이), 교체, 저장, 대체 텍스트 - 워드 스타일 **페이지 뷰**: `PageSize`(기본 Continuous, 또는 A4/A3/A5/B4/B5/Letter/Legal/Tabloid), - `PageOrientation`, `PageMargin`(네 변), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호 + `PageOrientation`, `PageMargin`(네 변, mm), `ShowPageBoundaries`, 줄 단위 페이지 나누기, 머리글/바닥글/쪽번호 - 페이지 설정은 **문서 단위로 저장**되고(`FlowDocument.PageSetup`) 불러올 때 다시 적용됩니다 — 워드프로세서와 같습니다 - **인쇄 및 PDF**: 페이지별 렌더링(`RenderPrintPage`, 300 DPI)과 글자를 선택·검색할 수 있는 PDF diff --git a/README.md b/README.md index 44dd891..405ccf4 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ for a full editor host. - Inline and block **images** — insert, resize (corner or single-edge handles), replace, save, alt text - Word-style **page view**: `PageSize` (Continuous by default, or A4/A3/A5/B4/B5/Letter/Legal/Tabloid), - `PageOrientation`, `PageMargin` (four sides), `ShowPageBoundaries`, line-boundary page breaks, + `PageOrientation`, `PageMargin` (four sides, in mm), `ShowPageBoundaries`, line-boundary page breaks, headers/footers/page numbers - Page setup is **persisted per document** (`FlowDocument.PageSetup`) and re-applied on load, like a word processor diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md index 2f76c35..98a9597 100644 --- a/docs/DOCUMENT_FORMAT.md +++ b/docs/DOCUMENT_FORMAT.md @@ -64,15 +64,16 @@ FlowDocument "Header": null, // 머리글 텍스트(없으면 생략) "Footer": null, // 바닥글 텍스트(없으면 생략) "ShowPageNumbers": false, - "MarginLeft": 96, // 페이지 여백 px(DIP), 변마다 하나. 기본값이면 생략 - "MarginTop": 80, // 기본 좌우 48 · 상하 40 - "MarginRight": 96, - "MarginBottom": 80 + "MarginLeft": 25, // 페이지 여백 **mm**, 변마다 하나. 기본값이면 생략 + "MarginTop": 20, // 기본 좌우 12.7mm(=0.5인치) · 상하 10.6mm + "MarginRight": 25, + "MarginBottom": 20 } } ``` -- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 변마다 하나이며 **기본값(좌우 48 · 상하 40)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다. +- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 단위는 **밀리미터**다 — 용지 크기가 mm로 정의되고 Word·아래한글도 mm로 보여 주며, RTF는 물리 길이(twips)로 싣는다(렌더는 96dpi DIP로 환산: 1mm = 96/25.4 ≈ 3.7795px). 변마다 하나이며 **기본값(좌우 12.7 · 상하 10.6mm)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다. + - RTF는 정수 twips라 mm가 정확히 왕복되지 않는다(25mm → 1417 → 24.994). 오차는 최대 1 twip(≈0.018mm). - **`PageSetup`(선택)**: 워드프로세서식 페이지 설정. 로드 시 에디터의 용지/방향/머리글·바닥글/쪽번호 속성에 적용되고, 이후 페이지 속성을 바꾸면 문서로 다시 캡처된다. **기본 상태(용지 `Continuous`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음. #### 버전 이력 diff --git a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml index 321c1c4..d8205cc 100644 --- a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml +++ b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml @@ -15,7 +15,17 @@ behaviour (right-click Copy on a table, the table-border click) can only be checked in a viewer. --> - + + + + + + + + + diff --git a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs index da24159..fb6cdd6 100644 --- a/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs +++ b/samples/AvaloniaRichEditor.Demo/Views/MainWindow.axaml.cs @@ -32,8 +32,29 @@ public MainWindow() Editor.IsReadOnly = ReadOnlyToggle.IsChecked == true; Editor.ShowFormattingMenu = !Editor.IsReadOnly; }; + + // Page margins (1.3.0). The view's toolbar has paper and orientation but no margins, and the + // margin band is where the header, the footer and the page number are drawn — the asymmetric + // preset is the one that tells "four sides" apart from "two", on screen and in print. + MarginPicker.ItemsSource = MarginPresets.ConvertAll(p => p.Label); + MarginPicker.SelectedIndex = 0; + MarginPicker.SelectionChanged += (_, _) => + { + int i = MarginPicker.SelectedIndex; + if (i >= 0 && i < MarginPresets.Count) Editor.PageMargin = MarginPresets[i].Margin; + }; } + // Millimetres, like the toolbar's own picker — these exist to reach shapes the toolbar's five steps + // do not: a band too thin for the header, and an asymmetric one (which tells "four sides" from "two"). + private static readonly System.Collections.Generic.List<(string Label, AvaloniaRichEditor.Documents.PageMargins Margin)> MarginPresets = + [ + ("기본 12.7 / 10.6mm", AvaloniaRichEditor.Documents.PageSetup.DefaultMargin), + ("아주 좁게 4 / 3mm", new AvaloniaRichEditor.Documents.PageMargins(4, 3, 4, 3)), + ("아주 넓게 40 / 30mm", new AvaloniaRichEditor.Documents.PageMargins(40, 30, 40, 30)), + ("비대칭 좌42 상6 우8 하31mm", new AvaloniaRichEditor.Documents.PageMargins(42, 6, 8, 31)), + ]; + protected override void OnOpened(EventArgs e) { base.OnOpened(e); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs index a82e49e..9caf589 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs @@ -57,7 +57,7 @@ public partial class RichEditor double yOffset = 0, maxWidth = ContentLayoutWidth; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out var ft, out var tl); yOffset += h + block.MarginBottom; @@ -260,6 +260,24 @@ private static TableLayout AssembleTableLayout(TableBlock tb, double[] colX, dou return new TableLayout(colX, rowY, colX[cols] - startX, rowY[rows] - top, anchors); } + // The gap above a block, which every walker advances by before the block's own box starts. + // + // Normally the block's MarginTop. A table whose margin is unset (NaN — TableBlock.AutoMarginTop) gets + // ONE LINE GAP instead: the white space a line break leaves between two lines of body text. Without it + // a table butts straight against the paragraph above, because paragraphs carry no bottom margin + // (HWP-style, round 24) and a table carried no top one, so the two met at zero (reported from the + // demo, 2026-09-21). An explicit margin, including 0, is used as given. + // + // Sized from the DOCUMENT's default typography rather than from the block above: every walker below + // iterates blocks without tracking a predecessor, and several `continue` past culled ones, so a + // "previous block" would go stale in exactly the walkers that skip — the drift class G1/G2 cleaned up. + // The cost is that a table under a big heading gets a body-text gap, not a heading-sized one. + internal double TopGapOf(Block block) + => double.IsNaN(block.MarginTop) ? AutoBlockTopGap : block.MarginTop; + + // One line gap of body text: the line box (font size x spacing) minus the text it holds. + internal double AutoBlockTopGap => Math.Max(0, PtToPx(DefaultFontSize) * (DefaultLineSpacing - 1)); + // G1 — single source of a block's vertical extent (height, EXCLUDING MarginTop/MarginBottom) at the // given top, plus the layout objects the walkers reuse (a paragraph's TextLayout / a table's // TableLayout; null otherwise). Every read-only document walk — measure, hit-tests, block-at-y — @@ -308,7 +326,7 @@ private double BlockExtent(Block block, double maxWidth, double top, double yOffset = 0, listIndent = 10, maxWidth = ContentLayoutWidth; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out _, out var tl); yOffset += h + block.MarginBottom; @@ -332,7 +350,7 @@ private double BlockExtent(Block block, double maxWidth, double top, double yOffset = 0, maxWidth = ContentLayoutWidth; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out _, out var tl); if (block == target && tl is { } t) return (top, t); @@ -381,7 +399,7 @@ private bool IsOnTableLeftOrTopBorder(TableBlock tb, Point p) const double m = 4; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out _, out var tl); yOffset += h + block.MarginBottom; @@ -662,7 +680,7 @@ private TextPointer GetPositionFromPoint(Point p) foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out var ft, out var tl); yOffset += h + block.MarginBottom; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs index 0dfde2d..dfa13e0 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs @@ -1803,7 +1803,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow) bool found = false; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); if (found) return yOffset; double h = BlockExtent(block, maxWidth, yOffset, out _, out _); found = ReferenceEquals(block, p); @@ -1818,7 +1818,7 @@ private bool TryEnterTableRow(TableBlock tb, bool firstRow) double yOffset = 0, maxWidth = ContentLayoutWidth; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double top = yOffset; double h = BlockExtent(block, maxWidth, top, out _, out _); // Only image/table blocks are "entered" by Up/Down arrow navigation. diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs index 72b1db3..cfb11da 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs @@ -116,17 +116,17 @@ private void CapturePageSetupToDocument() // across paper sizes. // Aliases of the shared page geometry (Documents.PageSetup): the RTF writer needs the same numbers // for its footer tab stop and cannot read them off a control type without dragging the control in. - /// The page margins in DIPs — the band between the paper's edge and the text, where the - /// header, footer and page number are drawn. Four sides, as Word, HWP and RTF have them; it is part of - /// the document's , so it is saved with the document and applied on + /// The page margins in MILLIMETRES — the band between the paper's edge and the text, where + /// the header, footer and page number are drawn. Four sides, as Word, HWP and RTF have them; it is part + /// of the document's , so it is saved with the document and applied on /// load. Only meaningful for a concrete paper size (Continuous reflows to the control's width). - public static readonly StyledProperty PageMarginProperty = - AvaloniaProperty.Register(nameof(PageMargin), Documents.PageSetup.DefaultMargin, + public static readonly StyledProperty PageMarginProperty = + AvaloniaProperty.Register(nameof(PageMargin), Documents.PageSetup.DefaultMargin, coerce: CoercePageMargin); - /// Gets or sets the page margins (DIPs, four sides). Defaults to + /// Gets or sets the page margins (millimetres, four sides). Defaults to /// . - public Thickness PageMargin + public PageMargins PageMargin { get => GetValue(PageMarginProperty); set => SetValue(PageMarginProperty, value); @@ -136,17 +136,19 @@ public Thickness PageMargin // the layout width zero or negative, and every page-view measurement divides by it. A styled property // is reachable from XAML and from a binding, so the value is refused HERE rather than guarded at each // of the dozen places that read it; the editor keeps the last usable margins. - private static Thickness CoercePageMargin(AvaloniaObject o, Thickness value) + private static PageMargins CoercePageMargin(AvaloniaObject o, PageMargins value) { var ed = (RichEditor)o; - var (w, h) = Documents.PageSetup.PaperDips(ed.PageSize, ed.PageOrientation); + var (w, h) = Documents.PageSetup.PaperMillimetres(ed.PageSize, ed.PageOrientation); return Documents.PageSetup.IsUsableMargin(value, w, h) ? value : ed.PageMargin; } - internal double PagePadLeft => PageMargin.Left; - internal double PagePadRight => PageMargin.Right; - internal double PagePadTop => PageMargin.Top; - internal double PagePadBottom => PageMargin.Bottom; + // Layout is in DIPs; the page setting is in millimetres. Every reader of the margins goes through + // these four, so the conversion lives in one place. + internal double PagePadLeft => PageMargin.LeftDips; + internal double PagePadRight => PageMargin.RightDips; + internal double PagePadTop => PageMargin.TopDips; + internal double PagePadBottom => PageMargin.BottomDips; // Grey-desk gap above the first page and between consecutive pages in page-outline view. Kept thin // (~2 pt) so pages sit close together with just a sliver of desk between them, rather than a wide // grey band. The whole page-stack layout (MeasureOverride height, PageRectView, MapViewToDoc) is @@ -258,6 +260,13 @@ public bool ShowPageNumbers // Header/footer/page number, drawn inside the paper's margin bands (never the content box, so // pagination is unaffected). `paper` is the page rect in the caller's coordinate space — the // page-view loop passes view coordinates, RenderPrintPage passes the page at the origin. + // + // A band too thin to hold the line is left empty (user decision, 2026-09-20). The band was a constant + // 40 until the margins became a document setting, so the line always fitted; at a 12 DIP band an 11pt + // line is centred from -1 to 13 — drawn off the paper onto the desk at one end and over the body text + // at the other (seen in the demo). Skipping keeps the margins EXACTLY what was asked for, which + // pushing the body down (Word's answer) would not, and a header that is not there is visible enough to + // undo; centring inside a band that fits also keeps the line on the paper by construction. private void DrawPageMarginChrome(DrawingContext ctx, Rect paper, int pageIndex, int pageCount) { var typeface = new Avalonia.Media.Typeface(DefaultFontFamily); @@ -265,6 +274,8 @@ void DrawSmall(string text, bool top, bool right) { var ft = new Avalonia.Media.FormattedText(text, System.Globalization.CultureInfo.CurrentCulture, Avalonia.Media.FlowDirection.LeftToRight, typeface, 11, Avalonia.Media.Brushes.Gray); + double band = top ? PagePadTop : PagePadBottom; + if (ft.Height > band) return; double x = right ? paper.X + PagePadLeft + PaperContentWidth - ft.Width : paper.X + PagePadLeft; double bandCenter = top ? paper.Y + PagePadTop / 2 : paper.Bottom - PagePadBottom / 2; ctx.DrawText(ft, new Point(x, bandCenter - ft.Height / 2)); @@ -521,7 +532,7 @@ void PlaceAtom(double height) foreach (var block in Document.Blocks) { - y += block.MarginTop; + y += TopGapOf(block); // Block height + layout objects come from the single source (G1 BlockExtent), so the // vertical advance here can never drift from MeasureContentHeight / the hit-tests. Only the // *within-block* atom split (table rows, paragraph lines) is pagination-specific and stays. diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs index 61d837f..36846ec 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs @@ -43,7 +43,7 @@ private double MeasureContentHeight(double width) double yOffset = 0; foreach (var block in Document.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); double h = BlockExtent(block, width, yOffset, out _, out _); // While the IME composes, the render walk advances by the caret paragraph's height WITH the // preedit spliced in, so the extent has to as well — otherwise the scrollable range stays a @@ -187,6 +187,7 @@ public override void Render(DrawingContext context) { // Continuous (Free): one walk at the control width. (caretPoint, caretHeight, blockCaretRect) = DrawDocumentBlocks(context, Bounds.Width, visTop, visBottom); + FlushPictureChrome(context); } else if (!ShowPageBoundaries) { @@ -216,6 +217,11 @@ public override void Render(DrawingContext context) if (cp != null) { caretPoint = cp; caretHeight = ch; } if (bcr != null) blockCaretRect = bcr; } + // No paper to bound it here: the page's own column, widened into half the gap on each side. + using (context.PushClip(new Rect(NoChromeColX - 4, viewTop - NoChromePageGap / 2, + PaperContentWidth + 8, clipH + NoChromePageGap))) + using (context.PushTransform(Matrix.CreateTranslation(NoChromeColX, viewTop - sliceTop))) + FlushPictureChrome(context); } } else @@ -253,6 +259,9 @@ public override void Render(DrawingContext context) if (cp != null) { caretPoint = cp; caretHeight = ch; } if (bcr != null) blockCaretRect = bcr; } + using (context.PushClip(paper)) + using (context.PushTransform(Matrix.CreateTranslation(dx, contentBox.Y - sliceTop))) + FlushPictureChrome(context); } } @@ -270,6 +279,43 @@ public override void Render(DrawingContext context) // The document block walk: selection highlights, table grids, paragraphs, images, dividers, // resize-handle registration and caret geometry, all in continuous document coordinates. + // A 1px pen is centred on the rect it strokes, so a cell on the table's edge puts half its line OUTSIDE + // the table's own box. A page break lands exactly on that box, and the page's clip then cut the line in + // two — part of its weight on one page, the rest on the next (measured 2026-09-20: 67% of a whole line; + // reported from the demo). Pulling the edges that ARE the table's boundary half a pen inwards keeps all + // of a table's ink inside the box pagination knows about, and lands those lines on whole pixels, so they + // also come out crisper. Interior edges are shared by two neighbouring cells and stay centred — + // insetting those would draw each shared line twice, a pixel apart. + // The rect to stroke so a pen of this thickness lands just OUTSIDE box, touching none of its pixels. + // A pen is centred on the rect it strokes, so an outline drawn on a picture's own rect paints over the + // picture's outermost half-pen on every side — reported from the demo at a high zoom (2026-09-23) as the + // picture being "cut by a pixel or two". A table's borders go the other way (InsetTableEdges): there the + // line IS the table's own ink and has to stay inside the box pagination knows about, while a picture's + // outline is a marker drawn around content that must survive intact. + private static Rect Around(Rect box, double thickness) + => box.Inflate(thickness / 2); + + // Picture outlines, selection borders and handles queued by a block walk, in its own coordinates. Drawn + // after it under a looser clip than the content's, because they lie OUTSIDE the picture — and a picture + // opening a page sits exactly on the page's content clip, which cut its top border off (2026-09-23). + private readonly List> _pictureChrome = new(); + + private void FlushPictureChrome(DrawingContext context) + { + foreach (var draw in _pictureChrome) draw(context); + _pictureChrome.Clear(); + } + + private static Rect InsetTableEdges(Rect cell, Rect table) + { + const double half = 0.5, eps = 0.01; + double left = cell.X + (Math.Abs(cell.X - table.X) < eps ? half : 0); + double top = cell.Y + (Math.Abs(cell.Y - table.Y) < eps ? half : 0); + double right = cell.Right - (Math.Abs(cell.Right - table.Right) < eps ? half : 0); + double bottom = cell.Bottom - (Math.Abs(cell.Bottom - table.Bottom) < eps ? half : 0); + return new Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top)); + } + // Page view replays this once per visible page under a clip+translation, with the page's // document slice as the cull window; the continuous mode calls it once with the viewport. // chrome=false (print/export rendering) draws content only: no selection highlights, caret @@ -315,7 +361,7 @@ public override void Render(DrawingContext context) foreach (var block in Document!.Blocks) { - yOffset += block.MarginTop; + yOffset += TopGapOf(block); // G1 P5: source each block's height + layout objects from the single BlockExtent pass — the // same one measure/hit-tests/pagination consume — so the render walk can never drift from // them on a block's height. Drawing, culling, caret/selection and the per-cell IME-preedit @@ -346,6 +392,8 @@ public override void Render(DrawingContext context) // (Excel/Word style) instead of the linear text run; otherwise fall back to text highlight. var cellBlock = chrome ? SelectedCellRange(tb) : null; + var tableBox = new Rect(startX, tableTop, tl.TableWidth, tl.TotalHeight); + foreach (var (r, c, rect) in tl.AnchorRects) { var cell = tb.Cells[r][c]; @@ -353,7 +401,7 @@ public override void Render(DrawingContext context) if (cell.Background != null) context.FillRectangle(cell.Background, rect); - context.DrawRectangle(null, GrayBorderPen, rect); + context.DrawRectangle(null, GrayBorderPen, InsetTableEdges(rect, tableBox)); // A cell is in "cell-selection mode" when it's part of a multi-cell drag block, or its // whole content is selected (Tab focus / triple-click). Such cells show a fill and NO @@ -520,26 +568,34 @@ public override void Render(DrawingContext context) if (chrome) { bool imgSelected = ReferenceEquals(img, _selectedBlock); - if (imgSelected) + // Selection: translucent overlay (inside the picture, so the page clip suits it). + if (imgSelected) context.FillRectangle(AccentFill60, imgRect); + // The outline, selection border and handles lie AROUND the picture, and a picture + // that opens a page sits right on the page's clip — which cut off its top border + // (reported from the demo, 2026-09-23). They are drawn after the page's content, under + // the paper's clip instead (DeferPictureChrome). Only from the replay whose slice holds + // the picture: a selected one is drawn by every page's replay, clipped away elsewhere. + if (imgRect.Bottom > visTop && imgRect.Top < visBottom) { - // Selection: translucent overlay + bold border. - context.FillRectangle(AccentFill60, imgRect); - context.DrawRectangle(null, AccentPen2, imgRect); + var handles = imgSelected ? PictureHandles(imgRect, 12).Select(h => h.knob).ToArray() : null; + _pictureChrome.Add(ctx => + { + if (handles != null) ctx.DrawRectangle(null, AccentPen2, Around(imgRect, 2)); + // A faint outline marks the picture as an object at all times; the resize + // handle appears only once it is SELECTED, the way Word and HWP do it. An + // always-on handle put a solid accent square on every picture — in a read-only + // viewer that cannot resize anything, and in any screenshot of the document. + ctx.DrawRectangle(null, AccentBorderPen, Around(imgRect, 1)); + if (handles != null) + foreach (var knob in handles) ctx.FillRectangle(AccentHandleFill, knob); + }); } - // A faint outline marks the picture as an object at all times; the resize handle - // appears only once it is SELECTED, the way Word and HWP do it. An always-on - // handle put a solid accent square on every picture — in a read-only viewer that - // cannot resize anything, and in any screenshot of the document. - context.DrawRectangle(null, AccentBorderPen, imgRect); if (imgSelected) { // Registered with the drawn handles so there is never a grabbable area with // nothing under the pointer to explain it. Slightly larger for easy grabbing. - foreach (var (knob, grab, grip) in PictureHandles(imgRect, 12)) - { - context.FillRectangle(AccentHandleFill, knob); + foreach (var (_, grab, grip) in PictureHandles(imgRect, 12)) _imageHandles.Add((grab, img, width, height, grip)); - } } } @@ -679,9 +735,9 @@ private void DrawCellBlockList( if (ReferenceEquals(cimg, _selectedBlock)) { context.FillRectangle(AccentFill60, ir); - context.DrawRectangle(null, AccentPen2, ir); + context.DrawRectangle(null, AccentPen2, Around(ir, 2)); } - context.DrawRectangle(null, AccentBorderPen, ir); + context.DrawRectangle(null, AccentBorderPen, Around(ir, 1)); if (ReferenceEquals(cimg, _selectedBlock)) // handle on selection only { foreach (var (knob, grab, grip) in PictureHandles(ir, 12)) @@ -840,7 +896,7 @@ private void RegisterInlineImages(DrawingContext context, Paragraph p, TextLayou _inlineImageRects.Add((ir, p, ii)); if (_selectedInline is { } sel && ReferenceEquals(sel.img, ii)) { - context.DrawRectangle(null, AccentPen2, ir); + context.DrawRectangle(null, AccentPen2, Around(ir, 2)); foreach (var (knob, grab, grip) in PictureHandles(ir, 10)) { context.FillRectangle(Brushes.White, knob); diff --git a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs index 67d6b34..c7cd500 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs @@ -115,6 +115,8 @@ public enum RichEditorIcon FontSizeDecrease, /// Find / replace (the toolbar's find button, from the WinUI port). Find, + /// Page margins (the toolbar's margin picker). + PageMargin, } /// diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs index 98269cd..78b4f4a 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs @@ -6,6 +6,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using AvaloniaRichEditor.Documents; using AvaloniaRichEditor.Formatters; namespace AvaloniaRichEditor.Controls; @@ -60,6 +61,9 @@ public ToolbarLevel ToolbarLevel // ---- page / zoom ------------------------------------------------------ private ComboBox? _zoomCombo, _paperCombo, _orientCombo; + private Border? _marginBox; + private TextBlock? _marginLabel; + private readonly System.Collections.Generic.List