|
- using System.Drawing.Drawing2D;
- using EnvelopeRenderer.Desktop.Core.Design;
-
- namespace EnvelopeRenderer.Desktop.Views;
-
- /// <summary>
- /// 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 <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/>/
- /// <see cref="AddressBlockPreviewCalculator"/> (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.
- /// </summary>
- 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;
-
- /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
- /// mirrors <see cref="_isResizingAddressControl"/>'s shape — a control-specific drag-gesture
- /// flag, paralleling (not reusing) <see cref="CanvasElementEditor.IsRotating"/>, which only
- /// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary>
- private bool _isRotatingAddressControl;
-
- /// <summary>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/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
- private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
- private IReadOnlyDictionary<string, string>? _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);
- }
-
- /// <summary>Sprint 7, "Snap elements to grid and guides": mirrors
- /// <see cref="CanvasElementEditor.SnapToGridEnabled"/> so the same toggle governs standalone
- /// element dragging (handled inside <see cref="CanvasElementEditor"/>) 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.</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public bool SnapToGridEnabled
- {
- get => _editor.SnapToGridEnabled;
- set
- {
- _editor.SnapToGridEnabled = value;
- Invalidate();
- }
- }
-
- /// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
- /// lines are painted at, when <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public double GridSizePoints
- {
- get => _editor.GridSizePoints;
- set
- {
- _editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
- Invalidate();
- }
- }
-
- /// <summary>True while an active move, resize, or rotate gesture is in progress on the
- /// canvas — a standalone element drag/rotate (<see cref="CanvasElementEditor"/>) or an
- /// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets
- /// <see cref="TemplateDesignerForm"/> 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 <see cref="OnMouseMove"/> already used inline before this
- /// property existed.</summary>
- 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;
- }
-
- /// <summary>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
- /// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
- public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
- {
- _csvHeaders = headers;
- _csvSampleRecord = sampleRecord;
- Invalidate();
- }
-
- /// <summary>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.</summary>
- public void NotifyElementChanged()
- {
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- /// <summary>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.</summary>
- 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);
- }
- }
-
- /// <summary>Sprint 7: draws light dotted grid lines across the page at every
- /// <see cref="GridSizePoints"/> interval, in both directions, so an operator can see the
- /// alignment grid snapping is rounding positions to.</summary>
- 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);
- }
- }
- }
-
- /// <summary>Post-Sprint-8 user-requested feature: draws the font-size resize handle at the
- /// selected element's top-right corner (<see cref="CanvasElementEditor.ResizeHandlePosition"/>,
- /// 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.</summary>
- 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);
- }
-
- /// <summary>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 <see cref="CanvasElementEditor.HandlePosition"/> computes (which
- /// already accounts for the element's current rotation) — the same position
- /// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
- /// what's draggable.</summary>
- 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);
- }
-
- /// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
- /// <see cref="AddressControlLayout.RotationAngle"/> 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
- /// <see cref="AddressControlLayout.BoxCenter"/>, the same way <see cref="DrawElement"/> 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 <see cref="HitTestAddressControl"/> and
- /// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point
- /// back into this same unrotated local space before testing.</summary>
- 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));
- }
-
- /// <summary>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 (<see cref="DrawRotateHandle"/>) 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 <see cref="AddressControlRotateHandle"/> (mirroring how
- /// <see cref="CanvasElementEditor"/> 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 <see cref="DrawAddressControl"/>'s enclosing GDI+ transform —
- /// the same reason the resize handle above already orbits correctly.</summary>
- 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();
- }
-
- /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
- /// <see cref="AddressBlockPreviewCalculator"/> and the new preview panel) instead of keeping
- /// its own identical copy of this per-run resolution rule.</summary>
- private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
- TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord);
-
- /// <summary>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
- /// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'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 (<c>RotatedTextAnchorCalculator</c>/
- /// <c>DebenuPdfRenderer</c>) and the new preview panel (<c>TemplatePreviewControl</c>/
- /// <c>TemplatePreviewBuilder</c>) are unaffected — they still call
- /// <see cref="RotationPivotCalculator.Compute"/> directly with the real <c>isDynamic</c>
- /// value.</summary>
- private static (double X, double Y) RotationPivot(
- TextElementLayout element, double effectiveY, double width, double height) =>
- RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);
-
- /// <summary>Measures an element's rendered size in canvas-space points, using a
- /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
- /// the point-based coordinates <see cref="TextElementLayout"/> 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 (<see cref="TextElementLayout.HasBox"/>), 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
- /// <see cref="DrawElement"/> itself) automatically treats the box as the element's real
- /// bounding box with no changes needed at those call sites.</summary>
- 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);
- }
-
- /// <summary>Sprint 5, AC4: measures each run's own display segment width in canvas-space
- /// points (same <see cref="GraphicsUnit.Point"/> measuring context as <see cref="MeasureElement"/>,
- /// for consistency), so <see cref="DrawElement"/> can position a per-run highlight fill at the
- /// right cumulative offset. Like <see cref="MeasureElement"/>, 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).</summary>
- 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;
- }
-
- /// <summary>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").</summary>
- 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);
- }
-
- /// <summary>Sprint 8: a rotated control must remain correctly click-selectable at its actual
- /// rotated position, not its unrotated bounding box — ported from
- /// <c>CanvasElementEditor.IsPointInRotatedBounds</c>'s approach: rotate the click point
- /// backward (by <c>-RotationAngle</c>) around the same <see cref="AddressControlLayout.BoxCenter"/>
- /// pivot <see cref="DrawAddressControl"/> 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.</summary>
- 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);
- }
-
- /// <summary>Sprint 8: same rotate-the-click-point-backward approach as
- /// <see cref="HitTestAddressControl"/> — the resize handle is drawn (via
- /// <see cref="DrawAddressResizeHandle"/>) inside <see cref="DrawAddressControl"/>'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.</summary>
- 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);
- }
- }
- }
|