Преглед на файлове

Close out Sprints 5-8: composite address controls, live preview, record navigation, layout tooling

Adds multi-line address control rendering (CLI + designer), CSV record
navigation, live template preview, grid snapping, and rotation handles,
with corresponding sprint artifacts and backlog updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington преди 5 дни
родител
ревизия
aa7c4a8324
променени са 69 файла, в които са добавени 6833 реда и са изтрити 361 реда
  1. +106
    -7
      backlog/backlog.md
  2. +37
    -0
      backlog/epics/02_template_designer_gui_foundation.md
  3. +163
    -23
      backlog/epics/04_live_preview_and_record_navigation.md
  4. +3
    -1
      backlog/epics/05_cli_rendering_engine_and_debenu_integration.md
  5. +218
    -21
      backlog/epics/06_layout_efficiency_and_operator_tooling.md
  6. +88
    -4
      backlog/epics/08_composite_address_controls.md
  7. +66
    -0
      backlog/sprints/sprint-5-retrospective.md
  8. +37
    -0
      backlog/sprints/sprint-5.md
  9. +74
    -0
      backlog/sprints/sprint-6-retrospective.md
  10. +47
    -0
      backlog/sprints/sprint-6.md
  11. +71
    -0
      backlog/sprints/sprint-7-retrospective.md
  12. +50
    -0
      backlog/sprints/sprint-7.md
  13. +40
    -0
      backlog/sprints/sprint-8.md
  14. +50
    -0
      code/CLI_CONTRACT.md
  15. +116
    -21
      code/TEMPLATE_FORMAT.md
  16. +5
    -0
      code/sample-data/sample-envelope-template2.xml
  17. +20
    -0
      code/sample-data/sample-envelope-template3.xml
  18. +14
    -0
      code/sample-data/sample-envelope-template4.xml
  19. +54
    -0
      code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererRotationTests.cs
  20. +376
    -10
      code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
  21. +251
    -1
      code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
  22. +28
    -13
      code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
  23. +137
    -8
      code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
  24. +64
    -0
      code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs
  25. +13
    -0
      code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs
  26. +11
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs
  27. +41
    -9
      code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
  28. +14
    -0
      code/src/EnvelopeRenderer.Cli/Render/TemplateTextRun.cs
  29. +209
    -13
      code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs
  30. +14
    -6
      code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs
  31. +95
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Csv/CsvRecordNavigator.cs
  32. +22
    -31
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressBlockPreviewCalculator.cs
  33. +120
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs
  34. +35
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLineLayout.cs
  35. +84
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs
  36. +85
    -47
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  37. +11
    -2
      code/src/EnvelopeRenderer.Desktop.Core/Design/ElementPreviewState.cs
  38. +18
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/GridSnapper.cs
  39. +29
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/PointRotation.cs
  40. +44
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/PreviewTextDraw.cs
  41. +50
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/RotationPivotCalculator.cs
  42. +11
    -1
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs
  43. +352
    -37
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs
  44. +138
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs
  45. +50
    -14
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
  46. +30
    -7
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementPropertiesEditor.cs
  47. +87
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextResolver.cs
  48. +19
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextRun.cs
  49. +103
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextRunTextConverter.cs
  50. +61
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressBlockPreviewCalculatorTests.cs
  51. +104
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressControlLayoutTests.cs
  52. +158
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressControlRotateHandleTests.cs
  53. +124
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
  54. +107
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CsvRecordNavigatorTests.cs
  55. +49
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/GridSnapperTests.cs
  56. +57
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/PointRotationTests.cs
  57. +68
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/RotationPivotCalculatorTests.cs
  58. +285
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
  59. +308
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TemplatePreviewBuilderTests.cs
  60. +50
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextElementLayoutTests.cs
  61. +62
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
  62. +140
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextResolverTests.cs
  63. +146
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextRunTextConverterTests.cs
  64. +599
    -29
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  65. +529
    -48
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  66. +151
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs
  67. +1
    -0
      logs/process_improvement_log.md
  68. +4
    -0
      logs/technical_debt_log.md
  69. +30
    -7
      state.md

+ 106
- 7
backlog/backlog.md Целия файл

@@ -5,13 +5,13 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| Order | Epic | File | Status |
|---|---|---|---|
| 1 | End-to-End Text Rendering Slice | `epics/01_end_to_end_text_rendering_slice.md` | In Progress (2 of 3 stories Done — Sprint 1) |
| 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (6 of 6 stories — Sprint 4) |
| 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (7 of 7 stories — Sprint 2, 4, 6) |
| 3 | CSV Integration and Field Mapping | `epics/03_csv_integration_and_field_mapping.md` | Done (4 of 4 stories — Sprint 4) |
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | In Progress (5 of 7 stories Done — Sprint 1-3) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | Ready |
| 6 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | Not Started |
| 7 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started |
| 8 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Not Started (requirements gathered 2026-09-29, not yet sized) |
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of 3 stories Done — Sprint 7; 1 story, "Warn on text overflow before render," remains Not Ready, blocked on an open product question for the human product owner) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | In Progress (2 of 2 original stories Done — Sprint 5-6; reopened 2026-10-19 with 2 new Ready stories for whole-control rotation — 13 pts total, human-product-owner-requested, recommended first pull for Sprint 8; see Sprint 8 refinement outcome below) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |

## Notes
- Prioritization favors the requirements document's recommended vertical slicing: deliver a text-only end-to-end workflow first, then layer images, shapes, and operator-efficiency tooling.
@@ -57,8 +57,107 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
### New epic added during backlog refinement (2026-09-29)
- User requested, ahead of closing Sprint 4, the ability to compose an "address control" — a group of lines (mixing static text and CSV fields) placeable as one unit on both the canvas and the rendered PDF. `product-owner` gathered requirements directly with the user across two rounds of clarifying questions (composition model, single-anchor movement, collapse-default behavior, and — the highest-impact fork — whether mixed static+field content is scoped to the control or general) before writing anything down.
- Recorded as a new epic, `epics/08_composite_address_controls.md`, with two dependent stories: "Mix static text and CSV fields within a single text element" (a general upgrade to every text element in the designer, not just control lines — confirmed explicitly with the user) and "Group lines into a single, movable Address Control" (a fully custom, operator-built line list with single-anchor movement and automatic spacing, default-on-but-toggleable per-line blank-collapse). Full requirements, including several explicit "Development Team design decision" flags for details not resolved with the user, are in that epic file.
- **Not yet sized or ordered into the backlog.** This is deliberately left as a requirements-capture step, per the user's own framing ("before we close the sprint... get requirements"); dev-team sizing and backlog placement should happen in a subsequent backlog refinement pass, not folded into Sprint 4's close-out.
- **Sized 2026-09-29** by `dev-team` with real code inspection: both stories landed at **13 points each** — the largest single stories this team has sized to date (previous ceiling was 8). Both individually pass the Definition of Ready (each plausibly fits a single sprint against the 18-20 point velocity range), so both are marked Ready.
- **Placement recommendation (product-owner):** do not commit both to the same sprint. They are hard-dependent (Story 2 cannot start meaningfully until Story 1's run-sequence content model exists) and together total 26 points — over a full sprint's capacity on their own, before any other backlog item is considered. Plan "Mix static text and CSV fields within a single text element" for the next sprint that picks up this epic, and "Group lines into a single, movable Address Control" for the sprint after, mirroring how the Sprint 3 CSV-mapping chain and Sprint 4 rotation chain were sequenced across (or within) sprints by dependency. `dev-team`'s sizing note also flags one deliberate design call worth carrying into implementation: the Address Control owns a single X/Y anchor with lines auto-spaced relative to it (not N independently-positioned children), which as a byproduct means `AddressLineCollapser`'s existing same-X grouping rule already produces correct per-control collapse behavior with no changes needed — `qa-tech-debt` should log a low-priority future-hardening note about the pre-existing "unrelated elements coincidentally sharing X" ambiguity this leaves unchanged, not required by either story's acceptance criteria as written.
- Story 1 also carries a noted (not required) fallback split if a future sprint's capacity gets tight: a core vertical slice (data model, backward-compatible persistence, render resolution, a minimal bracket-syntax editor, single-color highlighting) versus a polish layer (protected token-chip editing, per-run canvas highlighting) — see the story's own sizing note for detail.

### Defect found in already-shipped rotation feature (2026-10-09)
- User reported rendered records with rotated text appearing to render at inconsistent positions. `product-owner` confirmed the root cause by code inspection before writing anything down: rotating a *dynamic or mixed-content* element pivots around a bounding-box center computed from that record's own resolved text width (`DebenuPdfRenderer.AddPage`/`RotatedTextAnchorCalculator`), so the pivot — and therefore the rendered position — shifts whenever resolved text length differs between records. Rotated static (fixed-text) elements are unaffected, which is why Sprint 4's own live verification didn't catch this. A second, compounding finding: this also breaks the rotation story's own already-shipped AC4 (canvas and rendered PDF must share the same pivot), since the canvas measures a fixed design-time placeholder rather than any record's actual resolved text.
- Logged as a new **Open, High-impact** entry in `logs/technical_debt_log.md` (2026-10-09) and written up as a full story, "Keep rotated dynamic and mixed-content fields positioned consistently across records" (`backlog/epics/02_template_designer_gui_foundation.md`), with candidate fix directions recorded but not decided (anchor-point pivot vs. a canonical/representative width). `dev-team` sized it at **5 points** on 2026-10-12 after inspecting the actual render/canvas geometry.
- **Priority decision for Sprint 6 (product-owner, user confirmed by "continue"):** treat this as the first pull for the next sprint because it is a defect in already-shipped, real print-output positioning, not a new feature. The most common way to trigger it (any rotated field bound to a CSV column, or any mixed-content line with a field run) is a realistic combination now that both rotation (Sprint 4) and mixed content (Sprint 5) exist. Put it ahead of "Group lines into a single, movable Address Control," while still pulling the Address Control story second if capacity allows.

### Sprint 6 planning outcome (2026-10-12)
- `scrum-master` facilitated with `product-owner` and `dev-team` input after the user confirmed the post-retro pause point with "continue." Committed 18 points: "Keep rotated dynamic and mixed-content fields positioned consistently across records" (5 pts, epic 2, High-impact shipped defect) first, then "Group lines into a single, movable Address Control" (13 pts, epic 8, unblocked by Sprint 5's mixed-content story). Full plan: `backlog/sprints/sprint-6.md`.
- Capacity signal: recent completed totals are 20, 19, 18, 18, and 15 points. The Sprint 5 15-point total was an intentional under-commit around the team's largest story rather than a missed-capacity signal, so Sprint 6 commits 18 points at the proven low end of the prior 18-20 range, not the ceiling.
- Sprint 5 retrospective action carried in explicitly: harden GUI verification automation reliability before/during the Address Control work, because GUI automation friction has now appeared in two consecutive sprints and the Address Control story will need list-management and two-level-selection verification.
- Not committed: any 1,000,000-record file-size mitigation and the two open path/UNC impediment decisions. They remain non-blocking for Sprint 6's committed scope.

### Sprint 6 execution update (2026-10-12)
- Batch 1, "Keep rotated dynamic and mixed-content fields positioned consistently across records" (5 pts), passed the `dev-team` Definition-of-Done check and was later accepted in Sprint Review. The High-impact rotation-positioning technical debt item from 2026-10-09 is resolved. Full evidence is recorded in `backlog/sprints/sprint-6.md` and `backlog/epics/02_template_designer_gui_foundation.md`.
- Batch 2, "Group lines into a single, movable Address Control" (13 pts), passed the `dev-team` Definition-of-Done check and was later accepted in Sprint Review. The first composite Address Control is implemented end-to-end: designer model and UI, whole-control move/resize, per-line mixed content and default-on collapse, XML save/reopen, CLI parse/render expansion, and docs. Full evidence is recorded in `backlog/sprints/sprint-6.md` and `backlog/epics/08_composite_address_controls.md`. Sprint 6's committed 18/18 points are now Done.

### Sprint 6 Review outcome (2026-10-12)
- Product-owner verified both committed Sprint 6 stories against acceptance criteria (verdict: sprint goal met in full). Full verification notes are recorded story-by-story in `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/epics/08_composite_address_controls.md`; detailed execution evidence is in `backlog/sprints/sprint-6.md`.
- The rotated dynamic/mixed-content positioning defect is accepted as fixed: dynamic and mixed rotated text now use a stable authored-anchor pivot, static rotated text keeps the already-verified center-pivot behavior, and the High-impact technical debt item from 2026-10-09 remains resolved.
- The Composite Address Controls and Mixed-Content Text epic is now Done outright. The first Address Control is usable end-to-end: create, manage lines, edit per-line mixed content, move/resize as a group, save/reopen XML, and render directly through the CLI.
- No new product backlog item is required from the review. Two scope notes remain intentionally documented rather than treated as defects: Address Control `width` is not yet render-time wrapping/clipping, and whole-control rotation remains an out-of-scope future candidate.
- Scrum/process observation to carry into retrospective: the Sprint 5 GUI-verification reliability action paid off. The full-form smoke caught a real dock-order bug hiding the properties panel before review, and the bug was fixed within the sprint instead of escaping.

### Sprint 6 Retrospective outcome (2026-10-12)
- `scrum-master` ran the retrospective (`backlog/sprints/sprint-6-retrospective.md`). Confirmed full follow-through on all three Sprint 5 action items — the fifth clean full-follow-through sprint in a row — with the GUI-verification action paying off directly by catching the hidden properties-panel dock-order bug before review.
- Sprint 7 planning carry-ins: include an actual built-form smoke for any GUI story touching form layout/properties/toolbar actions; keep product-risk-first ordering during planning; and size future composite-element stories from cross-layer code inspection, not visible UI alone.
- No kit-level edit proposed. The full-form-smoke insight is team verification practice and fits the existing Definition of Done rather than exposing a gap in the Scrum kit.

### Sprint 5 Review outcome (2026-10-09)
- Both committed Sprint 5 stories (15/15 points) are Done; each is verified against its acceptance criteria with real, non-simulated evidence, recorded story-by-story in `backlog/epics/08_composite_address_controls.md` ("Mix static text and CSV fields") and `backlog/epics/05_cli_rendering_engine_and_debenu_integration.md` ("Harden production configuration delivery"). Full daily detail: `backlog/sprints/sprint-5.md`.
- **Sprint goal met in full.** The mixed-content capability is the largest single story this team has delivered (13 points), and its highest-risk acceptance criterion (existing templates must render identically) was proven, not assumed — dev-team built the actual pre-Sprint-5 CLI binary from a git worktree and byte-compared real output, controlling for Debenu's own internal nondeterminism to isolate a genuine zero-regression result. The CLI Rendering Engine and Debenu Integration epic is now Done outright (6 of 6 stories — note: `backlog.md`'s table previously said "5 of 7," a stale count corrected this review to the epic's actual 6 total stories).
- **Product-owner confirmed dev-team's production-configuration decision** (keep the existing env-var-then-`key.txt` mechanism unchanged) rather than treating it as pre-approved — the reasoning holds up independently given this product's single-workstation, non-technical-operator deployment model and the absence of any packaging story yet to justify a stronger mechanism. Flagging this decision for the human user's awareness specifically, since it's a licensing/deployment judgment call, not a pure engineering one, and remains revisitable if the distribution model changes.
- No new technical debt logged this sprint. One disclosed, accepted trade-off (not a defect): the bracket-syntax `{Column Name}` editing convention will mis-parse a literal `{...}` in static text not meant as a field token — documented in `TEMPLATE_FORMAT.md` as a known limitation of the faster-to-deliver approach, not a hidden gap.
- "Group lines into a single, movable Address Control" (13 pts, epic 8) is now unblocked — its dependency ("Mix static text and CSV fields") is Done — and is the clean next pull for Sprint 6, per dev-team's own sizing-note recommendation against combining both epic-08 stories in one sprint.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior, both in `logs/impediment_log.md`).

### Sprint 5 planning outcome (2026-10-05)
- `scrum-master` facilitated. Velocity range holds at 18-20 points, now off four data points (20, 19, 18, 18) — the last two sprints both landed exactly on their commitment. Committed 15 points, deliberately below the low end of the range: "Mix static text and CSV fields within a single text element" (13 pts, epic 8 — this team's largest single story to date) plus "Harden production configuration delivery for CLI runtime settings" (2 pts, epic 5). Extra buffer beyond the usual ~10% grooming reserve is intentional given the 13-point story's real structural risk (a breaking model change, no reusable UI pattern to copy). Full plan: `backlog/sprints/sprint-5.md`.
- Not committed (capacity + dependency discipline): "Group lines into a single, movable Address Control" (13 pts, epic 8) — hard-depends on this sprint's mixed-content story landing first, and `dev-team`'s own sizing note recommended against committing both epic-08 stories in the same sprint. Clean pull for Sprint 6.

### Sprint 4 planning outcome (2026-09-28)
- `scrum-master` facilitated. Velocity range tightened to 18-20 points (three data points: 20, 19, 18 completed in Sprints 1-3, the last with zero scope change). Committed 18 points at the low end of the range rather than the 20-point ceiling, per the standing "reserve ~10% for grooming" guidance: "Collapse blank optional address lines consistently" (5 pts, epic 3 — completes that epic outright), plus the full rotation chain from epic 2 — "Set a rotation angle for text and dynamic field elements" (8 pts) then "Rotate elements by dragging a handle on the canvas" (5 pts, depends on the former). Full plan: `backlog/sprints/sprint-4.md`.
- Not committed (capacity discipline, explicit stretch item): "Harden production configuration delivery for CLI runtime settings" (2 pts) — still not urgent per product-owner's standing note on that story; pull only if the committed three finish early.

### Sprint 7 planning outcome (2026-10-19)
- `scrum-master` facilitated Sprint 7 planning. Capacity signal now has six data points (Sprints 1-6: 20, 19, 18, 18, 15, 18), a stable 18-20 point range; Sprint 6 landed exactly on an 18-point commitment with zero scope change. Committed **18 points**: the full epic 4 Ready pair — "Render an accurate, record-specific preview of the current template" (8 pts) then "Jump to a specific record number" (5 pts, depends on the former) — plus one epic 6 stretch item, "Snap elements to grid and guides" (5 pts, no dependencies). Full plan: `backlog/sprints/sprint-7.md`.
- Planning reasoning: the epic 4 pair alone (13 pts) was clearly under the proven range, so a second pull was weighed rather than left uncommitted. "Snap elements to grid and guides" was chosen over the other two Ready epic 6 candidates specifically because it is self-contained (no CLI/XML surface, no dependency on any other story) and needs only a canvas-only smoke per its own story notes — the lowest-risk way to reach 18 points. "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" (10 pts combined) were deliberately kept as a pair rather than split across sprints (splitting them would repeat the anti-pattern epic 8 avoided in Sprints 5-6, since the prerequisite alone delivers little standalone operator value); together with the epic 4 pair they would total 23 points, over range. "Undo and redo layout changes" (13 pts) was left uncommitted per its own sizing note, which recommends against pairing this backlog's highest-uncertainty estimate with another large/novel story in the same sprint (the epic 4 preview story is itself non-trivial) and recommends a short document-cloning feasibility check before it is committed to any sprint.
- Product-risk-first ordering (Sprint 6 retrospective carry-in) was applied to sequencing: the epic 4 pair is pulled first because it closes a known-shape defect class (divergent per-record text-resolution paths) that already caused the 2026-10-09 rotation-positioning defect; "Snap elements to grid and guides" is pulled last as the lowest-risk item to flag first if the sprint runs short on time.
- Verified rather than assumed (Sprint 6 retrospective carry-in): both committed epic 4 and epic 6 stories' 2026-10-16 sizing notes already cite specific real code paths inspected (`TemplateCanvasControl`, `AddressBlockPreviewCalculator`, `RenderEngine`, `TemplateDesignerForm` for the preview story; `CanvasElementEditor.DragTo`/`BeginDrag`/`EndDrag` and `TemplateCanvasControl`'s paint routine for snap-to-grid), so no re-sizing was needed at planning time.
- Sprint 6 retrospective carry-in on built-form smokes is written into each committed story's tasks: both epic 4 stories require a full built-form smoke (they touch `TemplateDesignerForm` layout); "Snap elements to grid and guides" requires only a canvas-only smoke, per that story's own scope note (no form-layout/properties-panel/toolbar surface touched).
- Not committed: the epic 6 multi-select/align pair (10 pts) and undo/redo (13 pts) — both clean candidates for Sprint 8 planning, per the reasoning above. "Warn on text overflow before render" (epic 4) remains Not Ready, still blocked on the open product question for the human product owner about what "overflow" means. No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior) — still non-blocking for this sprint's committed items.

### Sprint 7 backlog refinement outcome (2026-10-16)
- Per `state.md`'s carried-in instruction, `product-owner` re-verified epic 4's "Ready" label (index previously said Ready) rather than trusting it, since it had not been touched since the 2026-09-04 onboarding refinement — before rotation (Sprint 4), mixed content (Sprint 5), and the Address Control (Sprint 6) all shipped.
- **Real code inspection finding, epic 4:** `TemplateCanvasControl` draws every standalone text element from `element.DisplayText` (the raw bracket-token string), never a per-record resolved value — "Preview the current layout with a selected CSV record" does not exist yet except for the one already-shipped Address Control preview path. The one "sample record" in use today is hardcoded to the CSV's first loaded row via the bounded, 20-row `CsvPreviewLoader`; there is no operator record selection anywhere in the app. This means the original 5-point estimate for that story was stale and materially understated. Rewrote the story with explicit ACs (reuse one shared resolution routine across CLI render/canvas/preview to avoid a *third* divergent implementation — the exact class of drift that caused the 2026-10-09 rotation defect — plus rotation-pivot and Address Control parity, plus a built-form smoke per the Sprint 6 retrospective action) and re-sized it at **8 points** (`epics/04_live_preview_and_record_navigation.md`).
- **Also found, epic 4:** the CLI's `CsvRecordSource` is already a proven forward-only streaming reader at 100k+-record scale, so "Jump to a specific record number" can reuse it via skip/take without new indexing infrastructure — de-risking that story, but it still needs to move off the bounded 20-row loader, so it was re-sized from 3 to **5 points**. "Warn on text overflow before render" could not be re-sized or marked Ready at all: no template element has any bounding-width concept today (`TextElementLayout` has no `Width` property; `AddressControlLayout.Width` is an unenforced resize handle only), so "overflow" is currently undefined behavior. This is logged as an **open question for the human product owner** in the epic file rather than decided unilaterally — see epic 4's Story 3 conversation notes for the two concrete options.
- **Real code inspection finding, epic 6:** its three stories were still onboarding-era placeholders (no Conversation notes, unconfirmed T-shirt sizes). Rewrote all three as full Card/Conversation/Confirmation stories. Found no multi-select exists anywhere in the app (`CanvasElementEditor`/`TemplateCanvasControl` each hold at most one selected item), so "Align and distribute multiple elements" was split into a new prerequisite, "Select multiple elements at once on the canvas" (5 pts), plus the alignment/distribution story itself (5 pts, depends on the prerequisite). Found no undo/redo infrastructure of any kind exists; sized "Undo and redo layout changes" at **13 points** and flagged it explicitly as the highest-uncertainty estimate in the backlog (no prior story of this shape to size against), with a design option noted for `dev-team` to evaluate (coarse-grained whole-document snapshotting, reusing the existing `TemplateLayoutXmlSerializer`-proven document model, instead of a fine-grained command pattern per mutation type) rather than mandated. "Snap elements to grid and guides" stayed contained (5 pts) — no multi-select or undo dependency. All four stories pass the Definition of Ready.
- **Epic 7:** confirmed both open impediments in `logs/impediment_log.md` (template asset path strategy; UNC timeout/retry behavior) are still Open, unresolved since 2026-09-04. Per the "delay firm decisions to the last responsible moment" principle applied at every prior review, epic 7 remains non-blocking for Sprint 7 — epic 6 (now 4 fully-Ready stories) still sits ahead of it in priority order, and epic 4's core preview story is the clear highest-priority pull regardless. Did not resolve the impediments this session; recommend revisiting only once epic 6 is substantially spent and epic 7 nears the top.
- **No reordering of the epic index was warranted.** Epic 4 stays 5th and epic 6 stays 6th — the code-inspection findings changed *sizing and scope*, not relative priority. Product-risk-first reasoning (Sprint 6 retrospective carry-in) actually reinforces keeping epic 4 first: its core preview story exists specifically to prevent the same canvas/render-drift defect class already seen once in production (the 2026-10-09 rotation-positioning bug).
- **Recommended Sprint 7 candidate:** "Render an accurate, record-specific preview of the current template" (8 pts) then "Jump to a specific record number" (5 pts) — epic 4's full Ready slice, 13 points, sequenced together since the second hard-depends on the first. This is both next in backlog order and the highest real product risk currently in the Ready set (a known-shape defect class, not a hypothetical). As an optional stretch item to round out capacity toward the team's proven 18-20 point range, "Snap elements to grid and guides" (epic 6, 5 pts) has no dependency on anything else and is a clean, low-risk pull if the core 13 points finish early — this is a recommendation for `scrum-master`/`dev-team` at planning, not a commitment.
- **Open question for the human product owner:** epic 4's "Warn on text overflow before render" needs a decision on what "overflow" means before it can be written with testable acceptance criteria or sized (page-edge overflow only, vs. adding a real enforced width/wrap boundary to elements first). Not blocking for Sprint 7 either way, since neither recommended candidate depends on it.

### Sprint 7 Review outcome (2026-10-19)
- All 3 committed Sprint 7 stories (18/18 points) are Done; each is verified against its acceptance criteria with real evidence, recorded story-by-story in `backlog/epics/04_live_preview_and_record_navigation.md` (the epic 4 pair) and `backlog/epics/06_layout_efficiency_and_operator_tooling.md` (grid/guide snapping). Full daily detail: `backlog/sprints/sprint-7.md`.
- **Sprint goal met in full.** Verification was independent, not a rubber-stamp of dev-team's own report: read `TextResolver.cs`, `RotationPivotCalculator.cs`, `TemplatePreviewBuilder.cs`/`PreviewTextDraw.cs`, `TemplatePreviewControl.cs`, and `TemplateDesignerForm.cs`'s wiring directly, and traced every auto-refresh path (canvas `ElementsChanged` → `_previewControl.Invalidate()` on drag/rotate/content-commit/rebind/X-Y edits, plus template open) to confirm the "refreshes after a layout edit, a field remapping, or a different record" acceptance criterion is actually wired, not just claimed. Confirmed `TemplatePreviewBuilderTests` genuinely mirrors `RenderEngineTests`' scenario shapes (collapse, mixed content, both rotation-pivot rules, Address Control expansion, z-order, unknown-column failure) rather than testing something narrower. Confirmed `CsvRecordNavigator` is a true full-file forward-only reader (no bounded 20-row shortcut) via both its source and `CsvRecordNavigatorTests`' record-25-of-30 case. Confirmed `GridSnapper`/`CanvasElementEditor.DragTo`/`TemplateCanvasControl` snap continuously mid-gesture (not only on release) via `CanvasElementEditorTests`' explicit mid-drag assertion, and confirmed by grep that `TemplateLayoutXmlSerializer` has no grid/snap-related change — the "no template format change" acceptance criterion holds.
- **Test-count plausibility check performed in place of re-running the suite:** this review session has no shell/build tool access, so `dotnet test` was not re-run directly. Instead, independently counted `[Fact]`/`[Theory]`/`[InlineData]` attributes across every test file in both `EnvelopeRenderer.Cli.Tests` and `EnvelopeRenderer.Desktop.Tests`: 91 `[Fact]` + 10 `[InlineData]` rows = **101 CLI**, and 219 `[Fact]` + 80 `[InlineData]` rows = **299 desktop**, summing to exactly **400** — matching dev-team's reported 400/400 exactly, not approximately. This is strong circumstantial confirmation the reported count is real, though it does not substitute for an actual test run; recommend whoever next has shell access (`dev-team` or `scrum-master`) do a real `dotnet test` pass as routine hygiene before the next sprint starts.
- One caveat on evidence depth, disclosed rather than smoothed over: dev-team's live built-`.exe` verification (reflection harness driving the real assemblies, screenshots) was not independently re-run this review — this session had no way to build or launch the desktop app. The claims were cross-checked against the underlying code instead (e.g., the fixed-anchor rotation pivot claim is directly supported by `RotationPivotCalculator.Compute`'s implementation and its own unit tests proving two different measured widths yield the same anchor), which is solid but is code-level verification, not a re-observed screenshot. Flagging this as a review-process limitation for the human product owner's awareness, not a defect in the sprint's delivery.
- **Technical debt reviewed, confirmed reasonable to leave open:** the 2026-10-19 Address Control line-overlap-at-small-font-sizes entry in `logs/technical_debt_log.md` is confirmed Low-impact and non-blocking on independent review — it is cosmetic (GDI+ `MeasureString` approximation), confined to the canvas/preview design-time surfaces, pre-existing since Sprint 6 (not a Sprint 7 regression, just newly re-observed because a second surface now shares the same measurement approach), and does not touch `DebenuPdfRenderer`/`RenderEngine`, the actual PDF render path. No pushback on dev-team's logging of it; agree it should stay open rather than block this sprint.
- **Explicit scope note:** "Warn on text overflow before render" (epic 4) was not part of this sprint and remains Not Ready — still an open product question for the human product owner (what "overflow" means with no element having an enforced bounding width today). Not touched or marked Done here.
- Epic 4 (Live Preview and Record Navigation) moves from "Partially Ready" to **In Progress** (2 of 3 stories Done); epic 6 (Layout Efficiency and Operator Tooling) moves from "Ready" to **In Progress** (1 of 4 stories Done — "Snap elements to grid and guides"). "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" (10 pts combined, dependent pair) and "Undo and redo layout changes" (13 pts, highest-uncertainty estimate in the backlog) remain the natural next pulls for Sprint 8, per the Sprint 7 planning outcome's own reasoning.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior, both in `logs/impediment_log.md`) — still non-blocking for likely Sprint 8 candidates (neither epic 6 story nor epic 7 touches image assets or network paths).

### New stories added during backlog refinement (2026-10-19) — Address Control whole-unit rotation
- The human product owner confirmed the "whole-control rotation remains out of scope" note from Sprint 6 Review (above) is now wanted. `product-owner` reopened `epics/08_composite_address_controls.md` from Done and wrote two dependent stories: "Rotate the whole Address Control as a single unit" (8 pts) and "Rotate the whole Address Control by dragging a handle on the canvas" (5 pts, depends on the former) — deliberately mirroring the exact two-story split this team already used for the original standalone-element rotation feature (`epics/02`, 2026-09-22).
- Confirmed by code inspection before writing either story, not assumed from the parent request: unlike the standalone dynamic-text rotation-positioning defect fixed in Sprint 6 (whose bounding-box pivot was measured from each record's own resolved text width), the Address Control's box geometry (`AddressControlLayout`/`TemplateAddressControl`'s `Width`/`Height`) is entirely author-set — `Width` is an explicit resize-handle value and `Height` derives only from each line's own authored font size/line-spacing, never from resolved text (`TemplateCanvasControl.DrawAddressControl` draws the box from `control.X/Y/Width/Height` alone). So rotating the whole control around its own box center is record-stable everywhere (canvas, Sprint 7 preview, real CLI render) without the anchor-pivot workaround the standalone defect fix needed — recorded as a deliberate scope note in the first story so future readers don't assume the two rotation features share that complexity. A second, real correctness requirement was also found and written into the story: the pivot must be computed from the control's authored geometry, not from any record's `AddressLineCollapser`-shifted line positions, so rotation stays stable across records that differ only in whether a blank line collapses.
- `dev-team` sized both stories at 8 and 5 points respectively (13 total, matching the original standalone-element rotation pair's total) after inspecting `RenderEngine.BuildAddressControlDraws`, `TemplatePreviewBuilder`'s address-control counterpart, `TemplateCanvasControl.DrawAddressControl`/`HitTestAddressControl`, `CanvasElementEditor`'s existing per-element rotation machinery (`HandlePosition`/`HitTestHandle`/`RotateDragTo`/`RotatePointAroundPivot`/`IsPointInRotatedBounds`), and `TemplateDesignerForm`'s already-present-but-disabled angle input for an Address Control selection. A genuine, code-confirmed scope reduction versus the original single-element rotation story: `DebenuPdfRenderer` needs no changes at all here, since the existing fixed-pivot rotation path added by the Sprint 6 defect fix already covers a rotated line once it's handed an already-correct anchor point — the new work is upstream rigid-group-rotation math (rotating each line's anchor around the control's box center before the existing draw call), needed independently on the CLI and desktop sides per this project's established split. Both stories pass the Definition of Ready and are marked Ready.
- Not yet placed into a sprint — left for Sprint 8 planning (or an immediate same-session pull), per the human product owner's own call.

### Sprint 8 backlog refinement outcome (2026-10-19)
*(Note on numbering: epic references below use each epic's fixed file-based number, e.g. "epic 8" always means `epics/08_composite_address_controls.md` and "epic 6" always means `epics/06_layout_efficiency_and_operator_tooling.md`, matching every prior dated note in this file — this is independent of the reorderable "Order" column position in the table above.)*
- `product-owner` ran Sprint 8 backlog refinement after the human product owner confirmed the post-Sprint-7-retrospective pause point with "continue." Verified every claim in the incoming summary against the real files rather than trusting it: `backlog/backlog.md`'s epic table, `epics/04_live_preview_and_record_navigation.md`, `epics/06_layout_efficiency_and_operator_tooling.md`, `epics/08_composite_address_controls.md`, and `logs/impediment_log.md` all matched — no stale statuses found this time (contrast the 2026-10-16 refinement, which did find one).
- **Applied the three Sprint 7 retrospective carry-ins directly to the backlog rather than only noting them:**
1. Re-checked "Align and distribute multiple elements" (epic 6) against the built-form-vs-canvas-only smoke rule per the carry-in's own concern — found its Confirmation section had no built-form-smoke AC at all, unlike its prerequisite story. Added an explicit AC and Conversation note requiring a full built-form smoke if this story adds any new toolbar/menu affordance to trigger alignment/distribution (a real, plausible gap, not a hypothetical one — no such trigger exists today).
2. Added a paired Conversation note to the same story: if executed immediately after its prerequisite in the same sprint, capture a **fresh** full-form screenshot rather than reusing the prerequisite's, directly restating the exact reuse mistake named in the Sprint 7 retrospective so it isn't repeated.
3. The third carry-in (disclose lack of shell/build access and arrange an independent cross-check) is a review-session process practice, not something to encode into a story — left as-is, to be applied at the next Sprint Review that needs it.
- **Feasibility check performed on "Undo and redo layout changes" (13 points, epic 6), per this refinement's own instruction to do the check now rather than defer it again:** read `TemplateLayoutDocument.cs`, `TemplateLayoutXmlSerializer.cs`, `AddressControlLayout.cs`, and `TextElementLayout.cs` directly. Confirmed the document model is a simple, flat, cleanly-cloneable object graph (two plain lists, no circular references, no UI-bound fields) and that a full serialize/deserialize round-trip already exists and is proven (`TemplateLayoutXmlSerializer.Save`/`TryLoad`, used for save/reopen) — a snapshot-based undo/redo design can reuse this exact path in-memory rather than inventing new clone logic. This is a real, code-inspection-backed de-risking of the 13-point estimate's core assumption, not a rubber-stamp; full reasoning recorded in `epics/06_layout_efficiency_and_operator_tooling.md`. Residual uncertainty is now isolated to the UI/mutation-wiring side (no prior story of this shape to size against), not the data-model side — this story can be treated as having a reasonably firm 13-point estimate for planning purposes, though still this backlog's largest single uncertainty and still not recommended for pairing with another large/novel story in the same sprint.
- **No re-litigation of already-correctly-sized Ready items**, per this refinement's own instruction: the two Address Control whole-unit-rotation stories (epic 8, 8+5=13 pts) and the epic 6 multi-select/align pair (5+5=10 pts) were re-read in full and found sound on inspection — no rework needed.
- **Epic index reordered.** Composite Address Controls and Mixed-Content Text (epic 8, formerly Order position 8) moves to **Order position 6**, ahead of Layout Efficiency and Operator Tooling (epic 6, now Order position 7) and Dynamic and Network Image Handling (epic 7, now Order position 8, unchanged in relative terms — still last, still Not Started). Reasoning: epic 8's two new Ready stories are a direct, explicit, recent human-product-owner request ("the user explicitly chose to queue this for Sprint 8 planning rather than implement it ad hoc," per `state.md`) reversing a scope decision the same human product owner made only one sprint ago (Sprint 6 Review's "whole-control rotation remains out of scope" note) — a stronger, more specific priority signal than epic 6's pair, which is next in line by ordinary dependency/capacity sequencing but was not itself singled out as newly urgent by the human product owner. This is a recommendation, presented with reasoning, not a unilateral final call — the human product owner can leave the pair in either relative order. No change to epic 7 (images)'s own relative position: both impediments in `logs/impediment_log.md` remain Open since 2026-09-04, and epic 7 still sits behind both epics now ahead of it, so it remains non-blocking.
- **Definition of Ready exit criteria checked:** three fully Ready items/pairs are available — epic 8's rotation pair (13 pts), epic 6's multi-select/align pair (10 pts), and epic 6's undo/redo story (13 pts) — 36 points total, enough for 1-2 sprints per `process/01_backlog_refinement.md`'s exit criteria. No Ready item is missing acceptance criteria or a size estimate (undo/redo's estimate is now feasibility-confirmed rather than merely asserted).
- **Recommended Sprint 8 candidate:** epic 8's whole-control-rotation pair ("Rotate the whole Address Control as a single unit," 8 pts, then "...by dragging a handle on the canvas," 5 pts) as the definite first pull — 13 points, directly requested by the human product owner, and confirmed low technical risk (needs zero `DebenuPdfRenderer` changes, per its own sizing note). This alone sits below this team's proven 18-20 point range (six data points: 20, 19, 18, 18, 15, 18), so a genuine capacity trade-off exists for `scrum-master`/`dev-team` to weigh at planning: pairing it with epic 6's multi-select/align pair (10 pts) would total 23 points, a new high above the proven range and not a number this team has hit before, versus taking the rotation pair alone (13 pts, a new low, undercommitting relative to proven capacity) and leaving the multi-select/align pair as a clean Sprint 9 pull. **Not** recommending any combination that splits either hard-dependent pair mid-sprint, consistent with this team's established practice (Sprint 4's rotation chain, Sprint 7's preview pair). The undo/redo story (13 pts) is not recommended for Sprint 8 regardless of which option above is chosen — pairing it with the rotation pair (13+13=26) or the multi-select/align pair (13+10=23) both exceed anything this team has attempted, and its own sizing note already recommends against pairing it with another large/novel story.
- **Open question restated for the human product owner, not re-decided here:** "Warn on text overflow before render" (epic 4, Live Preview and Record Navigation) has now been parked unresolved since 2026-10-16 across two backlog refinements. It remains non-blocking for every Sprint 8 candidate above, so it is not holding anything up — but it is the oldest open item in this backlog and worth a direct decision from the human product owner whenever convenient (does "overflow" mean page-edge overflow only, or does it require a new enforced per-element/control width boundary first).
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior, both in `logs/impediment_log.md`) — confirmed still Open, unresolved since 2026-09-04, and still non-blocking for every Sprint 8 candidate named above.

### Sprint 8 planning outcome (2026-10-26)
- `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has seven data points (Sprints 1-7: 20, 19, 18, 18, 15, 18, 18), a stable 18-20 point range.
- **Committed 13 points**: the full epic 8 whole-Address-Control-rotation pair — "Rotate the whole Address Control as a single unit" (8 pts) then "...by dragging a handle on the canvas" (5 pts, dependent) — as a deliberate, reasoned under-commit, not a capacity miss. Full plan: `backlog/sprints/sprint-8.md`.
- **Capacity reasoning, weighed explicitly rather than defaulted to the safe choice:** the only other Ready work (epic 6's multi-select + align/distribute pair, 10 pts) must be pulled whole or not at all per product-owner's recommendation and this team's established practice (never split a hard-dependent pair across sprints — Sprint 4's rotation chain, Sprint 7's preview pair). That leaves only two real options: 13 points alone (a new low) or 23 points (13+10, a new high this team has never attempted; its best sprint to date, 20, was itself flagged at the time as a coincidence to watch, not a target). Decided against 23: although the two pairs are not formally dependent on each other, both land real changes in the same small set of GUI files (`CanvasElementEditor`, `TemplateCanvasControl`, `TemplateDesignerForm`) — this sprint's drag-handle story adds a new Address-Control-specific handle/hit-test/rotate-drag path in `CanvasElementEditor`, while multi-select would rework `CanvasElementEditor.Selected` from a single reference into a genuinely new multi-item selection model (no such concept exists anywhere today) — the same foundational, first-of-its-kind risk profile this team has consistently avoided pairing with another large/novel story in one sprint (per "Undo and redo layout changes"' own sizing note). No external date/contractual pressure was cited anywhere in this backlog to justify reaching for an unproven new high just to avoid idle capacity.
- **Checked for a smaller item to round out capacity short of 23 — found none that cleanly fits.** "Complete the first text-only operator workflow" (`epics/01_end_to_end_text_rendering_slice.md`, 5 pts, labeled Ready) is a placeholder from the original 2026-09-04 onboarding refinement, never re-verified since — the same staleness pattern that made epic 4's "Ready" label wrong at the 2026-10-16 refinement (it predates rotation, mixed content, the Address Control, and the live preview, which plausibly already satisfy its ACs). Pulling it blind would repeat that exact mistake rather than learn from it, so it was left uncommitted and is flagged here for product-owner re-verification (close outright, or confirm genuinely still open) at the next backlog refinement, rather than left to sit indefinitely. "Undo and redo layout changes" (13 pts) is both too large to be a rounding-out item and its own sizing note already argues against pairing it with another large/novel story. Nothing else in the backlog is currently Ready.
- **Epic reorder confirmed as `scrum-master`'s own decision, not just inherited:** epic 8 stays ahead of epic 6 in this file's Order table. It is a direct, recent, explicit human-product-owner request reversing a scope call the same human product owner made only one sprint ago (Sprint 6 Review), and it is exactly what this sprint commits to — keeping the stated backlog order and the actual sprint commitment consistent.
- **Recommended for Sprint 9:** the multi-select/align pair (10 pts, epic 6) as its own clean, undisturbed sprint slice, deliberately not sharing a sprint with another novel canvas-selection rework. "Undo and redo layout changes" (13 pts) remains available whenever it can be paired appropriately — a future planning session should weigh that pairing explicitly rather than default to bundling it in with the multi-select/align pair.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior, both in `logs/impediment_log.md`) — still non-blocking for this sprint's committed items.

+ 37
- 0
backlog/epics/02_template_designer_gui_foundation.md Целия файл

@@ -131,3 +131,40 @@ As a **print operator**, I want to save and reload templates, so that I can reus

**Estimate:** 3 points
**Dependencies:** None

### Keep rotated dynamic and mixed-content fields positioned consistently across records - Status: Done
**Sprint Review verification (Sprint 6, 2026-10-12):** Product-owner verdict: accepted. All 4 acceptance criteria are met, and the implementation fixes the user-reported shipped-output defect without broadening scope. AC1 (stable position across records): accepted on the strength of the new fixed-anchor rule for any rotated text containing a field run, render-engine propagation via `UsesFixedRotationPivot`, and the real-DLL regression proving different text widths use the same authored transform anchor. AC2 (canvas/render parity): accepted because the canvas now uses the same static-center vs. dynamic-anchor pivot split for draw transforms, hit-testing, rotate handles, and drag-to-angle math; this directly repairs the Sprint 4 parity criterion that the defect broke. AC3 (static rotated text unaffected): accepted because static rotated text remains on the existing `RotatedTextAnchorCalculator` center-pivot path and has explicit regression coverage. AC4 (test using different resolved lengths): accepted via the real-DLL two-width transform test plus render-engine coverage for dynamic and mixed-content cases. The chosen product behavior is also acceptable: dynamic/mixed rotated content no longer promises geometric center-pivoting, but the stable authored anchor is the right economic/product trade-off for batch print alignment and is documented in `TEMPLATE_FORMAT.md`. No new backlog item is required from this review.

**Development verification (Sprint 6 Batch 1, 2026-10-12):** All 4 acceptance criteria were met in the dev-team DoD check and accepted in Sprint Review. The selected fix is the story's candidate (a): rotated elements containing at least one field run now pivot around their fixed authored `(x, y)` anchor, not around a per-record resolved-text bounding-box center. That deliberately trades exact center-pivoting for dynamic/mixed content in favor of stable print alignment across all records; rotated static text keeps the Sprint 4 center-pivot behavior unchanged. AC1 is covered by `RenderEngine` passing `UsesFixedRotationPivot: element.IsDynamic` into `TextDraw`, and by `DebenuPdfRenderer` using that fixed anchor directly for rotated dynamic/mixed draws instead of measuring `draw.Text` width per page. AC2 is covered by the matching desktop geometry update in `CanvasElementEditor` and `TemplateCanvasControl`: draw transforms, hit-testing, rotate-handle placement, and drag-to-angle math now use the same static-center vs. dynamic-anchor pivot split as the CLI. AC3 is covered by tests and the unchanged static path through `RotatedTextAnchorCalculator.ComputeAnchor`. AC4 is covered by a new real-DLL regression, `DebenuPdfRendererRotationTests.AddPage_FixedRotationPivot_UsesTheAuthoredAnchorForDifferentTextWidths`, which renders two pages with different text widths and asserts the rotated transform uses the same authored anchor for both. Additional tests cover rotated dynamic/mixed fixed-pivot propagation in `RenderEngineTests` and dynamic canvas geometry in `CanvasElementEditorTests`. Full suite: 336/336 passing (`dotnet test code/EnvelopeRenderer.slnx`: 94 CLI, 242 desktop). Live checks: built the desktop app successfully (`dotnet build code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj`, 0 warnings/errors); rendered `code/sample-data/sample-envelope-template2.xml` (already contains a rotated `Full Name` field) against the real 392-record sample CSV through the built CLI, exit 0, `PROGRESS complete ... completed=392`, output 2,375,011 bytes; rendered the built WinForms `TemplateCanvasControl` for that same template to a bitmap and visually confirmed the rotated `{Full Name}` dynamic element draws on the canvas.

**Card**
As a **print operator**, I want a rotated dynamic or mixed-content text field to render at the same, predictable position on every record's page, so that rotated address/label content prints correctly aligned across an entire batch instead of drifting from record to record.

**Conversation notes**
- User-reported (2026-10-09): rendered records with rotated text appear to "jump around" position-to-position instead of lining up consistently. Root cause confirmed by code inspection before writing this story, not assumed:
- `DebenuPdfRenderer.AddPage` measures each record's own resolved text (`_pdf.GetTextWidth(draw.Text)`) to compute the rotation pivot (`RotatedTextAnchorCalculator.ComputeAnchor`'s `centerLocalX = width / 2.0`), so the bounding-box center — and therefore the anchor point actually passed to `DrawRotatedText` — shifts whenever a record's resolved text length differs from another's. A rotated element bound to a CSV column, or (since Sprint 5) containing any field run within mixed content, is affected; a rotated purely-static (fixed-text) element is not, since its width never varies by record — which is why Sprint 4's own live verification (a single static "ROTATED" label) never caught this.
- Compounding finding, also confirmed by inspection: this breaks the "Set a rotation angle..." story's own already-shipped AC4 ("rendered PDF reflects the same rotation, around the same pivot, as shown in the designer canvas") for any dynamic/mixed content. `TemplateCanvasControl.MeasureElement` measures `element.DisplayText` — a fixed, design-time placeholder — so the canvas always shows one static position for such an element, which cannot match the actual per-record rendered position in the PDF except by coincidence. Fixing the render-side drift without also restoring canvas/render parity would leave this AC still broken.
- Candidate fix directions to evaluate, not yet decided here (the same "confirm one hypothesis, implement one mitigation" shape this project has used for prior root-cause stories):
(a) Pivot dynamic/mixed elements around their fixed anchor point `(x, y)` instead of a resolved-text-dependent bounding-box center — trades exact geometric centering for guaranteed per-record consistency; the canvas would need the same rule applied to any element containing at least one field run.
(b) Compute the bounding-box center from one canonical width (e.g., a representative value fixed at design or save time, such as the designer's currently-loaded sample/preview row) rather than each record's own resolved text — preserves true center-pivoting, but needs new persisted state and a defined rule for what "representative" means and what happens if no CSV/sample is loaded yet.
(c) Any other canonicalization the team identifies during investigation — not required to be (a) or (b) if a better option is found.
- Whichever direction is chosen must restore genuine canvas/render parity, not just record-to-record consistency within the PDF alone.
- Out of scope: any change to the rotation feature's angle range, properties-panel numeric field, drag handle, or persisted `angle` attribute format — all unaffected and already correct; this story is scoped strictly to the pivot-point calculation for elements whose resolved text varies by record.

**Confirmation (Acceptance Criteria)**
- [ ] A rotated element bound to a CSV column, or containing at least one field run (mixed content), renders at the same position on every record's page, regardless of that record's resolved text length.
- [ ] The canvas preview's displayed position for a rotated dynamic or mixed-content element matches the actual rendered PDF position for a real record, not just a design-time approximation.
- [ ] Rotated static (fixed-text) elements are unaffected — no regression to already-verified Sprint 4 behavior.
- [ ] A regression test proves the fix using at least two real records with different resolved-text lengths for the same rotated dynamic element, asserting their rendered positions match.

**Post-Sprint-7-review note (2026-10-19, `dev-team`):** The second AC above ("The canvas preview's displayed position for a rotated dynamic or mixed-content element matches the actual rendered PDF position for a real record, not just a design-time approximation") is now satisfied by the Sprint 7 record-accurate preview panel (`TemplatePreviewControl`/`TemplatePreviewBuilder`), not by the plain editing canvas (`TemplateCanvasControl`/`CanvasElementEditor`). Re-examined during Sprint 7 post-review feedback: the plain editing canvas only ever draws an element's literal authored `{ColumnName}` bracket-token text (`TextElementLayout.DisplayText`/`TextRunTextConverter.ToEditableText`) — never a per-record resolved value — so it was never actually a record-accuracy surface in the first place; that AC's real intent (canvas-vs-render pivot parity for record accuracy) did not apply to it, only to a genuinely record-resolving preview surface, which did not exist until Sprint 7 added one. Per the human product owner's explicit review and approval, the plain editing canvas's interactive rotate-handle pivot is now decoupled from this AC's fixed-anchor rule: it always rotates every element (static and dynamic/mixed alike) around its own bounding-box center (`RotationPivotCalculator.ComputeForCanvasEditing`), for a smoother, consistent editing feel, since doing so cannot reintroduce the original Oct 9 record-drift defect (there is no per-record text on that surface to drift). The real render (`RotatedTextAnchorCalculator`/`DebenuPdfRenderer`) and the new preview panel remain exactly as originally fixed here — still calling `RotationPivotCalculator.Compute` with the real `isDynamic` value — so the original defect fix is fully preserved where it actually matters (the printed PDF and the record-accurate preview). No AC checkbox above is being marked complete or incomplete differently by this note; it documents where responsibility for satisfying the second AC now actually lives.

**Estimate:** 5 points
**Sizing note (`dev-team`, 2026-10-12):** Sized after direct inspection of the actual Sprint 4/Sprint 5 code paths that create the defect, not from the symptom alone. The defect is real and narrow: `DebenuPdfRenderer.AddPage` computes a rotated draw's corrected anchor from `GetTextWidth(draw.Text)`, and `draw.Text` is already resolved per record by `RenderEngine.BuildDraws`, so dynamic and mixed-content elements can get a different width, center, corrected anchor, and final position on every page. The canvas has the matching parity gap in the opposite direction: `TemplateCanvasControl.MeasureElement`/`CanvasElementEditor` compute the center from `element.DisplayText`, a fixed design-time placeholder such as `{Full Name}` or `Attn: {Full Name}`, so it cannot represent the real row text that the CLI is rotating.

What is reusable: the story does not need new UI controls, XML shape, angle persistence, drag-handle plumbing, field-run parsing, or the core text-resolution model. All of those were already delivered by the Sprint 4 rotation and Sprint 5 mixed-content stories. The existing `RotatedTextAnchorCalculator` tests and real-DLL `DebenuPdfRendererRotationTests` provide the right verification style to extend rather than inventing a new harness from nothing, and `AddressBlockPreviewCalculator` already has a loaded-sample-record path for resolving preview text the same way the CLI does.

What is genuinely new: the team must choose and implement one canonical pivot rule for variable-width content, then apply it consistently in both render and canvas geometry. The lower-risk expected implementation is the anchor-point pivot for any element containing at least one field run: fixed `(x, y)` is already persisted, already record-independent, and needs no new template schema. Under that choice, static rotated elements continue using the existing bounding-box-center correction unchanged, while dynamic/mixed elements bypass the record-width-dependent center correction and rotate around the fixed anchor in both `DebenuPdfRenderer` and `TemplateCanvasControl`/`CanvasElementEditor` geometry. The alternative canonical-width approach remains possible but would likely need persisted or otherwise well-defined representative-width state and should be treated as a scope increase if selected during implementation.

Compared with prior stories, this is smaller than "Set a rotation angle..." (8 points), which added the angle property, persistence, canvas drawing/hit-testing, render parser support, vendor sign verification, and new center-pivot math from scratch. It is closest to "Rotate elements by dragging a handle..." (5 points): a contained geometry correction across canvas/render plus focused regression coverage. It is still larger than a trivial bug fix because acceptance requires restoring real canvas/render parity and proving the PDF position is stable across at least two records with different resolved widths. Five points fits the team's scale and a single sprint; no split recommended.
**Dependencies:** Fixes a defect in "Set a rotation angle for text and dynamic field elements" and "Rotate elements by dragging a handle on the canvas" (both Done, Sprint 4); interacts with "Mix static text and CSV fields within a single text element" (Done, Sprint 5), which is the most common way to trigger this since mixed content nearly always varies in length by record.

+ 163
- 23
backlog/epics/04_live_preview_and_record_navigation.md Целия файл

@@ -2,58 +2,198 @@

**Vision / why this matters:** Operators need immediate visual feedback to catch layout, data, and overflow issues before wasting printer time.

## Refinement note (2026-10-16, Sprint 7 refinement)

This epic's stories were last written during Sprint 0/1 onboarding (2026-09-04), before rotation
(Sprint 4), mixed static/field content (Sprint 5), address-line collapse (Sprint 4), and the
composite Address Control (Sprint 6) existed. Before trusting this epic's "Ready" label,
`product-owner` re-verified it against the current codebase and found the label was stale:

- **Real code inspection finding:** `TemplateCanvasControl.DrawElement` draws every standalone
text element using `element.DisplayText` — the raw editable bracket-token string (e.g.
`{Full Name}`) — never a per-record *resolved* value. `AddressBlockPreviewCalculator` only
computes *visibility*/*collapse Y*/*unmapped-run* state for the canvas, not resolved text.
Only Address Control lines resolve real sample data today
(`TemplateCanvasControl.ResolveAddressLinePreviewText`, falling back to `DisplayText`). In other
words: **"Preview the current layout with a selected CSV record" does not exist yet for
standalone text elements** — today's canvas is a design surface showing tokens, not a data
preview, except for the one already-shipped Address Control code path. This is a materially
bigger gap than the original 5-point estimate assumed.
- **Second finding:** the one "sample record" the canvas does use (`TemplateDesignerForm`,
`BuildSampleRecord(result.Headers, result.SampleRows[0])`) is hardcoded to the CSV's first
loaded sample row and drawn from the bounded, 20-row `CsvPreviewLoader` — there is no operator
record selection anywhere in the app today, and no code path reads past row 20 of a CSV in the
desktop app at all.
- **Third finding (de-risks Story 2):** the CLI's `CsvRecordSource.ReadRecords()` is already a
forward-only streaming reader proven at 100k+-record scale (Sprint 3's throughput work) — a
"jump to record N" can reuse it directly via skip/take without inventing a new indexed-access
layer, and without loading the whole file into memory. This keeps "Jump to a specific record
number" close to its original scope, just needs to use the full-file reader instead of the
bounded preview loader.
- **Fourth finding (blocks Story 3):** no template element has any concept of a bounding width
today. `TextElementLayout` has no `Width` property at all (grep-confirmed); only
`AddressControlLayout.Width` exists, and per the Sprint 6 review it is a resize handle only, not
an enforced render-time wrap/clip boundary. **"Text overflow" is currently undefined behavior**
— there is nothing for a resolved string to overflow *against* except the page's own edge. This
is a product decision, not an engineering one; see the open question below.
- **Fifth finding, matching a known risk pattern:** this would be a *third* independent
implementation of "resolve one element/line's text for a record" (existing: `RenderEngine.
ResolveText` for CLI render, `AddressBlockPreviewCalculator.ResolveSampleText` for canvas
collapse/mapping state) if a new preview surface reimplements it again from scratch. The exact
bug class this epic exists to prevent — canvas/render drift — was the root cause of the
2026-10-09 rotation-positioning defect fixed in Sprint 6. Any new preview work must share, not
re-derive, this resolution logic.

Because of these findings, Story 1 as originally written failed the Definition of Ready (its ACs
were not explicit/testable against the current product surface, and its 5-point estimate no
longer reflects real scope). It has been rewritten and split below. Story 2 is updated but not
split. Story 3 is **not marked Ready** — see the open question.

## Stories

### Preview the current layout with a selected CSV record - Status: Ready
### Render an accurate, record-specific preview of the current template - Status: Done

**Sprint Review verification (Sprint 7, 2026-10-19):** Product-owner verdict: accepted, all 6 acceptance criteria met, verified independently via direct code and test-file inspection rather than dev-team's report alone. AC1 (resolved values for standalone elements and Address Control lines): confirmed by reading `TemplatePreviewBuilder.Build`/`BuildAddressControlDraws`, which resolve every element and control line through the shared `TextResolver` and never emit a bracket token. AC2 (rotation pivot parity): confirmed `RotationPivotCalculator.Compute` is called identically from `CanvasElementEditor`, `TemplatePreviewControl.DrawItem`, and (per its class remarks) mirrors the CLI's `RotatedTextAnchorCalculator` rule; `RotationPivotCalculatorTests` directly proves two very different measured widths yield the same dynamic-content anchor. AC3 (collapse/mixed-content parity): confirmed `TemplatePreviewBuilderTests` mirrors `RenderEngineTests`' exact scenario shapes (collapsible-blank-line shift, mixed literal/field concatenation, Address Control expansion and collapse, z-order) with matching expected values, not just superficially similar test names. AC4 (auto-refresh): traced the actual wiring in `TemplateDesignerForm` — `TemplateCanvasControl.ElementsChanged` (raised on drag, rotate, resize, add/remove, and every properties-panel commit including content/rebind/X/Y/angle) calls `_previewControl.Invalidate()`, and `TemplatePreviewControl.OnPaint` re-resolves against the live document on every repaint rather than a cached draw list — this is a real "no separate rebuild step" design, not a claim. AC5 (clear failure message): confirmed `TemplatePreviewBuilder.Build`'s pre-flight unknown-column check produces a specific per-column message surfaced by `TemplatePreviewControl.DrawMessage`, and `TemplatePreviewBuilderTests.Build_UnknownColumnReference_FailsWithSpecificMessage`/`Build_AddressControl_UnknownFieldRunColumn_FailsWithSpecificMessage` cover both standalone and Address Control cases. AC6 (built-form smoke): dev-team's reflection-harness/screenshot evidence was not independently re-run in this review session (no shell/build tool access available) — treated as a disclosed evidence-depth caveat, not a gap, since the underlying rotation-pivot-stability and message-on-failure claims it makes are independently corroborated by the code and unit tests above. Test-file inspection confirms new coverage (`TextResolverTests`, `RotationPivotCalculatorTests`, `TemplatePreviewBuilderTests`) is real and substantive, not thin.

**Sprint 7 completion note (2026-10-19, dev-team):** All 6 ACs met. A new read-only
`TemplatePreviewControl` sits beside the existing editable design canvas in `TemplateDesignerForm`
and draws the current template using one selected CSV record's real resolved values, via a new
`TemplatePreviewBuilder` (Desktop.Core) that reproduces address-line collapse, mixed-content
concatenation, and both rotation pivot rules exactly. The resolution logic itself was extracted
into one shared `TextResolver` used by both the canvas's `AddressBlockPreviewCalculator` and the
new preview builder (closing the "third divergent implementation" risk this epic's refinement note
called out), and the previously-duplicated rotation-pivot rule was likewise extracted into one
shared `RotationPivotCalculator`. Live built-`.exe` verification (screenshots) directly confirmed
the fixed-anchor rotation pivot stays stable across two records with very different resolved text
lengths, and that an unresolvable bound column produces a specific message rather than a blank
preview. Full details, live-verification evidence, and test counts: `backlog/sprints/sprint-7.md`
(Batch 1).

**Card**
As a **print operator**, I want to preview a chosen record in the current layout, so that I can verify the design before rendering a full job.
As a **print operator**, I want to preview a chosen record in the current layout using the
template's real rendering rules, so that I can verify the design before rendering a full job.

**Conversation notes**
- The preview should match the text positioning rules used by the render path.
- MVP scope is preview for text-only layouts first.
- The preview can use a dedicated panel instead of a separate window.
- Uses a dedicated preview panel (separate from the editable design canvas), per the epic's
original conversation note — the design canvas keeps showing bracket tokens for editing; the
preview panel shows resolved per-record text instead. This avoids a risky in-place redesign of
`TemplateCanvasControl`'s existing editing/hit-testing/drag behavior.
- Must reuse one shared text-resolution routine across CLI render, canvas collapse/mapping state,
and this new preview panel rather than adding a third divergent implementation — extract
`RenderEngine.ResolveText`'s per-run resolution (or an equivalent) into something callable from
`EnvelopeRenderer.Desktop.Core` so all three call sites agree by construction, not by convention.
- Must reproduce current shipped rendering rules exactly: address-line collapse
(`AddressLineCollapser`), per-run mixed static/field content, rotation pivot rules (fixed
authored anchor for dynamic/mixed elements, bounding-box center for static elements — the
Sprint 6 fix), and Address Control line layout/spacing.
- This is a GUI story touching form layout (a new panel added to `TemplateDesignerForm`) — per
the Sprint 6 retrospective action, evidence must include an actual built-form smoke test, not
only an in-process harness or canvas-only screenshot.
- MVP scope stays text-only, matching the product's current text-only render capability (no image
elements exist yet — epic 7).

**Confirmation (Acceptance Criteria)**
- [ ] The app renders the selected CSV record into a preview of the current template.
- [ ] Static and dynamic text positions in preview match the render rules for the text-only slice.
- [ ] Preview refreshes after relevant layout or mapping changes.
- [ ] If the selected record cannot be previewed, the app shows a clear message.
- [ ] A new preview panel in the desktop app renders the current template using one selected CSV
record's real, resolved values for every standalone text element and every Address Control
line (not bracket-token placeholders).
- [ ] Rotated elements preview with the same pivot the CLI render uses for that element's kind
(fixed authored anchor for dynamic/mixed content, bounding-box center for static text).
- [ ] Address-line collapse (blank optional lines) and mixed static/field runs preview identically
to actual CLI render output for the same template and record.
- [ ] The preview refreshes automatically after a layout edit, a field remapping, or a different
record being selected.
- [ ] If the selected record cannot be resolved (e.g., a bound column is missing from the loaded
CSV), the panel shows a clear, specific message instead of a blank or stale preview.
- [ ] Verified with an actual built-form smoke (per Sprint 6 retrospective action), not only an
in-process or canvas-only check.

**Estimate:** 8 points (dev-team, 2026-10-16, via code inspection of `TemplateCanvasControl`,
`AddressBlockPreviewCalculator`, `RenderEngine`, `TemplateDesignerForm`) — up from the original
5-point placeholder. Sizing basis: no CLI/XML format changes are needed (desktop-only), which
keeps this smaller than the 13-point Address Control stories, but it requires a genuinely new
drawing surface plus extracting/sharing resolution logic across three call sites, not a copy-paste
of existing canvas code.
**Dependencies:** None blocking (epics 2, 3, 5, 8 — its original "CSV mapping and template
stories" dependency — are all Done).

### Jump to a specific record number - Status: Done

**Estimate:** 5 points
**Dependencies:** Depends on the ready CSV mapping and template stories
**Sprint Review verification (Sprint 7, 2026-10-19):** Product-owner verdict: accepted, all 4 acceptance criteria met, verified via direct inspection of `CsvRecordNavigator.cs` and `CsvRecordNavigatorTests.cs` rather than dev-team's summary alone. AC1 (UI control to enter and navigate to a record): confirmed the `_recordNumberInput`/`_goToRecordButton` wiring in `TemplateDesignerForm.BuildPreviewArea`/`NavigateToRecord`. AC2 (preview panel updates after navigation): confirmed `NavigateToRecord` calls `_previewControl.SetContext(_loadedCsvHeaders, result.Record)` on success. AC3 (clear message on invalid/out-of-range input): confirmed `CsvRecordNavigator.TryReadRecord` returns specific messages for non-positive numbers, missing files, and out-of-range records (naming the actual row count), and confirmed `NavigateToRecord` leaves the previously-shown preview untouched on failure (does not call `SetContext`) rather than blanking it — a detail worth calling out since it would have been easy to get wrong. AC4 (full-file reader, not the bounded sample, proven against a CSV over 20 rows): confirmed by reading the implementation (forward-only `CsvReader` scan with no row cap) and independently confirming `CsvRecordNavigatorTests.TryReadRecord_BeyondBoundedSampleSize_StillReadsCorrectRow` requests record 25 of a 30-row file and asserts the correct row — genuine proof, not just an assertion in prose. Live built-`.exe` navigation evidence (record 392, record 999999) was not independently re-run this session (no shell/build access); treated as a disclosed evidence-depth caveat rather than a gap, since the code-level behavior fully supports the claim.

**Sprint 7 completion note (2026-10-19, dev-team):** All 4 ACs met. A "Record #" input plus "Go"
button in the new preview area navigates via `CsvRecordNavigator`, a forward-only full-file
streaming reader mirroring the CLI's `CsvRecordSource` pattern — not the bounded 20-row
`CsvPreviewLoader`. Live-verified against the real 392-record sample CSV including record 392
itself (well past the 20-row bound) and an out-of-range record number, which correctly leaves the
previously-shown preview untouched and reports a specific message instead. Full details and test
counts: `backlog/sprints/sprint-7.md` (Batch 2).

### Jump to a specific record number - Status: Ready
**Card**
As a **print operator**, I want to jump to records throughout a large CSV, so that I can spot-check data before launching a run.
As a **print operator**, I want to jump to records throughout a large CSV, so that I can spot-check
data before launching a run.

**Conversation notes**
- Reuses the CLI's `CsvRecordSource` (forward-only streaming reader, proven at 100k+-record scale
in Sprint 3's throughput work) via skip/take to reach an arbitrary record — not the bounded,
20-row `CsvPreviewLoader` used for the initial column-mapping grid, and not a new indexed/random
-access reader. This keeps the story close to its original scope: it is a new integration of an
already-proven reader, not new large-file infrastructure.
- The story covers direct record-number navigation rather than advanced filtering.
- Large-data support matters, but the UI only needs to load the selected record for preview.
- Invalid inputs should fail gently.

**Confirmation (Acceptance Criteria)**
- [ ] The UI allows the operator to enter a record number and navigate to it.
- [ ] The preview updates to the selected record after navigation.
- [ ] The preview panel (previous story) updates to the selected record after navigation.
- [ ] Invalid or out-of-range record selections yield a clear operator-facing message.
- [ ] Navigation remains usable with large CSV files.
- [ ] Navigation reads the requested record via the full-file streaming reader (not the bounded
sample loader), verified against a CSV larger than 20 rows.

**Estimate:** 5 points (dev-team, 2026-10-16) — up from the original 3-point placeholder, to
account for wiring a full-file `CsvRecordSource` skip/take integration (new, though low-risk given
Sprint 3's proof at scale) alongside the UI control itself.
**Dependencies:** Depends on "Render an accurate, record-specific preview of the current
template."

**Estimate:** 3 points
**Dependencies:** Depends on previewing a selected record
**Post-Sprint-7-review UX correction (2026-10-19, dev-team):** Human product-owner feedback on the
just-shipped Sprint 7 increment: requiring an explicit "Go" click after typing a record number was
an unnecessary extra step — the preview should update the instant the record number changes. Fixed
same-sprint, not as a new story: `_recordNumberInput.ValueChanged` now calls `NavigateToRecord`
directly, and the `&Go` button (`_goToRecordButton`) has been removed entirely from
`TemplateDesignerForm`. AC1 ("The UI allows the operator to enter a record number and navigate to
it") still holds — navigation is now automatic rather than button-triggered, which is a UX
correction to *how* AC1 is satisfied, not a change to AC1 itself; ACs 2-4 are unaffected. See
`backlog/sprints/sprint-7.md`'s post-review fix note for verification detail.

### Warn on text overflow before render - Status: Ready
### Warn on text overflow before render - Status: Not Ready (open question, see below)
**Card**
As a **print operator**, I want to see overflow warnings before generating the PDF, so that I can correct layouts before production time is wasted.
As a **print operator**, I want to see overflow warnings before generating the PDF, so that I can
correct layouts before production time is wasted.

**Conversation notes**
- Missing fonts are blocking; overflow is warning-driven unless otherwise decided later.
- The first implementation can focus on text field overflow.
- Warning details should help the operator find the affected field or record.
- **Blocking open question (product-owner, 2026-10-16):** "overflow" has no defined meaning in
the current product model. Standalone `TextElementLayout` elements have no width/bounding
concept at all (confirmed by code inspection — no `Width` property exists). Only
`AddressControlLayout.Width` exists today, and it is presently just a resize handle, not an
enforced wrap/clip boundary (Sprint 6 review scope note). Before this story can be sized or
written with testable ACs, the human product owner needs to decide: (a) does "overflow" mean a
resolved string running past the *page* edge only (works today, no model change needed), or
(b) does it require adding a real per-element/per-control width boundary with wrap or clip
behavior first (a separate, prerequisite story, likely landing in epic 6, Layout Efficiency, or
as a new epic-4 story) — in which case this story is blocked on that prerequisite. This is a
business/product definition, not an engineering call, so `product-owner` is not deciding it
unilaterally.

**Confirmation (Acceptance Criteria)**
- [ ] Pre-render validation identifies text overflow scenarios for the current template and CSV.
- [ ] The pre-render validation identifies text overflow scenarios for the current template and
CSV, once the human product owner has defined what "overflow" means against the current (or
an updated) element model.
- [ ] The operator can review warnings before starting generation.
- [ ] Blocking versus warning behavior is clearly distinguished in the UI.
- [ ] Warning output identifies enough context for the operator to correct the issue.

**Estimate:** 5 points
**Dependencies:** Depends on preview and mapping foundations
**Estimate:** Not yet sized — blocked on the open question above.
**Dependencies:** Depends on preview and mapping foundations; may additionally depend on a
per-element/control width-boundary story if the human product owner chooses option (b) above.

+ 3
- 1
backlog/epics/05_cli_rendering_engine_and_debenu_integration.md Целия файл

@@ -88,7 +88,9 @@ As a **development team**, I want an early benchmark of the text-only render pat
**Estimate:** 5 points
**Dependencies:** Depends on rendering text-only PDFs through Debenu

### Harden production configuration delivery for CLI runtime settings - Status: Ready
### Harden production configuration delivery for CLI runtime settings - Status: Done
**Sprint Review verification (Sprint 5, 2026-10-09):** All 4 acceptance criteria met. AC1 (an explicit decision): with no live human product owner to consult mid-sprint, `dev-team` made the call itself and flagged it plainly for product-owner confirmation at review, exactly as this story's own AC framing anticipated. **Product-owner confirmation:** concur with keeping `DebenuLicenseKeyResolver`'s existing env-var-then-`key.txt` mechanism unchanged. The reasoning holds up independently, not just as a rubber stamp: this is a single-workstation, Windows-login-only desktop app for non-technical operators (per `project_config.md`'s hard constraints), where a plaintext key file next to the executable costs zero extra operator steps and the exposure surface is a vendor-licensing concern rather than a security/privacy one (no customer/CSV data flows through this mechanism) — building a stronger mechanism now, with no packaging/installer story yet to justify one, would be speculative rather than value-driven. AC2 (documented decision + rationale): recorded in `CLI_CONTRACT.md`'s new "Production configuration delivery decision" section. AC3 (code updated if changed, else no change required): correctly no code change, per the story's own "kept as-is" allowance. AC4 (future packaging story references this decision): an explicit revisit trigger was recorded rather than left implicit. One judgment call worth surfacing to the human user directly, since it's a licensing/deployment decision rather than a pure engineering one: this decision can be revisited at any time if the product's distribution model changes (e.g., a future installer makes a stronger mechanism cheap to add) — it is not a one-way door.

**Card**
As a **system integrator**, I want a deliberate, documented configuration strategy for CLI runtime settings (starting with the Debenu license key), so that desktop-launched and other non-shell invocations of the CLI don't silently fail the way the Sprint 1 license-key gap did.



+ 218
- 21
backlog/epics/06_layout_efficiency_and_operator_tooling.md Целия файл

@@ -2,34 +2,231 @@

**Vision / why this matters:** Operator productivity improves when common layout edits are fast, forgiving, and precise.

## Refinement note (2026-10-16, Sprint 7 refinement)

This epic's three stories were placeholder one-liners from project onboarding (no Conversation
notes, T-shirt sizes never confirmed by `dev-team`). `product-owner` rewrote all three as full
Card/Conversation/Confirmation stories and had `dev-team` size them via real inspection of
`CanvasElementEditor`, `TemplateCanvasControl`, and `TemplateLayoutXmlSerializer`. Two real
prerequisite gaps were found and are called out below rather than papered over:

- **No multi-select exists anywhere in the app.** `CanvasElementEditor.Select` and
`TemplateCanvasControl`'s `_selectedAddressControl` both hold at most one selected item. "Align
and distribute multiple elements" cannot be built directly on top of this — it needs a
multi-select foundation first. Split into two stories below.
- **No undo/redo infrastructure of any kind exists** (grep-confirmed: no `ICommand`, command
stack, or undo manager anywhere in `EnvelopeRenderer.Desktop*`). Every mutation (drag, resize,
rotate, property-panel edit, add/delete element or Address Control, add/remove/reorder Address
Control lines) currently applies directly and irreversibly to the in-memory document. This is
flagged as the single highest-uncertainty story in the backlog — see its sizing note.

## Stories

### Snap elements to grid and guides - Status: Not Started
As a **print operator**, I want layout elements to snap into alignment, so that I can create professional layouts quickly.
### Snap elements to grid and guides - Status: Done

**Sprint Review verification (Sprint 7, 2026-10-19):** Product-owner verdict: accepted, all 4 acceptance criteria met, verified via direct inspection of `GridSnapper.cs`, `CanvasElementEditor.cs`, `TemplateCanvasControl.cs`, `GridSnapperTests.cs`, and `CanvasElementEditorTests.cs` rather than dev-team's summary alone. AC1 (toggleable snap for standalone elements and Address Controls): confirmed `CanvasElementEditor.SnapToGridEnabled`/`GridSizePoints` (defaulting off, per `SnapToGridEnabled_DefaultsToFalse_SoExistingDragBehaviorIsUnchanged`) govern standalone-element drag, and `TemplateCanvasControl.SnapToGridEnabled`/`GridSizePoints` mirror the same toggle into Address Control move/resize. AC2 (continuous snapping during the gesture, not only on release): confirmed `CanvasElementEditor.DragTo` calls `GridSnapper.Snap` on every invocation (not only in `EndDrag`), and independently confirmed `CanvasElementEditorTests.DragTo_SnapEnabled_SnapsContinuouslyThroughoutTheGesture_NotOnlyOnRelease` asserts an on-grid position mid-drag, before release — a real proof of the specific behavior this AC requires, not an inference from the on-release case alone. AC3 (visible grid lines/guides while snap is enabled): confirmed `TemplateCanvasControl.DrawGrid` paints dotted lines at `GridSizePoints` intervals across the page whenever `SnapToGridEnabled` is true. AC4 (snapped positions persist as ordinary X/Y, no template format change): confirmed by reading `TemplateLayoutXmlSerializer.cs` in full — no grid/snap-related attribute or shape exists there, and grid/snap values are plain `double`s on the existing `X`/`Y`/`Width` properties, not a new persisted concept. `GridSnapperTests` also independently confirms the rounding rule itself (nearest-increment, banker's-rounding-at-midpoint, non-positive-grid-size no-op, fractional grid sizes) is correct. The canvas-only bitmap smoke (per this story's own scope note, no full-form smoke required) was not independently re-run this session (no shell/build access); treated as a disclosed evidence-depth caveat, not a gap, since the code and unit-test evidence above independently support the "continuous, not release-only" claim that smoke was meant to demonstrate.

**Sprint 7 completion note (2026-10-19, dev-team):** All 4 ACs met. A "Snap to grid" checkbox plus
a grid-size (pt) input on the designer's canvas-settings toolbar toggles snapping for both
standalone text elements (`CanvasElementEditor.DragTo`) and Address Controls (move and
right-edge-handle resize, handled directly in `TemplateCanvasControl`), rounding to the nearest
grid increment continuously through the drag/resize gesture via a new shared `GridSnapper.Snap`
helper — not only once on release. Dotted grid lines paint across the page while snap is enabled.
Snapped values remain ordinary `X`/`Y`/`Width` doubles; `TemplateLayoutXmlSerializer` was not
touched, so there is no template format change. Live-verified with a canvas-only bitmap smoke
(per this story's own scope note) driving the real built `TemplateCanvasControl`'s actual mouse
handlers directly: an intermediate point mid-drag (before release) already landed exactly on-grid,
proving continuous snapping rather than an on-release-only correction. Full details and test
counts: `backlog/sprints/sprint-7.md` (Batch 3).

**Card**
As a **print operator**, I want layout elements to snap into alignment, so that I can create
professional layouts quickly.

**Conversation notes**
- No grid or snap concept exists today — `CanvasElementEditor.DragTo` sets an element's `X`/`Y`
directly from the raw pointer position with no rounding or alignment assist.
- Scope is confined to the drag/resize math in `CanvasElementEditor` plus optional grid-line/guide
painting in `TemplateCanvasControl` — no template format or CLI render change is implied; a
snapped position is still just an ordinary `X`/`Y` value once released.
- This is a canvas-interaction-only change (no new form/panel/toolbar layout), so per the Sprint 6
retrospective's built-form-smoke guidance, a canvas-only bitmap smoke is sufficient evidence —
it does not need the heavier full-form smoke required for form-layout/properties-panel/toolbar
stories.

**Confirmation (Acceptance Criteria)**
- [ ] Snap-to-grid can be enabled for supported elements (standalone text elements and Address
Controls) and can be toggled off.
- [ ] While dragging or resizing with snap enabled, the element's position/edges align to the
nearest grid increment, visually consistent for the whole drag gesture (not just on release).
- [ ] Operators can see grid lines or guides while snap is enabled, so alignment is visible, not
just felt.
- [ ] Snapped positions persist and reopen correctly (ordinary `X`/`Y` values — no template format
change).

**Estimate:** 5 points (dev-team, 2026-10-16, via inspection of `CanvasElementEditor.DragTo`/
`BeginDrag`/`EndDrag` and `TemplateCanvasControl`'s paint routine). Contained to the existing
editor/canvas pair; no cross-layer (CLI/XML) work needed.
**Dependencies:** None.

### Select multiple elements at once on the canvas - Status: Ready
**Card**
As a **print operator**, I want to select more than one layout element at a time, so that I can
apply the same operation (like alignment) to several elements at once instead of one at a time.

**Conversation notes**
- New story, split out during Sprint 7 refinement from "Align and distribute multiple elements"
once code inspection showed no multi-select exists today — `CanvasElementEditor.Selected` and
`TemplateCanvasControl._selectedAddressControl` are each a single reference, not a set.
- MVP interaction: rubber-band (click-drag on empty canvas) selection plus a modifier-click
(Ctrl/Shift) to add/remove a single element from the current selection.
- Multi-drag-move (moving the whole selection together) is in scope here since it is required to
make a multi-selection meaningfully usable, not just visually indicated.
- Mixing standalone text elements and Address Controls in the same multi-selection is in scope;
the properties panel's behavior when multiple items of different kinds are selected is a
**Development Team design decision** (e.g., disable/hide properties that don't apply to every
selected item) rather than a fixed requirement here.

**Confirmation (Acceptance Criteria)**
- [ ] An operator can select multiple elements via rubber-band drag on empty canvas space.
- [ ] An operator can add or remove a single element from the current selection via a modifier
click.
- [ ] All selected elements are visibly indicated as selected on the canvas.
- [ ] Dragging any selected element moves the entire selection together, preserving each
element's relative offset.
- [ ] Verified with an actual built-form smoke (this touches the properties panel's behavior for
a multi-item selection, per the Sprint 6 retrospective action).

**Estimate:** 5 points (dev-team, 2026-10-16, via inspection of `CanvasElementEditor` and
`TemplateCanvasControl`'s single-selection fields and hit-testing).
**Dependencies:** None. Unblocks "Align and distribute multiple elements" below.

### Align and distribute multiple elements - Status: Ready
**Card**
As a **print operator**, I want to align multiple objects together, so that address blocks and
motifs look consistent.

**Acceptance criteria**
- [ ] Snap-to-grid can be enabled for supported elements.
- [ ] Alignment behavior is visually consistent during drag operations.
- [ ] Operators can position elements accurately using guides or rulers.
**Conversation notes**
- Hard-depends on "Select multiple elements at once on the canvas" above — there is no multi-item
selection to align or distribute without it. Recommend sequencing these two stories in the same
sprint (like the Sprint 4 rotation chain) rather than across sprints, since this story has no
independent value until its prerequisite exists.
- MVP alignment operations: align left/right/top/bottom edges, and align horizontal/vertical
centers, of the current multi-selection.
- MVP distribution operations: distribute selected elements with equal horizontal or vertical
spacing.
- Applies to standalone text elements and Address Controls uniformly, using each item's existing
bounding box (Address Control's `Width`/computed height, standalone elements' measured
width/height).
- **Sprint 8 refinement addition (2026-10-19), applying the Sprint 7 retrospective's per-story
smoke-rule carry-in:** unlike the prerequisite story (whose form-touching surface is limited to
the properties panel's already-scoped multi-item behavior), this story plausibly needs a new
operator-facing trigger for the alignment/distribution operations themselves (e.g. a toolbar
button group or right-click/menu action) — no such affordance exists today. Whether that trigger
is a toolbar addition, a context menu, or keyboard shortcuts is a **Development Team design
decision**, but if it adds any new toolbar/menu control to `TemplateDesignerForm`, this story
requires the full built-form smoke (per the Sprint 6 retrospective action), not only a
canvas-only check — added as an explicit AC below so this isn't left to interpretation at
execution time.
- **Sprint 8 refinement note on verification practice (2026-10-19), applying the Sprint 7
retrospective's second carry-in:** if this story is executed in the same sprint immediately
after "Select multiple elements at once on the canvas" (recommended sequencing above), and that
prerequisite story's own built-form smoke already screenshotted the full form, this story must
still capture a **fresh** full-form screenshot once its own new control (toolbar/menu/etc.) is
added — do not reuse the prerequisite story's screenshot, since it predates this story's new
control. This mirrors the exact reuse mistake named in the Sprint 7 retrospective (Batch 2
reusing Batch 1's full-form screenshot after adding a new control).

**Estimate:** M
**Confirmation (Acceptance Criteria)**
- [ ] The designer supports the alignment operations listed above for the current multi-selection.
- [ ] The designer supports the distribution operations listed above for the current
multi-selection.
- [ ] Results are reflected immediately on the canvas (no save/reopen required to see the effect).
- [ ] Alignment/distribution operations are disabled or produce no effect when fewer than two
elements are selected (clearly communicated, not a silent no-op).
- [ ] If this story adds any new toolbar/menu/UI affordance to `TemplateDesignerForm` to trigger
alignment/distribution, it is verified with an actual **full built-form smoke** (a
freshly-captured screenshot, not a reused one from the prerequisite story) — not only a
canvas-only check.

### Align and distribute multiple elements - Status: Not Started
As a **print operator**, I want to align multiple objects together, so that address blocks and motifs look consistent.
**Estimate:** 5 points (dev-team, 2026-10-16) — the alignment/distribution math itself is
straightforward given each item's existing X/Y/width/height; most of the real risk lives in the
prerequisite multi-select story, not this one.
**Dependencies:** Depends on "Select multiple elements at once on the canvas."

**Acceptance criteria**
- [ ] The designer supports common alignment operations.
- [ ] The designer supports common distribution operations.
- [ ] Results are reflected immediately on the canvas.
### Undo and redo layout changes - Status: Ready (highest uncertainty in the backlog — see note)
**Card**
As a **print operator**, I want to undo mistakes during a session, so that I can iterate quickly
without rebuilding work.

**Estimate:** M
**Conversation notes**
- No undo/redo infrastructure exists anywhere today (grep-confirmed). Mutation entry points that
would need to participate span at least: `CanvasElementEditor` (move, rotate), `TemplateCanvasControl`
(Address Control move/resize, line add/remove/reorder), `TextElementPropertiesEditor` (all
property edits), and `TemplateDesignerForm` (add/delete element or Address Control).
- **Design option worth dev-team evaluating, not mandated here:** the app already has a full,
proven document serializer (`TemplateLayoutXmlSerializer`, used for save/reopen). A
coarse-grained approach — deep-clone/snapshot the whole in-memory document before each discrete
user action and push it onto an undo stack, restoring a full snapshot on undo/redo — could avoid
building a fine-grained command object per mutation type. This trades some memory/perf headroom
(acceptable at this product's single-workstation, document-sized-in-memory scale) for
significantly less new code than a command-pattern rewrite of every mutation path. This is a
recommendation to evaluate, not a requirement — `dev-team` should confirm feasibility (e.g.,
whether the document model is cleanly cloneable today) before committing to it.
- Undo granularity: one undo step per discrete completed user action (e.g., one full drag
gesture, one property change, one line add), not per intermediate mouse-move frame.
- This touches the properties panel, canvas, and toolbar/menu (undo/redo buttons or shortcuts) —
per the Sprint 6 retrospective action, requires an actual built-form smoke, not just an
in-process test.

### Undo and redo layout changes - Status: Not Started
As a **print operator**, I want to undo mistakes during a session, so that I can iterate quickly without rebuilding work.
**Confirmation (Acceptance Criteria)**
- [ ] Session-based multi-level undo is supported across move, resize, rotate, property edits,
add/delete element or Address Control, and Address Control line add/remove/reorder.
- [ ] Session-based multi-level redo is supported for the same operation set.
- [ ] Common layout operations are tracked consistently in history (no silently-skipped operation
types among those listed above).
- [ ] Undo/redo does not persist across save/reopen (session-scoped only, matching the Card's "
during a session" framing) unless the human product owner decides otherwise.
- [ ] Verified with an actual built-form smoke covering at least one full undo/redo cycle through
the real built `.exe`.

**Acceptance criteria**
- [ ] Session-based multi-level undo is supported.
- [ ] Session-based multi-level redo is supported.
- [ ] Common layout operations are tracked consistently in history.
**Estimate:** 13 points (dev-team, 2026-10-16) — flagged as the **highest-uncertainty estimate in
the current backlog**, on par with the two 13-point Address Control stories but with less
precedent to size against (no prior undo/redo-shaped story has been built or estimated by this
team). The snapshot-based design option above could bring real cost down; the estimate assumes it
is viable. Recommend not committing this in the same sprint as another large/novel story, and
recommend `dev-team` do a short feasibility check on document-cloning before sprint planning
treats this estimate as firm.

**Estimate:** M
**Feasibility check performed (`product-owner`, 2026-10-19, Sprint 8 refinement, real code
inspection, not dev-team's own DoD check — this is the pre-commitment check the sizing note above
asked for):** read `TemplateLayoutDocument.cs`, `TemplateLayoutXmlSerializer.cs`,
`AddressControlLayout.cs`, and `TextElementLayout.cs` directly. Findings support the snapshot-based
design option's viability, de-risking (not eliminating) this estimate:
- `TemplateLayoutDocument` is a simple, flat object graph: one `CanvasSettings` value plus two
plain `List<T>` collections (`Elements: List<TextElementLayout>`,
`AddressControls: List<AddressControlLayout>`) of small data-holder classes with no circular
references, no UI-thread-bound objects (no `Control`/`Graphics`/event-handler fields), and no
external resource handles embedded in the model — exactly the shape a deep-clone/snapshot
approach needs to be cheap and safe.
- The document already has a **proven, complete serialize/deserialize round-trip** in
`TemplateLayoutXmlSerializer.Save`/`TryLoad`, used today for save/reopen. `Save` builds an
in-memory `XElement`/`XDocument` tree before ever touching a file, and `TryLoad`'s parsing logic
operates on an already-loaded `XDocument` — meaning a snapshot could reuse this exact code path
(serialize to an in-memory `XDocument`/string on each discrete action, restore from it on
undo/redo) without inventing new clone logic or touching disk per snapshot, only a small
refactor to give `TryLoad` a string/`XDocument`-based entry point alongside its current
path-based one. This is a materially lower-risk starting point than writing a hand-rolled
deep-clone method from scratch.
- No blocking issue found. This confirms the sizing note's assumed design option is genuinely
viable, not just plausible — the 13-point estimate can be treated as reasonably firm for
planning purposes. Residual uncertainty (not resolved by this check, and the reason this story
still carries the "highest uncertainty" flag) is on the **UI/interaction side**, not the
data-model side: wiring every mutation entry point listed in the conversation notes above to
push a snapshot at the right granularity (one step per discrete user action, not per
intermediate drag frame), plus the two-level selection/Address-Control-line editing surfaces
Sprint 6 introduced, has no precedent in this codebase to size against as precisely as the
data-model side now does.
**Dependencies:** None blocking, but recommend sequencing after the multi-select and
align/distribute stories above if in the same sprint (undo should ideally cover the newest
mutation types too, avoiding rework).

+ 88
- 4
backlog/epics/08_composite_address_controls.md Целия файл

@@ -4,7 +4,9 @@

## Stories

### Mix static text and CSV fields within a single text element - Status: Not Started
### Mix static text and CSV fields within a single text element - Status: Done
**Sprint Review verification (Sprint 5, 2026-10-09):** All 6 acceptance criteria met with real evidence, no gaps that weren't already honestly disclosed. AC1 (any combination of runs, concatenated per record): live-verified via a real CLI render producing grep-confirmed literal page text (`(Attn: WILLIAM EDWARD ZIMMERMAN JR)Tj`), not a placeholder claim. AC2 (existing templates render identically) is this story's highest-risk criterion and got the strongest verification of the sprint: rather than just feeding old-format XML through new code, `dev-team` built the actual pre-Sprint-5 CLI binary from a git worktree at the prior commit and byte-compared its real output against the new binary's output for the same template/CSV — then, critically, controlled for Debenu's own internal nondeterminism (font-resource IDs, timestamps) by running the *old* binary against itself twice and confirming an equivalent diff magnitude, isolating the remaining difference to known-nondeterministic tokens only. That is a genuine regression proof, not an assumption. AC3 (editing affordance without corrupting a token): met via the bracket-syntax `{Column Name}` convention with a disclosed, accepted trade-off (a literal `{...}` not meant as a token gets parsed as one) — documented in `TEMPLATE_FORMAT.md` rather than silently left as a surprise, which is the right way to close this kind of scope call. AC4 (canvas distinguishes token from literal per-run): met via per-run segment measurement and highlighting, not a whole-element box. AC5 (persistence/reopen): met via a new `<run>` child-element shape that only appears for genuine multi-run content, keeping every legacy single-run template on the exact pre-existing attribute/inline-text shape — a sound way to make AC2 and AC5 mutually reinforcing rather than in tension. AC6 (unmapped column fails the whole run before any page renders): live-verified with the strictest real case — one valid and one invalid run *within the same element* — confirming the check is genuinely per-token, not per-element. One live-caught GUI bug (`RefreshSelectionLabel` showing a blank `"{}"` for mixed-content elements) was found and fixed in the same pass, consistent with this team's established "leave it better than you found it" pattern. Test suite grew to 330/330 (91 CLI + 239 desktop), and all 233 pre-existing tests passed completely unmodified — itself supporting evidence for AC2's backward-compatibility claim.

**Card**
As a **print operator**, I want to combine literal text and one or more CSV fields within a single text element (e.g., "Attn: {First Name} {Last Name}"), so that I can compose natural sentences and labels instead of being limited to one static line or one bound field per element.

@@ -26,10 +28,21 @@ As a **print operator**, I want to combine literal text and one or more CSV fiel
- [ ] The mixed content is persisted in the saved XML template and restored correctly on reopen.
- [ ] A field token bound to a column that isn't a real CSV header fails the whole run before any page renders, same as today's single-column rule.

**Estimate:** TBD (dev-team to size)
**Estimate:** 13 points
**Sizing note (`dev-team`, 2026-09-29):** Grounded in direct inspection of both the desktop and CLI projects, not the card alone. What's reusable: `TextDraw` (`code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs`) and `DebenuPdfRenderer.AddPage` already take one already-resolved `Text` string per draw and call plain `DrawText`/`DrawRotatedText` on it — this story never has to touch the vendor rendering call itself, since a run sequence only needs to be concatenated into one string before it reaches `TextDraw`. That is a meaningfully smaller render-engine footprint than "Set a rotation angle..." (8 points), which had to add new anchor-offset math inside `DebenuPdfRenderer` itself. `RenderEngine`'s existing upfront "reject any dynamic column that isn't a real CSV header before rendering a single page" check (`RenderEngine.cs` lines 20-33) is already a pre-pass over every element's bound column; flattening it across every *run* of every element instead is a small, mechanical change, not new design, and directly satisfies AC6's "fails the whole run before any page renders" rule using the same shape the existing rule already has.

What's genuinely net-new, and larger than any Done story to date: (1) `TextElementLayout` (`code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs`) currently enforces "exactly one of `StaticText`/`ColumnName`" as its stated core invariant — replacing that with an ordered run list is a breaking internal restructure, not an additive property (contrast `RotationAngle`, which was purely additive over an unchanged model). Every consumer that branches on `IsDynamic` or reads `StaticText`/`ColumnName`/`DisplayText` needs to be re-derived from a run sequence: `TextElementPropertiesEditor.SetColumnName` (single-column rebind semantics don't generalize cleanly to N field runs), `TemplateCanvasControl.DrawElement`'s blue/orange highlight-fill branch, and `AddressBlockPreviewCalculator.ResolveSampleText` (must resolve/concatenate N runs against the sample record instead of reading one field). (2) Confirmed by direct inspection: **there is no existing UI to edit static text content at all today.** `TextElementPropertiesEditor` has no `SetStaticText` method, and `TemplateDesignerForm.BuildPropertiesPanel` has no text-content input control — a static element's text is only ever set once, at creation (`CanvasElementEditor.AddStaticText`'s `"Static text"` default), never edited afterward via UI. So AC3's "real editing affordance for composing mixed content" isn't an extension of an existing control (unlike the rotation story's numeric-field-copied-from-X/Y precedent) — it is new from zero, including whatever bracket/token-parsing (or chip) logic turns an operator's raw input into a validated run list. Working assumption for implementation (matching the card's own framing and this team's established preference for the faster-to-deliver option when a story flags a choice, e.g. Sprint 3's explicit-bind-action over drag-and-drop): a `{Column Name}` bracket-typing convention parsed into runs on commit, not a full protected-token-chip rich editor — to confirm before Batch 1 starts. (3) Canvas per-run highlighting (AC4) requires segmenting the drawn string into per-run pixel ranges (cumulative substring-width measurement) and drawing distinct fill rectangles per segment — genuinely new geometry, not reachable by copying today's single whole-element highlight box. (4) Backward-compatible persistence is a two-sided schema design, not a one-attribute addition: `TemplateLayoutXmlSerializer` (desktop) and `TemplateXmlParser` (CLI) are two independently-implemented parsers (by this project's own established "desktop never references CLI internals" rule) that must each keep writing/reading the exact legacy shape (`column="..."` attribute or inline `XText` content) for the common single-run case — so pre-existing templates round-trip byte-for-byte identical, per AC2's explicit "real regression check against previously-saved templates" — *and* add a new nested shape (e.g. `<run>`/`<field>` children) for genuine multi-run content, reliably distinguishing the two on load. That doubles the design-and-test surface of the "optional attribute defaulting to old behavior" pattern every prior story (`collapsible`, `angle`, `zOrder`) used, since there are two independent implementations and the new case is a structural shape choice, not a scalar default.

Comparing to the largest Done stories on this team's scale: this story touches more files with real logic changes (at least `TextElementLayout`, `TextElementPropertiesEditor`, `TemplateDesignerForm`, `TemplateCanvasControl`, `AddressBlockPreviewCalculator`, `TemplateLayoutXmlSerializer`, `TemplateElement`, `TemplateXmlParser`, `RenderEngine`, `TEMPLATE_FORMAT.md` — ten-plus, versus "Set a rotation angle..."'s seven) and, unlike that story, has no directly-reusable UI pattern to copy for its central new interaction, plus a two-sided (desktop + CLI) backward-compatible schema design rather than one shared scalar default. That is a real step up from this team's 8-point ceiling to date, not an inflated guess — sized at 13 points, the next step on this team's relative scale (2, 3, 5, 8 used so far).

**Split option (not required, noted for capacity risk):** at 13 points against an 18-20 point/sprint velocity, this story alone still plausibly fits a single sprint (5-7 points of headroom), so a split is not required to meet the Definition of Ready. It does have a genuine vertical-slice fault line worth knowing about if capacity gets tight: (a) the run-sequence data model, backward-compatible persistence on both sides, and `RenderEngine`'s per-record resolution/pre-flight-failure behavior, delivered with a minimal-but-real bracket-syntax editing box and no per-run canvas highlight (single-color "this element has dynamic content," same as today), is a complete, shippable, testable vertical slice on its own; (b) the token-chip/protected-editing polish and the per-run canvas visual distinction (AC3's "without accidentally corrupting a token" and AC4) layer on top without touching the render/persistence core. Recommend keeping this as a fallback, not pre-splitting the card now.
**Dependencies:** Depends on "Create a dynamic text token from a CSV column" (Done, Sprint 3) — extends, rather than replaces, that story's persisted format for the existing single-run case.

### Group lines into a single, movable Address Control - Status: Not Started
### Group lines into a single, movable Address Control - Status: Done
**Sprint Review verification (Sprint 6, 2026-10-12):** Product-owner verdict: accepted. All 6 acceptance criteria are met. AC1 (create and manage any number of lines): accepted because the built form exposes Add Address Control plus per-control `+`, `-`, `Up`, and `Down` line controls, and the model has direct add/remove/reorder tests. AC2 (same static/field/mixed content per line): accepted because address lines use the same run model and `{Column}` editing convention as Sprint 5 text elements, with CLI and serializer tests covering mixed literal/field content. AC3 (move/resize as one unit): accepted because the Address Control owns one X/Y anchor, drags as one selected canvas object, and resizes either through the width property or the right-edge canvas handle confirmed in the actual WinForms smoke. AC4 (default-on/toggleable collapse): accepted because new address lines default `CollapseIfBlank` to true, the existing checkbox edits the selected line, and render/preview collapse both use the existing line-collapser behavior. AC5 (persist/reopen): accepted via the new `<addressControl>`/`<line>` XML round-trip coverage and docs. AC6 (render matches designer content/order/collapse): accepted via CLI parser/render tests, XML render-order preservation, live 392-record render output, and the canvas smoke showing resolved sample text with the blank line collapsed. The documented limitation that `width` is not a render-time wrap/clip boundary is acceptable because this story asked for grouped placement/resizing, not text wrapping or clipping. No new backlog item is required from this review; whole-control rotation remains an already-noted out-of-scope candidate, not a missed criterion.

**Development verification (Sprint 6 Batch 2, 2026-10-12):** All 6 acceptance criteria were met in the dev-team DoD check and accepted in Sprint Review. AC1 (create/add/remove/reorder any number of lines): met by the new Address Control toolbar action plus line-list UI (`+`, `-`, `Up`, `Down`) backed by `AddressControlLayout` tests for add/remove/reorder behavior. AC2 (each line supports static/field/mixed content): met by per-line `TextRun`/`TemplateTextRun` sequences using the same `{Column}` editor convention and `<run>` XML shape as standalone text elements. AC3 (move/resize as one unit): met by a single X/Y anchor on the control, whole-control drag in `TemplateCanvasControl`, width editing in the properties panel, and a right-edge resize handle visible on the canvas. AC4 (default-on, individually toggleable collapse): met by `AddressControlLineLayout.CollapseIfBlank = true` by default and the existing Collapse-if-blank checkbox applying to the selected line; render and preview both reuse the existing `AddressLineCollapser` semantics with every child line sharing the control's X by construction. AC5 (persist/reopen): met by `TemplateLayoutXmlSerializer` round-trip tests for `<addressControl>` with per-line content/font/color/collapse settings, and by `TEMPLATE_FORMAT.md` documenting the container shape. AC6 (PDF reflects designer order/content/collapse): met by CLI parser/render tests for address expansion, mixed content, XML render-order preservation across `<text>` and `<addressControl>`, unknown-column preflight failure, and blank-line collapse; live-verified with a temp `<addressControl>` template rendered by the built CLI against the real 392-record sample CSV, exit 0 and PDF text confirmed. GUI verification used the actual built WinForms controls, not a model-only harness: canvas bitmap smoke confirmed resolved sample text, collapse, selection border, and resize handle; full-form bitmap smoke confirmed the Add Address Control toolbar, visible right properties panel, content/width/line-list controls, and line add/remove/reorder controls. Test suite is 349/349 (101 CLI, 248 desktop/core), and the desktop executable builds with 0 warnings/errors. The only scope note documented in `TEMPLATE_FORMAT.md`: `width` is a designer resize box today, not a render-time wrap/clip boundary.

**Card**
As a **print operator**, I want to group a custom, ordered set of lines (each using the mixed static-text-and-field content from the previous story) into a single Address Control element that I can place, move, and resize on the canvas as one unit, so that I can build and manage address blocks without positioning every line by hand.

@@ -50,5 +63,76 @@ As a **print operator**, I want to group a custom, ordered set of lines (each us
- [ ] The control's full structure (lines, content, per-line settings) is persisted in the saved XML template and restored correctly on reopen.
- [ ] The rendered PDF reflects the same line content, order, and collapse behavior shown in the designer canvas.

**Estimate:** TBD (dev-team to size)
**Estimate:** 13 points
**Sizing note (`dev-team`, 2026-09-29):** Depends entirely on Story 1's run-sequence content model (a line's content IS a run sequence), so cannot start until that story lands — sized here on the assumption it is complete first. Rotating the control as a whole group is explicitly out of scope for this story (see conversation notes) — added as two dependent stories below on 2026-10-19, after this epic was reopened.

Key finding that reduces scope versus the epic's own flagged risk: inspected `AddressLineCollapser` (`code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs` and its desktop twin, `AddressBlockPreviewCalculator`) in detail. Its grouping rule stacks any lines sharing the same X (rounded to 2 decimals). **Design decision made for sizing purposes, the same kind of explicit call the rotation story's sizing note made:** the control owns a single X/Y anchor, with each line's position computed automatically from it (not stored as an independently-settable X/Y per line) — "one anchor, N lines auto-spaced," not "N independent `TextElementLayout` children this story keeps in lockstep on every drag." Under that design, every line inside one control shares the exact same X by construction, so the *existing* same-X grouping rule already produces exactly the intended per-control collapse/shift behavior with zero changes to `AddressLineCollapser`/`AddressBlockPreviewCalculator` — satisfying AC4's "matches the existing per-line collapse rule" for free, and directly answering the epic's own open question (should grouping key off shared X or off belonging-to-a-control?) for this story's purposes: it doesn't need to, because this design makes the two equivalent. The pre-existing "unrelated elements coincidentally sharing X" ambiguity the conversation notes flag is unchanged by this story either way — worth a `qa-tech-debt` note recommending an explicit control-scoped collapse key as future hardening, but not required by this story's AC as written. The lockstep-N-children alternative was rejected for sizing: it would require inventing a multi-element-move abstraction (`CanvasElementEditor.BeginDrag`/`DragTo` today only ever moves one `Selected` element by a single `(Dx, Dy)` offset) and would forfeit the free `AddressLineCollapser` reuse above, since independently-positioned children could drift out of X-alignment over time — a materially riskier and larger design than the anchor-owns-position choice made here.

What's genuinely new and large regardless of that scope reduction: this is the **first composite/heterogeneous element kind** this designer has ever had. `TemplateLayoutDocument.Elements` (`code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs`) is a plain `List<TextElementLayout>`, and `TemplateDocument.Elements` (CLI, `TemplateDocument.cs`) is `IReadOnlyList<TemplateElement>` — both hard-typed to the single element kind that has existed since Sprint 2, with every consuming piece of code (draw loop, z-order, hit-test, serializer, parser, render engine) written against that one type. Introducing an Address Control means threading a common base/interface (or a second parallel collection) through: `TemplateCanvasControl`'s paint/hit-test loop (draw the control as one selectable/movable unit while still rendering each line's individual mixed content via Story 1's per-run drawing); `CanvasElementEditor` (a new two-level selection model — select-the-whole-control-to-move vs. drill-into-one-line-to-edit-its-content, since AC5 explicitly requires editing an individual line without breaking the group's move behavior, and there is no "select a sub-part of a selection" concept anywhere in this codebase today); `TemplateDesignerForm` (a genuinely new line-list-management UI — add/remove/reorder controls plus, per line, Story 1's mixed-content editor and a pre-checked-by-default "Collapse if blank" — CRUD-list UI, a different and larger UI problem than any single-value properties-panel field added to date); `TemplateLayoutXmlSerializer` and `TemplateXmlParser`/`TemplateElement`/`RenderEngine` (a new `<addressControl>` container schema independently implemented on both sides, plus `RenderEngine.BuildDraws` expanding one control into N `TextDraw`s at automatically-spaced Y offsets per record); and `TEMPLATE_FORMAT.md`. The line-spacing rule (also flagged as a Development Team decision in the card) is sized on the assumption it derives from each line's own font size (a fixed leading multiple), needing no separate stored "line spacing" value on the control — simpler than inventing and persisting an explicit new attribute, and this element type is brand new so there is no backward-compatibility burden to design around for it, unlike Story 1's persistence work.

Comparing to Story 1 and to the largest Done stories: this story's core "new composite element kind threaded through the entire desktop+CLI stack" is a bigger structural change than anything shipped to date (bigger than rotation's "add one property and one anchor-offset formula," comparable in breadth to Story 1's run-sequence model swap), and it adds a wholly new CRUD/two-level-selection UI paradigm with no precedent to copy — arguably more UI surface than Story 1's single-element content editor, since it is list management (add/remove/reorder) wrapped around N of Story 1's own per-line editors. Sized at 13 points, matching Story 1 rather than exceeding this team's established 8-point ceiling twice over, since the collapse-logic reuse found above is a genuine, code-inspection-backed scope reduction, not an assumption.

**Sequencing recommendation (concrete finding, not a card rewrite):** not recommending an artificial pre-split of this story on its own — its most separable-looking feature, full add/remove/reorder of a fully custom line list, was explicitly confirmed with the user as required rather than a starter template, so trimming it would cut against a stated requirement rather than defer genuinely separable scope. The real capacity finding to flag instead: Story 1 (13 points) and this story (13 points) are hard-dependent — this story cannot start meaningfully until Story 1's run-sequence model exists — and together total 26 points against this team's 18-20 point/sprint velocity, comfortably more than one sprint's capacity even before any other backlog item is considered. Recommend product-owner/scrum-master plan these across two separate sprints (Story 1 first, this story next), the same dependency-respecting sequencing already used for the Sprint 3 CSV-mapping chain and the Sprint 4 rotation chain, rather than attempting to commit both to one sprint.
**Dependencies:** Depends on "Mix static text and CSV fields within a single text element" (above, same epic) and "Collapse blank optional address lines consistently" (Done, Sprint 4).

### Rotate the whole Address Control as a single unit - Status: Done

**Development verification (Sprint 8 Batch 1, 2026-10-26):** All 6 acceptance criteria met. AC1 (set via the properties panel's existing angle field): met — `_angleInput` is now enabled and wired for a selected Address Control (`TemplateDesignerForm.SetSelectedRotationAngle`/`RefreshPropertiesPanel`), same -360.0/360.0, 0.1-precision convention as standalone elements, confirmed live by typing a value into the real built control. AC2 (whole control rotates as one rigid unit): met — `RenderEngine.BuildAddressControlDraws` and `TemplatePreviewBuilder`'s equivalent rotate each visible line's own anchor as a rigid group around the control's fixed `BoxCenter`, and `TemplateCanvasControl.DrawAddressControl` wraps its entire paint call (box, lines, highlight fills, selection border, resize handle) in one GDI+ transform around the same pivot; confirmed visually via built-`.exe` screenshots showing the whole block rotate together. AC3 (consistent across CLI PDF, canvas, and preview): met — a real CLI render of a 20-degree-rotated template was confirmed via direct PDF content-stream inspection (`cos(20)`/`sin(20)` rotation matrices present, absent entirely for an unrotated control), and canvas/preview screenshots of the same template show the identical 20-degree (then 65-degree, set live via the panel) rotation on both surfaces. AC4 (pivot stable across records regardless of collapse): met — `BoxCenter` is a pure function of authored `X`/`Y`/`Width`/`Height`/line font sizes with zero record input, verified by direct unit tests on both `TemplateAddressControl.BoxCenter`/`AddressControlLayout.BoxCenter` and by CLI/desktop regression tests rendering two records (one collapsing a blank optional line, one not) and asserting the unaffected first line's rotated position is bit-for-bit identical both times. AC5 (rotated control remains correctly click-selectable at its rotated position): met — `HitTestAddressControl`/`HitTestAddressResizeHandle` (and the resize-drag math itself, fixed as a related correctness issue found during implementation) now rotate the click point backward into local space before testing; live-verified via simulated real mouse clicks through the actual built `OnMouseDown`/`OnMouseUp` handlers, confirming a click at the control's real (forward-rotated) on-screen position selects it and a click at its old un-rotated position does not. AC6 (persisted as `angle` on `<addressControl>`, default 0, backward compatible): met — new optional attribute in both `TemplateLayoutXmlSerializer` and `TemplateXmlParser`, round-trip and default-omitted tests passing, `TEMPLATE_FORMAT.md` updated. Test suite grew to 422/422 (107 CLI, 315 desktop). New Low-impact technical debt logged: the pre-existing (Sprint 6) Address Control hit-test/move/resize logic remains embedded directly in `TemplateCanvasControl` (WinForms) rather than a `CanvasElementEditor`-equivalent, so it stays smoke-tested only — this story's own *new* rotation math was deliberately extracted to unit-tested `Desktop.Core` classes (`AddressControlLayout.BoxCenter`/`TemplateAddressControl.BoxCenter`/`PointRotation`), but retroactively refactoring the older code was out of this story's scope.

**Epic reopened (2026-10-19):** this epic was Done outright after Sprint 6 (both stories above shipped). The human product owner has now confirmed the whole-control rotation capability that Sprint 6 Review explicitly logged as out of scope ("whole-control rotation remains an already-noted out-of-scope candidate, not a missed criterion" — see `backlog/backlog.md`) is wanted. This story and the dependent drag-handle story below reopen the epic.

**Card**
As a **print operator**, I want to set a rotation angle for an entire Address Control, so that I can print an angled address block (e.g., a rotated return-address stamp) without rotating every line individually or losing the block's automatic line spacing and alignment.

**Conversation notes**
- Confirmed with the user (2026-10-19): this reverses the explicit Sprint 6 out-of-scope call. Reuse the same rotation convention already shipped for standalone elements — free-form angle, -360.0 to 360.0, 0.1 precision, entered numerically in the properties panel — for consistency across the designer, not a new convention.
- **Key architectural finding, confirmed by direct code inspection before writing this story's acceptance criteria (not assumed from the parent request):** the standalone-element rotation-positioning defect fixed in Sprint 6 existed because a rotated dynamic/mixed element's bounding-box center was computed from that record's own *resolved text width* (`DebenuPdfRenderer.AddPage`'s pre-fix `GetTextWidth(draw.Text)` call), so the pivot moved record-to-record. The Address Control's box geometry is architecturally different and does **not** have this problem: `AddressControlLayout.Width`/`TemplateAddressControl.Width` are explicit, author-set properties driven by the canvas resize handle (`TemplateCanvasControl`'s `DrawAddressResizeHandle`/`HitTestAddressResizeHandle`), not a measurement of any line's text; and `Height` (`AddressControlLayout.Height`) is computed purely from each line's own authored `FontSize` and the control's `LineSpacingMultiplier` — never from resolved text. `TemplateCanvasControl.DrawAddressControl` confirms this directly: it draws the control's box from `control.X/Y/Width/Height` alone; only the per-line dynamic-content highlight fill (a cosmetic overlay) uses measured text width, and that overlay never affects the box's own position or size. Because the box's own geometry never depends on any record's resolved text, rotating the whole control around its own box center is safe and record-stable everywhere — editing canvas, the Sprint 7 preview panel, and the real CLI render — with **no anchor-pivot workaround required**, unlike the standalone dynamic-element rotation defect. This is a deliberate scope note, not an assumption of symmetry: do not port `RotationPivotCalculator`'s dynamic-vs-static branching into this story's design — it doesn't apply here. The whole control always rotates around its own fixed, authored box center, full stop.
- **Second finding, confirmed by inspection, that does need explicit handling:** `AddressLineCollapser` shifts a line's *effective* Y per record when a preceding blank line collapses. The rotation pivot (the control's box center) must always be computed from the control's authored, unrotated `X`/`Y`/`Width`/`Height` — never from any record's collapse-shifted line positions — so that two records differing only in whether a blank line collapses still rotate around the exact same pivot. This is the same record-stability discipline the Sprint 6 defect fix established, applied here to a different source of per-record variation (collapse, not text width) rather than assumed to be automatically safe.
- **Third finding, confirmed by inspection:** none of the three places that currently draw an Address Control's lines — `RenderEngine.BuildAddressControlDraws` (CLI render), `TemplatePreviewBuilder`'s address-control counterpart (Sprint 7 preview panel), and `TemplateCanvasControl.DrawAddressControl` (editing canvas) — has any concept today of "multiple draws that must rotate together as one rigid group around a shared external pivot." Each currently emits one independent, already-stacked-baseline draw per visible line. Delivering this story means each visible line's own anchor point must first be rotated around the control's box center (a standard rotate-a-point-around-a-pivot transform — `CanvasElementEditor.RotatePointAroundPivot` already has this exact formula on the desktop side, used today only for single-element hit-testing), and that already-rotated point then passed through unchanged to whichever existing per-line draw call already exists. A real, code-confirmed scope reduction versus the original standalone-element rotation story: `DebenuPdfRenderer` needs **no changes at all** here, since a rotated line's fixed pivot can reuse the existing `TextDraw.UsesFixedRotationPivot`/`Angle` fields exactly as they work today (the fixed-anchor rotation path added by the Sprint 6 defect fix) — this story's new work is entirely in computing the correct already-rotated anchor per line upstream of that call, not in the vendor-facing renderer itself.
- The properties panel's rotation-angle input (`_angleInput`, `TemplateDesignerForm.cs`) already exists with the correct -360/360, 0.1-precision convention, but is confirmed by inspection to be explicitly disabled for an Address Control selection today (`_angleInput.Enabled = selected is not null;`, where `selected` is only ever a standalone `TextElementLayout`, never `_canvas.SelectedAddressControl`). This story enables and wires that same existing input for a selected Address Control; no new panel control is needed.
- `TemplateCanvasControl.HitTestAddressControl` (click-to-select) is confirmed by inspection to be a plain axis-aligned rectangle test against `control.X/Y/Width/Height` today, with no rotated-rectangle handling — unlike `CanvasElementEditor.HitTest`'s existing point-in-rotated-rectangle logic for standalone elements. A rotated control must remain correctly click-selectable at its rotated position, so this hit test needs the equivalent rotated-rectangle treatment as a required part of this story, not an afterthought.
- Per-line dynamic-content highlight fills and the per-line selection border (both drawn in `TemplateCanvasControl.DrawAddressControl`) must visually rotate together with the box and text — an unrotated highlight sitting next to rotated text would misrepresent what's actually being rotated. Confirmed as a requirement of this story, not an open question.
- Persisted format: a new `angle` attribute on `<addressControl>`, defaulting to `0` so every template saved before this story renders unchanged — the same convention `<text>`'s own `angle` attribute already established (`TemplateLayoutXmlSerializer`/`TemplateXmlParser`).
- Out of scope for this story: the interactive drag-to-rotate canvas handle — a separate, dependent story below, matching this team's established pattern from the original standalone-element rotation split (`epics/02`).

**Confirmation (Acceptance Criteria)**
- [ ] An operator can set a rotation angle (-360.0 to 360.0, 0.1-degree precision, matching the standalone text-element convention) for a selected Address Control via the properties panel's existing numeric angle field.
- [ ] The whole control — its box, every line's text, and any per-line highlight/selection overlay — rotates together as one rigid unit around the control's own box center; lines never rotate independently of each other or around their own individual positions.
- [ ] The rotated control's position and visual angle are the same across all three surfaces: the CLI-rendered PDF, the desktop editing canvas, and the Sprint 7 record-accurate preview panel.
- [ ] Rotation is stable across records: the pivot used is always the control's authored, unrotated box center, regardless of whether a given record causes a blank line within the control to collapse.
- [ ] A rotated Address Control remains correctly click-selectable on the canvas at its actual rotated position, not its unrotated bounding box.
- [ ] The rotation angle is persisted in the saved XML template as a new `angle` attribute on `<addressControl>` (defaulting to `0`) and restored correctly on reopen; templates saved before this story (no attribute) render unchanged.

**Estimate:** 8 points
**Sizing note (`dev-team`, 2026-10-19):** Sized after direct inspection of every render/paint path this story touches, not the card alone. What genuinely reduces this story's scope versus its closest precedent, "Set a rotation angle for text and dynamic field elements" (8 points, `epics/02`): that story had to add brand-new vendor anchor-offset math inside `DebenuPdfRenderer.AddPage` itself, because the Debenu API has no "rotate about center" primitive and dynamic content's bounding box wasn't stable. This story needs **zero changes to `DebenuPdfRenderer`** — every line already flows through the existing fixed-pivot rotation path (`TextDraw.UsesFixedRotationPivot`/`Angle`, added by the Sprint 6 defect fix) once it's handed an already-correct, already-rotated anchor point; the new work is entirely upstream, in `RenderEngine.BuildAddressControlDraws` computing that rotated anchor per line before constructing each `TextDraw`.

What genuinely adds scope versus that precedent: this story's rigid-group-rotation math must be built independently on both sides of this project's established CLI/desktop split, mirroring the same "two independently-implemented parity" discipline `TemplateLayoutXmlSerializer`/`TemplateXmlParser` already use for persistence — `EnvelopeRenderer.Cli`'s `RenderEngine` has no existing point-rotation-around-a-pivot helper at all (a new one is needed there), while the desktop side already has the exact formula to extract and reuse across both `TemplateCanvasControl`/`CanvasElementEditor` (canvas draw/hit-test) and `TemplatePreviewBuilder` (Sprint 7 preview, same assembly so no third independent implementation is needed there) in `CanvasElementEditor.RotatePointAroundPivot`. Beyond that shared math, `TemplateCanvasControl.DrawAddressControl` needs a rotation transform applied to its whole draw call (box, lines, highlight fills, selection border together — a GDI+ `Graphics.RotateTransform`/`TranslateTransform` around the box center is simpler here than the CLI's per-line point math, since GDI+ supports a drawing-context-level transform), and `HitTestAddressControl` needs the rotated-rectangle test `CanvasElementEditor.IsPointInRotatedBounds` already established for standalone elements, ported to the control's own box. The collapse/pivot-stability requirement (rotation pivot must ignore `AddressLineCollapser`'s per-record Y-shift) is a real correctness risk not present in the original single-element rotation story at all, since a standalone element has no internal collapse-driven sub-layout to stay independent of; it needs its own explicit regression test (two records, one collapsing a blank line, one not, asserting an identical pivot).

Net: a materially smaller vendor-integration footprint (no `DebenuPdfRenderer` change) traded against needing the new rigid-group-rotation math correctly in three surfaces instead of one, plus a genuinely new collapse-interaction correctness requirement. Landed at 8 points, the same size as the original single-element rotation story, for different reasons rather than a higher or lower number by coincidence. Comfortably fits within a single sprint at this team's 18-20 point velocity; no split recommended beyond the drag-handle story already deliberately deferred below.
**Dependencies:** None (the Address Control model itself is Done, Sprint 6).

### Rotate the whole Address Control by dragging a handle on the canvas - Status: Done

**Development verification (Sprint 8 Batch 2, 2026-10-26):** All 5 acceptance criteria met. AC1 (draggable rotate handle shown on a selected control): met — `TemplateCanvasControl.DrawAddressRotateHandle` paints the handle (same SeaGreen-dot/dashed-line/white-outline style as the standalone-element handle) inside `DrawAddressControl`'s existing rotation transform, confirmed visually via a built-`.exe` screenshot of a freshly-created, selected Address Control. AC2 (dragging updates the angle live, rotating box/lines/overlays together): met — a new framework-free `AddressControlRotateHandle` class (Desktop.Core) provides the hit-test/drag-angle math; `TemplateCanvasControl` wires a new `_isRotatingAddressControl` gesture flag through `OnMouseDown`/`OnMouseMove`/`OnMouseUp`; live-verified via a simulated real mouse-down-drag-move (mid-gesture, before mouse-up) through the actual built handlers, screenshotted showing the whole block rotated together at that in-progress angle. AC3 (properties panel and canvas handle stay bidirectionally synced): met — for free, via Batch 1's existing `RefreshPropertiesPanel`/`SetSelectedRotationAngle` wiring; confirmed live both directions (drag updated the real `_angleInput` control's displayed value, and typing a new value into that same control after a drag correctly moved the handle). AC4 (releasing the drag persists the angle the same way a typed value does): met — `RotationAngle` is set directly on the shared `AddressControlLayout` model during the drag itself (matching how a typed value is applied), confirmed unchanged after mouse-up in the live smoke. AC5 (doesn't disturb line drill-in or existing move/resize): met — confirmed live that the drilled-in address line index was unchanged immediately after a full rotate-drag gesture, and that the control's existing whole-unit move continued to work correctly afterward. Tests: 11 new `AddressControlRotateHandleTests` covering local/world handle position (including the rotated-vs-unrotated-position discrimination that proves the hit test is genuinely rotation-aware, not just "moved"), drag-to-angle math, the pivot-exactly-under-pointer no-op case, and a round-trip angle-drives-position consistency check. Test suite grew to 433/433 (107 CLI, 326 desktop). Live verification used a **fresh** screenshot set for this batch (not reused from Batch 1's), per the Sprint 7 retrospective carry-in this sprint's own plan explicitly reinforced.

**Card**
As a **print operator**, I want to rotate an Address Control by dragging a handle directly on the canvas, so that I can adjust an angled address block visually without switching focus to the properties panel, the same way I already can with a standalone text or dynamic field element.

**Conversation notes**
- Strictly depends on the previous story: this story adds only the interactive handle and its live drag behavior, not the underlying `RotationAngle` property, persistence, or render/pivot logic, which the previous story must deliver first — the same dependency shape as the original standalone-element drag-handle story (`epics/02`).
- Confirmed by code inspection: `CanvasElementEditor`'s existing handle machinery (`HandlePosition`, `HitTestHandle`, `RotateDragTo`) operates only on the single selected `TextElementLayout` (`Selected`) today; there is no equivalent state or method for `SelectedAddressControl`. This story adds a parallel handle-position/hit-test/rotate-drag path for the whole control, computed around the control's box center (established by the previous story) — a new, analogous implementation, not a direct reuse of the existing per-element methods as-is.
- Exact handle affordance (e.g., a small grip offset above the selected control's rotated bounding box, matching the standalone text-element handle's green-dot-plus-dashed-line style and relative offset for visual consistency, versus some other placement) is a **Development Team design decision**, consistent with how this project already handled the identical "reasonable UI, team's call" case for the original standalone-element handle.
- The properties panel's numeric angle field (enabled for an Address Control selection by the previous story) and the canvas handle must stay synchronized in both directions: dragging the handle updates the number live, and typing a number moves the handle/rotates the canvas control live — the same bidirectional rule the standalone-element handle story established.
- Dragging the whole-control rotate handle must not disturb or be confused with the existing drill-into-a-line selection/editing behavior (selecting/editing an individual line's content, and the control's existing whole-unit move/resize interactions, are already-shipped, separate interactions from Sprint 6) — this story only adds a way to rotate the group as a whole on top of them.
- Out of scope for this story: snapping the drag to fixed increments — the same accepted future-enhancement note the original standalone-element handle story made, not required here either.

**Confirmation (Acceptance Criteria)**
- [ ] A selected Address Control shows a draggable rotate handle on the canvas.
- [ ] Dragging the handle updates the control's rotation angle live and visually, rotating the box, every line's text, and any highlight/selection overlay together as one unit (per the previous story's rigid-rotation behavior).
- [ ] The properties panel's numeric angle field and the canvas handle stay synchronized in both directions.
- [ ] Releasing the drag persists the resulting angle the same way a typed properties-panel value does.
- [ ] Dragging the rotate handle does not change which line is drilled into for content editing, and does not break the control's existing whole-unit move/resize behavior (both Sprint 6).

**Estimate:** 5 points
**Sizing note (`dev-team`, 2026-10-19):** Sized against its closest precedent, "Rotate elements by dragging a handle on the canvas" (5 points, `epics/02`), and lands at the same size for the same reason that story did: the underlying angle property, persistence, and render/pivot math are entirely out of scope here (delivered by the previous story), so this story is purely new hit-testing and new drag-angle math layered on top of an already-existing property — not a new design problem end to end. What's genuinely net-new: a control-specific handle-position/hit-test/rotate-drag implementation in `CanvasElementEditor` (paralleling, not reusing, the existing `TextElementLayout`-only methods), and new paint code in `TemplateCanvasControl` to draw the handle glyph for a selected Address Control. What carries over directly: the bidirectional properties-panel/canvas sync pattern `TemplateDesignerForm.cs` already established (and the previous story already extends to the Address Control's angle field), so wiring the handle into that loop is a proven pattern, not new plumbing. Fits comfortably within a single sprint at this team's velocity; no split recommended.
**Dependencies:** Depends on "Rotate the whole Address Control as a single unit" (above, same epic).

+ 66
- 0
backlog/sprints/sprint-5-retrospective.md Целия файл

@@ -0,0 +1,66 @@
# Sprint Retrospective

**Sprint:** 5
**Date:** 2026-10-09
**Facilitated by:** `scrum-master`, per `process/05_sprint_retrospective.md`
**Inputs used:** `backlog/sprints/sprint-5.md` (Daily Scrum Log + Execution Order), `backlog/backlog.md` (Sprint 5 Review outcome), `backlog/epics/08_composite_address_controls.md` and `05_cli_rendering_engine_and_debenu_integration.md` (Sprint Review verification notes), `logs/technical_debt_log.md`, `logs/impediment_log.md`, `logs/process_improvement_log.md` (all three untouched this sprint — confirmed via diff), `backlog/sprints/sprint-4-retrospective.md` (for follow-through check). No live human team to poll in real time; subjective signals are synthesized from dev-team's own daily-scrum notes and product-owner's independent review notes.

## Signals

**Objective:**
- 2/2 committed stories Done (15/15 points), sprint goal assessed "met in full" at Sprint Review (`backlog/backlog.md`, Sprint 5 Review outcome).
- Test suite grew from 288 (Sprint 4 close) to 330/330 (91 CLI + 239 desktop) — 42 new tests, with all 233 pre-existing tests passing completely unmodified, a strong regression signal for a story whose central risk was backward compatibility.
- Zero new impediments and zero new technical debt logged this sprint (confirmed by diff).
- The CLI Rendering Engine and Debenu Integration epic is now Done outright (6 of 6 stories) — closing out an epic that has been open since Sprint 1.
- One infrastructure event, not a project defect: the background agent executing this sprint's work hit a transient API/network failure mid-task and was resumed from its partial progress with no work lost and no quality impact on the delivered stories — the first real-world test of this project's "resume from task-id" recovery path.

**Subjective (from dev-team's daily-scrum notes and product-owner's independent review):**
- Empirical-verification discipline reached a new high point this sprint: to prove the highest-risk acceptance criterion (existing templates render identically after this story), dev-team built the *actual pre-Sprint-5 CLI binary* from a git worktree at the prior commit and byte-compared its real output against the new binary's — then controlled for Debenu's own internal nondeterminism by running the *old* binary against itself twice, isolating the remaining diff to known-nondeterministic tokens only. This is a meaningfully more rigorous instance of the "confirm real behavior, don't assume" pattern named in Sprints 3 and 4, since it required constructing a whole separate historical artifact for comparison, not just probing a single API call.
- Sizing-note predictive accuracy held again, on a much larger story than Sprint 4's example: the sizing note predicted `DebenuPdfRenderer` would need zero changes since a run sequence only needs to be concatenated into the one string `TextDraw` already expects — and the actual implementation confirmed exactly that.
- Honest self-reporting continued, including a second disclosed trade-off in the same sprint (not hidden or downplayed): the bracket-syntax editing convention's known limitation (a literal `{...}` not meant as a token gets parsed as one), documented plainly in `TEMPLATE_FORMAT.md`.
- "Leave it better than you found it" continued: a live-caught GUI bug (`RefreshSelectionLabel` showing a blank `"{}"` for mixed-content elements) was found and fixed in the same verification pass.
- A GUI verification automation issue surfaced: mouse-coordinate clicks proved unreliable (an apparent DPI-scaling mismatch between the automation script's coordinate space and the real screen), worked around by switching to keyboard-based input instead. This is a *different* specific issue than Sprint 4's `SetForegroundWindow` focus-stealing glitch, but the same broad category — GUI automation environment friction — recurring in back-to-back sprints.

## What went well
- **Full follow-through on all three Sprint 4 retrospective action items**, the fourth clean full-follow-through sprint in a row (see "Follow-up" below).
- This team's largest single story to date (13 points, a breaking internal model change) was delivered with zero regressions across 233 pre-existing tests — strong validation that the "computed backward-compatibility projections over an internal restructure" design (keeping `StaticText`/`ColumnName`/`IsDynamic` as derived views over the new `Runs` list) was the right call, not just a hopeful assumption.
- The background-execution-agent recovery path was exercised for real (not just designed-for) this sprint, and it worked cleanly — a transient infrastructure failure cost some turnaround time but zero delivered-work loss.
- Product-owner's Sprint Review treated dev-team's mid-sprint production-configuration decision as something to independently evaluate, not rubber-stamp — the confirmation note in `backlog/epics/05_cli_rendering_engine_and_debenu_integration.md` gives its own independent reasoning rather than restating dev-team's, consistent with the structural fix that resolved Sprint 3's PO-review-independence watch item.

## What didn't go well
- GUI verification automation friction showed up for a second consecutive sprint — a different specific root cause each time (Sprint 4: window-focus stealing; Sprint 5: mouse-coordinate DPI scaling), but the same broad category. Neither instance masked a real product defect (both were caught immediately and worked around), so this isn't a quality gap — but two occurrences in two sprints is enough to stop treating it as a one-off, especially with Sprint 6's likely next story (the Address Control, with list-management UI and a two-level selection model) needing even more GUI automation surface.
- The transient background-agent API/network failure cost real turnaround time mid-sprint, though it was fully recovered from. Nothing actionable here beyond continuing to use the demonstrated resume path — it's an external infrastructure event, not a team or process failure.

## Patterns / Insights (prioritized)
1. **(Team-level)** Empirical verification-before-claiming discipline keeps growing in rigor, not just repeating — this sprint's real-historical-binary byte-comparison is a step up from Sprint 3's DLL probes and Sprint 4's sign-convention check. Worth continuing to invest verification effort proportional to a claim's real risk, as happened here.
2. **(Team-level)** Sizing-note predictive accuracy is now confirmed on both a small story (Sprint 4's drag-handle reuse) and this team's largest story to date — strong evidence the code-inspection-backed sizing practice generalizes, not just works on easy cases.
3. **(Team-level, new)** GUI verification automation reliability has recurred as friction twice in two sprints, different root cause each time. Per this project's own recurrence bar, this now warrants a deliberate look rather than another "revisit if it recurs" deferral — added as a Sprint 6 action item below, not left deferred a second time.
4. **(Process-level, positive, not a kit matter)** The background-agent resume-from-failure path is now proven under real conditions, not just theoretical — worth noting as a demonstrated strength of how this project delegates large implementation work, but this is an operational/tooling observation, not a Scrum-kit (`process/`/`templates/`/`.claude/agents/`) insight, so it stays here rather than in `logs/process_improvement_log.md`.

## Kit-level decision
No kit edit proposed or logged this retrospective. Nothing found rises to a Scrum-kit-level insight (about `process/`, `templates/`, `.claude/agents/`, or `AGENTS.md` itself) — both findings above (GUI automation friction, the infrastructure recovery event) are team/tooling-level, not kit-level, and are handled as retrospective action items and observations instead.

## Action Items (added to Sprint 6's plan)
- [ ] Continue empirical vendor/spec/format verification before implementing render- or format-affecting code — no process change, a standing expectation now demonstrated at increasing rigor across four sprints — owner: dev-team — due: ongoing.
- [ ] Continue live actual-built-artifact verification for every GUI-facing story — owner: dev-team — due: ongoing.
- [ ] Look into hardening GUI verification automation reliability (coordinate-space/DPI-scaling and window-focus issues have each surfaced once) before or during Sprint 6's likely Address Control story, which will need more GUI automation surface than any story so far (list management, two-level selection) — owner: dev-team — due: Sprint 6, opportunistic (not a blocking gate on the story itself).
- [ ] No action needed on the transient background-agent failure beyond continuing to use the demonstrated resume-from-task-id recovery path — owner: n/a — due: n/a (already resolved cleanly).

## Deferred / lower-priority ideas (kept, not discarded)
- The pre-existing "unrelated elements coincidentally sharing X position" ambiguity in `AddressLineCollapser`'s grouping rule, flagged in `backlog/epics/08_composite_address_controls.md`'s own notes — still not required by any committed story's acceptance criteria, but will become directly relevant once "Group lines into a single, movable Address Control" (Sprint 6's likely pull) actually lands. Revisit then, not now.

## Follow-up on previous retro's actions
All three of Sprint 4's retrospective action items were carried into Sprint 5's plan and applied, confirmed via `backlog/sprints/sprint-5.md`'s Notes section and Daily Scrum Log:
1. Continue empirical vendor/spec verification before implementing render- or format-affecting code — Applied, and exceeded: the AC2 backward-compatibility proof (building and byte-comparing an actual historical binary) is more rigorous than any prior instance of this practice.
2. Continue live actual-built-artifact verification for every GUI-facing story — Applied: the mixed-content editing UI was verified against the real built `.exe` with real input injection, adapting in real time when mouse-coordinate automation proved unreliable.
3. Apply the same code-inspection-backed sizing rigor to epic 08 when it's next refined — Applied: both epic-08 stories were sized on 2026-09-29 (ahead of this sprint) with the same rigor as the throughput and rotation stories, and this sprint's actual implementation confirmed the sizing note's specific technical predictions.

No drops. This is the fourth clean full-follow-through sprint in a row.

## Anti-patterns checked
- **No follow-through on prior retro actions:** Ruled out — all three Sprint 4 actions were applied, one of them exceeded (see above).
- **Blame-focused discussion:** Ruled out. Both frictions found (GUI automation reliability, the background-agent network failure) are described as environment/tooling characteristics, never attributed to a person or treated as a mistake.
- **Hidden mini-waterfall within the sprint:** Ruled out. Both batches were designed, built, tested, and live-verified together in one pass, consistent with Sprints 1-4.
- **Avoiding an obvious known problem:** Ruled out. Both disclosed trade-offs this sprint (the bracket-syntax parsing limitation, the mouse-coordinate automation issue) were surfaced and documented rather than smoothed over.
- **Status-theater:** Ruled out. This sprint's central claim (backward compatibility) was proven with the strongest evidence standard used yet — a real historical binary byte-comparison, not a description of what should be true.
- **Review rubber-stamping (the Sprint 3-named watch item, resolved Sprint 4, checked again here rather than assumed permanently fixed):** Ruled out again — product-owner's confirmation of the production-configuration decision gives independent reasoning (deployment model, exposure risk, absence of a packaging story) rather than restating dev-team's framing.

+ 37
- 0
backlog/sprints/sprint-5.md
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 74
- 0
backlog/sprints/sprint-6-retrospective.md Целия файл

@@ -0,0 +1,74 @@
# Sprint Retrospective

**Sprint:** 6
**Date:** 2026-10-12
**Facilitated by:** `scrum-master`, per `process/05_sprint_retrospective.md`
**Inputs used:** `backlog/sprints/sprint-6.md`, `backlog/backlog.md` (Sprint 6 Review outcome), `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/08_composite_address_controls.md`, `logs/technical_debt_log.md`, `logs/impediment_log.md`, `logs/process_improvement_log.md`, and `backlog/sprints/sprint-5-retrospective.md`. No live human team was present to poll; subjective signals are synthesized from the dev-team daily-scrum notes and product-owner review notes.

## Signals

**Objective:**
- 2/2 committed stories Done and accepted at Sprint Review, 18/18 points delivered.
- Sprint goal met in full: the shipped rotation-positioning defect was fixed first, then the first grouped Address Control was delivered end-to-end.
- Test suite grew from 336 after Batch 1 to 349/349 at sprint close (101 CLI + 248 desktop/core).
- Desktop executable build passed with 0 warnings/errors.
- One High-impact technical debt item from 2026-10-09 was resolved; no new technical debt or impediment was logged.
- Live verification covered a real 392-record CLI render for both the rotated dynamic sample and a temp Address Control template, plus actual WinForms canvas and full-form bitmap smokes.

**Subjective:**
- The team kept the product-risk-first sequencing discipline: fix the already-shipped print-output defect before building the larger Address Control feature on top of the same rendering/canvas surface.
- The Sprint 5 retrospective action on GUI verification reliability paid off directly. The full-form smoke caught a real dock-order bug that hid the properties panel, and the team fixed it before the review rather than carrying it forward.
- The Address Control implementation made a sensible scope decision: one anchor with auto-spaced lines, not many independent child text elements. That kept the existing collapse rule useful and avoided a larger multi-selection/editor rewrite.
- The sprint carried a lot of UI and schema surface area, but evidence stayed concrete: tests, actual built artifacts, screenshots, and a real sample CSV/PDF path.

## What Went Well

- **Full follow-through on Sprint 5 retrospective action items**, the fifth clean full-follow-through sprint in a row. Empirical render/schema verification continued, actual built-GUI verification continued, and GUI automation reliability was deliberately hardened enough to catch a real issue.
- Product-owner review stayed independent: the accepted notes evaluate the dev-team evidence against each AC and explicitly accept the documented scope trade-offs rather than just restating implementation details.
- The team continued to separate defects, scope notes, and technical debt correctly. The rotation drift was treated as a shipped-output defect and fixed first; Address Control width/no-wrap and whole-control rotation were documented as scope notes, not disguised as delivered behavior.

## What Didn't Go Well

- A real GUI layout bug did exist until the full-form smoke (`TemplateDesignerForm` hid the properties panel because of dock order). It was caught before release, so it is not an escaped defect, but it proves the earlier GUI verification concern was not theoretical.
- The Address Control story ended up touching nearly every layer: desktop model, canvas, form, serializer, CLI parser, render engine, docs, and tests. The 13-point sizing was appropriate, but future composite-element stories should assume similar breadth unless proven otherwise by code inspection.

## Patterns / Insights (Prioritized)

1. **(Team-level)** Full-form GUI smoke tests are now worth treating as the default evidence shape for any form-layout or properties-panel story, not just canvas-only screenshots. This is the concrete lesson from the dock-order bug.
2. **(Team-level)** Product-risk-first sequencing worked: resolving a defect in already-shipped PDF positioning before taking on a large feature kept Sprint 6 from building new behavior over known-bad geometry.
3. **(Team-level)** The one-anchor composite design paid off economically. It met the operator workflow and preserved existing collapse behavior without creating a broad multi-child movement abstraction.

## Action Items (Add These To The Next Sprint's Plan)

- [ ] For every GUI-facing Sprint 7 story, include one actual built-form smoke when the story touches form layout, properties panels, or toolbar actions; canvas-only smokes are enough only for canvas-only changes — owner: dev-team — due: Sprint 7.
- [ ] Continue product-risk-first ordering during Sprint 7 planning: defects or hard product constraints before new feature polish, unless the human product owner explicitly reprioritizes — owner: product-owner/scrum-master — due: Sprint 7 planning.
- [ ] During Sprint 7 refinement, call out any composite-element story as likely cross-layer by default and size it from actual code inspection, not from the visible UI alone — owner: dev-team — due: Sprint 7 refinement.

## Deferred / Lower-Priority Ideas

- Address Control `width` is currently a designer resize box, not a render-time wrap/clip boundary. This is not a missed Sprint 6 criterion; add a future story only if operators need wrapping/clipping behavior.
- Whole-control rotation remains out of scope and can be considered later if real layouts need it.
- The old standalone same-X collapse-stack ambiguity remains low-priority. Address Controls did not make it worse; their child lines share one control X by construction.

## Follow-Up On Previous Retro's Actions

All Sprint 5 retrospective action items were applied:

1. Continue empirical vendor/spec/format verification before render- or format-affecting code — Applied. Sprint 6 included a real-DLL rotation-anchor regression, XML parser/render-order tests, and real CLI renders against the sample CSV.
2. Continue live actual-built-artifact verification for GUI-facing stories — Applied. Sprint 6 used actual WinForms canvas and full-form bitmap smokes from built assemblies, plus a clean desktop executable build.
3. Harden GUI verification automation reliability before/during the Address Control story — Applied and valuable. The full-form smoke caught the hidden properties-panel dock-order bug before review.

No drops. This is the fifth clean full-follow-through sprint in a row.

## Kit-Level Decision

No kit edit proposed. The main process learning (full-form GUI smokes for form-layout stories) is a team-level verification practice and fits inside the existing Definition of Done, which already requires visual GUI checks for layout/preview behavior. This does not reveal a gap in `AGENTS.md`, `process/`, `templates/`, or `.claude/agents/`.

## Anti-Patterns Checked

- **No follow-through on prior retro actions:** Ruled out; all Sprint 5 actions were applied.
- **Blame-focused discussion:** Ruled out; the dock-order bug and verification friction are treated as process/tooling signals, not personal failures.
- **Hidden mini-waterfall within the sprint:** Ruled out; both stories were designed, implemented, tested, documented, and live-verified inside their batches.
- **Avoiding an obvious known problem:** Ruled out; the shipped rotation defect was fixed first, and the GUI verification issue was exercised until it found a real bug.
- **Status-theater:** Ruled out; review evidence includes real tests, real builds, real renders, and actual WinForms screenshots.
- **Review rubber-stamping:** Ruled out; product-owner notes independently accepted the scope trade-offs and explicitly identified what is not included.

+ 47
- 0
backlog/sprints/sprint-6.md Целия файл

@@ -0,0 +1,47 @@
# Sprint Backlog

**Sprint:** 6 **Dates:** 2026-10-12 - 2026-10-16
**Sprint Goal:** Restore trustworthy rotated dynamic/mixed-content positioning in rendered output and the canvas, then deliver the first grouped Address Control so operators can manage address blocks as one unit.

## Committed Items

| Story | Size | Status | Tasks |
|---|---|---|---|
| Keep rotated dynamic and mixed-content fields positioned consistently across records | 5 points | Done | - [x] Decide and document the canonical pivot rule for variable-width rotated content, with the expected lower-risk path being fixed-anchor rotation for elements containing at least one field run <br> - [x] Update the CLI render geometry so dynamic and mixed-content rotated `TextDraw`s no longer compute a corrected anchor from each record's resolved text width, while rotated static elements keep the Sprint 4 center-pivot behavior unchanged <br> - [x] Update desktop canvas drawing, hit-testing, and rotate-handle geometry so the preview uses the same pivot rule the CLI uses for dynamic/mixed elements, restoring canvas/render parity <br> - [x] Add unit tests for the pivot decision and regression tests proving two records with different resolved lengths render at the same position for the same rotated dynamic element <br> - [x] Extend the existing real-DLL rotation verification where needed, and live-verify against a real CSV/template that the rendered PDF no longer drifts record-to-record |
| Group lines into a single, movable Address Control | 13 points | Done | - [x] Design the Address Control model on both desktop and CLI sides: one control anchor, ordered line list, per-line mixed-content runs, per-line font/content/collapse settings, and automatic line spacing <br> - [x] Add designer UI to create an Address Control, manage its line list, and add/remove/reorder lines without assigning each line an independent canvas position <br> - [x] Implement two-level canvas interaction: select/move/resize the whole control as one unit while allowing an individual line's content and collapse setting to be edited <br> - [x] Persist and reload the full Address Control structure in XML, documenting the new container shape in `TEMPLATE_FORMAT.md`; existing text-element templates must remain unchanged <br> - [x] Expand the CLI parser/render pipeline so one Address Control becomes ordered text draws with the same mixed-content resolution and collapse behavior shown in the designer <br> - [x] Verify Address Control collapse behavior reuses or preserves the existing per-line collapse semantics, and log any remaining same-X grouping ambiguity as future technical debt only if it remains relevant after implementation <br> - [x] Unit tests: model, line-list operations, selection/movement behavior, save/reopen, CLI parse/render expansion, per-line default-collapse behavior, and preview/render parity <br> - [x] Live actual-built-artifact verification: create a custom Address Control, add/reorder mixed-content lines, move it as a unit, save/reopen, and render against the real sample CSV |

## Notes
- Capacity signal: recent completed totals are **20, 19, 18, 18, and 15 points**. Sprint 5's 15-point total was an intentional under-commit around a high-risk 13-point story, not a failed capacity signal. This plan commits **18 points**, the proven low end from Sprints 3-4 and below the older 20-point ceiling.
- Planning rationale: the rotation-positioning story is pulled first because it fixes a High-impact defect in already-shipped PDF output and an already-shipped canvas/render parity acceptance criterion. The Address Control story is pulled second because its dependency ("Mix static text and CSV fields within a single text element") is Done and it remains the next major feature slice.
- Sprint 5 retrospective action items carried in: continue empirical vendor/spec/format verification before render- or format-affecting changes; continue live actual-built-artifact verification for GUI-facing stories; and deliberately harden GUI verification automation reliability before or during the Address Control story, since coordinate-space/DPI and window-focus issues have each surfaced once in the last two sprints.
- Impediments: template asset path strategy (absolute vs. relative) and UNC timeout/retry behavior remain open in `logs/impediment_log.md`. Neither blocks this sprint's committed items.
- Carried over from previous sprint: none (Sprint 5 completed both committed stories).
- Not committed: the 1,000,000-record PDF file-size mitigation remains open technical debt but does not block this sprint's committed scope; no committed story renders near that ceiling.

## Execution Order

Sequenced by product risk first, then feature dependency/surface area.

| Batch | Story | Why it's gated here |
|---|---|---|
| 1 | Keep rotated dynamic and mixed-content fields positioned consistently across records | Highest product risk because it fixes already-shipped print-output correctness and restores canvas/render parity before more composite text work builds on the same geometry. |
| 2 | Group lines into a single, movable Address Control | Depends on Sprint 5's mixed-content model, which is Done. Pull second so the team starts this larger GUI-heavy feature after the shipped-output defect is under control. |

## Daily Scrum Log

| Day | Date | Completed | Planned | Blocked/At risk |
|---|---|---|---|---|
| 1 | 2026-10-12 | Sprint 6 planned. Backlog refinement completed for the rotation-positioning defect: story marked Ready and sized at 5 points after code inspection. | Begin Batch 1. Decide the canonical pivot rule, then update the render/canvas geometry with regression coverage before touching Address Control work. | GUI automation reliability needs deliberate attention before/during Batch 2; not blocking Batch 1. |
| 1 | 2026-10-12 | **Batch 1** ("Keep rotated dynamic and mixed-content fields positioned consistently across records", 5 points) done, all 4 ACs met with one explicitly chosen scope trade-off. Pivot decision: any rotated element containing at least one field run now rotates around its fixed authored `(x, y)` anchor; purely static rotated text keeps the Sprint 4 bounding-box-center correction unchanged. CLI implementation: `RenderEngine` marks dynamic/mixed `TextDraw`s with `UsesFixedRotationPivot`, and `DebenuPdfRenderer` uses the authored anchor directly for those draws instead of calling `GetTextWidth(draw.Text)` and recomputing a center from each record's resolved value. Canvas implementation: `CanvasElementEditor` and `TemplateCanvasControl` use the same pivot rule for draw transforms, hit-testing, handle positioning, and drag-to-angle math, so variable-width content no longer gets canvas geometry based on a design-time placeholder center. Documentation: `TEMPLATE_FORMAT.md` now states the two pivot rules explicitly. Tests: full suite passes at **336/336** (`dotnet test code/EnvelopeRenderer.slnx`): 94 CLI tests and 242 desktop tests. New coverage includes render-engine fixed-pivot propagation for rotated dynamic and mixed elements, static rotated text preserving center-pivot mode, dynamic canvas hit-testing/handle/drag geometry around the fixed anchor, and a real-DLL regression (`DebenuPdfRendererRotationTests.AddPage_FixedRotationPivot_UsesTheAuthoredAnchorForDifferentTextWidths`) proving two different text widths produce rotated transforms with the same authored anchor. Live artifact checks: built the desktop executable successfully (`dotnet build code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj`, 0 warnings/errors); rendered the existing rotated dynamic sample template (`sample-envelope-template2.xml`) through the built CLI against the real 392-record sample CSV, exit 0 with `PROGRESS complete ... completed=392` and a 2,375,011-byte PDF; rendered the actual WinForms `TemplateCanvasControl` from the built assemblies to `sprint6-canvas-rotated-dynamic-smoke.png` and visually confirmed the page, static label, and rotated `{Full Name}` dynamic element all drew correctly. The trade-off: variable-width dynamic/mixed content no longer uses exact bounding-box-center rotation, by design, because that center is not stable across records; fixed-anchor rotation is the stable, schema-free rule chosen for print alignment and preview/render parity. The High-impact technical debt item opened on 2026-10-09 is resolved. | Begin Batch 2 ("Group lines into a single, movable Address Control"). Start with the model/schema decision and the GUI automation reliability hardening action from the Sprint 5 retrospective before driving the larger canvas/list-management UI. | None for Batch 1. Batch 2 remains GUI-heavy; automation reliability is an active risk to manage, not a blocker. |
| 1 | 2026-10-12 | **Batch 2** ("Group lines into a single, movable Address Control", 13 points) done, all 6 ACs met. Implemented a first composite element type with `AddressControlLayout`/`AddressControlLineLayout` in the designer and `TemplateAddressControl`/`TemplateAddressControlLine` in the CLI parser model: one X/Y anchor, width, automatic line baselines from each line's font size and `lineSpacing`, ordered per-line mixed-content runs, per-line font/color/collapse settings, and default-on collapse inside controls. Designer UI now creates an Address Control, lists its lines, edits the selected line with the existing `{Column}` mixed-content convention, supports add/remove/reorder, moves the whole control as one selection, and resizes the group from a right-edge canvas handle or the width property. Persistence uses a new `<addressControl>` container with `<line>` children; standalone text templates remain on their existing legacy shapes. CLI parsing/rendering expands one control into ordered `TextDraw`s while preserving XML render order across ordinary `<text>` and `<addressControl>` nodes. Collapse behavior reuses the existing line-collapser semantics: every child line shares the control's X by construction, so control grouping and the same-X stack rule agree; no new same-X technical debt was introduced. Docs: `TEMPLATE_FORMAT.md` now documents the container, line content forms, default-on collapse, line-spacing rule, and current no-wrap/no-clip width behavior. Tests: full suite passes at **349/349** (`dotnet test code/EnvelopeRenderer.slnx`): 101 CLI tests and 248 desktop/core tests. Desktop executable builds cleanly (`dotnet build code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj`, 0 warnings/errors). Live artifact checks: rendered a temp `<addressControl>` template through the built CLI against the real 392-record sample CSV, exit 0 with `PROGRESS complete ... completed=392`, 2,377,358-byte PDF, and grep-confirmed first-page strings including `WILLIAM EDWARD ZIMMERMAN JR`, `4900 BC/EJ RD`, and `EAST JORDAN, MI 49727-9765`; rendered the actual WinForms canvas from built assemblies to `sprint6-address-control-canvas-smoke-resolved.png`, visually confirming resolved sample text, blank-line collapse, selection border, and resize handle; rendered the actual `TemplateDesignerForm` through a live WinForms show/layout cycle to `sprint6-address-control-form-smoke-fixed.png`, visually confirming the Add Address Control toolbar action, right properties panel, content/width/line-list rows, and line add/remove/reorder controls. One verification improvement came directly from the Sprint 5 retrospective action: the full-form screenshot caught a real dock-order bug hiding the properties panel, which was fixed before completion. | Move Sprint 6 to product-owner Sprint Review. | None. |

## Sprint Review Outcome

**Date:** 2026-10-12
**Product-owner verdict:** Sprint goal met in full.

- "Keep rotated dynamic and mixed-content fields positioned consistently across records" is accepted. The user-reported shipped-output defect is fixed with a stable authored-anchor pivot for dynamic/mixed rotated text, static rotated text remains on the prior center-pivot path, and canvas/render parity for the touched scenario is restored.
- "Group lines into a single, movable Address Control" is accepted. The delivered control covers create, add/remove/reorder lines, per-line mixed-content editing, default-on/toggleable collapse, whole-control move/resize, XML save/reopen, and direct CLI rendering.
- Verification baseline at review: **349/349 tests passing** (`dotnet test code/EnvelopeRenderer.slnx`: 101 CLI, 248 desktop/core); desktop executable builds with **0 warnings/errors**; live checks included a real 392-record CLI render and actual WinForms canvas/form bitmap smokes.
- No new backlog item is required from the review. Documented scope notes: Address Control `width` is a designer resize box today, not render-time wrapping/clipping; whole-control rotation remains out of scope and only a future candidate.
- Retrospective carry-in: the Sprint 5 action to harden GUI verification automation produced value immediately by catching the hidden properties-panel dock-order bug before release.

+ 71
- 0
backlog/sprints/sprint-7-retrospective.md Целия файл

@@ -0,0 +1,71 @@
# Sprint Retrospective

**Sprint:** 7
**Date:** 2026-10-19
**Facilitated by:** `scrum-master`, per `process/05_sprint_retrospective.md`
**Inputs used:** `backlog/sprints/sprint-7.md` (Daily Scrum Log + Execution Order), `backlog/backlog.md` (Sprint 7 planning, backlog refinement, and Review outcome notes), `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`, `logs/technical_debt_log.md`, `logs/impediment_log.md`, `logs/process_improvement_log.md`, `backlog/sprints/sprint-6-retrospective.md` (for follow-through check), `backlog/sprints/sprint-3-retrospective.md` and `sprint-5-retrospective.md` (for prior watch-item context). No live human team to poll in real time; subjective signals are synthesized from dev-team's daily-scrum notes and product-owner's independent review notes, including a disclosed tooling limitation on the review side (see below). An independent `dotnet test` run by the top-level session, outside this retrospective, confirmed 400/400.

## Signals

**Objective:**
- 3/3 committed stories Done (18/18 points), sprint goal assessed "met in full" at Sprint Review (`backlog/backlog.md`, Sprint 7 Review outcome).
- Test suite grew from 349/349 (Sprint 6 close) to 400/400 (101 CLI + 299 desktop) — confirmed twice independently: once by product-owner's static `[Fact]`/`[Theory]`/`[InlineData]` count during review (no shell access that session), and once by a live `dotnet test` run performed afterward by the top-level session. Both landed on exactly 400/400 with no discrepancy.
- Zero new impediments; the two pre-existing open impediments (asset path strategy, UNC timeout/retry) remain non-blocking and untouched, as expected since neither epic 4 nor epic 6 touches image assets or network paths.
- One new Low-impact technical debt item logged and reviewed (Address Control lines visually overlapping at small font sizes, canvas/preview-only, confirmed by product-owner not to touch the real `DebenuPdfRenderer`/`RenderEngine` path) — correctly scoped as cosmetic and non-blocking, not swept under the rug.
- Live verification: a real built-`.exe` reflection harness drove the actual `TemplateDesignerForm`/`TemplateCanvasControl`/`TemplatePreviewControl` for Batches 1-2 (full-form screenshots proving no dock/clipping regression, real rotated-pivot stability across differently-sized names, real out-of-range record navigation against the 392-record sample CSV), and a canvas-only bitmap smoke for Batch 3 (continuous mid-drag grid snapping, confirmed via intermediate-vs-final position assertions).

**Subjective:**
- Dev-team deliberately designed the new preview panel's layout with a deterministic two-column `TableLayoutPanel` specifically to avoid repeating the Sprint 6 dock-order bug class — a proactive application of a lesson learned, not just a reactive fix.
- Record navigation reused the CLI's already-proven `CsvRecordSource` streaming pattern (`CsvRecordNavigator`) rather than building new indexed/random-access infrastructure, consistent with this team's standing "reuse over rebuild" discipline.
- Product-owner's Sprint Review this sprint lacked shell/build tool access. Rather than silently presenting a lighter check as full re-verification, product-owner explicitly disclosed the substitution (an independent `[Fact]`/`[Theory]`/`[InlineData]` count in place of `dotnet test`, and code-level reading in place of a live built-`.exe` re-run of dev-team's screenshots) and flagged it as "a review-process limitation ... not a defect in the sprint's delivery." The top-level session's own `dotnet test` run afterward matched exactly, so the substitution did not in fact hide any discrepancy this time.

## What Went Well

- **Full follow-through on all three Sprint 6 retrospective action items**, discussed in detail below — the sixth clean full-follow-through sprint in a row.
- The differentiated built-form-vs-canvas-only smoke rule (Sprint 6's carry-in) was applied with real judgment, not applied uniformly by rote: full-form smokes where form layout was actually touched (Batches 1-2), a lighter canvas-only smoke where it genuinely wasn't (Batch 3) — and the story notes state the reasoning for each choice explicitly rather than leaving it implicit.
- Product-owner's honest disclosure of a tooling limitation (no shell/build access) is a repeat of this team's established "surface the gap, don't smooth it over" norm, seen previously with dev-team's own self-reported gaps (Sprints 1, 3, 5).

## What Didn't Go Well

- Batch 2 ("Jump to a specific record number") added a new toolbar affordance (`NumericUpDown` + "Go" button) to the same `TemplateDesignerForm` surface Batch 1 had just full-form-screenshotted, but verification for Batch 2 relied on the same harness/session rather than capturing a fresh full-form screenshot after the new control was added. No layout regression resulted (the deterministic `TableLayoutPanel` used for Batch 1 held), but this is a narrower instance of the same risk class the built-form-smoke rule exists to catch — worth naming rather than assuming the Batch 1 screenshot fully covers a subsequently-added control.
- Product-owner's Sprint 7 review ran without shell/build tool access, substituting a static test-attribute count and code reading for an actual `dotnet test` run and a live re-run of dev-team's built-`.exe` screenshots. This was disclosed honestly and turned out to match exactly when independently re-run — but it is a real gap in verification depth for that session, not merely a stylistic difference from prior reviews.

## Patterns / Insights (Prioritized)

1. **(Team-level)** The built-form-vs-canvas-only smoke rule from Sprint 6 is now demonstrated working as a genuine judgment call, not a blanket policy — worth continuing to apply story-by-story rather than defaulting to either extreme.
2. **(Team-level, minor)** A new toolbar/panel control added to an already-screenshotted form should get its own fresh full-form check, not ride on an earlier batch's screenshot of the same form — a narrow refinement of the existing rule, not a new rule.
3. **(Process-level, new this sprint)** Product-owner review capability (shell/build tool access) varied session-to-session and directly affected verification depth this sprint. This is a **different category** from the Sprint 3 PO-review-independence watch item (which was about whether PO forms its own judgment versus leaning on dev-team's pre-written narrative — a judgment-independence question, resolved structurally at Sprint 4). This sprint's gap is about PO's *tooling access*, not its *independence of judgment* — PO still read the real code and formed independent conclusions; it just couldn't execute a live build/test pass. First occurrence of this specific shape; logged as a watch item, not conflated with the earlier, already-closed item.

## Action Items (Add These To Sprint 8's Plan)

- [ ] For Sprint 8's epic 6 multi-select/align pair: apply the built-form-vs-canvas-only smoke rule explicitly per story. "Select multiple elements at once on the canvas" is likely canvas-only (canvas-only smoke may suffice); "Align and distribute multiple elements" may add new toolbar/menu affordances (alignment buttons) and should get a full built-form smoke if it does — decide per the actual UI surface touched, not by analogy to Sprint 7's snap-to-grid story — owner: dev-team — due: Sprint 8.
- [ ] When a later batch in the same sprint adds a new control to a form surface an earlier batch already full-form-screenshotted, capture a fresh full-form screenshot after that addition rather than relying on the earlier batch's screenshot to cover it — owner: dev-team — due: Sprint 8, ongoing.
- [ ] If product-owner's Sprint 8 review session again lacks live shell/build access, disclose it explicitly (as done this sprint) and arrange an independent `dotnet test` / built-`.exe` cross-check before treating verification as complete — owner: product-owner / scrum-master — due: Sprint 8 review.

## Deferred / Lower-Priority Ideas

- Address Control line-overlap-at-small-font-sizes (Low-impact, canvas/preview cosmetic only) — revisit only if an operator reports it as more than cosmetic; not a Sprint 8 candidate on its own.
- The still-open product question on "Warn on text overflow before render" (epic 4) remains parked for the human product owner to resolve whenever convenient; not blocking Sprint 8.

## Follow-Up On Previous Retro's Actions

All three of Sprint 6's retrospective action items were carried into Sprint 7's plan. Checked against `backlog/sprints/sprint-7.md` and `backlog/backlog.md`'s Sprint 7 planning/review outcomes:

1. **Built-form smoke for GUI-facing stories touching form layout/properties/toolbar actions** — Applied, and correctly, not diluted. The action item's own original wording distinguished full-form smokes (for form-layout/properties/toolbar stories) from canvas-only smokes (for canvas-only changes) — this sprint's two epic-4 stories (which genuinely touch `TemplateDesignerForm` layout) got a real built-`.exe` reflection harness with full-form screenshots explicitly checked for dock/clipping regressions, while "Snap elements to grid and guides" (which touches only `CanvasElementEditor`/`TemplateCanvasControl` mouse handlers, no form/panel/toolbar surface) correctly got a canvas-only smoke per its own story notes. Judged as genuine follow-through: the differentiation is exactly what the rule called for, not a shortcut around it. One minor gap noted above (Batch 2's new toolbar control riding on Batch 1's screenshot) keeps this from being a perfect follow-through, but the substance of the rule was honored.
2. **Product-risk-first ordering** — Held. Confirmed at both planning (`backlog/backlog.md`'s Sprint 7 planning outcome: "the epic 4 pair is pulled first because it closes a known-shape defect class ... 'Snap elements to grid and guides' is pulled last as the lowest-risk item") and in `backlog/sprints/sprint-7.md`'s Execution Order table, which sequences by product risk first, then dependency.
3. **Cross-layer code-inspection sizing for composite/multi-part stories** — Confirmed genuinely held, not just asserted. The 2026-10-16 backlog refinement outcome cites specific real code paths inspected for both stories: `TemplateCanvasControl`, `AddressBlockPreviewCalculator`, `RenderEngine`, `TemplateDesignerForm` for the preview story, and `CanvasElementEditor.DragTo`/`BeginDrag`/`EndDrag` plus `TemplateCanvasControl`'s paint routine for snap-to-grid (`backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`, both dated 2026-10-16). These are specific method/class names tied to the actual sizing decision, not a surface-level UI description — this is real evidence of the practice, not a restated claim.

No drops. This is the sixth clean full-follow-through sprint in a row, with one minor, honestly-named nuance on item 1 rather than a clean pass claimed where it wasn't fully earned.

## Kit-Level Decision

No kit edit proposed. The PO-review tooling-access gap (item 3 in Patterns/Insights) is a first-occurrence, non-severe issue — it did not produce a wrong verdict (the independent `dotnet test` cross-check matched exactly), and it was disclosed rather than hidden. Per `AGENTS.md`'s bar (a recurring 2+-occurrence pattern, or one occurrence severe enough to have visibly broken the sprint), this does not qualify for a kit edit. Logged to `logs/process_improvement_log.md` as a new "Watching" entry, explicitly distinguished from the Sprint 3 PO-review-independence item (already closed, a different category — judgment independence, not tooling access) rather than folded into or reopening that closed entry.

## Anti-Patterns Checked

- **No follow-through on prior retro actions:** Ruled out; all three Sprint 6 actions were applied, with one honestly-named minor nuance on item 1 rather than an overstated clean pass.
- **Blame-focused discussion:** Ruled out; the toolbar-screenshot gap and the PO tooling-access gap are both described as process/environment characteristics, not attributed to a person.
- **Hidden mini-waterfall within the sprint:** Ruled out; all three batches were designed, implemented, tested, documented, and live-verified within their own batch, consistent with every prior sprint.
- **Avoiding an obvious known problem:** Ruled out; the still-undefined "text overflow" product question was correctly left Not Ready and explicitly flagged rather than guessed at or silently dropped, and the new Address Control overlap debt was logged rather than smoothed over.
- **Status-theater:** Ruled out; evidence is concrete and independently checkable (exact test counts confirmed twice by two different methods, real screenshots, exact grid-snap coordinate values, an out-of-range navigation message matched to source).
- **Review rubber-stamping (Sprint 3-named, resolved Sprint 4, checked again here):** Ruled out. Despite lacking shell access, product-owner's Sprint 7 review read the actual changed source files directly (`TextResolver.cs`, `RotationPivotCalculator.cs`, `TemplatePreviewBuilder.cs`, etc.), traced the real auto-refresh wiring path rather than trusting the claim, and independently confirmed the technical debt item's root cause and scope. The tooling-access gap (item 3 above) is a distinct, narrower issue from rubber-stamping and is tracked separately, not conflated with it.

+ 50
- 0
backlog/sprints/sprint-7.md Целия файл

@@ -0,0 +1,50 @@
# Sprint Backlog

**Sprint:** 7 **Dates:** 2026-10-19 - 2026-10-23
**Sprint Goal:** Give operators an accurate, record-specific layout preview with record navigation — built on one shared text-resolution routine across CLI render, canvas, and preview, closing the canvas/render-drift risk class that caused the 2026-10-09 rotation defect — and round out capacity with a self-contained grid/guide snapping tool for faster manual layout work.

## Committed Items

| Story | Size | Status | Tasks |
|---|---|---|---|
| Render an accurate, record-specific preview of the current template | 8 points | Done | - [x] Extract `RenderEngine.ResolveText`'s per-run resolution logic (or an equivalent) into a routine callable from `EnvelopeRenderer.Desktop.Core`, so CLI render, canvas collapse/mapping state, and the new preview panel all resolve text through the same code, not three independent implementations <br> - [x] Add a new, dedicated preview panel to `TemplateDesignerForm` (separate from the editable design canvas, which keeps showing bracket tokens) <br> - [x] Wire the preview panel to draw every standalone text element and every Address Control line using one selected CSV record's real resolved values via the shared resolution routine <br> - [x] Reproduce current shipped rendering rules exactly in the preview: address-line collapse (`AddressLineCollapser`), per-run mixed static/field content, and both rotation pivot rules (fixed authored anchor for dynamic/mixed elements, bounding-box center for static elements) <br> - [x] Auto-refresh the preview after a layout edit, a field remapping, or a different record being selected <br> - [x] Show a clear, specific message in the panel (not a blank/stale preview) when the selected record can't be resolved, e.g. a bound column missing from the loaded CSV <br> - [x] Unit tests: shared resolution routine correctness, preview panel resolution/refresh behavior, rotation-pivot and collapse parity with existing render/canvas tests <br> - [x] Live actual built-form smoke (per Sprint 6 retrospective action) showing the real preview panel rendering a real record's resolved text in the built `.exe` |
| Jump to a specific record number | 5 points | Done | - [x] Add a UI control for the operator to enter a record number and navigate to it <br> - [x] Integrate a full-file forward-only streaming reader (mirroring the CLI's proven `CsvRecordSource` pattern) via skip/take to fetch the requested record, replacing the bounded 20-row `CsvPreviewLoader` for this feature — no new indexed/random-access reader <br> - [x] Wire the fetched record into the preview panel from the prior story so navigation updates what's shown <br> - [x] Handle invalid or out-of-range record numbers with a clear operator-facing message <br> - [x] Unit tests, including navigation against a real CSV larger than 20 rows to prove the full-file reader (not the sample loader) is actually used <br> - [x] Live actual built-form smoke of entering a record number and seeing the preview panel update in the built `.exe` |
| Snap elements to grid and guides | 5 points | Done | - [x] Add a toggleable snap-to-grid state and grid increment to the canvas editing path <br> - [x] Update `CanvasElementEditor`'s drag (and resize) math to round position/edges to the nearest grid increment continuously during the gesture, not only on release <br> - [x] Paint grid lines/guides in `TemplateCanvasControl` while snap is enabled so alignment is visible <br> - [x] Confirm snapped positions persist and reopen as ordinary `X`/`Y` values with no template format change <br> - [x] Unit tests for the snap math on drag and resize <br> - [x] Canvas-only bitmap smoke against the built app (sufficient per this story's own scope note — no form-layout/properties-panel/toolbar surface is touched, so the heavier full-form smoke is not required) |

## Notes
- Capacity signal: completed totals across Sprints 1-6 are **20, 19, 18, 18, 15, 18** — six data points now, a stable 18-20 point range. Sprint 5's 15 was a deliberate under-commit around one large, high-structural-risk story (confirmed at that sprint's planning and not a capacity miss), and Sprint 6 landed exactly on an 18-point commitment with zero scope change. This plan commits **18 points**, the same proven low end used for Sprints 3, 4, and 6.
- Planning rationale — why the full epic 4 pair plus one epic 6 stretch item, not just the pair alone: the epic 4 pair (13 points) is clearly under the 18-20 range on its own, and capacity would go unused without a second pull. "Snap elements to grid and guides" (epic 6, 5 points) is deliberately chosen as that second pull over any other Ready epic 6 story: it has zero dependencies, is contained to the existing `CanvasElementEditor`/`TemplateCanvasControl` pair with no CLI/XML surface, and per its own story notes needs only a canvas-only smoke rather than the heavier full-form smoke — the lowest-risk way to reach 18 points without threatening "don't start what you can't finish." This matches product-owner's own stretch recommendation from the 2026-10-16 refinement outcome.
- Why not more from epic 6 instead: "Select multiple elements at once on the canvas" (5 pts) and "Align and distribute multiple elements" (5 pts, hard-depends on the former) only deliver real operator value together — splitting them across sprints repeats the exact anti-pattern epic 8 deliberately avoided in Sprints 5-6. Pulling both alongside the epic 4 pair would total 23 points, over the proven range. "Undo and redo layout changes" (13 pts) is explicitly flagged by dev-team as the backlog's highest-uncertainty estimate with no prior undo/redo-shaped story to size against, and its own sizing note recommends against pairing it with another large/novel story in the same sprint (the epic 4 preview story is itself non-trivial, touching a genuinely new drawing surface and a cross-cutting resolution-routine extraction) — deferring it whole to a future sprint, ideally with its own short feasibility check on document-cloning first, rather than starting it under time pressure this sprint.
- Product-risk-first ordering (Sprint 6 retrospective carry-in #2, reaffirmed here): the epic 4 pair is pulled first and the epic 6 item second. The preview story exists specifically to close a known-shape defect class (divergent per-record text-resolution paths) that already caused one shipped-output defect (the 2026-10-09 rotation-positioning bug) — this outweighs epic 6's plain backlog-order position below epic 4.
- Sprint 6 retrospective carry-in #1 (built-form smoke for GUI-facing stories) is written directly into each committed story's tasks above: both epic 4 stories touch `TemplateDesignerForm` layout and require a full built-form smoke; "Snap elements to grid and guides" touches canvas interaction only, so its own story notes call for a canvas-only smoke instead — this distinction is intentional, not a shortcut.
- Sprint 6 retrospective carry-in #3 (size composite/multi-part element stories from real cross-layer code inspection): verified as already held for this sprint's candidates rather than assumed. Epic 4's preview story was sized "via code inspection of `TemplateCanvasControl`, `AddressBlockPreviewCalculator`, `RenderEngine`, `TemplateDesignerForm`" (2026-10-16); "Snap elements to grid and guides" was sized "via inspection of `CanvasElementEditor.DragTo`/`BeginDrag`/`EndDrag` and `TemplateCanvasControl`'s paint routine" (2026-10-16) — both cite specific real code paths, not visible-UI guesses, so no re-sizing was needed at planning time.
- Impediments: template asset path strategy (absolute vs. relative) and UNC timeout/retry behavior remain open in `logs/impediment_log.md`. Neither blocks this sprint's committed items — none touch image assets or network paths.
- Carried over from previous sprint: none (Sprint 6 completed both committed stories, 18/18 points).
- Not committed: "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" (10 pts combined, epic 6 — natural pull for Sprint 8, kept as a pair per the reasoning above); "Undo and redo layout changes" (13 pts, epic 6 — recommend a short document-cloning feasibility check before it's committed to any sprint, given its flagged uncertainty); "Warn on text overflow before render" (epic 4) remains Not Ready, blocked on an open product question for the human product owner about what "overflow" means with no element having an enforced bounding width today — not blocking this sprint.

## Execution Order

Sequenced by product risk first, then feature dependency.

| Batch | Story | Why it's gated here |
|---|---|---|
| 1 | Render an accurate, record-specific preview of the current template | Highest product risk in this sprint — closes the divergent text-resolution-path defect class that already caused a shipped-output bug (2026-10-09 rotation defect). No dependency on any other committed story, and the story below depends on it. |
| 2 | Jump to a specific record number | Depends on Batch 1's preview panel to have somewhere to show the navigated-to record. |
| 3 | Snap elements to grid and guides | No dependency on Batches 1-2 or vice versa. Pulled last as the self-contained, lowest-risk stretch item that rounds capacity out to the proven 18-point low end; if the sprint runs short on time, this is the item to flag as at-risk first, not either epic 4 story. |

## Daily Scrum Log

| Day | Date | Completed | Planned | Blocked/At risk |
|---|---|---|---|---|
| 1 | 2026-10-19 | **Batch 1** ("Render an accurate, record-specific preview of the current template", 8 points) done, all 6 ACs met. Extracted the shared per-run resolution routine into `EnvelopeRenderer.Desktop.Core.Design.TextResolver` (`TryResolve`/`ComputeUnmappedFlags`) and refactored `AddressBlockPreviewCalculator` and `TemplateCanvasControl.ResolveAddressLinePreviewText` to call it instead of each keeping its own near-duplicate — one resolution implementation inside Desktop.Core instead of two, mirrored by test-data parity against `RenderEngineTests`' CLI scenarios (the CLI keeps its own independent `RenderEngine.ResolveText`, per this project's deliberate desktop/CLI project split documented in `EnvelopeRenderer.Desktop.csproj`). Also extracted the previously-duplicated rotation-pivot rule (static bounding-box-center vs. dynamic/mixed fixed-anchor) out of both `CanvasElementEditor` and `TemplateCanvasControl` into one shared `RotationPivotCalculator`. Built `TemplatePreviewBuilder` (framework-free, Desktop.Core) to produce ordered, already-resolved `PreviewTextDraw`s reproducing address-line collapse, mixed-content concatenation, both rotation pivot rules, and Address Control line layout/spacing — the desktop-model mirror of `RenderEngine.Render`/`BuildDraws`. Added a new read-only `TemplatePreviewControl` (WinForms) wired into `TemplateDesignerForm` beside (not replacing) the existing editable design canvas, laid out via a deterministic two-column `TableLayoutPanel` (preview area + properties panel) rather than ambiguous multi-Dock-Right ordering, specifically to avoid repeating the Sprint 6 dock-order bug class. The preview auto-refreshes via `Invalidate()` on every `TemplateCanvasControl.ElementsChanged` (layout edits/remaps) since it re-resolves against the live shared document on every repaint; an unresolvable-column or no-selected-record case shows a specific red message instead of a blank/stale preview. Tests: full suite **383/383** passing (`dotnet test code/EnvelopeRenderer.slnx`: 101 CLI, 282 desktop — up from 349/349), with new coverage in `TextResolverTests`, `RotationPivotCalculatorTests`, and `TemplatePreviewBuilderTests` (the latter deliberately mirrors `RenderEngineTests`' scenario names/data: mixed-content concatenation, collapsible-blank-line shift, rotated-dynamic fixed-anchor stability across different resolved text lengths, Address Control expansion/collapse/line-spacing, render/z-order, unknown-column failure). Live verification: built the desktop `.exe` (0 warnings/errors) and drove the real built `TemplateDesignerForm`/`TemplateCanvasControl`/`TemplatePreviewControl` via a throwaway reflection-driven harness project referencing the built assemblies (calling the same internal `LoadCsvFromPath`/`NavigateToRecord` entry points and public `TemplateLayoutXmlSerializer`/`AddStaticTextElement` APIs the real UI uses) — full-form screenshot confirmed no dock/clipping regression (properties panel, record-navigation toolbar, and preview panel all fully visible); preview screenshots against the real 392-record sample CSV and `sample-envelope-template3.xml` (Address Control) showed correctly resolved names/addresses with the blank `Address 3` line correctly collapsed; preview screenshots against `sample-envelope-template2.xml` (rotated dynamic `{Full Name}`) showed the rotated text's anchor point staying fixed in the same screen position across two records with very different name lengths ("WILLIAM EDWARD ZIMMERMAN JR" vs. "MURIEL LYN ZIMMERMAN") while the adjacent, still-token-showing design canvas visibly differed from the resolved preview — direct visual proof of this story's core fix; a synthetic bad-column template produced the exact expected "Cannot preview — the template references CSV column 'NoSuchColumn'..." message instead of a blank panel. One new, pre-existing (not a regression) cosmetic issue noticed during this verification — Address Control lines visually overlapping in both the canvas and the new preview at small font sizes — logged to `logs/technical_debt_log.md` (Low impact, canvas/preview-only, does not affect the real PDF render path). | Begin Batch 2 ("Jump to a specific record number"), which builds directly on this batch's preview panel and CSV-loaded-headers state. | None. |
| 1 | 2026-10-19 | **Batch 2** ("Jump to a specific record number", 5 points) done, all 4 ACs met. Added `EnvelopeRenderer.Desktop.Core.Csv.CsvRecordNavigator.TryReadRecord(path, recordNumber)` — a forward-only streaming reader mirroring the CLI's proven `CsvRecordSource` pattern/CsvHelper configuration (not the bounded 20-row `CsvPreviewLoader`, and not a new indexed/random-access reader, per the story's own scope note); returns a clear operator-facing error for an out-of-range record, a non-positive record number, a missing file, or a malformed CSV, without ever throwing. Wired a "Record #" `NumericUpDown` + "Go" button + status label into the new preview area (`TemplateDesignerForm.BuildPreviewArea`/`NavigateToRecord`); loading a CSV now automatically navigates to record 1 through this same full-file path (replacing the prior hardcoded "first bounded-sample row" used only for the canvas's own separate collapse/mapping preview, which is unchanged). An out-of-range or unreadable navigation leaves the previously-shown preview untouched and surfaces the specific error in the status label, rather than blanking a valid preview. Tests: `CsvRecordNavigatorTests` covers first-record, case-insensitive headers, out-of-range, non-positive input, missing file, no-path, and — the key proof this uses the full-file reader, not the bounded sample — a record beyond row 20. Full suite still **383/383** (this story's coverage is folded into the same run as Batch 1, both landed together). Live verification (same harness/session as Batch 1): navigated to record 2, to record 392 (the last record — proving full-file reads far past the 20-row bound), and to an out-of-range record 999999 (status correctly read "Record 999999 is out of range — the CSV has 392 data row(s)." while the preview panel correctly kept showing record 392's resolved text rather than going blank), then back to record 1 via the same `NumericUpDown`/"Go" path an operator would use. | Begin Batch 3 ("Snap elements to grid and guides") — no dependency on Batches 1-2. | None. |
| 1 | 2026-10-19 | **Batch 3** ("Snap elements to grid and guides", 5 points) done, all 4 ACs met, bringing Sprint 7 to 18/18 points Done. Added `EnvelopeRenderer.Desktop.Core.Design.GridSnapper.Snap` (pure rounding-to-nearest-increment helper) and `CanvasElementEditor.SnapToGridEnabled`/`GridSizePoints` (defaulting to off/10pt, so no existing drag behavior changes unless an operator opts in); `DragTo` now applies snapping on every call, i.e. continuously through the drag gesture rather than only at `EndDrag`. `TemplateCanvasControl` mirrors the same toggle/grid size for Address Control move and resize (handled directly in that control's mouse handlers, not through `CanvasElementEditor`) and paints light dotted grid lines across the page whenever snap is enabled, so the alignment grid is visible, not just felt. Snapped values are still plain `X`/`Y`/`Width` doubles — no template/XML format change, confirmed by inspection of `TemplateLayoutXmlSerializer` (untouched). Added a "Snap to grid" checkbox + grid-size (pt) `NumericUpDown` to the designer's canvas-settings toolbar. Tests: `GridSnapperTests` (rounding including banker's-rounding-at-exact-midpoint, non-positive grid size treated as no-op, fractional grid sizes) and new `CanvasElementEditorTests` cases (snap-disabled exact positioning, snap-enabled rounding, continuous-during-gesture snapping asserted mid-drag before release, custom grid size, and the off-by-default guarantee). Full suite **400/400** passing (`dotnet test code/EnvelopeRenderer.slnx`: 101 CLI, 299 desktop). Live verification: per this story's own scope note (canvas-interaction-only, no form/panel/toolbar layout touched), a canvas-only bitmap smoke was sufficient rather than the heavier full-form smoke — built the desktop `.exe` and drove the real `TemplateCanvasControl` via the same reflection-driven harness, invoking its actual (overridden) `OnMouseDown`/`OnMouseMove`/`OnMouseUp` handlers with real `MouseEventArgs` to simulate a genuine drag: with snap off, a drag landed at the raw pointer position (~121.6, ~559.6 for a ~123.4, ~561.7 target — pixel-rounding-only difference); with snap on at a 20pt grid, an intermediate move mid-gesture (before mouse-up) already landed exactly on-grid at (140, 480), and the final released position landed exactly on-grid at (220, 380) — both exact multiples of 20, confirming continuous snapping, not just an on-release correction. Screenshots confirmed the dotted grid lines are visible and the element visibly moved between grid intersections. | Sprint 7 complete — move to product-owner Sprint Review. | None. |

## Post-review fixes (2026-10-19)

Sprint 7 was already reviewed and retro'd before the human product owner, using the shipped increment, reported two same-increment issues. Both are fixed here as post-review corrections to already-Done Sprint 7 stories, not new sprint work — no phase transition, no new story/points.

- **"Jump to a specific record number" — auto-refresh instead of a "Go" button.** The operator wants the preview to update the instant the record number changes, with no explicit click. Fixed: `_recordNumberInput.ValueChanged` now calls `NavigateToRecord((int)_recordNumberInput.Value)` directly in `TemplateDesignerForm`, guarded by the existing `_suppressEvents` flag so the programmatic reset to record 1 on CSV load (which already calls `NavigateToRecord(1)` explicitly right after) doesn't also fire a redundant navigation through the event handler. `_goToRecordButton` (the `&Go` button) has been removed entirely, per the human product owner's explicit preference. This is a UX correction to how the story's AC1 is satisfied ("The UI allows the operator to enter a record number and navigate to it") — automatic instead of button-triggered — not a change to any AC's substance; ACs 2-4 (preview updates, clear message on invalid input, full-file reader) are unaffected and unchanged. See the dated note on the "Jump to a specific record number" story in `backlog/epics/04_live_preview_and_record_navigation.md`.
- **Rotation feel on the editing canvas — dynamic/mixed elements now rotate like static text.** The Sprint 6 fixed-anchor rotation-pivot rule (dynamic/mixed content pivots around its authored `(X, Y)` anchor instead of its bounding-box center, to keep per-record resolved-text-width drift from moving the pivot) was being applied uniformly to the plain editing canvas as well as the real render and the new Sprint 7 preview panel — but the editing canvas only ever draws an element's literal `{ColumnName}` token text, never a per-record resolved value, so that drift-prevention rule never actually applied there; it just made a dynamic/mixed element's rotate-handle drag swing around a corner instead of spinning in place like static text, a jarring interactive inconsistency. Fixed, with the human product owner's explicit approval of this exact trade-off: added `RotationPivotCalculator.ComputeForCanvasEditing` (always takes the center-pivot path, regardless of `IsDynamic`) and switched `TemplateCanvasControl.RotationPivot` and `CanvasElementEditor.RotationPivot` (used for drawing, hit-testing, the drag handle's position, and live rotate-drag math) to call it. The real render (`RotatedTextAnchorCalculator`/`DebenuPdfRenderer`) and the new preview panel (`TemplatePreviewControl`/`TemplatePreviewBuilder`) are untouched — both still call `RotationPivotCalculator.Compute` directly with the real `isDynamic` value, so the original 2026-10-09 record-drift defect fix is fully preserved where it actually matters. Documented with a dated note on the "Keep rotated dynamic and mixed-content fields positioned consistently across records" story in `backlog/epics/02_template_designer_gui_foundation.md` (reopening language in an already-accepted AC, per this project's convention for that kind of change) and a new Deliberate-debt row in `logs/technical_debt_log.md`.
- **Tests updated:** `RotationPivotCalculatorTests` gained `ComputeForCanvasEditing_MatchesStaticBoundingBoxCenter` and `ComputeForCanvasEditing_DifferentMeasuredWidths_MovesWithTheBoundingBox`. Three `CanvasElementEditorTests` cases that previously asserted the fixed-anchor pivot for dynamic elements on the canvas (`HitTest_RotatedDynamicElement_UsesFixedAnchorPivot`, `HandlePosition_RotatedDynamicElement_OrbitsAroundFixedAnchor`, `RotateDragTo_DynamicElement_UsesFixedAnchorZeroDirection`) were rewritten to assert the new center-pivot behavior instead (renamed to `..._UsesCenterPivot_SameAsStatic`/`..._OrbitsAroundCenter_SameAsStatic`), reusing the same math as their static-element counterparts. `RotatedTextAnchorCalculator`/CLI tests and `TemplatePreviewBuilderTests` were not touched and still pass unchanged, confirming the render/preview paths are unaffected. Full suite: **400/400 before -> 402/402 after** (Desktop 299 -> 301, CLI 101 unchanged; net +2: 2 new `RotationPivotCalculatorTests` cases, plus 3 `CanvasElementEditorTests` cases rewritten in place — same count, new assertions — to replace their prior fixed-anchor-for-dynamic versions).
- **Live smoke:** built the desktop `.exe` and confirmed both fixes against the real app: dragging the rotate handle on a static element and on a dynamic/mixed element now both visibly spin in place around their own center identically (no more corner-swing for the dynamic element); typing a new record number into the "Record #" field updates the preview panel immediately with no click and no "Go" button present at all, including typing an out-of-range record number (status label correctly reported the specific out-of-range message while the preview kept showing the last valid record, matching the story's existing AC3 behavior).

+ 40
- 0
backlog/sprints/sprint-8.md Целия файл

@@ -0,0 +1,40 @@
# Sprint Backlog

**Sprint:** 8 **Dates:** 2026-10-26 - 2026-10-30
**Sprint Goal:** Ship whole-Address-Control rotation end-to-end — settable via the properties panel and adjustable by dragging a canvas handle — staying record-stable and visually consistent across the editing canvas, the Sprint 7 live preview, and the real CLI-rendered PDF, directly delivering the human product owner's reversal of the Sprint 6 Review's "whole-control rotation remains out of scope" call.

## Committed Items

| Story | Size | Status | Tasks |
|---|---|---|---|
| Rotate the whole Address Control as a single unit | 8 points | Done | - [x] Enable and wire the properties panel's existing `-360.0`/`360.0`, 0.1-precision angle input for a selected Address Control (currently disabled for that selection) <br> - [x] Add a point-rotation-around-a-pivot helper to `EnvelopeRenderer.Cli`'s `RenderEngine` (no equivalent exists there today) and reuse the same formula on the desktop side (extracted to a new shared `PointRotation` helper, since `CanvasElementEditor.RotatePointAroundPivot` was private) so each visible line's anchor is rotated around the control's authored, unrotated box center before being handed to the existing per-line draw call — no `DebenuPdfRenderer` changes needed <br> - [x] Apply the same rigid-group rotation to `TemplateCanvasControl.DrawAddressControl` (box, every line's text, and any per-line highlight/selection overlay together, via a GDI+ transform around the box center) and to `TemplatePreviewBuilder`'s address-control counterpart <br> - [x] Port a rotated-rectangle test to `TemplateCanvasControl.HitTestAddressControl` (and `HitTestAddressResizeHandle`, plus the resize-drag math itself) so a rotated control stays correctly click-selectable/resizable at its rotated position <br> - [x] Confirm the pivot is always computed from the control's authored `X`/`Y`/`Width`/`Height`, never from any record's `AddressLineCollapser`-shifted line positions — regression tests with two records (one collapsing a blank line, one not) assert an identical pivot, both CLI and desktop <br> - [x] Persist a new `angle` attribute on `<addressControl>` (default `0`) in `TemplateLayoutXmlSerializer`/`TemplateXmlParser`; confirmed pre-existing templates (no attribute) render unchanged <br> - [x] Unit tests: rigid rotation math (CLI and desktop), collapse/pivot-stability regression, persistence round-trip (hit-test itself lives in the WinForms `Views` project and is covered by the live smoke below, not a unit test — see new technical-debt log entry) <br> - [x] Live actual built-form smoke showing a rotated Address Control matching across the canvas, the live preview, and a real CLI render of the same template |
| Rotate the whole Address Control by dragging a handle on the canvas | 5 points | Done | - [x] Add a control-specific handle-position/hit-test/rotate-drag path for the Address Control, paralleling (not reusing as-is) `CanvasElementEditor`'s existing `TextElementLayout`-only handle methods — implemented as a new framework-free `AddressControlRotateHandle` class (Desktop.Core) so it is unit tested, computed around the control's box center established by the prior story <br> - [x] Paint the rotate-handle glyph for a selected Address Control in `TemplateCanvasControl` (visual style consistent with the standalone-element handle — same SeaGreen dot/dashed line/white outline, same `HandleOffsetPoints`/`HandleHitRadiusPoints` constants reused for consistency) <br> - [x] Keep the properties panel's numeric angle field and the canvas handle synchronized in both directions (drag updates the number live; typing a number moves the handle) — free from Batch 1's existing wiring, confirmed live <br> - [x] Confirm dragging the handle persists the resulting angle the same way a typed value does, and does not disturb line-drill-in editing or the control's existing whole-unit move/resize behavior <br> - [x] Unit tests for handle position/hit-test/drag-angle math (`AddressControlRotateHandleTests`) <br> - [x] Live actual built-form smoke of a real handle drag rotating the control, synced with the properties panel, in the built `.exe` — fresh screenshots (not reused from Batch 1), per the Sprint 7 retrospective carry-in |

## Notes
- **Capacity signal:** completed totals across Sprints 1-7 are **20, 19, 18, 18, 15, 18, 18** — seven data points, a stable 18-20 point range. Sprint 5's 15 was a deliberate, reasoned under-commit around one large/risky story, not a capacity miss.
- **The real capacity decision, reasoned through explicitly, not defaulted:** the only Ready work beyond this sprint's 13-point commitment is epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) + align/distribute ("Align and distribute multiple elements," 5 pts) pair, which product-owner's own recommendation (and this team's established practice — see Sprint 4's rotation chain, Sprint 7's preview pair) says must be pulled whole or not at all, never split across sprints. That leaves exactly two options: 13 points alone (a new low, below even Sprint 5's deliberate 15) or 23 points (13 + 10, a new high this team has never attempted — its best sprint to date is 20, itself flagged at the time as "a ceiling-level coincidence to watch, not a target to hit"). A middle option (splitting either pair) was ruled out per the reasoning above, not overlooked.
- **Why 23 was not chosen, even though it would use proven-idle capacity:** the rotation pair and the multi-select/align pair are not formally dependent on each other, but they are not independent either — both land real changes in the same small set of GUI files (`CanvasElementEditor`, `TemplateCanvasControl`, `TemplateDesignerForm`). Concretely: this sprint's drag-handle story adds a new, Address-Control-specific handle/hit-test/rotate-drag path in `CanvasElementEditor`, while the multi-select story reworks `CanvasElementEditor.Selected` from a single reference into a genuinely new multi-item selection model (confirmed by code inspection at 2026-10-16 refinement: no multi-select concept exists anywhere today) — a foundational, first-of-its-kind interaction rework in the exact same class, with the exact same character of risk this team has consistently avoided stacking alongside another large/novel story in one sprint (the same reasoning "Undo and redo layout changes"'s own sizing note gives for staying solo). Sequencing both in the same sprint, even in separate batches, risks one story's in-progress selection/handle-model change complicating the other's, beyond what the point totals alone would suggest. No external date or contractual pressure was cited anywhere in this backlog to justify reaching for a new, unproven high purely to keep capacity from going idle.
- **Checked for a smaller, independent item to round out capacity instead of jumping to 23 — found none that cleanly fits:** "Complete the first text-only operator workflow" (`epics/01_end_to_end_text_rendering_slice.md`, 5 pts, labeled Ready) is a placeholder from the very first, 2026-09-04 onboarding refinement, never re-verified since — the same kind of staleness that made epic 4's "Ready" label wrong at the 2026-10-16 refinement (it predates rotation, mixed content, the Address Control, and the live preview, all of which likely already satisfy this story's acceptance criteria as a byproduct). Pulling it without a real re-verification pass would repeat that exact mistake rather than learn from it, so it is left out and flagged below for product-owner attention rather than blindly committed. "Undo and redo layout changes" (13 pts) is both too large to be a "rounding out" item and its own sizing note already recommends against pairing it with another large/novel story. Nothing else in the backlog is currently Ready.
- **Decision: commit 13 points — the rotation pair alone — as a deliberate, reasoned under-commit**, the same posture Sprint 5 took around its own largest/riskiest story. This is not a capacity miss; it is protecting sprint quality against a concrete, named interference risk rather than chasing a number.
- **Epic reorder confirmed (my own decision, not just inherited from product-owner):** epic 8 (Composite Address Controls and Mixed-Content Text) stays ahead of epic 6 (Layout Efficiency and Operator Tooling) in `backlog/backlog.md`'s order. Reasoning: it is a direct, recent, explicit human-product-owner request reversing a scope call the same human product owner made only one sprint ago (Sprint 6 Review), and it is exactly what this sprint commits to — keeping the backlog's stated order and the sprint's actual commitment consistent, rather than committing one thing while the backlog implies another is more urgent.
- **Recommended for Sprint 9:** the multi-select/align pair (10 pts, epic 6) as its own clean, undisturbed sprint slice — deliberately not sharing a sprint with another novel GUI-selection/canvas rework, per the reasoning above. "Undo and redo layout changes" (13 pts, epic 6) remains available whenever it can be paired appropriately; its own sizing note still recommends against pairing it with another large/novel story, so it is not a natural fit alongside the multi-select/align pair either — a future planning session should weigh that explicitly rather than default to bundling it in.
- **Backlog-hygiene flag for the next backlog refinement (not decided here):** "Complete the first text-only operator workflow" (epic 1, 5 pts) has carried a stale "Ready" label since 2026-09-04 onboarding, untouched through seven sprints of real feature work that plausibly already satisfies its acceptance criteria. Recommend product-owner re-verify (or close outright) at the next backlog refinement rather than let it sit indefinitely.
- Impediments: template asset path strategy (absolute vs. relative) and UNC timeout/retry behavior remain open in `logs/impediment_log.md`. Neither blocks this sprint's committed items.
- Carried over from previous sprint: none (Sprint 7 completed all 3 committed stories, 18/18 points).
- Not committed: "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" (10 pts combined, epic 6 — clean whole-pair pull for Sprint 9, per the reasoning above); "Undo and redo layout changes" (13 pts, epic 6 — feasibility-confirmed 2026-10-19, still not recommended for pairing with another large/novel story); "Complete the first text-only operator workflow" (5 pts, epic 1 — stale label, needs product-owner re-verification before being pulled, see flag above); "Warn on text overflow before render" (epic 4) remains Not Ready, blocked on an open product question for the human product owner.

## Execution Order

Sequenced by feature dependency (only two committed stories this sprint, hard-dependent on each other).

| Batch | Story | Why it's gated here |
|---|---|---|
| 1 | Rotate the whole Address Control as a single unit | No dependency on anything else. Delivers the underlying `RotationAngle` property, persistence, and rigid-group rotation/pivot math that the drag-handle story below needs. |
| 2 | Rotate the whole Address Control by dragging a handle on the canvas | Strictly depends on Batch 1's angle property, persistence, and render/pivot logic — adds only the interactive handle and its live drag behavior on top. |

## Daily Scrum Log

| Day | Date | Completed | Planned | Blocked/At risk |
|---|---|---|---|---|
| 1 | 2026-10-26 | **Batch 1** ("Rotate the whole Address Control as a single unit", 8 points) done, all 6 ACs met. Added `AddressControlLayout.RotationAngle`/`BoxCenter` (desktop) and `TemplateAddressControl.RotationAngle`/`Height`/`BoxCenter` (CLI) — independently-implemented parity, per this project's established CLI/desktop split — confirming by direct inspection that both `Height` (line font sizes + `LineSpacingMultiplier`) and `Width` (author-set) never depend on any record's resolved text, so `BoxCenter` is a pure function of authored geometry alone. Added `RenderEngine.BuildAddressControlDraws`' own rotate-point-around-pivot helper (no equivalent existed in `EnvelopeRenderer.Cli` before this story) and a new shared `EnvelopeRenderer.Desktop.Core.Design.PointRotation` helper on the desktop side (extracted since `CanvasElementEditor.RotatePointAroundPivot` was private to that class) reused by `TemplatePreviewBuilder` and `TemplateCanvasControl`. Each visible line's per-record (collapse-adjusted) anchor is rotated as a rigid group around the fixed `BoxCenter` pivot, then handed to the existing fixed-pivot `TextDraw`/`PreviewTextDraw` path exactly as a rotated dynamic/mixed standalone element already does — confirmed zero `DebenuPdfRenderer` changes were needed. `TemplateCanvasControl.DrawAddressControl` now wraps its whole unrotated paint call (box, lines, highlight fills, selection border, and the pre-existing resize handle) in one GDI+ rotation transform around `BoxCenter`, so everything rotates together automatically; `HitTestAddressControl` and `HitTestAddressResizeHandle` (plus the resize-drag math itself, a correctness fix beyond the story's literal AC list, found while implementing the rotated hit test) now rotate the click point backward into local space before testing, so a rotated control stays correctly click-selectable and resizable at its real on-screen position. Persistence: new optional `angle` attribute on `<addressControl>` (default `0`) in both `TemplateLayoutXmlSerializer` and `TemplateXmlParser`, following the exact convention `<text>`'s own `angle` already uses; `TEMPLATE_FORMAT.md` updated. Tests: 20 new (6 CLI — `RenderEngineTests`/`TemplateXmlParserTests` — and 14 desktop — `AddressControlLayoutTests`, new `PointRotationTests`, `TemplateLayoutXmlSerializerTests`, `TemplatePreviewBuilderTests`), including explicit pivot-stability regression tests (two records, one collapsing a blank line, one not, asserting an identical rotated position for the unaffected first line) on both CLI and desktop. Full suite 402/402 -> 422/422 (301->315 desktop, 101->107 CLI). Live verification: real CLI render of a 20-degree-rotated copy of `sample-envelope-template3.xml` against the real 392-record sample CSV (exit 0, all 392 pages) — confirmed via direct PDF content-stream inspection that the rotated render emits real `cos(20)`/`sin(20)` rotation matrices with distinct per-line translations (proving each line rotated individually as part of one rigid group), while the same template with no `angle` attribute produces zero rotation matrices at all (proving the zero-angle path is untouched). Built the desktop `.exe` and drove the real `TemplateDesignerForm`/`TemplateCanvasControl`/`TemplatePreviewControl` via a reflection-driven harness (same technique as Sprint 7): loaded the rotated template, screenshotted canvas + preview + full form showing the identical 20-degree rotation on both surfaces; simulated real mouse clicks through the actual `OnMouseDown`/`OnMouseUp` handlers proving the rotated control is selectable at its real (forward-rotated) position and correctly NOT selectable at its old un-rotated position; typed 65 into the real properties-panel `_angleInput` control and confirmed the canvas/preview both live-updated to the new angle; saved and reopened the template, confirming the panel-driven angle round-tripped through XML. New technical debt logged (Low impact): the pre-existing (Sprint 6) Address Control hit-test/move/resize logic still lives directly in the WinForms `TemplateCanvasControl` rather than a `CanvasElementEditor`-equivalent, so it remains smoke-tested only, not unit tested — this sprint's *new* rotation math was deliberately extracted to unit-testable Desktop.Core classes, but retroactively refactoring the older code was out of scope. | Begin Batch 2 ("Rotate the whole Address Control by dragging a handle on the canvas"), which depends on this batch's angle property, persistence, and render/pivot logic. | None. |
| 1 | 2026-10-26 | **Batch 2** ("Rotate the whole Address Control by dragging a handle on the canvas", 5 points) done, all 5 ACs met, bringing Sprint 8 to 13/13 points Done. Added `EnvelopeRenderer.Desktop.Core.Design.AddressControlRotateHandle` — a new, framework-free, unit-tested class (mirroring `CanvasElementEditor`'s per-element handle math but implemented fresh against the control's own box geometry, since no equivalent existed for a selected Address Control) providing `LocalPosition`/`WorldPosition`/`HitTest`/`RotateDragTo`, reusing `CanvasElementEditor.HandleOffsetPoints`/`HandleHitRadiusPoints` directly for exact visual/hit-test consistency with the standalone-element handle (the story's own recommended default). `TemplateCanvasControl` paints the handle (same SeaGreen-dot/dashed-line/white-outline style as `DrawRotateHandle`) inside the same rotation transform `DrawAddressControlUnrotated` already runs under, so it automatically orbits with the box; a new `_isRotatingAddressControl` drag-gesture flag parallels the existing `_isResizingAddressControl` one in `OnMouseDown`/`OnMouseMove`/`OnMouseUp`. The properties panel's angle field and the canvas handle were already bidirectionally wired by Batch 1's `RefreshPropertiesPanel`/`SetSelectedRotationAngle` plumbing, so no new sync code was needed — confirmed live rather than assumed. Tests: 11 new (`AddressControlRotateHandleTests` — local/world position, hit-test including a rotated-vs-unrotated-position discrimination case, rotate-drag-to-angle math, pivot-exactly-under-pointer no-op, and a bidirectional angle-drives-position check). Full suite 422/422 -> 433/433 (315->326 desktop, 107 CLI unchanged). Live verification: built the desktop `.exe` and, via the same reflection-driven harness, added a fresh Address Control interactively (`AddAddressControl()`, not loaded from XML), screenshotted it unrotated with both handles visible; grabbed the real rotate handle via a simulated `OnMouseDown` at its actual computed world position, dragged mid-gesture to a point 90 degrees around the pivot (screenshotted before mouse-up, confirming ~89.4 degrees live in both the model and the real `_angleInput` control — small deviation from the harness's pixel-rounding, not a model precision issue) and confirmed the drilled-in address line index (1) was unchanged mid-drag; released the drag and confirmed the angle persisted; typed -45 into the real Angle field afterward and confirmed the handle/box moved to match; confirmed the control's existing whole-unit move still worked correctly afterward (X+20/Y-20 within the same pixel-rounding tolerance). Took a **fresh** full-form screenshot for this batch (not reused from Batch 1), per the Sprint 7 retrospective carry-in explicitly reinforced in this sprint's own plan. One test-design note, not a product defect: a later step in the same harness run clicked the control's body to test whole-unit move, which (correctly, by pre-existing Sprint 6 design) re-picked the drilled-in line under that click point — unrelated to the rotate-drag itself, which the mid-drag check already separately confirmed does not disturb line selection. | Sprint 8 complete — move to product-owner Sprint Review. | None. |

+ 50
- 0
code/CLI_CONTRACT.md Целия файл

@@ -126,6 +126,56 @@ environment variable before every launch is not viable, so the key.txt fallback
path for that workflow — never commit a real key.txt or hardcode a key into source; it's
untracked (`.gitignore`) for exactly this reason.

### Production configuration delivery decision (Sprint 5, `dev-team`, 2026-10-05)

**Decision: keep the `key.txt`-adjacent-to-executable mechanism (env var first, then `key.txt`
walked up from the executable's directory) as this product's accepted production approach for the
Debenu license key, unchanged from how it already works today.** No code change was made or is
required for this story — `DebenuLicenseKeyResolver` and its tests are unchanged.

Reasoning, made as the most reasonable Development Team call in the absence of a live product
owner to consult mid-sprint (to be reviewed and confirmed or overridden by `product-owner` at
Sprint Review, per this story's own acceptance criteria):

- **The mechanism already works and is already verified against the real built `.exe`**, not just
a dev-shell invocation (see the 2026-09-04 fix in `logs/technical_debt_log.md`) — replacing a
working, verified mechanism without a concrete driving requirement would be change for its own
sake, not risk reduction.
- **It fits this product's actual deployment model.** Per `project_config.md`'s hard constraints,
this is a single-workstation Windows desktop application with Windows-login-only, no
app-level authentication, targeting **non-technical** print operators. A plaintext `key.txt`
dropped next to the executable requires zero extra operator steps (no environment variable to
set, no credential-manager entry to create) — anything requiring manual configuration would cut
directly against the "non-technical operator" constraint for a vendor SDK license key that the
operator never needs to see or touch.
- **The key's exposure risk is a vendor-licensing concern, not a security/privacy one.** It is a
Debenu Quick PDF Library license key, not user credentials, customer PII, or CSV data — the
product's actual sensitive data (operator-supplied CSV records) never passes through this
mechanism at all. Given the single-workstation, Windows-login-only deployment model, the
realistic exposure scenario (another local account, or someone copying the file off the
machine) is the same threat model already accepted for every other file on disk in this
product, not a new or heightened risk this mechanism introduces.
- **No packaging/installer story has been done yet**, so there is no concrete evidence today of a
distribution model (MSI installer, xcopy deployment, ClickOnce, etc.) that would benefit from a
stronger mechanism (e.g., an installer-provisioned per-machine `%ProgramData%` file with tighter
ACLs, or Windows Credential Manager/DPAPI-protected storage). Inventing and building that now
would be speculative work against a requirement that doesn't exist yet.

**Explicit revisit trigger (satisfies this story's 4th acceptance criterion):** the *next*
release/packaging/installer story must reference this decision rather than reopening the
question from scratch, but **should** revisit it if the chosen distribution mechanism makes a
stronger option cheap/natural to add (e.g., an installer that can already write a per-machine
`%ProgramData%` location as part of setup) — at that point, checking a `%ProgramData%`-rooted path
in addition to the existing walked-up-from-executable search would be a small, additive change to
`DebenuLicenseKeyResolver`, not a redesign.

**Scope-generalization decision:** the *pattern* (environment variable first, then a file-based
fallback discovered near the executable) is the team's established convention for any future CLI
runtime configuration value, not a one-off special case — but building a generic, reusable
"settings resolver" abstraction now, with exactly one configuration value in existence today,
would be speculative (YAGNI). Revisit genericizing this if/when a second CLI runtime setting is
actually introduced.

## UNC paths

Local and UNC paths are both accepted and validated identically — `System.IO.File.Exists` /


+ 116
- 21
code/TEMPLATE_FORMAT.md Целия файл

@@ -1,4 +1,4 @@
# Text-Only Template Format (MVP)
# Envelope Template Format

Implementation: [`src/EnvelopeRenderer.Cli/Render`](src/EnvelopeRenderer.Cli/Render). Sample:
[`sample-data/sample-envelope-template.xml`](sample-data/sample-envelope-template.xml).
@@ -9,6 +9,18 @@ Implementation: [`src/EnvelopeRenderer.Cli/Render`](src/EnvelopeRenderer.Cli/Ren
<text x="120" y="225" font="Arial [Bold]" size="12" column="Full Name" />
<text x="120" y="210" font="Arial" size="12" column="Address 2" collapsible="true" />
<text x="200" y="600" font="Arial" size="20" angle="45">Rotated label</text>
<addressControl x="120" y="180" width="180" angle="15">
<line font="Arial" size="12" column="Full Name" />
<line font="Arial" size="12" column="Address 1" />
<line font="Arial" size="12" column="Address 2" />
<line font="Arial" size="12">
<run column="City" />
<run text=", " />
<run column="State" />
<run text=" " />
<run column="ZIP" />
</line>
</addressControl>
</envelopeTemplate>
```

@@ -23,7 +35,7 @@ Implementation: [`src/EnvelopeRenderer.Cli/Render`](src/EnvelopeRenderer.Cli/Ren
## `<text>`

One page is rendered per CSV record, in the order the CSV rows appear, each carrying every
`<text>` element in the template.
`<text>` and `<addressControl>` element in the template.

- `x`, `y` — required, PDF points, measured from the page's **bottom-left** corner (Debenu's
default coordinate origin — this template format doesn't call `SetOrigin`, so that default
@@ -33,15 +45,48 @@ One page is rendered per CSV record, in the order the CSV rows appear, each carr
`"Arial [BoldItalic]"`. The font must be installed on the machine running the CLI; it is
always embedded in the output PDF so the reader doesn't need it installed too.
- `size` — required, positive, points.
- Exactly one of:
- Content is either the legacy single-run shape or the Sprint 5 multi-run shape — never both:
- inline text content — static, printed as-is on every page, e.g. `<text ...>Static label:</text>`
- `column="<CSV header>"` — dynamic, pulled from that column per record. Column matching is
case-insensitive. Interpolating a column into a literal string (e.g. `"Dear {FirstName},"`)
is out of scope for this template format — see the deferred story "Create a dynamic text
token from a CSV column."
case-insensitive.
- **Sprint 5, "Mix static text and CSV fields within a single text element":** one or more
`<run>` child elements, each either `text="<literal>"` (printed as-is) or
`column="<CSV header>"` (resolved per record, same case-insensitive matching as the
single-`column` form above), concatenated in document order at render time into the one
string the page draws — e.g.:
```xml
<text x="120" y="240" font="Arial" size="12">
<run text="Attn: " />
<run column="Full Name" />
<run text=" " />
<run column="Last Name" />
</text>
```
A `<text>` element with any `<run>` children must have no `column` attribute and no inline
text of its own (mixing the two forms is a template error, same style as the legacy
both-column-and-inline-text error). This is a genuinely new XML shape — it never appears in
any template saved before Sprint 5 — so it cannot be confused with, and never changes the
parsing of, the legacy single-run forms above; every pre-Sprint-5 template continues to parse
and render byte-for-byte identically. Formatting/transforming a run's resolved value (date
formatting, casing, truncation) remains out of scope — verbatim substitution only, matching
the single-`column` form's existing behavior exactly.

A `column` that isn't a real CSV header fails the whole run before any page is rendered, not
partway through a large batch.
A `column` (on either a `<text>` element or a `<run>` child) that isn't a real CSV header fails
the whole run before any page is rendered, not partway through a large batch — evaluated per
token when a `<text>` element has multiple runs, so one bad field reference fails the run even
when it's mixed alongside other, valid runs on the same line.

**Desktop designer editing convention (not part of the persisted format above):** the Template
Designer's properties panel lets an operator compose mixed content by typing plain text with
`{Column Name}` bracket tokens (e.g. `Attn: {Full Name}`), parsed into the run sequence above when
the field loses focus. This is a *editing-time* convention only — the persisted XML always uses
the explicit `<run>` shape above, never inline `{...}` text. Known limitation: literal text that
itself contains a matched `{...}` pair not intended as a field token (e.g. typing `note: {see
below}` as literal text) is indistinguishable from a real field token by this convention and will
be parsed as a field run bound to a column literally named "see below" — which then fails the
per-token pre-flight check above if no such column exists. This is an accepted trade-off of the
bracket convention (chosen as the faster-to-deliver option over a full protected-token-chip rich
editor, per this story's sizing note) rather than a silently-swallowed edge case.

- `collapsible` — optional, `true` or `false` (default `false`). Sprint 4, "Collapse blank
optional address lines consistently": when `true` and this element's resolved text is blank
@@ -65,21 +110,71 @@ partway through a large batch.
collapsible field bound to a real column that's simply blank in the loaded sample data gets
no warning at all — it just doesn't render, exactly as intended.
- `angle` — optional, degrees, positive or negative, default `0`. Sprint 4, "Set a rotation angle
for text and dynamic field elements": rotates the element around the center of
its own bounding box (not around `x`/`y`) by this many degrees. **Positive is
for text and dynamic field elements": rotates text by this many degrees. **Positive is
counterclockwise**, confirmed empirically against the real Debenu Quick PDF Library 10.13 DLL
(not assumed) — see [`src/EnvelopeRenderer.Cli/Render/RotatedTextAnchorCalculator.cs`](src/EnvelopeRenderer.Cli/Render/RotatedTextAnchorCalculator.cs)'s
class remarks for the probe methodology and result. Internally this calls Debenu's
`DrawRotatedText(x, y, angle, text)`, which rotates around the given `(x, y)` anchor, not a
center — `RotatedTextAnchorCalculator` solves for the *different* anchor point that keeps the
unrotated bounding-box center fixed, using `GetTextWidth`/`GetTextAscent`/`GetTextDescent` to
measure that box. One more empirically-confirmed vendor quirk: `DrawRotatedText` rejects a
negative `Angle` outright (despite rotation being mathematically periodic) — the renderer
normalizes to Debenu's expected `[0, 360)` range before that specific call; a negative `angle`
in a template is fully supported and unaffected by this internal normalization. An `angle` of
`0` (including when the attribute is absent) takes the exact same `DrawText` code path this
renderer used before this story — byte-for-byte unchanged output for every pre-existing
template.
class remarks for the probe methodology and result.

Static text rotates around the center of its own bounding box, preserving the original Sprint 4
behavior. Internally this calls Debenu's `DrawRotatedText(x, y, angle, text)`, which rotates
around the given `(x, y)` anchor, not a center — `RotatedTextAnchorCalculator` solves for the
*different* anchor point that keeps the unrotated bounding-box center fixed, using
`GetTextWidth`/`GetTextAscent`/`GetTextDescent` to measure that box.

Dynamic and mixed-content text (any `<text>` with a `column` attribute or at least one
field-run child) rotates around the fixed authored `(x, y)` anchor instead of a measured
bounding-box center. This Sprint 6 rule prevents record-to-record drift: resolved text width
can vary per CSV row, but the pivot cannot. The desktop canvas uses the same rule so the preview
and the rendered PDF agree on placement for variable-width rotated content. One more
empirically-confirmed vendor quirk: `DrawRotatedText` rejects a negative `Angle` outright
(despite rotation being mathematically periodic) — the renderer normalizes to Debenu's expected
`[0, 360)` range before that specific call; a negative `angle` in a template is fully supported
and unaffected by this internal normalization. An `angle` of `0` (including when the attribute
is absent) takes the exact same `DrawText` code path this renderer used before this story —
byte-for-byte unchanged output for every pre-existing template.

## `<addressControl>`

Sprint 6 adds a grouped Address Control: a movable address block with one anchor point and an
ordered list of child lines. The desktop designer saves it as an `<addressControl>` container,
and the CLI renders it directly; the desktop app is not required at render time.

- `x`, `y` — required, PDF points. This is the control's single anchor: the baseline position of
the first line.
- `width` — required, positive, PDF points. The designer uses this as the group's resize box.
Current render output does not wrap or clip text to this width; each line still draws as one
resolved text string.
- `lineSpacing` — optional, positive multiplier, default `1.25`. Line N+1's baseline is computed
from the previous line's font size: `previousY - previousSize * lineSpacing`.
- Child `<line>` elements are required; an address control must contain at least one line.
- Each `<line>` has required `font` and positive `size` attributes, and supports the same content
forms as `<text>`: inline literal text, `column="CSV header"`, or explicit `<run>` children for
mixed static/field content.
- `collapsible` on a `<line>` is optional and defaults to `true`, unlike standalone `<text>`,
whose default remains `false`. Set `collapsible="false"` on a line that must keep its row even
when it resolves blank.
- `angle` — optional, degrees, positive or negative, default `0`. Sprint 8, "Rotate the whole
Address Control as a single unit": rotates the **entire control** — its box, every line's text,
and any per-line highlight/selection overlay in the desktop designer — together as one rigid
unit. Same **positive is counterclockwise** convention as standalone `<text>`'s own `angle`.
Unlike a standalone element, an Address Control's box geometry (`width`, and `height` derived
purely from each line's authored `size`/the control's `lineSpacing`) never depends on any
record's resolved text, so the whole control always rotates around its own fixed, authored box
center — no per-record pivot drift is possible here, and no fixed-anchor/bounding-box-center
branch choice like standalone text's is needed. Internally, each visible line's own
(collapse-adjusted) anchor is rotated as a rigid group around that fixed box center before being
handed to the same `DrawRotatedText` path standalone rotated text already uses — `angle="0"`
(including when the attribute is absent) takes the exact same unrotated code path as before this
story, so every template written before Sprint 8 renders byte-for-byte unchanged.

At render time, the control expands into one draw per visible line, in child-line order. Blank
collapsible lines are omitted and following lines in the same control shift upward using the same
`AddressLineCollapser` rule described for standalone `<text>` elements; because every child line
shares the control's single `x`, the existing stack rule and the control boundary agree. Static,
field, and mixed-content lines all resolve per CSV record exactly like standalone text elements.
The rotation pivot is always computed from the control's authored, unrotated `x`/`y`/`width`/
`height`, never from any record's collapse-shifted line positions, so two records differing only
in whether a blank optional line collapses still rotate around the identical pivot.

## Known gaps



+ 5
- 0
code/sample-data/sample-envelope-template2.xml Целия файл

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<envelopeTemplate pageWidth="684" pageHeight="306" canvasUnit="Inches">
<text x="186.12824427480916" y="256.47938931297716" font="Arial" size="12" color="#000000" zOrder="0">Static text</text>
<text x="283.02290076335873" y="229.66106870229007" font="Arial" size="12" color="#000000" zOrder="1" angle="34.891852914314626" column="Full Name" />
</envelopeTemplate>

+ 20
- 0
code/sample-data/sample-envelope-template3.xml Целия файл

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<envelopeTemplate pageWidth="297" pageHeight="684" canvasUnit="Inches">
<addressControl x="14.035877862595441" y="608.812213740458" width="248.29007633587784" zOrder="0">
<line font="Arial" size="8" color="#000000">
<run column="Full Name" />
</line>
<line font="Arial" size="8" color="#000000">
<run column="Address 1" />
</line>
<line font="Arial" size="8" color="#000000">
<run column="Address 2" />
</line>
<line font="Arial" size="8" color="#000000">
<run column="Address 3" />
</line>
<line font="Arial" size="8" color="#000000">
<run column="IM barcode Characters" />
</line>
</addressControl>
</envelopeTemplate>

+ 14
- 0
code/sample-data/sample-envelope-template4.xml Целия файл

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<envelopeTemplate pageWidth="684" pageHeight="297" canvasUnit="Inches">
<text x="29.700000000000003" y="547.2" font="Arial" size="12" color="#000000" zOrder="0">Static text</text>
<text x="47.00992366412213" y="453.65801526717564" font="Arial" size="12" color="#000000" zOrder="1" column="Full Name" />
<addressControl x="7" y="280" width="280" zOrder="2">
<line font="Arial" size="12" color="#000000">
<run column="Full Name" />
</line>
</addressControl>
<text x="7" y="238" font="Arial" size="12" color="#000000" zOrder="3" collapsible="true">
<run text="Static text " />
<run column="Ballot ID" />
</text>
</envelopeTemplate>

+ 54
- 0
code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererRotationTests.cs Целия файл

@@ -1,4 +1,6 @@
using System.Drawing;
using System.Text;
using System.Text.RegularExpressions;
using DebenuPDFLibraryDLL1013;
using EnvelopeRenderer.Cli.Render;

@@ -129,4 +131,56 @@ public class DebenuPdfRendererRotationTests
}
}
}

[Fact]
public void AddPage_FixedRotationPivot_UsesTheAuthoredAnchorForDifferentTextWidths()
{
var licenseKey = DebenuLicenseKey.Resolve();
if (licenseKey is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dllPath = DllPath();
var workDir = Path.Combine(Path.GetTempPath(), $"fixed-rotation-pivot-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);

try
{
var created = DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError);
Assert.True(created, createError);

var pdfPath = Path.Combine(workDir, "fixed-pivot.pdf");
using (renderer)
{
var shortDraw = new TextDraw(150, 150, "Arial", 28, "Al", 35, UsesFixedRotationPivot: true);
var longDraw = new TextDraw(150, 150, "Arial", 28, "Alexandria Montgomery", 35, UsesFixedRotationPivot: true);
Assert.True(renderer!.AddPage(300, 300, new[] { shortDraw }, out var firstPageError), firstPageError);
Assert.True(renderer.AddPage(300, 300, new[] { longDraw }, out var secondPageError), secondPageError);
Assert.True(renderer.Save(pdfPath, out var saveError), saveError);
}

var pdfText = File.ReadAllText(pdfPath, Encoding.Latin1);
var fixedAnchorTransforms = Regex.Matches(
pdfText,
@"(?m)^[-+]?\d+(?:\.\d+)? [-+]?\d+(?:\.\d+)? [-+]?\d+(?:\.\d+)? [-+]?\d+(?:\.\d+)?\s+150(?:\.0+)? 150(?:\.0+)? cm\r?$");

// One rotated text transform per page should use the exact authored anchor. Before
// Sprint 6, each page's anchor was corrected from that page's resolved text width,
// so the short and long strings produced two different, non-authored anchors.
Assert.Equal(2, fixedAnchorTransforms.Count);
}
finally
{
try
{
Directory.Delete(workDir, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup only, matching this project's established pattern.
}
}
}
}

+ 376
- 10
code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs Целия файл

@@ -57,9 +57,10 @@ public class RenderEngineTests
PageHeight: 684,
Elements: new List<TemplateElement>
{
new(120, 240, "Arial", 12, "Static label", null),
new(120, 225, "Arial", 12, null, "Full Name"),
});
TemplateElement.Static(120, 240, "Arial", 12, "Static label"),
TemplateElement.Dynamic(120, 225, "Arial", 12, "Full Name"),
},
AddressControls: Array.Empty<TemplateAddressControl>());

private static readonly List<IReadOnlyDictionary<string, string>> Records = new()
{
@@ -194,10 +195,11 @@ public class RenderEngineTests
PageHeight: 684,
Elements: new List<TemplateElement>
{
new(100, 300, "Arial", 12, "Full Name line", null),
new(100, 285, "Arial", 12, null, "Address2", Collapsible: true),
new(100, 270, "Arial", 12, null, "CityStateZip"),
});
TemplateElement.Static(100, 300, "Arial", 12, "Full Name line"),
TemplateElement.Dynamic(100, 285, "Arial", 12, "Address2", collapsible: true),
TemplateElement.Dynamic(100, 270, "Arial", 12, "CityStateZip"),
},
AddressControls: Array.Empty<TemplateAddressControl>());

[Fact]
public void Render_CollapsibleFieldBlank_IsOmittedAndSubsequentLineShiftsUp()
@@ -261,8 +263,8 @@ public class RenderEngineTests
// this story must not change that baseline for fields that don't opt in.
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
new(100, 300, "Arial", 12, null, "MaybeBlank"),
new(100, 285, "Arial", 12, null, "AlwaysPresent"),
TemplateElement.Dynamic(100, 300, "Arial", 12, "MaybeBlank"),
TemplateElement.Dynamic(100, 285, "Arial", 12, "AlwaysPresent"),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
@@ -282,12 +284,68 @@ public class RenderEngineTests
Assert.Equal(285, draws[1].Y, precision: 6);
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void Render_MixedRunElement_ConcatenatesLiteralAndFieldRunsPerRecord()
{
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
new(120, 240, "Arial", 12, new List<TemplateTextRun>
{
new("Attn: ", null),
new(null, "Full Name"),
new(" - ", null),
new(null, "Last Name"),
}),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice", ["Last Name"] = "Smith" },
};

var result = RenderEngine.Render(template, new[] { "Full Name", "Last Name" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal("Attn: Alice - Smith", renderer.Pages[0].Draws[0].Text);
}

[Fact]
public void Render_MixedRunElement_UnknownFieldRunColumn_FailsBeforeTouchingRenderer()
{
// AC6: a field token bound to a column that isn't a real CSV header fails the whole run
// before any page renders, evaluated per *run* — this template's element has one good
// run ("Full Name") and one bad run ("Nickname"); the bad run alone must still fail the
// entire run up front, exactly as a whole bad element would pre-Sprint-5.
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
new(120, 240, "Arial", 12, new List<TemplateTextRun>
{
new("Hi ", null),
new(null, "Full Name"),
new(null, "Nickname"),
}),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice" },
};

var result = RenderEngine.Render(template, new[] { "Full Name" }, records, renderer, "out.pdf");

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("Nickname"));
Assert.Empty(renderer.Pages);
}

[Fact]
public void Render_ElementAngle_IsPassedThroughToTextDraw()
{
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
new(100, 300, "Arial", 12, "Rotated label", null, Collapsible: false, Angle: 45),
TemplateElement.Static(100, 300, "Arial", 12, "Rotated label", collapsible: false, angle: 45),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string>() };
@@ -296,5 +354,313 @@ public class RenderEngineTests

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(45, renderer.Pages[0].Draws[0].Angle);
Assert.False(renderer.Pages[0].Draws[0].UsesFixedRotationPivot);
}

[Fact]
public void Render_RotatedDynamicElement_UsesFixedRotationPivotAcrossRecords()
{
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
TemplateElement.Dynamic(100, 300, "Arial", 12, "Full Name", angle: 45),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Al" },
new Dictionary<string, string> { ["Full Name"] = "Alexandria Montgomery" },
};

var result = RenderEngine.Render(template, new[] { "Full Name" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(2, renderer.Pages.Count);
Assert.Equal("Al", renderer.Pages[0].Draws[0].Text);
Assert.Equal("Alexandria Montgomery", renderer.Pages[1].Draws[0].Text);
Assert.Equal(100, renderer.Pages[0].Draws[0].X, precision: 6);
Assert.Equal(300, renderer.Pages[0].Draws[0].Y, precision: 6);
Assert.Equal(renderer.Pages[0].Draws[0].X, renderer.Pages[1].Draws[0].X, precision: 6);
Assert.Equal(renderer.Pages[0].Draws[0].Y, renderer.Pages[1].Draws[0].Y, precision: 6);
Assert.True(renderer.Pages[0].Draws[0].UsesFixedRotationPivot);
Assert.True(renderer.Pages[1].Draws[0].UsesFixedRotationPivot);
}

[Fact]
public void Render_RotatedMixedRunElement_UsesFixedRotationPivot()
{
var template = new TemplateDocument(297, 684, new List<TemplateElement>
{
new(120, 240, "Arial", 12, new List<TemplateTextRun>
{
new("Attn: ", null),
new(null, "Full Name"),
}, Angle: 30),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice" },
};

var result = RenderEngine.Render(template, new[] { "Full Name" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal("Attn: Alice", renderer.Pages[0].Draws[0].Text);
Assert.Equal(30, renderer.Pages[0].Draws[0].Angle);
Assert.True(renderer.Pages[0].Draws[0].UsesFixedRotationPivot);
}

// Sprint 6, "Group lines into a single, movable Address Control".

[Fact]
public void Render_AddressControl_ExpandsLinesInOrderWithMixedContent()
{
var template = new TemplateDocument(
297,
684,
Array.Empty<TemplateElement>(),
new[]
{
new TemplateAddressControl(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }),
new TemplateAddressControlLine("Arial", 10, new[]
{
new TemplateTextRun("Attn: ", null),
new TemplateTextRun(null, "Department"),
}),
}),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice Smith", ["Department"] = "Accounting" },
};

var result = RenderEngine.Render(template, new[] { "Full Name", "Department" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(new[] { "Alice Smith", "Attn: Accounting" }, renderer.Pages[0].Draws.Select(d => d.Text));
Assert.Equal(120, renderer.Pages[0].Draws[0].X, precision: 6);
Assert.Equal(500, renderer.Pages[0].Draws[0].Y, precision: 6);
Assert.Equal(485, renderer.Pages[0].Draws[1].Y, precision: 6);
}

[Fact]
public void Render_AddressControl_BlankCollapsibleLineIsOmittedAndFollowingLineShiftsUp()
{
var template = new TemplateDocument(
297,
684,
Array.Empty<TemplateElement>(),
new[]
{
new TemplateAddressControl(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Address2") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "CityStateZip") }),
}),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["Full Name"] = "Alice Smith",
["Address2"] = "",
["CityStateZip"] = "Springfield, IL",
},
};

var result = RenderEngine.Render(
template, new[] { "Full Name", "Address2", "CityStateZip" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(new[] { "Alice Smith", "Springfield, IL" }, renderer.Pages[0].Draws.Select(d => d.Text));
Assert.Equal(500, renderer.Pages[0].Draws[0].Y, precision: 6);
Assert.Equal(485, renderer.Pages[0].Draws[1].Y, precision: 6);
}

[Fact]
public void Render_AddressControl_UnknownFieldRunColumn_FailsBeforeTouchingRenderer()
{
var template = new TemplateDocument(
297,
684,
Array.Empty<TemplateElement>(),
new[]
{
new TemplateAddressControl(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Missing Column") }),
}),
});
var renderer = new FakePdfRenderer();

var result = RenderEngine.Render(
template,
new[] { "Full Name" },
new[] { new Dictionary<string, string> { ["Full Name"] = "Alice" } },
renderer,
"out.pdf");

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("Missing Column"));
Assert.Empty(renderer.Pages);
}

// Sprint 8, "Rotate the whole Address Control as a single unit".

[Fact]
public void Render_RotatedAddressControl_RotatesEachLineAsRigidGroupAroundBoxCenter()
{
var control = new TemplateAddressControl(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Address1") }),
}, RotationAngle: 90);
var template = new TemplateDocument(297, 684, Array.Empty<TemplateElement>(), new[] { control });
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice Smith", ["Address1"] = "123 Main St" },
};

var result = RenderEngine.Render(template, new[] { "Full Name", "Address1" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var draws = renderer.Pages[0].Draws;
Assert.Equal(2, draws.Count);

// Both lines carry the control's angle and use the fixed-anchor pivot path (no
// DebenuPdfRenderer change needed — see RenderEngine.BuildAddressControlDraws' remarks).
Assert.All(draws, d => Assert.Equal(90, d.Angle));
Assert.All(draws, d => Assert.True(d.UsesFixedRotationPivot));

// Independently recompute the expected rotated anchor via the plain rotate-around-pivot
// formula (not by calling any of RenderEngine's own private helpers) and confirm the
// actual draw matches it exactly — a real correctness check, not just "it changed."
var pivot = control.BoxCenter;
var radians = 90.0 * Math.PI / 180.0;
var dx = 120 - pivot.X;
var dy = 500 - pivot.Y;
var expectedX = pivot.X + ((dx * Math.Cos(radians)) - (dy * Math.Sin(radians)));
var expectedY = pivot.Y + ((dx * Math.Sin(radians)) + (dy * Math.Cos(radians)));
Assert.Equal(expectedX, draws[0].X, precision: 6);
Assert.Equal(expectedY, draws[0].Y, precision: 6);

// And it must genuinely differ from the unrotated anchor — the rotation actually moved
// the point rather than leaving Angle as cosmetic metadata.
Assert.NotEqual(120, draws[0].X, precision: 3);
Assert.NotEqual(500, draws[0].Y, precision: 3);
}

[Fact]
public void Render_UnrotatedAddressControl_DefaultAngleZero_RendersUnchanged()
{
// A template with no `angle` attribute (or RotationAngle: 0 explicitly) must render
// byte-for-byte identically to how Sprint 6's Address Control rendered before this story —
// proving this story is purely additive.
var control = new TemplateAddressControl(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }),
});
var template = new TemplateDocument(297, 684, Array.Empty<TemplateElement>(), new[] { control });
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Full Name"] = "Alice Smith" },
};

var result = RenderEngine.Render(template, new[] { "Full Name" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var draw = renderer.Pages[0].Draws[0];
Assert.Equal(0, draw.Angle);
Assert.Equal(120, draw.X, precision: 6);
Assert.Equal(500, draw.Y, precision: 6);
// Pre-existing (Sprint 6) behavior for a field-bound line, unrelated to this story's
// whole-control angle: unchanged by this story's zero-angle path.
Assert.True(draw.UsesFixedRotationPivot);
}

[Fact]
public void Render_RotatedAddressControl_PivotIsStableAcrossRecordsRegardlessOfCollapse()
{
// Sprint 8's own flagged correctness requirement: the rotation pivot must come from the
// control's *authored* box geometry, never from any record's AddressLineCollapser-shifted
// line positions — so two records differing only in whether a blank optional line
// collapses must still rotate around the exact same pivot.
var control = new TemplateAddressControl(100, 400, 150, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Address2") }, Collapsible: true),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "CityStateZip") }),
}, RotationAngle: 45);
var template = new TemplateDocument(297, 684, Array.Empty<TemplateElement>(), new[] { control });
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["Full Name"] = "Alice Smith", ["Address2"] = "", ["CityStateZip"] = "Springfield, IL",
},
new Dictionary<string, string>
{
["Full Name"] = "Alice Smith", ["Address2"] = "Apt 4B", ["CityStateZip"] = "Springfield, IL",
},
};

var result = RenderEngine.Render(
template, new[] { "Full Name", "Address2", "CityStateZip" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));

// The pivot itself is a pure function of authored geometry (X/Y/Width/Height/line font
// sizes) — none of which any record can change — so BoxCenter must be identical
// regardless of which record rendered.
var pivot = control.BoxCenter;

// The first line (never affected by Address2's collapse either way) must land at the
// identical rotated position on both pages, proving the same pivot was used both times.
Assert.Equal(2, renderer.Pages.Count);
var firstLineRecordA = renderer.Pages[0].Draws[0];
var firstLineRecordB = renderer.Pages[1].Draws[0];
Assert.Equal(firstLineRecordA.X, firstLineRecordB.X, precision: 6);
Assert.Equal(firstLineRecordA.Y, firstLineRecordB.Y, precision: 6);

// Record A collapses Address2 (2 visible lines), record B keeps it (3 visible lines) — a
// genuinely different per-record shape, not just a coincidence of identical text.
Assert.Equal(2, renderer.Pages[0].Draws.Count);
Assert.Equal(3, renderer.Pages[1].Draws.Count);

// The un-rotated pivot never changes: independently confirmed via direct computation.
Assert.Equal(pivot, control.BoxCenter);
}

[Fact]
public void Render_TextAndAddressControl_FollowsParsedRenderOrder()
{
var template = new TemplateDocument(
297,
684,
new[] { TemplateElement.Static(10, 10, "Arial", 12, "Text after") with { RenderOrder = 1 } },
new[]
{
new TemplateAddressControl(
120,
500,
180,
new[] { new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Full Name") }) },
RenderOrder: 0),
});
var renderer = new FakePdfRenderer();
var records = new[] { new Dictionary<string, string> { ["Full Name"] = "Alice" } };

var result = RenderEngine.Render(template, new[] { "Full Name" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(new[] { "Alice", "Text after" }, renderer.Pages[0].Draws.Select(d => d.Text));
}
}

+ 251
- 1
code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs Целия файл

@@ -88,7 +88,7 @@ public class TemplateXmlParserTests
var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("no <text> elements"));
Assert.Contains(result.Errors, e => e.Contains("no <text> or <addressControl> elements"));
}

[Fact]
@@ -213,6 +213,256 @@ public class TemplateXmlParserTests
Assert.Contains(result.Errors, e => e.Contains("angle"));
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void Parse_TextElementWithRunChildren_ReturnsMultiRunElement()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="120" y="240" font="Arial" size="12">
<run text="Attn: " />
<run column="Full Name" />
<run text=" - " />
<run column="Last Name" />
</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var element = result.Document!.Elements.Single();
Assert.Equal(4, element.Runs.Count);
Assert.True(element.IsDynamic);
Assert.False(element.HasSingleColumnRun);
Assert.Null(element.StaticText);
Assert.Null(element.ColumnName);
Assert.Equal("Attn: ", element.Runs[0].Literal);
Assert.Equal("Full Name", element.Runs[1].ColumnName);
Assert.Equal(" - ", element.Runs[2].Literal);
Assert.Equal("Last Name", element.Runs[3].ColumnName);
}

[Fact]
public void Parse_TextElementWithRunChildrenAndColumnAttribute_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12" column="Full Name">
<run text="Hi" />
</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("<run>") && e.Contains("exactly one form"));
}

[Fact]
public void Parse_RunWithBothTextAndColumn_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12">
<run text="Hi" column="Full Name" />
</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("<run> #1") && e.Contains("both"));
}

[Fact]
public void Parse_RunWithNeitherTextNorColumn_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12">
<run />
</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("<run> #1") && e.Contains("neither"));
}

[Fact]
public void Parse_RunWithEmptyTextAttribute_IsAllowedAsBlankLiteralRun()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12">
<run column="Full Name" />
<run text="" />
<run column="Last Name" />
</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(3, result.Document!.Elements.Single().Runs.Count);
}

[Fact]
public void Parse_LegacySingleColumnAndSingleStaticText_StillProduceOneRunElements()
{
// Direct regression check for AC2, at the parser level: pre-Sprint-5 shapes parse into
// exactly the one-run special case of the new model, with the same back-compat
// StaticText/ColumnName projections the rest of the codebase (and pre-existing tests)
// already depend on.
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="120" y="240" font="Arial" size="12">Static:</text>
<text x="120" y="225" font="Arial" size="12" column="Full Name" />
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var staticElement = result.Document!.Elements[0];
Assert.Single(staticElement.Runs);
Assert.False(staticElement.Runs[0].IsField);

var dynamicElement = result.Document.Elements[1];
Assert.Single(dynamicElement.Runs);
Assert.True(dynamicElement.Runs[0].IsField);
Assert.True(dynamicElement.HasSingleColumnRun);
}

// Sprint 6, "Group lines into a single, movable Address Control".

[Fact]
public void Parse_AddressControl_ReturnsLinesWithMixedContentAndDefaults()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180">
<line font="Arial" size="12">
<run column="Full Name" />
</line>
<line font="Arial" size="10" collapsible="false">
<run text="Attn: " />
<run column="Department" />
</line>
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var control = result.Document!.AddressControls.Single();
Assert.Equal(120, control.X, precision: 6);
Assert.Equal(500, control.Y, precision: 6);
Assert.Equal(180, control.Width, precision: 6);
Assert.Equal(TemplateAddressControl.DefaultLineSpacingMultiplier, control.LineSpacingMultiplier, precision: 6);
Assert.Equal(2, control.Lines.Count);
Assert.True(control.Lines[0].Collapsible);
Assert.False(control.Lines[1].Collapsible);
Assert.Equal("Full Name", control.Lines[0].Runs.Single().ColumnName);
Assert.Equal("Attn: ", control.Lines[1].Runs[0].Literal);
Assert.Equal("Department", control.Lines[1].Runs[1].ColumnName);
}

// Sprint 8, "Rotate the whole Address Control as a single unit".

[Fact]
public void Parse_AddressControlWithoutAngleAttribute_DefaultsToZero()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180">
<line font="Arial" size="12" column="Full Name" />
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(0, result.Document!.AddressControls.Single().RotationAngle, precision: 6);
}

[Fact]
public void Parse_AddressControlWithAngleAttribute_ParsesRotationAngle()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180" angle="-45.5">
<line font="Arial" size="12" column="Full Name" />
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(-45.5, result.Document!.AddressControls.Single().RotationAngle, precision: 6);
}

[Fact]
public void Parse_AddressControlWithNonNumericAngle_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180" angle="sideways">
<line font="Arial" size="12" column="Full Name" />
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("non-numeric 'angle'"));
}

[Fact]
public void Parse_AddressControlWithoutLines_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180" />
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("at least one <line>"));
}

[Fact]
public void Parse_TextAndAddressControl_PreservesXmlRenderOrder()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="120" y="500" width="180">
<line font="Arial" size="12" column="Full Name" />
</addressControl>
<text x="10" y="10" font="Arial" size="12">Top text</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(0, result.Document!.AddressControls.Single().RenderOrder);
Assert.Equal(1, result.Document.Elements.Single().RenderOrder);
}

[Fact]
public void Parse_MalformedXml_Fails()
{


+ 28
- 13
code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs Целия файл

@@ -189,23 +189,38 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
}
else
{
// Rotate around the element's own bounding-box center, not the (X, Y) anchor
// DrawRotatedText itself rotates around — see RotatedTextAnchorCalculator for the
// anchor-offset math and the empirically-confirmed sign convention.
var width = _pdf.GetTextWidth(draw.Text);
var ascent = _pdf.GetTextAscent();
var descent = _pdf.GetTextDescent();
var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(
draw.X, draw.Y, width, ascent, descent, draw.Angle);

// Confirmed empirically against the real DLL: DrawRotatedText rejects a negative
// Angle outright (returns 0 with LastErrorCode() also 0 — no descriptive error at
// all), even though it is mathematically periodic. Normalize to Debenu's expected
// [0, 360) range before the call; the anchor-offset math above already used the
// original signed angle (trig functions handle negative angles natively and
// correctly), so this normalization is purely for the vendor call's own input
// validation, not a behavior change.
// [0, 360) range before the call. Any anchor-offset math below uses the original
// signed angle first (trig functions handle negative angles natively), so this
// normalization is purely for the vendor call's own input validation.
var normalizedAngle = ((draw.Angle % 360) + 360) % 360;
double anchorX;
double anchorY;

if (draw.UsesFixedRotationPivot)
{
// Sprint 6 defect fix: dynamic/mixed content has a record-varying resolved
// width, so computing a bounding-box center from draw.Text makes the pivot
// move on every page. Use the authored anchor directly for variable-width
// content so position is stable across records. Static rotated text keeps the
// Sprint 4 center-pivot path below.
anchorX = draw.X;
anchorY = draw.Y;
}
else
{
// Rotate static text around its own bounding-box center, not the (X, Y)
// anchor DrawRotatedText itself rotates around — see
// RotatedTextAnchorCalculator for the anchor-offset math and the
// empirically-confirmed sign convention.
var width = _pdf.GetTextWidth(draw.Text);
var ascent = _pdf.GetTextAscent();
var descent = _pdf.GetTextDescent();
(anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(
draw.X, draw.Y, width, ascent, descent, draw.Angle);
}

if (_pdf.DrawRotatedText(anchorX, anchorY, normalizedAngle, draw.Text) == 0)
{


+ 137
- 8
code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs Целия файл

@@ -17,9 +17,17 @@ public static class RenderEngine
string outputPath,
IProgressReporter? progress = null)
{
// Sprint 5, "Mix static text and CSV fields within a single text element", AC6: this used
// to be a pre-pass over every *element*'s single bound column; flattened here to run over
// every *run* of every element instead, so a field token bound to a bad column still fails
// the whole run before any page renders even when it's mixed alongside literal text or
// other field runs on the same line — the same blocking rule as before, just evaluated at
// run granularity.
var unknownColumns = template.Elements
.Where(e => e.IsDynamic)
.Select(e => e.ColumnName!)
.SelectMany(e => e.Runs)
.Concat(template.AddressControls.SelectMany(c => c.Lines).SelectMany(l => l.Runs))
.Where(r => r.IsField)
.Select(r => r.ColumnName!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(column => !csvHeaders.Contains(column, StringComparer.OrdinalIgnoreCase))
.ToList();
@@ -35,7 +43,7 @@ public static class RenderEngine
var recordCount = 0;
foreach (var record in records)
{
var draws = BuildDraws(template.Elements, record);
var draws = BuildDraws(template, record);

if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, out var pageError))
{
@@ -66,22 +74,23 @@ public static class RenderEngine
/// text, i.e. nothing, at its original position), exactly matching pre-Sprint-4 behavior, so
/// this is purely additive for templates that don't opt in.</summary>
private static List<TextDraw> BuildDraws(
IReadOnlyList<TemplateElement> elements, IReadOnlyDictionary<string, string> record)
TemplateDocument template, IReadOnlyDictionary<string, string> record)
{
var elements = template.Elements;
var resolvedText = new string[elements.Count];
var lines = new AddressLineCollapser.Line[elements.Count];

for (var i = 0; i < elements.Count; i++)
{
var element = elements[i];
resolvedText[i] = element.IsDynamic ? record[element.ColumnName!] : element.StaticText!;
resolvedText[i] = ResolveText(element, record);
lines[i] = new AddressLineCollapser.Line(
element.X, element.Y, element.Collapsible && string.IsNullOrWhiteSpace(resolvedText[i]));
}

var resolved = AddressLineCollapser.Resolve(lines);

var draws = new List<TextDraw>(elements.Count);
var drawGroups = new List<(int RenderOrder, IReadOnlyList<TextDraw> Draws)>(elements.Count + template.AddressControls.Count);
for (var i = 0; i < elements.Count; i++)
{
if (!resolved[i].Visible)
@@ -90,9 +99,129 @@ public static class RenderEngine
}

var element = elements[i];
draws.Add(new TextDraw(element.X, resolved[i].EffectiveY, element.FontName, element.Size, resolvedText[i], element.Angle));
drawGroups.Add((element.RenderOrder, new[]
{
new TextDraw(
element.X,
resolved[i].EffectiveY,
element.FontName,
element.Size,
resolvedText[i],
element.Angle,
UsesFixedRotationPivot: element.IsDynamic),
}));
}

foreach (var control in template.AddressControls)
{
drawGroups.Add((control.RenderOrder, BuildAddressControlDraws(control, record).ToList()));
}

return drawGroups
.OrderBy(g => g.RenderOrder)
.SelectMany(g => g.Draws)
.ToList();
}

/// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
/// <see cref="TemplateAddressControl.RotationAngle"/> is non-zero, every visible line's own
/// (record-collapse-adjusted) anchor is first rotated as a rigid group around the control's
/// fixed, authored <see cref="TemplateAddressControl.BoxCenter"/> pivot — never a per-record
/// value — and the already-rotated anchor is then handed to the existing fixed-pivot
/// <see cref="TextDraw"/> path exactly as a rotated dynamic/mixed standalone element already
/// does (see the Sprint 6 defect fix), so <c>DebenuPdfRenderer</c> needs no changes at all:
/// <c>DrawRotatedText</c> rotates the line's own text by <see cref="TemplateAddressControl.RotationAngle"/>
/// around that exact already-correct point, giving the whole block a single rigid rotation.</summary>
private static IEnumerable<TextDraw> BuildAddressControlDraws(
TemplateAddressControl control, IReadOnlyDictionary<string, string> record)
{
var resolvedText = new string[control.Lines.Count];
var collapseLines = new AddressLineCollapser.Line[control.Lines.Count];

var y = control.Y;
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
resolvedText[i] = ResolveText(line.Runs, record);
collapseLines[i] = new AddressLineCollapser.Line(
control.X,
y,
line.Collapsible && string.IsNullOrWhiteSpace(resolvedText[i]));
y -= line.Size * control.LineSpacingMultiplier;
}

var resolved = AddressLineCollapser.Resolve(collapseLines);
var angle = control.RotationAngle;
var pivot = angle != 0 ? control.BoxCenter : default;

for (var i = 0; i < control.Lines.Count; i++)
{
if (!resolved[i].Visible)
{
continue;
}

var line = control.Lines[i];
var drawX = control.X;
var drawY = resolved[i].EffectiveY;
if (angle != 0)
{
(drawX, drawY) = RotatePointAroundPivot(drawX, drawY, pivot, angle);
}

yield return new TextDraw(
drawX,
drawY,
line.FontName,
line.Size,
resolvedText[i],
Angle: angle,
UsesFixedRotationPivot: angle != 0 || line.IsDynamic);
}
}

/// <summary>Sprint 8: standard rotate-a-point-around-a-pivot transform, positive angle =
/// counterclockwise — matching this project's stored rotation-angle convention (see
/// <c>RotatedTextAnchorCalculator</c>'s empirically-confirmed sign convention) and the same
/// formula the desktop side already had for single-element hit-testing
/// (<c>CanvasElementEditor.RotatePointAroundPivot</c>). No equivalent existed in this CLI
/// project before this story.</summary>
private static (double X, double Y) RotatePointAroundPivot(
double x, double y, (double X, double Y) pivot, double angleDegrees)
{
var dx = x - pivot.X;
var dy = y - pivot.Y;

var radians = angleDegrees * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);

return (pivot.X + ((dx * cos) - (dy * sin)), pivot.Y + ((dx * sin) + (dy * cos)));
}

/// <summary>Sprint 5: resolves one element's full run sequence into the single concatenated
/// string the existing draw call already expects — every run's contribution back-to-back, in
/// order, exactly matching what the pre-Sprint-5 single-run code did as the one-run special
/// case. A field run's value is looked up directly (safe: the pre-flight check above already
/// guaranteed every referenced column exists in <paramref name="record"/>'s header set before
/// any record is resolved).</summary>
private static string ResolveText(TemplateElement element, IReadOnlyDictionary<string, string> record)
=> ResolveText(element.Runs, record);

private static string ResolveText(IReadOnlyList<TemplateTextRun> runs, IReadOnlyDictionary<string, string> record)
{
if (runs.Count == 1)
{
var only = runs[0];
return only.IsField ? record[only.ColumnName!] : only.Literal ?? string.Empty;
}

var builder = new System.Text.StringBuilder();
foreach (var run in runs)
{
builder.Append(run.IsField ? record[run.ColumnName!] : run.Literal ?? string.Empty);
}

return draws;
return builder.ToString();
}
}

+ 64
- 0
code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs Целия файл

@@ -0,0 +1,64 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>A composite address block authored as one movable control. The anchor is the
/// baseline position of the first line; subsequent line baselines are derived automatically from
/// each previous line's font size and <see cref="LineSpacingMultiplier"/>.</summary>
/// <param name="RotationAngle">Sprint 8, "Rotate the whole Address Control as a single unit":
/// degrees, positive = counterclockwise (the same convention <c>TemplateElement.Angle</c> already
/// uses), default <c>0</c> so every template written before this story renders unchanged. The
/// whole control — every line, together — rotates as one rigid unit around <see cref="BoxCenter"/>,
/// the control's own authored, unrotated box center. <see cref="BoxCenter"/> is computed purely
/// from <see cref="X"/>/<see cref="Y"/>/<see cref="Width"/>/<see cref="Height"/> and each line's
/// authored <c>Size</c> — never from any record's resolved text or
/// <c>AddressLineCollapser</c>-shifted line position — so the pivot is stable across every record
/// regardless of which lines happen to collapse for it.</param>
public sealed record TemplateAddressControl(
double X,
double Y,
double Width,
IReadOnlyList<TemplateAddressControlLine> Lines,
double LineSpacingMultiplier = TemplateAddressControl.DefaultLineSpacingMultiplier,
int RenderOrder = 0,
double RotationAngle = 0)
{
public const double DefaultLineSpacingMultiplier = 1.25;

/// <summary>The control's total authored vertical extent, from the first line's own font size
/// down through every subsequent line's spacing — the same "author-set box, never measured
/// text" invariant <see cref="Width"/> already has. Mirrors
/// <c>EnvelopeRenderer.Desktop.Core.Design.AddressControlLayout.Height</c> exactly (an
/// independently-implemented parity, per this project's established CLI/desktop split).</summary>
public double Height
{
get
{
if (Lines.Count == 0)
{
return 0;
}

var total = Lines[0].Size;
for (var i = 1; i < Lines.Count; i++)
{
total += Lines[i - 1].Size * LineSpacingMultiplier;
}

return total;
}
}

/// <summary>The fixed pivot the whole control rotates around — the center of the same
/// authored box the designer canvas draws as this control's selection border (top edge at
/// <c>Y + tallest line's font size</c>, bottom edge at <c>Y - Height</c>, so the visible box
/// and the rotation pivot always agree).</summary>
public (double X, double Y) BoxCenter
{
get
{
var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.Size);
var top = Y + topExtension;
var bottom = Y - Height;
return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0));
}
}
}

+ 13
- 0
code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs Целия файл

@@ -0,0 +1,13 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>One line inside an address control. Each line uses the same Sprint 5 run sequence as
/// a standalone text element, but its X/Y position is derived from the parent control's anchor
/// and automatic line spacing instead of being authored independently.</summary>
public sealed record TemplateAddressControlLine(
string FontName,
double Size,
IReadOnlyList<TemplateTextRun> Runs,
bool Collapsible = true)
{
public bool IsDynamic => Runs.Any(r => r.IsField);
}

+ 11
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs Целия файл

@@ -6,4 +6,14 @@ namespace EnvelopeRenderer.Cli.Render;
/// points rather than a named paper size: Debenu's only named-size function (SetPageSize)
/// covers ISO/ANSI paper sizes and DL but no US envelope sizes, while SetPageDimensions takes
/// exact points and covers every case uniformly — a #10 envelope is 297 x 684.</summary>
public sealed record TemplateDocument(double PageWidth, double PageHeight, IReadOnlyList<TemplateElement> Elements);
public sealed record TemplateDocument(
double PageWidth,
double PageHeight,
IReadOnlyList<TemplateElement> Elements,
IReadOnlyList<TemplateAddressControl> AddressControls)
{
public TemplateDocument(double pageWidth, double pageHeight, IReadOnlyList<TemplateElement> elements)
: this(pageWidth, pageHeight, elements, Array.Empty<TemplateAddressControl>())
{
}
}

+ 41
- 9
code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs Целия файл

@@ -1,11 +1,14 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>
/// One `&lt;text&gt;` element from the template: either static (fixed <see cref="StaticText"/>)
/// or dynamic (pulled per record from <see cref="ColumnName"/>). Never both, never neither —
/// <see cref="TemplateXmlParser"/> enforces that at parse time. Interpolating a column into a
/// literal string is out of scope for this sprint ("Create a dynamic text token from a CSV
/// column" is a deferred story).
/// One `&lt;text&gt;` element from the template. Sprint 5, "Mix static text and CSV fields within
/// a single text element": content is now an ordered sequence of <see cref="TemplateTextRun"/>s
/// (literal-text runs and field-token runs), concatenated per record at render time — the
/// pre-Sprint-5 "either pure static text or a single bound column" rule is simply the one-run
/// special case of this same model (<see cref="StaticText"/>/<see cref="ColumnName"/> below are
/// back-compat read-only projections for that one-run case, not separate storage), not a
/// parallel code path that could drift from it. <see cref="TemplateXmlParser"/> enforces "every
/// run is exactly one of literal/field" at parse time.
/// </summary>
/// <param name="Collapsible">Sprint 4, "Collapse blank optional address lines consistently":
/// when <c>true</c> and this element's resolved text is blank for a given record, the element
@@ -22,10 +25,39 @@ public sealed record TemplateElement(
double Y,
string FontName,
double Size,
string? StaticText,
string? ColumnName,
IReadOnlyList<TemplateTextRun> Runs,
bool Collapsible = false,
double Angle = 0)
double Angle = 0,
int RenderOrder = 0)
{
public bool IsDynamic => ColumnName is not null;
/// <summary>Back-compat convenience factory matching the pre-Sprint-5 "pure static text"
/// shape — builds the equivalent one-literal-run <see cref="Runs"/> list. (A second
/// constructor overload was deliberately rejected here in favor of named factory methods: a
/// positional/named-argument overload alongside the primary constructor's own
/// <c>Collapsible</c>/<c>Angle</c> parameters is ambiguous to the compiler — CS1744 — for any
/// caller using named arguments for either, which every <c>Collapsible: true</c>/
/// <c>Angle: 45</c> call site in this codebase does.)</summary>
public static TemplateElement Static(
double x, double y, string fontName, double size, string text, bool collapsible = false, double angle = 0) =>
new(x, y, fontName, size, new[] { new TemplateTextRun(text, null) }, collapsible, angle);

/// <summary>Back-compat convenience factory matching the pre-Sprint-5 "single bound column"
/// shape — builds the equivalent one-field-run <see cref="Runs"/> list.</summary>
public static TemplateElement Dynamic(
double x, double y, string fontName, double size, string columnName, bool collapsible = false, double angle = 0) =>
new(x, y, fontName, size, new[] { new TemplateTextRun(null, columnName) }, collapsible, angle);

public bool IsDynamic => Runs.Any(r => r.IsField);

/// <summary>True only for the legacy "exactly one field run, nothing else" shape — the only
/// case that still has a single, unambiguous bound column (used by the render-time pre-flight
/// column check's error-message grouping and by any code that only makes sense for a
/// single-column binding).</summary>
public bool HasSingleColumnRun => Runs.Count == 1 && Runs[0].IsField;

/// <summary>Back-compat projection: non-null only for the legacy single-literal-run case.</summary>
public string? StaticText => Runs.Count == 1 && !Runs[0].IsField ? Runs[0].Literal : null;

/// <summary>Back-compat projection: non-null only for the legacy single-field-run case.</summary>
public string? ColumnName => HasSingleColumnRun ? Runs[0].ColumnName : null;
}

+ 14
- 0
code/src/EnvelopeRenderer.Cli/Render/TemplateTextRun.cs Целия файл

@@ -0,0 +1,14 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>
/// Sprint 5, "Mix static text and CSV fields within a single text element": one piece of a
/// <see cref="TemplateElement"/>'s content — either a literal run (<see cref="Literal"/> set,
/// printed as-is) or a field run (<see cref="ColumnName"/> set, resolved from the current CSV
/// record at render time). Never both, never neither, mirroring the pre-Sprint-5
/// <c>StaticText</c>/<c>ColumnName</c> exclusivity rule at the granularity of one run instead of
/// one whole element.
/// </summary>
public sealed record TemplateTextRun(string? Literal, string? ColumnName)
{
public bool IsField => ColumnName is not null;
}

+ 209
- 13
code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs Целия файл

@@ -42,18 +42,36 @@ public static class TemplateXmlParser
var pageHeight = ParseRequiredDouble(root, "pageHeight", "<envelopeTemplate>", errors, mustBePositive: true);

var elements = new List<TemplateElement>();
var textNodes = root.Elements().Where(e => e.Name.LocalName == "text").ToList();
var addressControls = new List<TemplateAddressControl>();
var renderNodes = root.Elements()
.Where(e => e.Name.LocalName is "text" or "addressControl")
.ToList();

if (textNodes.Count == 0)
if (renderNodes.Count == 0)
{
errors.Add("Template has no <text> elements — nothing to render.");
errors.Add("Template has no <text> or <addressControl> elements — nothing to render.");
}

var index = 0;
foreach (var node in textNodes)
var textIndex = 0;
var addressIndex = 0;
var renderOrder = 0;
foreach (var node in renderNodes)
{
index++;
var label = $"<text> #{index}";
if (node.Name.LocalName == "addressControl")
{
addressIndex++;
var parsed = ParseAddressControl(node, addressIndex, renderOrder, errors);
if (parsed is not null)
{
addressControls.Add(parsed);
}

renderOrder++;
continue;
}

textIndex++;
var label = $"<text> #{textIndex}";

var x = ParseRequiredDouble(node, "x", label, errors);
var y = ParseRequiredDouble(node, "y", label, errors);
@@ -69,7 +87,28 @@ public static class TemplateXmlParser
var hasColumn = !string.IsNullOrWhiteSpace(column);
var hasStaticText = !string.IsNullOrWhiteSpace(staticText);

if (hasColumn && hasStaticText)
// Sprint 5, "Mix static text and CSV fields within a single text element": a <text>
// element with one or more <run> children carries multi-run mixed content instead of
// the legacy single column/inline-text shape. This is a genuinely new, unambiguous
// XML shape (never present in any pre-Sprint-5 template) rather than a reinterpretation
// of existing inline text — so no pre-existing template can ever be misparsed by this
// branch, which is what keeps AC2's "existing templates render identically" true by
// construction, not by convention.
var runNodes = node.Elements().Where(e => e.Name.LocalName == "run").ToList();
IReadOnlyList<TemplateTextRun>? runs = null;

if (runNodes.Count > 0)
{
if (hasColumn || hasStaticText)
{
errors.Add($"{label} has <run> children and also a 'column' attribute or inline text — use exactly one form.");
}
else
{
runs = ParseRuns(runNodes, label, errors);
}
}
else if (hasColumn && hasStaticText)
{
errors.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
}
@@ -77,6 +116,13 @@ public static class TemplateXmlParser
{
errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
}
else
{
runs = new[]
{
hasColumn ? new TemplateTextRun(null, column) : new TemplateTextRun(staticText, null),
};
}

// `collapsible` (Sprint 4, "Collapse blank optional address lines consistently") and
// `angle` (Sprint 4, "Set a rotation angle...") are both optional and default to the
@@ -91,7 +137,7 @@ public static class TemplateXmlParser

var angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0);

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText)
if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null
|| angle is null)
{
continue;
@@ -102,10 +148,11 @@ public static class TemplateXmlParser
y.Value,
font,
size.Value,
hasStaticText ? staticText : null,
hasColumn ? column : null,
runs,
collapsible,
angle.Value));
angle.Value,
renderOrder));
renderOrder++;
}

if (errors.Count > 0)
@@ -113,7 +160,156 @@ public static class TemplateXmlParser
return TemplateParseResult.Failure(errors.ToArray());
}

return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements));
return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements, addressControls));
}

private static TemplateAddressControl? ParseAddressControl(
XElement node, int index, int renderOrder, List<string> errors)
{
var label = $"<addressControl> #{index}";

var x = ParseRequiredDouble(node, "x", label, errors);
var y = ParseRequiredDouble(node, "y", label, errors);
var width = ParseRequiredDouble(node, "width", label, errors, mustBePositive: true);
var lineSpacing = ParseOptionalDouble(
node, "lineSpacing", label, errors, TemplateAddressControl.DefaultLineSpacingMultiplier);
if (lineSpacing is <= 0)
{
errors.Add($"{label} has a non-positive 'lineSpacing' value: '{lineSpacing}'.");
lineSpacing = null;
}

// Sprint 8, "Rotate the whole Address Control as a single unit": optional, defaults to 0
// so every template written before this story renders unchanged — same convention as
// <text>'s own `angle` attribute.
var angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0);

var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList();
if (lineNodes.Count == 0)
{
errors.Add($"{label} must contain at least one <line> child.");
}

var lines = new List<TemplateAddressControlLine>();
for (var i = 0; i < lineNodes.Count; i++)
{
var line = ParseAddressControlLine(lineNodes[i], $"{label} <line> #{i + 1}", errors);
if (line is not null)
{
lines.Add(line);
}
}

if (x is null || y is null || width is null || lineSpacing is null || angle is null || lines.Count == 0)
{
return null;
}

return new TemplateAddressControl(
x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value);
}

private static TemplateAddressControlLine? ParseAddressControlLine(
XElement node, string label, List<string> errors)
{
var size = ParseRequiredDouble(node, "size", label, errors, mustBePositive: true);
var font = (string?)node.Attribute("font");
if (string.IsNullOrWhiteSpace(font))
{
errors.Add($"{label} is missing a required 'font' attribute.");
}

var collapsible = true;
var collapsibleRaw = (string?)node.Attribute("collapsible");
if (!string.IsNullOrWhiteSpace(collapsibleRaw) && !bool.TryParse(collapsibleRaw, out collapsible))
{
errors.Add($"{label} has an invalid 'collapsible' value: '{collapsibleRaw}' (expected 'true' or 'false').");
}

var runs = ParseContentRuns(node, label, errors);
if (size is null || string.IsNullOrWhiteSpace(font) || runs is null)
{
return null;
}

return new TemplateAddressControlLine(font, size.Value, runs, collapsible);
}

/// <summary>Sprint 5: parses a `&lt;text&gt;` element's `&lt;run&gt;` children into an ordered
/// run list. Each `&lt;run&gt;` must carry exactly one of `text` (a literal run, printed as-is
/// — may be empty, e.g. a run of pure whitespace between two field runs) or `column` (a field
/// run, resolved per record). Returns <c>null</c> (having already recorded at least one error)
/// if any run is malformed, mirroring the legacy single-run validation style.</summary>
private static List<TemplateTextRun>? ParseRuns(List<XElement> runNodes, string label, List<string> errors)
{
var runs = new List<TemplateTextRun>();
var runIndex = 0;
var hadError = false;

foreach (var runNode in runNodes)
{
runIndex++;
var runLabel = $"{label} <run> #{runIndex}";

var runColumn = (string?)runNode.Attribute("column");
var runText = (string?)runNode.Attribute("text");
var runHasColumn = !string.IsNullOrWhiteSpace(runColumn);
var runHasText = runText is not null;

if (runHasColumn && runHasText)
{
errors.Add($"{runLabel} has both a 'text' attribute and a 'column' attribute — use exactly one.");
hadError = true;
}
else if (!runHasColumn && !runHasText)
{
errors.Add($"{runLabel} has neither a 'text' attribute nor a 'column' attribute — use exactly one.");
hadError = true;
}
else
{
runs.Add(runHasColumn ? new TemplateTextRun(null, runColumn) : new TemplateTextRun(runText, null));
}
}

return hadError ? null : runs;
}

private static IReadOnlyList<TemplateTextRun>? ParseContentRuns(XElement node, string label, List<string> errors)
{
var column = (string?)node.Attribute("column");
var staticText = node.Nodes().OfType<XText>().Select(t => t.Value).FirstOrDefault();
var hasColumn = !string.IsNullOrWhiteSpace(column);
var hasStaticText = !string.IsNullOrWhiteSpace(staticText);
var runNodes = node.Elements().Where(e => e.Name.LocalName == "run").ToList();

if (runNodes.Count > 0)
{
if (hasColumn || hasStaticText)
{
errors.Add($"{label} has <run> children and also a 'column' attribute or inline text — use exactly one form.");
return null;
}

return ParseRuns(runNodes, label, errors);
}

if (hasColumn && hasStaticText)
{
errors.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
return null;
}

if (!hasColumn && !hasStaticText)
{
errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
return null;
}

return new[]
{
hasColumn ? new TemplateTextRun(null, column) : new TemplateTextRun(staticText, null),
};
}

/// <summary>Like <see cref="ParseRequiredDouble"/> but for an attribute that is allowed to be


+ 14
- 6
code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs Целия файл

@@ -3,9 +3,17 @@ namespace EnvelopeRenderer.Cli.Render;
/// <summary>A single resolved piece of text to place on the current page, in PDF points measured
/// from the page's bottom-left corner (Debenu's default coordinate system — deliberately not
/// remapped via SetOrigin/SetMeasurementUnits so template authors get the plain PDF convention).
/// <paramref name="Angle"/> is degrees, positive or negative, applied around the *bounding-box
/// center* implied by (X, Y) plus this text's measured width/ascent/descent at (FontName, Size)
/// — not around (X, Y) itself, which is what Debenu's own `DrawRotatedText` rotates around. See
/// <see cref="DebenuPdfRenderer"/> for the anchor-offset math that reconciles the two. Defaults
/// to <c>0</c> (no rotation), matching every render before Sprint 4's rotation story.</summary>
public sealed record TextDraw(double X, double Y, string FontName, double Size, string Text, double Angle = 0);
/// <paramref name="Angle"/> is degrees, positive or negative. Static text keeps Sprint 4's
/// original behavior: rotate around the bounding-box center implied by (X, Y) plus this text's
/// measured width/ascent/descent at (FontName, Size). Variable-width dynamic/mixed content sets
/// <paramref name="UsesFixedRotationPivot"/> so rotation happens around the authored (X, Y)
/// anchor instead, avoiding a different pivot for every record's resolved text width. Defaults
/// preserve every pre-rotation render and every static rotated render.</summary>
public sealed record TextDraw(
double X,
double Y,
string FontName,
double Size,
string Text,
double Angle = 0,
bool UsesFixedRotationPivot = false);

+ 95
- 0
code/src/EnvelopeRenderer.Desktop.Core/Csv/CsvRecordNavigator.cs Целия файл

@@ -0,0 +1,95 @@
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;

namespace EnvelopeRenderer.Desktop.Core.Csv;

/// <summary>
/// Sprint 7, "Jump to a specific record number": reads one arbitrary 1-based data record from a
/// CSV file via a forward-only streaming reader — the same reading approach (and the same
/// CsvHelper configuration) as <c>EnvelopeRenderer.Cli.Render.CsvRecordSource</c>, which is
/// already proven at 100k+-record scale (Sprint 3's throughput work). Deliberately not a shared
/// reference to that CLI type, per this project's deliberate desktop/CLI split (see
/// `EnvelopeRenderer.Desktop.csproj`'s remarks) — this is a new, independent skip/take integration
/// of an already-proven *pattern*, not new large-file infrastructure, and not the bounded 20-row
/// <see cref="CsvPreviewLoader"/> used for the initial column-mapping grid.
///
/// Never throws — every failure mode (missing file, out-of-range record number, malformed CSV) is
/// surfaced as an operator-facing <c>Error</c> on the returned <see cref="Result"/>, matching the
/// `TryLoad`-style error handling this project already uses for CSV and template file I/O.
/// </summary>
public static class CsvRecordNavigator
{
/// <param name="Success">Whether <paramref name="Record"/> was successfully read.</param>
/// <param name="Record">The resolved header -> value record (case-insensitive), or <c>null</c>
/// on failure.</param>
/// <param name="Error">A clear, operator-facing message on failure, or <c>null</c> on success.</param>
public readonly record struct Result(bool Success, IReadOnlyDictionary<string, string>? Record, string? Error);

/// <summary>Reads the 1-based <paramref name="recordNumber"/>-th data record (i.e. record 1 is
/// the first row after the header) from the CSV at <paramref name="csvPath"/>, scanning
/// forward from the start of the file each call — no whole-file buffering, so this remains
/// safe against very large CSVs even though repeated jumps each re-scan from the top (an
/// accepted simplicity trade-off for this first record-navigation story; see
/// `logs/technical_debt_log.md` if repeated large-file re-scanning ever needs optimizing).</summary>
public static Result TryReadRecord(string? csvPath, int recordNumber)
{
if (recordNumber < 1)
{
return new Result(false, null, "Record number must be 1 or greater.");
}

if (string.IsNullOrWhiteSpace(csvPath))
{
return new Result(false, null, "No CSV file is loaded.");
}

if (!File.Exists(csvPath))
{
return new Result(false, null, $"CSV file not found: '{csvPath}'.");
}

try
{
using var reader = new StreamReader(csvPath);
using var csv = new CsvReader(reader, CreateConfig());
csv.Read();
csv.ReadHeader();
var headers = csv.HeaderRecord ?? Array.Empty<string>();

var current = 0;
while (csv.Read())
{
current++;
if (current != recordNumber)
{
continue;
}

var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in headers)
{
row[header] = csv.GetField(header) ?? string.Empty;
}

return new Result(true, row, null);
}

return new Result(
false,
null,
current == 0
? "The CSV has no data rows."
: $"Record {recordNumber} is out of range — the CSV has {current} data row(s).");
}
catch (Exception ex) when (ex is CsvHelperException or IOException or UnauthorizedAccessException)
{
return new Result(false, null, $"CSV file could not be read: {ex.Message}");
}
}

private static CsvConfiguration CreateConfig() => new(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
};
}

+ 22
- 31
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressBlockPreviewCalculator.cs Целия файл

@@ -8,6 +8,16 @@ namespace EnvelopeRenderer.Desktop.Core.Design;
/// (the "Preview and final render must agree" conversation note) and so the operator has a way to
/// tell "intentionally blank per data" apart from "field failed to map" (this story's fourth
/// acceptance criterion). Kept WinForms/GDI-free so it is independently unit testable.
///
/// Sprint 5, "Mix static text and CSV fields within a single text element": generalized from
/// "one element, at most one bound column" to "one element, N runs, any subset of which may be
/// field runs" — unmapped-column detection and sample-text resolution now both operate per run
/// (see <see cref="ElementPreviewState.UnmappedRuns"/>).
///
/// Sprint 7, "Render an accurate, record-specific preview...": resolution and unmapped-flag logic
/// were extracted into <see cref="TextResolver"/> so this class and the new
/// <see cref="TemplatePreviewBuilder"/> share exactly one implementation instead of two
/// independently-maintained near-duplicates.
/// </summary>
public static class AddressBlockPreviewCalculator
{
@@ -28,21 +38,22 @@ public static class AddressBlockPreviewCalculator
IReadOnlyList<string> csvHeaders,
IReadOnlyDictionary<string, string>? sampleRecord)
{
var headerSet = new HashSet<string>(csvHeaders, StringComparer.OrdinalIgnoreCase);

var isUnmapped = new bool[elements.Count];
var elementUnmapped = new bool[elements.Count];
var runUnmapped = new IReadOnlyList<bool>[elements.Count];
var lines = new AddressLineCollapser.Line[elements.Count];

for (var i = 0; i < elements.Count; i++)
{
var element = elements[i];
var flags = TextResolver.ComputeUnmappedFlags(element.Runs, csvHeaders);
runUnmapped[i] = flags;
elementUnmapped[i] = flags.Any(f => f);

if (element.IsDynamic)
{
isUnmapped[i] = headerSet.Count > 0 && !headerSet.Contains(element.ColumnName!);
}

var resolvedText = ResolveSampleText(element, sampleRecord, isUnmapped[i]);
// A run bound to an unmapped column is never resolvable ("unknown," not "blank"), so
// it must never be treated as an intentional blank worth collapsing away — TryResolve
// already returns null in that case (same rule TextResolver applies uniformly), which
// is exactly the behavior this element-level check needs.
var resolvedText = TextResolver.TryResolve(element.Runs, csvHeaders, sampleRecord);
var shouldCollapse = element.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
lines[i] = new AddressLineCollapser.Line(element.X, element.Y, shouldCollapse);
}
@@ -52,30 +63,10 @@ public static class AddressBlockPreviewCalculator
var result = new Dictionary<Guid, ElementPreviewState>(elements.Count);
for (var i = 0; i < elements.Count; i++)
{
result[elements[i].Id] = new ElementPreviewState(resolved[i].Visible, resolved[i].EffectiveY, isUnmapped[i]);
result[elements[i].Id] = new ElementPreviewState(
resolved[i].Visible, resolved[i].EffectiveY, elementUnmapped[i], runUnmapped[i]);
}

return result;
}

/// <summary>Static text always "resolves" to itself (it never varies by record). A dynamic
/// element resolves from the sample row when one is loaded and the column is actually mapped;
/// an unmapped or not-yet-loaded column resolves to <c>null</c> ("unknown," not "blank") so it
/// is never treated as an intentional blank worth collapsing — that would silently mask a
/// real mapping error.</summary>
private static string? ResolveSampleText(
TextElementLayout element, IReadOnlyDictionary<string, string>? sampleRecord, bool isUnmapped)
{
if (!element.IsDynamic)
{
return element.StaticText;
}

if (isUnmapped || sampleRecord is null)
{
return null;
}

return sampleRecord.TryGetValue(element.ColumnName!, out var value) ? value : null;
}
}

+ 120
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs Целия файл

@@ -0,0 +1,120 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>A composite address block placed by one anchor. The first line baseline is at
/// <see cref="X"/>/<see cref="Y"/>; subsequent baselines are computed automatically from each
/// previous line's font size and <see cref="LineSpacingMultiplier"/>.</summary>
public sealed class AddressControlLayout
{
public const double DefaultWidth = 180.0;
public const double DefaultLineSpacingMultiplier = 1.25;

public Guid Id { get; } = Guid.NewGuid();
public double X { get; set; }
public double Y { get; set; }
public double Width { get; set; } = DefaultWidth;
public int ZOrder { get; set; }
public double LineSpacingMultiplier { get; set; } = DefaultLineSpacingMultiplier;

/// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": degrees, positive
/// = counterclockwise (the same convention <see cref="TextElementLayout.RotationAngle"/>
/// already uses), default 0. The whole control rotates as one rigid unit around
/// <see cref="BoxCenter"/>.</summary>
public double RotationAngle { get; set; }

public List<AddressControlLineLayout> Lines { get; } = new();

public static AddressControlLayout CreateDefault(double x, double y, int zOrder = 0)
{
var control = new AddressControlLayout { X = x, Y = y, ZOrder = zOrder };
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name"));
return control;
}

public AddressControlLineLayout AddLine(string literalText = "")
{
var line = AddressControlLineLayout.CreateLiteral(literalText);
Lines.Add(line);
return line;
}

public bool RemoveLineAt(int index)
{
if (index < 0 || index >= Lines.Count || Lines.Count == 1)
{
return false;
}

Lines.RemoveAt(index);
return true;
}

public bool MoveLineUp(int index)
{
if (index <= 0 || index >= Lines.Count)
{
return false;
}

(Lines[index - 1], Lines[index]) = (Lines[index], Lines[index - 1]);
return true;
}

public bool MoveLineDown(int index)
{
if (index < 0 || index >= Lines.Count - 1)
{
return false;
}

(Lines[index + 1], Lines[index]) = (Lines[index], Lines[index + 1]);
return true;
}

public double BaselineYForLine(int index)
{
var y = Y;
for (var i = 0; i < index && i < Lines.Count; i++)
{
y -= Lines[i].FontSize * LineSpacingMultiplier;
}

return y;
}

public double Height
{
get
{
if (Lines.Count == 0)
{
return 0;
}

var total = Lines[0].FontSize;
for (var i = 1; i < Lines.Count; i++)
{
total += Lines[i - 1].FontSize * LineSpacingMultiplier;
}

return total;
}
}

/// <summary>Sprint 8: the fixed pivot the whole control rotates around — the center of the
/// same authored box <c>TemplateCanvasControl.DrawAddressControl</c> already draws as this
/// control's border (top edge at <c>Y + tallest line's font size</c>, bottom edge at
/// <c>Y - Height</c>), computed purely from authored <see cref="X"/>/<see cref="Y"/>/
/// <see cref="Width"/>/<see cref="Height"/> — never from any record's resolved text or
/// <see cref="AddressLineCollapser"/>-shifted line position, so the pivot is identical across
/// every record regardless of which lines happen to collapse for it.</summary>
public (double X, double Y) BoxCenter
{
get
{
var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.FontSize);
var top = Y + topExtension;
var bottom = Y - Height;
return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0));
}
}
}

+ 35
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLineLayout.cs Целия файл

@@ -0,0 +1,35 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>One editable line inside an address control. Content uses the same Sprint 5 run
/// sequence as standalone text, but position is derived from the parent control.</summary>
public sealed class AddressControlLineLayout
{
public string FontFamily { get; set; }
public double FontSize { get; set; }
public RgbColor Color { get; set; } = RgbColor.Black;
public bool CollapseIfBlank { get; set; } = true;
public List<TextRun> Runs { get; } = new() { TextRun.ForLiteral(string.Empty) };

public AddressControlLineLayout(string fontFamily = "Arial", double fontSize = 12)
{
FontFamily = fontFamily;
FontSize = fontSize;
}

public bool IsDynamic => Runs.Any(r => r.IsField);
public string DisplayText => TextRunTextConverter.ToEditableText(Runs);

public static AddressControlLineLayout CreateLiteral(string text, string fontFamily = "Arial", double fontSize = 12)
{
var line = new AddressControlLineLayout(fontFamily, fontSize);
line.Runs[0] = TextRun.ForLiteral(text);
return line;
}

public static AddressControlLineLayout CreateField(string columnName, string fontFamily = "Arial", double fontSize = 12)
{
var line = new AddressControlLineLayout(fontFamily, fontSize);
line.Runs[0] = TextRun.ForField(columnName);
return line;
}
}

+ 84
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs Целия файл

@@ -0,0 +1,84 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": UI-independent
/// handle-position/hit-test/rotate-drag math for the whole-Address-Control drag handle. This
/// mirrors the *shape* of <see cref="CanvasElementEditor"/>'s own per-element handle math
/// (<c>HandlePosition</c>/<c>HitTestHandle</c>/<c>RotateDragTo</c>) but is a new, analogous
/// implementation against the control's own box geometry (<see cref="AddressControlLayout.BoxCenter"/>)
/// rather than a measured text size — <see cref="CanvasElementEditor"/> only ever operates on a
/// single selected <see cref="TextElementLayout"/> and has no equivalent state for a selected
/// <see cref="AddressControlLayout"/>. Kept framework-free/pure (no WinForms/GDI+ dependency) so it
/// is independently unit tested, matching this project's established "coordinate math lives outside
/// WinForms, only drawing/mouse-plumbing stays in the Views project" convention
/// (<see cref="CanvasElementEditor"/>'s own class remarks).
/// </summary>
public static class AddressControlRotateHandle
{
/// <summary>The box's own local (unrotated) top-center point — the connector line's near end,
/// and the point <see cref="LocalPosition"/>'s offset is measured from. Matches the same top
/// edge <c>TemplateCanvasControl.DrawAddressControl</c> already draws the box's border at.</summary>
public static (double X, double Y) LocalOrigin(AddressControlLayout control)
{
var maxFontSize = control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
return (control.X + (control.Width / 2.0), control.Y + maxFontSize);
}

/// <summary>The handle's local (unrotated) position: the box's own top-center plus
/// <see cref="CanvasElementEditor.HandleOffsetPoints"/> straight out along the box's local "up"
/// direction — reusing the exact same offset constant the standalone-element handle uses, per
/// this story's own "match the standalone handle for consistency" recommendation.</summary>
public static (double X, double Y) LocalPosition(AddressControlLayout control)
{
var (centerX, topY) = LocalOrigin(control);
return (centerX, topY + CanvasElementEditor.HandleOffsetPoints);
}

/// <summary>The handle's actual world-space (canvas-point) position: its local position,
/// forward-rotated by <see cref="AddressControlLayout.RotationAngle"/> around
/// <see cref="AddressControlLayout.BoxCenter"/> — the same pivot the whole control visually
/// rotates around, so the handle orbits the control exactly the way it is drawn, and what's
/// clickable matches what's drawn.</summary>
public static (double X, double Y) WorldPosition(AddressControlLayout control)
{
var local = LocalPosition(control);
return control.RotationAngle == 0
? local
: PointRotation.RotateAroundPivot(local.X, local.Y, control.BoxCenter, control.RotationAngle);
}

/// <summary>Whether the given canvas-space point is within grab range of the handle — plain
/// point-space distance against <see cref="CanvasElementEditor.HandleHitRadiusPoints"/>, the
/// same constant and comparison style <see cref="CanvasElementEditor.HitTestHandle"/> already
/// uses for a standalone element's handle (no pixel/zoom-scale adjustment, for consistency).</summary>
public static bool HitTest(AddressControlLayout control, double xPoints, double yPoints)
{
var handle = WorldPosition(control);
var dx = xPoints - handle.X;
var dy = yPoints - handle.Y;
return (dx * dx) + (dy * dy) <= CanvasElementEditor.HandleHitRadiusPoints * CanvasElementEditor.HandleHitRadiusPoints;
}

/// <summary>Updates <paramref name="control"/>'s <see cref="AddressControlLayout.RotationAngle"/>
/// from the pointer's current position, via <c>atan2</c> of the pointer relative to the
/// control's <see cref="AddressControlLayout.BoxCenter"/> pivot — the same math
/// <c>CanvasElementEditor.RotateDragTo</c> uses for a standalone element. No-op if the pointer
/// is exactly on the pivot (an undefined direction — left at its last angle rather than
/// snapping arbitrarily).</summary>
public static void RotateDragTo(AddressControlLayout control, double xPoints, double yPoints)
{
var pivot = control.BoxCenter;
var dx = xPoints - pivot.X;
var dy = yPoints - pivot.Y;

if (dx == 0 && dy == 0)
{
return;
}

var zeroHandle = LocalPosition(control);
var zeroAngleDegrees = Math.Atan2(zeroHandle.Y - pivot.Y, zeroHandle.X - pivot.X) * 180.0 / Math.PI;
var angleToPointerDegrees = Math.Atan2(dy, dx) * 180.0 / Math.PI;
control.RotationAngle = angleToPointerDegrees - zeroAngleDegrees;
}
}

+ 85
- 47
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs Целия файл

@@ -27,6 +27,10 @@ public sealed class CanvasElementEditor
/// select/move click on the element body.</summary>
public const double HandleHitRadiusPoints = 8.0;

/// <summary>Sprint 7, "Snap elements to grid and guides": the default grid increment (canvas-
/// space points) when snapping is enabled but no explicit size has been set.</summary>
public const double DefaultGridSizePoints = 10.0;

private readonly TemplateLayoutDocument _document;
private readonly Func<TextElementLayout, (double Width, double Height)> _measureText;
private (double Dx, double Dy)? _dragOffset;
@@ -39,6 +43,15 @@ public sealed class CanvasElementEditor
_measureText = measureText;
}

/// <summary>Sprint 7: whether dragged/resized positions should round to the nearest
/// <see cref="GridSizePoints"/> increment. Defaults to <c>false</c> so every existing
/// drag/resize behavior is unchanged unless an operator explicitly opts in.</summary>
public bool SnapToGridEnabled { get; set; }

/// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to when
/// <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
public double GridSizePoints { get; set; } = DefaultGridSizePoints;

public TextElementLayout? Selected { get; private set; }

public TextElementLayout AddStaticText(
@@ -77,9 +90,9 @@ public sealed class CanvasElementEditor
return null;
}

/// <summary>Point-in-rotated-rectangle test: rotates the point into the element's own local
/// (unrotated) frame around its bounding-box center by the *inverse* of its rotation angle,
/// then runs the same plain-rectangle check the pre-Sprint-4 code always used. An unrotated
/// <summary>Point-in-rotated-rectangle test: rotates the point back into the element's
/// unrotated world-space rectangle around the same pivot the canvas/render path uses, then
/// runs the same plain-rectangle check the pre-Sprint-4 code always used. An unrotated
/// element (the overwhelmingly common case) takes the cheap plain-rectangle path directly.</summary>
private static bool IsPointInRotatedBounds(
double xPoints, double yPoints, TextElementLayout element, double width, double height)
@@ -90,30 +103,40 @@ public sealed class CanvasElementEditor
&& yPoints >= element.Y && yPoints <= element.Y + height;
}

var (localX, localY) = ToLocalFrame(xPoints, yPoints, element, width, height, inverse: true);
return localX >= -width / 2.0 && localX <= width / 2.0
&& localY >= -height / 2.0 && localY <= height / 2.0;
}

/// <summary>Rotates a world-space point into (or, with <paramref name="inverse"/>, out of) the
/// element's local frame centered on its own bounding-box center — shared by hit-testing and
/// handle-position math so both agree on exactly the same geometry.</summary>
private static (double X, double Y) ToLocalFrame(
double xPoints, double yPoints, TextElementLayout element, double width, double height, bool inverse)
{
var centerX = element.X + (width / 2.0);
var centerY = element.Y + (height / 2.0);
var dx = xPoints - centerX;
var dy = yPoints - centerY;

var angle = inverse ? -element.RotationAngle : element.RotationAngle;
var radians = angle * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
var (testX, testY) = RotatePointAroundPivot(
xPoints,
yPoints,
RotationPivot(element, width, height),
-element.RotationAngle);

return ((dx * cos) - (dy * sin), (dx * sin) + (dy * cos));
return testX >= element.X && testX <= element.X + width
&& testY >= element.Y && testY <= element.Y + height;
}

/// <summary>Sprint 6 defect fix (record-accurate render/preview only — see below): static
/// rotated text keeps Sprint 4's bounding-box-center pivot, while dynamic/mixed content
/// rotates around the authored anchor so a different resolved width per record cannot move the
/// pivot. Post-Sprint-7-review fix (2026-10-19): used for hit-testing
/// (<see cref="IsPointInRotatedBounds"/>), the drag handle's position
/// (<see cref="ComputeHandlePosition"/>), and live rotate-drag math (<see cref="RotateDragTo"/>)
/// on this editing canvas — which only ever hit-tests/rotates against an element's literal
/// `{ColumnName}` token text, never a per-record resolved value, so the record-width drift the
/// anchor-pivot branch guards against cannot occur here (see
/// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'s remarks). Always takes the
/// center-pivot path now so a dynamic/mixed element hit-tests and rotates exactly like static
/// text — matching what <c>TemplateCanvasControl</c> now draws. The real render and the new
/// preview panel are unaffected; they still call <see cref="RotationPivotCalculator.Compute"/>
/// directly with the real <c>isDynamic</c> value.</summary>
private static (double X, double Y) RotationPivot(TextElementLayout element, double width, double height) =>
RotationPivotCalculator.ComputeForCanvasEditing(element.X, element.Y, width, height);

/// <summary>Sprint 8: delegates to the shared <see cref="PointRotation"/> helper (extracted so
/// the new whole-Address-Control rigid-group rotation math elsewhere can reuse this exact
/// formula instead of a third copy) rather than keeping its own identical private copy.</summary>
private static (double X, double Y) RotatePointAroundPivot(
double xPoints, double yPoints, (double X, double Y) pivot, double angleDegrees) =>
PointRotation.RotateAroundPivot(xPoints, yPoints, pivot, angleDegrees);

/// <summary>Selects whatever element is at the given point (or clears selection if none),
/// returning whether something was selected.</summary>
public bool TrySelectAt(double xPoints, double yPoints)
@@ -138,7 +161,12 @@ public sealed class CanvasElementEditor
}

/// <summary>Moves the selected element so the original grab offset is preserved relative to
/// the new pointer position. No-op if nothing is selected or a drag hasn't begun.</summary>
/// the new pointer position. No-op if nothing is selected or a drag hasn't begun.
///
/// Sprint 7, "Snap elements to grid and guides": when <see cref="SnapToGridEnabled"/> is
/// <c>true</c>, the resulting position is rounded to the nearest <see cref="GridSizePoints"/>
/// increment on every call — i.e. continuously during the drag gesture, not only once on
/// release — so the element visibly snaps as it moves rather than jumping only at the end.</summary>
public void DragTo(double xPoints, double yPoints)
{
if (Selected is null || _dragOffset is null)
@@ -146,8 +174,17 @@ public sealed class CanvasElementEditor
return;
}

Selected.X = xPoints - _dragOffset.Value.Dx;
Selected.Y = yPoints - _dragOffset.Value.Dy;
var newX = xPoints - _dragOffset.Value.Dx;
var newY = yPoints - _dragOffset.Value.Dy;

if (SnapToGridEnabled)
{
newX = GridSnapper.Snap(newX, GridSizePoints);
newY = GridSnapper.Snap(newY, GridSizePoints);
}

Selected.X = newX;
Selected.Y = newY;
}

public void EndDrag()
@@ -176,18 +213,16 @@ public sealed class CanvasElementEditor

private static (double X, double Y) ComputeHandlePosition(TextElementLayout element, double width, double height)
{
var centerX = element.X + (width / 2.0);
var centerY = element.Y + (height / 2.0);

// Local (unrotated) handle offset: straight up from center, past the top edge.
var localY = (height / 2.0) + HandleOffsetPoints;

var radians = element.RotationAngle * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);

// R(angle) * (0, localY) — same forward-rotation convention HitTest's inverse undoes.
return (centerX + (-localY * sin), centerY + (localY * cos));
var pivot = RotationPivot(element, width, height);

// Unrotated handle position: top center plus an offset. It is then rotated around the
// element's actual pivot, which is the center for static text and the fixed anchor for
// dynamic/mixed content.
return RotatePointAroundPivot(
element.X + (width / 2.0),
element.Y + height + HandleOffsetPoints,
pivot,
element.RotationAngle);
}

/// <summary>Whether the given canvas-space point is within grab range of the currently
@@ -233,20 +268,23 @@ public sealed class CanvasElementEditor
}

var (width, height) = _measureText(Selected);
var centerX = Selected.X + (width / 2.0);
var centerY = Selected.Y + (height / 2.0);
var dx = xPoints - centerX;
var dy = yPoints - centerY;
var pivot = RotationPivot(Selected, width, height);
var dx = xPoints - pivot.X;
var dy = yPoints - pivot.Y;

if (dx == 0 && dy == 0)
{
return;
}

// atan2(dy, dx) is the standard CCW-from-+X angle to the pointer; the handle's own
// zero-rotation reference direction is "up" (+Y), which is +90 degrees in that same
// frame, so subtract 90 to convert "angle to pointer" into "element rotation angle."
var zeroHandleX = Selected.X + (width / 2.0);
var zeroHandleY = Selected.Y + height + HandleOffsetPoints;
var zeroAngleDegrees = Math.Atan2(zeroHandleY - pivot.Y, zeroHandleX - pivot.X) * 180.0 / Math.PI;

// atan2(dy, dx) is the standard CCW-from-+X angle to the pointer; subtract the handle's
// own unrotated direction from the element's actual pivot to convert "angle to pointer"
// into "element rotation angle." For static elements this remains the old "-90" rule.
var angleToPointerDegrees = Math.Atan2(dy, dx) * 180.0 / Math.PI;
Selected.RotationAngle = angleToPointerDegrees - 90.0;
Selected.RotationAngle = angleToPointerDegrees - zeroAngleDegrees;
}
}

+ 11
- 2
code/src/EnvelopeRenderer.Desktop.Core/Design/ElementPreviewState.cs Целия файл

@@ -11,5 +11,14 @@ namespace EnvelopeRenderer.Desktop.Core.Design;
/// error": <c>true</c> when this is a dynamic element bound to a column name that is NOT among
/// the currently loaded CSV's headers — a real mapping problem, distinct from a column that
/// exists but happens to be blank for the current sample data (which is <see cref="Visible"/>
/// <c>== false</c> when also collapsible, with no error implied at all).</param>
public readonly record struct ElementPreviewState(bool Visible, double EffectiveY, bool IsUnmappedColumn);
/// <c>== false</c> when also collapsible, with no error implied at all). Sprint 5: now <c>true</c>
/// when *any* of the element's field runs is unmapped (see <see cref="UnmappedRuns"/> for which
/// one specifically).</param>
/// <param name="UnmappedRuns">Sprint 5, "Mix static text and CSV fields within a single text
/// element": per-run unmapped flags, indexed the same as the element's own
/// <see cref="TextElementLayout.Runs"/> — <c>true</c> at index <c>i</c> exactly when run
/// <c>i</c> is a field run bound to a column not among the currently loaded CSV's headers. Always
/// a literal <c>false</c> for a literal run. Lets the canvas flag only the specific field-token
/// segment(s) with a mapping problem, rather than the whole element, when content is mixed.</param>
public readonly record struct ElementPreviewState(
bool Visible, double EffectiveY, bool IsUnmappedColumn, IReadOnlyList<bool> UnmappedRuns);

+ 18
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/GridSnapper.cs Целия файл

@@ -0,0 +1,18 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 7, "Snap elements to grid and guides": the pure snap-to-grid rounding rule shared by
/// <see cref="CanvasElementEditor"/>'s standalone-element drag math and
/// <c>EnvelopeRenderer.Desktop.Views.TemplateCanvasControl</c>'s Address Control move/resize math
/// (which is handled directly in that WinForms control rather than through
/// <see cref="CanvasElementEditor"/> — see that class's own remarks). Kept as one tiny,
/// framework-free helper rather than two copies.
/// </summary>
public static class GridSnapper
{
/// <summary>Rounds <paramref name="value"/> to the nearest multiple of
/// <paramref name="gridSizePoints"/>. A non-positive grid size is treated as "no snapping"
/// (returns <paramref name="value"/> unchanged) rather than dividing by zero.</summary>
public static double Snap(double value, double gridSizePoints) =>
gridSizePoints <= 0 ? value : Math.Round(value / gridSizePoints) * gridSizePoints;
}

+ 29
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/PointRotation.cs Целия файл

@@ -0,0 +1,29 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 8, "Rotate the whole Address Control as a single unit": the one shared "rotate a point
/// around a pivot" transform for the desktop app (canvas-space points, positive angle =
/// counterclockwise, matching this project's stored rotation-angle convention — see
/// <c>RotatedTextAnchorCalculator</c>'s empirically-confirmed sign convention in
/// <c>EnvelopeRenderer.Cli</c>). <see cref="CanvasElementEditor"/> already had this exact formula
/// as a private method for single-element hit-testing/handle math; it is extracted here so the
/// new whole-Address-Control rigid-group rotation math in <see cref="TemplateCanvasControl"/> and
/// <see cref="TemplatePreviewBuilder"/> (a different project/namespace than
/// <c>CanvasElementEditor</c>'s own private method) can share exactly one implementation instead
/// of a third copy.
/// </summary>
public static class PointRotation
{
public static (double X, double Y) RotateAroundPivot(
double x, double y, (double X, double Y) pivot, double angleDegrees)
{
var dx = x - pivot.X;
var dy = y - pivot.Y;

var radians = angleDegrees * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);

return (pivot.X + ((dx * cos) - (dy * sin)), pivot.Y + ((dx * sin) + (dy * cos)));
}
}

+ 44
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/PreviewTextDraw.cs Целия файл

@@ -0,0 +1,44 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview of the current template": one already-
/// resolved piece of text the preview panel should draw for the currently selected CSV record —
/// the desktop-model analog of <c>EnvelopeRenderer.Cli.Render.TextDraw</c>. Produced by
/// <see cref="TemplatePreviewBuilder"/>, drawn by <c>EnvelopeRenderer.Desktop.Views.
/// TemplatePreviewControl</c>.
/// </summary>
/// <param name="X">Authored anchor X, in PDF points (bottom-left origin) — same value the design
/// canvas uses for this element/line.</param>
/// <param name="Y">Effective baseline Y after any address-line collapse shift has been applied.</param>
/// <param name="Text">The already-resolved text for the current record (never a bracket token).</param>
/// <param name="Angle">Rotation angle in degrees, positive = counterclockwise. For a standalone
/// element this is its own <c>RotationAngle</c>; for an Address Control line (Sprint 8, "Rotate
/// the whole Address Control as a single unit") this is the parent control's
/// <c>RotationAngle</c>, and <see cref="X"/>/<see cref="Y"/> are already the line's rigid-group-
/// rotated anchor (see <see cref="TemplatePreviewBuilder"/>), so no further pivot offset is
/// needed here — see <paramref name="IsDynamic"/>.</param>
/// <param name="IsDynamic">Whether this draw's source content contains at least one field run, OR
/// (Sprint 8) whether this is an Address Control line whose parent control is rotated — either way
/// selects the fixed-anchor <see cref="RotationPivotCalculator"/> rule (rotate around the already-
/// correct <see cref="X"/>/<see cref="Y"/> directly) rather than a measured-bounding-box-center
/// rule, when <see cref="Angle"/> is non-zero.</param>
public sealed record PreviewTextDraw(
double X,
double Y,
string FontFamily,
double FontSize,
string Text,
RgbColor Color,
double Angle,
bool IsDynamic);

/// <summary>The outcome of <see cref="TemplatePreviewBuilder.Build"/>: either a resolved,
/// ready-to-draw list of <see cref="PreviewTextDraw"/>s, or a clear, specific failure message the
/// preview panel should show instead of a blank or stale preview (this story's fifth acceptance
/// criterion).</summary>
public sealed record PreviewResult(bool Success, IReadOnlyList<PreviewTextDraw> Draws, string? Message)
{
public static PreviewResult Ok(IReadOnlyList<PreviewTextDraw> draws) => new(true, draws, null);

public static PreviewResult Failure(string message) => new(false, Array.Empty<PreviewTextDraw>(), message);
}

+ 50
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/RotationPivotCalculator.cs Целия файл

@@ -0,0 +1,50 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview...": the one shared rotation-pivot
/// rule for the desktop app, extracted from <c>CanvasElementEditor</c> and
/// <c>TemplateCanvasControl</c> (which each held an identical private copy) so a third copy was
/// not needed for the new preview panel (<see cref="TemplatePreviewBuilder"/> /
/// <c>TemplatePreviewControl</c>). Encodes the Sprint 6 defect-fix rule exactly: static
/// (fixed-text) rotated elements rotate around their own measured bounding-box center, while any
/// element containing at least one field run (dynamic or mixed content) rotates around its fixed
/// authored <c>(X, Y)</c> anchor instead, since that anchor — unlike the bounding-box center — is
/// stable across records whose resolved text width differs (see
/// `logs/technical_debt_log.md`'s 2026-10-09 entry and <c>RotatedTextAnchorCalculator</c> in
/// `EnvelopeRenderer.Cli` for the render-time counterpart of this same rule).
/// </summary>
public static class RotationPivotCalculator
{
/// <param name="isDynamic">Whether the element/content contains at least one field run.</param>
/// <param name="x">The element's authored anchor X.</param>
/// <param name="y">The element's authored anchor Y (baseline).</param>
/// <param name="width">The text's measured width — only used for the static bounding-box-center
/// path; ignored for dynamic/mixed content.</param>
/// <param name="height">The text's measured height — only used for the static bounding-box-
/// center path; ignored for dynamic/mixed content.</param>
public static (double X, double Y) Compute(bool isDynamic, double x, double y, double width, double height) =>
isDynamic ? (x, y) : (x + (width / 2.0), y + (height / 2.0));

/// <summary>Canvas-editing-only variant of <see cref="Compute"/>: always takes the bounding-
/// box-center path, regardless of whether the element/content is dynamic or mixed. Exists
/// because the plain WinForms editing canvas (<c>TemplateCanvasControl</c> /
/// <c>CanvasElementEditor</c>) only ever draws an element's literal authored
/// <c>{ColumnName}</c> bracket-token text (see <c>TextElementLayout.DisplayText</c> /
/// <c>TextRunTextConverter.ToEditableText</c>) — never a per-record resolved value — so the
/// record-to-record text-width drift <see cref="Compute"/>'s anchor-pivot branch exists to
/// guard against cannot happen there. Applying that branch anyway just made a dynamic/mixed
/// element's rotate-handle drag swing around a corner instead of spinning in place like static
/// text — a jarring interactive inconsistency the human product owner asked to have fixed
/// (post-Sprint-7-review, 2026-10-19; see the dated note on the "Keep rotated dynamic and
/// mixed-content fields positioned consistently across records" story in
/// `backlog/epics/02_template_designer_gui_foundation.md`).
///
/// Deliberately a separate, explicitly-named method rather than a magic <c>isDynamic: false</c>
/// scattered across call sites, so the semantic difference between "canvas-editing pivot"
/// (this) and "record-accurate render/preview pivot" (<see cref="Compute"/>, still used
/// unchanged by <c>EnvelopeRenderer.Cli</c>'s <c>RotatedTextAnchorCalculator</c> /
/// <c>DebenuPdfRenderer</c> and by <c>TemplatePreviewControl</c> / <c>TemplatePreviewBuilder</c>)
/// stays explicit and self-documenting in code, not a scattered implicit assumption.</summary>
public static (double X, double Y) ComputeForCanvasEditing(double x, double y, double width, double height) =>
Compute(isDynamic: false, x, y, width, height);
}

+ 11
- 1
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs Целия файл

@@ -14,6 +14,11 @@ public sealed class TemplateLayoutDocument
/// the authoritative stacking order (0 = bottom-most), not list position.</summary>
public List<TextElementLayout> Elements { get; } = new();

/// <summary>Composite Address Controls. Kept in a parallel collection for Sprint 6 so the
/// long-standing standalone text-element model stays stable while the first composite
/// element kind is introduced.</summary>
public List<AddressControlLayout> AddressControls { get; } = new();

public TemplateLayoutDocument(CanvasSettings canvas)
{
Canvas = canvas;
@@ -24,5 +29,10 @@ public sealed class TemplateLayoutDocument

/// <summary>The next free z-order value (one past the current highest), so newly added
/// elements land on top by default without colliding with an existing z-order.</summary>
public int NextZOrder() => Elements.Count == 0 ? 0 : Elements.Max(e => e.ZOrder) + 1;
public int NextZOrder()
{
var textMax = Elements.Count == 0 ? -1 : Elements.Max(e => e.ZOrder);
var controlMax = AddressControls.Count == 0 ? -1 : AddressControls.Max(c => c.ZOrder);
return Math.Max(textMax, controlMax) + 1;
}
}

+ 352
- 37
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs Целия файл

@@ -30,47 +30,119 @@ public static class TemplateLayoutXmlSerializer
new XAttribute("pageHeight", document.Canvas.HeightPoints.ToString(CultureInfo.InvariantCulture)),
new XAttribute("canvasUnit", document.Canvas.DisplayUnit.ToString()));

foreach (var element in document.Elements.OrderBy(e => e.ZOrder))
var textItems = document.Elements
.Select(e => (ZOrder: e.ZOrder, Node: CreateTextElement(e)));
var addressItems = document.AddressControls
.Select(c => (ZOrder: c.ZOrder, Node: CreateAddressControl(c)));

foreach (var item in textItems.Concat(addressItems).OrderBy(i => i.ZOrder))
{
var textElement = new XElement(
"text",
new XAttribute("x", element.X.ToString(CultureInfo.InvariantCulture)),
new XAttribute("y", element.Y.ToString(CultureInfo.InvariantCulture)),
new XAttribute("font", element.FontFamily),
new XAttribute("size", element.FontSize.ToString(CultureInfo.InvariantCulture)),
new XAttribute("color", element.Color.ToHex()),
new XAttribute("zOrder", element.ZOrder.ToString(CultureInfo.InvariantCulture)));
root.Add(item.Node);
}

// Sprint 4: `collapsible` and `angle` are the same render-time attributes
// `TemplateXmlParser` reads, written here only when non-default so a template with no
// rotated/collapsible elements round-trips byte-for-byte identical to how it looked
// before this sprint.
if (element.CollapseIfBlank)
{
textElement.Add(new XAttribute("collapsible", "true"));
}
// Overwrite semantics (a fresh XDocument written to `path`) rather than any kind of
// merge — matches the story's "save the current layout" scope; there is no concept of a
// partial/incremental template save.
new XDocument(root).Save(path);
}

if (element.RotationAngle != 0)
private static XElement CreateTextElement(TextElementLayout element)
{
var textElement = new XElement(
"text",
new XAttribute("x", element.X.ToString(CultureInfo.InvariantCulture)),
new XAttribute("y", element.Y.ToString(CultureInfo.InvariantCulture)),
new XAttribute("font", element.FontFamily),
new XAttribute("size", element.FontSize.ToString(CultureInfo.InvariantCulture)),
new XAttribute("color", element.Color.ToHex()),
new XAttribute("zOrder", element.ZOrder.ToString(CultureInfo.InvariantCulture)));

// Sprint 4: `collapsible` and `angle` are the same render-time attributes
// `TemplateXmlParser` reads, written here only when non-default so a template with no
// rotated/collapsible elements round-trips byte-for-byte identical to how it looked
// before this sprint.
if (element.CollapseIfBlank)
{
textElement.Add(new XAttribute("collapsible", "true"));
}

if (element.RotationAngle != 0)
{
textElement.Add(new XAttribute("angle", element.RotationAngle.ToString(CultureInfo.InvariantCulture)));
}

AddRuns(textElement, element.Runs, useLegacySingleRunShape: true);
return textElement;
}

private static XElement CreateAddressControl(AddressControlLayout control)
{
var node = new XElement(
"addressControl",
new XAttribute("x", control.X.ToString(CultureInfo.InvariantCulture)),
new XAttribute("y", control.Y.ToString(CultureInfo.InvariantCulture)),
new XAttribute("width", control.Width.ToString(CultureInfo.InvariantCulture)),
new XAttribute("zOrder", control.ZOrder.ToString(CultureInfo.InvariantCulture)));

if (control.LineSpacingMultiplier != AddressControlLayout.DefaultLineSpacingMultiplier)
{
node.Add(new XAttribute(
"lineSpacing",
control.LineSpacingMultiplier.ToString(CultureInfo.InvariantCulture)));
}

// Sprint 8, "Rotate the whole Address Control as a single unit": same convention as
// <text>'s own `angle` attribute — written only when non-default so a template with no
// rotated Address Control round-trips byte-for-byte identical to how it looked before
// this story.
if (control.RotationAngle != 0)
{
node.Add(new XAttribute("angle", control.RotationAngle.ToString(CultureInfo.InvariantCulture)));
}

foreach (var line in control.Lines)
{
var lineNode = new XElement(
"line",
new XAttribute("font", line.FontFamily),
new XAttribute("size", line.FontSize.ToString(CultureInfo.InvariantCulture)),
new XAttribute("color", line.Color.ToHex()));

if (!line.CollapseIfBlank)
{
textElement.Add(new XAttribute("angle", element.RotationAngle.ToString(CultureInfo.InvariantCulture)));
lineNode.Add(new XAttribute("collapsible", "false"));
}

if (element.IsDynamic)
AddRuns(lineNode, line.Runs, useLegacySingleRunShape: false);
node.Add(lineNode);
}

return node;
}

private static void AddRuns(XElement node, IReadOnlyList<TextRun> runs, bool useLegacySingleRunShape)
{
if (useLegacySingleRunShape && runs.Count == 1)
{
var only = runs[0];
if (only.IsField)
{
textElement.Add(new XAttribute("column", element.ColumnName!));
node.Add(new XAttribute("column", only.ColumnName!));
}
else
{
textElement.SetValue(element.StaticText ?? string.Empty);
node.SetValue(only.Literal ?? string.Empty);
}

root.Add(textElement);
return;
}

// Overwrite semantics (a fresh XDocument written to `path`) rather than any kind of
// merge — matches the story's "save the current layout" scope; there is no concept of a
// partial/incremental template save.
new XDocument(root).Save(path);
foreach (var run in runs)
{
node.Add(run.IsField
? new XElement("run", new XAttribute("column", run.ColumnName!))
: new XElement("run", new XAttribute("text", run.Literal ?? string.Empty)));
}
}

/// <summary>Attempts to load a template. Returns <c>false</c> with one or more
@@ -112,14 +184,31 @@ public static class TemplateLayoutXmlSerializer
}

var elements = new List<TextElementLayout>();
var textNodes = root.Elements().Where(e => e.Name.LocalName == "text").ToList();
var addressControls = new List<AddressControlLayout>();
var renderNodes = root.Elements()
.Where(e => e.Name.LocalName is "text" or "addressControl")
.ToList();

var autoZOrder = 0;
var index = 0;
foreach (var node in textNodes)
var textIndex = 0;
var addressIndex = 0;
foreach (var node in renderNodes)
{
index++;
var label = $"<text> #{index}";
if (node.Name.LocalName == "addressControl")
{
addressIndex++;
var parsed = ParseAddressControl(node, addressIndex, autoZOrder, errorList);
if (parsed is not null)
{
addressControls.Add(parsed);
}

autoZOrder++;
continue;
}

textIndex++;
var label = $"<text> #{textIndex}";

var x = ParseRequiredDouble(node, "x", label, errorList);
var y = ParseRequiredDouble(node, "y", label, errorList);
@@ -135,7 +224,25 @@ public static class TemplateLayoutXmlSerializer
var hasColumn = !string.IsNullOrWhiteSpace(column);
var hasStaticText = !string.IsNullOrWhiteSpace(staticText);

if (hasColumn && hasStaticText)
// Sprint 5, "Mix static text and CSV fields within a single text element": mirrors
// the CLI's TemplateXmlParser exactly — a genuinely new, unambiguous `<run>`
// child-element shape for multi-run content, never present in any pre-Sprint-5 saved
// template, so this branch can never misinterpret existing content (AC2).
var runNodes = node.Elements().Where(e => e.Name.LocalName == "run").ToList();
List<TextRun>? runs = null;

if (runNodes.Count > 0)
{
if (hasColumn || hasStaticText)
{
errorList.Add($"{label} has <run> children and also a 'column' attribute or inline text — use exactly one form.");
}
else
{
runs = ParseRuns(runNodes, label, errorList);
}
}
else if (hasColumn && hasStaticText)
{
errorList.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
}
@@ -143,6 +250,10 @@ public static class TemplateLayoutXmlSerializer
{
errorList.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
}
else
{
runs = new List<TextRun> { hasColumn ? TextRun.ForField(column!) : TextRun.ForLiteral(staticText!) };
}

var color = RgbColor.Black;
var colorRaw = (string?)node.Attribute("color");
@@ -197,15 +308,20 @@ public static class TemplateLayoutXmlSerializer
}
}

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText)
if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null
|| angle is null)
{
continue;
}

elements.Add(hasStaticText
? TextElementLayout.CreateStatic(x.Value, y.Value, staticText!, font, size.Value, zOrder)
: TextElementLayout.CreateDynamic(x.Value, y.Value, column!, font, size.Value, zOrder));
// Runs is always non-empty by construction (either the legacy one-run branch above,
// or ParseRuns, which only returns non-null when it produced at least one run) — the
// factory call just needs *some* single-run element to build on top of; its content
// is immediately overwritten with the full parsed run list.
var element = TextElementLayout.CreateStatic(x.Value, y.Value, string.Empty, font, size.Value, zOrder);
element.Runs.Clear();
element.Runs.AddRange(runs);
elements.Add(element);

elements[^1].Color = color;
elements[^1].CollapseIfBlank = collapsible;
@@ -221,10 +337,209 @@ public static class TemplateLayoutXmlSerializer
var canvas = new CanvasSettings(width!.Value, height!.Value, unit);
document = new TemplateLayoutDocument(canvas);
document.Elements.AddRange(elements);
document.AddressControls.AddRange(addressControls);
errors = Array.Empty<string>();
return true;
}

private static AddressControlLayout? ParseAddressControl(
XElement node, int index, int autoZOrder, List<string> errors)
{
var label = $"<addressControl> #{index}";

var x = ParseRequiredDouble(node, "x", label, errors);
var y = ParseRequiredDouble(node, "y", label, errors);
var width = ParseRequiredDouble(node, "width", label, errors, mustBePositive: true);
var zOrder = autoZOrder;
var zOrderRaw = (string?)node.Attribute("zOrder");
if (!string.IsNullOrWhiteSpace(zOrderRaw))
{
if (int.TryParse(zOrderRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedZOrder))
{
zOrder = ZOrderRule.Clamp(parsedZOrder);
}
else
{
errors.Add($"{label} has a non-integer 'zOrder' value: '{zOrderRaw}'.");
}
}

var lineSpacing = AddressControlLayout.DefaultLineSpacingMultiplier;
var lineSpacingRaw = (string?)node.Attribute("lineSpacing");
if (!string.IsNullOrWhiteSpace(lineSpacingRaw))
{
if (double.TryParse(lineSpacingRaw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedLineSpacing)
&& parsedLineSpacing > 0)
{
lineSpacing = parsedLineSpacing;
}
else
{
errors.Add($"{label} has an invalid 'lineSpacing' value: '{lineSpacingRaw}'.");
}
}

// Sprint 8, "Rotate the whole Address Control as a single unit": optional, defaults to 0
// so every template written before this story loads and displays unchanged — same
// convention as <text>'s own `angle` attribute.
double? angle = 0;
var angleRaw = (string?)node.Attribute("angle");
if (!string.IsNullOrWhiteSpace(angleRaw))
{
if (double.TryParse(angleRaw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedAngle))
{
angle = parsedAngle;
}
else
{
angle = null;
errors.Add($"{label} has a non-numeric 'angle' value: '{angleRaw}'.");
}
}

var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList();
if (lineNodes.Count == 0)
{
errors.Add($"{label} must contain at least one <line> child.");
}

var lines = new List<AddressControlLineLayout>();
for (var i = 0; i < lineNodes.Count; i++)
{
var line = ParseAddressControlLine(lineNodes[i], $"{label} <line> #{i + 1}", errors);
if (line is not null)
{
lines.Add(line);
}
}

if (x is null || y is null || width is null || angle is null || lines.Count == 0)
{
return null;
}

var control = new AddressControlLayout
{
X = x.Value,
Y = y.Value,
Width = width.Value,
ZOrder = zOrder,
LineSpacingMultiplier = lineSpacing,
RotationAngle = angle.Value,
};
control.Lines.AddRange(lines);
return control;
}

private static AddressControlLineLayout? ParseAddressControlLine(
XElement node, string label, List<string> errors)
{
var size = ParseRequiredDouble(node, "size", label, errors, mustBePositive: true);
var font = (string?)node.Attribute("font");
if (string.IsNullOrWhiteSpace(font))
{
errors.Add($"{label} is missing a required 'font' attribute.");
}

var color = RgbColor.Black;
var colorRaw = (string?)node.Attribute("color");
if (!string.IsNullOrWhiteSpace(colorRaw) && !RgbColor.TryParseHex(colorRaw, out color))
{
errors.Add($"{label} has an invalid 'color' value: '{colorRaw}' (expected #RRGGBB).");
}

var collapsible = true;
var collapsibleRaw = (string?)node.Attribute("collapsible");
if (!string.IsNullOrWhiteSpace(collapsibleRaw) && !bool.TryParse(collapsibleRaw, out collapsible))
{
errors.Add($"{label} has an invalid 'collapsible' value: '{collapsibleRaw}' (expected 'true' or 'false').");
}

var runs = ParseContentRuns(node, label, errors);
if (size is null || string.IsNullOrWhiteSpace(font) || runs is null)
{
return null;
}

var line = new AddressControlLineLayout(font, size.Value) { Color = color, CollapseIfBlank = collapsible };
line.Runs.Clear();
line.Runs.AddRange(runs);
return line;
}

/// <summary>Sprint 5: parses a `&lt;text&gt;` element's `&lt;run&gt;` children into an
/// ordered run list — mirrors the CLI's `TemplateXmlParser.ParseRuns` exactly (same shape,
/// same validation), since this project's own convention (`TemplateLayoutXmlSerializer`'s own
/// class remarks) is two independently-implemented parsers, not a shared reference.</summary>
private static List<TextRun>? ParseRuns(List<XElement> runNodes, string label, List<string> errors)
{
var runs = new List<TextRun>();
var runIndex = 0;
var hadError = false;

foreach (var runNode in runNodes)
{
runIndex++;
var runLabel = $"{label} <run> #{runIndex}";

var runColumn = (string?)runNode.Attribute("column");
var runText = (string?)runNode.Attribute("text");
var runHasColumn = !string.IsNullOrWhiteSpace(runColumn);
var runHasText = runText is not null;

if (runHasColumn && runHasText)
{
errors.Add($"{runLabel} has both a 'text' attribute and a 'column' attribute — use exactly one.");
hadError = true;
}
else if (!runHasColumn && !runHasText)
{
errors.Add($"{runLabel} has neither a 'text' attribute nor a 'column' attribute — use exactly one.");
hadError = true;
}
else
{
runs.Add(runHasColumn ? TextRun.ForField(runColumn!) : TextRun.ForLiteral(runText!));
}
}

return hadError ? null : runs;
}

private static List<TextRun>? ParseContentRuns(XElement node, string label, List<string> errors)
{
var column = (string?)node.Attribute("column");
var staticText = node.Nodes().OfType<XText>().Select(t => t.Value).FirstOrDefault();
var hasColumn = !string.IsNullOrWhiteSpace(column);
var hasStaticText = !string.IsNullOrWhiteSpace(staticText);
var runNodes = node.Elements().Where(e => e.Name.LocalName == "run").ToList();

if (runNodes.Count > 0)
{
if (hasColumn || hasStaticText)
{
errors.Add($"{label} has <run> children and also a 'column' attribute or inline text — use exactly one form.");
return null;
}

return ParseRuns(runNodes, label, errors);
}

if (hasColumn && hasStaticText)
{
errors.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
return null;
}

if (!hasColumn && !hasStaticText)
{
errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
return null;
}

return new List<TextRun> { hasColumn ? TextRun.ForField(column!) : TextRun.ForLiteral(staticText!) };
}

private static double? ParseRequiredDouble(
XElement node, string attributeName, string label, List<string> errors, bool mustBePositive = false)
{


+ 138
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs Целия файл

@@ -0,0 +1,138 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview of the current template": builds the
/// ordered list of already-resolved <see cref="PreviewTextDraw"/>s the new preview panel should
/// draw for one selected CSV record, reproducing the CLI's shipped rendering rules exactly:
/// address-line collapse (<see cref="AddressLineCollapser"/>, the same routine
/// <see cref="AddressBlockPreviewCalculator"/> already uses for the design canvas), per-run mixed
/// static/field content (<see cref="TextResolver"/>), both rotation pivot rules
/// (<see cref="RotationPivotCalculator"/>), Address Control line layout/spacing, and — Sprint 8,
/// "Rotate the whole Address Control as a single unit" — the whole-control rigid-group rotation
/// around <see cref="AddressControlLayout.BoxCenter"/> (<see cref="PointRotation"/>). This is the
/// desktop-model mirror of <c>EnvelopeRenderer.Cli.Render.RenderEngine.Render</c>/<c>BuildDraws</c>
/// — kept as an independent implementation per this project's deliberate desktop/CLI split (see
/// `EnvelopeRenderer.Desktop.csproj`'s remarks), with parity enforced by mirrored test data
/// (<c>TemplatePreviewBuilderTests</c> mirrors <c>RenderEngineTests</c>' scenarios) rather than a
/// shared reference. Framework-free and pure, so it is independently unit testable without a
/// WinForms host.
/// </summary>
public static class TemplatePreviewBuilder
{
public static PreviewResult Build(
TemplateLayoutDocument document,
IReadOnlyList<string> csvHeaders,
IReadOnlyDictionary<string, string>? record)
{
if (record is null)
{
return PreviewResult.Failure("No record selected. Load a CSV and select a record to preview.");
}

// Mirrors RenderEngine.Render's pre-flight column check exactly: every field run's column
// must be a known CSV header before anything is drawn, so a mapping error surfaces as one
// clear message rather than a half-drawn or silently-wrong preview.
var unknownColumns = document.Elements
.SelectMany(e => e.Runs)
.Concat(document.AddressControls.SelectMany(c => c.Lines).SelectMany(l => l.Runs))
.Where(r => r.IsField)
.Select(r => r.ColumnName!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(column => !csvHeaders.Contains(column, StringComparer.OrdinalIgnoreCase))
.ToList();

if (unknownColumns.Count > 0)
{
var message = "Cannot preview — " + string.Join(" ", unknownColumns
.Select(c => $"the template references CSV column '{c}', which is not in the loaded CSV header row."));
return PreviewResult.Failure(message);
}

var draws = new List<(int ZOrder, PreviewTextDraw Draw)>();

var elements = document.Elements;
var resolvedText = new string[elements.Count];
var lines = new AddressLineCollapser.Line[elements.Count];
for (var i = 0; i < elements.Count; i++)
{
var element = elements[i];
resolvedText[i] = TextResolver.TryResolve(element.Runs, csvHeaders, record) ?? string.Empty;
lines[i] = new AddressLineCollapser.Line(
element.X, element.Y, element.CollapseIfBlank && string.IsNullOrWhiteSpace(resolvedText[i]));
}

var resolved = AddressLineCollapser.Resolve(lines);
for (var i = 0; i < elements.Count; i++)
{
if (!resolved[i].Visible)
{
continue;
}

var element = elements[i];
draws.Add((element.ZOrder, new PreviewTextDraw(
element.X,
resolved[i].EffectiveY,
element.FontFamily,
element.FontSize,
resolvedText[i],
element.Color,
element.RotationAngle,
element.IsDynamic)));
}

foreach (var control in document.AddressControls)
{
draws.AddRange(BuildAddressControlDraws(control, csvHeaders, record).Select(d => (control.ZOrder, d)));
}

return PreviewResult.Ok(draws.OrderBy(d => d.ZOrder).Select(d => d.Draw).ToList());
}

private static IEnumerable<PreviewTextDraw> BuildAddressControlDraws(
AddressControlLayout control, IReadOnlyList<string> csvHeaders, IReadOnlyDictionary<string, string> record)
{
var resolvedText = new string[control.Lines.Count];
var collapseLines = new AddressLineCollapser.Line[control.Lines.Count];

var y = control.Y;
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
resolvedText[i] = TextResolver.TryResolve(line.Runs, csvHeaders, record) ?? string.Empty;
collapseLines[i] = new AddressLineCollapser.Line(
control.X, y, line.CollapseIfBlank && string.IsNullOrWhiteSpace(resolvedText[i]));
y -= line.FontSize * control.LineSpacingMultiplier;
}

var resolved = AddressLineCollapser.Resolve(collapseLines);
var angle = control.RotationAngle;
var pivot = angle != 0 ? control.BoxCenter : default;

for (var i = 0; i < control.Lines.Count; i++)
{
if (!resolved[i].Visible)
{
continue;
}

var line = control.Lines[i];
var drawX = control.X;
var drawY = resolved[i].EffectiveY;
if (angle != 0)
{
(drawX, drawY) = PointRotation.RotateAroundPivot(drawX, drawY, pivot, angle);
}

yield return new PreviewTextDraw(
drawX,
drawY,
line.FontFamily,
line.FontSize,
resolvedText[i],
line.Color,
Angle: angle,
IsDynamic: angle != 0 || line.IsDynamic);
}
}
}

+ 50
- 14
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs Целия файл

@@ -6,10 +6,15 @@ namespace EnvelopeRenderer.Desktop.Core.Design;
/// font, and z-order are all mutated in place as the operator drags, edits properties, and
/// reorders elements (Sprint 2 Batches 3-4).
///
/// Exactly one of <see cref="StaticText"/> / <see cref="ColumnName"/> is set, mirroring the
/// render-time template format's rule (enforced here by the two factory methods rather than by a
/// constructor invariant, since the properties panel — Batch 4 — needs to freely edit either
/// without re-validating the other on every keystroke).
/// Sprint 5, "Mix static text and CSV fields within a single text element": content is
/// <see cref="Runs"/>, an ordered sequence of literal-text and field-token runs, concatenated per
/// record at render time. This replaces the pre-Sprint-5 mutually-exclusive
/// <c>StaticText</c>/<c>ColumnName</c> properties — the pre-Sprint-5 rule ("exactly one of static
/// text or a bound column") is now simply the one-run special case of this same model, not a
/// separate parallel invariant. <see cref="StaticText"/>/<see cref="ColumnName"/> below are kept
/// as read-only back-compat projections of that one-run case (still non-null exactly when they
/// used to be), so every pre-Sprint-5 consumer that only ever dealt with single-run elements
/// keeps compiling and behaving identically without change.
/// </summary>
public sealed class TextElementLayout
{
@@ -51,10 +56,30 @@ public sealed class TextElementLayout
/// and displays unchanged.</summary>
public double RotationAngle { get; set; }

public string? StaticText { get; set; }
public string? ColumnName { get; set; }
/// <summary>Sprint 5: the element's content as an ordered run sequence. Always has at least
/// one entry (an empty single literal run is this model's equivalent of "no content yet") —
/// maintained by every mutator in this class and by <see cref="TextRunTextConverter.Parse"/>,
/// which the properties panel's content editor commits through
/// (<see cref="TextElementPropertiesEditor.SetContent"/>). A mutable list, not a
/// re-assignable property, so callers replace its *contents* (Clear + Add/AddRange) rather
/// than ever leaving it empty mid-edit.</summary>
public List<TextRun> Runs { get; } = new() { TextRun.ForLiteral(string.Empty) };

public bool IsDynamic => ColumnName is not null;
public bool IsDynamic => Runs.Any(r => r.IsField);

/// <summary>True only for the legacy "exactly one field run, nothing else" shape — the only
/// case <see cref="TextElementPropertiesEditor.SetColumnName"/> (and the properties panel's
/// rebind combo) still supports, since rebind semantics don't generalize cleanly to N field
/// runs in mixed content (per the story's sizing note).</summary>
public bool HasSingleColumnRun => Runs.Count == 1 && Runs[0].IsField;

/// <summary>Back-compat projection for the legacy single-literal-run case: non-null exactly
/// when this element used to be (and still behaves as) pure static text.</summary>
public string? StaticText => Runs.Count == 1 && !Runs[0].IsField ? Runs[0].Literal : null;

/// <summary>Back-compat projection for the legacy single-field-run case: non-null exactly
/// when this element used to be (and still behaves as) a single bound column.</summary>
public string? ColumnName => HasSingleColumnRun ? Runs[0].ColumnName : null;

private TextElementLayout(string fontFamily, double fontSize)
{
@@ -63,14 +88,25 @@ public sealed class TextElementLayout
}

public static TextElementLayout CreateStatic(
double x, double y, string text, string fontFamily = "Arial", double fontSize = 12, int zOrder = 0) =>
new(fontFamily, fontSize) { X = x, Y = y, StaticText = text, ZOrder = zOrder };
double x, double y, string text, string fontFamily = "Arial", double fontSize = 12, int zOrder = 0)
{
var element = new TextElementLayout(fontFamily, fontSize) { X = x, Y = y, ZOrder = zOrder };
element.Runs[0] = TextRun.ForLiteral(text);
return element;
}

public static TextElementLayout CreateDynamic(
double x, double y, string columnName, string fontFamily = "Arial", double fontSize = 12, int zOrder = 0) =>
new(fontFamily, fontSize) { X = x, Y = y, ColumnName = columnName, ZOrder = zOrder };
double x, double y, string columnName, string fontFamily = "Arial", double fontSize = 12, int zOrder = 0)
{
var element = new TextElementLayout(fontFamily, fontSize) { X = x, Y = y, ZOrder = zOrder };
element.Runs[0] = TextRun.ForField(columnName);
return element;
}

/// <summary>What the canvas/renderer should display: the literal static text, or a
/// human-readable placeholder for a dynamic column binding (e.g. <c>{Full Name}</c>).</summary>
public string DisplayText => IsDynamic ? $"{{{ColumnName}}}" : StaticText ?? string.Empty;
/// <summary>What the canvas/renderer should display: literal runs verbatim, concatenated with
/// a human-readable placeholder for each field run (e.g. <c>{Full Name}</c>) — the same
/// concatenation <see cref="TextRunTextConverter.ToEditableText"/> performs, kept as a
/// convenience property here since it predates that converter (Sprint 2) and many call sites
/// already depend on it.</summary>
public string DisplayText => TextRunTextConverter.ToEditableText(Runs);
}

+ 30
- 7
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementPropertiesEditor.cs Целия файл

@@ -62,19 +62,42 @@ public sealed class TextElementPropertiesEditor
}

/// <summary>Rebinds a dynamic element to a different CSV column (Sprint 3 Batch 4: "Re-map an
/// existing dynamic field"). No-op for a static element (there is nothing to rebind — a
/// static element's <see cref="TextElementLayout.StaticText"/> is edited separately) and for
/// blank input. Deliberately does not touch <see cref="TextElementLayout.X"/> or
/// <see cref="TextElementLayout.Y"/> — rebinding must not recreate or reposition the
/// element.</summary>
/// existing dynamic field"). No-op for a static element, for blank input, and — Sprint 5 —
/// for a mixed-content element with more than one run: rebind semantics don't generalize
/// cleanly to "which of N field runs?" (per the story's sizing note), so this remains scoped
/// to the legacy single-field-run shape only; general content editing for every other case
/// goes through <see cref="SetContent"/> instead. Deliberately does not touch
/// <see cref="TextElementLayout.X"/> or <see cref="TextElementLayout.Y"/> — rebinding must not
/// recreate or reposition the element.</summary>
public void SetColumnName(string? columnName)
{
if (Selected is { IsDynamic: true } && !string.IsNullOrWhiteSpace(columnName))
if (Selected is { HasSingleColumnRun: true } && !string.IsNullOrWhiteSpace(columnName))
{
Selected.ColumnName = columnName;
Selected.Runs[0] = TextRun.ForField(columnName);
}
}

/// <summary>Sprint 5, "Mix static text and CSV fields within a single text element": commits
/// the properties panel's raw content box text — using the story's <c>{Column Name}</c>
/// bracket-typing convention — into the selected element's <see cref="TextElementLayout.Runs"/>
/// sequence, via <see cref="TextRunTextConverter.Parse"/>. This is the general-purpose content
/// editor for both pure static text and mixed static/field content; it supersedes needing a
/// separate "edit static text" affordance (none existed before this story) since a plain
/// literal string with no <c>{...}</c> tokens parses straight through to a single literal run,
/// identical to what <see cref="TextElementLayout.CreateStatic"/> produces. A no-op when
/// nothing is selected.</summary>
public void SetContent(string? rawText)
{
if (Selected is null)
{
return;
}

var runs = TextRunTextConverter.Parse(rawText);
Selected.Runs.Clear();
Selected.Runs.AddRange(runs);
}

/// <summary>Applies the product's numeric z-order rule (0 = bottom-most, never negative) via
/// <see cref="ZOrderRule"/> regardless of what the operator typed.</summary>
public void SetZOrder(int zOrder)


+ 87
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextResolver.cs Целия файл

@@ -0,0 +1,87 @@
using System.Text;

namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview of the current template": the one
/// shared per-run text resolution routine for the desktop app. Used by both
/// <see cref="AddressBlockPreviewCalculator"/> (canvas blank/collapse and mapping-error state) and
/// <see cref="TemplatePreviewBuilder"/> (the new record-accurate preview panel) so the two
/// Desktop.Core call sites can never independently drift from each other — closing exactly the
/// divergent-implementation risk class named in this epic's refinement note (and the underlying
/// cause of the 2026-10-09 rotation defect, see `logs/technical_debt_log.md`).
///
/// This mirrors <c>EnvelopeRenderer.Cli.Render.RenderEngine</c>'s per-run concatenation rule
/// exactly (same order, same literal-vs-field handling: a field run resolves from the record, a
/// literal run is printed as-is). It is kept as an independent implementation rather than a
/// shared assembly reference, per this project's deliberate desktop/CLI process split (see
/// `EnvelopeRenderer.Desktop.csproj`'s remarks — the desktop app never references the CLI's
/// internals). Parity between the two is enforced by mirrored test data
/// (<c>TemplatePreviewBuilderTests</c> mirrors <c>RenderEngineTests</c>' scenarios), the same
/// convention this codebase already uses for its two independent XML parsers
/// (<see cref="TemplateLayoutXmlSerializer"/> vs. the CLI's <c>TemplateXmlParser</c>).
/// </summary>
public static class TextResolver
{
/// <summary>Resolves a run sequence's text for one record, or returns <c>null</c> if the
/// value cannot be determined — a field run bound to a column not present in
/// <paramref name="csvHeaders"/>, or a field run whose column is absent from
/// <paramref name="record"/> itself. <c>null</c> means "unknown," never "blank": callers must
/// not treat an unresolvable run as if it were a genuinely blank value (e.g. for collapse
/// purposes), since that would silently mask a real mapping error.
///
/// A purely literal run sequence (no field runs at all) always resolves, even with no CSV
/// loaded and no record supplied — it never varies by record.</summary>
public static string? TryResolve(
IReadOnlyList<TextRun> runs,
IReadOnlyCollection<string> csvHeaders,
IReadOnlyDictionary<string, string>? record)
{
if (!runs.Any(r => r.IsField))
{
return string.Concat(runs.Select(r => r.Literal ?? string.Empty));
}

if (record is null)
{
return null;
}

var headerSet = new HashSet<string>(csvHeaders, StringComparer.OrdinalIgnoreCase);
var builder = new StringBuilder();
foreach (var run in runs)
{
if (!run.IsField)
{
builder.Append(run.Literal ?? string.Empty);
continue;
}

if (headerSet.Count > 0 && !headerSet.Contains(run.ColumnName!))
{
return null;
}

if (!record.TryGetValue(run.ColumnName!, out var value))
{
return null;
}

builder.Append(value);
}

return builder.ToString();
}

/// <summary>Per-run "unmapped" flags, indexed the same as <paramref name="runs"/>: <c>true</c>
/// at index <c>i</c> exactly when run <c>i</c> is a field run bound to a column not present in
/// <paramref name="csvHeaders"/>. An empty header set means "unknown, no CSV loaded yet," not
/// "no columns exist" — nothing is ever flagged before any CSV has been loaded, matching
/// <see cref="AddressBlockPreviewCalculator"/>'s pre-existing rule.</summary>
public static IReadOnlyList<bool> ComputeUnmappedFlags(
IReadOnlyList<TextRun> runs, IReadOnlyCollection<string> csvHeaders)
{
var headerSet = new HashSet<string>(csvHeaders, StringComparer.OrdinalIgnoreCase);
return runs.Select(r => r.IsField && headerSet.Count > 0 && !headerSet.Contains(r.ColumnName!)).ToList();
}
}

+ 19
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextRun.cs Целия файл

@@ -0,0 +1,19 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 5, "Mix static text and CSV fields within a single text element": one piece of a
/// <see cref="TextElementLayout"/>'s content — either a literal run (<see cref="Literal"/> set,
/// printed as-is) or a field run (<see cref="ColumnName"/> set, resolved from the bound CSV
/// column at render/preview time). Never both, never neither. The CLI's parallel type is
/// <c>EnvelopeRenderer.Cli.Render.TemplateTextRun</c> — deliberately not shared, since this
/// project's desktop/CLI split intentionally never references the other's internals (see
/// <c>EnvelopeRenderer.Desktop.csproj</c>'s remarks).
/// </summary>
public sealed record TextRun(string? Literal, string? ColumnName)
{
public bool IsField => ColumnName is not null;

public static TextRun ForLiteral(string text) => new(text, null);

public static TextRun ForField(string columnName) => new(null, columnName);
}

+ 103
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextRunTextConverter.cs Целия файл

@@ -0,0 +1,103 @@
using System.Text;

namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// Sprint 5, "Mix static text and CSV fields within a single text element": converts between an
/// operator's raw typed text (the properties panel's single content box) and the structured
/// <see cref="TextRun"/> sequence <see cref="TextElementLayout.Runs"/> stores, via the story's
/// chosen editing convention — a <c>{Column Name}</c> bracket-typing token, parsed into runs on
/// commit. This is the Development Team's working assumption from the story's own sizing note
/// (matching this team's established preference for the faster-to-deliver option over a full
/// protected-token-chip rich editor), applied as-is.
///
/// Known, deliberate limitation (documented rather than silently accepted): literal text that
/// itself contains a matched <c>{...}</c> pair not intended as a field token (e.g. an operator
/// typing literally "note: {see below}") is indistinguishable from a real field token by this
/// convention — it will be parsed as a field run bound to a column named "see below", which then
/// fails Sprint 5 AC6's per-token pre-flight check at render time if no such column exists. This
/// is an accepted trade-off of the bracket convention (a protected-token-chip editor would avoid
/// it, at higher UI cost) and is called out in `TEMPLATE_FORMAT.md` and the properties panel.
/// </summary>
public static class TextRunTextConverter
{
/// <summary>Parses raw operator-typed text into an ordered run list. Always returns at least
/// one run (an empty single literal run for empty/null input) — the model's invariant that a
/// text element always has content to persist, matching the pre-Sprint-5 rule that an element
/// must always resolve to exactly one of static text or a bound column.</summary>
public static List<TextRun> Parse(string? raw)
{
raw ??= string.Empty;
var runs = new List<TextRun>();
var literalStart = 0;
var i = 0;

while (i < raw.Length)
{
if (raw[i] != '{')
{
i++;
continue;
}

var close = raw.IndexOf('}', i + 1);
if (close < 0)
{
// Unmatched '{' — no token here; keep scanning as literal text.
i++;
continue;
}

var columnName = raw[(i + 1)..close].Trim();
if (columnName.Length == 0)
{
// "{}" (or whitespace-only braces) has no real column name to bind to — leave it
// as literal text rather than manufacturing an empty-named field run.
i = close + 1;
continue;
}

if (i > literalStart)
{
runs.Add(TextRun.ForLiteral(raw[literalStart..i]));
}

runs.Add(TextRun.ForField(columnName));
i = close + 1;
literalStart = i;
}

if (literalStart < raw.Length)
{
runs.Add(TextRun.ForLiteral(raw[literalStart..]));
}

if (runs.Count == 0)
{
runs.Add(TextRun.ForLiteral(string.Empty));
}

return runs;
}

/// <summary>Reconstructs the raw editable text the properties panel's content box should
/// display for a given run sequence — the exact inverse of <see cref="Parse"/> for any run
/// list <see cref="Parse"/> itself could have produced (field runs render back as
/// <c>{ColumnName}</c>; literal runs render back as their own text verbatim).</summary>
public static string ToEditableText(IReadOnlyList<TextRun> runs)
{
var builder = new StringBuilder();
foreach (var run in runs)
{
builder.Append(run.IsField ? $"{{{run.ColumnName}}}" : run.Literal ?? string.Empty);
}

return builder.ToString();
}

/// <summary>Sprint 5 AC4 support: the same text <see cref="ToEditableText"/> would produce,
/// split back out per-run — what the canvas needs to measure and highlight each run's own
/// pixel span without duplicating the <c>{ColumnName}</c> placeholder formatting logic.</summary>
public static IReadOnlyList<(TextRun Run, string DisplaySegment)> ToDisplaySegments(IReadOnlyList<TextRun> runs) =>
runs.Select(r => (r, r.IsField ? $"{{{r.ColumnName}}}" : r.Literal ?? string.Empty)).ToList();
}

+ 61
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressBlockPreviewCalculatorTests.cs Целия файл

@@ -96,6 +96,67 @@ public class AddressBlockPreviewCalculatorTests
Assert.False(states[element.Id].IsUnmappedColumn);
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void Compute_MixedContentElement_ResolvesConcatenatedSampleTextForCollapsing()
{
var element = TextElementLayout.CreateStatic(100, 300, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[] { TextRun.ForLiteral("Attn: "), TextRun.ForField("Full Name") });
element.CollapseIfBlank = true;
var elements = new List<TextElementLayout> { element };

// Full Name resolves to a non-blank value once "Attn: " is prefixed, so it must NOT
// collapse even though the field run's own raw value could theoretically be blank —
// this asserts the whole run is resolved/concatenated, not just the field run in isolation.
var states = AddressBlockPreviewCalculator.Compute(
elements, new[] { "Full Name" }, Record(("Full Name", "")));

Assert.True(states[element.Id].Visible); // "Attn: " + "" = "Attn: ", not blank
}

[Fact]
public void Compute_MixedContentElement_OneOfTwoFieldRunsUnmapped_FlagsOnlyThatRun()
{
var element = TextElementLayout.CreateStatic(100, 300, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[]
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" "),
TextRun.ForField("TypoedColumn"),
});
var elements = new List<TextElementLayout> { element };

var states = AddressBlockPreviewCalculator.Compute(
elements, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.True(states[element.Id].IsUnmappedColumn);
var flags = states[element.Id].UnmappedRuns;
Assert.Equal(4, flags.Count);
Assert.False(flags[0]); // literal run — never flagged
Assert.False(flags[1]); // "Full Name" — mapped
Assert.False(flags[2]); // literal run — never flagged
Assert.True(flags[3]); // "TypoedColumn" — unmapped
}

[Fact]
public void Compute_MixedContentElement_UnmappedFieldRun_NeverCollapsesEvenIfConfigured()
{
var element = TextElementLayout.CreateStatic(100, 300, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[] { TextRun.ForLiteral("Attn: "), TextRun.ForField("TypoedColumn") });
element.CollapseIfBlank = true;
var elements = new List<TextElementLayout> { element };

var states = AddressBlockPreviewCalculator.Compute(elements, new[] { "Full Name" }, Record());

Assert.True(states[element.Id].IsUnmappedColumn);
Assert.True(states[element.Id].Visible); // never silently collapsed away
}

[Fact]
public void Compute_StaticElement_IsNeverFlaggedUnmapped()
{


+ 104
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressControlLayoutTests.cs Целия файл

@@ -0,0 +1,104 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class AddressControlLayoutTests
{
[Fact]
public void CreateDefault_StartsWithOneCollapsibleFullNameLine()
{
var control = AddressControlLayout.CreateDefault(120, 500, zOrder: 3);

Assert.Equal(120, control.X, precision: 6);
Assert.Equal(500, control.Y, precision: 6);
Assert.Equal(3, control.ZOrder);
var line = Assert.Single(control.Lines);
Assert.True(line.CollapseIfBlank);
Assert.Equal("Full Name", line.Runs.Single().ColumnName);
}

[Fact]
public void AddRemoveAndMoveLine_UpdatesCustomLineList()
{
var control = AddressControlLayout.CreateDefault(120, 500);
control.AddLine("Address 1");
control.AddLine("City");

Assert.True(control.MoveLineUp(2));
Assert.Equal(new[] { "{Full Name}", "City", "Address 1" }, control.Lines.Select(l => l.DisplayText));

Assert.True(control.MoveLineDown(1));
Assert.Equal(new[] { "{Full Name}", "Address 1", "City" }, control.Lines.Select(l => l.DisplayText));

Assert.True(control.RemoveLineAt(1));
Assert.Equal(new[] { "{Full Name}", "City" }, control.Lines.Select(l => l.DisplayText));
Assert.True(control.RemoveLineAt(1));
Assert.False(control.RemoveLineAt(0));
Assert.Single(control.Lines);
}

[Fact]
public void BaselineYForLine_UsesPreviousLineFontSizeAndLeading()
{
var control = AddressControlLayout.CreateDefault(120, 500);
control.LineSpacingMultiplier = 1.5;
control.Lines[0].FontSize = 12;
control.AddLine("Address 1").FontSize = 10;
control.AddLine("City").FontSize = 8;

Assert.Equal(500, control.BaselineYForLine(0), precision: 6);
Assert.Equal(482, control.BaselineYForLine(1), precision: 6);
Assert.Equal(467, control.BaselineYForLine(2), precision: 6);
}

// Sprint 8, "Rotate the whole Address Control as a single unit".

[Fact]
public void RotationAngle_DefaultsToZero()
{
var control = AddressControlLayout.CreateDefault(120, 500);

Assert.Equal(0, control.RotationAngle, precision: 6);
}

[Fact]
public void BoxCenter_ComputedFromAuthoredGeometryOnly_MatchesDrawnBoxCenter()
{
var control = AddressControlLayout.CreateDefault(100, 400);
control.Width = 150;
control.Lines[0].FontSize = 12;
control.AddLine("Address 1").FontSize = 10;
control.AddLine("City").FontSize = 8;

// Same box the canvas draws: top = Y + tallest line's font size, bottom = Y - Height.
var expectedTop = 400 + 12; // tallest line font size is 12
var expectedBottom = 400 - control.Height;
var expectedCenterY = expectedBottom + ((expectedTop - expectedBottom) / 2.0);

var center = control.BoxCenter;

Assert.Equal(100 + (150 / 2.0), center.X, precision: 6);
Assert.Equal(expectedCenterY, center.Y, precision: 6);
}

[Fact]
public void BoxCenter_DoesNotDependOnLineCollapseState()
{
// BoxCenter must be a pure function of authored X/Y/Width/Height/line font sizes — it
// takes no record/collapse input at all, so it is inherently identical across any two
// records regardless of which lines happen to collapse for them. This test documents that
// invariant directly against the model (the render-time regression proof lives in
// TemplatePreviewBuilderTests/RenderEngineTests, which drive real per-record resolution).
var control = AddressControlLayout.CreateDefault(120, 500);
control.Width = 180;
control.AddLine("Address 2");
control.AddLine("CityStateZip");

var centerBefore = control.BoxCenter;
// Nothing about a record's blankness is ever fed into BoxCenter — asserting it twice with
// no state change in between confirms it is a pure function of the model's own fields.
var centerAfter = control.BoxCenter;

Assert.Equal(centerBefore, centerAfter);
}
}

+ 158
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressControlRotateHandleTests.cs Целия файл

@@ -0,0 +1,158 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas".
public class AddressControlRotateHandleTests
{
private static AddressControlLayout NewControl(double x = 100, double y = 400, double width = 150, double angle = 0)
{
var control = AddressControlLayout.CreateDefault(x, y);
control.Width = width;
control.RotationAngle = angle;
control.Lines[0].FontSize = 12;
control.AddLine("Address 1").FontSize = 10;
return control;
}

[Fact]
public void LocalPosition_UnrotatedControl_SitsAboveTopCenterByHandleOffset()
{
var control = NewControl();

var local = AddressControlRotateHandle.LocalPosition(control);
var origin = AddressControlRotateHandle.LocalOrigin(control);

Assert.Equal(control.X + (control.Width / 2.0), local.X, precision: 6);
Assert.Equal(origin.Y + CanvasElementEditor.HandleOffsetPoints, local.Y, precision: 6);
Assert.Equal(local.X, origin.X, precision: 6); // straight up, no horizontal offset
}

[Fact]
public void WorldPosition_UnrotatedControl_MatchesLocalPosition()
{
var control = NewControl();

var world = AddressControlRotateHandle.WorldPosition(control);
var local = AddressControlRotateHandle.LocalPosition(control);

Assert.Equal(local, world);
}

[Fact]
public void WorldPosition_RotatedControl_OrbitsAroundBoxCenter()
{
var control = NewControl(angle: 90);

var world = AddressControlRotateHandle.WorldPosition(control);
var local = AddressControlRotateHandle.LocalPosition(control);
var pivot = control.BoxCenter;

// Independently recompute the expected forward-rotated position via the plain formula.
var radians = 90.0 * Math.PI / 180.0;
var dx = local.X - pivot.X;
var dy = local.Y - pivot.Y;
var expectedX = pivot.X + ((dx * Math.Cos(radians)) - (dy * Math.Sin(radians)));
var expectedY = pivot.Y + ((dx * Math.Sin(radians)) + (dy * Math.Cos(radians)));

Assert.Equal(expectedX, world.X, precision: 6);
Assert.Equal(expectedY, world.Y, precision: 6);
Assert.NotEqual(local.X, world.X, precision: 3);
}

[Fact]
public void HitTest_PointExactlyAtHandle_ReturnsTrue()
{
var control = NewControl(angle: 30);
var handle = AddressControlRotateHandle.WorldPosition(control);

Assert.True(AddressControlRotateHandle.HitTest(control, handle.X, handle.Y));
}

[Fact]
public void HitTest_PointJustWithinRadius_ReturnsTrue()
{
var control = NewControl();
var handle = AddressControlRotateHandle.WorldPosition(control);

Assert.True(AddressControlRotateHandle.HitTest(
control, handle.X + (CanvasElementEditor.HandleHitRadiusPoints - 0.5), handle.Y));
}

[Fact]
public void HitTest_PointFarFromHandle_ReturnsFalse()
{
var control = NewControl();
var handle = AddressControlRotateHandle.WorldPosition(control);

Assert.False(AddressControlRotateHandle.HitTest(control, handle.X + 100, handle.Y + 100));
}

[Fact]
public void HitTest_RotatedControl_OnlyHitsAtRotatedHandlePosition_NotUnrotatedOne()
{
var control = NewControl(angle: 45);
var localHandle = AddressControlRotateHandle.LocalPosition(control);
var worldHandle = AddressControlRotateHandle.WorldPosition(control);

// The un-rotated ("old") handle position must no longer register as a hit once the
// control is actually rotated 45 degrees — proving the hit test really accounts for
// rotation rather than always testing the local position.
Assert.False(AddressControlRotateHandle.HitTest(control, localHandle.X, localHandle.Y));
Assert.True(AddressControlRotateHandle.HitTest(control, worldHandle.X, worldHandle.Y));
}

[Fact]
public void RotateDragTo_PointerAtZeroHandleDirection_SetsAngleToZero()
{
var control = NewControl();
var zeroHandle = AddressControlRotateHandle.LocalPosition(control);

AddressControlRotateHandle.RotateDragTo(control, zeroHandle.X, zeroHandle.Y);

Assert.Equal(0, control.RotationAngle, precision: 3);
}

[Fact]
public void RotateDragTo_PointerNinetyDegreesAroundPivot_SetsAngleToNinety()
{
var control = NewControl();
var pivot = control.BoxCenter;
var zeroHandle = AddressControlRotateHandle.LocalPosition(control);

// Rotate the zero-handle direction 90 degrees CCW around the pivot and drag there.
var (pointerX, pointerY) = PointRotation.RotateAroundPivot(zeroHandle.X, zeroHandle.Y, pivot, 90);
AddressControlRotateHandle.RotateDragTo(control, pointerX, pointerY);

Assert.Equal(90, control.RotationAngle, precision: 3);
}

[Fact]
public void RotateDragTo_PointerExactlyOnPivot_IsNoOp()
{
var control = NewControl(angle: 15);
var pivot = control.BoxCenter;

AddressControlRotateHandle.RotateDragTo(control, pivot.X, pivot.Y);

Assert.Equal(15, control.RotationAngle, precision: 6);
}

[Fact]
public void RotateDragTo_ThenAngleDrivesWorldPosition_HandleFollowsTheDrag()
{
// Bidirectional-consistency check: after a drag sets the angle, WorldPosition (what's
// drawn/next hit-testable) must reflect that exact new angle.
var control = NewControl();
var pivot = control.BoxCenter;
var zeroHandle = AddressControlRotateHandle.LocalPosition(control);
var (pointerX, pointerY) = PointRotation.RotateAroundPivot(zeroHandle.X, zeroHandle.Y, pivot, 40);

AddressControlRotateHandle.RotateDragTo(control, pointerX, pointerY);

Assert.Equal(40, control.RotationAngle, precision: 3);
var handleAfter = AddressControlRotateHandle.WorldPosition(control);
Assert.Equal(pointerX, handleAfter.X, precision: 3);
Assert.Equal(pointerY, handleAfter.Y, precision: 3);
}
}

+ 124
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs Целия файл

@@ -231,6 +231,27 @@ public class CanvasElementEditorTests
}
}

[Fact]
public void HitTest_RotatedDynamicElement_UsesCenterPivot_SameAsStatic()
{
var editor = CreateEditor(out _);
var element = editor.AddDynamicPlaceholder(100, 100, "Full Name");
element.RotationAngle = 45;

// Post-Sprint-7-review fix (2026-10-19): the editing canvas now rotates dynamic/mixed
// elements around their bounding-box center too, identically to static text (see
// RotationPivotCalculator.ComputeForCanvasEditing's remarks) — this is the exact same
// rotated-corner world point as
// HitTest_RotatedElement_PointInRotatedCornerButOutsideUnrotatedBox_StillHits above,
// which only hits via a center-pivot rotation, not the record-accurate fixed-anchor
// pivot the real render/preview paths still use for dynamic content.
var worldPoint = (X: 113.54, Y: 115.47);

var hit = editor.HitTest(worldPoint.X, worldPoint.Y);

Assert.Same(element, hit);
}

// Sprint 4, "Rotate elements by dragging a handle on the canvas".

[Fact]
@@ -267,6 +288,23 @@ public class CanvasElementEditorTests
Assert.Equal(105, handle.Y, precision: 3); // stayed level with center vertically
}

[Fact]
public void HandlePosition_RotatedDynamicElement_OrbitsAroundCenter_SameAsStatic()
{
var editor = CreateEditor(out _);
var element = editor.AddDynamicPlaceholder(100, 100, "Full Name");
element.RotationAngle = 90;

var handle = editor.HandlePosition()!.Value;

// Post-Sprint-7-review fix (2026-10-19): the handle now orbits the bounding-box center
// (110,105) for dynamic/mixed content too, exactly like
// HandlePosition_RotatedElement_OrbitsWithTheElement's static case, rather than the fixed
// authored anchor (100,100) the real render/preview paths still use.
Assert.True(handle.X < 110); // moved left of center
Assert.Equal(105, handle.Y, precision: 3); // stayed level with center vertically
}

[Fact]
public void HitTestHandle_PointAtHandlePosition_ReturnsTrue()
{
@@ -330,6 +368,22 @@ public class CanvasElementEditorTests
Assert.Equal(-90, element.RotationAngle, precision: 3);
}

[Fact]
public void RotateDragTo_DynamicElement_UsesCenterPivot_SameAsStatic()
{
var editor = CreateEditor(out _);
var element = editor.AddDynamicPlaceholder(100, 100, "Full Name");
editor.BeginRotateDrag();

// Post-Sprint-7-review fix (2026-10-19): dynamic/mixed content now rotates around the
// bounding-box center (110,105) on this editing canvas too, matching
// RotateDragTo_PointerToTheLeft_SetsAngleTo90Degrees's static case exactly — not the fixed
// authored anchor (100,100) the real render/preview paths still use.
editor.RotateDragTo(0, 105); // directly left of center, same height

Assert.Equal(90, element.RotationAngle, precision: 3);
}

[Fact]
public void RotateDragTo_WithoutBeginRotateDrag_IsNoOp()
{
@@ -370,6 +424,76 @@ public class CanvasElementEditorTests
Assert.Equal(100, element.Y, precision: 6);
}

// Sprint 7, "Snap elements to grid and guides".

[Fact]
public void DragTo_SnapDisabled_MovesToExactRawPosition()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello");
editor.BeginDrag(100, 100);

editor.DragTo(123, 157);

Assert.Equal(123.0, element.X, precision: 6);
Assert.Equal(157.0, element.Y, precision: 6);
}

[Fact]
public void DragTo_SnapEnabled_RoundsPositionToNearestGridIncrement()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello");
editor.SnapToGridEnabled = true;
editor.GridSizePoints = 10;
editor.BeginDrag(100, 100);

editor.DragTo(123, 157); // no offset (grabbed at origin) -> raw target (123, 157)

Assert.Equal(120.0, element.X, precision: 6);
Assert.Equal(160.0, element.Y, precision: 6);
}

[Fact]
public void DragTo_SnapEnabled_SnapsContinuouslyThroughoutTheGesture_NotOnlyOnRelease()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello");
editor.SnapToGridEnabled = true;
editor.GridSizePoints = 10;
editor.BeginDrag(100, 100);

editor.DragTo(104, 100); // mid-gesture, well before EndDrag/release
Assert.Equal(100.0, element.X, precision: 6); // already snapped, not just a raw 104

editor.DragTo(106, 100); // still mid-gesture
Assert.Equal(110.0, element.X, precision: 6);
}

[Fact]
public void DragTo_SnapEnabled_CustomGridSize_RoundsToThatIncrement()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(0, 0, "Hello");
editor.SnapToGridEnabled = true;
editor.GridSizePoints = 25;
editor.BeginDrag(0, 0);

editor.DragTo(37, 12);

Assert.Equal(25.0, element.X, precision: 6);
Assert.Equal(0.0, element.Y, precision: 6);
}

[Fact]
public void SnapToGridEnabled_DefaultsToFalse_SoExistingDragBehaviorIsUnchanged()
{
var editor = CreateEditor(out _);

Assert.False(editor.SnapToGridEnabled);
Assert.Equal(CanvasElementEditor.DefaultGridSizePoints, editor.GridSizePoints, precision: 6);
}

[Fact]
public void Move_UpdatesTemplateLayoutDocumentInPlace_SoCanvasAndStoredStateAgree()
{


+ 107
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CsvRecordNavigatorTests.cs Целия файл

@@ -0,0 +1,107 @@
using EnvelopeRenderer.Desktop.Core.Csv;

namespace EnvelopeRenderer.Desktop.Tests;

public class CsvRecordNavigatorTests : IDisposable
{
private readonly string _tempCsvPath = Path.Combine(Path.GetTempPath(), $"csvnav-{Guid.NewGuid():N}.csv");

public void Dispose()
{
if (File.Exists(_tempCsvPath))
{
File.Delete(_tempCsvPath);
}
}

/// <summary>Writes a CSV with a header row plus <paramref name="rowCount"/> data rows (well
/// over the bounded 20-row <c>CsvPreviewLoader</c> sample size), so navigating past row 20
/// proves this reads the full file, not a bounded sample.</summary>
private void WriteCsv(int rowCount)
{
using var writer = new StreamWriter(_tempCsvPath);
writer.WriteLine("Full Name,City");
for (var i = 1; i <= rowCount; i++)
{
writer.WriteLine($"Person {i},City {i}");
}
}

[Fact]
public void TryReadRecord_FirstRecord_ReturnsExpectedRow()
{
WriteCsv(30);

var result = CsvRecordNavigator.TryReadRecord(_tempCsvPath, 1);

Assert.True(result.Success, result.Error);
Assert.Equal("Person 1", result.Record!["Full Name"]);
Assert.Equal("City 1", result.Record["City"]);
}

[Fact]
public void TryReadRecord_BeyondBoundedSampleSize_StillReadsCorrectRow()
{
// CsvPreviewLoader.DefaultMaxSampleRows is 20 — record 25 proves the full-file streaming
// reader is used, not the bounded sample loader.
WriteCsv(30);

var result = CsvRecordNavigator.TryReadRecord(_tempCsvPath, 25);

Assert.True(result.Success, result.Error);
Assert.Equal("Person 25", result.Record!["Full Name"]);
Assert.Equal("City 25", result.Record["City"]);
}

[Fact]
public void TryReadRecord_ColumnLookupIsCaseInsensitive()
{
WriteCsv(5);

var result = CsvRecordNavigator.TryReadRecord(_tempCsvPath, 1);

Assert.True(result.Success, result.Error);
Assert.Equal("Person 1", result.Record!["full name"]);
}

[Fact]
public void TryReadRecord_OutOfRange_FailsWithClearMessage()
{
WriteCsv(5);

var result = CsvRecordNavigator.TryReadRecord(_tempCsvPath, 100);

Assert.False(result.Success);
Assert.Contains("out of range", result.Error);
Assert.Contains("5", result.Error);
}

[Fact]
public void TryReadRecord_ZeroOrNegativeRecordNumber_FailsWithClearMessage()
{
WriteCsv(5);

var result = CsvRecordNavigator.TryReadRecord(_tempCsvPath, 0);

Assert.False(result.Success);
Assert.Contains("1 or greater", result.Error);
}

[Fact]
public void TryReadRecord_MissingFile_FailsWithClearMessage()
{
var result = CsvRecordNavigator.TryReadRecord(Path.Combine(Path.GetTempPath(), "does-not-exist.csv"), 1);

Assert.False(result.Success);
Assert.Contains("not found", result.Error);
}

[Fact]
public void TryReadRecord_NoCsvPath_FailsWithClearMessage()
{
var result = CsvRecordNavigator.TryReadRecord(null, 1);

Assert.False(result.Success);
Assert.Contains("No CSV", result.Error);
}
}

+ 49
- 0
code/src/EnvelopeRenderer.Desktop.Tests/GridSnapperTests.cs Целия файл

@@ -0,0 +1,49 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class GridSnapperTests
{
[Theory]
[InlineData(0, 10, 0)]
[InlineData(4, 10, 0)]
[InlineData(6, 10, 10)]
[InlineData(14, 10, 10)]
[InlineData(16, 10, 20)]
[InlineData(-4, 10, 0)]
[InlineData(-6, 10, -10)]
public void Snap_RoundsToNearestGridIncrement(double value, double gridSize, double expected)
{
Assert.Equal(expected, GridSnapper.Snap(value, gridSize), precision: 6);
}

[Fact]
public void Snap_ExactMidpoint_RoundsToEven_MatchingMathRoundDefault()
{
// Math.Round(0.5) banker's-rounds to 0 (nearest even), not away-from-zero — documenting
// this explicitly since it's the one subtle case a naive re-implementation could get
// wrong differently.
Assert.Equal(0, GridSnapper.Snap(5, 10), precision: 6);
Assert.Equal(20, GridSnapper.Snap(15, 10), precision: 6);
}

[Fact]
public void Snap_AlreadyOnGrid_ReturnsSameValue()
{
Assert.Equal(30, GridSnapper.Snap(30, 10), precision: 6);
}

[Theory]
[InlineData(0)]
[InlineData(-5)]
public void Snap_NonPositiveGridSize_ReturnsValueUnchanged(double gridSize)
{
Assert.Equal(123.456, GridSnapper.Snap(123.456, gridSize), precision: 6);
}

[Fact]
public void Snap_FractionalGridSize_RoundsCorrectly()
{
Assert.Equal(2.5, GridSnapper.Snap(2.6, 2.5), precision: 6);
}
}

+ 57
- 0
code/src/EnvelopeRenderer.Desktop.Tests/PointRotationTests.cs Целия файл

@@ -0,0 +1,57 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

// Sprint 8, "Rotate the whole Address Control as a single unit".
public class PointRotationTests
{
[Fact]
public void RotateAroundPivot_ZeroAngle_ReturnsSamePoint()
{
var (x, y) = PointRotation.RotateAroundPivot(10, 20, (5, 5), 0);

Assert.Equal(10, x, precision: 6);
Assert.Equal(20, y, precision: 6);
}

[Fact]
public void RotateAroundPivot_PointAtPivot_StaysAtPivot()
{
var (x, y) = PointRotation.RotateAroundPivot(5, 5, (5, 5), 90);

Assert.Equal(5, x, precision: 6);
Assert.Equal(5, y, precision: 6);
}

[Fact]
public void RotateAroundPivot_NinetyDegrees_MatchesCounterclockwiseConvention()
{
// +X axis point at (1, 0) relative to origin pivot; rotating 90 degrees CCW (this
// project's stored convention, matching RotatedTextAnchorCalculator's empirically
// confirmed sign) should land on +Y, i.e. (0, 1).
var (x, y) = PointRotation.RotateAroundPivot(1, 0, (0, 0), 90);

Assert.Equal(0, x, precision: 6);
Assert.Equal(1, y, precision: 6);
}

[Fact]
public void RotateAroundPivot_180Degrees_ReflectsThroughPivot()
{
var (x, y) = PointRotation.RotateAroundPivot(10, 4, (0, 0), 180);

Assert.Equal(-10, x, precision: 6);
Assert.Equal(-4, y, precision: 6);
}

[Fact]
public void RotateAroundPivot_ThenRotateBack_ReturnsOriginalPoint()
{
var pivot = (X: 3.0, Y: -2.0);
var (rx, ry) = PointRotation.RotateAroundPivot(11, 7, pivot, 37);
var (x, y) = PointRotation.RotateAroundPivot(rx, ry, pivot, -37);

Assert.Equal(11, x, precision: 6);
Assert.Equal(7, y, precision: 6);
}
}

+ 68
- 0
code/src/EnvelopeRenderer.Desktop.Tests/RotationPivotCalculatorTests.cs Целия файл

@@ -0,0 +1,68 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class RotationPivotCalculatorTests
{
[Fact]
public void Compute_StaticContent_ReturnsBoundingBoxCenter()
{
var pivot = RotationPivotCalculator.Compute(isDynamic: false, x: 100, y: 100, width: 20, height: 10);

Assert.Equal(110, pivot.X, precision: 6);
Assert.Equal(105, pivot.Y, precision: 6);
}

[Fact]
public void Compute_DynamicContent_ReturnsFixedAuthoredAnchor_IgnoringMeasuredSize()
{
var pivot = RotationPivotCalculator.Compute(isDynamic: true, x: 100, y: 100, width: 20, height: 10);

Assert.Equal(100, pivot.X, precision: 6);
Assert.Equal(100, pivot.Y, precision: 6);
}

[Fact]
public void Compute_DynamicContent_DifferentMeasuredWidths_StillReturnsSameAnchor()
{
// The whole point of the fixed-anchor rule: two records with very different resolved text
// widths must not move the pivot (see the 2026-10-09 rotation defect in
// logs/technical_debt_log.md).
var narrow = RotationPivotCalculator.Compute(isDynamic: true, x: 50, y: 60, width: 5, height: 10);
var wide = RotationPivotCalculator.Compute(isDynamic: true, x: 50, y: 60, width: 500, height: 10);

Assert.Equal(narrow, wide);
Assert.Equal((50, 60), narrow);
}

// Post-Sprint-7-review fix (2026-10-19): the editing canvas rotates every element around its
// bounding-box center, static or dynamic/mixed alike, since it only ever draws the literal
// authored token text (never a per-record resolved value) — see ComputeForCanvasEditing's
// remarks. These confirm that path always agrees with Compute's static branch and never with
// its dynamic branch, regardless of measured width/height.

[Fact]
public void ComputeForCanvasEditing_MatchesStaticBoundingBoxCenter()
{
var canvasPivot = RotationPivotCalculator.ComputeForCanvasEditing(x: 100, y: 100, width: 20, height: 10);
var staticPivot = RotationPivotCalculator.Compute(isDynamic: false, x: 100, y: 100, width: 20, height: 10);

Assert.Equal(staticPivot, canvasPivot);
Assert.Equal(110, canvasPivot.X, precision: 6);
Assert.Equal(105, canvasPivot.Y, precision: 6);
}

[Fact]
public void ComputeForCanvasEditing_DifferentMeasuredWidths_MovesWithTheBoundingBox()
{
// Unlike Compute's dynamic-content branch (which deliberately ignores measured size), the
// canvas-editing pivot always follows the bounding box — appropriate here since the canvas
// only ever measures the fixed literal token text, never a varying per-record value.
var narrow = RotationPivotCalculator.ComputeForCanvasEditing(x: 50, y: 60, width: 5, height: 10);
var wide = RotationPivotCalculator.ComputeForCanvasEditing(x: 50, y: 60, width: 500, height: 10);

Assert.NotEqual(narrow, wide);
Assert.Equal((52.5, 65), narrow);
Assert.Equal((300, 65), wide);
}
}

+ 285
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs Целия файл

@@ -296,6 +296,291 @@ public class TemplateLayoutXmlSerializerTests : IDisposable
Assert.Contains(errors, e => e.Contains("angle", StringComparison.OrdinalIgnoreCase));
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void SaveThenLoad_MixedContentElement_RoundTripsRunSequenceExactly()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var element = TextElementLayout.CreateStatic(10, 20, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[]
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" - "),
TextRun.ForField("Last Name"),
});
document.Elements.Add(element);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var loaded = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var reopened, out var errors);

Assert.True(loaded, string.Join("; ", errors));
var reloaded = reopened!.Elements.Single();
Assert.Equal(4, reloaded.Runs.Count);
Assert.Equal("Attn: ", reloaded.Runs[0].Literal);
Assert.Equal("Full Name", reloaded.Runs[1].ColumnName);
Assert.Equal(" - ", reloaded.Runs[2].Literal);
Assert.Equal("Last Name", reloaded.Runs[3].ColumnName);
}

[Fact]
public void Save_MultiRunElement_UsesRunChildElements()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var element = TextElementLayout.CreateStatic(10, 20, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[] { TextRun.ForLiteral("Attn: "), TextRun.ForField("Full Name") });
document.Elements.Add(element);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var xml = File.ReadAllText(_tempPath);

Assert.Contains("<run text=\"Attn: \" />", xml);
Assert.Contains("<run column=\"Full Name\" />", xml);
}

[Fact]
public void Save_SingleRunElements_NeverEmitRunChildElements()
{
// Directly confirms the legacy single-run shape is written unchanged (no <run> children
// at all) — the mechanism behind this story's AC2 byte-for-byte backward compatibility.
var document = BuildSampleDocument();

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var xml = File.ReadAllText(_tempPath);

Assert.DoesNotContain("<run ", xml);
}

[Fact]
public void Load_TextElementWithRunChildren_ParsesMultiRunContent()
{
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<text x=\"1\" y=\"1\" font=\"Arial\" size=\"12\">" +
"<run text=\"Hi \" /><run column=\"Full Name\" />" +
"</text>" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);

Assert.True(result, string.Join("; ", errors));
var element = document!.Elements.Single();
Assert.Equal(2, element.Runs.Count);
Assert.Equal("Hi ", element.Runs[0].Literal);
Assert.Equal("Full Name", element.Runs[1].ColumnName);
}

[Fact]
public void Load_RunChildrenAlongsideColumnAttribute_ReturnsFalseWithClearError()
{
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<text x=\"1\" y=\"1\" font=\"Arial\" size=\"12\" column=\"Full Name\">" +
"<run text=\"Hi\" />" +
"</text>" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);

Assert.False(result);
Assert.Contains(errors, e => e.Contains("<run>") && e.Contains("exactly one form"));
}

[Fact]
public void Load_PreSprint5TemplateFile_LoadsAndRoundTripsAsBeforeThisStory()
{
// AC2's explicit "real regression check against previously-saved templates" requirement:
// this is byte-for-byte the shape TemplateLayoutXmlSerializer wrote before this story
// (Sprint 4's own SaveThenLoad round-trip fixture), confirming Sprint 5's changes did not
// alter how a genuinely pre-existing saved file loads.
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\" canvasUnit=\"Points\">" +
"<text x=\"120\" y=\"240\" font=\"Arial\" size=\"12\" color=\"#C80A1E\" zOrder=\"0\">Static label:</text>" +
"<text x=\"120\" y=\"225\" font=\"Arial [Bold]\" size=\"14\" color=\"#000000\" zOrder=\"1\" column=\"Full Name\" />" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);

Assert.True(result, string.Join("; ", errors));
Assert.Equal(2, document!.Elements.Count);

var staticElement = document.Elements.Single(e => !e.IsDynamic);
Assert.Equal("Static label:", staticElement.StaticText);
Assert.Single(staticElement.Runs);

var dynamicElement = document.Elements.Single(e => e.IsDynamic);
Assert.Equal("Full Name", dynamicElement.ColumnName);
Assert.Single(dynamicElement.Runs);

// Re-saving must reproduce the exact same legacy shape (no <run> children introduced).
var reSavePath = _tempPath + ".resave.xml";
try
{
TemplateLayoutXmlSerializer.Save(document, reSavePath);
var resavedXml = File.ReadAllText(reSavePath);
Assert.DoesNotContain("<run ", resavedXml);
Assert.Contains("column=\"Full Name\"", resavedXml);
Assert.Contains("Static label:", resavedXml);
}
finally
{
if (File.Exists(reSavePath))
{
File.Delete(reSavePath);
}
}
}

// Sprint 6, "Group lines into a single, movable Address Control".

[Fact]
public void SaveThenLoad_AddressControl_RoundTripsLinesAndPerLineSettings()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var control = AddressControlLayout.CreateDefault(120, 500, zOrder: 4);
control.Width = 190;
control.LineSpacingMultiplier = 1.4;
control.Lines[0].FontFamily = "Arial";
control.Lines[0].FontSize = 12;
control.Lines[0].Color = new RgbColor(10, 20, 30);
control.Lines.Add(new AddressControlLineLayout("Arial [Bold]", 10)
{
CollapseIfBlank = false,
Color = new RgbColor(40, 50, 60),
});
control.Lines[1].Runs.Clear();
control.Lines[1].Runs.AddRange(new[]
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Department"),
});
document.AddressControls.Add(control);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var loaded = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var reopened, out var errors);

Assert.True(loaded, string.Join("; ", errors));
var reloaded = reopened!.AddressControls.Single();
Assert.Equal(120, reloaded.X, precision: 6);
Assert.Equal(500, reloaded.Y, precision: 6);
Assert.Equal(190, reloaded.Width, precision: 6);
Assert.Equal(4, reloaded.ZOrder);
Assert.Equal(1.4, reloaded.LineSpacingMultiplier, precision: 6);
Assert.Equal(2, reloaded.Lines.Count);
Assert.True(reloaded.Lines[0].CollapseIfBlank);
Assert.Equal(new RgbColor(10, 20, 30), reloaded.Lines[0].Color);
Assert.False(reloaded.Lines[1].CollapseIfBlank);
Assert.Equal("Attn: ", reloaded.Lines[1].Runs[0].Literal);
Assert.Equal("Department", reloaded.Lines[1].Runs[1].ColumnName);
}

[Fact]
public void Save_AddressControl_UsesRenderTimeContainerShape()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var control = AddressControlLayout.CreateDefault(120, 500);
document.AddressControls.Add(control);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var xml = File.ReadAllText(_tempPath);

Assert.Contains("<addressControl", xml);
Assert.Contains("<line font=\"Arial\" size=\"12\"", xml);
Assert.Contains("<run column=\"Full Name\" />", xml);
}

// Sprint 8, "Rotate the whole Address Control as a single unit".

[Fact]
public void SaveThenLoad_RotatedAddressControl_RoundTripsAngleAttribute()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var control = AddressControlLayout.CreateDefault(120, 500);
control.RotationAngle = -33.5;
document.AddressControls.Add(control);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var xml = File.ReadAllText(_tempPath);
Assert.Contains("angle=\"-33.5\"", xml);

var loaded = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var reopened, out var errors);

Assert.True(loaded, string.Join("; ", errors));
Assert.Equal(-33.5, reopened!.AddressControls.Single().RotationAngle, precision: 6);
}

[Fact]
public void Save_UnrotatedAddressControl_OmitsAngleAttribute()
{
// Written only when non-default, matching every other optional attribute's convention —
// a template with no rotated Address Control round-trips byte-for-byte identical to how
// it looked before this story.
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var control = AddressControlLayout.CreateDefault(120, 500);
document.AddressControls.Add(control);

TemplateLayoutXmlSerializer.Save(document, _tempPath);
var xml = File.ReadAllText(_tempPath);

Assert.DoesNotContain("angle=", xml);
}

[Fact]
public void Load_AddressControlWithoutAngleAttribute_DefaultsToZero()
{
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<addressControl x=\"120\" y=\"500\" width=\"180\">" +
"<line font=\"Arial\" size=\"12\" column=\"Full Name\" />" +
"</addressControl>" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);

Assert.True(result, string.Join("; ", errors));
Assert.Equal(0, document!.AddressControls.Single().RotationAngle, precision: 6);
}

[Fact]
public void Load_AddressControlWithNonNumericAngle_ReturnsFalseWithClearError()
{
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<addressControl x=\"120\" y=\"500\" width=\"180\" angle=\"sideways\">" +
"<line font=\"Arial\" size=\"12\" column=\"Full Name\" />" +
"</addressControl>" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out _, out var errors);

Assert.False(result);
Assert.Contains(errors, e => e.Contains("non-numeric 'angle'"));
}

[Fact]
public void Load_AddressControlWithoutLines_ReturnsFalseWithClearError()
{
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<addressControl x=\"120\" y=\"500\" width=\"180\" />" +
"</envelopeTemplate>");

var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);

Assert.False(result);
Assert.Null(document);
Assert.Contains(errors, e => e.Contains("at least one <line>"));
}

[Fact]
public void Save_OverwritesExistingFile()
{


+ 308
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TemplatePreviewBuilderTests.cs Целия файл

@@ -0,0 +1,308 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview of the current template". These
/// scenarios deliberately mirror `EnvelopeRenderer.Cli.Tests.RenderEngineTests`' equivalent CLI
/// scenarios (same shapes, same expected resolved text/positions) — the two implementations are
/// independent (see `TemplatePreviewBuilder`'s class remarks for why), and this is how parity
/// between them is verified, the same convention already used for this project's two independent
/// XML parsers.
/// </summary>
public class TemplatePreviewBuilderTests
{
private static IReadOnlyDictionary<string, string> Record(params (string Key, string Value)[] pairs) =>
pairs.ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase);

private static TemplateLayoutDocument NewDocument() =>
new(new CanvasSettings(297, 684, CanvasUnit.Points));

[Fact]
public void Build_NoRecordSelected_ReturnsClearFailureMessage_NotBlank()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateStatic(100, 300, "Hello"));

var result = TemplatePreviewBuilder.Build(document, Array.Empty<string>(), null);

Assert.False(result.Success);
Assert.NotNull(result.Message);
Assert.Empty(result.Draws);
}

[Fact]
public void Build_StaticAndDynamicElements_ResolvesPerRecord()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateStatic(120, 240, "Static label"));
document.Elements.Add(TextElementLayout.CreateDynamic(120, 225, "Full Name"));

var result = TemplatePreviewBuilder.Build(document, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.True(result.Success, result.Message);
Assert.Equal(new[] { "Static label", "Alice" }, result.Draws.Select(d => d.Text));
}

[Fact]
public void Build_UnknownColumnReference_FailsWithSpecificMessage()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateDynamic(120, 240, "Full Name"));

var result = TemplatePreviewBuilder.Build(document, new[] { "Some Other Column" }, Record());

Assert.False(result.Success);
Assert.Contains("Full Name", result.Message);
}

[Fact]
public void Build_ColumnLookupIsCaseInsensitive()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateDynamic(120, 240, "Full Name"));

var result = TemplatePreviewBuilder.Build(document, new[] { "full name" }, Record(("full name", "Alice")));

Assert.True(result.Success, result.Message);
Assert.Equal("Alice", result.Draws[0].Text);
}

// Mirrors RenderEngineTests.Render_CollapsibleFieldBlank_IsOmittedAndSubsequentLineShiftsUp.
[Fact]
public void Build_CollapsibleFieldBlank_IsOmittedAndSubsequentLineShiftsUp()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateStatic(100, 300, "Full Name line"));
var address2 = TextElementLayout.CreateDynamic(100, 285, "Address2");
address2.CollapseIfBlank = true;
document.Elements.Add(address2);
document.Elements.Add(TextElementLayout.CreateDynamic(100, 270, "CityStateZip"));

var result = TemplatePreviewBuilder.Build(
document,
new[] { "Address2", "CityStateZip" },
Record(("Address2", ""), ("CityStateZip", "Springfield, IL")));

Assert.True(result.Success, result.Message);
Assert.Equal(2, result.Draws.Count);
Assert.Equal("Full Name line", result.Draws[0].Text);
Assert.Equal(300, result.Draws[0].Y, precision: 6);
Assert.Equal("Springfield, IL", result.Draws[1].Text);
Assert.Equal(285, result.Draws[1].Y, precision: 6); // shifted up from 270 to 285
}

// Mirrors RenderEngineTests.Render_MixedRunElement_ConcatenatesLiteralAndFieldRunsPerRecord.
[Fact]
public void Build_MixedContentElement_ConcatenatesLiteralAndFieldRunsPerRecord()
{
var document = NewDocument();
var element = TextElementLayout.CreateStatic(120, 240, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[]
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" - "),
TextRun.ForField("Last Name"),
});
document.Elements.Add(element);

var result = TemplatePreviewBuilder.Build(
document, new[] { "Full Name", "Last Name" }, Record(("Full Name", "Alice"), ("Last Name", "Smith")));

Assert.True(result.Success, result.Message);
Assert.Equal("Attn: Alice - Smith", result.Draws[0].Text);
}

// Mirrors RenderEngineTests.Render_RotatedDynamicElement_UsesFixedRotationPivotAcrossRecords.
[Theory]
[InlineData("Al")]
[InlineData("Alexandria Montgomery")]
public void Build_RotatedDynamicElement_ReportsFixedAnchorPositionRegardlessOfResolvedTextLength(string name)
{
var document = NewDocument();
var element = TextElementLayout.CreateDynamic(100, 300, "Full Name");
element.RotationAngle = 45;
document.Elements.Add(element);

var result = TemplatePreviewBuilder.Build(document, new[] { "Full Name" }, Record(("Full Name", name)));

Assert.True(result.Success, result.Message);
Assert.Equal(name, result.Draws[0].Text);
Assert.Equal(100, result.Draws[0].X, precision: 6);
Assert.Equal(300, result.Draws[0].Y, precision: 6);
Assert.Equal(45, result.Draws[0].Angle, precision: 6);
Assert.True(result.Draws[0].IsDynamic);
}

[Fact]
public void Build_RotatedStaticElement_IsNotMarkedDynamic()
{
var document = NewDocument();
var element = TextElementLayout.CreateStatic(100, 300, "Rotated label");
element.RotationAngle = 45;
document.Elements.Add(element);

var result = TemplatePreviewBuilder.Build(document, Array.Empty<string>(), Record());

Assert.True(result.Success, result.Message);
Assert.False(result.Draws[0].IsDynamic);
Assert.Equal(45, result.Draws[0].Angle, precision: 6);
}

// Mirrors RenderEngineTests.Render_AddressControl_ExpandsLinesInOrderWithMixedContent.
[Fact]
public void Build_AddressControl_ExpandsLinesInOrderWithMixedContent()
{
var document = NewDocument();
var control = AddressControlLayout.CreateDefault(120, 500);
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name", fontSize: 12));
var line2 = new AddressControlLineLayout("Arial", 10);
line2.Runs.Clear();
line2.Runs.AddRange(new[] { TextRun.ForLiteral("Attn: "), TextRun.ForField("Department") });
control.Lines.Add(line2);
document.AddressControls.Add(control);

var result = TemplatePreviewBuilder.Build(
document,
new[] { "Full Name", "Department" },
Record(("Full Name", "Alice Smith"), ("Department", "Accounting")));

Assert.True(result.Success, result.Message);
Assert.Equal(new[] { "Alice Smith", "Attn: Accounting" }, result.Draws.Select(d => d.Text));
Assert.Equal(120, result.Draws[0].X, precision: 6);
Assert.Equal(500, result.Draws[0].Y, precision: 6);
Assert.Equal(485, result.Draws[1].Y, precision: 6); // 500 - (12 * 1.25)
}

// Mirrors RenderEngineTests.Render_AddressControl_BlankCollapsibleLineIsOmittedAndFollowingLineShiftsUp.
[Fact]
public void Build_AddressControl_BlankCollapsibleLineIsOmittedAndFollowingLineShiftsUp()
{
var document = NewDocument();
var control = AddressControlLayout.CreateDefault(120, 500);
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name"));
control.Lines.Add(AddressControlLineLayout.CreateField("Address2"));
control.Lines.Add(AddressControlLineLayout.CreateField("CityStateZip"));
document.AddressControls.Add(control);

var result = TemplatePreviewBuilder.Build(
document,
new[] { "Full Name", "Address2", "CityStateZip" },
Record(("Full Name", "Alice Smith"), ("Address2", ""), ("CityStateZip", "Springfield, IL")));

Assert.True(result.Success, result.Message);
Assert.Equal(new[] { "Alice Smith", "Springfield, IL" }, result.Draws.Select(d => d.Text));
Assert.Equal(500, result.Draws[0].Y, precision: 6);
Assert.Equal(485, result.Draws[1].Y, precision: 6);
}

// Mirrors RenderEngineTests.Render_TextAndAddressControl_FollowsParsedRenderOrder.
[Fact]
public void Build_TextAndAddressControl_FollowsZOrder()
{
var document = NewDocument();
var textElement = TextElementLayout.CreateStatic(10, 10, "Text after");
textElement.ZOrder = 1;
document.Elements.Add(textElement);

var control = AddressControlLayout.CreateDefault(120, 500);
control.ZOrder = 0;
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name"));
document.AddressControls.Add(control);

var result = TemplatePreviewBuilder.Build(document, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.True(result.Success, result.Message);
Assert.Equal(new[] { "Alice", "Text after" }, result.Draws.Select(d => d.Text));
}

// Sprint 8, "Rotate the whole Address Control as a single unit".
// Mirrors RenderEngineTests.Render_RotatedAddressControl_RotatesEachLineAsRigidGroupAroundBoxCenter.
[Fact]
public void Build_RotatedAddressControl_RotatesEachLineAsRigidGroupAroundBoxCenter()
{
var document = NewDocument();
var control = AddressControlLayout.CreateDefault(120, 500);
control.Width = 180;
control.RotationAngle = 90;
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name", fontSize: 12));
control.Lines.Add(AddressControlLineLayout.CreateField("Address1", fontSize: 12));
document.AddressControls.Add(control);

var result = TemplatePreviewBuilder.Build(
document,
new[] { "Full Name", "Address1" },
Record(("Full Name", "Alice Smith"), ("Address1", "123 Main St")));

Assert.True(result.Success, result.Message);
Assert.Equal(2, result.Draws.Count);
Assert.All(result.Draws, d => Assert.Equal(90, d.Angle, precision: 6));
Assert.All(result.Draws, d => Assert.True(d.IsDynamic));

var pivot = control.BoxCenter;
var radians = 90.0 * Math.PI / 180.0;
var dx = 120 - pivot.X;
var dy = 500 - pivot.Y;
var expectedX = pivot.X + ((dx * Math.Cos(radians)) - (dy * Math.Sin(radians)));
var expectedY = pivot.Y + ((dx * Math.Sin(radians)) + (dy * Math.Cos(radians)));
Assert.Equal(expectedX, result.Draws[0].X, precision: 6);
Assert.Equal(expectedY, result.Draws[0].Y, precision: 6);
}

[Fact]
public void Build_RotatedAddressControl_PivotIsStableAcrossRecordsRegardlessOfCollapse()
{
var document = NewDocument();
var control = AddressControlLayout.CreateDefault(100, 400);
control.Width = 150;
control.RotationAngle = 45;
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Full Name"));
var address2 = AddressControlLineLayout.CreateField("Address2");
address2.CollapseIfBlank = true;
control.Lines.Add(address2);
control.Lines.Add(AddressControlLineLayout.CreateField("CityStateZip"));
document.AddressControls.Add(control);

var headers = new[] { "Full Name", "Address2", "CityStateZip" };
var collapsedResult = TemplatePreviewBuilder.Build(
document, headers,
Record(("Full Name", "Alice Smith"), ("Address2", ""), ("CityStateZip", "Springfield, IL")));
var uncollapsedResult = TemplatePreviewBuilder.Build(
document, headers,
Record(("Full Name", "Alice Smith"), ("Address2", "Apt 4B"), ("CityStateZip", "Springfield, IL")));

Assert.True(collapsedResult.Success, collapsedResult.Message);
Assert.True(uncollapsedResult.Success, uncollapsedResult.Message);
Assert.Equal(2, collapsedResult.Draws.Count);
Assert.Equal(3, uncollapsedResult.Draws.Count);

// The first line is unaffected by Address2's collapse either way, so it must land at the
// identical rotated position in both — proving the same fixed pivot was used both times,
// not one derived from AddressLineCollapser's per-record shifted positions.
Assert.Equal(collapsedResult.Draws[0].X, uncollapsedResult.Draws[0].X, precision: 6);
Assert.Equal(collapsedResult.Draws[0].Y, uncollapsedResult.Draws[0].Y, precision: 6);
}

[Fact]
public void Build_AddressControl_UnknownFieldRunColumn_FailsWithSpecificMessage()
{
var document = NewDocument();
var control = AddressControlLayout.CreateDefault(120, 500);
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Missing Column"));
document.AddressControls.Add(control);

var result = TemplatePreviewBuilder.Build(document, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.False(result.Success);
Assert.Contains("Missing Column", result.Message);
Assert.Empty(result.Draws);
}
}

+ 50
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextElementLayoutTests.cs Целия файл

@@ -71,4 +71,54 @@ public class TextElementLayoutTests
Assert.True(element.CollapseIfBlank);
Assert.Equal(45.5, element.RotationAngle, precision: 6);
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void CreateStatic_ProducesSingleLiteralRun()
{
var element = TextElementLayout.CreateStatic(0, 0, "Hello");

var run = Assert.Single(element.Runs);
Assert.False(run.IsField);
Assert.Equal("Hello", run.Literal);
Assert.False(element.HasSingleColumnRun);
}

[Fact]
public void CreateDynamic_ProducesSingleFieldRun()
{
var element = TextElementLayout.CreateDynamic(0, 0, "Full Name");

var run = Assert.Single(element.Runs);
Assert.True(run.IsField);
Assert.Equal("Full Name", run.ColumnName);
Assert.True(element.HasSingleColumnRun);
}

[Fact]
public void MixedContentRuns_IsDynamicTrue_ButHasSingleColumnRunFalse()
{
var element = TextElementLayout.CreateStatic(0, 0, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[] { TextRun.ForLiteral("Attn: "), TextRun.ForField("Full Name") });

Assert.True(element.IsDynamic);
Assert.False(element.HasSingleColumnRun);
Assert.Null(element.StaticText);
Assert.Null(element.ColumnName);
Assert.Equal("Attn: {Full Name}", element.DisplayText);
}

[Fact]
public void PureLiteralMultiRunElement_IsNotDynamic()
{
var element = TextElementLayout.CreateStatic(0, 0, "placeholder");
element.Runs.Clear();
element.Runs.AddRange(new[] { TextRun.ForLiteral("Hello "), TextRun.ForLiteral("World") });

Assert.False(element.IsDynamic);
Assert.Null(element.StaticText); // back-compat projection only covers the single-run case
Assert.Equal("Hello World", element.DisplayText);
}
}

+ 62
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs Целия файл

@@ -240,6 +240,68 @@ public class TextElementPropertiesEditorTests
Assert.Equal(10, element.RotationAngle, precision: 6);
}

// Sprint 5, "Mix static text and CSV fields within a single text element".

[Fact]
public void SetContent_PlainLiteralText_ProducesSingleLiteralRun()
{
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateDynamic(0, 0, "Full Name"); // starts dynamic
editor.Select(element);

editor.SetContent("Plain label");

var run = Assert.Single(element.Runs);
Assert.False(run.IsField);
Assert.Equal("Plain label", run.Literal);
Assert.False(element.IsDynamic);
}

[Fact]
public void SetContent_MixedBracketSyntax_ProducesRunSequence()
{
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(15, 25, "placeholder");
editor.Select(element);

editor.SetContent("Attn: {Full Name} - {Last Name}");

Assert.Equal(4, element.Runs.Count);
Assert.True(element.IsDynamic);
Assert.Equal("Full Name", element.Runs[1].ColumnName);
Assert.Equal("Last Name", element.Runs[3].ColumnName);
// Sprint 5's own acceptance criterion: editing content must not reposition the element.
Assert.Equal(15, element.X, precision: 6);
Assert.Equal(25, element.Y, precision: 6);
}

[Fact]
public void SetContent_NoSelection_IsNoOp()
{
var editor = new TextElementPropertiesEditor();

editor.SetContent("Attn: {Full Name}"); // must not throw

Assert.Null(editor.Selected);
}

[Fact]
public void SetColumnName_MixedContentElement_IsIgnored()
{
// Rebind semantics don't generalize to "which of N field runs?" — SetContent is the
// general editor for mixed content; SetColumnName stays scoped to the legacy single-run
// dynamic shape only.
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(0, 0, "placeholder");
editor.Select(element);
editor.SetContent("Attn: {Full Name}");

editor.SetColumnName("Different Column");

Assert.Equal(2, element.Runs.Count);
Assert.Equal("Full Name", element.Runs[1].ColumnName);
}

[Fact]
public void Select_ThenEdits_ThenSelectDifferentElement_EditsApplyToCorrectElement()
{


+ 140
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextResolverTests.cs Целия файл

@@ -0,0 +1,140 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class TextResolverTests
{
private static IReadOnlyDictionary<string, string> Record(params (string Key, string Value)[] pairs) =>
pairs.ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase);

[Fact]
public void TryResolve_PureLiteralRuns_ResolvesWithNoCsvAtAll()
{
var runs = new List<TextRun> { TextRun.ForLiteral("Static label") };

var resolved = TextResolver.TryResolve(runs, Array.Empty<string>(), null);

Assert.Equal("Static label", resolved);
}

[Fact]
public void TryResolve_FieldRun_NoRecord_ReturnsNull()
{
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var resolved = TextResolver.TryResolve(runs, new[] { "Full Name" }, null);

Assert.Null(resolved);
}

[Fact]
public void TryResolve_FieldRun_ColumnNotInHeaders_ReturnsNull()
{
var runs = new List<TextRun> { TextRun.ForField("TypoedColumn") };

var resolved = TextResolver.TryResolve(runs, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.Null(resolved);
}

[Fact]
public void TryResolve_FieldRun_MissingFromRecord_ReturnsNull()
{
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var resolved = TextResolver.TryResolve(runs, new[] { "Full Name" }, Record());

Assert.Null(resolved);
}

[Fact]
public void TryResolve_FieldRun_Resolved_ReturnsRecordValue()
{
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var resolved = TextResolver.TryResolve(runs, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.Equal("Alice", resolved);
}

[Fact]
public void TryResolve_ColumnLookupIsCaseInsensitive()
{
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var resolved = TextResolver.TryResolve(runs, new[] { "full name" }, Record(("FULL NAME", "Alice")));

Assert.Equal("Alice", resolved);
}

[Fact]
public void TryResolve_MixedRuns_ConcatenatesLiteralAndFieldInOrder()
{
var runs = new List<TextRun>
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" - "),
TextRun.ForField("Last Name"),
};

var resolved = TextResolver.TryResolve(
runs, new[] { "Full Name", "Last Name" }, Record(("Full Name", "Alice"), ("Last Name", "Smith")));

Assert.Equal("Attn: Alice - Smith", resolved);
}

[Fact]
public void TryResolve_MixedRuns_OneUnmappedFieldRun_WholeResolutionFails()
{
var runs = new List<TextRun>
{
TextRun.ForLiteral("Hi "),
TextRun.ForField("Full Name"),
TextRun.ForField("Nickname"),
};

var resolved = TextResolver.TryResolve(runs, new[] { "Full Name" }, Record(("Full Name", "Alice")));

Assert.Null(resolved);
}

[Fact]
public void TryResolve_EmptyHeaderSet_TreatsEveryColumnAsUnknownState_NotAsMissing()
{
// An empty header list means "no CSV loaded yet," not "no columns exist" — TryResolve
// still requires a record to resolve a field run, but does not special-case an empty
// header set as "everything is unmapped" the way ComputeUnmappedFlags does.
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var resolved = TextResolver.TryResolve(runs, Array.Empty<string>(), Record(("Full Name", "Alice")));

Assert.Equal("Alice", resolved);
}

[Fact]
public void ComputeUnmappedFlags_NoHeadersLoaded_NothingIsFlagged()
{
var runs = new List<TextRun> { TextRun.ForField("Full Name") };

var flags = TextResolver.ComputeUnmappedFlags(runs, Array.Empty<string>());

Assert.False(flags[0]);
}

[Fact]
public void ComputeUnmappedFlags_PerRun_FlagsOnlyUnmappedFieldRuns()
{
var runs = new List<TextRun>
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" "),
TextRun.ForField("TypoedColumn"),
};

var flags = TextResolver.ComputeUnmappedFlags(runs, new[] { "Full Name" });

Assert.Equal(new[] { false, false, false, true }, flags);
}
}

+ 146
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextRunTextConverterTests.cs Целия файл

@@ -0,0 +1,146 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class TextRunTextConverterTests
{
[Fact]
public void Parse_PlainLiteralText_ReturnsSingleLiteralRun()
{
var runs = TextRunTextConverter.Parse("Static label:");

var run = Assert.Single(runs);
Assert.False(run.IsField);
Assert.Equal("Static label:", run.Literal);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Parse_NullOrEmpty_ReturnsSingleEmptyLiteralRun(string? input)
{
var runs = TextRunTextConverter.Parse(input);

var run = Assert.Single(runs);
Assert.False(run.IsField);
Assert.Equal(string.Empty, run.Literal);
}

[Fact]
public void Parse_SingleFieldToken_ReturnsSingleFieldRun()
{
var runs = TextRunTextConverter.Parse("{Full Name}");

var run = Assert.Single(runs);
Assert.True(run.IsField);
Assert.Equal("Full Name", run.ColumnName);
}

[Fact]
public void Parse_MixedLiteralAndFieldTokens_ReturnsRunsInOrder()
{
var runs = TextRunTextConverter.Parse("Attn: {Full Name} - {Last Name}");

Assert.Equal(4, runs.Count);
Assert.Equal("Attn: ", runs[0].Literal);
Assert.Equal("Full Name", runs[1].ColumnName);
Assert.Equal(" - ", runs[2].Literal);
Assert.Equal("Last Name", runs[3].ColumnName);
}

[Fact]
public void Parse_TokenAtStartAndEndWithNoSurroundingLiteral_ProducesNoEmptyLiteralRuns()
{
var runs = TextRunTextConverter.Parse("{First}{Last}");

Assert.Equal(2, runs.Count);
Assert.Equal("First", runs[0].ColumnName);
Assert.Equal("Last", runs[1].ColumnName);
}

[Fact]
public void Parse_UnmatchedOpenBrace_IsTreatedAsLiteralText()
{
var runs = TextRunTextConverter.Parse("Note: { see file");

var run = Assert.Single(runs);
Assert.False(run.IsField);
Assert.Equal("Note: { see file", run.Literal);
}

[Fact]
public void Parse_EmptyBraces_AreTreatedAsLiteralText()
{
var runs = TextRunTextConverter.Parse("Value: {}");

var run = Assert.Single(runs);
Assert.False(run.IsField);
Assert.Equal("Value: {}", run.Literal);
}

[Fact]
public void Parse_ColumnNameWithSurroundingWhitespace_IsTrimmed()
{
var runs = TextRunTextConverter.Parse("{ Full Name }");

var run = Assert.Single(runs);
Assert.True(run.IsField);
Assert.Equal("Full Name", run.ColumnName);
}

[Fact]
public void ToEditableText_SingleLiteralRun_ReturnsLiteralVerbatim()
{
var text = TextRunTextConverter.ToEditableText(new[] { TextRun.ForLiteral("Static label:") });

Assert.Equal("Static label:", text);
}

[Fact]
public void ToEditableText_SingleFieldRun_ReturnsBracketedColumnName()
{
var text = TextRunTextConverter.ToEditableText(new[] { TextRun.ForField("Full Name") });

Assert.Equal("{Full Name}", text);
}

[Fact]
public void ToEditableText_MixedRuns_ReconstructsOriginalOrder()
{
var runs = new[]
{
TextRun.ForLiteral("Attn: "),
TextRun.ForField("Full Name"),
TextRun.ForLiteral(" - "),
TextRun.ForField("Last Name"),
};

Assert.Equal("Attn: {Full Name} - {Last Name}", TextRunTextConverter.ToEditableText(runs));
}

[Theory]
[InlineData("Static label:")]
[InlineData("{Full Name}")]
[InlineData("Attn: {Full Name} - {Last Name}!")]
[InlineData("")]
public void ParseThenToEditableText_RoundTripsExactly(string original)
{
var runs = TextRunTextConverter.Parse(original);

Assert.Equal(original, TextRunTextConverter.ToEditableText(runs));
}

[Fact]
public void ToDisplaySegments_MatchesToEditableTextWhenConcatenated()
{
var runs = TextRunTextConverter.Parse("Attn: {Full Name} - {Last Name}");

var segments = TextRunTextConverter.ToDisplaySegments(runs);

Assert.Equal(4, segments.Count);
Assert.Equal("Attn: {Full Name} - {Last Name}", string.Concat(segments.Select(s => s.DisplaySegment)));
Assert.False(segments[0].Run.IsField);
Assert.True(segments[1].Run.IsField);
Assert.Equal("{Full Name}", segments[1].DisplaySegment);
}
}

+ 599
- 29
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs Целия файл

@@ -19,6 +19,16 @@ public sealed class TemplateCanvasControl : Control
{
private readonly TemplateLayoutDocument _document;
private readonly CanvasElementEditor _editor;
private AddressControlLayout? _selectedAddressControl;
private int _selectedAddressLineIndex;
private (double Dx, double Dy)? _addressControlDragOffset;
private bool _isResizingAddressControl;

/// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
/// mirrors <see cref="_isResizingAddressControl"/>'s shape — a control-specific drag-gesture
/// flag, paralleling (not reusing) <see cref="CanvasElementEditor.IsRotating"/>, which only
/// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary>
private bool _isRotatingAddressControl;

/// <summary>The currently loaded CSV's headers and one representative sample record, used
/// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
@@ -39,7 +49,44 @@ public sealed class TemplateCanvasControl : Control
SetStyle(ControlStyles.ResizeRedraw, true);
}

/// <summary>Sprint 7, "Snap elements to grid and guides": mirrors
/// <see cref="CanvasElementEditor.SnapToGridEnabled"/> so the same toggle governs standalone
/// element dragging (handled inside <see cref="CanvasElementEditor"/>) and Address Control
/// move/resize (handled directly in this control's mouse handlers below) uniformly, and so
/// this control knows whether to paint grid lines.</summary>
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public bool SnapToGridEnabled
{
get => _editor.SnapToGridEnabled;
set
{
_editor.SnapToGridEnabled = value;
Invalidate();
}
}

/// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
/// lines are painted at, when <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public double GridSizePoints
{
get => _editor.GridSizePoints;
set
{
_editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
Invalidate();
}
}

public TextElementLayout? SelectedElement => _editor.Selected;
public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
public int SelectedAddressLineIndex => _selectedAddressLineIndex;
public AddressControlLineLayout? SelectedAddressLine =>
_selectedAddressControl is not null
&& _selectedAddressLineIndex >= 0
&& _selectedAddressLineIndex < _selectedAddressControl.Lines.Count
? _selectedAddressControl.Lines[_selectedAddressLineIndex]
: null;

public TextElementLayout AddStaticTextElement()
{
@@ -51,6 +98,82 @@ public sealed class TemplateCanvasControl : Control
return element;
}

public AddressControlLayout AddAddressControl()
{
var (x, y) = DefaultNewElementPosition();
var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder());
_document.AddressControls.Add(control);
SelectAddressControl(control, 0);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return control;
}

public void AddAddressLine()
{
if (_selectedAddressControl is null)
{
return;
}

_selectedAddressControl.AddLine();
_selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}

public void RemoveSelectedAddressLine()
{
if (_selectedAddressControl is null)
{
return;
}

if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex))
{
_selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}

public void SelectAddressLine(int lineIndex)
{
if (_selectedAddressControl is null)
{
return;
}

_selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1));
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}

public void MoveSelectedAddressLineUp()
{
if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex))
{
_selectedAddressLineIndex--;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}

public void MoveSelectedAddressLineDown()
{
if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex))
{
_selectedAddressLineIndex++;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}

public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
{
var (x, y) = DefaultNewElementPosition();
@@ -86,6 +209,11 @@ public sealed class TemplateCanvasControl : Control
public void ClearSelection()
{
_editor.Select(null);
_selectedAddressControl = null;
_selectedAddressLineIndex = 0;
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
@@ -94,7 +222,7 @@ public sealed class TemplateCanvasControl : Control
{
// Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
// on top of the last one.
var count = _document.Elements.Count;
var count = _document.Elements.Count + _document.AddressControls.Count;
var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
return (x, y);
@@ -118,21 +246,41 @@ public sealed class TemplateCanvasControl : Control
g.FillRectangle(Brushes.White, pageRect);
g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);

// Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
// alignment is visible, not just felt during a drag — drawn under every element so it
// never obscures selection/rotate-handle/mapping-warning visuals.
if (SnapToGridEnabled)
{
DrawGrid(g, transform);
}

// Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
// time, run here against the loaded CSV's sample record so the canvas preview and the
// final PDF agree (the story's "Preview and final render must agree" conversation note).
var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);

foreach (var element in _document.Elements.OrderBy(el => el.ZOrder))
var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>();
paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null)));
paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c)));
foreach (var item in paintItems.OrderBy(i => i.ZOrder))
{
var state = previewStates[element.Id];
if (!state.Visible)
if (item.Text is not null)
{
var state = previewStates[item.Text.Id];
if (!state.Visible)
{
continue;
}

DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected));
continue;
}

DrawElement(g, transform, element, state.EffectiveY, state.IsUnmappedColumn,
isSelected: ReferenceEquals(element, _editor.Selected));
DrawAddressControl(
g,
transform,
item.Control!,
isSelected: ReferenceEquals(item.Control, _selectedAddressControl));
}

if (_editor.Selected is not null)
@@ -141,10 +289,58 @@ public sealed class TemplateCanvasControl : Control
}
}

/// <summary>Sprint 7: draws light dotted grid lines across the page at every
/// <see cref="GridSizePoints"/> interval, in both directions, so an operator can see the
/// alignment grid snapping is rounding positions to.</summary>
private void DrawGrid(Graphics g, CanvasViewTransform transform)
{
var gridSize = GridSizePoints;
if (gridSize <= 0)
{
return;
}

using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1)
{
DashStyle = DashStyle.Dot,
};

for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize)
{
var (x1, y1) = transform.ToPixels(x, 0);
var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints);
g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
}

for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize)
{
var (x1, y1) = transform.ToPixels(0, y);
var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y);
g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
}
}

private void SelectAddressControl(AddressControlLayout control, int lineIndex)
{
_selectedAddressControl = control;
_selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1));
_editor.Select(null);
}

private void ClearAddressSelection()
{
_selectedAddressControl = null;
_selectedAddressLineIndex = 0;
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
}

private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
double effectiveY, bool isUnmappedColumn, bool isSelected)
ElementPreviewState state, bool isSelected)
{
var effectiveY = state.EffectiveY;
using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
var (width, height) = MeasureElement(element);

@@ -158,9 +354,10 @@ public sealed class TemplateCanvasControl : Control
GraphicsState? savedState = null;
if (element.RotationAngle != 0)
{
var (centerXPx, centerYPx) = transform.ToPixels(element.X + (width / 2.0), effectiveY + (height / 2.0));
var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height);
var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
savedState = g.Save();
g.TranslateTransform((float)centerXPx, (float)centerYPx);
g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
// GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
// Y-down pixel space. The stored RotationAngle uses the opposite convention —
// counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
@@ -169,33 +366,44 @@ public sealed class TemplateCanvasControl : Control
// will, per this story's "same rotation, around the same pivot, as shown in the
// designer canvas" acceptance criterion.
g.RotateTransform((float)-element.RotationAngle);
g.TranslateTransform((float)-centerXPx, (float)-centerYPx);
g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
}

try
{
if (element.IsDynamic)
// Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a
// visible placeholder highlight per field-run *segment* rather than one whole-element
// box — literal text within a mixed element gets no fill at all, so an operator can
// see exactly which portion(s) of the line are field tokens versus literal text.
// Sprint 4's unmapped-column warning color is now decided per run (see
// AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns) rather
// than for the whole element, so a mixed element with one bad token among several
// good ones only flags that one segment.
var boxHeight = height * transform.Scale;
var segments = MeasureRunSegments(element, font);
var cumulativeWidth = 0.0;
for (var i = 0; i < segments.Count; i++)
{
// A visible placeholder representation distinct from static text (Sprint 3 Batch 3
// acceptance criterion). Sprint 4: an unmapped column (bound to a name that isn't
// among the currently loaded CSV's headers — a real mapping problem) gets a
// visually distinct warning color instead of the normal "this is dynamic" blue, so
// an operator can tell a mapping error apart from a field that's merely blank for
// the current sample data (which draws with the ordinary blue fill, or is skipped
// entirely if also collapsible — see the caller's `Visible` check).
var boxWidth = width * transform.Scale;
var boxHeight = height * transform.Scale;
var fillColor = isUnmappedColumn
? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
: System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
using var dynamicFill = new SolidBrush(fillColor);
g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight);
var (run, segmentWidth) = segments[i];
if (run.IsField)
{
var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i];
var fillColor = isRunUnmapped
? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
: System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
using var dynamicFill = new SolidBrush(fillColor);
var segX = drawX + (cumulativeWidth * transform.Scale);
var segBoxWidth = segmentWidth * transform.Scale;
g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight);
}
cumulativeWidth += segmentWidth;
}

using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);

if (isUnmappedColumn)
if (state.IsUnmappedColumn)
{
using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
g.DrawRectangle(
@@ -245,7 +453,8 @@ public sealed class TemplateCanvasControl : Control
}

var (width, height) = MeasureElement(element);
var (centerPx, centerPy) = transform.ToPixels(element.X + (width / 2.0), element.Y + (height / 2.0));
var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height);
var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY);
var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);

using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
@@ -258,6 +467,192 @@ public sealed class TemplateCanvasControl : Control
g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
}

/// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
/// <see cref="AddressControlLayout.RotationAngle"/> is non-zero, the entire method body below
/// — the box border, every line's text and per-line highlight/selection overlay, and the
/// resize handle — is drawn under one GDI+ rotation transform around
/// <see cref="AddressControlLayout.BoxCenter"/>, the same way <see cref="DrawElement"/> already
/// rotates a standalone element's whole draw call. Because every pixel-space draw call inside
/// this method (box, lines, handle) shares that one transform, they all visually rotate
/// together as one rigid unit — matching what <see cref="HitTestAddressControl"/> and
/// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point
/// back into this same unrotated local space before testing.</summary>
private void DrawAddressControl(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
{
GraphicsState? savedState = null;
if (control.RotationAngle != 0)
{
var (pivotX, pivotY) = control.BoxCenter;
var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
savedState = g.Save();
g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
// See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in
// this Y-down pixel space, the opposite of this project's counterclockwise-positive
// stored convention, hence the negation.
g.RotateTransform((float)-control.RotationAngle);
g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
}

try
{
DrawAddressControlUnrotated(g, transform, control, isSelected);
}
finally
{
if (savedState is not null)
{
g.Restore(savedState);
}
}
}

private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
{
var lineStates = ComputeAddressControlLineStates(control);
using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);

var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height);
var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
g.DrawRectangle(isSelected ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
if (isSelected)
{
DrawAddressResizeHandle(g, transform, control);
// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
// drawn here, in the control's own local (unrotated) coordinates, so it automatically
// rotates together with the box/lines via DrawAddressControl's enclosing GDI+
// transform — the same reason the resize handle above already orbits correctly.
DrawAddressRotateHandle(g, transform, control);
}

for (var i = 0; i < control.Lines.Count; i++)
{
if (!lineStates[i].Visible)
{
continue;
}

var line = control.Lines[i];
using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText;
var size = MeasureText(text, font);
var (drawX, drawY) = transform.ToPixels(control.X, lineStates[i].EffectiveY + size.Height);

if (line.IsDynamic)
{
using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue));
g.FillRectangle(
fill,
(float)drawX,
(float)drawY,
(float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)),
(float)(size.Height * transform.Scale));
}

using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
g.DrawString(text, font, brush, (float)drawX, (float)drawY);

if (isSelected && i == _selectedAddressLineIndex)
{
using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
g.DrawRectangle(
linePen,
(float)drawX - 2,
(float)drawY - 2,
(float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4,
(float)(size.Height * transform.Scale) + 4);
}
}
}

private static double MaxLineFontSize(AddressControlLayout control) =>
control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);

private static void DrawAddressResizeHandle(
Graphics g, CanvasViewTransform transform, AddressControlLayout control)
{
var handle = AddressResizeHandleCenter(control);
var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y);
var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
using var outline = new Pen(System.Drawing.Color.White, 1);
g.FillRectangle(brush, rect);
g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
}

private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
}

/// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
/// paints the drag handle for a selected Address Control, in the same visual style
/// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate
/// handle (<see cref="DrawRotateHandle"/>) for consistency — the recommended default per this
/// story's own notes, absent a strong reason to diverge. Position math lives in the
/// framework-free, unit-tested <see cref="AddressControlRotateHandle"/> (mirroring how
/// <see cref="CanvasElementEditor"/> holds the standalone-element equivalent); drawn here in
/// local (unrotated) coordinates, which is sufficient to make it visually orbit with the
/// control's own rotation via <see cref="DrawAddressControl"/>'s enclosing GDI+ transform —
/// the same reason the resize handle above already orbits correctly.</summary>
private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control)
{
var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control);
var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control);
var (centerPx, centerPy) = transform.ToPixels(centerX, topY);
var (handlePx, handlePy) = transform.ToPixels(handleX, handleY);

using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);

const float radius = 5f;
using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
}

private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control)
{
var lines = new AddressLineCollapser.Line[control.Lines.Count];
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
var resolvedText = ResolveAddressLinePreviewText(line);
var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
}

return AddressLineCollapser.Resolve(lines).ToArray();
}

/// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
/// <see cref="AddressBlockPreviewCalculator"/> and the new preview panel) instead of keeping
/// its own identical copy of this per-run resolution rule.</summary>
private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord);

/// <summary>Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see
/// below): static text rotates around its measured center, while dynamic/mixed content rotates
/// around its fixed authored anchor so record-to-record text-width changes cannot move the
/// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an
/// element's literal `{ColumnName}` token text, never a per-record resolved value (see
/// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'s remarks), so the dynamic/
/// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method
/// now always takes the bounding-box-center path for every element, static or not, so a
/// dynamic/mixed element's rotate-handle drag spins in place like static text instead of
/// swinging around a corner. The real render (<c>RotatedTextAnchorCalculator</c>/
/// <c>DebenuPdfRenderer</c>) and the new preview panel (<c>TemplatePreviewControl</c>/
/// <c>TemplatePreviewBuilder</c>) are unaffected — they still call
/// <see cref="RotationPivotCalculator.Compute"/> directly with the real <c>isDynamic</c>
/// value.</summary>
private static (double X, double Y) RotationPivot(
TextElementLayout element, double effectiveY, double width, double height) =>
RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);

/// <summary>Measures an element's rendered size in canvas-space points, using a
/// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
/// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
@@ -274,6 +669,41 @@ public sealed class TemplateCanvasControl : Control
return (size.Width, size.Height);
}

private static (double Width, double Height) MeasureText(string text, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
return (size.Width, size.Height);
}

/// <summary>Sprint 5, AC4: measures each run's own display segment width in canvas-space
/// points (same <see cref="GraphicsUnit.Point"/> measuring context as <see cref="MeasureElement"/>,
/// for consistency), so <see cref="DrawElement"/> can position a per-run highlight fill at the
/// right cumulative offset. Like <see cref="MeasureElement"/>, this is a design-time visual
/// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal
/// its measurement of the whole concatenated string to the last fraction of a point (kerning/
/// spacing metrics are not strictly additive), which is an accepted, documented limitation of
/// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws
/// one already-concatenated string, never per-run pieces).</summary>
private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;

var segments = TextRunTextConverter.ToDisplaySegments(element.Runs);
var result = new List<(TextRun, double)>(segments.Count);
foreach (var (run, text) in segments)
{
var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width;
result.Add((run, width));
}

return result;
}

/// <summary>Falls back to a generic sans-serif font if the requested family isn't installed,
/// so a missing font only affects the design-time preview's appearance — it does not crash
/// the designer. This is independent of, and does not relax, the render-time rule that a
@@ -298,7 +728,41 @@ public sealed class TemplateCanvasControl : Control
return;
}

var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
var transform = CurrentTransform();
var (x, y) = transform.ToPoints(e.X, e.Y);

if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform))
{
_isResizingAddressControl = true;
Capture = true;
Invalidate();
return;
}

// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked
// right alongside the resize-handle check above (same precedence rule: only meaningful,
// and only checked, when this control is already selected) so a rotate-handle grab is
// never confused with a normal select/move click elsewhere on the control.
if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y))
{
_isRotatingAddressControl = true;
Capture = true;
Invalidate();
return;
}

var addressHit = HitTestAddressControl(x, y);
if (addressHit.Control is not null)
{
SelectAddressControl(addressHit.Control, addressHit.LineIndex);
_addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
Capture = true;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
return;
}

ClearAddressSelection();

// Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
// instead of a normal select/move — checked first (and only when something is already
@@ -322,16 +786,119 @@ public sealed class TemplateCanvasControl : Control
SelectionChanged?.Invoke(this, EventArgs.Empty);
}

/// <summary>Sprint 8: a rotated control must remain correctly click-selectable at its actual
/// rotated position, not its unrotated bounding box — ported from
/// <c>CanvasElementEditor.IsPointInRotatedBounds</c>'s approach: rotate the click point
/// backward (by <c>-RotationAngle</c>) around the same <see cref="AddressControlLayout.BoxCenter"/>
/// pivot <see cref="DrawAddressControl"/> rotates around, landing it back in the control's
/// unrotated local space, then run the exact same plain-rectangle/line test as before. An
/// unrotated control (the overwhelmingly common case) takes the cheap direct path.</summary>
private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints)
{
foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder))
{
var (testX, testY) = control.RotationAngle == 0
? (xPoints, yPoints)
: PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);

var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
{
continue;
}

var lineIndex = 0;
for (var i = 0; i < control.Lines.Count; i++)
{
var baseline = control.BaselineYForLine(i);
var lineTop = baseline + control.Lines[i].FontSize;
var nextBaseline = i == control.Lines.Count - 1
? bottom
: control.BaselineYForLine(i + 1);
if (testY <= lineTop && testY >= nextBaseline)
{
lineIndex = i;
break;
}
}

return (control, lineIndex);
}

return (null, 0);
}

/// <summary>Sprint 8: same rotate-the-click-point-backward approach as
/// <see cref="HitTestAddressControl"/> — the resize handle is drawn (via
/// <see cref="DrawAddressResizeHandle"/>) inside <see cref="DrawAddressControl"/>'s rotation
/// transform, so it visually orbits with the rotated box; the hit test rotates the click point
/// back into the same unrotated local space before comparing against the handle's unrotated
/// position.</summary>
private static bool HitTestAddressResizeHandle(
AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform)
{
const double handleTolerancePixels = 8;
var tolerancePoints = handleTolerancePixels / transform.Scale;
var (testX, testY) = control.RotationAngle == 0
? (xPoints, yPoints)
: PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
var handle = AddressResizeHandleCenter(control);
return Math.Abs(testX - handle.X) <= tolerancePoints
&& Math.Abs(testY - handle.Y) <= tolerancePoints;
}

protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (!_editor.IsDragging && !_editor.IsRotating)
if (!_editor.IsDragging && !_editor.IsRotating && _addressControlDragOffset is null
&& !_isResizingAddressControl && !_isRotatingAddressControl)
{
return;
}

var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);

if (_selectedAddressControl is not null && _isRotatingAddressControl)
{
AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}

if (_selectedAddressControl is not null && _isResizingAddressControl)
{
// Sprint 8: rotate the drag point backward into the control's unrotated local space
// first (same as the hit test above) so resizing a rotated control still tracks the
// mouse along the box's own local width axis, not the world X axis.
var (localX, _) = _selectedAddressControl.RotationAngle == 0
? (x, y)
: PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle);
var width = Math.Max(1, localX - _selectedAddressControl.X);
_selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width;
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}

if (_selectedAddressControl is not null && _addressControlDragOffset is not null)
{
var newX = x - _addressControlDragOffset.Value.Dx;
var newY = y - _addressControlDragOffset.Value.Dy;
if (SnapToGridEnabled)
{
newX = GridSnapper.Snap(newX, GridSizePoints);
newY = GridSnapper.Snap(newY, GridSizePoints);
}

_selectedAddressControl.X = newX;
_selectedAddressControl.Y = newY;
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}

if (_editor.IsRotating)
{
_editor.RotateDragTo(x, y);
@@ -349,6 +916,9 @@ public sealed class TemplateCanvasControl : Control
{
base.OnMouseUp(e);
_editor.EndDrag();
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
Capture = false;
}
}

+ 529
- 48
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs Целия файл

@@ -44,6 +44,19 @@ public sealed class TemplateDesignerForm : Form

private readonly Label _pointsLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left };

// Sprint 7, "Snap elements to grid and guides" — canvas-wide toggle plus grid increment, no
// template/XML format change (a snapped position is still just an ordinary X/Y value once
// released).
private readonly CheckBox _snapToGridCheckBox = new() { Text = "Snap to &grid", AutoSize = true };
private readonly NumericUpDown _gridSizeInput = new()
{
DecimalPlaces = 1,
Minimum = 1,
Maximum = 500m,
Value = (decimal)CanvasElementEditor.DefaultGridSizePoints,
Width = 70,
};

// Save/reopen (Sprint 2 Batch 5).
private readonly Button _saveButton = new() { Text = "&Save Template...", AutoSize = true };
private readonly Button _openButton = new() { Text = "&Open Template...", AutoSize = true };
@@ -51,6 +64,7 @@ public sealed class TemplateDesignerForm : Form

private readonly Button _addStaticTextButton = new() { Text = "Add Stati&c Text", AutoSize = true };
private readonly Button _addDynamicPlaceholderButton = new() { Text = "Add &Dynamic Placeholder", AutoSize = true, Enabled = false };
private readonly Button _addAddressControlButton = new() { Text = "Add &Address Control", AutoSize = true };
private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." };

// CSV field mapping (Sprint 3, Batches 2-4).
@@ -99,8 +113,21 @@ public sealed class TemplateDesignerForm : Form

// Properties panel (Sprint 2 Batch 4) — X, Y, font family, font size, color, z-order for the
// selected element, kept in live two-way sync with the canvas.
// Sprint 5, "Mix static text and CSV fields within a single text element": the content editor
// — the first UI in this designer that lets an operator edit an element's text content at
// all (a static element's text was previously set once at creation and never editable). Uses
// the story's `{Column Name}` bracket-typing convention (TextRunTextConverter), parsed into
// runs on commit; works for pure static text, a single bound column, and any mix, replacing
// the implicit "static text is fixed at creation" behavior for every element going forward.
private readonly TextBox _contentInput = new() { Width = 210 };
private readonly NumericUpDown _xInput = new() { DecimalPlaces = 2, Minimum = 0, Maximum = 100000m, Width = 80 };
private readonly NumericUpDown _yInput = new() { DecimalPlaces = 2, Minimum = 0, Maximum = 100000m, Width = 80 };
private readonly NumericUpDown _addressWidthInput = new() { DecimalPlaces = 2, Minimum = 1, Maximum = 100000m, Width = 80 };
private readonly ListBox _addressLineList = new() { Width = 210, Height = 72, IntegralHeight = false };
private readonly Button _addAddressLineButton = new() { Text = "+", Width = 34, Height = 24 };
private readonly Button _removeAddressLineButton = new() { Text = "-", Width = 34, Height = 24 };
private readonly Button _moveAddressLineUpButton = new() { Text = "Up", Width = 48, Height = 24 };
private readonly Button _moveAddressLineDownButton = new() { Text = "Down", Width = 56, Height = 24 };
private readonly TextBox _fontFamilyInput = new() { Width = 160 };
private readonly NumericUpDown _fontSizeInput = new() { DecimalPlaces = 1, Minimum = 1, Maximum = 1000m, Width = 80 };
private readonly Button _colorButton = new() { Text = string.Empty, Width = 60, Height = 24, FlatStyle = FlatStyle.Popup };
@@ -121,10 +148,34 @@ public sealed class TemplateDesignerForm : Form
Enabled = false,
};

// Record-accurate preview panel (Sprint 7, "Render an accurate, record-specific preview of
// the current template" + "Jump to a specific record number") — a dedicated, read-only
// surface separate from the editable design canvas above, showing one selected CSV record's
// real resolved values via TemplatePreviewBuilder/TemplatePreviewControl.
// Post-Sprint-7-review fix (2026-10-19): the operator asked for the preview to update the
// instant the record number changes — no separate "Go" click/button — so ValueChanged now
// drives navigation directly (see the constructor's wiring and _suppressEvents' remarks) and
// there is no button here at all anymore.
private readonly NumericUpDown _recordNumberInput = new()
{
DecimalPlaces = 0,
Minimum = 1,
Maximum = 100_000_000m,
Value = 1,
Width = 80,
};
private readonly Label _recordStatusLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No CSV loaded." };

/// <summary>The most recently loaded CSV's full file path (not just its headers/sample), so
/// record navigation can re-scan the real file via <see cref="CsvRecordNavigator"/> rather
/// than the bounded <see cref="CsvPreviewLoader"/> sample. Null until a CSV is loaded.</summary>
private string? _currentCsvPath;

private readonly CanvasDimensionsEditor _dimensionsEditor;
private readonly TextElementPropertiesEditor _propertiesEditor = new();
private readonly TemplateLayoutDocument _document;
private readonly TemplateCanvasControl _canvas;
private readonly TemplatePreviewControl _previewControl;

/// <summary>Guards against re-entrant control-event handling while this form is
/// programmatically updating a control's value in response to another control's change
@@ -142,6 +193,7 @@ public sealed class TemplateDesignerForm : Form
_document = document;
_dimensionsEditor = new CanvasDimensionsEditor(document.Canvas);
_canvas = new TemplateCanvasControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12) };
_previewControl = new TemplatePreviewControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12, 0, 12, 12) };

Text = "Template Designer";
// Sprint 4: the properties panel grew from 7 rows to 9 (added Angle and Collapse-if-blank
@@ -153,7 +205,17 @@ public sealed class TemplateDesignerForm : Form
// plus that overhead, not just the rows' own height — caught and corrected during this
// sprint's own live GUI verification (see AGENTS.md's DoD rule requiring exactly this
// kind of real-`.exe` check for GUI-affecting stories), not left as a hidden regression.
MinimumSize = new Size(720, 760);
// Sprint 5: grew from 9 to 10 rows (added the Content row at the top) — same reasoning,
// one more 40px row of extra height, confirmed against the real built .exe (not just
// computed) per the same DoD rule.
// Sprint 6: Address Control adds line management and width controls to the properties
// panel. Height grows again so those controls are visible in the real built app, not only
// reachable by scrolling in a designer harness.
// Sprint 7: the new record-accurate preview panel sits beside the properties panel
// (widening, not heightening, the window) — see BuildRightSidePanel. Width grown from 760
// to fit both the 340px preview area and the existing 240px properties panel without
// clipping either, confirmed against the real built .exe per the same DoD rule.
MinimumSize = new Size(1140, 900);
StartPosition = FormStartPosition.CenterScreen;

Controls.Add(BuildLayout());
@@ -167,7 +229,27 @@ public sealed class TemplateDesignerForm : Form
// stale position after a drag.
RefreshPropertiesPanel();
RefreshSelectionLabel();

// Sprint 7 AC: the preview must auto-refresh after any layout edit. TemplatePreviewControl
// re-resolves against the live, shared document on every repaint, so an Invalidate() is
// all that's needed here — no separate "rebuild" step to keep in sync.
_previewControl.Invalidate();
};

// Post-Sprint-7-review fix (2026-10-19): auto-navigate on every change instead of
// requiring an explicit "Go" click — guarded by _suppressEvents so the programmatic reset
// to record 1 in LoadCsvFromPath (which already calls NavigateToRecord itself right after)
// doesn't also trigger a redundant navigation call through this handler; see that
// method's own comment and _suppressEvents' class-level remarks for the same pattern used
// elsewhere in this form.
_recordNumberInput.ValueChanged += (_, _) =>
{
if (!_suppressEvents)
{
NavigateToRecord((int)_recordNumberInput.Value);
}
};
_previewControl.SetContext(Array.Empty<string>(), null);
}

/// <summary>The current layout state, kept in sync with the dimensions controls and the
@@ -199,10 +281,8 @@ public sealed class TemplateDesignerForm : Form
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // row 5: canvas + properties panel

var canvasRow = new Panel { Dock = DockStyle.Fill };
// Fill-docked control must be added last so the properties panel claims its fixed-width
// slice of the row first, leaving the remainder for the canvas.
canvasRow.Controls.Add(BuildPropertiesPanel());
canvasRow.Controls.Add(_canvas);
canvasRow.Controls.Add(BuildRightSidePanel());

root.Controls.Add(BuildFileToolbar(), 0, 0);
root.Controls.Add(BuildCsvToolbar(), 0, 1);
@@ -287,13 +367,18 @@ public sealed class TemplateDesignerForm : Form
_document.Canvas = loaded!.Canvas;
_document.Elements.Clear();
_document.Elements.AddRange(loaded.Elements);
_document.AddressControls.Clear();
_document.AddressControls.AddRange(loaded.AddressControls);

_dimensionsEditor.Reset(_document.Canvas);
RefreshDimensionControls();
_canvas.ClearSelection();
_canvas.Invalidate();
_previewControl.Invalidate();

SetFileStatus($"Opened '{dialog.FileName}' ({_document.Elements.Count} element(s)).", isError: false);
SetFileStatus(
$"Opened '{dialog.FileName}' ({_document.Elements.Count} element(s), {_document.AddressControls.Count} address control(s)).",
isError: false);
}

private void SetFileStatus(string message, bool isError)
@@ -385,6 +470,26 @@ public sealed class TemplateDesignerForm : Form
var sampleRecord = result.SampleRows.Count > 0 ? BuildSampleRecord(result.Headers, result.SampleRows[0]) : null;
_canvas.SetCsvPreviewContext(_loadedCsvHeaders, sampleRecord);

// Sprint 7, "Jump to a specific record number": the new preview panel navigates the real
// full CSV file (not the bounded sample above) — record 1 by default whenever a CSV is
// (re)loaded, via the same full-file streaming reader a later explicit record-number jump
// uses, so the panel is never left showing a stale record from a previously loaded CSV.
_currentCsvPath = path;
// Reset the record-number input without letting its own ValueChanged handler (wired in
// the constructor, now that there's no separate "Go" button) fire a redundant navigation
// call — NavigateToRecord(1) below is the single, explicit navigation for this reset.
_suppressEvents = true;
try
{
_recordNumberInput.Value = 1;
}
finally
{
_suppressEvents = false;
}

NavigateToRecord(1);

SetCsvStatus(
$"Loaded '{path}' ({result.Headers.Count} column(s), {result.SampleRows.Count} sample row(s)).",
isError: false);
@@ -449,6 +554,117 @@ public sealed class TemplateDesignerForm : Form
_csvStatusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
}

/// <summary>Sprint 7: the canvas row's right side now holds two side-by-side areas — the new
/// record-accurate preview panel and the existing properties panel — laid out via a
/// <see cref="TableLayoutPanel"/> with two fixed-width columns rather than two independently
/// Dock-ed controls, so their relative placement is deterministic by construction (row/column
/// position) instead of depending on Controls-collection add order. This deliberately avoids
/// the exact class of dock-order bug the Sprint 6 retrospective's live-verification action
/// caught (a hidden properties panel from ambiguous Dock ordering).</summary>
private Control BuildRightSidePanel()
{
const int previewWidth = 340;
const int propertiesWidth = 240;

var panel = new TableLayoutPanel
{
Dock = DockStyle.Right,
Width = previewWidth + propertiesWidth,
ColumnCount = 2,
RowCount = 1,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, previewWidth));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, propertiesWidth));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));

panel.Controls.Add(BuildPreviewArea(), 0, 0);
panel.Controls.Add(BuildPropertiesPanel(), 1, 0);

return panel;
}

/// <summary>Sprint 7: the record-navigation toolbar (record number) stacked above the
/// read-only preview surface, via row/column position rather than Dock ordering (see
/// <see cref="BuildRightSidePanel"/>'s remarks). Post-Sprint-7-review fix (2026-10-19): the
/// number input alone now drives navigation on every change (see the constructor's
/// <c>_recordNumberInput.ValueChanged</c> wiring) — no separate "Go" button anymore.</summary>
private Control BuildPreviewArea()
{
var container = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
};
container.RowStyles.Add(new RowStyle(SizeType.AutoSize));
container.RowStyles.Add(new RowStyle(SizeType.AutoSize));
container.RowStyles.Add(new RowStyle(SizeType.Percent, 100));

var toolbar = new FlowLayoutPanel
{
AutoSize = true,
Padding = new Padding(12, 12, 3, 3),
FlowDirection = FlowDirection.LeftToRight,
};
toolbar.Controls.Add(new Label
{
Text = "Record #:",
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(3, 6, 3, 3),
});
toolbar.Controls.Add(_recordNumberInput);
_recordStatusLabel.Margin = new Padding(12, 6, 3, 3);
toolbar.Controls.Add(_recordStatusLabel);

var header = new Label
{
Text = "Preview (resolved values, read-only):",
AutoSize = true,
Padding = new Padding(12, 0, 0, 4),
};

container.Controls.Add(toolbar, 0, 0);
container.Controls.Add(header, 0, 1);
container.Controls.Add(_previewControl, 0, 2);

return container;
}

/// <summary>Sprint 7, "Jump to a specific record number": fetches the requested 1-based
/// record via <see cref="CsvRecordNavigator"/> (the full-file streaming reader, not the
/// bounded <see cref="CsvPreviewLoader"/> sample) and, on success, feeds it to the preview
/// panel. On failure (out of range, no CSV loaded, unreadable file), leaves whatever the
/// preview panel was already showing untouched and instead surfaces a clear, specific message
/// in <see cref="_recordStatusLabel"/> — this story's own "invalid or out-of-range record
/// selections yield a clear operator-facing message" acceptance criterion is about that
/// message, not about blanking a previously-valid preview.</summary>
private void NavigateToRecord(int recordNumber)
{
if (string.IsNullOrEmpty(_currentCsvPath))
{
_previewControl.SetContext(Array.Empty<string>(), null);
SetRecordStatus("No CSV loaded. Load a CSV to preview a record.", isError: false);
return;
}

var result = CsvRecordNavigator.TryReadRecord(_currentCsvPath, recordNumber);
if (!result.Success)
{
SetRecordStatus(result.Error!, isError: true);
return;
}

_previewControl.SetContext(_loadedCsvHeaders, result.Record);
SetRecordStatus($"Showing record {recordNumber}.", isError: false);
}

private void SetRecordStatus(string message, bool isError)
{
_recordStatusLabel.Text = message;
_recordStatusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
}

private Control BuildPropertiesPanel()
{
const int rowHeight = 40;
@@ -456,33 +672,70 @@ public sealed class TemplateDesignerForm : Form
const int labelTop = 6;
const int inputTop = 20;

AddPropertyRow(0 * rowHeight, "X (pt):", _xInput, leftMargin, labelTop, inputTop);
AddPropertyRow(1 * rowHeight, "Y (pt):", _yInput, leftMargin, labelTop, inputTop);
AddPropertyRow(2 * rowHeight, "Font family:", _fontFamilyInput, leftMargin, labelTop, inputTop);
AddPropertyRow(3 * rowHeight, "Font size (pt):", _fontSizeInput, leftMargin, labelTop, inputTop);
AddPropertyRow(4 * rowHeight, "Color:", _colorButton, leftMargin, labelTop, inputTop);
// Sprint 3, Batch 4 ("Re-map an existing dynamic field"): only meaningful for a dynamic
// element, so it lives in the panel but is only enabled when one is selected — see
// RefreshPropertiesPanel. Placed between Color and Z-order rather than appended at the
// end so it sits next to the other per-element display properties, not after the
// stacking-order control.
AddPropertyRow(5 * rowHeight, "CSV column:", _rebindColumnComboBox, leftMargin, labelTop, inputTop);
AddPropertyRow(6 * rowHeight, "Z-order (0 = bottom):", _zOrderInput, leftMargin, labelTop, inputTop);
// Sprint 5, "Mix static text and CSV fields within a single text element": the content
// editor leads the panel — it is the single most important field for composing an
// element's text, and (unlike every other row here) is the *only* way to edit a static
// element's literal text at all.
AddPropertyRow(0 * rowHeight, "Content ({Column} for a field):", _contentInput, leftMargin, labelTop, inputTop);
AddPropertyRow(1 * rowHeight, "X (pt):", _xInput, leftMargin, labelTop, inputTop);
AddPropertyRow(2 * rowHeight, "Y (pt):", _yInput, leftMargin, labelTop, inputTop);
AddPropertyRow(3 * rowHeight, "Address width (pt):", _addressWidthInput, leftMargin, labelTop, inputTop);
AddAddressLineControls(4 * rowHeight, leftMargin, labelTop, inputTop);
AddPropertyRow(7 * rowHeight, "Font family:", _fontFamilyInput, leftMargin, labelTop, inputTop);
AddPropertyRow(8 * rowHeight, "Font size (pt):", _fontSizeInput, leftMargin, labelTop, inputTop);
AddPropertyRow(9 * rowHeight, "Color:", _colorButton, leftMargin, labelTop, inputTop);
// Sprint 3, Batch 4 ("Re-map an existing dynamic field"): only meaningful for the legacy
// single-bound-column shape (Sprint 5: TextElementLayout.HasSingleColumnRun), so it lives
// in the panel but is only enabled when one is selected — see RefreshPropertiesPanel. For
// mixed content, rebinding is done by editing the Content field directly instead (general
// content editing doesn't generalize to "rebind which of N fields?" — see
// TextElementPropertiesEditor.SetColumnName's remarks). Placed between Color and Z-order
// rather than appended at the end so it sits next to the other per-element display
// properties, not after the stacking-order control.
AddPropertyRow(10 * rowHeight, "CSV column:", _rebindColumnComboBox, leftMargin, labelTop, inputTop);
AddPropertyRow(11 * rowHeight, "Z-order (0 = bottom):", _zOrderInput, leftMargin, labelTop, inputTop);
// Sprint 4, "Set a rotation angle...": degrees, free-form.
AddPropertyRow(7 * rowHeight, "Angle (deg):", _angleInput, leftMargin, labelTop, inputTop);
AddPropertyRow(12 * rowHeight, "Angle (deg):", _angleInput, leftMargin, labelTop, inputTop);
// Sprint 4, "Collapse blank optional address lines...": a checkbox has no separate label
// row — its own text doubles as the label, positioned like the other inputs.
_collapseIfBlankInput.Location = new Point(leftMargin, (8 * rowHeight) + labelTop);
_collapseIfBlankInput.Location = new Point(leftMargin, (13 * rowHeight) + labelTop);
_propertiesPanel.Controls.Add(_collapseIfBlankInput);

_xInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetX((double)_xInput.Value); _canvas.NotifyElementChanged(); } };
_yInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetY((double)_yInput.Value); _canvas.NotifyElementChanged(); } };
_fontFamilyInput.TextChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontFamily(_fontFamilyInput.Text); _canvas.NotifyElementChanged(); } };
_fontSizeInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontSize((double)_fontSizeInput.Value); _canvas.NotifyElementChanged(); } };
_zOrderInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetZOrder((int)_zOrderInput.Value); _canvas.NotifyElementChanged(); } };
_angleInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetRotationAngle((double)_angleInput.Value); _canvas.NotifyElementChanged(); } };
_collapseIfBlankInput.CheckedChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetCollapseIfBlank(_collapseIfBlankInput.Checked); _canvas.NotifyElementChanged(); } };
// Commits on Leave (not every keystroke, unlike the simpler FontFamily text box) — parsing
// a full run sequence and re-syncing the rebind combo/selection label on every keystroke
// while an operator is still mid-edit (e.g. typing an unfinished "{Full N") would be both
// wasteful and visually noisy; Leave fires once the operator tabs/clicks away, matching
// when a "commit" conceptually happens for this kind of structured, multi-character input.
_contentInput.Leave += (_, _) =>
{
if (_suppressEvents)
{
return;
}

CommitContentFromPanel();
};

_xInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedX((double)_xInput.Value); } };
_yInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedY((double)_yInput.Value); } };
_addressWidthInput.ValueChanged += (_, _) => { if (!_suppressEvents && _canvas.SelectedAddressControl is not null) { _canvas.SelectedAddressControl.Width = (double)_addressWidthInput.Value; _canvas.NotifyElementChanged(); } };
_fontFamilyInput.TextChanged += (_, _) => { if (!_suppressEvents) { SetSelectedFontFamily(_fontFamilyInput.Text); } };
_fontSizeInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedFontSize((double)_fontSizeInput.Value); } };
_zOrderInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedZOrder((int)_zOrderInput.Value); } };
_angleInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedRotationAngle((double)_angleInput.Value); } };
_collapseIfBlankInput.CheckedChanged += (_, _) => { if (!_suppressEvents) { SetSelectedCollapseIfBlank(_collapseIfBlankInput.Checked); } };
_colorButton.Click += (_, _) => OnColorButtonClick();
_addressLineList.SelectedIndexChanged += (_, _) =>
{
if (!_suppressEvents && _addressLineList.SelectedIndex >= 0)
{
_canvas.SelectAddressLine(_addressLineList.SelectedIndex);
}
};
_addAddressLineButton.Click += (_, _) => { _canvas.AddAddressLine(); RefreshPropertiesPanel(); };
_removeAddressLineButton.Click += (_, _) => { _canvas.RemoveSelectedAddressLine(); RefreshPropertiesPanel(); };
_moveAddressLineUpButton.Click += (_, _) => { _canvas.MoveSelectedAddressLineUp(); RefreshPropertiesPanel(); };
_moveAddressLineDownButton.Click += (_, _) => { _canvas.MoveSelectedAddressLineDown(); RefreshPropertiesPanel(); };
_rebindColumnComboBox.SelectedIndexChanged += (_, _) =>
{
if (_suppressEvents)
@@ -517,9 +770,158 @@ public sealed class TemplateDesignerForm : Form
_propertiesPanel.Controls.Add(input);
}

private void AddAddressLineControls(int rowTop, int leftMargin, int labelTop, int inputTop)
{
var label = new Label
{
Text = "Address lines:",
AutoSize = true,
Location = new Point(leftMargin, rowTop + labelTop),
};
_addressLineList.Location = new Point(leftMargin, rowTop + inputTop);

var buttonRow = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
Location = new Point(leftMargin, rowTop + inputTop + _addressLineList.Height + 4),
Width = 210,
Height = 28,
};
buttonRow.Controls.Add(_addAddressLineButton);
buttonRow.Controls.Add(_removeAddressLineButton);
buttonRow.Controls.Add(_moveAddressLineUpButton);
buttonRow.Controls.Add(_moveAddressLineDownButton);

_propertiesPanel.Controls.Add(label);
_propertiesPanel.Controls.Add(_addressLineList);
_propertiesPanel.Controls.Add(buttonRow);
}

private void CommitContentFromPanel()
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetContent(_contentInput.Text);
}
else if (_canvas.SelectedAddressLine is not null)
{
var runs = TextRunTextConverter.Parse(_contentInput.Text);
_canvas.SelectedAddressLine.Runs.Clear();
_canvas.SelectedAddressLine.Runs.AddRange(runs);
}

_canvas.NotifyElementChanged();
RefreshPropertiesPanel();
RefreshSelectionLabel();
}

private void SetSelectedX(double x)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetX(x);
}
else if (_canvas.SelectedAddressControl is not null)
{
_canvas.SelectedAddressControl.X = x;
}

_canvas.NotifyElementChanged();
}

private void SetSelectedY(double y)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetY(y);
}
else if (_canvas.SelectedAddressControl is not null)
{
_canvas.SelectedAddressControl.Y = y;
}

_canvas.NotifyElementChanged();
}

private void SetSelectedFontFamily(string? fontFamily)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetFontFamily(fontFamily);
}
else if (_canvas.SelectedAddressLine is not null && !string.IsNullOrWhiteSpace(fontFamily))
{
_canvas.SelectedAddressLine.FontFamily = fontFamily;
}

_canvas.NotifyElementChanged();
}

private void SetSelectedFontSize(double fontSize)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetFontSize(fontSize);
}
else if (_canvas.SelectedAddressLine is not null && fontSize > 0)
{
_canvas.SelectedAddressLine.FontSize = fontSize;
}

_canvas.NotifyElementChanged();
}

private void SetSelectedZOrder(int zOrder)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetZOrder(zOrder);
}
else if (_canvas.SelectedAddressControl is not null)
{
_canvas.SelectedAddressControl.ZOrder = ZOrderRule.Clamp(zOrder);
}

_canvas.NotifyElementChanged();
}

/// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": the properties
/// panel's existing -360.0/360.0, 0.1-precision angle input (<see cref="_angleInput"/>) was
/// already wired for a selected standalone element; this enables and wires the same input for
/// a selected Address Control too, reusing the exact same convention rather than a new
/// control.</summary>
private void SetSelectedRotationAngle(double angleDegrees)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetRotationAngle(angleDegrees);
}
else if (_canvas.SelectedAddressControl is not null && double.IsFinite(angleDegrees))
{
_canvas.SelectedAddressControl.RotationAngle = angleDegrees;
}

_canvas.NotifyElementChanged();
}

private void SetSelectedCollapseIfBlank(bool collapseIfBlank)
{
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetCollapseIfBlank(collapseIfBlank);
}
else if (_canvas.SelectedAddressLine is not null)
{
_canvas.SelectedAddressLine.CollapseIfBlank = collapseIfBlank;
}

_canvas.NotifyElementChanged();
}

private void OnColorButtonClick()
{
if (_propertiesEditor.Selected is null)
if (_propertiesEditor.Selected is null && _canvas.SelectedAddressLine is null)
{
return;
}
@@ -528,7 +930,15 @@ public sealed class TemplateDesignerForm : Form
if (dialog.ShowDialog(this) == DialogResult.OK)
{
var chosen = new RgbColor(dialog.Color.R, dialog.Color.G, dialog.Color.B);
_propertiesEditor.SetColor(chosen);
if (_canvas.SelectedElement is not null)
{
_propertiesEditor.SetColor(chosen);
}
else if (_canvas.SelectedAddressLine is not null)
{
_canvas.SelectedAddressLine.Color = chosen;
}

_colorButton.BackColor = dialog.Color;
_canvas.NotifyElementChanged();
}
@@ -548,19 +958,36 @@ public sealed class TemplateDesignerForm : Form
private void RefreshPropertiesPanel()
{
var selected = _canvas.SelectedElement;
_propertiesPanel.Enabled = selected is not null;
var selectedAddressControl = _canvas.SelectedAddressControl;
var selectedAddressLine = _canvas.SelectedAddressLine;
var hasSelection = selected is not null || selectedAddressControl is not null;
_propertiesPanel.Enabled = hasSelection;

_suppressEvents = true;
try
{
_xInput.Value = ClampToNumericRange(_xInput, (decimal)(selected?.X ?? 0));
_yInput.Value = ClampToNumericRange(_yInput, (decimal)(selected?.Y ?? 0));
_fontFamilyInput.Text = selected?.FontFamily ?? string.Empty;
_fontSizeInput.Value = ClampToNumericRange(_fontSizeInput, (decimal)(selected?.FontSize ?? 12));
_zOrderInput.Value = ClampToNumericRange(_zOrderInput, selected?.ZOrder ?? 0);
_angleInput.Value = ClampToNumericRange(_angleInput, (decimal)(selected?.RotationAngle ?? 0));
_collapseIfBlankInput.Checked = selected?.CollapseIfBlank ?? false;
var color = selected?.Color ?? RgbColor.Black;
_contentInput.Text = selected is not null
? TextRunTextConverter.ToEditableText(selected.Runs)
: selectedAddressLine is not null
? TextRunTextConverter.ToEditableText(selectedAddressLine.Runs)
: string.Empty;
_xInput.Value = ClampToNumericRange(_xInput, (decimal)(selected?.X ?? selectedAddressControl?.X ?? 0));
_yInput.Value = ClampToNumericRange(_yInput, (decimal)(selected?.Y ?? selectedAddressControl?.Y ?? 0));
_addressWidthInput.Value = ClampToNumericRange(
_addressWidthInput, (decimal)(selectedAddressControl?.Width ?? AddressControlLayout.DefaultWidth));
_addressWidthInput.Enabled = selectedAddressControl is not null;
RefreshAddressLineList(selectedAddressControl);

_fontFamilyInput.Text = selected?.FontFamily ?? selectedAddressLine?.FontFamily ?? string.Empty;
_fontSizeInput.Value = ClampToNumericRange(_fontSizeInput, (decimal)(selected?.FontSize ?? selectedAddressLine?.FontSize ?? 12));
_zOrderInput.Value = ClampToNumericRange(_zOrderInput, selected?.ZOrder ?? selectedAddressControl?.ZOrder ?? 0);
// Sprint 8: enabled and populated for a selected Address Control too, not just a
// selected standalone element (see SetSelectedRotationAngle).
_angleInput.Value = ClampToNumericRange(
_angleInput, (decimal)(selected?.RotationAngle ?? selectedAddressControl?.RotationAngle ?? 0));
_angleInput.Enabled = selected is not null || selectedAddressControl is not null;
_collapseIfBlankInput.Checked = selected?.CollapseIfBlank ?? selectedAddressLine?.CollapseIfBlank ?? false;
var color = selected?.Color ?? selectedAddressLine?.Color ?? RgbColor.Black;
_colorButton.BackColor = Color.FromArgb(color.R, color.G, color.B);
RefreshRebindColumnComboBox(selected);
}
@@ -570,20 +997,52 @@ public sealed class TemplateDesignerForm : Form
}
}

private void RefreshAddressLineList(AddressControlLayout? selectedAddressControl)
{
_addressLineList.Items.Clear();
_addressLineList.Enabled = selectedAddressControl is not null;
_addAddressLineButton.Enabled = selectedAddressControl is not null;
_removeAddressLineButton.Enabled = selectedAddressControl is not null && selectedAddressControl.Lines.Count > 1;
_moveAddressLineUpButton.Enabled = selectedAddressControl is not null && _canvas.SelectedAddressLineIndex > 0;
_moveAddressLineDownButton.Enabled =
selectedAddressControl is not null && _canvas.SelectedAddressLineIndex < selectedAddressControl.Lines.Count - 1;

if (selectedAddressControl is null)
{
return;
}

for (var i = 0; i < selectedAddressControl.Lines.Count; i++)
{
var line = selectedAddressControl.Lines[i];
_addressLineList.Items.Add($"{i + 1}: {line.DisplayText}");
}

if (selectedAddressControl.Lines.Count > 0)
{
_addressLineList.SelectedIndex = Math.Max(
0,
Math.Min(_canvas.SelectedAddressLineIndex, selectedAddressControl.Lines.Count - 1));
}
}

/// <summary>Populates the rebind combo box from the currently loaded CSV headers (Batch 4).
/// Only enabled for a dynamic element, and only when a CSV is loaded — an operator can't
/// rebind to a column list that doesn't exist yet. If the element's current column isn't
/// among the loaded headers (e.g. a template saved against a different CSV), it is still
/// shown as the current selection so the panel never silently changes the binding just by
/// being displayed.</summary>
/// Sprint 5: scoped to <see cref="TextElementLayout.HasSingleColumnRun"/> rather than
/// <see cref="TextElementLayout.IsDynamic"/> — a mixed-content element has no single column to
/// rebind, so this combo simply stays disabled for one; the Content field is the general
/// editor for that case instead. Only enabled when a CSV is loaded — an operator can't rebind
/// to a column list that doesn't exist yet. If the element's current column isn't among the
/// loaded headers (e.g. a template saved against a different CSV), it is still shown as the
/// current selection so the panel never silently changes the binding just by being
/// displayed.</summary>
private void RefreshRebindColumnComboBox(TextElementLayout? selected)
{
_rebindColumnComboBox.Items.Clear();

var isDynamic = selected is { IsDynamic: true };
_rebindColumnComboBox.Enabled = isDynamic && _loadedCsvHeaders.Count > 0;
var isSingleColumn = selected is { HasSingleColumnRun: true };
_rebindColumnComboBox.Enabled = isSingleColumn && _loadedCsvHeaders.Count > 0;

if (!isDynamic)
if (!isSingleColumn)
{
return;
}
@@ -621,6 +1080,7 @@ public sealed class TemplateDesignerForm : Form
};

_addStaticTextButton.Click += (_, _) => _canvas.AddStaticTextElement();
_addAddressControlButton.Click += (_, _) => _canvas.AddAddressControl();
_addDynamicPlaceholderButton.Click += (_, _) =>
{
// Guarded by the button's own Enabled state (only enabled once a CSV is loaded — see
@@ -636,6 +1096,7 @@ public sealed class TemplateDesignerForm : Form
panel.Controls.Add(_addDynamicPlaceholderButton);
_dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3);
panel.Controls.Add(_dynamicFieldColumnComboBox);
panel.Controls.Add(_addAddressControlButton);
_selectionLabel.Margin = new Padding(18, 6, 3, 3);
panel.Controls.Add(_selectionLabel);

@@ -644,11 +1105,23 @@ public sealed class TemplateDesignerForm : Form

private void RefreshSelectionLabel()
{
// Sprint 5: was `selected.IsDynamic ? "{ColumnName}" : StaticText`, which silently showed
// an empty "{}" for any genuinely mixed-content element (ColumnName is null once an
// element has more than one run) — found via live GUI verification against the real
// built .exe. TextElementLayout.DisplayText already generalizes this exact concatenation
// (literal runs verbatim, field runs as `{ColumnName}`) for the static/dynamic/mixed
// cases uniformly, so use it here directly instead of re-deriving the old two-case logic.
var selected = _canvas.SelectedElement;
_selectionLabel.Text = selected is null
if (selected is not null)
{
_selectionLabel.Text = $"Selected: {selected.DisplayText} at ({selected.X:0.#}, {selected.Y:0.#}) pt.";
return;
}

var selectedAddress = _canvas.SelectedAddressControl;
_selectionLabel.Text = selectedAddress is null
? "No element selected."
: $"Selected: {(selected.IsDynamic ? $"{{{selected.ColumnName}}}" : selected.StaticText)} " +
$"at ({selected.X:0.#}, {selected.Y:0.#}) pt.";
: $"Selected address control at ({selectedAddress.X:0.#}, {selectedAddress.Y:0.#}) pt.";
}

private Control BuildCanvasSettingsPanel()
@@ -669,6 +1142,14 @@ public sealed class TemplateDesignerForm : Form
panel.Controls.Add(_unitComboBox);
panel.Controls.Add(_pointsLabel);

_snapToGridCheckBox.Margin = new Padding(24, 6, 3, 3);
panel.Controls.Add(_snapToGridCheckBox);
panel.Controls.Add(new Label { Text = "Grid (pt):", AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(6, 6, 3, 3) });
panel.Controls.Add(_gridSizeInput);

_snapToGridCheckBox.CheckedChanged += (_, _) => _canvas.SnapToGridEnabled = _snapToGridCheckBox.Checked;
_gridSizeInput.ValueChanged += (_, _) => _canvas.GridSizePoints = (double)_gridSizeInput.Value;

_unitComboBox.Items.AddRange(new object[]
{
new UnitComboItem(CanvasUnit.Inches),


+ 151
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs Целия файл

@@ -0,0 +1,151 @@
using System.Drawing.Drawing2D;
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Views;

/// <summary>
/// Sprint 7, "Render an accurate, record-specific preview of the current template": a dedicated,
/// read-only preview surface — separate from the editable design canvas
/// (<see cref="TemplateCanvasControl"/>, which keeps showing bracket tokens for editing) — that
/// draws the current template using one selected CSV record's real, resolved values. All
/// resolution/collapse/pivot logic lives in <see cref="TemplatePreviewBuilder"/> (framework-free,
/// unit tested); this class only does the GDI+ drawing, since that part genuinely cannot be
/// extracted from WinForms.
/// </summary>
public sealed class TemplatePreviewControl : Control
{
private readonly TemplateLayoutDocument _document;
private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
private IReadOnlyDictionary<string, string>? _record;

public TemplatePreviewControl(TemplateLayoutDocument document)
{
_document = document;
DoubleBuffered = true;
BackColor = SystemColors.ControlDark;
SetStyle(ControlStyles.ResizeRedraw, true);
}

/// <summary>Supplies the CSV headers and the currently selected/navigated-to record this
/// panel should preview. Pass an empty header list and a <c>null</c> record to show the
/// "nothing to preview yet" message (e.g. before any CSV is loaded).</summary>
public void SetContext(IReadOnlyList<string> csvHeaders, IReadOnlyDictionary<string, string>? record)
{
_csvHeaders = csvHeaders;
_record = record;
Invalidate();
}

private CanvasViewTransform CurrentTransform() =>
CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);

protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;

var transform = CurrentTransform();
var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);

g.FillRectangle(Brushes.White, pageRect);
g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);

// Re-resolves against the live, shared TemplateLayoutDocument on every repaint (rather
// than caching a previously-built draw list), which is what makes "auto-refresh after a
// layout edit, a field remapping, or a different record being selected" free — the caller
// only ever needs to call Invalidate() (see TemplateDesignerForm's ElementsChanged
// subscription), never to explicitly rebuild anything.
var result = TemplatePreviewBuilder.Build(_document, _csvHeaders, _record);
if (!result.Success)
{
DrawMessage(g, pageRect, result.Message ?? "Preview unavailable.");
return;
}

foreach (var draw in result.Draws)
{
DrawItem(g, transform, draw);
}
}

private static void DrawItem(Graphics g, CanvasViewTransform transform, PreviewTextDraw draw)
{
if (draw.Text.Length == 0)
{
return;
}

using var font = ResolveFont(draw.FontFamily, (float)draw.FontSize);
var size = MeasureText(draw.Text, font);
var (drawX, drawY) = transform.ToPixels(draw.X, draw.Y + size.Height);

GraphicsState? savedState = null;
if (draw.Angle != 0)
{
var (pivotX, pivotY) = RotationPivotCalculator.Compute(draw.IsDynamic, draw.X, draw.Y, size.Width, size.Height);
var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
savedState = g.Save();
g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
// See TemplateCanvasControl.DrawElement's remarks: GDI+'s RotateTransform is
// visually clockwise-positive in this Y-down pixel space, the opposite of the
// counterclockwise-positive convention this project stores/renders with, hence the
// negation — keeping the preview's rotation direction consistent with both the
// design canvas and the final PDF.
g.RotateTransform((float)-draw.Angle);
g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
}

try
{
using var brush = new SolidBrush(Color.FromArgb(draw.Color.R, draw.Color.G, draw.Color.B));
g.DrawString(draw.Text, font, brush, (float)drawX, (float)drawY);
}
finally
{
if (savedState is not null)
{
g.Restore(savedState);
}
}
}

private static void DrawMessage(Graphics g, RectangleF pageRect, string message)
{
using var font = new Font(FontFamily.GenericSansSerif, 10f, GraphicsUnit.Point);
using var brush = new SolidBrush(Color.Firebrick);
using var format = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
var textRect = RectangleF.Inflate(pageRect, -8, -8);
g.DrawString(message, font, brush, textRect, format);
}

private static (double Width, double Height) MeasureText(string text, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
return (size.Width, size.Height);
}

/// <summary>Falls back to a generic sans-serif font if the requested family isn't installed —
/// same design-time leniency as <see cref="TemplateCanvasControl"/>'s own font resolution.</summary>
private static Font ResolveFont(string familyName, float size)
{
try
{
return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
}
catch (ArgumentException)
{
return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
}
}
}

+ 1
- 0
logs/process_improvement_log.md Целия файл

@@ -6,4 +6,5 @@ Append-only log of insights about the **Scrum kit itself** (this repo's `process
|---|---|---|---|---|---|---|
| | | | | 1st time / 2nd time / 3rd+ | Watching / Proposed / Applied / Rejected | |
| 2026-09-22 | 3 | Sprint 3's product-owner sprint review leaned more heavily on dev-team's own pre-written verification notes in `backlog/epics/03_csv_integration_and_field_mapping.md` (already written in a "Sprint Review verification" style before the review step ran) than in prior sprints, where epic notes were written fresh by product-owner at review time. No AC was actually missed and the one story without a pre-written note (the throughput story) received a fully independent product-owner confirmation that added real judgment, so this has not yet caused a defect — but if epic-note authorship keeps drifting from "PO writes it at review time" toward "dev-team writes it, PO signs it," the review step's independence could erode unnoticed. Logged now as a first-occurrence watch item per `AGENTS.md`'s bar, not yet severe or recurring enough for a kit edit. | `process/04_sprint_review.md`, `.claude/agents/product-owner.md` | 1st time | Resolved | Revisited at Sprint 4 review/retrospective (`backlog/sprints/sprint-4-retrospective.md`, "Follow-up on previous retro's actions" item 4): this sprint, `dev-team` was explicitly instructed not to write or touch epic "Status:"/verification content, and product-owner wrote all three Sprint 4 stories' Sprint Review verification notes fresh and independently — citing dev-team's evidence but forming its own judgment on it (e.g. explicitly assessing whether a flagged sizing-note risk was "genuinely resolved rather than papered over," not just restating dev-team's own framing). The concern never caused an actual missed AC in either sprint, and this sprint's structural separation of "who implements and self-verifies" from "who writes the Sprint Review's AC verification" resolves it without needing a `process/04_sprint_review.md` edit. Closed; no kit change made. Re-open as a new entry if this drifts back in a future sprint rather than reusing this row. |
| 2026-10-19 | 7 | Product-owner's Sprint 7 review session had no shell/build tool access, so it substituted an independent static count of `[Fact]`/`[Theory]`/`[InlineData]` attributes across test files (landed on exactly 400, matching dev-team's reported 400/400) and direct source-code reading in place of an actual `dotnet test` run and a live re-run of dev-team's built-`.exe` screenshots. Disclosed explicitly by product-owner as "a review-process limitation ... not a defect in the sprint's delivery," not smoothed over. The top-level session independently ran `dotnet test` afterward and confirmed 400/400 exactly, so this did not produce a wrong verdict this time. Distinct from the 2026-09-22 (Sprint 3) PO-review-independence entry below: that item was about whether PO forms independent *judgment* versus leaning on dev-team's pre-written narrative (resolved structurally at Sprint 4); this item is about PO's *tooling access* to actually execute a build/test pass, which is a different failure mode and not yet resolved by any structural change. First occurrence of this specific shape — logged now per `AGENTS.md`'s bar (not yet severe or recurring), not folded into the closed Sprint 3 entry. | `process/04_sprint_review.md`, `.claude/agents/product-owner.md` | 1st time | Watching | Sprint 7 retrospective (`backlog/sprints/sprint-7-retrospective.md`) carried forward a Sprint 8 action: if product-owner again lacks shell/build access, disclose it and arrange an independent cross-check, as done this sprint. Revisit at Sprint 8 review/retrospective; if this recurs with a mismatched result (not just a missing capability), it would meet the bar for a kit edit (e.g., a `process/04_sprint_review.md` note requiring an explicit disclosure + follow-up cross-check whenever PO verification can't execute a build/test pass). |
| 2026-09-11 | 1 | `templates/definition_of_done.md`'s "runnable in the current local development setup" bar let two GUI-launching stories (Sprint 1 Batches 4-5, "Launch a text-only render from the desktop app" and "Show render progress and completion summary") reach Done using an in-process/dev-shell test harness that never exercised the actual built artifact the real target user (an operator double-clicking a shipped `.exe`) would run. This let a real bug (Debenu error 999 — the CLI only read its license key from a process environment variable a double-clicked app never has) through both DoD sign-off and past `state.md` advancing to Phase 4, only caught same-day by a real user report, not by the sprint's own verification. Judged severe enough on its own (per `AGENTS.md`'s "Process Self-Improvement" bar — a single occurrence that visibly broke a Done story's core happy path for the target user) to log now rather than wait for a second occurrence, though `scrum-master` should confirm at the retrospective before any kit edit is proposed. | `templates/definition_of_done.md` (possibly `process/03_sprint_execution.md`'s verification guidance) | 1st time | Applied | Decided at the Sprint 1 retrospective (`backlog/sprints/sprint-1-retrospective.md`, "Kit-level decision" section): this single occurrence meets the severity bar (silently broke a Done story's core happy path for the actual target user and let `state.md` advance to Phase 4 undetected). User approved the proposed edit on 2026-09-11; applied to `templates/definition_of_done.md` as a new bullet immediately after the existing "runnable in the current local development setup" line: "If the story changes how the product is launched, packaged, or resolves runtime configuration (e.g., a new desktop entry point, a new child-process launch, a new license/config resolution path), verification includes running the actual built artifact the way the target user would run it — not only an in-process test harness or a dev-shell invocation such as `dotnet run`." Scoped only to launch/packaging/config-resolution stories, not all stories. |

+ 4
- 0
logs/technical_debt_log.md Целия файл

@@ -9,4 +9,8 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
| 2026-09-08 | `EnvelopeRenderer.Desktop`'s Render button re-enables as soon as the CLI process is confirmed *started* (`CliProcessLauncher.LaunchAsync` returning), not once it finishes rendering — this story (Sprint 1 Batch 4) intentionally does not track render completion. An operator can click Render again (e.g. against the same output path) while a prior render is still in progress, since nothing yet observes the child process's lifetime or exit code. | Unavoidable (scope boundary of this story) | Low | Resolved | Sprint 1 Batch 5 ("Show render progress and completion summary") replaced `CliProcessLauncher.LaunchAsync`/`Launch` with `RunAsync`/`Run`, which stream the CLI's redirected stdout/stderr, wait for the process to actually exit, and return exit code + final progress event. `MainForm.OnRenderClick` now only re-enables the Render button in the `finally` block after that awaited call completes, not when the process starts — verified with a real launch against the sample CSV (button stayed disabled for the full render, both success and forced-failure runs). |
| 2026-09-14 | The render path's actual high-volume throughput badly misses the product's stated "100,000 records at 300 DPI in under 10 minutes" target: a real (not simulated) 100k-record benchmark run showed throughput degrading monotonically from ~399 rec/s to ~15 rec/s (and still falling) by record 4,827 of 100,352, isolated with reasonable confidence to Debenu Quick PDF Library 10.13's in-memory document model rather than this repo's own O(1)-per-record merge/CSV code. Projected full-run time is on the order of 45-90+ minutes — 5x-10x+ over target. Full methodology, raw data, and root-cause investigation in `code/BENCHMARK.md`. | Unavoidable (external vendor library characteristic, not yet confirmed fixable) | High (directly threatens a hard product constraint — `project_config.md`'s "render 100,000 records at 300 DPI in under 10 minutes" — and would be worse at the stated 1,000,000-record ceiling) | Resolved (for the 100,000-record target) | Sprint 3 story "Investigate and address high-volume render throughput degradation" confirmed the Debenu-internal-document-model hypothesis (a scaled-down probe showed the per-page cost reliably resets after a save+release/reopen cycle) and implemented a batching mitigation in `DebenuPdfRenderer` (save+release/reopen every 300 pages, merged via Debenu's `MergeFileListFast`). The full 100k-record benchmark was re-run to completion (not time-boxed): 315 seconds, ~47.5% under the 10-minute budget, valid 100,352-page PDF, 397 MB. Full results in `code/BENCHMARK.md`'s "Sprint 3 follow-up" section. This closes the 100,000-record risk but opens a new, narrower one at the 1,000,000-record ceiling -- see the next entry. |
| 2026-09-21 | The Sprint 3 batching mitigation for the above throughput issue (save+release/reopen every 300 pages, merged via `MergeFileListFast`) fixes the 100,000-record/10-minute target with margin, but introduces a new file-size risk at the product's stated 1,000,000-record ceiling: each batch re-embeds the template's TrueType font from scratch (~1.05 MB per extra batch, measured directly), so linear extrapolation of the 100k run's real numbers (397 MB, ~350 MB of which is batching overhead) to 1,000,000 records suggests a combined output size in the neighborhood of 4.5-5 GB -- over the product's sub-2GB final PDF constraint (`project_config.md`). Not yet observed directly (no 1,000,000-record run has been performed); this is a qualitative, evidence-informed extrapolation, not a confirmed failure. Full analysis in `code/BENCHMARK.md`'s "Re-assessment of the 1,000,000-record ceiling" section. | Unintentional (side effect of the chosen mitigation, not present in this form before it) | Medium (only threatens the stated ceiling's extreme end, not the tested and confirmed 100,000-record target; no story currently commits to rendering 1,000,000 records) | Open | Not fixed as part of this story (out of scope: the story's AC2 asks for one mitigation sized against the 100k target). Two candidate directions identified, not yet attempted: a batch size that scales with total record count, or switching to Debenu's `AddTrueTypeSubsettedFont` so each batch only re-embeds the glyphs actually used instead of the full font. Should be revisited before any story commits to rendering at or near the 1,000,000-record ceiling. |
| 2026-10-09 | A rotated text element bound to a CSV column (or, since Sprint 5, containing any field run within mixed content) renders at a **different position on every record's page** instead of a consistent one. Root cause confirmed by code inspection: `DebenuPdfRenderer.AddPage` measures each record's own resolved text (`_pdf.GetTextWidth(draw.Text)`) to compute the rotation pivot (`RotatedTextAnchorCalculator.ComputeAnchor`'s `centerLocalX = width / 2.0`), so the bounding-box center — and therefore the anchor point passed to `DrawRotatedText` — shifts whenever a record's resolved text length differs from another's. Static (fixed-text) rotated elements are unaffected, since their width never varies by record, which is why this wasn't caught during Sprint 4's live verification (only a static "ROTATED" label was tested rotated). Compounding finding: this also breaks the "Set a rotation angle..." story's own already-shipped AC4 (canvas and rendered PDF must share the same pivot) for any dynamic/mixed content — `TemplateCanvasControl.MeasureElement` measures `element.DisplayText`, a fixed design-time placeholder, so the canvas always shows one static position that cannot match the actual per-record rendered position except by coincidence. User-reported (2026-10-09): observed as rendered records appearing to "jump around" position-to-position when rotation is applied to real per-record data. | Unintentional (an unanticipated interaction between Sprint 4's "rotate around bounding-box center" design and any per-record-varying resolved text — never exercised together until real dynamic/mixed content was rotated) | High (breaks correct print positioning for any real workflow combining rotation with dynamic or mixed-content fields — a realistic, likely-common combination — and breaks an already-shipped, verified acceptance criterion) | Resolved | Sprint 6 Batch 1 fixed this by choosing the stable fixed-anchor pivot for any rotated text element containing at least one field run, while leaving rotated static text on the Sprint 4 bounding-box-center path. `RenderEngine` now marks dynamic/mixed `TextDraw`s with `UsesFixedRotationPivot`; `DebenuPdfRenderer` uses `(X, Y)` directly for those rotated draws instead of measuring record-specific text width; `CanvasElementEditor`/`TemplateCanvasControl` use the same pivot rule for drawing, hit-testing, and rotate handles. Verified by 336/336 passing tests, including a real-DLL regression proving two different text widths use the same authored rotated transform anchor, plus a real CLI render of `sample-envelope-template2.xml` against the 392-record sample CSV and a bitmap smoke of the actual WinForms canvas drawing the rotated dynamic field. |
| 2026-10-19 | Address Control lines rendered on the designer canvas (`TemplateCanvasControl.DrawAddressControl`) and, since Sprint 7, on the new record-accurate preview panel (`TemplatePreviewControl`) visually overlap when a control uses a small font size with the default 1.25 line-spacing multiplier (observed with `sample-envelope-template3.xml`'s 8pt lines against the real sample CSV): each line's GDI+ `MeasureString` height is taller than the `FontSize * LineSpacingMultiplier` row height the baseline math advances by, so consecutive lines' bounding boxes visually overlap on screen even though their baselines are correctly spaced per `TEMPLATE_FORMAT.md`. Confirmed via live built-`.exe` screenshots of both surfaces during Sprint 7 batch 1 verification — present identically on the pre-existing (Sprint 6) design canvas, so this is not a Sprint 7 regression, just newly re-observed because a second surface now shares the same measurement approach. | Unintentional (side effect of using GDI+ `MeasureString`'s full line height as a stand-in for the real Debenu-rendered glyph height, which the code's own remarks already flag as "a design-time visual approximation... not a guarantee of pixel-for-point parity with the final PDF") | Low (cosmetic, canvas/preview-only — the actual PDF render path, `DebenuPdfRenderer`/`RenderEngine`, does not use this measurement at all and is unaffected; does not block reading resolved text, just makes tightly-spaced small-font address blocks visually crowded in the designer) | Open | Not fixed as part of Sprint 7 (out of scope for both the preview-panel and snap-to-grid stories). Candidate direction: derive each line's row height from the same font's ascent/descent (or a smaller line-height fraction of `MeasureString`) instead of the raw measured string height, or accept a documented minimum recommended `LineSpacingMultiplier` for small fonts. Revisit if an operator reports this as more than cosmetic. **Product-owner review confirmation (Sprint 7 Review, 2026-10-19):** Impact/status agreed as logged — Low, Open, non-blocking. Independently confirmed the root cause is confined to `TemplateCanvasControl`/`TemplatePreviewControl`'s GDI+ measurement path and that neither `DebenuPdfRenderer` nor `RenderEngine` (the real PDF render path) references this measurement at all, so no print-output correctness is at risk. No pushback on leaving this open; agree it should not block Sprint 8 planning. |
| 2026-09-04 | `EnvelopeRenderer.Cli` only read `DEBENU_LICENSE_KEY` from the process environment. That's fine for `dotnet run --project ... --` (the CLI inherits the invoking shell's env directly), but `EnvelopeRenderer.Desktop` launches the CLI as a child process, which only inherits whatever environment variables were already present in whatever launched the desktop app itself (a double-clicked `.exe` or Start Menu shortcut typically has none) — so every desktop-launched render failed with Debenu error 999 regardless of a valid key existing on disk. Real-user-reported: the operator correctly guessed a `key.txt` dropped next to the exe should work (matching how the CLI's own test helper already resolved keys), but production code had no such fallback. This is a real Definition-of-Done verification gap from Sprint 1 Batches 4-5: the "real success run" verification used an in-process test harness with the env var set directly in that process, never the actual built `.exe` launched the way an operator would, so the gap wasn't caught before Sprint Review. | Unintentional | High (silently broke the desktop app's core success path for any non-`dotnet run` launch) | Resolved | Added `DebenuLicenseKeyResolver` (`code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs`) to the shipped CLI: env var first, then a `key.txt` walked up from the executable's own directory — the same rule the test-only helper already used, now shared via delegation instead of duplicated. Documented in `CLI_CONTRACT.md`'s "Debenu license key" section and `code/README.md`'s desktop-app instructions. Verified by running the actual built `EnvelopeRenderer.Cli.exe` from the Desktop app's own output folder with no environment variable set at all, `key.txt` sitting next to it: exit `0`, valid 1.3 MB `%PDF-1.4` output, all 392 records. 5 new unit tests added (`DebenuLicenseKeyResolverTests.cs`); full suite 111/111 passing. |
| 2026-10-19 | Post-Sprint-7-review, the plain editing canvas (`TemplateCanvasControl`/`CanvasElementEditor`) now deliberately rotates every element — static and dynamic/mixed alike — around its own bounding-box center (`RotationPivotCalculator.ComputeForCanvasEditing`), instead of the Sprint 6 fixed-anchor rule (`RotationPivotCalculator.Compute(isDynamic, ...)`) it previously shared uniformly with the real render and the new Sprint 7 preview panel. The editing canvas only ever draws an element's literal authored `{ColumnName}` bracket-token text (`TextElementLayout.DisplayText`), never a per-record resolved value, so the record-to-record text-width drift the fixed-anchor rule exists to prevent cannot occur there — applying it anyway just made a dynamic/mixed element's rotate-handle drag swing around a corner instead of spinning in place, a jarring interactive inconsistency the human product owner asked to have fixed after using the Sprint 7 increment. The real render (`RotatedTextAnchorCalculator`/`DebenuPdfRenderer`) and the new preview panel (`TemplatePreviewControl`/`TemplatePreviewBuilder`) are intentionally untouched and still call `RotationPivotCalculator.Compute` with the real `isDynamic` value, so the original 2026-10-09 record-drift defect fix is fully preserved where it actually matters. See the dated note added to the "Keep rotated dynamic and mixed-content fields positioned consistently across records" story in `backlog/epics/02_template_designer_gui_foundation.md` for the full reasoning and human product-owner approval record. | Deliberate (a considered, approved trade-off decoupling a purely cosmetic/interactive editing-canvas behavior from a record-accuracy rule that surface was never actually subject to — not a regression or an oversight) | Low (the editing canvas was never a record-accuracy surface for rotation to begin with, since it never draws resolved per-record text; the real render and record-accurate preview panel, where the original defect actually mattered, are unaffected and keep the exact same fixed-anchor behavior) | Logged (deliberate design decision, not a defect to resolve) | Not applicable — this row documents a considered, already-implemented, and approved design decision for future reference (e.g. if a future story ever gives the editing canvas its own resolved-per-record preview capability, this decoupling would need to be revisited). |
| 2026-10-26 | Sprint 8 ("Rotate the whole Address Control...") extracted its *new* rigid-group-rotation and drag-handle math into framework-free, unit-tested `EnvelopeRenderer.Desktop.Core.Design` classes (`AddressControlLayout.BoxCenter`, `PointRotation`, `AddressControlRotateHandle`), but the *pre-existing* (Sprint 6) Address Control interaction logic it builds on top of — `TemplateCanvasControl.HitTestAddressControl`, `HitTestAddressResizeHandle`, and the whole-control move/resize mouse handlers — still lives directly in the WinForms `Views` project rather than a `CanvasElementEditor`-equivalent for Address Controls, so it remains untestable by `EnvelopeRenderer.Desktop.Tests` (verified only via a live built-`.exe` reflection-driven smoke, not a unit test) unlike the standalone-element equivalents (`CanvasElementEditor.HitTest`/`HandlePosition`/`RotateDragTo`, which are unit tested). This is a proportional, in-scope decision for this sprint (retroactively refactoring Sprint 6's already-shipped, already-smoke-verified move/resize/hit-test code was not part of either committed story), not an oversight, but it leaves a real, growing architectural inconsistency between the two element kinds' testability. | Unintentional (a natural side effect of adding new, better-architected code alongside older code that predates the pattern, not a deliberate call to leave the old code as-is) | Low (no known behavioral defect — the smoke-tested code paths work correctly per this sprint's live verification; purely a testability/maintainability gap, and the two newly-added rotation classes themselves are fully unit tested) | Open | Candidate direction: extract `TemplateCanvasControl`'s Address-Control-specific hit-test/drag/resize state and math into a new `AddressControlEditor` (Desktop.Core), mirroring `CanvasElementEditor`'s existing role for standalone elements, so all four interaction kinds (select, move, resize, rotate) are unit tested consistently. Not required by any currently-committed story; revisit if Address Control interaction logic grows further (e.g. multi-select epic 6 work) or a live-verified defect is found in it. |

+ 30
- 7
state.md Целия файл

@@ -2,15 +2,21 @@

> The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff."

**Phase:** 1 - Backlog refinement (next sprint cycle)
**Leading agent:** `product-owner`
**Process file:** `process/01_backlog_refinement.md`
**Phase:** 3 - Sprint execution
**Leading agent:** `dev-team`
**Process file:** `process/03_sprint_execution.md`

**Sprint:** 4 closed. Sprint 5 not yet planned.
**Sprint goal (Sprint 4, closed):** Complete the CSV Integration and Field Mapping epic by collapsing blank optional address lines, and deliver the first slice of text/field rotation (numeric angle entry, persistence, and correct rendering around the element's bounding-box center). Met in full.
**Current sprint backlog:** none active — see `backlog/sprints/sprint-4.md` (closed) and `backlog/sprints/sprint-4-retrospective.md`.
**Sprint:** 8 planned (2026-10-26), not yet executed.
**Sprint goal:** Ship whole-Address-Control rotation end-to-end - settable via the properties panel and adjustable by dragging a canvas handle - staying record-stable and visually consistent across the editing canvas, the Sprint 7 live preview, and the real CLI-rendered PDF, directly delivering the human product owner's reversal of the Sprint 6 Review "out of scope" call.
**Current sprint backlog:** `backlog/sprints/sprint-8.md`

**Next action:** Sprint 4 retrospective complete (`backlog/sprints/sprint-4-retrospective.md`) — full follow-through on all 4 Sprint 3 retro actions confirmed (the third clean full-follow-through sprint in a row), including a structural resolution of the one open process watch item (product-owner review independence — now marked Resolved in `logs/process_improvement_log.md`, not carried forward). Two Sprint 5 action items logged: continue empirical vendor/spec verification and live actual-built-artifact verification (both standing expectations, no process change); apply the same code-inspection-backed sizing rigor to epic 08 when it's next refined. No kit edits made. Test suite at 288/288. Two epics now fully Done (Template Designer GUI Foundation; CSV Integration and Field Mapping). Ready-but-uncommitted backlog: "Harden production configuration delivery for CLI runtime settings" (epic 5, 2 pts). Unsized: the new "Composite Address Controls and Mixed-Content Text" epic (`backlog/epics/08_composite_address_controls.md`, 2 stories, requirements captured 2026-09-29 but not yet sized by dev-team). **This is a natural pause point — confirm with the user before starting Sprint 5 backlog refinement/planning.** Likely next refinement step: size epic 08's two stories with dev-team before Sprint 5 can be planned around them.
**Next action:** Natural post-retrospective pause. When the user says to continue, start Sprint 8 backlog refinement as `product-owner`, folding in the Sprint 7 retrospective's three carried-forward action items: (1) apply the built-form-vs-canvas-only smoke rule per-story for epic 6's multi-select/align pair, since aligning/distributing may add new toolbar affordances requiring a full smoke; (2) when a later batch adds a control to a form an earlier batch already screenshotted, capture a fresh full-form screenshot rather than reusing the earlier one; (3) if a future PO sprint review again lacks shell/build access, disclose it and arrange an independent cross-check (as was done this sprint). No kit-level edit was made - the one new process signal (PO review lacking shell/build access this sprint) was logged as a "Watching" item in `logs/process_improvement_log.md`, judged a first-occurrence tooling-access gap distinct from the closed Sprint 3 PO-independence item, not severe enough to change `AGENTS.md`/`process/`/`templates/`. Also still open: the "Warn on text overflow before render" (epic 4) product question, not blocking, for whenever the human product owner wants to resolve it.

**Since the retrospective, same-day user feedback on the live Sprint 7 build (2026-10-19), handled outside formal ceremony:**
- Two post-review fixes already implemented, tested, and live-verified (not new sprint work - corrections to Sprint 7's own just-shipped increment): (1) record-number navigation now auto-updates the preview with no "Go" button; (2) the editing canvas now rotates static and dynamic/mixed text elements identically (smooth center-pivot), while the real render and the Sprint 7 preview panel keep the record-stable anchor-pivot behavior from the Oct 9 defect fix unchanged. Both documented in `backlog/sprints/sprint-7.md`'s "Post-review fixes" section, `backlog/epics/02_template_designer_gui_foundation.md`, and `logs/technical_debt_log.md`. Test suite now 402/402 (up from 400), independently confirmed via a real `dotnet test` run.
- New backlog item queued for Sprint 8 (user explicitly chose to queue rather than implement ad hoc): epic 8 (Composite Address Controls, reopened from Done) gained two new Ready, sized stories - "Rotate the whole Address Control as a single unit" (8 pts) and "Rotate the whole Address Control by dragging a handle on the canvas" (5 pts, depends on the former) - 13 points total. Confirmed by code inspection that Address Control's box geometry is author-set (not measured from resolved text), so whole-control rotation can safely pivot around the box center everywhere (editing canvas, preview, and real render) with no anchor-pivot workaround needed, unlike the standalone-element defect fix. Full detail in `backlog/epics/08_composite_address_controls.md` and `backlog/backlog.md`'s new dated note.

**Next action:** Execute Sprint 8 as `dev-team` (13 points committed, dependency-ordered per `backlog/sprints/sprint-8.md`): Batch 1 "Rotate the whole Address Control as a single unit" (8 pts, no dependency - angle property, persistence, box-center pivot math across canvas/preview/render), Batch 2 "Rotate the whole Address Control by dragging a handle on the canvas" (5 pts, depends on Batch 1). `scrum-master` deliberately kept this an under-commit (13 pts vs. the proven 18-20 range) rather than pairing with epic 6's multi-select/align pair (would total 23, a new high): both this sprint's drag-handle story and multi-select would land foundational, first-of-its-kind changes in the same small set of GUI files (`CanvasElementEditor`, `TemplateCanvasControl`), and this team consistently keeps that class of risk isolated. Honor the Sprint 7 retro carry-ins: built-form smoke for any GUI story touching form layout/properties/toolbar actions; a FRESH full-form screenshot per batch if a later batch changes a form an earlier batch already screenshotted (not a reused one - the exact Sprint 7 near-miss). Still-open, non-blocking: "Warn on text overflow before render" (epic 4) product question; both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); epic 6's multi-select/align pair (10 pts) and "Undo and redo layout changes" (13 pts, feasibility de-risked) remain Ready for Sprint 9; "Complete the first text-only operator workflow" (epic 1, 5 pts) is labeled Ready but flagged by scrum-master as a stale onboarding-era placeholder needing re-verification before ever being pulled, same staleness pattern epic 4 had at the 2026-10-16 refinement.

## Phase reference

@@ -53,3 +59,20 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-09-29 | 4 - Sprint review | `product-owner` verified all 3 Sprint 4 stories against acceptance criteria (verdict: sprint goal met in full). Updated `backlog/backlog.md`'s epic status table and added a "Sprint 4 Review outcome" section; wrote independent confirmation notes for all 3 stories (dev-team was deliberately kept from pre-writing epic verification content this sprint). Both the Template Designer GUI Foundation and CSV Integration and Field Mapping epics are now Done outright. |
| 2026-09-29 | 1 - Backlog refinement | Ahead of closing Sprint 4, the user requested requirements for a new composite "Address Control" element (grouped lines mixing static text and CSV fields, single-anchor placement). `product-owner` gathered requirements across two rounds of clarifying questions and recorded a new epic, `backlog/epics/08_composite_address_controls.md` (2 dependent stories), deliberately left unsized per the user's own framing of "get requirements" before closing the sprint. |
| 2026-09-29 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-4-retrospective.md`). Confirmed full follow-through on all four Sprint 3 retro action items, the third clean full-follow-through sprint in a row — notably, the Sprint 3-named PO-review-independence watch item resolved structurally (dev-team was kept from writing epic verification content; product-owner wrote all 3 stories' notes fresh and independently) and was closed out in `logs/process_improvement_log.md` rather than carried forward. Named a new positive signal (a sizing note's specific reuse claim was later confirmed exactly by the real implementation). No kit edits. Checked all anti-patterns including the PO-review-independence watch item explicitly; none found. |
| 2026-09-29 | 1 - Backlog refinement | `dev-team` sized epic 08's two stories with real code inspection: 13 points each (a new size ceiling for this team, previously 8). Story 1 ("Mix static text and CSV fields") is a breaking model change to `TextElementLayout` with no existing UI to build from. Story 2 ("Group lines into a single, movable Address Control") is the designer's first composite element kind; a deliberate sizing-time design call (single anchor, auto-spaced lines, not N independently-moved children) means the existing `AddressLineCollapser` grouping logic needs no changes. Both pass the Definition of Ready and are marked Ready, but `product-owner` recommends committing them to separate sprints given their combined 26-point size against 18-20 point velocity. |
| 2026-10-05 | 2 - Sprint planning | `scrum-master` facilitated Sprint 5 planning. Velocity range holds at 18-20 points (four data points: 20, 19, 18, 18). Committed 15 points, deliberately below the low end of the range given "Mix static text and CSV fields within a single text element"'s (13 pts) size and structural risk: plus "Harden production configuration delivery for CLI runtime settings" (2 pts). "Group lines into a single, movable Address Control" (13 pts) left uncommitted per dev-team's own sizing-note recommendation against combining both epic-08 stories in one sprint. Recorded in `backlog/sprints/sprint-5.md`. |
| 2026-10-05 | 3 - Sprint execution | `dev-team` completed both committed batches (survived one transient background-agent API/network failure mid-run, resumed cleanly with no work lost). Batch 1 (mixed static/field text content, 13 points): replaced `TextElementLayout`/`TemplateElement`'s exclusive `StaticText`/`ColumnName` with an ordered `Runs` list, keeping the old properties as computed back-compat projections so all 233 pre-existing tests passed unmodified. Proved backward compatibility by building the actual pre-Sprint-5 CLI binary from a git worktree and byte-comparing real output, controlling for Debenu's own internal nondeterminism. Batch 2 (production configuration hardening, 2 points): decided to keep the existing license-key mechanism unchanged, documented in `CLI_CONTRACT.md`. 330/330 tests passing (up from 288). One live-caught GUI bug fixed same-pass; one disclosed bracket-syntax editing limitation documented as an accepted trade-off. |
| 2026-10-09 | 4 - Sprint review | `product-owner` verified both Sprint 5 stories against acceptance criteria (verdict: sprint goal met in full). Updated `backlog/backlog.md`'s epic status table (correcting a stale story count for epic 05 along the way) and added a "Sprint 5 Review outcome" section; independently confirmed dev-team's production-configuration decision rather than rubber-stamping it, flagging it to the user as a licensing/deployment judgment call. The CLI Rendering Engine and Debenu Integration epic is now Done outright. |
| 2026-10-09 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-5-retrospective.md`). Confirmed full follow-through on all three Sprint 4 retro action items, the fourth clean full-follow-through sprint in a row, one exceeded (backward-compatibility verification via a real historical-binary byte-comparison). Named a new action item: GUI verification automation reliability has now had two distinct issues in two consecutive sprints, worth hardening before Sprint 6's likely GUI-heavy Address Control story. No kit edits — both findings judged team/tooling-level, not Scrum-kit-level. Checked all anti-patterns including PO-review independence; none found. |
| 2026-10-09 | 1 - Backlog refinement | User reported rotated dynamic/mixed-content fields rendering at inconsistent positions per record. `product-owner` confirmed the root cause by code inspection (bounding-box-center pivot depends on each record's own resolved-text width; canvas preview doesn't reflect this either, breaking the rotation story's own already-shipped canvas/render-parity AC) before writing anything down. Logged as Open/High-impact in `logs/technical_debt_log.md` and written up as a full story in `backlog/epics/02_template_designer_gui_foundation.md`, not yet sized. Recommended for high priority in whatever's planned next, pending human product-owner confirmation. |
| 2026-10-12 | 1 - Backlog refinement | `dev-team` sized "Keep rotated dynamic and mixed-content fields positioned consistently across records" at 5 points after code inspection (`DebenuPdfRenderer`, `RotatedTextAnchorCalculator`, `TemplateCanvasControl`, `CanvasElementEditor`, `RenderEngine`). Story now passes Definition of Ready: clear ACs, no blocking dependency, plausible single-sprint scope. `product-owner` prioritized it first for Sprint 6 because it fixes already-shipped print-output correctness and canvas/render parity. |
| 2026-10-12 | 2 - Sprint planning | `scrum-master` facilitated Sprint 6 planning after the user confirmed the post-retro pause point with "continue." Committed 18 points: the 5-point rotation-positioning defect first, then "Group lines into a single, movable Address Control" (13 pts). Recorded in `backlog/sprints/sprint-6.md`. Sprint 5 retrospective action carried in explicitly: harden GUI verification automation reliability before/during the Address Control story. |
| 2026-10-12 | 3 - Sprint execution (in progress) | `dev-team` completed Sprint 6 Batch 1, "Keep rotated dynamic and mixed-content fields positioned consistently across records" (5 pts). Chose the fixed authored `(x, y)` anchor as the canonical pivot for rotated dynamic/mixed content, leaving rotated static text on the existing center-pivot path. Updated CLI rendering, desktop canvas geometry, tests, docs, sprint notes, and the technical debt log. Verified with 336/336 tests, a real-DLL transform-anchor regression, a real 392-record CLI render of the rotated dynamic sample template, desktop build, and a WinForms canvas bitmap smoke. Sprint remains active with Batch 2 next. |
| 2026-10-12 | 3 - Sprint execution | `dev-team` completed Sprint 6 Batch 2, "Group lines into a single, movable Address Control" (13 pts), bringing Sprint 6 to 18/18 points Done. Added a new Address Control model on desktop and CLI, line-list UI, per-line mixed-content editing and default-on collapse, whole-control move/resize including a right-edge canvas handle, `<addressControl>` XML persistence, CLI parse/render expansion, and template-format docs. Verified with 349/349 tests, clean desktop build, a real 392-record CLI render from a temp Address Control template, an actual WinForms canvas bitmap smoke, and a full `TemplateDesignerForm` bitmap smoke that caught and fixed a dock-order bug hiding the properties panel. |
| 2026-10-12 | 4 - Sprint review | `product-owner` verified both Sprint 6 stories against acceptance criteria (verdict: sprint goal met in full). The rotated dynamic/mixed-content defect fix is accepted and the 2026-10-09 High-impact technical debt item remains resolved. The Composite Address Controls and Mixed-Content Text epic is now Done outright. No new backlog item required; two scope notes remain documented, not defects: Address Control width does not wrap/clip render text, and whole-control rotation remains out of scope. |
| 2026-10-12 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-6-retrospective.md`). Confirmed the fifth clean full-follow-through sprint in a row. Main action for Sprint 7: use actual built-form smokes for GUI stories that touch form layout/properties/toolbar actions. No kit-level edit proposed. |
| 2026-10-16 | 1 - Backlog refinement | `product-owner` ran Sprint 7 backlog refinement. Found epic 4's "Ready" label stale (unchanged since onboarding, predating rotation/mixed-content/Address Controls) - real code inspection showed the canvas preview never resolves per-record text at all, just raw bracket tokens. Rewrote and re-sized both stories: "Render an accurate, record-specific preview of the current template" (8 pts, up from 5) and "Jump to a specific record number" (5 pts, up from 3), both now Ready with ACs requiring one shared text-resolution routine (avoiding a third divergent implementation, the same drift class behind the 2026-10-09 rotation defect) and a built-form smoke. A third epic-4 story, "Warn on text overflow before render," was left explicitly Not Ready - no element has any bounding-width concept today, logged as an open question for the human product owner, not blocking Sprint 7. Also converted epic 6's three placeholder one-liners into full sized stories: "Select multiple elements at once on the canvas" (5 pts, new prerequisite story, no multi-select exists), "Align and distribute multiple elements" (5 pts, depends on the former), "Snap elements to grid and guides" (5 pts), "Undo and redo layout changes" (13 pts, flagged as this backlog's highest-uncertainty estimate - no undo/redo infrastructure exists at all). Confirmed the two open impediments (asset path strategy, UNC timeout/retry) remain non-blocking since epic 7 stays behind epic 6. No backlog reordering - findings changed sizing/scope, not relative priority. Full reasoning in `backlog/backlog.md`'s "Sprint 7 backlog refinement outcome (2026-10-16)" note. |
| 2026-10-19 | 2 - Sprint planning | `scrum-master` facilitated Sprint 7 planning. Velocity now has six data points (20, 19, 18, 18, 15, 18) - a stable 18-20 range. Committed 18 points: the full epic 4 pair ("Render an accurate, record-specific preview," 8 pts; "Jump to a specific record number," 5 pts, dependent) plus "Snap elements to grid and guides" (epic 6, 5 pts, no dependencies) - chosen over the other epic 6 candidates as the lowest-risk way to round out capacity (no CLI/XML surface, canvas-only smoke needed). Deliberately kept "Select multiple elements" and "Align and distribute" (10 pts combined, hard-dependent) out rather than splitting the prerequisite across sprints. Left "Undo and redo layout changes" (13 pts, this backlog's highest-uncertainty estimate) out entirely, recommending a short feasibility check before it's ever committed. Recorded in `backlog/sprints/sprint-7.md`; planning outcome note added to `backlog/backlog.md`. |
| 2026-10-19 | 3 - Sprint execution | `dev-team` completed all 3 committed batches (18/18 points). Batch 1 (record-specific preview, 8 pts): extracted a single shared `TextResolver` and `RotationPivotCalculator` (replacing two near-duplicate implementations each), built a framework-free `TemplatePreviewBuilder`/`PreviewTextDraw` mirroring `RenderEngine`, and a dedicated `TemplatePreviewControl` wired into `TemplateDesignerForm` via a deterministic `TableLayoutPanel` (avoiding a repeat of Sprint 6's dock-order bug). Live-verified via a real built-`.exe` reflection harness: rotated dynamic pivot stayed fixed across differently-sized names, Address Control collapse worked against real data. One new Low-impact tech debt logged (Address Control lines visually overlap at small font sizes - pre-existing GDI+ measurement approximation, doesn't affect the real PDF render path). Batch 2 (jump to record, 5 pts): new `CsvRecordNavigator` mirroring the CLI's proven `CsvRecordSource` pattern; live-verified navigating the real 392-record sample CSV including an out-of-range case. Batch 3 (grid/guide snapping, 5 pts): new `GridSnapper` wired into both `CanvasElementEditor` and `TemplateCanvasControl`; live-verified with a canvas-only smoke confirming mid-gesture snapping. 400/400 tests passing (up from 349). |
| 2026-10-19 | 4 - Sprint review | `product-owner` verified all 3 Sprint 7 stories against acceptance criteria by reading the actual new/changed code and test files (not just dev-team's summary) - confirmed the shared `TextResolver`/`RotationPivotCalculator` are genuinely used by canvas and preview alike, the preview auto-refreshes on every real edit path, collapse/mixed-content/Address-Control scenarios in `TemplatePreviewBuilderTests` genuinely mirror `RenderEngineTests`, record navigation is a real unbounded scan (not the 20-row preview cap), and grid snapping is continuous mid-drag with zero template-format changes. Verdict: sprint goal met in full. Lacking shell/build access this session, substituted an independent `[Fact]`/`[Theory]` count (matched 400/400 exactly) and code-level checks for a live `dotnet test` run and built-`.exe` re-verification - disclosed explicitly rather than presented as full re-verification. I (top-level session) ran `dotnet test` directly afterward and confirmed 400/400 (299 Desktop + 101 CLI) for real. Updated `backlog/backlog.md`'s epic table and Sprint 7 Review outcome note, epics 04/06 with verification detail, and added a PO confirmation to the new Low-impact Address Control overlap tech debt entry (confirmed root cause confined to GDI+ canvas/preview measurement, doesn't touch the real PDF render path). |
| 2026-10-19 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-7-retrospective.md`). Sixth consecutive clean full-follow-through sprint: all three Sprint 6 carry-ins held (built-form smoke rule applied correctly - canvas-only smoke for the canvas-only grid-snap story was correct per the rule's own original wording, not a shortcut, though one minor nuance was named: Batch 2 reused Batch 1's full-form screenshot rather than capturing a fresh one after adding a new toolbar control; product-risk-first ordering confirmed; cross-layer code-inspection sizing confirmed via cited real method/class names in the 2026-10-16 sizing notes). New "Watching" item logged in `logs/process_improvement_log.md` (not a kit edit): this sprint's PO review lacked shell/build access and substituted a static test-attribute count plus code reading for a live `dotnet test` run - judged a first-occurrence tooling-access gap, explicitly distinct from the closed Sprint 3 PO-independence item, not severe enough for a kit change. No anti-patterns found. Three concrete action items carried into Sprint 8 planning (see Next action above). |

Loading…
Отказ
Запис

Powered by TurnKey Linux.