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