|
- 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;
-
- /// <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);
- }
-
- 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;
- }
-
- /// <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);
- 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);
- }
- }
- }
-
- /// <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 (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);
- }
-
- /// <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.</summary>
- 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);
- }
-
- /// <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 (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;
- }
- }
|