using System.Drawing.Drawing2D; using EnvelopeRenderer.Desktop.Core.Design; namespace EnvelopeRenderer.Desktop.Views; /// /// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"): /// draws the page and its text elements, and lets the operator select/drag-reposition them with /// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with /// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element /// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic /// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/ /// drag/rotate state live in // /// (all framework-free and unit tested in /// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse /// events, since that part genuinely cannot be extracted from WinForms. /// public sealed class TemplateCanvasControl : Control { private readonly TemplateLayoutDocument _document; private readonly CanvasElementEditor _editor; private AddressControlLayout? _selectedAddressControl; private int _selectedAddressLineIndex; private (double Dx, double Dy)? _addressControlDragOffset; private bool _isResizingAddressControl; /// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": /// mirrors 's shape — a control-specific drag-gesture /// flag, paralleling (not reusing) , which only /// ever operates on a selected standalone . private bool _isRotatingAddressControl; /// The currently loaded CSV's headers and one representative sample record, used /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) — /// empty/null until is called after a CSV loads. private IReadOnlyList _csvHeaders = Array.Empty(); private IReadOnlyDictionary? _csvSampleRecord; public event EventHandler? SelectionChanged; public event EventHandler? ElementsChanged; public TemplateCanvasControl(TemplateLayoutDocument document) { _document = document; _editor = new CanvasElementEditor(document, MeasureElement); DoubleBuffered = true; BackColor = SystemColors.ControlDark; SetStyle(ControlStyles.ResizeRedraw, true); } /// Sprint 7, "Snap elements to grid and guides": mirrors /// so the same toggle governs standalone /// element dragging (handled inside ) and Address Control /// move/resize (handled directly in this control's mouse handlers below) uniformly, and so /// this control knows whether to paint grid lines. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] public bool SnapToGridEnabled { get => _editor.SnapToGridEnabled; set { _editor.SnapToGridEnabled = value; Invalidate(); } } /// Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid /// lines are painted at, when is true. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] public double GridSizePoints { get => _editor.GridSizePoints; set { _editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints; Invalidate(); } } /// True while an active move, resize, or rotate gesture is in progress on the /// canvas — a standalone element drag/rotate () or an /// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets /// skip its full properties-panel refresh (which /// repopulates the rebind-column combo box from every loaded CSV header on each call — real /// work only for a single-column-bound dynamic element) on every mouse-move tick of a /// gesture, syncing just the position/angle fields the gesture can actually change instead. /// Mirrors the exact condition already used inline before this /// property existed. public bool IsInteracting => _editor.IsDragging || _editor.IsRotating || _editor.IsResizingFontSize || _addressControlDragOffset is not null || _isResizingAddressControl || _isRotatingAddressControl; public TextElementLayout? SelectedElement => _editor.Selected; public AddressControlLayout? SelectedAddressControl => _selectedAddressControl; public int SelectedAddressLineIndex => _selectedAddressLineIndex; public AddressControlLineLayout? SelectedAddressLine => _selectedAddressControl is not null && _selectedAddressLineIndex >= 0 && _selectedAddressLineIndex < _selectedAddressControl.Lines.Count ? _selectedAddressControl.Lines[_selectedAddressLineIndex] : null; public TextElementLayout AddStaticTextElement() { var (x, y) = DefaultNewElementPosition(); var element = _editor.AddStaticText(x, y); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return element; } public AddressControlLayout AddAddressControl() { var (x, y) = DefaultNewElementPosition(); var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder()); _document.AddressControls.Add(control); SelectAddressControl(control, 0); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return control; } public void AddAddressLine() { if (_selectedAddressControl is null) { return; } _selectedAddressControl.AddLine(); _selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); } public void RemoveSelectedAddressLine() { if (_selectedAddressControl is null) { return; } if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex)) { _selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); } } public void SelectAddressLine(int lineIndex) { if (_selectedAddressControl is null) { return; } _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1)); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); } public void MoveSelectedAddressLineUp() { if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex)) { _selectedAddressLineIndex--; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); } } public void MoveSelectedAddressLineDown() { if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex)) { _selectedAddressLineIndex++; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); } } public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column") { var (x, y) = DefaultNewElementPosition(); var element = _editor.AddDynamicPlaceholder(x, y, columnName); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return element; } /// Sprint 4: supplies the loaded CSV's headers and one representative sample record /// (typically the first loaded sample row) so the canvas can preview address-line collapsing /// and flag mapping errors exactly the way a real render would. Pass an empty header list and /// null record to clear the preview context (e.g. nothing loaded yet). public void SetCsvPreviewContext(IReadOnlyList headers, IReadOnlyDictionary? sampleRecord) { _csvHeaders = headers; _csvSampleRecord = sampleRecord; Invalidate(); } /// Re-selects the given element (e.g. after the properties panel changes it) and /// redraws — used so external edits stay visually in sync with the canvas. public void NotifyElementChanged() { Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); } /// Clears the current selection and redraws — used after reopening a saved template /// (Batch 5), since a freshly loaded document's elements are new object instances and any /// previously selected element instance no longer belongs to it. public void ClearSelection() { _editor.Select(null); _selectedAddressControl = null; _selectedAddressLineIndex = 0; _addressControlDragOffset = null; _isResizingAddressControl = false; _isRotatingAddressControl = false; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); } private (double X, double Y) DefaultNewElementPosition() { // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly // on top of the last one. var count = _document.Elements.Count + _document.AddressControls.Count; var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20); var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10); return (x, y); } private CanvasViewTransform CurrentTransform() => CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height); protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; var transform = CurrentTransform(); var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints); var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0); var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom); g.FillRectangle(Brushes.White, pageRect); g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height); // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so // alignment is visible, not just felt during a drag — drawn under every element so it // never obscures selection/rotate-handle/mapping-warning visuals. if (SnapToGridEnabled) { DrawGrid(g, transform); } // Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render // time, run here against the loaded CSV's sample record so the canvas preview and the // final PDF agree (the story's "Preview and final render must agree" conversation note). var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord); var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>(); paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null))); paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c))); foreach (var item in paintItems.OrderBy(i => i.ZOrder)) { if (item.Text is not null) { var state = previewStates[item.Text.Id]; if (!state.Visible) { continue; } DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected)); continue; } DrawAddressControl( g, transform, item.Control!, isSelected: ReferenceEquals(item.Control, _selectedAddressControl)); } if (_editor.Selected is not null) { DrawResizeHandle(g, transform, _editor.Selected); DrawRotateHandle(g, transform, _editor.Selected); } } /// Sprint 7: draws light dotted grid lines across the page at every /// interval, in both directions, so an operator can see the /// alignment grid snapping is rounding positions to. private void DrawGrid(Graphics g, CanvasViewTransform transform) { var gridSize = GridSizePoints; if (gridSize <= 0) { return; } using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1) { DashStyle = DashStyle.Dot, }; for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize) { var (x1, y1) = transform.ToPixels(x, 0); var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints); g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2); } for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize) { var (x1, y1) = transform.ToPixels(0, y); var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y); g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2); } } private void SelectAddressControl(AddressControlLayout control, int lineIndex) { _selectedAddressControl = control; _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1)); _editor.Select(null); } private void ClearAddressSelection() { _selectedAddressControl = null; _selectedAddressLineIndex = 0; _addressControlDragOffset = null; _isResizingAddressControl = false; _isRotatingAddressControl = false; } private void DrawElement( Graphics g, CanvasViewTransform transform, TextElementLayout element, ElementPreviewState state, bool isSelected) { var effectiveY = state.EffectiveY; using var font = ResolveFont(element.FontFamily, (float)element.FontSize); var (width, height) = MeasureElement(element); // Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points, // bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so // the pixel position to draw the string's top-left corner at is the transform of // (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's // address-line collapsing has shifted it (see AddressBlockPreviewCalculator). var (drawX, drawY) = transform.ToPixels(element.X, effectiveY + height); GraphicsState? savedState = null; if (element.RotationAngle != 0) { var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height); var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY); savedState = g.Save(); g.TranslateTransform((float)pivotXPx, (float)pivotYPx); // GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this // Y-down pixel space. The stored RotationAngle uses the opposite convention — // counterclockwise-positive, confirmed empirically against the real Debenu DLL (see // RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle // is negated here to keep the canvas rotating the same visual direction the final PDF // will, per this story's "same rotation, around the same pivot, as shown in the // designer canvas" acceptance criterion. g.RotateTransform((float)-element.RotationAngle); g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx); } try { var boxHeight = height * transform.Scale; using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B)); if (element.HasBox) { // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own // rectangle-bounded DrawString wraps natively within the box, the design-time // approximation of the real CLI render's Debenu-native wrap (see this story's // sizing note — the two engines have no shared line-breaking code path, so this is // an accepted approximation, not a guarantee of matching the exact wrap points). // The per-run highlight segmentation below is a single-line layout and does not // yet extend across wrapped lines — left as a known, documented visual // approximation gap for this story; a per-run-aware wrapped highlight is polish- // tier scope for a later story, not required here. var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight); g.DrawString(element.DisplayText, font, brush, boxRect); } else { // Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a // visible placeholder highlight per field-run *segment* rather than one // whole-element box — literal text within a mixed element gets no fill at all, so // an operator can see exactly which portion(s) of the line are field tokens versus // literal text. Sprint 4's unmapped-column warning color is now decided per run // (see AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns) // rather than for the whole element, so a mixed element with one bad token among // several good ones only flags that one segment. var segments = MeasureRunSegments(element, font); var cumulativeWidth = 0.0; for (var i = 0; i < segments.Count; i++) { var (run, segmentWidth) = segments[i]; if (run.IsField) { var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i]; var fillColor = isRunUnmapped ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed) : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue); using var dynamicFill = new SolidBrush(fillColor); var segX = drawX + (cumulativeWidth * transform.Scale); var segBoxWidth = segmentWidth * transform.Scale; g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight); } cumulativeWidth += segmentWidth; } g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY); } if (state.IsUnmappedColumn) { using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot }; g.DrawRectangle( warnPen, (float)drawX - 1, (float)drawY - 1, (float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2); } else if (element.CollapseIfBlank) { // A small marker for a configured-collapsible field, regardless of whether it // happens to be blank right now — lets an operator see at a glance which fields // are configured to collapse, without needing to select each one individually. using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2); var badgeY = (float)(drawY + (height * transform.Scale) + 2); var badgeWidth = (float)Math.Min(width * transform.Scale, 16); g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY); } if (isSelected) { var selWidth = width * transform.Scale; var selHeight = height * transform.Scale; using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash }; g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4); } } finally { if (savedState is not null) { g.Restore(savedState); } } } /// Post-Sprint-8 user-requested feature: draws the font-size resize handle at the /// selected element's top-right corner (, /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white- /// outline style already used for the Address Control's resize handle, deliberately distinct /// from the rotate handle's round dot so the two are never confused at a glance. private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element) { var handle = _editor.ResizeHandlePosition(); if (handle is null) { return; } var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y); var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8); using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen); using var outline = new Pen(System.Drawing.Color.White, 1); g.FillRectangle(brush, rect); g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height); } /// Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small /// dot connected to the selected element's bounding-box center by a dotted line, at the /// world-space position computes (which /// already accounts for the element's current rotation) — the same position /// checks against, so what's drawn is exactly /// what's draggable. private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element) { var handle = _editor.HandlePosition(); if (handle is null) { return; } var (width, height) = MeasureElement(element); var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height); var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY); var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y); using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot }; g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy); const float radius = 5f; using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen); using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f); g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2); g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2); } /// Sprint 8, "Rotate the whole Address Control as a single unit": when /// is non-zero, the entire method body below /// — the box border, every line's text and per-line highlight/selection overlay, and the /// resize handle — is drawn under one GDI+ rotation transform around /// , the same way already /// rotates a standalone element's whole draw call. Because every pixel-space draw call inside /// this method (box, lines, handle) shares that one transform, they all visually rotate /// together as one rigid unit — matching what and /// independently confirm by rotating the click point /// back into this same unrotated local space before testing. private void DrawAddressControl(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected) { GraphicsState? savedState = null; if (control.RotationAngle != 0) { var (pivotX, pivotY) = control.BoxCenter; var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY); savedState = g.Save(); g.TranslateTransform((float)pivotXPx, (float)pivotYPx); // See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in // this Y-down pixel space, the opposite of this project's counterclockwise-positive // stored convention, hence the negation. g.RotateTransform((float)-control.RotationAngle); g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx); } try { DrawAddressControlUnrotated(g, transform, control, isSelected); } finally { if (savedState is not null) { g.Restore(savedState); } } } private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected) { var lineStates = ComputeAddressControlLineStates(control); using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash }; using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1); var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control)); var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height); var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom); g.DrawRectangle(isSelected ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height); if (isSelected) { DrawAddressResizeHandle(g, transform, control); // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": // drawn here, in the control's own local (unrotated) coordinates, so it automatically // rotates together with the box/lines via DrawAddressControl's enclosing GDI+ // transform — the same reason the resize handle above already orbits correctly. DrawAddressRotateHandle(g, transform, control); } for (var i = 0; i < control.Lines.Count; i++) { if (!lineStates[i].Visible) { continue; } var line = control.Lines[i]; using var font = ResolveFont(line.FontFamily, (float)line.FontSize); var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText; var size = MeasureText(text, font); var (drawX, drawY) = transform.ToPixels(control.X, lineStates[i].EffectiveY + size.Height); if (line.IsDynamic) { using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue)); g.FillRectangle( fill, (float)drawX, (float)drawY, (float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)), (float)(size.Height * transform.Scale)); } using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B)); g.DrawString(text, font, brush, (float)drawX, (float)drawY); if (isSelected && i == _selectedAddressLineIndex) { using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1); g.DrawRectangle( linePen, (float)drawX - 2, (float)drawY - 2, (float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4, (float)(size.Height * transform.Scale) + 4); } } } private static double MaxLineFontSize(AddressControlLayout control) => control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize); private static void DrawAddressResizeHandle( Graphics g, CanvasViewTransform transform, AddressControlLayout control) { var handle = AddressResizeHandleCenter(control); var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y); var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8); using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen); using var outline = new Pen(System.Drawing.Color.White, 1); g.FillRectangle(brush, rect); g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height); } private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control) { var top = control.Y + MaxLineFontSize(control); var bottom = control.Y - control.Height; return (control.X + control.Width, bottom + ((top - bottom) / 2.0)); } /// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": /// paints the drag handle for a selected Address Control, in the same visual style /// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate /// handle () for consistency — the recommended default per this /// story's own notes, absent a strong reason to diverge. Position math lives in the /// framework-free, unit-tested (mirroring how /// holds the standalone-element equivalent); drawn here in /// local (unrotated) coordinates, which is sufficient to make it visually orbit with the /// control's own rotation via 's enclosing GDI+ transform — /// the same reason the resize handle above already orbits correctly. private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control) { var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control); var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control); var (centerPx, centerPy) = transform.ToPixels(centerX, topY); var (handlePx, handlePy) = transform.ToPixels(handleX, handleY); using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot }; g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy); const float radius = 5f; using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen); using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f); g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2); g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2); } private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control) { var lines = new AddressLineCollapser.Line[control.Lines.Count]; for (var i = 0; i < control.Lines.Count; i++) { var line = control.Lines[i]; var resolvedText = ResolveAddressLinePreviewText(line); var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText); lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse); } return AddressLineCollapser.Resolve(lines).ToArray(); } /// Sprint 7: delegates to the shared (also used by /// and the new preview panel) instead of keeping /// its own identical copy of this per-run resolution rule. private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) => TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord); /// Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see /// below): static text rotates around its measured center, while dynamic/mixed content rotates /// around its fixed authored anchor so record-to-record text-width changes cannot move the /// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an /// element's literal `{ColumnName}` token text, never a per-record resolved value (see /// 's remarks), so the dynamic/ /// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method /// now always takes the bounding-box-center path for every element, static or not, so a /// dynamic/mixed element's rotate-handle drag spins in place like static text instead of /// swinging around a corner. The real render (RotatedTextAnchorCalculator/ /// DebenuPdfRenderer) and the new preview panel (TemplatePreviewControl/ /// TemplatePreviewBuilder) are unaffected — they still call /// directly with the real isDynamic /// value. private static (double X, double Y) RotationPivot( TextElementLayout element, double effectiveY, double width, double height) => RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height); /// Measures an element's rendered size in canvas-space points, using a /// measuring context so the result is directly comparable to /// the point-based coordinates stores — this is a design-time /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point /// parity with the final PDF. Sprint 9, "Add an adjustable width and height with text /// wrapping...": once an element has a box (), its size /// *is* the box — authored geometry, not something to (re-)measure from text — so every /// downstream consumer of this method (hit-testing, the rotate handle, the resize handle, and /// itself) automatically treats the box as the element's real /// bounding box with no changes needed at those call sites. private static (double Width, double Height) MeasureElement(TextElementLayout element) { if (element.HasBox) { return (element.Width!.Value, element.Height!.Value); } using var bitmap = new Bitmap(1, 1); using var g = Graphics.FromImage(bitmap); g.PageUnit = GraphicsUnit.Point; using var font = ResolveFont(element.FontFamily, (float)element.FontSize); var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText; var size = g.MeasureString(text, font); return (size.Width, size.Height); } private static (double Width, double Height) MeasureText(string text, Font font) { using var bitmap = new Bitmap(1, 1); using var g = Graphics.FromImage(bitmap); g.PageUnit = GraphicsUnit.Point; var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font); return (size.Width, size.Height); } /// Sprint 5, AC4: measures each run's own display segment width in canvas-space /// points (same measuring context as , /// for consistency), so can position a per-run highlight fill at the /// right cumulative offset. Like , this is a design-time visual /// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal /// its measurement of the whole concatenated string to the last fraction of a point (kerning/ /// spacing metrics are not strictly additive), which is an accepted, documented limitation of /// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws /// one already-concatenated string, never per-run pieces). private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font) { using var bitmap = new Bitmap(1, 1); using var g = Graphics.FromImage(bitmap); g.PageUnit = GraphicsUnit.Point; var segments = TextRunTextConverter.ToDisplaySegments(element.Runs); var result = new List<(TextRun, double)>(segments.Count); foreach (var (run, text) in segments) { var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width; result.Add((run, width)); } return result; } /// Falls back to a generic sans-serif font if the requested family isn't installed, /// so a missing font only affects the design-time preview's appearance — it does not crash /// the designer. This is independent of, and does not relax, the render-time rule that a /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps"). private static Font ResolveFont(string familyName, float size) { try { return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point); } catch (ArgumentException) { return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point); } } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (e.Button != MouseButtons.Left) { return; } var transform = CurrentTransform(); var (x, y) = transform.ToPoints(e.X, e.Y); if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform)) { _isResizingAddressControl = true; Capture = true; Invalidate(); return; } // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked // right alongside the resize-handle check above (same precedence rule: only meaningful, // and only checked, when this control is already selected) so a rotate-handle grab is // never confused with a normal select/move click elsewhere on the control. if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y)) { _isRotatingAddressControl = true; Capture = true; Invalidate(); return; } var addressHit = HitTestAddressControl(x, y); if (addressHit.Control is not null) { SelectAddressControl(addressHit.Control, addressHit.LineIndex); _addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y); Capture = true; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); return; } ClearAddressSelection(); // Post-Sprint-8 user-requested feature: a click on the selected element's font-size // resize handle starts a resize-drag, checked with the same precedence as the rotate // handle immediately below (only meaningful, and only checked, once something is already // selected) so it is never confused with a normal select/move click. if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y)) { _editor.BeginResizeDrag(); Capture = true; Invalidate(); return; } // Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag // instead of a normal select/move — checked first (and only when something is already // selected) so it never intercepts a normal click elsewhere on the canvas. if (_editor.Selected is not null && _editor.HitTestHandle(x, y)) { _editor.BeginRotateDrag(); Capture = true; Invalidate(); return; } var hit = _editor.TrySelectAt(x, y); if (hit) { _editor.BeginDrag(x, y); Capture = true; } Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); } /// Sprint 8: a rotated control must remain correctly click-selectable at its actual /// rotated position, not its unrotated bounding box — ported from /// CanvasElementEditor.IsPointInRotatedBounds's approach: rotate the click point /// backward (by -RotationAngle) around the same /// pivot rotates around, landing it back in the control's /// unrotated local space, then run the exact same plain-rectangle/line test as before. An /// unrotated control (the overwhelmingly common case) takes the cheap direct path. private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints) { foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder)) { var (testX, testY) = control.RotationAngle == 0 ? (xPoints, yPoints) : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle); var top = control.Y + MaxLineFontSize(control); var bottom = control.Y - control.Height; if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top) { continue; } var lineIndex = 0; for (var i = 0; i < control.Lines.Count; i++) { var baseline = control.BaselineYForLine(i); var lineTop = baseline + control.Lines[i].FontSize; var nextBaseline = i == control.Lines.Count - 1 ? bottom : control.BaselineYForLine(i + 1); if (testY <= lineTop && testY >= nextBaseline) { lineIndex = i; break; } } return (control, lineIndex); } return (null, 0); } /// Sprint 8: same rotate-the-click-point-backward approach as /// — the resize handle is drawn (via /// ) inside 's rotation /// transform, so it visually orbits with the rotated box; the hit test rotates the click point /// back into the same unrotated local space before comparing against the handle's unrotated /// position. private static bool HitTestAddressResizeHandle( AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform) { const double handleTolerancePixels = 8; var tolerancePoints = handleTolerancePixels / transform.Scale; var (testX, testY) = control.RotationAngle == 0 ? (xPoints, yPoints) : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle); var handle = AddressResizeHandleCenter(control); return Math.Abs(testX - handle.X) <= tolerancePoints && Math.Abs(testY - handle.Y) <= tolerancePoints; } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!IsInteracting) { return; } var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); if (_selectedAddressControl is not null && _isRotatingAddressControl) { AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y); Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); return; } if (_selectedAddressControl is not null && _isResizingAddressControl) { // Sprint 8: rotate the drag point backward into the control's unrotated local space // first (same as the hit test above) so resizing a rotated control still tracks the // mouse along the box's own local width axis, not the world X axis. var (localX, _) = _selectedAddressControl.RotationAngle == 0 ? (x, y) : PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle); var width = Math.Max(1, localX - _selectedAddressControl.X); _selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width; Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); return; } if (_selectedAddressControl is not null && _addressControlDragOffset is not null) { var newX = x - _addressControlDragOffset.Value.Dx; var newY = y - _addressControlDragOffset.Value.Dy; if (SnapToGridEnabled) { newX = GridSnapper.Snap(newX, GridSizePoints); newY = GridSnapper.Snap(newY, GridSizePoints); } _selectedAddressControl.X = newX; _selectedAddressControl.Y = newY; Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); return; } if (_editor.IsResizingFontSize) { _editor.ResizeDragTo(x, y); } else if (_editor.IsRotating) { _editor.RotateDragTo(x, y); } else { _editor.DragTo(x, y); } Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); var wasInteracting = IsInteracting; _editor.EndDrag(); _addressControlDragOffset = null; _isResizingAddressControl = false; _isRotatingAddressControl = false; Capture = false; // The gesture's own per-tick ElementsChanged notifications (OnMouseMove) only ask // TemplateDesignerForm for a cheap position/angle-only sync (see IsInteracting's // remarks) — fire one last notification now that the gesture has actually ended so the // form's full properties-panel refresh (rebind combo, address line list, etc.) still // runs exactly once at the end, the same as it always did before this smoothness fix. if (wasInteracting) { ElementsChanged?.Invoke(this, EventArgs.Empty); } } }