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; /// Sprint 10, "Select multiple elements at once on the canvas": the Address Control /// half of the multi-selection — mirrors for /// standalone elements, since has no knowledge of Address /// Controls at all (the same split as single-selection's /// vs. CanvasElementEditor.Selected). An *additive* layer: every existing single-select /// code path keeps working unchanged whenever this set (plus the editor's) totals fewer than /// two items — see . private readonly HashSet _multiSelectedAddressControls = new(); private bool _isRubberBandSelecting; private bool _rubberBandAdditive; private (double X, double Y)? _rubberBandStart; private (double X, double Y)? _rubberBandCurrent; /// Grab point and original positions for the Address Control half of an in-progress /// group drag — mirrors /MultiDragTo for /// standalone elements, applied to both halves from the same shared delta every tick so the /// whole mixed multi-selection moves together. private (double X, double Y)? _multiDragGrabPoint; private Dictionary? _multiDragOriginalAddressPositions; /// 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; AllowDrop = true; BackColor = SystemColors.ControlDark; SetStyle(ControlStyles.ResizeRedraw, true); // Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by // default, so it never received key events at all — needed for the Delete key to remove // the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on // the reported gap this fixes). SetStyle(ControlStyles.Selectable, true); TabStop = 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.IsResizing || _addressControlDragOffset is not null || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging; public TextElementLayout? SelectedElement => _editor.Selected; public AddressControlLayout? SelectedAddressControl => _selectedAddressControl; public int SelectedAddressLineIndex => _selectedAddressLineIndex; /// Sprint 10, "Select multiple elements at once on the canvas": the combined size of /// the multi-selection across both standalone elements and Address Controls. Zero or one means /// no *active* multi-selection — see and /// for why a single leftover item always /// collapses back into the ordinary single-selection fields instead of staying here. public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count; /// True while two or more items (any mix of standalone elements and Address Controls) /// are selected together. uses this to show "N items /// selected" and disable per-item property editing instead of displaying stale/misleading /// single-item values. public bool IsMultiSelectionActive => MultiSelectionCount >= 2; 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; } /// Adds a filled rectangle (a text element with an empty content, a box and a fill /// color) that covers whatever is under it and can have text typed into it. White by default, /// like a cover-up patch. public TextElementLayout AddRectangleElement() { var (x, y) = DefaultNewElementPosition(); var element = _editor.AddStaticText(x, y, text: string.Empty); element.Width = DefaultRectangleWidth; element.Height = DefaultRectangleHeight; element.FillColor = new RgbColor(255, 255, 255); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return element; } public const double DefaultRectangleWidth = 120; public const double DefaultRectangleHeight = 40; 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); } } /// Drag-and-drop data format carrying a CSV column header name from the form's CSV /// grid onto this canvas. public const string ColumnDragFormat = "EnvelopeRenderer.CsvColumn"; /// Drops a CSV column onto the canvas at a client-space pixel position: onto an /// Address Control it appends a new line bound to the column; anywhere else it places a new /// dynamic placeholder there. Ignored in show-data (read-only) mode. public bool DropColumn(string columnName, Point clientPoint) { if (_showData || string.IsNullOrEmpty(columnName)) { return false; } var (x, y) = CurrentTransform().ToPoints(clientPoint.X, clientPoint.Y); var (control, _) = HitTestAddressControl(x, y); if (control is not null) { control.Lines.Add(AddressControlLineLayout.CreateField(columnName)); SelectAddressControl(control, control.Lines.Count - 1); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return true; } _editor.AddDynamicPlaceholder(x, y, columnName); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return true; } protected override void OnDragOver(DragEventArgs drgevent) { base.OnDragOver(drgevent); drgevent.Effect = !_showData && drgevent.Data?.GetDataPresent(ColumnDragFormat) == true ? DragDropEffects.Copy : DragDropEffects.None; } protected override void OnDragDrop(DragEventArgs drgevent) { base.OnDragDrop(drgevent); if (drgevent.Data?.GetData(ColumnDragFormat) is string column) { DropColumn(column, PointToClient(new Point(drgevent.X, drgevent.Y))); } } 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(); } private bool _showData; /// "Show data" view mode: the canvas draws each element's text resolved against the /// current record (the one supplied via ) instead of the /// literal {Column} tokens with their blue placeholder fills. Read-only while on — hit /// testing and measuring work from the token text, so editing gestures are ignored until the /// operator switches back to design view. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] public bool ShowData { get => _showData; set { if (_showData == value) { return; } _showData = value; Invalidate(); } } /// The text an element is drawn with: its resolved value for the current record in /// show-data mode (falling back to the token text if it cannot be resolved), otherwise the /// token text. private string DisplayTextFor(TextElementLayout element) => _showData ? TextResolver.TryResolve(element.Runs, _csvHeaders, _csvSampleRecord) ?? element.DisplayText : element.DisplayText; /// 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); _editor.ClearMultiSelect(); _selectedAddressControl = null; _selectedAddressLineIndex = 0; _addressControlDragOffset = null; _isResizingAddressControl = false; _isRotatingAddressControl = false; _multiSelectedAddressControls.Clear(); _isRubberBandSelecting = false; _rubberBandStart = null; _rubberBandCurrent = null; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); } /// Post-Sprint-9 user-reported gap: there was no way to delete a placed element or /// Address Control at all once added. Removes whichever is currently selected (a standalone /// element or a whole Address Control — never just a drilled-into address line, which has its /// own dedicated ) and clears the selection. Returns /// false (a no-op) if nothing is selected. public bool RemoveSelectedElement() { if (_selectedAddressControl is not null) { var removed = _document.AddressControls.Remove(_selectedAddressControl); ClearSelection(); if (removed) { ElementsChanged?.Invoke(this, EventArgs.Empty); } return removed; } if (_editor.Selected is not null) { var removed = _editor.RemoveSelected(); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); if (removed) { ElementsChanged?.Invoke(this, EventArgs.Empty); } return removed; } return false; } /// Sprint 10, "Align and distribute multiple elements": aligns every item in the /// current multi-selection to the given of the selection's combined /// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are /// expected to disable themselves via rather than relying on /// this guard alone, per this story's "clearly disabled, not a silent no-op" AC. public void AlignSelection(AlignmentCalculator.Edge edge) { if (MultiSelectionCount < 2) { return; } ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls); } /// Distributes the current multi-selection with equal center-to-center spacing along /// the given — see /// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and, /// per that method's own remarks, has no visible effect below 3, since there is nothing to /// place between the two extreme items). public void DistributeSelection(AlignmentCalculator.Axis axis) { if (MultiSelectionCount < 2) { return; } ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls); } /// Builds the combined, fixed-order bounding-box list /// operates on — standalone elements first, then Address Controls — so a returned delta list /// can be zipped back to the exact item it applies to via / /// ' matching order. private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds( out List elements, out List controls) { elements = _editor.MultiSelected.ToList(); controls = _multiSelectedAddressControls.ToList(); var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count); bounds.AddRange(elements.Select(_editor.GetWorldBounds)); bounds.AddRange(controls.Select(GetAddressControlWorldBounds)); return bounds; } private void ApplySelectionDeltas( IReadOnlyList<(double Dx, double Dy)> deltas, List elements, List controls) { for (var i = 0; i < elements.Count; i++) { elements[i].X += deltas[i].Dx; elements[i].Y += deltas[i].Dy; } for (var i = 0; i < controls.Count; i++) { var delta = deltas[elements.Count + i]; controls[i].X += delta.Dx; controls[i].Y += delta.Dy; } Invalidate(); ElementsChanged?.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); /// Folder relative background paths resolve against (the template folder). [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] public string? BackgroundBaseDirectory { get; set; } private string? _backgroundImagePath; private DateTime _backgroundImageStamp; private Image? _backgroundImage; /// Paints the template page background under everything else. Images are drawn /// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page /// shows a labelled placeholder - the real PDF page appears in the rendered output. private void DrawPageBackground(Graphics g, RectangleF pageRect) { var background = _document.Background; if (background is null) { return; } var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory); if (file is null) { return; } if (PageBackgroundLayout.IsPdf(file)) { var page = background.ResolvePage(_csvSampleRecord); var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null; if (pdfBitmap is not null) { g.DrawImage(pdfBitmap, pageRect); return; } using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250)); g.FillRectangle(tint, pageRect); using var font = new Font("Segoe UI", 9f); g.DrawString( $"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})", font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6); return; } var image = LoadBackgroundImage(file); if (image is not null) { g.DrawImage(image, pageRect); } } private string? _pdfBackgroundKey; private int _pdfBackgroundWidth; private Bitmap? _pdfBackgroundBitmap; private string? _pdfBackgroundError; /// The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file /// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom), /// so a repaint during drag or selection never re-renders the PDF. private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error) { var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0; var key = $"{path}|{page}|{stamp}"; var wanted = Math.Max(pageWidthPixels, 50); var sameKey = _pdfBackgroundKey == key; var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75; if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth)) { error = _pdfBackgroundError; return _pdfBackgroundBitmap; } var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError); _pdfBackgroundBitmap?.Dispose(); _pdfBackgroundBitmap = rendered; _pdfBackgroundKey = key; _pdfBackgroundWidth = rendered is null ? 0 : wanted; _pdfBackgroundError = renderError; error = renderError; return rendered; } private Image? LoadBackgroundImage(string path) { try { var stamp = File.GetLastWriteTimeUtc(path); if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp) { return _backgroundImage; } _backgroundImage?.Dispose(); _backgroundImage = null; _backgroundImagePath = path; _backgroundImageStamp = stamp; // Copy through a MemoryStream so the file is not left locked by GDI+. using var stream = new MemoryStream(File.ReadAllBytes(path)); using var loaded = Image.FromStream(stream); _backgroundImage = new Bitmap(loaded); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException) { _backgroundImage = null; } return _backgroundImage; } 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); DrawPageBackground(g, 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; } // Sprint 10, "Select multiple elements at once on the canvas": a multi-selected // element gets the same highlight border as the single primary selection (see // DrawElement's isSelected remarks — it only ever drives the highlight, never the // resize/rotate handles, which are drawn separately below gated on // _editor.Selected alone) so every selected item is visibly marked, not just one. DrawElement( g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected) || _editor.MultiSelected.Contains(item.Text)); continue; } DrawAddressControl( g, transform, item.Control!, isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl), isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!)); } if (_editor.Selected is not null) { DrawResizeHandle(g, transform, _editor.Selected); DrawHeightHandle(g, transform); DrawRotateHandle(g, transform, _editor.Selected); } if (_isRubberBandSelecting && _rubberBandStart is not null && _rubberBandCurrent is not null) { DrawRubberBand(g, transform, _rubberBandStart.Value, _rubberBandCurrent.Value); } } /// Sprint 10, "Select multiple elements at once on the canvas": the marquee rectangle /// drawn while a rubber-band selection drag is in progress, in canvas-page-agnostic pixel /// space (drawn last, on top of everything else, the same way a rotate/resize handle already /// draws on top of its element). private static void DrawRubberBand( Graphics g, CanvasViewTransform transform, (double X, double Y) start, (double X, double Y) current) { var (x1, y1) = transform.ToPixels(start.X, start.Y); var (x2, y2) = transform.ToPixels(current.X, current.Y); var rect = RectangleF.FromLTRB( (float)Math.Min(x1, x2), (float)Math.Min(y1, y2), (float)Math.Max(x1, x2), (float)Math.Max(y1, y2)); using var fill = new SolidBrush(System.Drawing.Color.FromArgb(40, System.Drawing.Color.DodgerBlue)); using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash }; g.FillRectangle(fill, rect); g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); } /// 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; } /// Sprint 10, "Select multiple elements at once on the canvas": empties both halves /// of the multi-selection (standalone elements and Address Controls) without touching the /// ordinary single-selection fields — callers that are about to establish a fresh single /// selection call this first so a stale multi-selection never lingers alongside it. private void ClearMultiSelection() { _editor.ClearMultiSelect(); _multiSelectedAddressControls.Clear(); } /// Applies the multi-selection's one collapse rule after every mutation (a rubber-band /// release, a modifier-click toggle): zero items means nothing is selected at all; exactly one /// item collapses back into the ordinary single-selection fields ( /// or ) so every existing single-select code path — /// properties panel, rotate/resize handles, address-line drill-in — keeps working completely /// unchanged; two or more items clears both single-selection fields so the properties panel /// shows "N items selected" instead of stale single-item values. private void CollapseMultiSelectionIfSingular() { var multiElements = _editor.MultiSelected; var total = multiElements.Count + _multiSelectedAddressControls.Count; if (total == 0) { _editor.Select(null); ClearAddressSelection(); return; } if (total == 1) { if (multiElements.Count == 1) { var onlyElement = multiElements.First(); _editor.ClearMultiSelect(); _editor.Select(onlyElement); ClearAddressSelection(); } else { var onlyControl = _multiSelectedAddressControls.First(); _multiSelectedAddressControls.Clear(); SelectAddressControl(onlyControl, 0); } return; } // Two or more: neither single-selection field applies while a multi-selection is active. _editor.Select(null); ClearAddressSelection(); } /// Modifier-click (Ctrl/Shift) support: toggles the given element's membership in the /// multi-selection. If nothing was multi-selected yet, first seeds the set with whatever was /// singly selected — the standard modifier-click UX extends the current selection rather than /// starting over from empty. private void ToggleElementMultiSelect(TextElementLayout element) { SeedMultiSelectionFromSingleSelectionIfEmpty(); _editor.ToggleMultiSelect(element); CollapseMultiSelectionIfSingular(); } private void ToggleAddressControlMultiSelect(AddressControlLayout control) { SeedMultiSelectionFromSingleSelectionIfEmpty(); if (!_multiSelectedAddressControls.Remove(control)) { _multiSelectedAddressControls.Add(control); } CollapseMultiSelectionIfSingular(); } private void SeedMultiSelectionFromSingleSelectionIfEmpty() { if (_editor.MultiSelected.Count > 0 || _multiSelectedAddressControls.Count > 0) { return; } if (_editor.Selected is not null) { _editor.SetMultiSelection(new[] { _editor.Selected }); } if (_selectedAddressControl is not null) { _multiSelectedAddressControls.Add(_selectedAddressControl); } } /// Rubber-band selection support: every Address Control whose world-space /// (rotation-aware) axis-aligned bounding box intersects the given rectangle — the Address /// Control counterpart of , kept here rather /// than in Desktop.Core since it needs , the same WinForms-only /// geometry helper already uses. private List AddressControlsInRect(double minX, double minY, double maxX, double maxY) { var result = new List(); foreach (var control in _document.AddressControls) { var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control); if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY) { result.Add(control); } } return result; } /// Sprint 10, "Align and distribute multiple elements": the same rotation-aware /// world-space AABB already computed inline for rubber-band /// hit-testing, extracted so alignment/distribution can feed it into /// alongside /// for standalone elements. private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control) { var top = control.TopBaselineY + MaxLineFontSize(control); var bottom = control.TopBaselineY - control.Height; var left = control.X; var right = control.X + control.Width; if (control.RotationAngle == 0) { return (left, bottom, right, top); } var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) }; var minX = double.MaxValue; var minY = double.MaxValue; var maxX = double.MinValue; var maxY = double.MinValue; foreach (var (cornerX, cornerY) in corners) { var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle); minX = Math.Min(minX, rx); maxX = Math.Max(maxX, rx); minY = Math.Min(minY, ry); maxY = Math.Max(maxY, ry); } return (minX, minY, maxX, maxY); } /// Finalizes a rubber-band drag on release: computes the final (normalized) rectangle, /// finds every element/control it intersects, and either replaces the multi-selection with that /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag, /// ) — mirroring modifier-click's "extend, don't replace" /// behavior for consistency. private void FinalizeRubberBandSelection() { if (_rubberBandStart is not null && _rubberBandCurrent is not null) { var (x1, y1) = _rubberBandStart.Value; var (x2, y2) = _rubberBandCurrent.Value; var minX = Math.Min(x1, x2); var maxX = Math.Max(x1, x2); var minY = Math.Min(y1, y2); var maxY = Math.Max(y1, y2); var hitElements = _editor.ElementsInRect(minX, minY, maxX, maxY); var hitControls = AddressControlsInRect(minX, minY, maxX, maxY); if (_rubberBandAdditive) { var combinedElements = new HashSet(_editor.MultiSelected); foreach (var element in hitElements) { combinedElements.Add(element); } _editor.SetMultiSelection(combinedElements); foreach (var control in hitControls) { _multiSelectedAddressControls.Add(control); } } else { _editor.SetMultiSelection(hitElements); _multiSelectedAddressControls.Clear(); foreach (var control in hitControls) { _multiSelectedAddressControls.Add(control); } } CollapseMultiSelectionIfSingular(); } _isRubberBandSelecting = false; _rubberBandStart = null; _rubberBandCurrent = null; Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); } private void BeginGroupDrag(double grabXPoints, double grabYPoints) { _editor.BeginMultiDrag(grabXPoints, grabYPoints); _multiDragGrabPoint = (grabXPoints, grabYPoints); _multiDragOriginalAddressPositions = _multiSelectedAddressControls.ToDictionary(c => c, c => (c.X, c.Y)); } private void ApplyMultiDragToAddressControls(double xPoints, double yPoints) { if (_multiDragGrabPoint is null || _multiDragOriginalAddressPositions is null) { return; } var dx = xPoints - _multiDragGrabPoint.Value.X; var dy = yPoints - _multiDragGrabPoint.Value.Y; if (SnapToGridEnabled) { dx = GridSnapper.Snap(dx, GridSizePoints); dy = GridSnapper.Snap(dy, GridSizePoints); } foreach (var (control, original) in _multiDragOriginalAddressPositions) { control.X = original.X + dx; control.Y = original.Y + dy; } } private void EndGroupDrag() { _editor.EndMultiDrag(); _multiDragGrabPoint = null; _multiDragOriginalAddressPositions = null; } 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, boxTopY) = transform.ToPixels(element.X, effectiveY + height); using var drawFont = ResolveDrawFont(element.FontFamily, (float)element.FontSize, transform.Scale); // Plain (non-box) text is positioned by baseline like the PDF; a boxed element's text // hangs from the top of its box (PDF DrawTextBox). var drawY = element.HasBox ? boxTopY : transform.ToPixels(element.X, effectiveY).Y - BaselineOffsetPx(drawFont); 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.HasFill && element.FillColor is { } rectFill) { // Filled rectangle: painted first so the element's own text (and any element with // a higher z-order) draws over it, like the real render. using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B)); g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight); // Design-view outline so a white (or page-coloured) rectangle is still visible and // clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light // dashed line together stay visible over any fill or background. var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight); using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f); using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash }; g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height); g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height); } 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(DisplayTextFor(element), drawFont, brush, boxRect); // Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange // outline, the same visual language as the unmapped-column warning below, drawn // whenever the element's *current* content is actually wrapping and/or being // clipped — reusing the same box-mode text this method just drew, not a second // divergent measurement. var (isWrapping, isClipping) = MeasureWrapClip(element, font); if (isWrapping || isClipping) { using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot }; g.DrawRectangle( wrapClipPen, (float)drawX - 1, (float)drawY - 1, (float)(width * transform.Scale) + 2, (float)boxHeight + 2); } } 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 = _showData ? new List<(TextRun Run, double Width)>() : 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(DisplayTextFor(element), drawFont, 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 DrawHeightHandle(Graphics g, CanvasViewTransform transform) { var handle = _editor.HeightHandlePosition(); 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); } 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 isPrimarySelected, bool isMultiSelected) { 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, isPrimarySelected, isMultiSelected); } finally { if (savedState is not null) { g.Restore(savedState); } } } /// Sprint 10, "Select multiple elements at once on the canvas": and are deliberately separate /// — both draw the same highlighted border (so every selected item is visibly marked), but /// only the primary selection gets the resize/rotate handles and the drilled-into-line /// highlight, so a multi-selected control never shows handles that would be ambiguous about /// which item they act on (out of scope for this story; align/distribute operates on the whole /// set instead). private void DrawAddressControlUnrotated( Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected) { var isHighlighted = isPrimarySelected || isMultiSelected; 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.TopBaselineY + MaxLineFontSize(control)); var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height); var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom); g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height); if (isPrimarySelected) { 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 = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText); var size = MeasureText(text, font); using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale); var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X; var drawY = transform.ToPixels(control.X, lineStates[i].EffectiveY).Y - BaselineOffsetPx(drawFont); if (line.IsDynamic && !_showData) { 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, drawFont, brush, (float)drawX, (float)drawY); if (isPrimarySelected && 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.TopBaselineY + MaxLineFontSize(control); var bottom = control.TopBaselineY - 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); } var resolved = AddressLineCollapser.Resolve(lines).ToArray(); if (control.VerticalAnchor == AddressVerticalAnchor.Bottom) { var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y); for (var i = 0; i < resolved.Length; i++) { resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift }; } } return resolved; } /// 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) => _showData ? TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord) : null; /// 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/10, "Add an adjustable width and height with text /// wrapping...": once an element has a box ( — Width /// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure /// from text; its height is the explicit clip ceiling when /// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font /// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing, /// the rotate handle, the resize handle, and itself) automatically /// treats this 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) { using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize); var effectiveHeight = element.HasHeightClip ? element.Height!.Value : MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value); return (element.Width!.Value, effectiveHeight); } 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); } /// Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap /// to points — the auto-height a boxed /// element with no explicit clip ceiling uses, mirroring /// how the CLI asks Debenu's own GetWrappedTextHeight for the equivalent value. private static double MeasureWrappedHeight(string text, Font font, double width) { using var bitmap = new Bitmap(1, 1); using var g = Graphics.FromImage(bitmap); g.PageUnit = GraphicsUnit.Point; var displayText = string.IsNullOrEmpty(text) ? " " : text; var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width))); return size.Height; } /// Sprint 10, "Add a live wrap/clip indicator for text elements": compares the /// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide /// whether the *current* content is actually wrapping and/or being clipped right now — pure /// arithmetic lives in ; this just does the GDI+ measurement /// that decision needs. private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font) { using var bitmap = new Bitmap(1, 1); using var g = Graphics.FromImage(bitmap); g.PageUnit = GraphicsUnit.Point; var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText; var naturalWidth = g.MeasureString(text, font).Width; var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value); // No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) — // never flag clipping in that case, regardless of how tall the wrapped content grows. var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity; return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight); } 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"). /// The font used to actually paint text: sized in pixels (point size times the /// canvas zoom) so glyphs scale with the page exactly like positions and boxes do. A /// points-unit font would be converted at the screen DPI regardless of zoom, so text would /// not track the PDF's size. Measurement helpers keep using (points). private static Font ResolveDrawFont(string familyName, float sizePoints, double scale) { var px = (float)(Math.Max(sizePoints <= 0 ? 12f : sizePoints, 0.1f) * scale); try { return new Font(familyName, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel); } catch (ArgumentException) { return new Font(FontFamily.GenericSansSerif, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel); } } /// Distance in pixels from the top of a GDI+ text box to the baseline for a /// pixel-unit font. The PDF places text by baseline (the element's Y), so the canvas draws the /// box this far above the baseline. private static float BaselineOffsetPx(Font pixelFont) { var family = pixelFont.FontFamily; var style = pixelFont.Style; return pixelFont.Size * family.GetCellAscent(style) / family.GetEmHeight(style); } 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); // Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all — // see the constructor's ControlStyles.Selectable remarks. Focus(); if (e.Button != MouseButtons.Left || _showData) { return; } // Post-Sprint-10 user-reported regression fix: SelectionChanged used to fire unconditionally // on every left-click, including a click on an item that was ALREADY selected (the normal // way to begin dragging it). TemplateDesignerForm's SelectionChanged handler runs the full, // non-trivial RefreshPropertiesPanel() (combo-box rebind repopulation etc.) with no // IsInteracting-style gating the way the drag/rotate/resize ElementsChanged path has, so // every single click paid that cost — a real, measured ~40-60ms hitch per click once this // session's added property-panel rows (box width/height, delete button) made the refresh // heavy enough to notice. Capturing the pre-click selection identity and only raising the // event when it actually changed makes "click an already-selected element to move it" free // again while still notifying on every real selection change. var previousSelectedElement = _editor.Selected; var previousSelectedAddressControl = _selectedAddressControl; var previousSelectedAddressLineIndex = _selectedAddressLineIndex; // Sprint 10, "Select multiple elements at once on the canvas": Ctrl or Shift held during // the click means "toggle this one item's membership in the multi-selection" (or, for an // empty-space click below, "add to the multi-selection via rubber-band" rather than // replacing it) — checked once up front since every hit-test branch below needs it. var isModifierClick = (ModifierKeys & (Keys.Control | Keys.Shift)) != 0; 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) { if (isModifierClick) { ToggleAddressControlMultiSelect(addressHit.Control); Invalidate(); RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex); return; } if (IsMultiSelectionActive && _multiSelectedAddressControls.Contains(addressHit.Control)) { // Clicking a member of an existing multi-selection (no modifier) starts a group // drag of the whole set instead of collapsing back to a single selection — the // same "click an already-selected item to move it" gesture single-select has // always supported, extended to the group. BeginGroupDrag(x, y); Capture = true; Invalidate(); return; } ClearMultiSelection(); SelectAddressControl(addressHit.Control, addressHit.LineIndex); _addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y); Capture = true; Invalidate(); RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex); 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.HitTestHeightHandle(x, y)) { _editor.BeginHeightResizeDrag(); Capture = true; Invalidate(); return; } 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 hitElement = _editor.HitTest(x, y); if (hitElement is not null) { if (isModifierClick) { ToggleElementMultiSelect(hitElement); Invalidate(); RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex); return; } if (IsMultiSelectionActive && _editor.MultiSelected.Contains(hitElement)) { BeginGroupDrag(x, y); Capture = true; Invalidate(); return; } ClearMultiSelection(); _editor.Select(hitElement); _editor.BeginDrag(x, y); Capture = true; Invalidate(); RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex); return; } // Nothing hit: start a rubber-band selection rather than just clearing the selection — a // zero-size drag (a plain click with no movement) naturally selects nothing on release, // reproducing the old "click empty space to deselect" behavior without special-casing it. if (!isModifierClick) { ClearMultiSelection(); } _editor.Select(null); _isRubberBandSelecting = true; _rubberBandAdditive = isModifierClick; _rubberBandStart = (x, y); _rubberBandCurrent = (x, y); Capture = true; Invalidate(); RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex); } /// Raises only when the selection identity captured /// before a click actually differs from the current one — see 's /// remarks for why an unconditional raise on every click was a real, measured hitch. private void RaiseSelectionChangedIfDifferent( TextElementLayout? previousElement, AddressControlLayout? previousAddressControl, int previousAddressLineIndex) { if (!ReferenceEquals(previousElement, _editor.Selected) || !ReferenceEquals(previousAddressControl, _selectedAddressControl) || previousAddressLineIndex != _selectedAddressLineIndex) { 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.TopBaselineY + MaxLineFontSize(control); var bottom = control.TopBaselineY - 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); // Checked before the IsInteracting gate below: a rubber-band drag doesn't change any // element's data (so it never needs ElementsChanged/the property-panel refresh that gate // exists to guard), it only needs the marquee rectangle to repaint on every tick. if (_isRubberBandSelecting) { var (rbX, rbY) = CurrentTransform().ToPoints(e.X, e.Y); _rubberBandCurrent = (rbX, rbY); Invalidate(); return; } if (!IsInteracting) { return; } var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); if (_editor.IsMultiDragging) { _editor.MultiDragTo(x, y); ApplyMultiDragToAddressControls(x, y); Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); return; } 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.IsResizing) { _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); if (_isRubberBandSelecting) { Capture = false; FinalizeRubberBandSelection(); return; } var wasInteracting = IsInteracting; _editor.EndDrag(); EndGroupDrag(); _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); } } /// Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or /// Address Control is currently selected — there was no way to delete a placed item at all /// before this. Only fires while not mid-gesture, so an accidental key press during a drag /// can't delete out from under an in-progress move/resize/rotate. protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting && !_showData) { RemoveSelectedElement(); e.Handled = true; } else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt) { // Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden // behind other items. Ctrl+Tab still leaves the canvas as usual. SelectNextItem(forward: !e.Shift); e.Handled = true; e.SuppressKeyPress = true; } } /// Selects the next (or previous) item in z-order, so an item hidden behind another /// one can be reached without clicking it. Wraps at both ends. Returns false when there /// is nothing to select or the canvas is showing data (selection is disabled then). public bool SelectNextItem(bool forward) { if (_showData || IsInteracting) { return false; } var items = _document.Elements .Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y)) .Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y))) .ToList(); Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id; if (SelectionCycler.Next(items, current, forward) is not { } nextId) { return false; } ClearMultiSelection(); ClearAddressSelection(); var element = _document.Elements.FirstOrDefault(e => e.Id == nextId); if (element is not null) { _editor.Select(element); } else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control) { SelectAddressControl(control, 0); } Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); return true; } protected override bool IsInputKey(Keys keyData) => keyData is Keys.Delete or Keys.Back || ((keyData & ~Keys.Shift) == Keys.Tab) || base.IsInputKey(keyData); }