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

1977 строки
92KB

  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. private AddressControlLayout? _selectedAddressControl;
  21. private int _selectedAddressLineIndex;
  22. private (double Dx, double Dy)? _addressControlDragOffset;
  23. private bool _isResizingAddressControl;
  24. /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  25. /// mirrors <see cref="_isResizingAddressControl"/>'s shape — a control-specific drag-gesture
  26. /// flag, paralleling (not reusing) <see cref="CanvasElementEditor.IsRotating"/>, which only
  27. /// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary>
  28. private bool _isRotatingAddressControl;
  29. /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the Address Control
  30. /// half of the multi-selection — mirrors <see cref="CanvasElementEditor.MultiSelected"/> for
  31. /// standalone elements, since <see cref="CanvasElementEditor"/> has no knowledge of Address
  32. /// Controls at all (the same split as single-selection's <see cref="_selectedAddressControl"/>
  33. /// vs. <c>CanvasElementEditor.Selected</c>). An *additive* layer: every existing single-select
  34. /// code path keeps working unchanged whenever this set (plus the editor's) totals fewer than
  35. /// two items — see <see cref="CollapseMultiSelectionIfSingular"/>.</summary>
  36. private readonly HashSet<AddressControlLayout> _multiSelectedAddressControls = new();
  37. private bool _isRubberBandSelecting;
  38. private bool _rubberBandAdditive;
  39. private (double X, double Y)? _rubberBandStart;
  40. private (double X, double Y)? _rubberBandCurrent;
  41. /// <summary>Grab point and original positions for the Address Control half of an in-progress
  42. /// group drag — mirrors <see cref="CanvasElementEditor.BeginMultiDrag"/>/<c>MultiDragTo</c> for
  43. /// standalone elements, applied to both halves from the same shared delta every tick so the
  44. /// whole mixed multi-selection moves together.</summary>
  45. private (double X, double Y)? _multiDragGrabPoint;
  46. private Dictionary<AddressControlLayout, (double X, double Y)>? _multiDragOriginalAddressPositions;
  47. /// <summary>The currently loaded CSV's headers and one representative sample record, used
  48. /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
  49. /// empty/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
  50. private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
  51. private IReadOnlyDictionary<string, string>? _csvSampleRecord;
  52. public event EventHandler? SelectionChanged;
  53. public event EventHandler? ElementsChanged;
  54. public TemplateCanvasControl(TemplateLayoutDocument document)
  55. {
  56. _document = document;
  57. _editor = new CanvasElementEditor(document, MeasureElement);
  58. DoubleBuffered = true;
  59. AllowDrop = true;
  60. BackColor = SystemColors.ControlDark;
  61. SetStyle(ControlStyles.ResizeRedraw, true);
  62. // Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by
  63. // default, so it never received key events at all — needed for the Delete key to remove
  64. // the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on
  65. // the reported gap this fixes).
  66. SetStyle(ControlStyles.Selectable, true);
  67. TabStop = true;
  68. }
  69. /// <summary>Sprint 7, "Snap elements to grid and guides": mirrors
  70. /// <see cref="CanvasElementEditor.SnapToGridEnabled"/> so the same toggle governs standalone
  71. /// element dragging (handled inside <see cref="CanvasElementEditor"/>) and Address Control
  72. /// move/resize (handled directly in this control's mouse handlers below) uniformly, and so
  73. /// this control knows whether to paint grid lines.</summary>
  74. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  75. public bool SnapToGridEnabled
  76. {
  77. get => _editor.SnapToGridEnabled;
  78. set
  79. {
  80. _editor.SnapToGridEnabled = value;
  81. Invalidate();
  82. }
  83. }
  84. /// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
  85. /// lines are painted at, when <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
  86. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  87. public double GridSizePoints
  88. {
  89. get => _editor.GridSizePoints;
  90. set
  91. {
  92. _editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
  93. Invalidate();
  94. }
  95. }
  96. /// <summary>True while an active move, resize, or rotate gesture is in progress on the
  97. /// canvas — a standalone element drag/rotate (<see cref="CanvasElementEditor"/>) or an
  98. /// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets
  99. /// <see cref="TemplateDesignerForm"/> skip its full properties-panel refresh (which
  100. /// repopulates the rebind-column combo box from every loaded CSV header on each call — real
  101. /// work only for a single-column-bound dynamic element) on every mouse-move tick of a
  102. /// gesture, syncing just the position/angle fields the gesture can actually change instead.
  103. /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this
  104. /// property existed.</summary>
  105. public bool IsInteracting =>
  106. _editor.IsDragging || _editor.IsRotating || _editor.IsResizing || _addressControlDragOffset is not null
  107. || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging;
  108. public TextElementLayout? SelectedElement => _editor.Selected;
  109. public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
  110. public int SelectedAddressLineIndex => _selectedAddressLineIndex;
  111. /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the combined size of
  112. /// the multi-selection across both standalone elements and Address Controls. Zero or one means
  113. /// no *active* multi-selection — see <see cref="IsMultiSelectionActive"/> and
  114. /// <see cref="CollapseMultiSelectionIfSingular"/> for why a single leftover item always
  115. /// collapses back into the ordinary single-selection fields instead of staying here.</summary>
  116. public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count;
  117. /// <summary>True while two or more items (any mix of standalone elements and Address Controls)
  118. /// are selected together. <see cref="TemplateDesignerForm"/> uses this to show "N items
  119. /// selected" and disable per-item property editing instead of displaying stale/misleading
  120. /// single-item values.</summary>
  121. public bool IsMultiSelectionActive => MultiSelectionCount >= 2;
  122. public AddressControlLineLayout? SelectedAddressLine =>
  123. _selectedAddressControl is not null
  124. && _selectedAddressLineIndex >= 0
  125. && _selectedAddressLineIndex < _selectedAddressControl.Lines.Count
  126. ? _selectedAddressControl.Lines[_selectedAddressLineIndex]
  127. : null;
  128. public TextElementLayout AddStaticTextElement()
  129. {
  130. var (x, y) = DefaultNewElementPosition();
  131. var element = _editor.AddStaticText(x, y);
  132. Invalidate();
  133. SelectionChanged?.Invoke(this, EventArgs.Empty);
  134. ElementsChanged?.Invoke(this, EventArgs.Empty);
  135. return element;
  136. }
  137. /// <summary>Adds a filled rectangle (a text element with an empty content, a box and a fill
  138. /// color) that covers whatever is under it and can have text typed into it. White by default,
  139. /// like a cover-up patch.</summary>
  140. public TextElementLayout AddRectangleElement()
  141. {
  142. var (x, y) = DefaultNewElementPosition();
  143. var element = _editor.AddStaticText(x, y, text: string.Empty);
  144. element.Width = DefaultRectangleWidth;
  145. element.Height = DefaultRectangleHeight;
  146. element.FillColor = new RgbColor(255, 255, 255);
  147. Invalidate();
  148. SelectionChanged?.Invoke(this, EventArgs.Empty);
  149. ElementsChanged?.Invoke(this, EventArgs.Empty);
  150. return element;
  151. }
  152. public const double DefaultRectangleWidth = 120;
  153. public const double DefaultRectangleHeight = 40;
  154. public AddressControlLayout AddAddressControl()
  155. {
  156. var (x, y) = DefaultNewElementPosition();
  157. var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder());
  158. _document.AddressControls.Add(control);
  159. SelectAddressControl(control, 0);
  160. Invalidate();
  161. SelectionChanged?.Invoke(this, EventArgs.Empty);
  162. ElementsChanged?.Invoke(this, EventArgs.Empty);
  163. return control;
  164. }
  165. public void AddAddressLine()
  166. {
  167. if (_selectedAddressControl is null)
  168. {
  169. return;
  170. }
  171. _selectedAddressControl.AddLine();
  172. _selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1;
  173. Invalidate();
  174. SelectionChanged?.Invoke(this, EventArgs.Empty);
  175. ElementsChanged?.Invoke(this, EventArgs.Empty);
  176. }
  177. public void RemoveSelectedAddressLine()
  178. {
  179. if (_selectedAddressControl is null)
  180. {
  181. return;
  182. }
  183. if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex))
  184. {
  185. _selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1);
  186. Invalidate();
  187. SelectionChanged?.Invoke(this, EventArgs.Empty);
  188. ElementsChanged?.Invoke(this, EventArgs.Empty);
  189. }
  190. }
  191. public void SelectAddressLine(int lineIndex)
  192. {
  193. if (_selectedAddressControl is null)
  194. {
  195. return;
  196. }
  197. _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1));
  198. Invalidate();
  199. SelectionChanged?.Invoke(this, EventArgs.Empty);
  200. }
  201. public void MoveSelectedAddressLineUp()
  202. {
  203. if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex))
  204. {
  205. _selectedAddressLineIndex--;
  206. Invalidate();
  207. SelectionChanged?.Invoke(this, EventArgs.Empty);
  208. ElementsChanged?.Invoke(this, EventArgs.Empty);
  209. }
  210. }
  211. public void MoveSelectedAddressLineDown()
  212. {
  213. if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex))
  214. {
  215. _selectedAddressLineIndex++;
  216. Invalidate();
  217. SelectionChanged?.Invoke(this, EventArgs.Empty);
  218. ElementsChanged?.Invoke(this, EventArgs.Empty);
  219. }
  220. }
  221. /// <summary>Drag-and-drop data format carrying a CSV column header name from the form's CSV
  222. /// grid onto this canvas.</summary>
  223. public const string ColumnDragFormat = "EnvelopeRenderer.CsvColumn";
  224. /// <summary>Drops a CSV column onto the canvas at a client-space pixel position: onto an
  225. /// Address Control it appends a new line bound to the column; anywhere else it places a new
  226. /// dynamic placeholder there. Ignored in show-data (read-only) mode.</summary>
  227. public bool DropColumn(string columnName, Point clientPoint)
  228. {
  229. if (_showData || string.IsNullOrEmpty(columnName))
  230. {
  231. return false;
  232. }
  233. var (x, y) = CurrentTransform().ToPoints(clientPoint.X, clientPoint.Y);
  234. var (control, _) = HitTestAddressControl(x, y);
  235. if (control is not null)
  236. {
  237. control.Lines.Add(AddressControlLineLayout.CreateField(columnName));
  238. SelectAddressControl(control, control.Lines.Count - 1);
  239. Invalidate();
  240. SelectionChanged?.Invoke(this, EventArgs.Empty);
  241. ElementsChanged?.Invoke(this, EventArgs.Empty);
  242. return true;
  243. }
  244. _editor.AddDynamicPlaceholder(x, y, columnName);
  245. Invalidate();
  246. SelectionChanged?.Invoke(this, EventArgs.Empty);
  247. ElementsChanged?.Invoke(this, EventArgs.Empty);
  248. return true;
  249. }
  250. protected override void OnDragOver(DragEventArgs drgevent)
  251. {
  252. base.OnDragOver(drgevent);
  253. drgevent.Effect = !_showData && drgevent.Data?.GetDataPresent(ColumnDragFormat) == true
  254. ? DragDropEffects.Copy
  255. : DragDropEffects.None;
  256. }
  257. protected override void OnDragDrop(DragEventArgs drgevent)
  258. {
  259. base.OnDragDrop(drgevent);
  260. if (drgevent.Data?.GetData(ColumnDragFormat) is string column)
  261. {
  262. DropColumn(column, PointToClient(new Point(drgevent.X, drgevent.Y)));
  263. }
  264. }
  265. public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
  266. {
  267. var (x, y) = DefaultNewElementPosition();
  268. var element = _editor.AddDynamicPlaceholder(x, y, columnName);
  269. Invalidate();
  270. SelectionChanged?.Invoke(this, EventArgs.Empty);
  271. ElementsChanged?.Invoke(this, EventArgs.Empty);
  272. return element;
  273. }
  274. /// <summary>Sprint 4: supplies the loaded CSV's headers and one representative sample record
  275. /// (typically the first loaded sample row) so the canvas can preview address-line collapsing
  276. /// and flag mapping errors exactly the way a real render would. Pass an empty header list and
  277. /// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
  278. public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
  279. {
  280. _csvHeaders = headers;
  281. _csvSampleRecord = sampleRecord;
  282. Invalidate();
  283. }
  284. private bool _showData;
  285. /// <summary>"Show data" view mode: the canvas draws each element's text resolved against the
  286. /// current record (the one supplied via <see cref="SetCsvPreviewContext"/>) instead of the
  287. /// literal <c>{Column}</c> tokens with their blue placeholder fills. Read-only while on — hit
  288. /// testing and measuring work from the token text, so editing gestures are ignored until the
  289. /// operator switches back to design view.</summary>
  290. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  291. public bool ShowData
  292. {
  293. get => _showData;
  294. set
  295. {
  296. if (_showData == value)
  297. {
  298. return;
  299. }
  300. _showData = value;
  301. Invalidate();
  302. }
  303. }
  304. /// <summary>The text an element is drawn with: its resolved value for the current record in
  305. /// show-data mode (falling back to the token text if it cannot be resolved), otherwise the
  306. /// token text.</summary>
  307. private string DisplayTextFor(TextElementLayout element) =>
  308. _showData
  309. ? TextResolver.TryResolve(element.Runs, _csvHeaders, _csvSampleRecord) ?? element.DisplayText
  310. : element.DisplayText;
  311. /// <summary>Re-selects the given element (e.g. after the properties panel changes it) and
  312. /// redraws — used so external edits stay visually in sync with the canvas.</summary>
  313. public void NotifyElementChanged()
  314. {
  315. Invalidate();
  316. ElementsChanged?.Invoke(this, EventArgs.Empty);
  317. }
  318. /// <summary>Clears the current selection and redraws — used after reopening a saved template
  319. /// (Batch 5), since a freshly loaded document's elements are new object instances and any
  320. /// previously selected element instance no longer belongs to it.</summary>
  321. public void ClearSelection()
  322. {
  323. _editor.Select(null);
  324. _editor.ClearMultiSelect();
  325. _selectedAddressControl = null;
  326. _selectedAddressLineIndex = 0;
  327. _addressControlDragOffset = null;
  328. _isResizingAddressControl = false;
  329. _isRotatingAddressControl = false;
  330. _multiSelectedAddressControls.Clear();
  331. _isRubberBandSelecting = false;
  332. _rubberBandStart = null;
  333. _rubberBandCurrent = null;
  334. Invalidate();
  335. SelectionChanged?.Invoke(this, EventArgs.Empty);
  336. }
  337. /// <summary>Post-Sprint-9 user-reported gap: there was no way to delete a placed element or
  338. /// Address Control at all once added. Removes whichever is currently selected (a standalone
  339. /// element or a whole Address Control — never just a drilled-into address line, which has its
  340. /// own dedicated <see cref="RemoveSelectedAddressLine"/>) and clears the selection. Returns
  341. /// <c>false</c> (a no-op) if nothing is selected.</summary>
  342. public bool RemoveSelectedElement()
  343. {
  344. if (_selectedAddressControl is not null)
  345. {
  346. var removed = _document.AddressControls.Remove(_selectedAddressControl);
  347. ClearSelection();
  348. if (removed)
  349. {
  350. ElementsChanged?.Invoke(this, EventArgs.Empty);
  351. }
  352. return removed;
  353. }
  354. if (_editor.Selected is not null)
  355. {
  356. var removed = _editor.RemoveSelected();
  357. Invalidate();
  358. SelectionChanged?.Invoke(this, EventArgs.Empty);
  359. if (removed)
  360. {
  361. ElementsChanged?.Invoke(this, EventArgs.Empty);
  362. }
  363. return removed;
  364. }
  365. return false;
  366. }
  367. /// <summary>Sprint 10, "Align and distribute multiple elements": aligns every item in the
  368. /// current multi-selection to the given <paramref name="edge"/> of the selection's combined
  369. /// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are
  370. /// expected to disable themselves via <see cref="MultiSelectionCount"/> rather than relying on
  371. /// this guard alone, per this story's "clearly disabled, not a silent no-op" AC.</summary>
  372. public void AlignSelection(AlignmentCalculator.Edge edge)
  373. {
  374. if (MultiSelectionCount < 2)
  375. {
  376. return;
  377. }
  378. ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls);
  379. }
  380. /// <summary>Distributes the current multi-selection with equal center-to-center spacing along
  381. /// the given <paramref name="axis"/> — see <see cref="AlignmentCalculator.ComputeDistributionDeltas"/>
  382. /// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and,
  383. /// per that method's own remarks, has no visible effect below 3, since there is nothing to
  384. /// place between the two extreme items).</summary>
  385. public void DistributeSelection(AlignmentCalculator.Axis axis)
  386. {
  387. if (MultiSelectionCount < 2)
  388. {
  389. return;
  390. }
  391. ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls);
  392. }
  393. /// <summary>Builds the combined, fixed-order bounding-box list <see cref="AlignmentCalculator"/>
  394. /// operates on — standalone elements first, then Address Controls — so a returned delta list
  395. /// can be zipped back to the exact item it applies to via <paramref name="elements"/>/
  396. /// <paramref name="controls"/>' matching order.</summary>
  397. private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds(
  398. out List<TextElementLayout> elements, out List<AddressControlLayout> controls)
  399. {
  400. elements = _editor.MultiSelected.ToList();
  401. controls = _multiSelectedAddressControls.ToList();
  402. var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count);
  403. bounds.AddRange(elements.Select(_editor.GetWorldBounds));
  404. bounds.AddRange(controls.Select(GetAddressControlWorldBounds));
  405. return bounds;
  406. }
  407. private void ApplySelectionDeltas(
  408. IReadOnlyList<(double Dx, double Dy)> deltas, List<TextElementLayout> elements, List<AddressControlLayout> controls)
  409. {
  410. for (var i = 0; i < elements.Count; i++)
  411. {
  412. elements[i].X += deltas[i].Dx;
  413. elements[i].Y += deltas[i].Dy;
  414. }
  415. for (var i = 0; i < controls.Count; i++)
  416. {
  417. var delta = deltas[elements.Count + i];
  418. controls[i].X += delta.Dx;
  419. controls[i].Y += delta.Dy;
  420. }
  421. Invalidate();
  422. ElementsChanged?.Invoke(this, EventArgs.Empty);
  423. }
  424. private (double X, double Y) DefaultNewElementPosition()
  425. {
  426. // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
  427. // on top of the last one.
  428. var count = _document.Elements.Count + _document.AddressControls.Count;
  429. var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
  430. var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
  431. return (x, y);
  432. }
  433. private CanvasViewTransform CurrentTransform() =>
  434. CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);
  435. /// <summary>Folder relative background paths resolve against (the template folder).</summary>
  436. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  437. public string? BackgroundBaseDirectory { get; set; }
  438. private string? _backgroundImagePath;
  439. private DateTime _backgroundImageStamp;
  440. private Image? _backgroundImage;
  441. /// <summary>Paints the template page background under everything else. Images are drawn
  442. /// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page
  443. /// shows a labelled placeholder - the real PDF page appears in the rendered output.</summary>
  444. private void DrawPageBackground(Graphics g, RectangleF pageRect)
  445. {
  446. var background = _document.Background;
  447. if (background is null)
  448. {
  449. return;
  450. }
  451. var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory);
  452. if (file is null)
  453. {
  454. return;
  455. }
  456. if (PageBackgroundLayout.IsPdf(file))
  457. {
  458. var page = background.ResolvePage(_csvSampleRecord);
  459. var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null;
  460. if (pdfBitmap is not null)
  461. {
  462. g.DrawImage(pdfBitmap, pageRect);
  463. return;
  464. }
  465. using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250));
  466. g.FillRectangle(tint, pageRect);
  467. using var font = new Font("Segoe UI", 9f);
  468. g.DrawString(
  469. $"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})",
  470. font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6);
  471. return;
  472. }
  473. var image = LoadBackgroundImage(file);
  474. if (image is not null)
  475. {
  476. g.DrawImage(image, pageRect);
  477. }
  478. }
  479. private string? _pdfBackgroundKey;
  480. private int _pdfBackgroundWidth;
  481. private Bitmap? _pdfBackgroundBitmap;
  482. private string? _pdfBackgroundError;
  483. /// <summary>The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file
  484. /// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom),
  485. /// so a repaint during drag or selection never re-renders the PDF.</summary>
  486. private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error)
  487. {
  488. var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0;
  489. var key = $"{path}|{page}|{stamp}";
  490. var wanted = Math.Max(pageWidthPixels, 50);
  491. var sameKey = _pdfBackgroundKey == key;
  492. var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75;
  493. if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth))
  494. {
  495. error = _pdfBackgroundError;
  496. return _pdfBackgroundBitmap;
  497. }
  498. var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError);
  499. _pdfBackgroundBitmap?.Dispose();
  500. _pdfBackgroundBitmap = rendered;
  501. _pdfBackgroundKey = key;
  502. _pdfBackgroundWidth = rendered is null ? 0 : wanted;
  503. _pdfBackgroundError = renderError;
  504. error = renderError;
  505. return rendered;
  506. }
  507. private Image? LoadBackgroundImage(string path)
  508. {
  509. try
  510. {
  511. var stamp = File.GetLastWriteTimeUtc(path);
  512. if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp)
  513. {
  514. return _backgroundImage;
  515. }
  516. _backgroundImage?.Dispose();
  517. _backgroundImage = null;
  518. _backgroundImagePath = path;
  519. _backgroundImageStamp = stamp;
  520. // Copy through a MemoryStream so the file is not left locked by GDI+.
  521. using var stream = new MemoryStream(File.ReadAllBytes(path));
  522. using var loaded = Image.FromStream(stream);
  523. _backgroundImage = new Bitmap(loaded);
  524. }
  525. catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException)
  526. {
  527. _backgroundImage = null;
  528. }
  529. return _backgroundImage;
  530. }
  531. protected override void OnPaint(PaintEventArgs e)
  532. {
  533. base.OnPaint(e);
  534. var g = e.Graphics;
  535. g.SmoothingMode = SmoothingMode.AntiAlias;
  536. g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
  537. var transform = CurrentTransform();
  538. var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
  539. var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
  540. var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);
  541. g.FillRectangle(Brushes.White, pageRect);
  542. DrawPageBackground(g, pageRect);
  543. g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);
  544. // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
  545. // alignment is visible, not just felt during a drag — drawn under every element so it
  546. // never obscures selection/rotate-handle/mapping-warning visuals.
  547. if (SnapToGridEnabled)
  548. {
  549. DrawGrid(g, transform);
  550. }
  551. // Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
  552. // time, run here against the loaded CSV's sample record so the canvas preview and the
  553. // final PDF agree (the story's "Preview and final render must agree" conversation note).
  554. var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);
  555. var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>();
  556. paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null)));
  557. paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c)));
  558. foreach (var item in paintItems.OrderBy(i => i.ZOrder))
  559. {
  560. if (item.Text is not null)
  561. {
  562. var state = previewStates[item.Text.Id];
  563. if (!state.Visible)
  564. {
  565. continue;
  566. }
  567. // Sprint 10, "Select multiple elements at once on the canvas": a multi-selected
  568. // element gets the same highlight border as the single primary selection (see
  569. // DrawElement's isSelected remarks — it only ever drives the highlight, never the
  570. // resize/rotate handles, which are drawn separately below gated on
  571. // _editor.Selected alone) so every selected item is visibly marked, not just one.
  572. DrawElement(
  573. g, transform, item.Text, state,
  574. isSelected: ReferenceEquals(item.Text, _editor.Selected) || _editor.MultiSelected.Contains(item.Text));
  575. continue;
  576. }
  577. DrawAddressControl(
  578. g,
  579. transform,
  580. item.Control!,
  581. isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl),
  582. isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!));
  583. }
  584. if (_editor.Selected is not null)
  585. {
  586. DrawResizeHandle(g, transform, _editor.Selected);
  587. DrawHeightHandle(g, transform);
  588. DrawRotateHandle(g, transform, _editor.Selected);
  589. }
  590. if (_isRubberBandSelecting && _rubberBandStart is not null && _rubberBandCurrent is not null)
  591. {
  592. DrawRubberBand(g, transform, _rubberBandStart.Value, _rubberBandCurrent.Value);
  593. }
  594. }
  595. /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the marquee rectangle
  596. /// drawn while a rubber-band selection drag is in progress, in canvas-page-agnostic pixel
  597. /// space (drawn last, on top of everything else, the same way a rotate/resize handle already
  598. /// draws on top of its element).</summary>
  599. private static void DrawRubberBand(
  600. Graphics g, CanvasViewTransform transform, (double X, double Y) start, (double X, double Y) current)
  601. {
  602. var (x1, y1) = transform.ToPixels(start.X, start.Y);
  603. var (x2, y2) = transform.ToPixels(current.X, current.Y);
  604. var rect = RectangleF.FromLTRB(
  605. (float)Math.Min(x1, x2), (float)Math.Min(y1, y2), (float)Math.Max(x1, x2), (float)Math.Max(y1, y2));
  606. using var fill = new SolidBrush(System.Drawing.Color.FromArgb(40, System.Drawing.Color.DodgerBlue));
  607. using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
  608. g.FillRectangle(fill, rect);
  609. g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
  610. }
  611. /// <summary>Sprint 7: draws light dotted grid lines across the page at every
  612. /// <see cref="GridSizePoints"/> interval, in both directions, so an operator can see the
  613. /// alignment grid snapping is rounding positions to.</summary>
  614. private void DrawGrid(Graphics g, CanvasViewTransform transform)
  615. {
  616. var gridSize = GridSizePoints;
  617. if (gridSize <= 0)
  618. {
  619. return;
  620. }
  621. using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1)
  622. {
  623. DashStyle = DashStyle.Dot,
  624. };
  625. for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize)
  626. {
  627. var (x1, y1) = transform.ToPixels(x, 0);
  628. var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints);
  629. g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
  630. }
  631. for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize)
  632. {
  633. var (x1, y1) = transform.ToPixels(0, y);
  634. var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y);
  635. g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
  636. }
  637. }
  638. private void SelectAddressControl(AddressControlLayout control, int lineIndex)
  639. {
  640. _selectedAddressControl = control;
  641. _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1));
  642. _editor.Select(null);
  643. }
  644. private void ClearAddressSelection()
  645. {
  646. _selectedAddressControl = null;
  647. _selectedAddressLineIndex = 0;
  648. _addressControlDragOffset = null;
  649. _isResizingAddressControl = false;
  650. _isRotatingAddressControl = false;
  651. }
  652. /// <summary>Sprint 10, "Select multiple elements at once on the canvas": empties both halves
  653. /// of the multi-selection (standalone elements and Address Controls) without touching the
  654. /// ordinary single-selection fields — callers that are about to establish a fresh single
  655. /// selection call this first so a stale multi-selection never lingers alongside it.</summary>
  656. private void ClearMultiSelection()
  657. {
  658. _editor.ClearMultiSelect();
  659. _multiSelectedAddressControls.Clear();
  660. }
  661. /// <summary>Applies the multi-selection's one collapse rule after every mutation (a rubber-band
  662. /// release, a modifier-click toggle): zero items means nothing is selected at all; exactly one
  663. /// item collapses back into the ordinary single-selection fields (<see cref="CanvasElementEditor.Selected"/>
  664. /// or <see cref="_selectedAddressControl"/>) so every existing single-select code path —
  665. /// properties panel, rotate/resize handles, address-line drill-in — keeps working completely
  666. /// unchanged; two or more items clears both single-selection fields so the properties panel
  667. /// shows "N items selected" instead of stale single-item values.</summary>
  668. private void CollapseMultiSelectionIfSingular()
  669. {
  670. var multiElements = _editor.MultiSelected;
  671. var total = multiElements.Count + _multiSelectedAddressControls.Count;
  672. if (total == 0)
  673. {
  674. _editor.Select(null);
  675. ClearAddressSelection();
  676. return;
  677. }
  678. if (total == 1)
  679. {
  680. if (multiElements.Count == 1)
  681. {
  682. var onlyElement = multiElements.First();
  683. _editor.ClearMultiSelect();
  684. _editor.Select(onlyElement);
  685. ClearAddressSelection();
  686. }
  687. else
  688. {
  689. var onlyControl = _multiSelectedAddressControls.First();
  690. _multiSelectedAddressControls.Clear();
  691. SelectAddressControl(onlyControl, 0);
  692. }
  693. return;
  694. }
  695. // Two or more: neither single-selection field applies while a multi-selection is active.
  696. _editor.Select(null);
  697. ClearAddressSelection();
  698. }
  699. /// <summary>Modifier-click (Ctrl/Shift) support: toggles the given element's membership in the
  700. /// multi-selection. If nothing was multi-selected yet, first seeds the set with whatever was
  701. /// singly selected — the standard modifier-click UX extends the current selection rather than
  702. /// starting over from empty.</summary>
  703. private void ToggleElementMultiSelect(TextElementLayout element)
  704. {
  705. SeedMultiSelectionFromSingleSelectionIfEmpty();
  706. _editor.ToggleMultiSelect(element);
  707. CollapseMultiSelectionIfSingular();
  708. }
  709. private void ToggleAddressControlMultiSelect(AddressControlLayout control)
  710. {
  711. SeedMultiSelectionFromSingleSelectionIfEmpty();
  712. if (!_multiSelectedAddressControls.Remove(control))
  713. {
  714. _multiSelectedAddressControls.Add(control);
  715. }
  716. CollapseMultiSelectionIfSingular();
  717. }
  718. private void SeedMultiSelectionFromSingleSelectionIfEmpty()
  719. {
  720. if (_editor.MultiSelected.Count > 0 || _multiSelectedAddressControls.Count > 0)
  721. {
  722. return;
  723. }
  724. if (_editor.Selected is not null)
  725. {
  726. _editor.SetMultiSelection(new[] { _editor.Selected });
  727. }
  728. if (_selectedAddressControl is not null)
  729. {
  730. _multiSelectedAddressControls.Add(_selectedAddressControl);
  731. }
  732. }
  733. /// <summary>Rubber-band selection support: every Address Control whose world-space
  734. /// (rotation-aware) axis-aligned bounding box intersects the given rectangle — the Address
  735. /// Control counterpart of <see cref="CanvasElementEditor.ElementsInRect"/>, kept here rather
  736. /// than in Desktop.Core since it needs <see cref="MaxLineFontSize"/>, the same WinForms-only
  737. /// geometry helper <see cref="DrawAddressControlUnrotated"/> already uses.</summary>
  738. private List<AddressControlLayout> AddressControlsInRect(double minX, double minY, double maxX, double maxY)
  739. {
  740. var result = new List<AddressControlLayout>();
  741. foreach (var control in _document.AddressControls)
  742. {
  743. var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control);
  744. if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
  745. {
  746. result.Add(control);
  747. }
  748. }
  749. return result;
  750. }
  751. /// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
  752. /// world-space AABB <see cref="AddressControlsInRect"/> already computed inline for rubber-band
  753. /// hit-testing, extracted so alignment/distribution can feed it into
  754. /// <see cref="AlignmentCalculator"/> alongside <see cref="CanvasElementEditor.GetWorldBounds"/>
  755. /// for standalone elements.</summary>
  756. private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control)
  757. {
  758. var top = control.TopBaselineY + MaxLineFontSize(control);
  759. var bottom = control.TopBaselineY - control.Height;
  760. var left = control.X;
  761. var right = control.X + control.Width;
  762. if (control.RotationAngle == 0)
  763. {
  764. return (left, bottom, right, top);
  765. }
  766. var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
  767. var minX = double.MaxValue;
  768. var minY = double.MaxValue;
  769. var maxX = double.MinValue;
  770. var maxY = double.MinValue;
  771. foreach (var (cornerX, cornerY) in corners)
  772. {
  773. var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
  774. minX = Math.Min(minX, rx);
  775. maxX = Math.Max(maxX, rx);
  776. minY = Math.Min(minY, ry);
  777. maxY = Math.Max(maxY, ry);
  778. }
  779. return (minX, minY, maxX, maxY);
  780. }
  781. /// <summary>Finalizes a rubber-band drag on release: computes the final (normalized) rectangle,
  782. /// finds every element/control it intersects, and either replaces the multi-selection with that
  783. /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,
  784. /// <see cref="_rubberBandAdditive"/>) — mirroring modifier-click's "extend, don't replace"
  785. /// behavior for consistency.</summary>
  786. private void FinalizeRubberBandSelection()
  787. {
  788. if (_rubberBandStart is not null && _rubberBandCurrent is not null)
  789. {
  790. var (x1, y1) = _rubberBandStart.Value;
  791. var (x2, y2) = _rubberBandCurrent.Value;
  792. var minX = Math.Min(x1, x2);
  793. var maxX = Math.Max(x1, x2);
  794. var minY = Math.Min(y1, y2);
  795. var maxY = Math.Max(y1, y2);
  796. var hitElements = _editor.ElementsInRect(minX, minY, maxX, maxY);
  797. var hitControls = AddressControlsInRect(minX, minY, maxX, maxY);
  798. if (_rubberBandAdditive)
  799. {
  800. var combinedElements = new HashSet<TextElementLayout>(_editor.MultiSelected);
  801. foreach (var element in hitElements)
  802. {
  803. combinedElements.Add(element);
  804. }
  805. _editor.SetMultiSelection(combinedElements);
  806. foreach (var control in hitControls)
  807. {
  808. _multiSelectedAddressControls.Add(control);
  809. }
  810. }
  811. else
  812. {
  813. _editor.SetMultiSelection(hitElements);
  814. _multiSelectedAddressControls.Clear();
  815. foreach (var control in hitControls)
  816. {
  817. _multiSelectedAddressControls.Add(control);
  818. }
  819. }
  820. CollapseMultiSelectionIfSingular();
  821. }
  822. _isRubberBandSelecting = false;
  823. _rubberBandStart = null;
  824. _rubberBandCurrent = null;
  825. Invalidate();
  826. SelectionChanged?.Invoke(this, EventArgs.Empty);
  827. }
  828. private void BeginGroupDrag(double grabXPoints, double grabYPoints)
  829. {
  830. _editor.BeginMultiDrag(grabXPoints, grabYPoints);
  831. _multiDragGrabPoint = (grabXPoints, grabYPoints);
  832. _multiDragOriginalAddressPositions = _multiSelectedAddressControls.ToDictionary(c => c, c => (c.X, c.Y));
  833. }
  834. private void ApplyMultiDragToAddressControls(double xPoints, double yPoints)
  835. {
  836. if (_multiDragGrabPoint is null || _multiDragOriginalAddressPositions is null)
  837. {
  838. return;
  839. }
  840. var dx = xPoints - _multiDragGrabPoint.Value.X;
  841. var dy = yPoints - _multiDragGrabPoint.Value.Y;
  842. if (SnapToGridEnabled)
  843. {
  844. dx = GridSnapper.Snap(dx, GridSizePoints);
  845. dy = GridSnapper.Snap(dy, GridSizePoints);
  846. }
  847. foreach (var (control, original) in _multiDragOriginalAddressPositions)
  848. {
  849. control.X = original.X + dx;
  850. control.Y = original.Y + dy;
  851. }
  852. }
  853. private void EndGroupDrag()
  854. {
  855. _editor.EndMultiDrag();
  856. _multiDragGrabPoint = null;
  857. _multiDragOriginalAddressPositions = null;
  858. }
  859. private void DrawElement(
  860. Graphics g, CanvasViewTransform transform, TextElementLayout element,
  861. ElementPreviewState state, bool isSelected)
  862. {
  863. var effectiveY = state.EffectiveY;
  864. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  865. var (width, height) = MeasureElement(element);
  866. // Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
  867. // bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
  868. // the pixel position to draw the string's top-left corner at is the transform of
  869. // (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
  870. // address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
  871. var (drawX, boxTopY) = transform.ToPixels(element.X, effectiveY + height);
  872. using var drawFont = ResolveDrawFont(element.FontFamily, (float)element.FontSize, transform.Scale);
  873. // Plain (non-box) text is positioned by baseline like the PDF; a boxed element's text
  874. // hangs from the top of its box (PDF DrawTextBox).
  875. var drawY = element.HasBox
  876. ? boxTopY
  877. : transform.ToPixels(element.X, effectiveY).Y - BaselineOffsetPx(drawFont);
  878. GraphicsState? savedState = null;
  879. if (element.RotationAngle != 0)
  880. {
  881. var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height);
  882. var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
  883. savedState = g.Save();
  884. g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
  885. // GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
  886. // Y-down pixel space. The stored RotationAngle uses the opposite convention —
  887. // counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
  888. // RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
  889. // is negated here to keep the canvas rotating the same visual direction the final PDF
  890. // will, per this story's "same rotation, around the same pivot, as shown in the
  891. // designer canvas" acceptance criterion.
  892. g.RotateTransform((float)-element.RotationAngle);
  893. g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
  894. }
  895. try
  896. {
  897. var boxHeight = height * transform.Scale;
  898. using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
  899. if (element.HasFill && element.FillColor is { } rectFill)
  900. {
  901. // Filled rectangle: painted first so the element's own text (and any element with
  902. // a higher z-order) draws over it, like the real render.
  903. using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B));
  904. g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
  905. // Design-view outline so a white (or page-coloured) rectangle is still visible and
  906. // clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light
  907. // dashed line together stay visible over any fill or background.
  908. var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
  909. using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f);
  910. using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash };
  911. g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
  912. g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
  913. }
  914. if (element.HasBox)
  915. {
  916. // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own
  917. // rectangle-bounded DrawString wraps natively within the box, the design-time
  918. // approximation of the real CLI render's Debenu-native wrap (see this story's
  919. // sizing note — the two engines have no shared line-breaking code path, so this is
  920. // an accepted approximation, not a guarantee of matching the exact wrap points).
  921. // The per-run highlight segmentation below is a single-line layout and does not
  922. // yet extend across wrapped lines — left as a known, documented visual
  923. // approximation gap for this story; a per-run-aware wrapped highlight is polish-
  924. // tier scope for a later story, not required here.
  925. var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight);
  926. g.DrawString(DisplayTextFor(element), drawFont, brush, boxRect);
  927. // Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange
  928. // outline, the same visual language as the unmapped-column warning below, drawn
  929. // whenever the element's *current* content is actually wrapping and/or being
  930. // clipped — reusing the same box-mode text this method just drew, not a second
  931. // divergent measurement.
  932. var (isWrapping, isClipping) = MeasureWrapClip(element, font);
  933. if (isWrapping || isClipping)
  934. {
  935. using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
  936. g.DrawRectangle(
  937. wrapClipPen, (float)drawX - 1, (float)drawY - 1,
  938. (float)(width * transform.Scale) + 2, (float)boxHeight + 2);
  939. }
  940. }
  941. else
  942. {
  943. // Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a
  944. // visible placeholder highlight per field-run *segment* rather than one
  945. // whole-element box — literal text within a mixed element gets no fill at all, so
  946. // an operator can see exactly which portion(s) of the line are field tokens versus
  947. // literal text. Sprint 4's unmapped-column warning color is now decided per run
  948. // (see AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns)
  949. // rather than for the whole element, so a mixed element with one bad token among
  950. // several good ones only flags that one segment.
  951. var segments = _showData ? new List<(TextRun Run, double Width)>() : MeasureRunSegments(element, font);
  952. var cumulativeWidth = 0.0;
  953. for (var i = 0; i < segments.Count; i++)
  954. {
  955. var (run, segmentWidth) = segments[i];
  956. if (run.IsField)
  957. {
  958. var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i];
  959. var fillColor = isRunUnmapped
  960. ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
  961. : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
  962. using var dynamicFill = new SolidBrush(fillColor);
  963. var segX = drawX + (cumulativeWidth * transform.Scale);
  964. var segBoxWidth = segmentWidth * transform.Scale;
  965. g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight);
  966. }
  967. cumulativeWidth += segmentWidth;
  968. }
  969. g.DrawString(DisplayTextFor(element), drawFont, brush, (float)drawX, (float)drawY);
  970. }
  971. if (state.IsUnmappedColumn)
  972. {
  973. using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
  974. g.DrawRectangle(
  975. warnPen, (float)drawX - 1, (float)drawY - 1,
  976. (float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
  977. }
  978. else if (element.CollapseIfBlank)
  979. {
  980. // A small marker for a configured-collapsible field, regardless of whether it
  981. // happens to be blank right now — lets an operator see at a glance which fields
  982. // are configured to collapse, without needing to select each one individually.
  983. using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
  984. var badgeY = (float)(drawY + (height * transform.Scale) + 2);
  985. var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
  986. g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
  987. }
  988. if (isSelected)
  989. {
  990. var selWidth = width * transform.Scale;
  991. var selHeight = height * transform.Scale;
  992. using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
  993. g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
  994. }
  995. }
  996. finally
  997. {
  998. if (savedState is not null)
  999. {
  1000. g.Restore(savedState);
  1001. }
  1002. }
  1003. }
  1004. /// <summary>Post-Sprint-8 user-requested feature: draws the font-size resize handle at the
  1005. /// selected element's top-right corner (<see cref="CanvasElementEditor.ResizeHandlePosition"/>,
  1006. /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white-
  1007. /// outline style already used for the Address Control's resize handle, deliberately distinct
  1008. /// from the rotate handle's round dot so the two are never confused at a glance.</summary>
  1009. private void DrawHeightHandle(Graphics g, CanvasViewTransform transform)
  1010. {
  1011. var handle = _editor.HeightHandlePosition();
  1012. if (handle is null)
  1013. {
  1014. return;
  1015. }
  1016. var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  1017. var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
  1018. using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
  1019. using var outline = new Pen(System.Drawing.Color.White, 1);
  1020. g.FillRectangle(brush, rect);
  1021. g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
  1022. }
  1023. private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
  1024. {
  1025. var handle = _editor.ResizeHandlePosition();
  1026. if (handle is null)
  1027. {
  1028. return;
  1029. }
  1030. var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  1031. var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
  1032. using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
  1033. using var outline = new Pen(System.Drawing.Color.White, 1);
  1034. g.FillRectangle(brush, rect);
  1035. g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
  1036. }
  1037. /// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
  1038. /// dot connected to the selected element's bounding-box center by a dotted line, at the
  1039. /// world-space position <see cref="CanvasElementEditor.HandlePosition"/> computes (which
  1040. /// already accounts for the element's current rotation) — the same position
  1041. /// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
  1042. /// what's draggable.</summary>
  1043. private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
  1044. {
  1045. var handle = _editor.HandlePosition();
  1046. if (handle is null)
  1047. {
  1048. return;
  1049. }
  1050. var (width, height) = MeasureElement(element);
  1051. var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height);
  1052. var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY);
  1053. var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  1054. using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
  1055. g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
  1056. const float radius = 5f;
  1057. using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
  1058. using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
  1059. g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  1060. g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  1061. }
  1062. /// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
  1063. /// <see cref="AddressControlLayout.RotationAngle"/> is non-zero, the entire method body below
  1064. /// — the box border, every line's text and per-line highlight/selection overlay, and the
  1065. /// resize handle — is drawn under one GDI+ rotation transform around
  1066. /// <see cref="AddressControlLayout.BoxCenter"/>, the same way <see cref="DrawElement"/> already
  1067. /// rotates a standalone element's whole draw call. Because every pixel-space draw call inside
  1068. /// this method (box, lines, handle) shares that one transform, they all visually rotate
  1069. /// together as one rigid unit — matching what <see cref="HitTestAddressControl"/> and
  1070. /// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point
  1071. /// back into this same unrotated local space before testing.</summary>
  1072. private void DrawAddressControl(
  1073. Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
  1074. {
  1075. GraphicsState? savedState = null;
  1076. if (control.RotationAngle != 0)
  1077. {
  1078. var (pivotX, pivotY) = control.BoxCenter;
  1079. var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
  1080. savedState = g.Save();
  1081. g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
  1082. // See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in
  1083. // this Y-down pixel space, the opposite of this project's counterclockwise-positive
  1084. // stored convention, hence the negation.
  1085. g.RotateTransform((float)-control.RotationAngle);
  1086. g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
  1087. }
  1088. try
  1089. {
  1090. DrawAddressControlUnrotated(g, transform, control, isPrimarySelected, isMultiSelected);
  1091. }
  1092. finally
  1093. {
  1094. if (savedState is not null)
  1095. {
  1096. g.Restore(savedState);
  1097. }
  1098. }
  1099. }
  1100. /// <summary>Sprint 10, "Select multiple elements at once on the canvas": <paramref
  1101. /// name="isPrimarySelected"/> and <paramref name="isMultiSelected"/> are deliberately separate
  1102. /// — both draw the same highlighted border (so every selected item is visibly marked), but
  1103. /// only the primary selection gets the resize/rotate handles and the drilled-into-line
  1104. /// highlight, so a multi-selected control never shows handles that would be ambiguous about
  1105. /// which item they act on (out of scope for this story; align/distribute operates on the whole
  1106. /// set instead).</summary>
  1107. private void DrawAddressControlUnrotated(
  1108. Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
  1109. {
  1110. var isHighlighted = isPrimarySelected || isMultiSelected;
  1111. var lineStates = ComputeAddressControlLineStates(control);
  1112. using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
  1113. using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);
  1114. var (left, top) = transform.ToPixels(control.X, control.TopBaselineY + MaxLineFontSize(control));
  1115. var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height);
  1116. var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
  1117. g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
  1118. if (isPrimarySelected)
  1119. {
  1120. DrawAddressResizeHandle(g, transform, control);
  1121. // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  1122. // drawn here, in the control's own local (unrotated) coordinates, so it automatically
  1123. // rotates together with the box/lines via DrawAddressControl's enclosing GDI+
  1124. // transform — the same reason the resize handle above already orbits correctly.
  1125. DrawAddressRotateHandle(g, transform, control);
  1126. }
  1127. for (var i = 0; i < control.Lines.Count; i++)
  1128. {
  1129. if (!lineStates[i].Visible)
  1130. {
  1131. continue;
  1132. }
  1133. var line = control.Lines[i];
  1134. using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
  1135. var text = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText);
  1136. var size = MeasureText(text, font);
  1137. using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale);
  1138. var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X;
  1139. var drawY = transform.ToPixels(control.X, lineStates[i].EffectiveY).Y - BaselineOffsetPx(drawFont);
  1140. if (line.IsDynamic && !_showData)
  1141. {
  1142. using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue));
  1143. g.FillRectangle(
  1144. fill,
  1145. (float)drawX,
  1146. (float)drawY,
  1147. (float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)),
  1148. (float)(size.Height * transform.Scale));
  1149. }
  1150. using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
  1151. g.DrawString(text, drawFont, brush, (float)drawX, (float)drawY);
  1152. if (isPrimarySelected && i == _selectedAddressLineIndex)
  1153. {
  1154. using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
  1155. g.DrawRectangle(
  1156. linePen,
  1157. (float)drawX - 2,
  1158. (float)drawY - 2,
  1159. (float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4,
  1160. (float)(size.Height * transform.Scale) + 4);
  1161. }
  1162. }
  1163. }
  1164. private static double MaxLineFontSize(AddressControlLayout control) =>
  1165. control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
  1166. private static void DrawAddressResizeHandle(
  1167. Graphics g, CanvasViewTransform transform, AddressControlLayout control)
  1168. {
  1169. var handle = AddressResizeHandleCenter(control);
  1170. var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y);
  1171. var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
  1172. using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
  1173. using var outline = new Pen(System.Drawing.Color.White, 1);
  1174. g.FillRectangle(brush, rect);
  1175. g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
  1176. }
  1177. private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
  1178. {
  1179. var top = control.TopBaselineY + MaxLineFontSize(control);
  1180. var bottom = control.TopBaselineY - control.Height;
  1181. return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
  1182. }
  1183. /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  1184. /// paints the drag handle for a selected Address Control, in the same visual style
  1185. /// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate
  1186. /// handle (<see cref="DrawRotateHandle"/>) for consistency — the recommended default per this
  1187. /// story's own notes, absent a strong reason to diverge. Position math lives in the
  1188. /// framework-free, unit-tested <see cref="AddressControlRotateHandle"/> (mirroring how
  1189. /// <see cref="CanvasElementEditor"/> holds the standalone-element equivalent); drawn here in
  1190. /// local (unrotated) coordinates, which is sufficient to make it visually orbit with the
  1191. /// control's own rotation via <see cref="DrawAddressControl"/>'s enclosing GDI+ transform —
  1192. /// the same reason the resize handle above already orbits correctly.</summary>
  1193. private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control)
  1194. {
  1195. var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control);
  1196. var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control);
  1197. var (centerPx, centerPy) = transform.ToPixels(centerX, topY);
  1198. var (handlePx, handlePy) = transform.ToPixels(handleX, handleY);
  1199. using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
  1200. g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
  1201. const float radius = 5f;
  1202. using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
  1203. using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
  1204. g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  1205. g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  1206. }
  1207. private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control)
  1208. {
  1209. var lines = new AddressLineCollapser.Line[control.Lines.Count];
  1210. for (var i = 0; i < control.Lines.Count; i++)
  1211. {
  1212. var line = control.Lines[i];
  1213. var resolvedText = ResolveAddressLinePreviewText(line);
  1214. var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
  1215. lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
  1216. }
  1217. var resolved = AddressLineCollapser.Resolve(lines).ToArray();
  1218. if (control.VerticalAnchor == AddressVerticalAnchor.Bottom)
  1219. {
  1220. var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y);
  1221. for (var i = 0; i < resolved.Length; i++)
  1222. {
  1223. resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift };
  1224. }
  1225. }
  1226. return resolved;
  1227. }
  1228. /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
  1229. /// <see cref="AddressBlockPreviewCalculator"/> and the new preview panel) instead of keeping
  1230. /// its own identical copy of this per-run resolution rule.</summary>
  1231. private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
  1232. _showData ? TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord) : null;
  1233. /// <summary>Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see
  1234. /// below): static text rotates around its measured center, while dynamic/mixed content rotates
  1235. /// around its fixed authored anchor so record-to-record text-width changes cannot move the
  1236. /// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an
  1237. /// element's literal `{ColumnName}` token text, never a per-record resolved value (see
  1238. /// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'s remarks), so the dynamic/
  1239. /// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method
  1240. /// now always takes the bounding-box-center path for every element, static or not, so a
  1241. /// dynamic/mixed element's rotate-handle drag spins in place like static text instead of
  1242. /// swinging around a corner. The real render (<c>RotatedTextAnchorCalculator</c>/
  1243. /// <c>DebenuPdfRenderer</c>) and the new preview panel (<c>TemplatePreviewControl</c>/
  1244. /// <c>TemplatePreviewBuilder</c>) are unaffected — they still call
  1245. /// <see cref="RotationPivotCalculator.Compute"/> directly with the real <c>isDynamic</c>
  1246. /// value.</summary>
  1247. private static (double X, double Y) RotationPivot(
  1248. TextElementLayout element, double effectiveY, double width, double height) =>
  1249. RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);
  1250. /// <summary>Measures an element's rendered size in canvas-space points, using a
  1251. /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
  1252. /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
  1253. /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
  1254. /// parity with the final PDF. Sprint 9/10, "Add an adjustable width and height with text
  1255. /// wrapping...": once an element has a box (<see cref="TextElementLayout.HasBox"/> — Width
  1256. /// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure
  1257. /// from text; its height is the explicit clip ceiling when <see cref="TextElementLayout.HasHeightClip"/>
  1258. /// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font
  1259. /// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing,
  1260. /// the rotate handle, the resize handle, and <see cref="DrawElement"/> itself) automatically
  1261. /// treats this as the element's real bounding box with no changes needed at those call sites.</summary>
  1262. private static (double Width, double Height) MeasureElement(TextElementLayout element)
  1263. {
  1264. if (element.HasBox)
  1265. {
  1266. using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize);
  1267. var effectiveHeight = element.HasHeightClip
  1268. ? element.Height!.Value
  1269. : MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value);
  1270. return (element.Width!.Value, effectiveHeight);
  1271. }
  1272. using var bitmap = new Bitmap(1, 1);
  1273. using var g = Graphics.FromImage(bitmap);
  1274. g.PageUnit = GraphicsUnit.Point;
  1275. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  1276. var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
  1277. var size = g.MeasureString(text, font);
  1278. return (size.Width, size.Height);
  1279. }
  1280. /// <summary>Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap
  1281. /// <paramref name="text"/> to <paramref name="width"/> points — the auto-height a boxed
  1282. /// element with no explicit <see cref="TextElementLayout.Height"/> clip ceiling uses, mirroring
  1283. /// how the CLI asks Debenu's own <c>GetWrappedTextHeight</c> for the equivalent value.</summary>
  1284. private static double MeasureWrappedHeight(string text, Font font, double width)
  1285. {
  1286. using var bitmap = new Bitmap(1, 1);
  1287. using var g = Graphics.FromImage(bitmap);
  1288. g.PageUnit = GraphicsUnit.Point;
  1289. var displayText = string.IsNullOrEmpty(text) ? " " : text;
  1290. var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width)));
  1291. return size.Height;
  1292. }
  1293. /// <summary>Sprint 10, "Add a live wrap/clip indicator for text elements": compares the
  1294. /// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide
  1295. /// whether the *current* content is actually wrapping and/or being clipped right now — pure
  1296. /// arithmetic lives in <see cref="WrapClipDetector"/>; this just does the GDI+ measurement
  1297. /// that decision needs.</summary>
  1298. private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font)
  1299. {
  1300. using var bitmap = new Bitmap(1, 1);
  1301. using var g = Graphics.FromImage(bitmap);
  1302. g.PageUnit = GraphicsUnit.Point;
  1303. var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
  1304. var naturalWidth = g.MeasureString(text, font).Width;
  1305. var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value);
  1306. // No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) —
  1307. // never flag clipping in that case, regardless of how tall the wrapped content grows.
  1308. var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity;
  1309. return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight);
  1310. }
  1311. private static (double Width, double Height) MeasureText(string text, Font font)
  1312. {
  1313. using var bitmap = new Bitmap(1, 1);
  1314. using var g = Graphics.FromImage(bitmap);
  1315. g.PageUnit = GraphicsUnit.Point;
  1316. var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
  1317. return (size.Width, size.Height);
  1318. }
  1319. /// <summary>Sprint 5, AC4: measures each run's own display segment width in canvas-space
  1320. /// points (same <see cref="GraphicsUnit.Point"/> measuring context as <see cref="MeasureElement"/>,
  1321. /// for consistency), so <see cref="DrawElement"/> can position a per-run highlight fill at the
  1322. /// right cumulative offset. Like <see cref="MeasureElement"/>, this is a design-time visual
  1323. /// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal
  1324. /// its measurement of the whole concatenated string to the last fraction of a point (kerning/
  1325. /// spacing metrics are not strictly additive), which is an accepted, documented limitation of
  1326. /// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws
  1327. /// one already-concatenated string, never per-run pieces).</summary>
  1328. private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font)
  1329. {
  1330. using var bitmap = new Bitmap(1, 1);
  1331. using var g = Graphics.FromImage(bitmap);
  1332. g.PageUnit = GraphicsUnit.Point;
  1333. var segments = TextRunTextConverter.ToDisplaySegments(element.Runs);
  1334. var result = new List<(TextRun, double)>(segments.Count);
  1335. foreach (var (run, text) in segments)
  1336. {
  1337. var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width;
  1338. result.Add((run, width));
  1339. }
  1340. return result;
  1341. }
  1342. /// <summary>Falls back to a generic sans-serif font if the requested family isn't installed,
  1343. /// so a missing font only affects the design-time preview's appearance — it does not crash
  1344. /// the designer. This is independent of, and does not relax, the render-time rule that a
  1345. /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps").</summary>
  1346. /// <summary>The font used to actually paint text: sized in pixels (point size times the
  1347. /// canvas zoom) so glyphs scale with the page exactly like positions and boxes do. A
  1348. /// points-unit font would be converted at the screen DPI regardless of zoom, so text would
  1349. /// not track the PDF's size. Measurement helpers keep using <see cref="ResolveFont"/> (points).</summary>
  1350. private static Font ResolveDrawFont(string familyName, float sizePoints, double scale)
  1351. {
  1352. var px = (float)(Math.Max(sizePoints <= 0 ? 12f : sizePoints, 0.1f) * scale);
  1353. try
  1354. {
  1355. return new Font(familyName, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
  1356. }
  1357. catch (ArgumentException)
  1358. {
  1359. return new Font(FontFamily.GenericSansSerif, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
  1360. }
  1361. }
  1362. /// <summary>Distance in pixels from the top of a GDI+ text box to the baseline for a
  1363. /// pixel-unit font. The PDF places text by baseline (the element's Y), so the canvas draws the
  1364. /// box this far above the baseline.</summary>
  1365. private static float BaselineOffsetPx(Font pixelFont)
  1366. {
  1367. var family = pixelFont.FontFamily;
  1368. var style = pixelFont.Style;
  1369. return pixelFont.Size * family.GetCellAscent(style) / family.GetEmHeight(style);
  1370. }
  1371. private static Font ResolveFont(string familyName, float size)
  1372. {
  1373. try
  1374. {
  1375. return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
  1376. }
  1377. catch (ArgumentException)
  1378. {
  1379. return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
  1380. }
  1381. }
  1382. protected override void OnMouseDown(MouseEventArgs e)
  1383. {
  1384. base.OnMouseDown(e);
  1385. // Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all —
  1386. // see the constructor's ControlStyles.Selectable remarks.
  1387. Focus();
  1388. if (e.Button != MouseButtons.Left || _showData)
  1389. {
  1390. return;
  1391. }
  1392. // Post-Sprint-10 user-reported regression fix: SelectionChanged used to fire unconditionally
  1393. // on every left-click, including a click on an item that was ALREADY selected (the normal
  1394. // way to begin dragging it). TemplateDesignerForm's SelectionChanged handler runs the full,
  1395. // non-trivial RefreshPropertiesPanel() (combo-box rebind repopulation etc.) with no
  1396. // IsInteracting-style gating the way the drag/rotate/resize ElementsChanged path has, so
  1397. // every single click paid that cost — a real, measured ~40-60ms hitch per click once this
  1398. // session's added property-panel rows (box width/height, delete button) made the refresh
  1399. // heavy enough to notice. Capturing the pre-click selection identity and only raising the
  1400. // event when it actually changed makes "click an already-selected element to move it" free
  1401. // again while still notifying on every real selection change.
  1402. var previousSelectedElement = _editor.Selected;
  1403. var previousSelectedAddressControl = _selectedAddressControl;
  1404. var previousSelectedAddressLineIndex = _selectedAddressLineIndex;
  1405. // Sprint 10, "Select multiple elements at once on the canvas": Ctrl or Shift held during
  1406. // the click means "toggle this one item's membership in the multi-selection" (or, for an
  1407. // empty-space click below, "add to the multi-selection via rubber-band" rather than
  1408. // replacing it) — checked once up front since every hit-test branch below needs it.
  1409. var isModifierClick = (ModifierKeys & (Keys.Control | Keys.Shift)) != 0;
  1410. var transform = CurrentTransform();
  1411. var (x, y) = transform.ToPoints(e.X, e.Y);
  1412. if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform))
  1413. {
  1414. _isResizingAddressControl = true;
  1415. Capture = true;
  1416. Invalidate();
  1417. return;
  1418. }
  1419. // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked
  1420. // right alongside the resize-handle check above (same precedence rule: only meaningful,
  1421. // and only checked, when this control is already selected) so a rotate-handle grab is
  1422. // never confused with a normal select/move click elsewhere on the control.
  1423. if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y))
  1424. {
  1425. _isRotatingAddressControl = true;
  1426. Capture = true;
  1427. Invalidate();
  1428. return;
  1429. }
  1430. var addressHit = HitTestAddressControl(x, y);
  1431. if (addressHit.Control is not null)
  1432. {
  1433. if (isModifierClick)
  1434. {
  1435. ToggleAddressControlMultiSelect(addressHit.Control);
  1436. Invalidate();
  1437. RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
  1438. return;
  1439. }
  1440. if (IsMultiSelectionActive && _multiSelectedAddressControls.Contains(addressHit.Control))
  1441. {
  1442. // Clicking a member of an existing multi-selection (no modifier) starts a group
  1443. // drag of the whole set instead of collapsing back to a single selection — the
  1444. // same "click an already-selected item to move it" gesture single-select has
  1445. // always supported, extended to the group.
  1446. BeginGroupDrag(x, y);
  1447. Capture = true;
  1448. Invalidate();
  1449. return;
  1450. }
  1451. ClearMultiSelection();
  1452. SelectAddressControl(addressHit.Control, addressHit.LineIndex);
  1453. _addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
  1454. Capture = true;
  1455. Invalidate();
  1456. RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
  1457. return;
  1458. }
  1459. ClearAddressSelection();
  1460. // Post-Sprint-8 user-requested feature: a click on the selected element's font-size
  1461. // resize handle starts a resize-drag, checked with the same precedence as the rotate
  1462. // handle immediately below (only meaningful, and only checked, once something is already
  1463. // selected) so it is never confused with a normal select/move click.
  1464. if (_editor.HitTestHeightHandle(x, y))
  1465. {
  1466. _editor.BeginHeightResizeDrag();
  1467. Capture = true;
  1468. Invalidate();
  1469. return;
  1470. }
  1471. if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y))
  1472. {
  1473. _editor.BeginResizeDrag();
  1474. Capture = true;
  1475. Invalidate();
  1476. return;
  1477. }
  1478. // Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
  1479. // instead of a normal select/move — checked first (and only when something is already
  1480. // selected) so it never intercepts a normal click elsewhere on the canvas.
  1481. if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
  1482. {
  1483. _editor.BeginRotateDrag();
  1484. Capture = true;
  1485. Invalidate();
  1486. return;
  1487. }
  1488. var hitElement = _editor.HitTest(x, y);
  1489. if (hitElement is not null)
  1490. {
  1491. if (isModifierClick)
  1492. {
  1493. ToggleElementMultiSelect(hitElement);
  1494. Invalidate();
  1495. RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
  1496. return;
  1497. }
  1498. if (IsMultiSelectionActive && _editor.MultiSelected.Contains(hitElement))
  1499. {
  1500. BeginGroupDrag(x, y);
  1501. Capture = true;
  1502. Invalidate();
  1503. return;
  1504. }
  1505. ClearMultiSelection();
  1506. _editor.Select(hitElement);
  1507. _editor.BeginDrag(x, y);
  1508. Capture = true;
  1509. Invalidate();
  1510. RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
  1511. return;
  1512. }
  1513. // Nothing hit: start a rubber-band selection rather than just clearing the selection — a
  1514. // zero-size drag (a plain click with no movement) naturally selects nothing on release,
  1515. // reproducing the old "click empty space to deselect" behavior without special-casing it.
  1516. if (!isModifierClick)
  1517. {
  1518. ClearMultiSelection();
  1519. }
  1520. _editor.Select(null);
  1521. _isRubberBandSelecting = true;
  1522. _rubberBandAdditive = isModifierClick;
  1523. _rubberBandStart = (x, y);
  1524. _rubberBandCurrent = (x, y);
  1525. Capture = true;
  1526. Invalidate();
  1527. RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
  1528. }
  1529. /// <summary>Raises <see cref="SelectionChanged"/> only when the selection identity captured
  1530. /// before a click actually differs from the current one — see <see cref="OnMouseDown"/>'s
  1531. /// remarks for why an unconditional raise on every click was a real, measured hitch.</summary>
  1532. private void RaiseSelectionChangedIfDifferent(
  1533. TextElementLayout? previousElement, AddressControlLayout? previousAddressControl, int previousAddressLineIndex)
  1534. {
  1535. if (!ReferenceEquals(previousElement, _editor.Selected)
  1536. || !ReferenceEquals(previousAddressControl, _selectedAddressControl)
  1537. || previousAddressLineIndex != _selectedAddressLineIndex)
  1538. {
  1539. SelectionChanged?.Invoke(this, EventArgs.Empty);
  1540. }
  1541. }
  1542. /// <summary>Sprint 8: a rotated control must remain correctly click-selectable at its actual
  1543. /// rotated position, not its unrotated bounding box — ported from
  1544. /// <c>CanvasElementEditor.IsPointInRotatedBounds</c>'s approach: rotate the click point
  1545. /// backward (by <c>-RotationAngle</c>) around the same <see cref="AddressControlLayout.BoxCenter"/>
  1546. /// pivot <see cref="DrawAddressControl"/> rotates around, landing it back in the control's
  1547. /// unrotated local space, then run the exact same plain-rectangle/line test as before. An
  1548. /// unrotated control (the overwhelmingly common case) takes the cheap direct path.</summary>
  1549. private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints)
  1550. {
  1551. foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder))
  1552. {
  1553. var (testX, testY) = control.RotationAngle == 0
  1554. ? (xPoints, yPoints)
  1555. : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
  1556. var top = control.TopBaselineY + MaxLineFontSize(control);
  1557. var bottom = control.TopBaselineY - control.Height;
  1558. if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
  1559. {
  1560. continue;
  1561. }
  1562. var lineIndex = 0;
  1563. for (var i = 0; i < control.Lines.Count; i++)
  1564. {
  1565. var baseline = control.BaselineYForLine(i);
  1566. var lineTop = baseline + control.Lines[i].FontSize;
  1567. var nextBaseline = i == control.Lines.Count - 1
  1568. ? bottom
  1569. : control.BaselineYForLine(i + 1);
  1570. if (testY <= lineTop && testY >= nextBaseline)
  1571. {
  1572. lineIndex = i;
  1573. break;
  1574. }
  1575. }
  1576. return (control, lineIndex);
  1577. }
  1578. return (null, 0);
  1579. }
  1580. /// <summary>Sprint 8: same rotate-the-click-point-backward approach as
  1581. /// <see cref="HitTestAddressControl"/> — the resize handle is drawn (via
  1582. /// <see cref="DrawAddressResizeHandle"/>) inside <see cref="DrawAddressControl"/>'s rotation
  1583. /// transform, so it visually orbits with the rotated box; the hit test rotates the click point
  1584. /// back into the same unrotated local space before comparing against the handle's unrotated
  1585. /// position.</summary>
  1586. private static bool HitTestAddressResizeHandle(
  1587. AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform)
  1588. {
  1589. const double handleTolerancePixels = 8;
  1590. var tolerancePoints = handleTolerancePixels / transform.Scale;
  1591. var (testX, testY) = control.RotationAngle == 0
  1592. ? (xPoints, yPoints)
  1593. : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
  1594. var handle = AddressResizeHandleCenter(control);
  1595. return Math.Abs(testX - handle.X) <= tolerancePoints
  1596. && Math.Abs(testY - handle.Y) <= tolerancePoints;
  1597. }
  1598. protected override void OnMouseMove(MouseEventArgs e)
  1599. {
  1600. base.OnMouseMove(e);
  1601. // Checked before the IsInteracting gate below: a rubber-band drag doesn't change any
  1602. // element's data (so it never needs ElementsChanged/the property-panel refresh that gate
  1603. // exists to guard), it only needs the marquee rectangle to repaint on every tick.
  1604. if (_isRubberBandSelecting)
  1605. {
  1606. var (rbX, rbY) = CurrentTransform().ToPoints(e.X, e.Y);
  1607. _rubberBandCurrent = (rbX, rbY);
  1608. Invalidate();
  1609. return;
  1610. }
  1611. if (!IsInteracting)
  1612. {
  1613. return;
  1614. }
  1615. var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
  1616. if (_editor.IsMultiDragging)
  1617. {
  1618. _editor.MultiDragTo(x, y);
  1619. ApplyMultiDragToAddressControls(x, y);
  1620. Invalidate();
  1621. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1622. return;
  1623. }
  1624. if (_selectedAddressControl is not null && _isRotatingAddressControl)
  1625. {
  1626. AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
  1627. Invalidate();
  1628. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1629. return;
  1630. }
  1631. if (_selectedAddressControl is not null && _isResizingAddressControl)
  1632. {
  1633. // Sprint 8: rotate the drag point backward into the control's unrotated local space
  1634. // first (same as the hit test above) so resizing a rotated control still tracks the
  1635. // mouse along the box's own local width axis, not the world X axis.
  1636. var (localX, _) = _selectedAddressControl.RotationAngle == 0
  1637. ? (x, y)
  1638. : PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle);
  1639. var width = Math.Max(1, localX - _selectedAddressControl.X);
  1640. _selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width;
  1641. Invalidate();
  1642. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1643. return;
  1644. }
  1645. if (_selectedAddressControl is not null && _addressControlDragOffset is not null)
  1646. {
  1647. var newX = x - _addressControlDragOffset.Value.Dx;
  1648. var newY = y - _addressControlDragOffset.Value.Dy;
  1649. if (SnapToGridEnabled)
  1650. {
  1651. newX = GridSnapper.Snap(newX, GridSizePoints);
  1652. newY = GridSnapper.Snap(newY, GridSizePoints);
  1653. }
  1654. _selectedAddressControl.X = newX;
  1655. _selectedAddressControl.Y = newY;
  1656. Invalidate();
  1657. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1658. return;
  1659. }
  1660. if (_editor.IsResizing)
  1661. {
  1662. _editor.ResizeDragTo(x, y);
  1663. }
  1664. else if (_editor.IsRotating)
  1665. {
  1666. _editor.RotateDragTo(x, y);
  1667. }
  1668. else
  1669. {
  1670. _editor.DragTo(x, y);
  1671. }
  1672. Invalidate();
  1673. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1674. }
  1675. protected override void OnMouseUp(MouseEventArgs e)
  1676. {
  1677. base.OnMouseUp(e);
  1678. if (_isRubberBandSelecting)
  1679. {
  1680. Capture = false;
  1681. FinalizeRubberBandSelection();
  1682. return;
  1683. }
  1684. var wasInteracting = IsInteracting;
  1685. _editor.EndDrag();
  1686. EndGroupDrag();
  1687. _addressControlDragOffset = null;
  1688. _isResizingAddressControl = false;
  1689. _isRotatingAddressControl = false;
  1690. Capture = false;
  1691. // The gesture's own per-tick ElementsChanged notifications (OnMouseMove) only ask
  1692. // TemplateDesignerForm for a cheap position/angle-only sync (see IsInteracting's
  1693. // remarks) — fire one last notification now that the gesture has actually ended so the
  1694. // form's full properties-panel refresh (rebind combo, address line list, etc.) still
  1695. // runs exactly once at the end, the same as it always did before this smoothness fix.
  1696. if (wasInteracting)
  1697. {
  1698. ElementsChanged?.Invoke(this, EventArgs.Empty);
  1699. }
  1700. }
  1701. /// <summary>Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or
  1702. /// Address Control is currently selected — there was no way to delete a placed item at all
  1703. /// before this. Only fires while not mid-gesture, so an accidental key press during a drag
  1704. /// can't delete out from under an in-progress move/resize/rotate.</summary>
  1705. protected override void OnKeyDown(KeyEventArgs e)
  1706. {
  1707. base.OnKeyDown(e);
  1708. if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting && !_showData)
  1709. {
  1710. RemoveSelectedElement();
  1711. e.Handled = true;
  1712. }
  1713. else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt)
  1714. {
  1715. // Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden
  1716. // behind other items. Ctrl+Tab still leaves the canvas as usual.
  1717. SelectNextItem(forward: !e.Shift);
  1718. e.Handled = true;
  1719. e.SuppressKeyPress = true;
  1720. }
  1721. }
  1722. /// <summary>Selects the next (or previous) item in z-order, so an item hidden behind another
  1723. /// one can be reached without clicking it. Wraps at both ends. Returns <c>false</c> when there
  1724. /// is nothing to select or the canvas is showing data (selection is disabled then).</summary>
  1725. public bool SelectNextItem(bool forward)
  1726. {
  1727. if (_showData || IsInteracting)
  1728. {
  1729. return false;
  1730. }
  1731. var items = _document.Elements
  1732. .Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y))
  1733. .Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y)))
  1734. .ToList();
  1735. Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id;
  1736. if (SelectionCycler.Next(items, current, forward) is not { } nextId)
  1737. {
  1738. return false;
  1739. }
  1740. ClearMultiSelection();
  1741. ClearAddressSelection();
  1742. var element = _document.Elements.FirstOrDefault(e => e.Id == nextId);
  1743. if (element is not null)
  1744. {
  1745. _editor.Select(element);
  1746. }
  1747. else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control)
  1748. {
  1749. SelectAddressControl(control, 0);
  1750. }
  1751. Invalidate();
  1752. SelectionChanged?.Invoke(this, EventArgs.Empty);
  1753. return true;
  1754. }
  1755. protected override bool IsInputKey(Keys keyData) =>
  1756. keyData is Keys.Delete or Keys.Back
  1757. || ((keyData & ~Keys.Shift) == Keys.Tab)
  1758. || base.IsInputKey(keyData);
  1759. }

Powered by TurnKey Linux.