Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

355 строки
16KB

  1. using System.Drawing.Drawing2D;
  2. using EnvelopeRenderer.Desktop.Core.Design;
  3. namespace EnvelopeRenderer.Desktop.Views;
  4. /// <summary>
  5. /// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"):
  6. /// draws the page and its text elements, and lets the operator select/drag-reposition them with
  7. /// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with
  8. /// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element
  9. /// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic
  10. /// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/
  11. /// drag/rotate state live in <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/>/
  12. /// <see cref="AddressBlockPreviewCalculator"/> (all framework-free and unit tested in
  13. /// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse
  14. /// events, since that part genuinely cannot be extracted from WinForms.
  15. /// </summary>
  16. public sealed class TemplateCanvasControl : Control
  17. {
  18. private readonly TemplateLayoutDocument _document;
  19. private readonly CanvasElementEditor _editor;
  20. /// <summary>The currently loaded CSV's headers and one representative sample record, used
  21. /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
  22. /// empty/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
  23. private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
  24. private IReadOnlyDictionary<string, string>? _csvSampleRecord;
  25. public event EventHandler? SelectionChanged;
  26. public event EventHandler? ElementsChanged;
  27. public TemplateCanvasControl(TemplateLayoutDocument document)
  28. {
  29. _document = document;
  30. _editor = new CanvasElementEditor(document, MeasureElement);
  31. DoubleBuffered = true;
  32. BackColor = SystemColors.ControlDark;
  33. SetStyle(ControlStyles.ResizeRedraw, true);
  34. }
  35. public TextElementLayout? SelectedElement => _editor.Selected;
  36. public TextElementLayout AddStaticTextElement()
  37. {
  38. var (x, y) = DefaultNewElementPosition();
  39. var element = _editor.AddStaticText(x, y);
  40. Invalidate();
  41. SelectionChanged?.Invoke(this, EventArgs.Empty);
  42. ElementsChanged?.Invoke(this, EventArgs.Empty);
  43. return element;
  44. }
  45. public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
  46. {
  47. var (x, y) = DefaultNewElementPosition();
  48. var element = _editor.AddDynamicPlaceholder(x, y, columnName);
  49. Invalidate();
  50. SelectionChanged?.Invoke(this, EventArgs.Empty);
  51. ElementsChanged?.Invoke(this, EventArgs.Empty);
  52. return element;
  53. }
  54. /// <summary>Sprint 4: supplies the loaded CSV's headers and one representative sample record
  55. /// (typically the first loaded sample row) so the canvas can preview address-line collapsing
  56. /// and flag mapping errors exactly the way a real render would. Pass an empty header list and
  57. /// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
  58. public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
  59. {
  60. _csvHeaders = headers;
  61. _csvSampleRecord = sampleRecord;
  62. Invalidate();
  63. }
  64. /// <summary>Re-selects the given element (e.g. after the properties panel changes it) and
  65. /// redraws — used so external edits stay visually in sync with the canvas.</summary>
  66. public void NotifyElementChanged()
  67. {
  68. Invalidate();
  69. ElementsChanged?.Invoke(this, EventArgs.Empty);
  70. }
  71. /// <summary>Clears the current selection and redraws — used after reopening a saved template
  72. /// (Batch 5), since a freshly loaded document's elements are new object instances and any
  73. /// previously selected element instance no longer belongs to it.</summary>
  74. public void ClearSelection()
  75. {
  76. _editor.Select(null);
  77. Invalidate();
  78. SelectionChanged?.Invoke(this, EventArgs.Empty);
  79. }
  80. private (double X, double Y) DefaultNewElementPosition()
  81. {
  82. // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
  83. // on top of the last one.
  84. var count = _document.Elements.Count;
  85. var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
  86. var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
  87. return (x, y);
  88. }
  89. private CanvasViewTransform CurrentTransform() =>
  90. CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);
  91. protected override void OnPaint(PaintEventArgs e)
  92. {
  93. base.OnPaint(e);
  94. var g = e.Graphics;
  95. g.SmoothingMode = SmoothingMode.AntiAlias;
  96. g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
  97. var transform = CurrentTransform();
  98. var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
  99. var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
  100. var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);
  101. g.FillRectangle(Brushes.White, pageRect);
  102. g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);
  103. // Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
  104. // time, run here against the loaded CSV's sample record so the canvas preview and the
  105. // final PDF agree (the story's "Preview and final render must agree" conversation note).
  106. var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);
  107. foreach (var element in _document.Elements.OrderBy(el => el.ZOrder))
  108. {
  109. var state = previewStates[element.Id];
  110. if (!state.Visible)
  111. {
  112. continue;
  113. }
  114. DrawElement(g, transform, element, state.EffectiveY, state.IsUnmappedColumn,
  115. isSelected: ReferenceEquals(element, _editor.Selected));
  116. }
  117. if (_editor.Selected is not null)
  118. {
  119. DrawRotateHandle(g, transform, _editor.Selected);
  120. }
  121. }
  122. private void DrawElement(
  123. Graphics g, CanvasViewTransform transform, TextElementLayout element,
  124. double effectiveY, bool isUnmappedColumn, bool isSelected)
  125. {
  126. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  127. var (width, height) = MeasureElement(element);
  128. // Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
  129. // bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
  130. // the pixel position to draw the string's top-left corner at is the transform of
  131. // (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
  132. // address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
  133. var (drawX, drawY) = transform.ToPixels(element.X, effectiveY + height);
  134. GraphicsState? savedState = null;
  135. if (element.RotationAngle != 0)
  136. {
  137. var (centerXPx, centerYPx) = transform.ToPixels(element.X + (width / 2.0), effectiveY + (height / 2.0));
  138. savedState = g.Save();
  139. g.TranslateTransform((float)centerXPx, (float)centerYPx);
  140. // GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
  141. // Y-down pixel space. The stored RotationAngle uses the opposite convention —
  142. // counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
  143. // RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
  144. // is negated here to keep the canvas rotating the same visual direction the final PDF
  145. // will, per this story's "same rotation, around the same pivot, as shown in the
  146. // designer canvas" acceptance criterion.
  147. g.RotateTransform((float)-element.RotationAngle);
  148. g.TranslateTransform((float)-centerXPx, (float)-centerYPx);
  149. }
  150. try
  151. {
  152. if (element.IsDynamic)
  153. {
  154. // A visible placeholder representation distinct from static text (Sprint 3 Batch 3
  155. // acceptance criterion). Sprint 4: an unmapped column (bound to a name that isn't
  156. // among the currently loaded CSV's headers — a real mapping problem) gets a
  157. // visually distinct warning color instead of the normal "this is dynamic" blue, so
  158. // an operator can tell a mapping error apart from a field that's merely blank for
  159. // the current sample data (which draws with the ordinary blue fill, or is skipped
  160. // entirely if also collapsible — see the caller's `Visible` check).
  161. var boxWidth = width * transform.Scale;
  162. var boxHeight = height * transform.Scale;
  163. var fillColor = isUnmappedColumn
  164. ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
  165. : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
  166. using var dynamicFill = new SolidBrush(fillColor);
  167. g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight);
  168. }
  169. using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
  170. g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);
  171. if (isUnmappedColumn)
  172. {
  173. using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
  174. g.DrawRectangle(
  175. warnPen, (float)drawX - 1, (float)drawY - 1,
  176. (float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
  177. }
  178. else if (element.CollapseIfBlank)
  179. {
  180. // A small marker for a configured-collapsible field, regardless of whether it
  181. // happens to be blank right now — lets an operator see at a glance which fields
  182. // are configured to collapse, without needing to select each one individually.
  183. using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
  184. var badgeY = (float)(drawY + (height * transform.Scale) + 2);
  185. var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
  186. g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
  187. }
  188. if (isSelected)
  189. {
  190. var selWidth = width * transform.Scale;
  191. var selHeight = height * transform.Scale;
  192. using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
  193. g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
  194. }
  195. }
  196. finally
  197. {
  198. if (savedState is not null)
  199. {
  200. g.Restore(savedState);
  201. }
  202. }
  203. }
  204. /// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
  205. /// dot connected to the selected element's bounding-box center by a dotted line, at the
  206. /// world-space position <see cref="CanvasElementEditor.HandlePosition"/> computes (which
  207. /// already accounts for the element's current rotation) — the same position
  208. /// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
  209. /// what's draggable.</summary>
  210. private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
  211. {
  212. var handle = _editor.HandlePosition();
  213. if (handle is null)
  214. {
  215. return;
  216. }
  217. var (width, height) = MeasureElement(element);
  218. var (centerPx, centerPy) = transform.ToPixels(element.X + (width / 2.0), element.Y + (height / 2.0));
  219. var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  220. using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
  221. g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
  222. const float radius = 5f;
  223. using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
  224. using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
  225. g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  226. g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  227. }
  228. /// <summary>Measures an element's rendered size in canvas-space points, using a
  229. /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
  230. /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
  231. /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
  232. /// parity with the final PDF.</summary>
  233. private static (double Width, double Height) MeasureElement(TextElementLayout element)
  234. {
  235. using var bitmap = new Bitmap(1, 1);
  236. using var g = Graphics.FromImage(bitmap);
  237. g.PageUnit = GraphicsUnit.Point;
  238. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  239. var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
  240. var size = g.MeasureString(text, font);
  241. return (size.Width, size.Height);
  242. }
  243. /// <summary>Falls back to a generic sans-serif font if the requested family isn't installed,
  244. /// so a missing font only affects the design-time preview's appearance — it does not crash
  245. /// the designer. This is independent of, and does not relax, the render-time rule that a
  246. /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps").</summary>
  247. private static Font ResolveFont(string familyName, float size)
  248. {
  249. try
  250. {
  251. return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
  252. }
  253. catch (ArgumentException)
  254. {
  255. return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
  256. }
  257. }
  258. protected override void OnMouseDown(MouseEventArgs e)
  259. {
  260. base.OnMouseDown(e);
  261. if (e.Button != MouseButtons.Left)
  262. {
  263. return;
  264. }
  265. var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
  266. // Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
  267. // instead of a normal select/move — checked first (and only when something is already
  268. // selected) so it never intercepts a normal click elsewhere on the canvas.
  269. if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
  270. {
  271. _editor.BeginRotateDrag();
  272. Capture = true;
  273. Invalidate();
  274. return;
  275. }
  276. var hit = _editor.TrySelectAt(x, y);
  277. if (hit)
  278. {
  279. _editor.BeginDrag(x, y);
  280. Capture = true;
  281. }
  282. Invalidate();
  283. SelectionChanged?.Invoke(this, EventArgs.Empty);
  284. }
  285. protected override void OnMouseMove(MouseEventArgs e)
  286. {
  287. base.OnMouseMove(e);
  288. if (!_editor.IsDragging && !_editor.IsRotating)
  289. {
  290. return;
  291. }
  292. var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
  293. if (_editor.IsRotating)
  294. {
  295. _editor.RotateDragTo(x, y);
  296. }
  297. else
  298. {
  299. _editor.DragTo(x, y);
  300. }
  301. Invalidate();
  302. ElementsChanged?.Invoke(this, EventArgs.Empty);
  303. }
  304. protected override void OnMouseUp(MouseEventArgs e)
  305. {
  306. base.OnMouseUp(e);
  307. _editor.EndDrag();
  308. Capture = false;
  309. }
  310. }

Powered by TurnKey Linux.