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; /// The currently loaded CSV's headers and one representative sample record, used /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) — /// empty/null until is called after a CSV loads. private IReadOnlyList _csvHeaders = Array.Empty(); private IReadOnlyDictionary? _csvSampleRecord; public event EventHandler? SelectionChanged; public event EventHandler? ElementsChanged; public TemplateCanvasControl(TemplateLayoutDocument document) { _document = document; _editor = new CanvasElementEditor(document, MeasureElement); DoubleBuffered = true; BackColor = SystemColors.ControlDark; SetStyle(ControlStyles.ResizeRedraw, true); } public TextElementLayout? SelectedElement => _editor.Selected; 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 TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column") { var (x, y) = DefaultNewElementPosition(); var element = _editor.AddDynamicPlaceholder(x, y, columnName); Invalidate(); SelectionChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty); return element; } /// Sprint 4: supplies the loaded CSV's headers and one representative sample record /// (typically the first loaded sample row) so the canvas can preview address-line collapsing /// and flag mapping errors exactly the way a real render would. Pass an empty header list and /// null record to clear the preview context (e.g. nothing loaded yet). public void SetCsvPreviewContext(IReadOnlyList headers, IReadOnlyDictionary? sampleRecord) { _csvHeaders = headers; _csvSampleRecord = sampleRecord; Invalidate(); } /// Re-selects the given element (e.g. after the properties panel changes it) and /// redraws — used so external edits stay visually in sync with the canvas. public void NotifyElementChanged() { Invalidate(); ElementsChanged?.Invoke(this, EventArgs.Empty); } /// Clears the current selection and redraws — used after reopening a saved template /// (Batch 5), since a freshly loaded document's elements are new object instances and any /// previously selected element instance no longer belongs to it. public void ClearSelection() { _editor.Select(null); 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; 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 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); foreach (var element in _document.Elements.OrderBy(el => el.ZOrder)) { var state = previewStates[element.Id]; if (!state.Visible) { continue; } DrawElement(g, transform, element, state.EffectiveY, state.IsUnmappedColumn, isSelected: ReferenceEquals(element, _editor.Selected)); } if (_editor.Selected is not null) { DrawRotateHandle(g, transform, _editor.Selected); } } private void DrawElement( Graphics g, CanvasViewTransform transform, TextElementLayout element, double effectiveY, bool isUnmappedColumn, bool isSelected) { 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 (centerXPx, centerYPx) = transform.ToPixels(element.X + (width / 2.0), effectiveY + (height / 2.0)); savedState = g.Save(); g.TranslateTransform((float)centerXPx, (float)centerYPx); // 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)-centerXPx, (float)-centerYPx); } try { if (element.IsDynamic) { // A visible placeholder representation distinct from static text (Sprint 3 Batch 3 // acceptance criterion). Sprint 4: an unmapped column (bound to a name that isn't // among the currently loaded CSV's headers — a real mapping problem) gets a // visually distinct warning color instead of the normal "this is dynamic" blue, so // an operator can tell a mapping error apart from a field that's merely blank for // the current sample data (which draws with the ordinary blue fill, or is skipped // entirely if also collapsible — see the caller's `Visible` check). var boxWidth = width * transform.Scale; var boxHeight = height * transform.Scale; var fillColor = isUnmappedColumn ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed) : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue); using var dynamicFill = new SolidBrush(fillColor); g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight); } using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B)); g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY); if (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); } } } /// 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 (centerPx, centerPy) = transform.ToPixels(element.X + (width / 2.0), element.Y + (height / 2.0)); 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); } /// 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. private static (double Width, double Height) MeasureElement(TextElementLayout element) { 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); } /// Falls back to a generic sans-serif font if the requested family isn't installed, /// so a missing font only affects the design-time preview's appearance — it does not crash /// the designer. This is independent of, and does not relax, the render-time rule that a /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps"). private static Font ResolveFont(string familyName, float size) { try { return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point); } catch (ArgumentException) { return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point); } } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (e.Button != MouseButtons.Left) { return; } var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); // 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); } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!_editor.IsDragging && !_editor.IsRotating) { return; } var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); 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); _editor.EndDrag(); Capture = false; } }