Просмотр исходного кода

Close out Sprint 4: blank-line collapse, element rotation, review, retrospective

Completes the CSV Integration and Field Mapping epic by collapsing blank
optional address lines (with a visual distinction between blank data and a
mapping error), and delivers text/field rotation end-to-end: a free-form
angle property that rotates about the element's bounding-box center
(verified against the real Debenu DLL with a pixel-centroid regression
test), plus an interactive drag-to-rotate canvas handle. Closes out Sprint 4
with review and retrospective notes, including resolving the prior
retrospective's product-owner review-independence watch item. Also captures
requirements (not yet sized) for a new composite Address Control epic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 1 неделю назад
Родитель
Сommit
08d6a84a07
37 измененных файлов: 2245 добавлений и 73 удалений
  1. +20
    -2
      backlog/backlog.md
  2. +6
    -2
      backlog/epics/02_template_designer_gui_foundation.md
  3. +3
    -1
      backlog/epics/03_csv_integration_and_field_mapping.md
  4. +54
    -0
      backlog/epics/08_composite_address_controls.md
  5. +67
    -0
      backlog/sprints/sprint-4-retrospective.md
  6. +41
    -0
      backlog/sprints/sprint-4.md
  7. +40
    -0
      code/TEMPLATE_FORMAT.md
  8. +141
    -0
      code/src/EnvelopeRenderer.Cli.Tests/AddressLineCollapserTests.cs
  9. +132
    -0
      code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererRotationTests.cs
  10. +5
    -0
      code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj
  11. +110
    -0
      code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
  12. +103
    -0
      code/src/EnvelopeRenderer.Cli.Tests/RotatedTextAnchorCalculatorTests.cs
  13. +62
    -0
      code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
  14. +74
    -0
      code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs
  15. +37
    -4
      code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
  16. +38
    -8
      code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
  17. +65
    -0
      code/src/EnvelopeRenderer.Cli/Render/RotatedTextAnchorCalculator.cs
  18. +13
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
  19. +39
    -2
      code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs
  20. +7
    -2
      code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs
  21. +81
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressBlockPreviewCalculator.cs
  22. +64
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs
  23. +162
    -13
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  24. +15
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/ElementPreviewState.cs
  25. +42
    -1
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs
  26. +14
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
  27. +23
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementPropertiesEditor.cs
  28. +110
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressBlockPreviewCalculatorTests.cs
  29. +92
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressLineCollapserTests.cs
  30. +193
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
  31. +81
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
  32. +28
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextElementLayoutTests.cs
  33. +50
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
  34. +170
    -30
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  35. +52
    -2
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  36. +1
    -1
      logs/process_improvement_log.md
  37. +10
    -4
      state.md

+ 20
- 2
backlog/backlog.md Просмотреть файл

@@ -5,12 +5,13 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| Order | Epic | File | Status | | 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) | | 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` | In Progress (4 of 6 stories Done — 2 new Ready stories added 2026-09-22) |
| 3 | CSV Integration and Field Mapping | `epics/03_csv_integration_and_field_mapping.md` | In Progress (3 of 4 stories Done — Sprint 3) |
| 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (6 of 6 stories — Sprint 4) |
| 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) | | 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 | | 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 | | 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 | | 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) |


## Notes ## 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. - 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.
@@ -44,3 +45,20 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
### New feature added during backlog refinement (2026-09-22) ### New feature added during backlog refinement (2026-09-22)
- User requested the ability to rotate text/dynamic field elements at an arbitrary angle. `product-owner` clarified scope directly with the user (angle range, UI interaction, pivot point) before writing the story, then split it into two dependent stories in `epics/02_template_designer_gui_foundation.md` (previously fully Done, now reopened): "Set a rotation angle for text and dynamic field elements" (properties-panel numeric entry, persistence, and render-side rotation about the bounding-box center — 8 points) and "Rotate elements by dragging a handle on the canvas" (interactive drag handle, depends on the first story — 5 points). `dev-team` sized both against real code inspection, confirming the vendor Debenu API (`DrawRotatedText`, `GetTextWidth`/`GetTextAscent`/`GetTextDescent`) supports the underlying capability but has no "rotate about center" primitive, so the center-pivot math is genuine new work, not a pass-through. Both pass the Definition of Ready; recommend against splitting further. - User requested the ability to rotate text/dynamic field elements at an arbitrary angle. `product-owner` clarified scope directly with the user (angle range, UI interaction, pivot point) before writing the story, then split it into two dependent stories in `epics/02_template_designer_gui_foundation.md` (previously fully Done, now reopened): "Set a rotation angle for text and dynamic field elements" (properties-panel numeric entry, persistence, and render-side rotation about the bounding-box center — 8 points) and "Rotate elements by dragging a handle on the canvas" (interactive drag handle, depends on the first story — 5 points). `dev-team` sized both against real code inspection, confirming the vendor Debenu API (`DrawRotatedText`, `GetTextWidth`/`GetTextAscent`/`GetTextDescent`) supports the underlying capability but has no "rotate about center" primitive, so the center-pivot math is genuine new work, not a pass-through. Both pass the Definition of Ready; recommend against splitting further.
- **Placement recommendation (product-owner, not yet ordered into a sprint):** slot these two stories immediately after the two items already flagged as clean Sprint 4 carryover from Sprint 3 planning — "Collapse blank optional address lines consistently" (epic 3, 5 pts) and "Harden production configuration delivery for CLI runtime settings" (epic 5, 2 pts) — rather than ahead of them, since neither carryover item depends on rotation and both were already committed-to in spirit at Sprint 3 planning. Note for Sprint 4 planning: those two carryover items (7 pts) plus both rotation stories (13 pts) sum to exactly 20 points, matching the top of this team's 19-20 point velocity range — scrum-master/dev-team should treat that as a ceiling-level coincidence to watch, not a target to hit, and should still leave normal grooming buffer per `AGENTS.md`'s guidance. This ordering is a recommendation only; the human product owner can reprioritize the rotation stories ahead of the carryover items if rotation is more urgent than sequencing suggests. - **Placement recommendation (product-owner, not yet ordered into a sprint):** slot these two stories immediately after the two items already flagged as clean Sprint 4 carryover from Sprint 3 planning — "Collapse blank optional address lines consistently" (epic 3, 5 pts) and "Harden production configuration delivery for CLI runtime settings" (epic 5, 2 pts) — rather than ahead of them, since neither carryover item depends on rotation and both were already committed-to in spirit at Sprint 3 planning. Note for Sprint 4 planning: those two carryover items (7 pts) plus both rotation stories (13 pts) sum to exactly 20 points, matching the top of this team's 19-20 point velocity range — scrum-master/dev-team should treat that as a ceiling-level coincidence to watch, not a target to hit, and should still leave normal grooming buffer per `AGENTS.md`'s guidance. This ordering is a recommendation only; the human product owner can reprioritize the rotation stories ahead of the carryover items if rotation is more urgent than sequencing suggests.

### Sprint 4 Review outcome (2026-09-29)
- All 3 committed Sprint 4 stories (18/18 points) are Done; each is verified against its acceptance criteria with real, non-simulated evidence, recorded story-by-story in `backlog/epics/02_template_designer_gui_foundation.md` (both rotation stories) and `backlog/epics/03_csv_integration_and_field_mapping.md` (blank-line collapse). Full daily detail: `backlog/sprints/sprint-4.md`.
- **Sprint goal met in full.** The CSV Integration and Field Mapping epic is now Done outright (4 of 4 stories). The Template Designer GUI Foundation epic is also Done outright again (6 of 6 stories, having been reopened for the rotation work). Test suite grew to 288/288 (up from 223 at Sprint 3 close).
- No new technical debt logged this sprint. The one open item carried in from Sprint 3 (1,000,000-record file-size risk) remains untouched and non-blocking, as expected — nothing this sprint touched the render batching/font-embedding path.
- One honestly-reported, non-blocking note from dev-team's own verification: a testing-tooling focus glitch (`SetForegroundWindow` failing once during GUI automation) briefly misdirected some keystrokes during Batch 3's live verification; caught immediately from screenshot evidence and worked around, confirmed to be a tooling artifact rather than a product defect — no product-facing gap, not logged as technical debt.
- "Harden production configuration delivery for CLI runtime settings" (epic 5, 2 points, Ready) remains uncommitted, as planned — still a clean pull whenever it's prioritized, not urgent per the product owner's standing note.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry behavior, both in `logs/impediment_log.md`).

### 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.

### 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.

+ 6
- 2
backlog/epics/02_template_designer_gui_foundation.md Просмотреть файл

@@ -64,7 +64,9 @@ As a **print operator**, I want to edit key text properties from a properties pa
**Estimate:** 3 points **Estimate:** 3 points
**Dependencies:** None **Dependencies:** None


### Set a rotation angle for text and dynamic field elements - Status: Ready
### Set a rotation angle for text and dynamic field elements - Status: Done
**Sprint Review verification (Sprint 4, 2026-09-29):** All 4 acceptance criteria met, with the render-side risk this story's sizing note flagged (rotate-about-anchor vendor API vs. the required rotate-about-bounding-box-center behavior) genuinely resolved rather than papered over. AC1 (numeric field, any degree): a free-form "Angle (deg):" properties-panel field with no clamping. AC2 (rotates around bbox center, not anchor): the new `RotatedTextAnchorCalculator` solves the anchor offset Debenu's `DrawRotatedText` needs; proven, not just asserted, by a durable regression test (`DebenuPdfRendererRotationTests`) that rasterizes real rendered pages at five angles and checks the pixel-ink centroid stays fixed within tolerance — a test that would fail by 90-190px without the correction, so it's actually exercising the claimed behavior. AC3 (persists, restores, old templates unaffected): `angle=0`/absent takes the exact pre-existing `DrawText` code path, confirmed byte-for-byte unchanged. AC4 (canvas and PDF rotate the same way): the direction convention wasn't assumed — dev-team empirically confirmed against the real DLL that positive `Angle` is counterclockwise in PDF space, then correctly negated GDI+'s canvas rotation (which is clockwise-positive in Y-down pixel space) to match, and live-verified a 45-degree label rendering visibly tilted the same way in both the canvas and the actual output PDF. This is exactly the "confirm real vendor behavior empirically" discipline this project has required since the Sprint 3 throughput investigation, applied correctly again here.

**Card** **Card**
As a **print operator**, I want to set a rotation angle for a text or dynamic field element, so that I can print rotated text (e.g., an angled return-address stamp or a slanted design element) without restructuring my layout. As a **print operator**, I want to set a rotation angle for a text or dynamic field element, so that I can print rotated text (e.g., an angled return-address stamp or a slanted design element) without restructuring my layout.


@@ -87,7 +89,9 @@ As a **print operator**, I want to set a rotation angle for a text or dynamic fi
**Sizing note (`dev-team`, 2026-09-22):** Confirmed the story's own framing against the real code rather than taking it on faith. `code/vendor/debenu/interop/DebenuPDFLibraryDLL1013.cs` does expose `DrawRotatedText(XPos, YPos, Angle, Text)` (line ~4213) rotating around the given anchor point, plus `GetTextWidth`/`GetTextAscent`/`GetTextDescent` for bounding-box measurement — the vendor API genuinely has no "rotate about center" primitive, so the anchor-offset rotation math the conversation notes describe is real, not-yet-written work, not just a pass-through parameter. On the reusable side: `TextElementLayout` (`code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs`) is a plain mutable class that both static and dynamic elements already share, so a new `RotationAngle` property needs no per-kind branching; `TextElementPropertiesEditor` already has the exact pattern to copy for a new numeric field (see `SetFontSize`'s validate-then-assign shape); and `TemplateDesignerForm`'s existing X/Y-input wiring (`_xInput.ValueChanged += ...; _canvas.NotifyElementChanged();`) is a direct template for a new angle input. On the net-new side, this story touches real logic in seven places, not one: (1) `TextElementLayout` — new property; (2) `TextElementPropertiesEditor` — new setter + validation; (3) `TemplateDesignerForm` — new panel control and wiring; (4) `TemplateCanvasControl.DrawElement` — must rotate the drawn glyph and its selection outline about the bbox center (GDI+ `Graphics.RotateTransform`/`TranslateTransform` around the measured center, not just an angle pass-through) and `CanvasElementEditor.HitTest` must do point-in-rotated-rectangle math instead of today's plain AABB check (`xPoints >= element.X && ...`) so click-to-select still works once an element is rotated; (5) `TemplateLayoutXmlSerializer` — new `angle` attribute, defaulting to `0` on load for old files (mirrors the already-established pattern for `zOrder`'s own "missing is fine, invalid is an error" leniency); (6) the CLI's own render-time `TextDraw` record (`code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs`, currently `(X, Y, FontName, Size, Text)` with no angle field) and `TemplateXmlParser`/`RenderEngine`, which must also learn the new `angle` attribute; (7) `DebenuPdfRenderer.AddPage`, which must compute the unrotated bbox center from `GetTextWidth`/`GetTextAscent`/`GetTextDescent`, solve for the anchor point that keeps that center fixed after rotation, and call `DrawRotatedText` instead of `DrawText` when angle is non-zero — plus the conversation notes' own flagged requirement to empirically verify Debenu's rotation-direction sign convention against the real DLL before documenting it in `TEMPLATE_FORMAT.md`, the same "confirm against the real library, don't assume" discipline this team already applied to the throughput investigation. That is a materially larger surface than "Edit selected text properties in a properties panel" (3 points — pure data-binding into already-existing panel/model/serializer, no new geometry) and larger than "Place and move text elements on the canvas" (5 points — new interaction but only ever needed plain-rectangle hit-testing/dragging, no rotation transform on either side of the app). It is comparable in shape, though not in raw novelty, to "Render text-only PDFs through Debenu Quick PDF" (8 points) and "Investigate and address high-volume render throughput degradation" (8 points): like those two, it is a full vertical slice spanning the desktop model/UI/canvas *and* the CLI render engine/vendor integration, with a real (if bounded) geometry/empirical-verification risk on the render side. At the team's current 19-20 point/sprint velocity, 8 points is well within "plausibly fits in a single sprint" per the Definition of Ready (it's the same size as two stories already delivered start-to-finish within one sprint each) — not recommending a split. **Sizing note (`dev-team`, 2026-09-22):** Confirmed the story's own framing against the real code rather than taking it on faith. `code/vendor/debenu/interop/DebenuPDFLibraryDLL1013.cs` does expose `DrawRotatedText(XPos, YPos, Angle, Text)` (line ~4213) rotating around the given anchor point, plus `GetTextWidth`/`GetTextAscent`/`GetTextDescent` for bounding-box measurement — the vendor API genuinely has no "rotate about center" primitive, so the anchor-offset rotation math the conversation notes describe is real, not-yet-written work, not just a pass-through parameter. On the reusable side: `TextElementLayout` (`code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs`) is a plain mutable class that both static and dynamic elements already share, so a new `RotationAngle` property needs no per-kind branching; `TextElementPropertiesEditor` already has the exact pattern to copy for a new numeric field (see `SetFontSize`'s validate-then-assign shape); and `TemplateDesignerForm`'s existing X/Y-input wiring (`_xInput.ValueChanged += ...; _canvas.NotifyElementChanged();`) is a direct template for a new angle input. On the net-new side, this story touches real logic in seven places, not one: (1) `TextElementLayout` — new property; (2) `TextElementPropertiesEditor` — new setter + validation; (3) `TemplateDesignerForm` — new panel control and wiring; (4) `TemplateCanvasControl.DrawElement` — must rotate the drawn glyph and its selection outline about the bbox center (GDI+ `Graphics.RotateTransform`/`TranslateTransform` around the measured center, not just an angle pass-through) and `CanvasElementEditor.HitTest` must do point-in-rotated-rectangle math instead of today's plain AABB check (`xPoints >= element.X && ...`) so click-to-select still works once an element is rotated; (5) `TemplateLayoutXmlSerializer` — new `angle` attribute, defaulting to `0` on load for old files (mirrors the already-established pattern for `zOrder`'s own "missing is fine, invalid is an error" leniency); (6) the CLI's own render-time `TextDraw` record (`code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs`, currently `(X, Y, FontName, Size, Text)` with no angle field) and `TemplateXmlParser`/`RenderEngine`, which must also learn the new `angle` attribute; (7) `DebenuPdfRenderer.AddPage`, which must compute the unrotated bbox center from `GetTextWidth`/`GetTextAscent`/`GetTextDescent`, solve for the anchor point that keeps that center fixed after rotation, and call `DrawRotatedText` instead of `DrawText` when angle is non-zero — plus the conversation notes' own flagged requirement to empirically verify Debenu's rotation-direction sign convention against the real DLL before documenting it in `TEMPLATE_FORMAT.md`, the same "confirm against the real library, don't assume" discipline this team already applied to the throughput investigation. That is a materially larger surface than "Edit selected text properties in a properties panel" (3 points — pure data-binding into already-existing panel/model/serializer, no new geometry) and larger than "Place and move text elements on the canvas" (5 points — new interaction but only ever needed plain-rectangle hit-testing/dragging, no rotation transform on either side of the app). It is comparable in shape, though not in raw novelty, to "Render text-only PDFs through Debenu Quick PDF" (8 points) and "Investigate and address high-volume render throughput degradation" (8 points): like those two, it is a full vertical slice spanning the desktop model/UI/canvas *and* the CLI render engine/vendor integration, with a real (if bounded) geometry/empirical-verification risk on the render side. At the team's current 19-20 point/sprint velocity, 8 points is well within "plausibly fits in a single sprint" per the Definition of Ready (it's the same size as two stories already delivered start-to-finish within one sprint each) — not recommending a split.
**Dependencies:** Depends on "Edit selected text properties in a properties panel" (Done) and "Place and move text elements on the canvas" (Done). **Dependencies:** Depends on "Edit selected text properties in a properties panel" (Done) and "Place and move text elements on the canvas" (Done).


### Rotate elements by dragging a handle on the canvas - Status: Ready
### Rotate elements by dragging a handle on the canvas - Status: Done
**Sprint Review verification (Sprint 4, 2026-09-29):** All 4 acceptance criteria met. AC1/AC2 (handle shown, drag updates angle live): `CanvasElementEditor.HandlePosition`/`HitTestHandle`/`RotateDragTo` add a distinct rotate-drag mode alongside existing move-drag, using `atan2` of the pointer relative to the bounding-box center. AC3 (panel and handle stay synced both directions): correctly reused the previous story's existing bidirectional panel/canvas wiring rather than building new plumbing, exactly as the story's sizing note anticipated — real reuse, not just a claim of reuse. AC4 (drag persists exactly like a typed value): live-verified with real Win32 mouse-drag input injection (`SetCursorPos`/`mouse_event`) against the actual built `.exe`, confirming the on-disk saved XML's `angle` attribute matched the dragged value to full floating-point precision, not a rounded display value — a meaningfully stronger check than confirming the displayed number alone. One honestly-reported testing-tooling hiccup (a `SetForegroundWindow` focus failure mid-verification, caught from screenshot evidence and worked around) is a tooling artifact, not a product defect, correctly separated from the acceptance criteria evidence.

**Card** **Card**
As a **print operator**, I want to rotate a text or dynamic field element by dragging a handle on the canvas, so that I can adjust its angle visually without switching focus to the properties panel. As a **print operator**, I want to rotate a text or dynamic field element by dragging a handle on the canvas, so that I can adjust its angle visually without switching focus to the properties panel.




+ 3
- 1
backlog/epics/03_csv_integration_and_field_mapping.md Просмотреть файл

@@ -64,7 +64,9 @@ As a **print operator**, I want to reassign an existing dynamic field to a diffe
**Estimate:** 2 points **Estimate:** 2 points
**Dependencies:** Depends on dynamic text token creation **Dependencies:** Depends on dynamic text token creation


### Collapse blank optional address lines consistently - Status: Ready
### Collapse blank optional address lines consistently - Status: Done
**Sprint Review verification (Sprint 4, 2026-09-29):** All 4 acceptance criteria met with real evidence, no gaps. AC1/AC2 (blank collapses and shifts, populated preserves spacing): the new `AddressLineCollapser` row-height-shift math is unit tested for both cases plus multiple consecutive blanks, and live-verified against the real CLI/Debenu DLL — a template binding "Address 2/3/4" as collapsible, rendered against the real sample CSV (Address 3/4 blank, Address 2 populated for every sampled record), produced a rasterized page with exactly 3 tightly and evenly spaced lines and no gap where the two blank lines would have been. AC3 (preview/render parity): the same collapse math now drives both the CLI's `RenderEngine.BuildDraws` and a new `AddressBlockPreviewCalculator` feeding the desktop canvas via `SetCsvPreviewContext` — one algorithm, two call sites, not two independently-maintained implementations that could drift. AC4 (distinguish intentionally blank from a mapping error) was this story's one open design decision, and the resolution is sound: a `column` that isn't a real CSV header stays a hard, whole-run failure exactly as before (never silently collapses), while the canvas now flags a dynamic element bound to a column absent from the *currently loaded* CSV with a distinct dashed orange/red highlight — a collapsible field that's merely blank in real data gets no warning at all, which is the correct distinction (blank data is not a mapping error). This closes out the CSV Integration and Field Mapping epic outright (4 of 4 stories Done).

**Card** **Card**
As a **print operator**, I want blank lines like `Address2` to collapse automatically, so that envelopes do not show awkward gaps. As a **print operator**, I want blank lines like `Address2` to collapse automatically, so that envelopes do not show awkward gaps.




+ 54
- 0
backlog/epics/08_composite_address_controls.md Просмотреть файл

@@ -0,0 +1,54 @@
# Epic: Composite Address Controls and Mixed-Content Text

**Vision / why this matters:** Today every text element is one line, and each line is either pure static text or a single bound CSV column. Real address blocks and salutation lines need to combine literal text with one or more fields on the same line (e.g., "Attn: {First Name} {Last Name}"), and operators currently have to place, align, and manage every address line by hand with no notion that they belong together. This epic adds that composition, then lets operators place a whole address block as one manageable unit.

## Stories

### Mix static text and CSV fields within a single text element - Status: Not Started
**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.

**Conversation notes**
- Confirmed with the user (2026-09-29): this is a **general** upgrade, not scoped only to the Address Control below — every text element in the designer gains this capability, replacing today's mutually-exclusive `StaticText`/`ColumnName` model (`TextElementLayout`, `TemplateElement`) everywhere.
- Content becomes an ordered sequence of "runs": literal text runs and field-token runs (each bound to a CSV column), concatenated per record at render time.
- Backward compatibility is non-negotiable: every existing saved template (pure static text, or a single bound column) must continue to render byte-for-byte identically — model these as the one-run special case of the new run-sequence, not a separate parallel code path that could drift from it.
- A field token bound to a column that isn't a real CSV header remains a hard, whole-run failure before any page renders — the same blocking rule as today's single-column binding (`TEMPLATE_FORMAT.md`'s "Known gaps"), just evaluated per token instead of per element.
- The properties panel needs a real editing affordance for composing mixed content — not a plain text box where a field token is just a raw substring an operator could accidentally mistype or break. Exact mechanism (e.g., an "Insert field" action that drops in a protected token chip at the cursor, or a `{Column Name}` bracket-typing convention that's parsed back into runs on commit) is a Development Team design decision; document whichever is chosen.
- Canvas display should visually distinguish the field-token portions from literal text within the same element, extending today's highlight-fill treatment for pure dynamic elements so it now applies per-run rather than per-element.
- Persisted XML shape (e.g., nested run/field child elements, or an inline token syntax within the element's own text content) is a Development Team decision, to be documented in `TEMPLATE_FORMAT.md` — but it must remain backward-compatible with every template saved before this story.
- Out of scope: any formatting/transformation applied to a token's value (date formatting, casing, truncation) — verbatim column value substitution only, matching today's single-column binding behavior exactly.

**Confirmation (Acceptance Criteria)**
- [ ] A text element can contain any combination of literal text runs and CSV-field-bound runs, rendered concatenated per record.
- [ ] Existing templates using only static text or only a single bound column render identically to before this story (a real regression check against previously-saved templates, not just new-template testing).
- [ ] The properties panel lets an operator compose and edit mixed content without accidentally corrupting a field token.
- [ ] The canvas visually distinguishes field-token portions from literal text within the same element.
- [ ] 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)
**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
**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.

**Conversation notes**
- Confirmed with the user (2026-09-29): the operator builds a **fully custom line list** — add, remove, and reorder any number of lines — not a fixed starter template of pre-named lines.
- Confirmed with the user: the whole control moves and resizes via a **single anchor point**; line spacing is automatic. Exact spacing rule (derived from each line's own font size/leading, or an explicit "line spacing" value stored on the control) is a Development Team design decision, documented alongside the persisted format.
- Confirmed with the user: "Collapse if blank" (already shipped, Sprint 4) stays a per-line checkbox exactly as it works today, but **defaults to checked/on** for a line added inside an Address Control — an explicit change from a standalone element's default-off. Existing standalone elements are unaffected.
- `AddressLineCollapser`'s current grouping rule (lines sharing the same X position stack together) is an implicit heuristic from before this story existed. Once lines live inside one named control, the team should decide whether the collapse-stack grouping should key off "belongs to the same control" instead of (or in addition to) shared X — flagged for the team to resolve during implementation, not decided here, since it affects whether two visually-adjacent-but-unrelated elements could accidentally group.
- Persisted format: a new container element (e.g. `<addressControl>`) wrapping its child lines — exact shape is a Development Team decision, documented in `TEMPLATE_FORMAT.md`, but must round-trip through save/reopen and remain independently renderable by the CLI without the desktop app, same as every other template element.
- Rotating the control as a whole group (interacting with Sprint 4's rotation feature) is explicitly **out of scope** for this story — note as a candidate follow-up, not required here.
- Selecting the control on canvas must still allow editing an individual line's content/binding (via the previous story's editing UI) without breaking the group's single-anchor movement behavior.

**Confirmation (Acceptance Criteria)**
- [ ] An operator can create an Address Control on the canvas and add, remove, and reorder any number of lines within it.
- [ ] Each line supports the same static/field/mixed content as a standalone text element (previous story).
- [ ] The control moves and resizes on the canvas as a single unit via one anchor point, with line spacing computed automatically.
- [ ] Each line's "Collapse if blank" defaults to on within a control, remains individually toggleable, and the resulting collapse/shift behavior matches the existing per-line collapse rule (Sprint 4).
- [ ] 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)
**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).

+ 67
- 0
backlog/sprints/sprint-4-retrospective.md Просмотреть файл

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

**Sprint:** 4
**Date:** 2026-09-29
**Facilitated by:** `scrum-master`, per `process/05_sprint_retrospective.md`
**Inputs used:** `backlog/sprints/sprint-4.md` (Daily Scrum Log + Execution Order), `backlog/backlog.md` (Sprint 4 Review outcome), `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/epics/03_csv_integration_and_field_mapping.md` (Sprint Review verification notes), `logs/technical_debt_log.md`, `logs/impediment_log.md` (both untouched this sprint — confirmed via diff, not assumed), `logs/process_improvement_log.md` (Sprint 3's "Watching" entry), `backlog/sprints/sprint-3-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:**
- 3/3 committed stories Done (18/18 points), sprint goal assessed "met in full" at Sprint Review (`backlog/backlog.md`, Sprint 4 Review outcome).
- Test suite grew from 223 (Sprint 3 close) to 288/288 — 65 new tests across desktop and CLI projects.
- Zero new impediments and zero new technical debt logged this sprint (confirmed by diff, not just absence of mention) — notable given the sprint's work (rotation geometry, bounding-box math, vendor sign-convention discovery) was inherently more error-prone than an average sprint's.
- Two epics closed outright this sprint: CSV Integration and Field Mapping (4 of 4 stories) and Template Designer GUI Foundation (6 of 6, having been reopened specifically for the rotation work).
- The Sprint 3 retrospective's one "Watching" process item — product-owner's Sprint 3 review leaning on dev-team's pre-written verification notes for 3 of 4 stories — did not recur. This sprint, `dev-team` was explicitly instructed not to touch epic "Status:"/verification content, and all three stories' Sprint Review verification notes were authored fresh by product-owner at review time, independently assessing evidence rather than inheriting dev-team's framing (see "Follow-up" below).

**Subjective (from dev-team's daily-scrum notes and product-owner's independent review):**
- Empirical-verification-before-coding discipline showed up a third distinct time this project: before writing any rotation render code, dev-team probed the real Debenu DLL to confirm the rotation-direction sign convention (positive angle is counterclockwise in PDF space) rather than assuming it — the same discipline as Sprint 3's throughput-hypothesis probe and CSV-token-format spec check, now demonstrated on a third, unrelated kind of question.
- Sizing accuracy showed a concrete positive signal: Batch 3's sizing note (written during backlog refinement, before implementation) predicted it would reuse Batch 2's panel/canvas sync plumbing with no new wiring needed — and the actual implementation confirmed exactly that. This is evidence the "code-inspection-backed sizing" practice introduced in Sprint 3 is predictively accurate, not just a retrospectively-justified estimate.
- Honest self-reporting continued with good judgment, not just volume: a testing-tooling focus glitch (`SetForegroundWindow` failing once during Batch 3's live GUI verification, briefly misdirecting some keystrokes) was caught from screenshot evidence, worked around, and reported plainly — but correctly *not* logged as technical debt, since it's a verification-tooling artifact, not a product defect. Distinguishing "worth reporting" from "worth logging as debt" is a sharper judgment call than Sprint 2-3's debt entries required.
- "Leave it better than you found it" continued inline: a `TemplateDesignerForm.MinimumSize` GUI bug (too small for the two new rotation panel rows, clipping them) was caught live during verification and fixed same-batch, not deferred.
- Dependency-ordered batch swarming held for a fourth consecutive sprint without deviation (Batch 1 and 2 independent and pulled by priority, Batch 3 correctly gated on Batch 2).

## What went well
- **Full follow-through on all four Sprint 3 retrospective action items**, including a clean, structural resolution of the one process watch item (see "Follow-up" below) — the fourth clean full-follow-through sprint in a row counting the watch item's resolution as a follow-through, not just a repeat of prior actions.
- Two full epics (Template Designer GUI Foundation, CSV Integration and Field Mapping) are now genuinely Done outright, each verified against every one of their stories' acceptance criteria, not just "the last story landed."
- The empirical-verification habit named as a strength in Sprint 3 wasn't a one-off — it showed up again on a materially different kind of question (a rendering geometry/sign-convention question, not a performance or format question), suggesting it's becoming a durable team habit rather than a lesson tied to one specific investigation.
- Zero new technical debt on a sprint with real geometric/rendering complexity is a meaningfully stronger signal than zero debt on a low-risk sprint would be — and the one rough edge that did surface (the tooling focus glitch) was triaged correctly rather than either buried or over-logged.

## What didn't go well
- Nothing rises to a real finding this sprint. The closest candidate — the `SetForegroundWindow` testing-tooling glitch — was self-caught, self-corrected within the same verification pass, and correctly kept out of the technical debt log since it never reflected product behavior. Naming it here for completeness rather than manufacturing a larger problem where the evidence doesn't support one.

## Patterns / Insights (prioritized)
1. **(Process-level)** The Sprint 3-named PO-review-independence watch item is resolved, not just "not recurred by luck" — this sprint structurally separated dev-team's implementation evidence from product-owner's own acceptance-criteria authorship (dev-team was told not to write epic verification notes; product-owner wrote all three independently). Close the watch item in `logs/process_improvement_log.md` rather than carrying it forward as an open monitor.
2. **(Team-level)** Empirical-verification-before-coding is now demonstrated across three distinct, unrelated kinds of questions (throughput root cause, template token format, rotation sign convention) — stable enough to treat as a default team habit, not something that needs re-earning each sprint.
3. **(Team-level, new evidence)** Code-inspection-backed sizing (introduced Sprint 3) produced a sizing note whose specific technical claim (plumbing reuse) was later confirmed exactly by the real implementation — a concrete data point that this practice is working as intended, worth keeping as the default sizing approach for render/canvas-touching stories going forward.
4. **(Product-level, not a process/kit matter)** With epics 02 and 03 both fully Done, this is a natural moment for `release-planner`-level thinking about what a first releasable milestone looks like — routed as an observation for whenever the user wants that conversation, not a retro action here.

## Kit-level decision
No new kit edit proposed. The one open item from `logs/process_improvement_log.md` (PO-review independence, logged 2026-09-22) is marked **Resolved** below — it never needed a formal process-file change; it resolved through how work was delegated this sprint (product-owner writing review notes independently, dev-team leaving epic status untouched), which is exactly the "watch and see whether the natural behavior holds" outcome that entry's own resolution note anticipated. See `logs/process_improvement_log.md` for the updated entry.

## Action Items (added to Sprint 5's plan)
- [ ] Continue empirical vendor/spec verification before implementing render- or format-affecting code — no process change, a standing expectation now demonstrated three times — owner: dev-team — due: ongoing.
- [ ] Continue live actual-built-artifact verification for every GUI-facing story — owner: dev-team — due: ongoing.
- [ ] When `backlog/epics/08_composite_address_controls.md` is next refined and sized, apply the same code-inspection-backed sizing rigor used for the throughput and rotation stories, especially to "Mix static text and CSV fields within a single text element" — flagged in that epic itself as the biggest render/format-compatibility risk in the whole epic — owner: dev-team (sizing), product-owner (facilitating) — due: whenever epic 08 is next refined.
- [ ] No further monitoring needed on PO-review independence — watch item resolved this sprint, closed out, not carried forward as an open action — owner: scrum-master — due: n/a (closed).

## Deferred / lower-priority ideas (kept, not discarded)
- Whether the GUI verification tooling's focus-stealing reliability (`SetForegroundWindow`) is worth hardening — revisit only if it recurs and risks masking a real defect behind a tooling artifact, not preemptively off a single self-caught instance.
- `AddressLineCollapser`'s current "lines sharing the same X position" grouping heuristic may need to key off "belongs to the same Address Control" once epic 08 lands — already flagged directly in that epic's own conversation notes; not duplicating as a separate action here, just cross-referencing so it isn't lost.

## Follow-up on previous retro's actions
All four of Sprint 3's retrospective action items were carried into Sprint 4's plan and applied, confirmed via `backlog/sprints/sprint-4.md`'s Notes section and Daily Scrum Log, plus this sprint's own review process:
1. Resolve the open 1,000,000-record file-size technical debt item before any story commits to rendering near that ceiling — Correctly not touched: no Sprint 4 story rendered anywhere near that scale, so this item remains open and untouched, exactly as expected, not a drop.
2. Continue live actual-built-artifact verification for every GUI-facing story — Applied: all three stories (blank-line collapse, rotation angle, drag handle) verified against the real built `.exe`, two of them with real Win32 input injection.
3. Continue verifying real spec/code before implementing rather than trusting stale story notes — Applied: the Debenu rotation-direction sign convention was empirically confirmed against the real DLL before any render code was written, exactly matching the story's own conversation-note requirement.
4. Watch whether product-owner's Sprint 4 review notes are written independently at review time versus carried over from dev-team's own pre-written verification text — **Resolved, not just watched:** this sprint structurally enforced the independence by instructing dev-team not to touch epic "Status:"/verification content at all, and product-owner wrote all three stories' Sprint Review verification notes fresh, citing and independently assessing dev-team's evidence rather than inheriting its framing (see e.g. the rotation-angle story's note explicitly judging the sizing note's flagged risk as "genuinely resolved rather than papered over" — an independent judgment call, not a restatement).

No drops. This is the third clean full-follow-through sprint in a row (counting Sprint 3's own clean follow-through on Sprint 2's actions).

## Anti-patterns checked
- **No follow-through on prior retro actions:** Ruled out — all four Sprint 3 actions were applied or structurally resolved, not just referenced (see above).
- **Blame-focused discussion:** Ruled out. The one imperfection found (the tooling focus glitch) is described as a tooling artifact, never attributed to an individual or treated as a mistake worth dwelling on.
- **Hidden mini-waterfall within the sprint:** Ruled out. Each batch was designed, built, tested, and live-verified together in one pass, consistent with Sprints 1-3.
- **Avoiding an obvious known problem:** Ruled out — there wasn't one to avoid this sprint, and the one candidate (the tooling glitch) was surfaced and addressed immediately rather than left for someone else to find.
- **Status-theater (named in Sprint 1, improved in Sprint 2, still absent in Sprint 3):** Ruled out again. Evidence this sprint is unusually concrete even by this project's standard — a pixel-ink-centroid regression test proving the rotation pivot is correct (not just "it looked right"), and an exact full-floating-point XML value match proving drag-to-rotate persistence (not just "the number looked right in the UI").
- **Review rubber-stamping (the Sprint 3-named watch item, checked explicitly here rather than assumed away):** Ruled out with direct evidence — see "Follow-up" item 4 above.

+ 41
- 0
backlog/sprints/sprint-4.md Просмотреть файл

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

**Sprint:** 4 **Dates:** 2026-09-28 - 2026-10-02
**Sprint Goal:** 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).

## Committed Items

| Story | Size | Status | Tasks |
|---|---|---|---|
| Collapse blank optional address lines consistently | 5 points | Done | - [x] Design and implement a per-line "collapsible if blank" configuration flag on address-block text elements (model + XML persistence), defaulting existing templates to today's non-collapsing behavior <br> - [x] Implement the shift-upward layout rule in the render engine so subsequent configured lines move up when a bound field is blank for that record <br> - [x] Apply the same collapsing math to the desktop canvas preview so preview and final PDF agree <br> - [x] Give operators a way to visually distinguish "intentionally blank per data" from "field failed to map" <br> - [x] Unit tests: single blank line collapses and shifts correctly, populated line preserves normal spacing, multiple consecutive blank lines, preview/render parity <br> - [x] Live manual verification against the built `.exe` and a real CLI render: one real record with a blank optional line, one with it populated |
| Set a rotation angle for text and dynamic field elements | 8 points | Done | - [x] Add `RotationAngle` (degrees) to `TextElementLayout`, applying uniformly to static and dynamic elements <br> - [x] Add a numeric angle field to `TextElementPropertiesEditor`/`TemplateDesignerForm`'s properties panel with validation <br> - [x] Update `TemplateCanvasControl.DrawElement` to rotate the drawn element about its bounding-box center and update `CanvasElementEditor.HitTest` for point-in-rotated-rectangle selection <br> - [x] Persist `angle` as a new `TemplateLayoutXmlSerializer` attribute, defaulting to `0` for templates saved before this story <br> - [x] Extend the CLI's render-time template format (`TextDraw`, `TemplateXmlParser`, `RenderEngine`) to read the new `angle` attribute <br> - [x] Implement rotate-about-bounding-box-center rendering in `DebenuPdfRenderer` using `GetTextWidth`/`GetTextAscent`/`GetTextDescent` to compute the anchor offset, then call `DrawRotatedText`; empirically confirm Debenu's rotation-direction sign convention against the real DLL and document it in `TEMPLATE_FORMAT.md` <br> - [x] Unit tests: the property itself, persistence round-trip (including old templates with no `angle` attribute), and the anchor-offset rotation math <br> - [x] Live manual verification against the built `.exe` and a real CLI render: a non-zero angle rotates the canvas and the rendered PDF the same way, around the same visual center |
| Rotate elements by dragging a handle on the canvas | 5 points | Done | - [x] Add a rotate-handle hit-test region to `CanvasElementEditor` for a selected element, positioned relative to its rotated bounding box <br> - [x] Implement drag-to-rotate: compute the angle via `atan2` of the pointer position relative to the bounding-box center as the operator drags <br> - [x] Wire the drag result into the existing bidirectional properties-panel/canvas sync so the numeric angle field and the handle update each other live <br> - [x] Add handle-glyph painting to `TemplateCanvasControl` <br> - [x] Unit tests: handle hit-test, drag-to-angle math, panel/canvas sync in both directions <br> - [x] Live manual verification against the built `.exe`: drag the handle, confirm canvas and properties-panel angle stay in sync, save and confirm the resulting angle persists exactly as a typed value would |

## Notes
- Capacity signal: velocity range refined to **18-20 points**, now off three data points (Sprint 1: 20 completed, Sprint 2: 19 completed, Sprint 3: 18 committed and completed exactly, no scope change) — a tighter, more trustworthy range than Sprint 3 planning had.
- Committing 18 points, deliberately at the low end of the range rather than the 20-point ceiling, per `AGENTS.md`'s guidance to reserve roughly 10% of capacity for grooming rather than packing to the exact top. This mirrors Sprint 3's own committed (and fully completed) total.
- Deliberately not committed this sprint (capacity discipline): "Harden production configuration delivery for CLI runtime settings" (2 points) — kept as an explicit stretch item, consistent with product-owner's standing note that it isn't urgent (see `backlog/epics/05_cli_rendering_engine_and_debenu_integration.md`). Pull it only if the three committed stories finish with room to spare.
- Planning rationale: "Collapse blank optional address lines consistently" completes the CSV Integration and Field Mapping epic outright (its only remaining story) and has no dependency on this sprint's other work, so it's pulled first. The two rotation stories are committed together as one dependency-ordered chain (angle property before drag handle) — the same pattern Sprint 3 used successfully for its CSV chain (headers -> token -> rebind, 10 points across 3 dependent stories in one sprint).
- Sizing: both rotation stories were sized during backlog refinement (2026-09-22) with real code/vendor-API inspection (see `backlog/epics/02_template_designer_gui_foundation.md`'s sizing notes) — the vendor Debenu API has no "rotate about center" primitive, so the center-pivot math in story 1 is genuine new work, not a pass-through parameter. Both pass the Definition of Ready.
- 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 3 completed all 4 committed stories).
- Sequencing risk: "Rotate elements by dragging a handle on the canvas" depends on "Set a rotation angle for text and dynamic field elements" landing first, both committed in this same sprint — swarm to finish the angle-property story before starting the drag-handle story's implementation, mirroring how Sprint 3 sequenced its CSV dependency chain.
- Sprint 4 retrospective action items to apply during this sprint (from `backlog/sprints/sprint-3-retrospective.md`): continue live actual-built-artifact verification for GUI-facing stories; continue verifying real spec/code (not stale notes) before implementing; before "Collapse blank optional address lines" is considered fully done, note it has no dependency on the still-open 1,000,000-record file-size technical debt item, so that item remains untouched this sprint, as expected.

## Execution Order

Sequenced by dependency; the two independent stories are pulled before the dependent drag-handle story.

| Batch | Story | Why it's gated here |
|---|---|---|
| 1 | Collapse blank optional address lines consistently | No dependency on any other Sprint 4 story (its only dependency, "Create a dynamic text token," is already Done). Pulled first, completing epic 3 outright. |
| 2 | Set a rotation angle for text and dynamic field elements | No dependency on any other Sprint 4 story. Pulled second so its angle property exists before Batch 3 needs it. |
| 3 | Rotate elements by dragging a handle on the canvas | Needs the `RotationAngle` property, persistence, and render/pivot logic from Batch 2 done first. |

## Daily Scrum Log

| Day | Date | Completed | Planned | Blocked/At risk |
|---|---|---|---|---|
| 1 | 2026-09-28 | **Batch 1** ("Collapse blank optional address lines consistently", 5 points) done, all 4 ACs met. Added a shared, independently-implemented-twice `AddressLineCollapser` pure-geometry class (`EnvelopeRenderer.Cli.Render` and `EnvelopeRenderer.Desktop.Core.Design` — deliberately not shared via project reference, mirroring `TemplateLayoutXmlSerializer`'s existing precedent for keeping the desktop project free of CLI-internals references) implementing the row-height shift math: lines sharing the same X position (rounded to 2 decimals) form a top-to-bottom stack, and a collapsed line's own row height (gap to the line below it, using original undisturbed Y values) shifts every subsequent line up. Added `Collapsible`/`CollapseIfBlank` to `TemplateElement`/`TextElementLayout` and a new `collapsible="true"` XML attribute (`TemplateXmlParser`, `TemplateLayoutXmlSerializer`), both defaulting to `false`/absent so every pre-existing template is unaffected. `RenderEngine.BuildDraws` now resolves each record's text, decides per-line collapse via `AddressLineCollapser`, and omits collapsed lines' draws entirely. For "distinguish intentionally blank from a mapping error" (the story's 4th AC and the one open design decision it left to the team): a `column` that isn't a real CSV header remains a hard, whole-run failure exactly as before (never silently collapses regardless of `collapsible`) — the design decision made was to surface that same distinction visually in the desktop canvas via a new `AddressBlockPreviewCalculator` (framework-free, unit tested) that flags a dynamic element bound to a column absent from the *currently loaded* CSV with a dashed orange/red highlight, distinct from the ordinary blue dynamic-field fill; a collapsible field that's merely blank in the loaded sample data gets no warning at all, just silently doesn't render — exactly the intended distinction. `TemplateCanvasControl` now runs the identical collapse-preview math (via `SetCsvPreviewContext`, fed the loaded CSV's headers and first sample row from `TemplateDesignerForm`) so preview and final PDF agree, per the story's own conversation note. 41 new unit tests across both test projects (`AddressLineCollapserTests` x2, `AddressBlockPreviewCalculatorTests`, plus `TemplateXmlParser`/`TemplateLayoutXmlSerializer`/`RenderEngine` collapsible-attribute coverage). **Live-verified against the real CLI and the real Debenu DLL**: built a template binding "Address 2/3/4" as collapsible against the real sample CSV (`87700 - 999999 - Wilson Township.csv`, where Address 3/4 are blank for every sampled record and Address 2 is populated) — `PROGRESS complete ... completed=392`, exit 0; rasterized page 1 via Debenu's own `RenderPageToFile` and visually confirmed exactly 3 tightly, evenly spaced address lines (Full Name / Address 1 / Address 2) with no gap where the two blank collapsed lines would have been, and `GetPageText` confirmed no stray blank-line artifacts. Desktop-side: added a static element, toggled the new "Collapse if blank" checkbox, confirmed a small green marker badge appears beneath it on canvas (screenshot captured), and confirmed a hand-authored template with one column bound to a real CSV header and one to a nonexistent header shows the intended visual contrast (plain vs. dashed-orange) once the real sample CSV is loaded (screenshot captured). | Batch 2 ("Set a rotation angle..."), pulled next per the Execution Order table (independent of Batch 1). | None. |
| 1-2 | 2026-09-28 to 2026-09-29 | **Batch 2** ("Set a rotation angle for text and dynamic field elements", 8 points) done, all 4 ACs met. Before writing any rendering code, empirically confirmed two real vendor-DLL behaviors rather than assuming them (per this project's standing practice, same as the Sprint 3 throughput investigation): (1) a probe render (unrotated baseline vs. `DrawRotatedText` at Angle=90, both rasterized via Debenu's own `RenderPageToFile` and pixel-inspected) showed positive `Angle` is **counterclockwise** in PDF's Y-up coordinate system — the standard math rotation convention, no sign flip needed against PDF space; (2) `GetTextDescent()` returns an already-**negative** value (e.g. -5.088 for 24pt Arial), i.e. a glyph's local vertical extent is `[descent, ascent]` directly. Also discovered, only once real `DrawRotatedText` calls were exercised: it flatly rejects a negative `Angle` (returns 0, no error code) despite rotation being periodic — `DebenuPdfRenderer` now normalizes to Debenu's expected `[0, 360)` range for that specific call only, while the anchor-offset math itself correctly uses the original signed angle. Added `RotatedTextAnchorCalculator` (pure geometry, `EnvelopeRenderer.Cli.Render`): solves for the anchor point `DrawRotatedText` needs so its rotation lands on the *unrotated* bounding-box center rather than the (x, y) anchor Debenu itself rotates around, using `GetTextWidth`/`GetTextAscent`/`GetTextDescent`. `angle=0` (including when the attribute is absent) takes the exact original `DrawText` code path — byte-for-byte unchanged for every pre-Sprint-4 template. Added `RotationAngle` to `TextElementLayout`/`TemplateElement`/`TextDraw`, a numeric "Angle (deg):" field to the properties panel (`TextElementPropertiesEditor.SetRotationAngle`, free-form, no clamping since any real value is valid), a new `angle` XML attribute (`TemplateLayoutXmlSerializer`, `TemplateXmlParser`), and rotated-bounding-box hit-testing in `CanvasElementEditor.HitTest` (rotates the click point into the element's local frame by the inverse angle) so a rotated element's actual (not axis-aligned) footprint is what's clickable. `TemplateCanvasControl.DrawElement` rotates the drawn glyph and its selection outline via GDI+'s `Graphics.RotateTransform` around the bounding-box center — negating the stored angle, since GDI+'s Y-down pixel space makes a positive `RotateTransform` angle appear *clockwise* as viewed, the opposite of the PDF-space convention just confirmed; this negation is what keeps the canvas and the final PDF rotating the same visual direction. 29 new unit tests (`RotatedTextAnchorCalculatorTests` — including an angle-agnostic property test asserting the bounding-box center is preserved at 30/90/180/-45 degrees — plus rotated-hit-test and property/persistence coverage). **Live-verified against the real Debenu DLL with a durable regression test, not just a one-off probe**: `DebenuPdfRendererRotationTests` renders real text at five different angles (0/30/90/180/-45) through the real `DebenuPdfRenderer`, rasterizes each page, and asserts via actual pixel-ink-centroid analysis that the centroid stays within a tight, generous-but-meaningful tolerance of the angle-0 centroid — the concrete, observable meaning of "rotates around the bounding-box center," which would fail by 90-190px without the anchor-offset correction (rotating around the plain X/Y anchor instead). Also live-verified end-to-end against the real CLI/sample CSV: a 45-degree rotated "ROTATED" static label rendered visibly tilted counterclockwise in the actual output PDF (rasterized and visually inspected). Desktop-side, live against the actual built `.exe`: added a static element, typed `60` into the Angle field, confirmed the canvas glyph and its rotate-handle marker visibly rotated counterclockwise (screenshot captured); saved the template and confirmed the on-disk XML's `angle` attribute matched exactly. | Batch 3 ("Rotate elements by dragging a handle..."), which depends on this batch. | None. |
| 2 | 2026-09-29 | **Batch 3** ("Rotate elements by dragging a handle on the canvas", 5 points) done, all 4 ACs met, swarmed immediately after Batch 2 landed per the Execution Order table's dependency note. Added a rotate-handle to `CanvasElementEditor`: `HandlePosition()` computes a world-space point `HandleOffsetPoints` (20pt, a Development Team design choice) beyond the rotated bounding box's top edge, rotating *with* the element (using the same forward-rotation convention `HitTest`'s inverse undoes, so hit-testing and painting always agree); `HitTestHandle` grabs it within an 8pt radius. `BeginRotateDrag`/`RotateDragTo`/`IsRotating` add a second drag mode alongside the existing move-drag (mutually exclusive — `BeginRotateDrag` clears any pending move offset): `RotateDragTo` computes the new angle via `atan2` of the pointer relative to the bounding-box center, adjusted by -90 degrees since the handle's own zero-rotation reference direction is "up" (+Y), not the atan2 convention's +X. `TemplateCanvasControl` paints the handle as a small filled circle connected to the element's center by a dotted line, and its `OnMouseDown`/`OnMouseMove` check the handle hit-region first (only when something is already selected) before falling through to the existing select/move logic. Bidirectional panel/canvas sync required no new plumbing at all — it reused Batch 2's own `RotationAngle` panel wiring and the pre-existing `ElementsChanged`-triggers-`RefreshPropertiesPanel()` pattern `TemplateDesignerForm` already had for X/Y, exactly as the story's sizing note anticipated. 20 new unit tests (rotated hit-test edge cases, handle position/hit-test at several angles, `atan2` drag-to-angle math including cardinal-direction sanity checks, mutual-exclusivity with move-drag). **Live-verified against the actual built `.exe` with real Win32 mouse-drag input injection** (`SetCursorPos`/`mouse_event`, not an in-process harness): computed the handle's exact expected screen position externally (replicating `CanvasElementEditor`'s own geometry plus a pixel-calibrated canvas transform derived from the real rendered page's on-screen bounds), dragged from that position to a point straight above the element's center, and confirmed via UI Automation that the properties panel's Angle field live-updated to -1.5 degrees (expected ~0, within pixel-rounding tolerance) — both the visual rotation (screenshot captured, text visibly returned to level) and the numeric field updated together, live, during the drag. Saved the template afterward and confirmed the on-disk XML's `angle="-1.5464733298407793"` matched the dragged value exactly (not the rounded 1-decimal display), satisfying "releasing the drag persists the resulting angle the same way a typed value would." One incidental, honestly-reported note (not a product defect): mid-session, an `SetForegroundWindow` call from the verification tooling itself failed to bring the designer window forward as expected, and a handful of keystrokes intended for a CSV-column combo box briefly landed in the development environment instead of the app under test — caught immediately from the screenshot evidence, worked around by switching remaining interactions to direct UI-Automation control invocation instead of `SendKeys`, and does not reflect any behavior of the product under test. With this batch done, all three of Sprint 4's committed stories are complete: 288/288 tests passing (up from 223 at Sprint 3 close). | Sprint's committed backlog is now fully done (18/18 points across all 3 committed stories). Next: sprint review and retrospective per `process/03_sprint_execution.md`'s exit criteria. | None. |

+ 40
- 0
code/TEMPLATE_FORMAT.md Просмотреть файл

@@ -7,6 +7,8 @@ Implementation: [`src/EnvelopeRenderer.Cli/Render`](src/EnvelopeRenderer.Cli/Ren
<envelopeTemplate pageWidth="297" pageHeight="684"> <envelopeTemplate pageWidth="297" pageHeight="684">
<text x="120" y="240" font="Arial" size="12">Static label:</text> <text x="120" y="240" font="Arial" size="12">Static label:</text>
<text x="120" y="225" font="Arial [Bold]" size="12" column="Full Name" /> <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>
</envelopeTemplate> </envelopeTemplate>
``` ```


@@ -41,6 +43,44 @@ One page is rendered per CSV record, in the order the CSV rows appear, each carr
A `column` that isn't a real CSV header fails the whole run before any page is rendered, not A `column` that isn't a real CSV header fails the whole run before any page is rendered, not
partway through a large batch. partway through a large batch.


- `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
for a given CSV record, the element is skipped entirely for that record's page, and every
*other* `<text>` element that shares its `x` position (rounded to 2 decimal places) — a
"vertical stack," the closest thing this format has to an address block — shifts upward to
close the gap that line would have left. A non-collapsible blank field still renders nothing
visible at its fixed position, exactly as before this story (no change for templates that don't
opt in). See [`src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs`](src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs)
for the exact shift math (row height = the gap between a line and the one below it, using
original, not already-shifted, positions) and its design-decision rationale (X-position grouping
in lieu of an explicit block concept). The desktop designer's canvas previews the identical
math (`EnvelopeRenderer.Desktop.Core.Design.AddressLineCollapser`) against a loaded CSV's first
sample row, so what an operator sees while designing matches what the CLI renders.
- **Distinguishing "intentionally blank" from "mapping error":** a `column` that doesn't exist
in the CSV's header row is *always* a hard failure for the whole run (see above) — it never
silently collapses, regardless of `collapsible`. Only a column that *does* exist, but whose
value is blank for a specific record, is eligible to collapse. The desktop designer surfaces
this distinction visually: a dynamic field bound to a column that isn't among the currently
loaded CSV's headers gets a dashed orange/red highlight (a real mapping problem), while a
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
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.

## Known gaps ## Known gaps


- **Font resolution failure is blocking**, not a warning — matches the Definition of Done's - **Font resolution failure is blocking**, not a warning — matches the Definition of Done's


+ 141
- 0
code/src/EnvelopeRenderer.Cli.Tests/AddressLineCollapserTests.cs Просмотреть файл

@@ -0,0 +1,141 @@
using EnvelopeRenderer.Cli.Render;

namespace EnvelopeRenderer.Cli.Tests;

public class AddressLineCollapserTests
{
[Fact]
public void Resolve_NoLineCollapses_EveryLineVisibleAtOriginalY()
{
var lines = new[]
{
new AddressLineCollapser.Line(X: 100, Y: 300, ShouldCollapse: false),
new AddressLineCollapser.Line(X: 100, Y: 285, ShouldCollapse: false),
new AddressLineCollapser.Line(X: 100, Y: 270, ShouldCollapse: false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.All(resolved, r => Assert.True(r.Visible));
Assert.Equal(300, resolved[0].EffectiveY, precision: 6);
Assert.Equal(285, resolved[1].EffectiveY, precision: 6);
Assert.Equal(270, resolved[2].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_SingleBlankLine_SubsequentLinesShiftUpByItsRowHeight()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false), // Line 1 (name) - always populated
new AddressLineCollapser.Line(100, 285, true), // Line 2 (address2) - blank this record
new AddressLineCollapser.Line(100, 270, false), // Line 3 (city/state/zip)
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.Equal(300, resolved[0].EffectiveY, precision: 6);

Assert.False(resolved[1].Visible);

Assert.True(resolved[2].Visible);
// Row height of the collapsed line = 285 - 270 = 15, so line 3 shifts up to 270 + 15 = 285
// — exactly where line 2 used to be, closing the gap.
Assert.Equal(285, resolved[2].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_MultipleConsecutiveBlankLines_CollapseWithoutLeavingExtraGaps()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false), // Line 1
new AddressLineCollapser.Line(100, 285, true), // Line 2 - blank
new AddressLineCollapser.Line(100, 270, true), // Line 3 - blank
new AddressLineCollapser.Line(100, 255, false), // Line 4
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.False(resolved[1].Visible);
Assert.False(resolved[2].Visible);
Assert.True(resolved[3].Visible);

// Both blank lines collapse: line 4 should land exactly one normal row-height (15) below
// line 1, i.e. at 300 - 15 = 285, as if lines 2 and 3 never existed at all.
Assert.Equal(285, resolved[3].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_TopmostLineBlank_SubsequentLineMovesIntoItsPosition()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, true), // topmost line collapses
new AddressLineCollapser.Line(100, 285, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.False(resolved[0].Visible);
Assert.True(resolved[1].Visible);
Assert.Equal(300, resolved[1].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_LastLineBlank_IsANoOpSinceNothingIsBelowIt()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, true), // last line, collapses
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.Equal(300, resolved[0].EffectiveY, precision: 6);
Assert.False(resolved[1].Visible);
}

[Fact]
public void Resolve_LinesAtDifferentX_DoNotAffectEachOthersCollapsing()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false), // stack A, line 1
new AddressLineCollapser.Line(100, 285, true), // stack A, line 2 - blank
new AddressLineCollapser.Line(400, 300, false), // stack B, line 1 (different X)
new AddressLineCollapser.Line(400, 285, false), // stack B, line 2 - populated
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.False(resolved[1].Visible); // stack A's blank line still collapses...
Assert.True(resolved[3].Visible);
Assert.Equal(285, resolved[3].EffectiveY, precision: 6); // ...but stack B is untouched.
}

[Fact]
public void Resolve_NearlyEqualXWithinRoundingTolerance_StillGroupsTogether()
{
// 120.004999... rounds to 120.00 at 2 decimal places, same grouping key as an exact 120 —
// covers a template authored via the properties panel (2-decimal precision) round-tripped
// through a save/reload.
var lines = new[]
{
new AddressLineCollapser.Line(120.00, 300, false),
new AddressLineCollapser.Line(120.004, 285, true),
new AddressLineCollapser.Line(119.996, 270, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.False(resolved[1].Visible);
Assert.True(resolved[2].Visible);
Assert.Equal(285, resolved[2].EffectiveY, precision: 3);
}
}

+ 132
- 0
code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererRotationTests.cs Просмотреть файл

@@ -0,0 +1,132 @@
using System.Drawing;
using DebenuPDFLibraryDLL1013;
using EnvelopeRenderer.Cli.Render;

// This whole project only ever runs on Windows (it P/Invokes the Windows-only vendored Debenu
// DLL), so System.Drawing.Common's Windows-only GDI+ surface used here for test-only pixel
// inspection is always safe to call — silence the analyzer's platform-compatibility warning
// rather than adding an unused runtime guard.
#pragma warning disable CA1416

namespace EnvelopeRenderer.Cli.Tests;

/// <summary>
/// Empirical, real-DLL verification of Sprint 4's "Set a rotation angle..." story: renders the
/// same text at several different angles via <see cref="DebenuPdfRenderer"/> (not a fake),
/// rasterizes each resulting page with Debenu's own <c>RenderPageToFile</c>, and confirms via
/// actual pixel content that the text's ink stays centered on the same point regardless of
/// angle — the concrete, observable meaning of "rotates around the bounding-box center, not the
/// X/Y anchor." Soft-skips without a license key, matching every other real-DLL test in this
/// project (see <see cref="DebenuPdfRendererIntegrationTests"/>).
/// </summary>
public class DebenuPdfRendererRotationTests
{
private static string DllPath() => Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");

/// <summary>Renders a single page containing one text draw at the given angle and returns the
/// path to a rasterized PNG/BMP of that page.</summary>
private static string RenderAngleToImage(string dllPath, string licenseKey, double angle, string workDir, string label)
{
var created = DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError);
Assert.True(created, createError);

var pdfPath = Path.Combine(workDir, $"{label}.pdf");
using (renderer)
{
var draws = new List<TextDraw> { new(150, 150, "Arial", 28, "Hello", angle) };
Assert.True(renderer!.AddPage(300, 300, draws, out var pageError), pageError);
Assert.True(renderer.Save(pdfPath, out var saveError), saveError);
}

var verify = new PDFLibrary(dllPath);
verify.UnlockKey(licenseKey);
Assert.Equal(1, verify.LoadFromFile(pdfPath, ""));
var imagePath = Path.Combine(workDir, $"{label}.png");
Assert.Equal(1, verify.RenderPageToFile(200, 1, 0, imagePath));
verify.ReleaseLibrary();

return imagePath;
}

/// <summary>Debenu's <c>RenderPageToFile</c> writes a BMP regardless of the requested file
/// extension (confirmed empirically alongside the rotation-direction probe) — load it via
/// <see cref="System.Drawing.Image.FromFile"/>, which sniffs the real format from content.</summary>
private static (double X, double Y) InkCentroid(string imagePath)
{
using var bitmap = new Bitmap(imagePath);
double sumX = 0, sumY = 0;
long count = 0;

for (var y = 0; y < bitmap.Height; y++)
{
for (var x = 0; x < bitmap.Width; x++)
{
var pixel = bitmap.GetPixel(x, y);
// "Dark" pixel = ink. Anti-aliased glyph edges are gray; a mid-gray threshold
// captures the glyph body without being thrown off by a handful of near-white
// anti-aliasing fringe pixels.
if (pixel.R < 128 && pixel.G < 128 && pixel.B < 128)
{
sumX += x;
sumY += y;
count++;
}
}
}

Assert.True(count > 0, $"No ink pixels found in '{imagePath}'.");
return (sumX / count, sumY / count);
}

[Fact]
public void AddPage_TextAtDifferentAngles_InkCentroidStaysNearTheSameBoundingBoxCenter()
{
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(), $"rotation-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);

try
{
var angles = new[] { 0.0, 30.0, 90.0, 180.0, -45.0 };
var centroids = angles
.Select((angle, i) => InkCentroid(RenderAngleToImage(dllPath, licenseKey, angle, workDir, $"angle-{i}")))
.ToList();

var reference = centroids[0];
for (var i = 1; i < centroids.Count; i++)
{
// "Hello" at 28pt renders roughly 65-70pt wide; at 200 DPI that's ~180-195px. A
// 25px tolerance is generous enough to absorb per-glyph ink-weight asymmetry
// (the true bbox-geometric-center and the visual ink centroid of "Hello" are not
// pixel-identical) while still being far tighter than the ~90-190px swing this
// test would show WITHOUT the bounding-box-center anchor-offset correction (i.e.
// rotating directly around the X/Y anchor, which sits at the LEFT edge of the
// text, not its center).
Assert.True(
Math.Abs(centroids[i].X - reference.X) < 25 && Math.Abs(centroids[i].Y - reference.Y) < 25,
$"Angle {angles[i]}: centroid {centroids[i]} strayed too far from angle-0 centroid {reference} " +
"— rotation does not appear to be centered on the bounding box.");
}
}
finally
{
try
{
Directory.Delete(workDir, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup only, matching this project's established pattern.
}
}
}
}

+ 5
- 0
code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj Просмотреть файл

@@ -12,6 +12,11 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- Test-only: decodes the BMP/PNG pages DebenuPdfRendererRotationTests renders via the real
DLL's RenderPageToFile so the empirically-confirmed rotate-about-bounding-box-center
behavior can be asserted against actual pixels, not just the pure anchor-offset math.
Not referenced by any shipped project — CLI-observable behavior is unaffected. -->
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
</ItemGroup> </ItemGroup>


<ItemGroup> <ItemGroup>


+ 110
- 0
code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs Просмотреть файл

@@ -187,4 +187,114 @@ public class RenderEngineTests


Assert.Empty(progress.RenderProgressCalls); Assert.Empty(progress.RenderProgressCalls);
} }

// Sprint 4, "Collapse blank optional address lines consistently".
private static readonly TemplateDocument CollapsibleTemplate = new(
PageWidth: 297,
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"),
});

[Fact]
public void Render_CollapsibleFieldBlank_IsOmittedAndSubsequentLineShiftsUp()
{
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["Full Name"] = "Static ignored", // unused by this template, header only
["Address2"] = "",
["CityStateZip"] = "Springfield, IL",
},
};

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

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

Assert.Equal(2, draws.Count); // the blank Address2 draw is omitted entirely
Assert.Equal("Full Name line", draws[0].Text);
Assert.Equal(300, draws[0].Y, precision: 6);

Assert.Equal("Springfield, IL", draws[1].Text);
Assert.Equal(285, draws[1].Y, precision: 6); // shifted up from 270 to 285
}

[Fact]
public void Render_CollapsibleFieldPopulated_PreservesNormalSpacing()
{
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string>
{
["Address2"] = "Apt 4B",
["CityStateZip"] = "Springfield, IL",
},
};

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

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

Assert.Equal(3, draws.Count);
Assert.Equal("Apt 4B", draws[1].Text);
Assert.Equal(285, draws[1].Y, precision: 6);
Assert.Equal("Springfield, IL", draws[2].Text);
Assert.Equal(270, draws[2].Y, precision: 6); // untouched
}

[Fact]
public void Render_NonCollapsibleBlankField_StillDrawsNothingAtItsOriginalPosition()
{
// Pre-Sprint-4 behavior for a blank field that ISN'T configured collapsible: it simply
// renders no visible text at its fixed position (DebenuPdfRenderer skips empty text) —
// 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"),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["MaybeBlank"] = "", ["AlwaysPresent"] = "Here" },
};

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

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

Assert.Equal(2, draws.Count);
Assert.Equal(string.Empty, draws[0].Text);
Assert.Equal(300, draws[0].Y, precision: 6); // not shifted — not configured collapsible
Assert.Equal("Here", draws[1].Text);
Assert.Equal(285, draws[1].Y, precision: 6);
}

[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),
});
var renderer = new FakePdfRenderer();
var records = new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string>() };

var result = RenderEngine.Render(template, Array.Empty<string>(), records, renderer, "out.pdf");

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

+ 103
- 0
code/src/EnvelopeRenderer.Cli.Tests/RotatedTextAnchorCalculatorTests.cs Просмотреть файл

@@ -0,0 +1,103 @@
using EnvelopeRenderer.Cli.Render;

namespace EnvelopeRenderer.Cli.Tests;

public class RotatedTextAnchorCalculatorTests
{
[Fact]
public void ComputeAnchor_ZeroAngle_ReturnsOriginalAnchorUnchanged()
{
var (x, y) = RotatedTextAnchorCalculator.ComputeAnchor(
x: 120, y: 240, width: 50, ascent: 10, descent: -3, angleDegrees: 0);

Assert.Equal(120, x, precision: 9);
Assert.Equal(240, y, precision: 9);
}

[Fact]
public void ComputeAnchor_180Degrees_ReflectsAnchorThroughBoundingBoxCenter()
{
// A 180-degree rotation about a fixed center C maps any point P to 2C - P. Applying that
// to the anchor itself: the corrected anchor should be exactly the bbox center reflected
// through itself minus... concretely: center = (x + width/2, y + (ascent+descent)/2);
// for a 180-degree rotation, anchor' = 2*center - (x, y).
const double x = 100;
const double y = 100;
const double width = 40;
const double ascent = 20;
const double descent = -4;

var centerX = x + (width / 2.0);
var centerY = y + ((ascent + descent) / 2.0);

var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(x, y, width, ascent, descent, 180);

Assert.Equal((2 * centerX) - x, anchorX, precision: 6);
Assert.Equal((2 * centerY) - y, anchorY, precision: 6);
}

[Fact]
public void ComputeAnchor_90Degrees_MatchesHandDerivedCounterclockwiseFormula()
{
// Confirmed empirically against the real Debenu DLL (see RotatedTextAnchorCalculator's
// class remarks): positive angle is counterclockwise in PDF's Y-up space, i.e. the
// standard math rotation matrix applied directly with no sign flip.
const double x = 0;
const double y = 0;
const double width = 10; // centerLocal = (5, 5)
const double ascent = 12;
const double descent = -2; // (ascent+descent)/2 = 5

var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(x, y, width, ascent, descent, 90);

// R(90) * (5,5) = (5*cos90 - 5*sin90, 5*sin90 + 5*cos90) = (-5, 5).
// anchor' = (x,y) + centerLocal - R(90)*centerLocal = (0,0) + (5,5) - (-5,5) = (10, 0).
Assert.Equal(10, anchorX, precision: 6);
Assert.Equal(0, anchorY, precision: 6);
}

[Fact]
public void ComputeAnchor_NegativeAngle_RotatesTheOppositeDirection()
{
var (positiveX, positiveY) = RotatedTextAnchorCalculator.ComputeAnchor(50, 50, 30, 15, -3, 40);
var (negativeX, negativeY) = RotatedTextAnchorCalculator.ComputeAnchor(50, 50, 30, 15, -3, -40);

Assert.NotEqual(positiveX, negativeX, precision: 3);
Assert.NotEqual(positiveY, negativeY, precision: 3);
}

[Theory]
[InlineData(30)]
[InlineData(90)]
[InlineData(180)]
[InlineData(-45)]
[InlineData(270)]
public void ComputeAnchor_AnyAngle_PreservesTheUnrotatedBoundingBoxCenter(double angle)
{
// The whole point of this calculator: regardless of angle, the resulting anchor, once
// Debenu rotates its local bbox center around it by `angle`, lands back on the *original*
// (unrotated) bbox center. Verify that directly by applying the same rotation formula
// DrawRotatedText uses (confirmed CCW-positive) to the anchor's own local center offset.
const double x = 77;
const double y = 33;
const double width = 64;
const double ascent = 18;
const double descent = -6;

var centerLocalX = width / 2.0;
var centerLocalY = (ascent + descent) / 2.0;
var expectedCenterX = x + centerLocalX;
var expectedCenterY = y + centerLocalY;

var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor(x, y, width, ascent, descent, angle);

var radians = angle * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
var rotatedCenterX = anchorX + ((centerLocalX * cos) - (centerLocalY * sin));
var rotatedCenterY = anchorY + ((centerLocalX * sin) + (centerLocalY * cos));

Assert.Equal(expectedCenterX, rotatedCenterX, precision: 6);
Assert.Equal(expectedCenterY, rotatedCenterY, precision: 6);
}
}

+ 62
- 0
code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs Просмотреть файл

@@ -151,6 +151,68 @@ public class TemplateXmlParserTests
Assert.Contains(result.Errors, e => e.Contains("non-numeric") && e.Contains("x")); Assert.Contains(result.Errors, e => e.Contains("non-numeric") && e.Contains("x"));
} }


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

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded);
Assert.False(result.Document!.Elements[0].Collapsible);
Assert.Equal(0, result.Document.Elements[0].Angle);
}

[Fact]
public void Parse_CollapsibleTrueAndAngle_AreReadCorrectly()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12" collapsible="true" angle="-15.5" column="Address2" />
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded);
Assert.True(result.Document!.Elements[0].Collapsible);
Assert.Equal(-15.5, result.Document.Elements[0].Angle);
}

[Fact]
public void Parse_InvalidCollapsibleValue_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="1" y="1" font="Arial" size="12" collapsible="not-a-bool">Hi</text>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("collapsible"));
}

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

var result = TemplateXmlParser.Parse(path);

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

[Fact] [Fact]
public void Parse_MalformedXml_Fails() public void Parse_MalformedXml_Fails()
{ {


+ 74
- 0
code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs Просмотреть файл

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

/// <summary>
/// Pure geometry for Sprint 4's "Collapse blank optional address lines consistently": given a
/// set of text lines (each with an X/Y position and whether it should collapse for this
/// particular record), decides which lines are visible and where the visible ones land after
/// closing the gaps left by any collapsed lines above them.
///
/// <para><b>Grouping rule (a Development Team design decision — the template format has no
/// explicit "address block" concept):</b> lines that share the same X position (rounded to 2
/// decimal places, matching the precision the designer's properties panel and XML persistence
/// already use) are treated as one vertical stack — i.e. one address block — ordered top-to-
/// bottom by descending Y (PDF points increase upward, so the top line of a block has the
/// highest Y). Lines at a different X never affect each other's collapsing math. This mirrors
/// how a real address block is authored today: every line in it shares the same left margin.</para>
///
/// <para><b>Shift math:</b> for a stack ordered top-to-bottom, line k's "row height" is defined
/// as the gap between it and the line immediately below it, i.e. <c>Y[k] - Y[k+1]</c> (using the
/// stack's original, undisturbed Y values — never an already-shifted one, so consecutive
/// collapsed lines don't compound rounding error). When line k collapses, every line below it in
/// the same stack shifts upward by that row height. The last line in a stack has no line below
/// it, so its own row height is irrelevant (nothing shifts if it collapses) — a deliberate
/// no-op, not a bug.</para>
/// </summary>
public static class AddressLineCollapser
{
/// <summary>One input line: its original template position and whether this particular
/// record's resolved value makes it collapse (i.e. the caller has already combined "is this
/// element configured as collapsible" with "is its resolved text blank for this record").</summary>
public readonly record struct Line(double X, double Y, bool ShouldCollapse);

/// <summary>One resolved line, same order/count as the input. <see cref="Visible"/> is
/// <c>false</c> exactly for lines whose own <see cref="Line.ShouldCollapse"/> was true — the
/// caller should skip drawing those. <see cref="EffectiveY"/> is the Y every line (visible or
/// not) should be treated as occupying after any collapsed lines above it in its stack have
/// closed their gaps.</summary>
public readonly record struct Resolved(bool Visible, double EffectiveY);

/// <summary>Rounding precision (decimal places) used to decide whether two lines share the
/// same X position and therefore belong to the same address-line stack. 2 decimal places
/// matches the precision already used by the designer's XML persistence and properties
/// panel, so two lines placed at "the same" X via the UI reliably group together even after a
/// save/reload round trip through floating point.</summary>
private const int GroupingPrecision = 2;

public static IReadOnlyList<Resolved> Resolve(IReadOnlyList<Line> lines)
{
var result = new Resolved[lines.Count];

var groups = lines
.Select((line, index) => (Line: line, Index: index))
.GroupBy(t => Math.Round(t.Line.X, GroupingPrecision));

foreach (var group in groups)
{
var stack = group.OrderByDescending(t => t.Line.Y).ToList();
var cumulativeShift = 0.0;

for (var i = 0; i < stack.Count; i++)
{
var (line, index) = stack[i];
result[index] = new Resolved(!line.ShouldCollapse, line.Y + cumulativeShift);

if (line.ShouldCollapse && i < stack.Count - 1)
{
var nextY = stack[i + 1].Line.Y;
cumulativeShift += line.Y - nextY;
}
}
}

return result;
}
}

+ 37
- 4
code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs Просмотреть файл

@@ -175,11 +175,44 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
return false; return false;
} }


if (_pdf.DrawText(draw.X, draw.Y, draw.Text) == 0)
if (draw.Angle == 0)
{ {
error = $"Failed to draw text with font '{draw.FontName}' at ({draw.X}, {draw.Y}) " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
// Unrotated path — byte-for-byte the same call this renderer made before Sprint
// 4's rotation story, so every pre-existing template (angle always 0) renders
// identically and pays no extra GetTextWidth/Ascent/Descent cost per draw.
if (_pdf.DrawText(draw.X, draw.Y, draw.Text) == 0)
{
error = $"Failed to draw text with font '{draw.FontName}' at ({draw.X}, {draw.Y}) " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
}
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.
var normalizedAngle = ((draw.Angle % 360) + 360) % 360;

if (_pdf.DrawRotatedText(anchorX, anchorY, normalizedAngle, draw.Text) == 0)
{
error = $"Failed to draw rotated text with font '{draw.FontName}' at " +
$"({draw.X}, {draw.Y}), angle {draw.Angle} (error code {_pdf.LastErrorCode()}).";
return false;
}
} }
} }




+ 38
- 8
code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs Просмотреть файл

@@ -35,14 +35,7 @@ public static class RenderEngine
var recordCount = 0; var recordCount = 0;
foreach (var record in records) foreach (var record in records)
{ {
var draws = template.Elements
.Select(e => new TextDraw(
e.X,
e.Y,
e.FontName,
e.Size,
e.IsDynamic ? record[e.ColumnName!] : e.StaticText!))
.ToList();
var draws = BuildDraws(template.Elements, record);


if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, out var pageError)) if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, out var pageError))
{ {
@@ -65,4 +58,41 @@ public static class RenderEngine


return RenderResult.Success(recordCount); return RenderResult.Success(recordCount);
} }

/// <summary>Resolves every element's text for this record, then runs the address-line
/// collapse math (Sprint 4: "Collapse blank optional address lines consistently") to decide
/// which lines are actually visible and at what (possibly shifted) Y — a blank line whose
/// element is not marked <see cref="TemplateElement.Collapsible"/> still draws (as empty
/// 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)
{
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!;
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);
for (var i = 0; i < elements.Count; i++)
{
if (!resolved[i].Visible)
{
continue;
}

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

return draws;
}
} }

+ 65
- 0
code/src/EnvelopeRenderer.Cli/Render/RotatedTextAnchorCalculator.cs Просмотреть файл

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

/// <summary>
/// Pure geometry for Sprint 4's "Set a rotation angle for text and dynamic field elements":
/// Debenu Quick PDF Library 10.13's <c>DrawRotatedText(XPos, YPos, Angle, Text)</c> rotates
/// around the given anchor point <c>(XPos, YPos)</c> — the same point <c>DrawText</c> treats as
/// the left end of the text baseline — not around the text's own bounding-box center. This story
/// requires rotation around the bounding-box center instead, so this class solves for the
/// *different* anchor point that, when Debenu rotates around it, leaves the original (unrotated)
/// bounding-box center fixed in place.
///
/// <para><b>Sign convention — confirmed empirically against the real vendor DLL (not assumed),
/// per this project's established practice:</b> a probe render (unrotated baseline text vs. the
/// same text drawn via <c>DrawRotatedText</c> at Angle=90, both rasterized via
/// <c>RenderPageToFile</c> and visually inspected) showed the text sweeping from the anchor
/// toward <b>+Y</b> (up the page) at Angle=90, instead of its normal +X (rightward) direction at
/// Angle=0. In PDF's Y-up coordinate system, rotating the +X axis by 90° counterclockwise lands
/// exactly on +Y — so <b>positive Angle is counterclockwise</b>, the standard mathematical
/// rotation convention, applied directly (no sign flip needed) in PDF's own coordinate space.
/// The same probe found <c>GetTextDescent()</c> returns a <b>negative</b> value (e.g. -5.088 for
/// a 24pt Arial), i.e. already signed as "below the baseline" — so a glyph's local vertical
/// extent is <c>[descent, ascent]</c> directly, not <c>[-descent, ascent]</c>.</para>
/// </summary>
public static class RotatedTextAnchorCalculator
{
/// <summary>Computes the anchor point to pass to <c>DrawRotatedText</c> so the text rotates
/// visually around the center of its own unrotated bounding box, rather than around
/// <paramref name="x"/>/<paramref name="y"/> directly.</summary>
/// <param name="x">The element's authored (unrotated) anchor X — the same value that would
/// be passed to plain <c>DrawText</c>.</param>
/// <param name="y">The element's authored (unrotated) anchor Y (baseline).</param>
/// <param name="width">The text's measured width at its font/size (<c>GetTextWidth</c>).</param>
/// <param name="ascent">The font's ascent at the current size (<c>GetTextAscent</c>),
/// positive, extending above the baseline.</param>
/// <param name="descent">The font's descent at the current size (<c>GetTextDescent</c>) —
/// already negative (see class remarks), extending below the baseline.</param>
/// <param name="angleDegrees">Rotation angle in degrees, positive = counterclockwise (see
/// class remarks).</param>
public static (double X, double Y) ComputeAnchor(
double x, double y, double width, double ascent, double descent, double angleDegrees)
{
if (angleDegrees == 0)
{
return (x, y);
}

// Bounding-box center in the text's own local (unrotated) frame, relative to the
// baseline-left anchor: half the width horizontally, the midpoint of [descent, ascent]
// vertically.
var centerLocalX = width / 2.0;
var centerLocalY = (ascent + descent) / 2.0;

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

// Anchor' = A0 + (I - R(theta)) * centerLocal — see class remarks for the derivation:
// this is the anchor position that keeps the *unrotated* bounding-box center fixed after
// DrawRotatedText applies its own (confirmed CCW-positive) rotation around Anchor'.
var anchorX = x + centerLocalX - ((centerLocalX * cos) - (centerLocalY * sin));
var anchorY = y + centerLocalY - ((centerLocalX * sin) + (centerLocalY * cos));

return (anchorX, anchorY);
}
}

+ 13
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs Просмотреть файл

@@ -7,13 +7,25 @@ namespace EnvelopeRenderer.Cli.Render;
/// literal string is out of scope for this sprint ("Create a dynamic text token from a CSV /// literal string is out of scope for this sprint ("Create a dynamic text token from a CSV
/// column" is a deferred story). /// column" is a deferred story).
/// </summary> /// </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
/// is not drawn and every other element sharing its X position (an "address line stack" — see
/// <see cref="AddressLineCollapser"/>) shifts upward to close the gap. Defaults to <c>false</c>
/// so every template written before this story renders identically (a blank line simply prints
/// nothing at its fixed position, exactly as before).</param>
/// <param name="Angle">Sprint 4, "Set a rotation angle for text and dynamic field elements":
/// degrees, positive or negative, applied around the element's own bounding-box center (not its
/// X/Y anchor). Defaults to <c>0</c> so every template written before this story renders
/// unchanged.</param>
public sealed record TemplateElement( public sealed record TemplateElement(
double X, double X,
double Y, double Y,
string FontName, string FontName,
double Size, double Size,
string? StaticText, string? StaticText,
string? ColumnName)
string? ColumnName,
bool Collapsible = false,
double Angle = 0)
{ {
public bool IsDynamic => ColumnName is not null; public bool IsDynamic => ColumnName is not null;
} }

+ 39
- 2
code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs Просмотреть файл

@@ -78,7 +78,21 @@ public static class TemplateXmlParser
errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one."); errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
} }


if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText))
// `collapsible` (Sprint 4, "Collapse blank optional address lines consistently") and
// `angle` (Sprint 4, "Set a rotation angle...") are both optional and default to the
// pre-Sprint-4 behavior (no collapsing, no rotation) when absent, so every template
// written before this sprint parses and renders identically.
var collapsible = false;
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 angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0);

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText)
|| angle is null)
{ {
continue; continue;
} }
@@ -89,7 +103,9 @@ public static class TemplateXmlParser
font, font,
size.Value, size.Value,
hasStaticText ? staticText : null, hasStaticText ? staticText : null,
hasColumn ? column : null));
hasColumn ? column : null,
collapsible,
angle.Value));
} }


if (errors.Count > 0) if (errors.Count > 0)
@@ -100,6 +116,27 @@ public static class TemplateXmlParser
return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements)); return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements));
} }


/// <summary>Like <see cref="ParseRequiredDouble"/> but for an attribute that is allowed to be
/// absent, returning <paramref name="defaultValue"/> in that case; a present-but-non-numeric
/// value is still a real error, same as a required attribute.</summary>
private static double? ParseOptionalDouble(
XElement node, string attributeName, string label, List<string> errors, double defaultValue)
{
var raw = (string?)node.Attribute(attributeName);
if (string.IsNullOrWhiteSpace(raw))
{
return defaultValue;
}

if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
errors.Add($"{label} has a non-numeric '{attributeName}' value: '{raw}'.");
return null;
}

return value;
}

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


+ 7
- 2
code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs Просмотреть файл

@@ -2,5 +2,10 @@ namespace EnvelopeRenderer.Cli.Render;


/// <summary>A single resolved piece of text to place on the current page, in PDF points measured /// <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 /// 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).</summary>
public sealed record TextDraw(double X, double Y, string FontName, double Size, string Text);
/// 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);

+ 81
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressBlockPreviewCalculator.cs Просмотреть файл

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

/// <summary>
/// Sprint 4, "Collapse blank optional address lines consistently": computes each element's canvas
/// preview state — whether it should be drawn, at what (possibly shifted) Y, and whether its
/// dynamic binding looks like a real mapping problem rather than merely-blank sample data — so
/// <c>TemplateCanvasControl</c> can apply the exact same collapsing behavior a real render would
/// (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.
/// </summary>
public static class AddressBlockPreviewCalculator
{
/// <summary>
/// </summary>
/// <param name="elements">The document's elements, in the same order the caller wants results
/// back in (keyed by <see cref="TextElementLayout.Id"/>).</param>
/// <param name="csvHeaders">The currently loaded CSV's headers, or empty if none is loaded —
/// an empty list means "unknown," not "no columns exist," so no element is ever flagged
/// <see cref="ElementPreviewState.IsUnmappedColumn"/> before any CSV has been loaded at all
/// (nothing to compare against yet).</param>
/// <param name="sampleRecord">One representative CSV data row (header -> value, case
/// insensitive), or <c>null</c> if no CSV/sample is available yet — in which case no dynamic
/// element's blankness can be determined, so no dynamic element ever collapses in preview
/// until real sample data exists to judge it against.</param>
public static IReadOnlyDictionary<Guid, ElementPreviewState> Compute(
IReadOnlyList<TextElementLayout> elements,
IReadOnlyList<string> csvHeaders,
IReadOnlyDictionary<string, string>? sampleRecord)
{
var headerSet = new HashSet<string>(csvHeaders, StringComparer.OrdinalIgnoreCase);

var isUnmapped = new bool[elements.Count];
var lines = new AddressLineCollapser.Line[elements.Count];

for (var i = 0; i < elements.Count; i++)
{
var element = elements[i];

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

var resolvedText = ResolveSampleText(element, sampleRecord, isUnmapped[i]);
var shouldCollapse = element.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
lines[i] = new AddressLineCollapser.Line(element.X, element.Y, shouldCollapse);
}

var resolved = AddressLineCollapser.Resolve(lines);

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]);
}

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;
}
}

+ 64
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs Просмотреть файл

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

/// <summary>
/// Pure geometry for Sprint 4's "Collapse blank optional address lines consistently" — the
/// designer canvas's own copy of the same shift math <c>EnvelopeRenderer.Cli.Render</c> uses at
/// render time, so preview and final PDF agree (the "Preview and final render must agree on
/// suppression behavior" conversation note). Deliberately reimplemented here rather than shared
/// via a project reference — the desktop project intentionally never references the CLI's
/// internals (see <c>EnvelopeRenderer.Desktop.csproj</c>'s remarks and
/// <see cref="TemplateLayoutXmlSerializer"/>'s own precedent for the same reasoning); the two
/// copies are small, pure, and each independently unit tested.
///
/// <para><b>Grouping rule (a Development Team design decision — the template format has no
/// explicit "address block" concept):</b> lines that share the same X position (rounded to 2
/// decimal places, matching the precision the properties panel and XML persistence already use)
/// are treated as one vertical stack — i.e. one address block — ordered top-to-bottom by
/// descending Y (PDF points increase upward, so the top line of a block has the highest Y).</para>
///
/// <para><b>Shift math:</b> line k's "row height" is the gap between it and the line immediately
/// below it in its stack, i.e. <c>Y[k] - Y[k+1]</c> (using the stack's original, undisturbed Y
/// values). When line k collapses, every line below it in the same stack shifts upward by that
/// row height. The last line in a stack has no line below it, so collapsing it is a no-op.</para>
/// </summary>
public static class AddressLineCollapser
{
/// <summary>One input line: its original template position and whether this particular
/// record/sample makes it collapse (the caller combines "is this element configured
/// collapsible" with "is its resolved text blank" before constructing this).</summary>
public readonly record struct Line(double X, double Y, bool ShouldCollapse);

/// <summary>One resolved line, same order/count as the input.</summary>
public readonly record struct Resolved(bool Visible, double EffectiveY);

private const int GroupingPrecision = 2;

public static IReadOnlyList<Resolved> Resolve(IReadOnlyList<Line> lines)
{
var result = new Resolved[lines.Count];

var groups = lines
.Select((line, index) => (Line: line, Index: index))
.GroupBy(t => Math.Round(t.Line.X, GroupingPrecision));

foreach (var group in groups)
{
var stack = group.OrderByDescending(t => t.Line.Y).ToList();
var cumulativeShift = 0.0;

for (var i = 0; i < stack.Count; i++)
{
var (line, index) = stack[i];
result[index] = new Resolved(!line.ShouldCollapse, line.Y + cumulativeShift);

if (line.ShouldCollapse && i < stack.Count - 1)
{
var nextY = stack[i + 1].Line.Y;
cumulativeShift += line.Y - nextY;
}
}
}

return result;
}
}

+ 162
- 13
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs Просмотреть файл

@@ -1,20 +1,36 @@
namespace EnvelopeRenderer.Desktop.Core.Design; namespace EnvelopeRenderer.Desktop.Core.Design;


/// <summary> /// <summary>
/// UI-independent add/select/drag logic for placing and moving text elements on the designer
/// canvas (Sprint 2 Batch 3: "Place and move text elements on the canvas"). All positions taken
/// and returned are in canvas space (PDF points, bottom-left origin) — the WinForms canvas
/// control is responsible for converting mouse pixel coordinates via <see cref="CanvasViewTransform"/>
/// before calling in here, so this class has no GDI/WinForms dependency and is fully unit
/// testable. Text measurement (needed for hit-testing a text element's bounding box) is injected
/// via <paramref name="measureText"/> rather than hard-coded to real GDI measurement, so tests can
/// supply deterministic fake sizes.
/// UI-independent add/select/drag/rotate logic for placing and moving text elements on the
/// designer canvas (Sprint 2 Batch 3: "Place and move text elements on the canvas"; Sprint 4:
/// rotated-bounding-box hit-testing and the drag-to-rotate handle). All positions taken and
/// returned are in canvas space (PDF points, bottom-left origin, positive angle = counterclockwise
/// — the same convention <c>DebenuPdfRenderer</c> confirmed empirically for the real PDF output,
/// see <c>RotatedTextAnchorCalculator</c>) — the WinForms canvas control is responsible for
/// converting mouse pixel coordinates via <see cref="CanvasViewTransform"/> before calling in
/// here, and for negating the angle when it hands off to GDI+'s own (Y-down, clockwise-positive)
/// <c>Graphics.RotateTransform</c>. Text measurement (needed for hit-testing a text element's
/// bounding box) is injected via <paramref name="measureText"/> rather than hard-coded to real
/// GDI measurement, so tests can supply deterministic fake sizes.
/// </summary> /// </summary>
public sealed class CanvasElementEditor public sealed class CanvasElementEditor
{ {
/// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": how far beyond
/// the element's rotated bounding box (in canvas-space points) the drag handle sits, measured
/// from the top edge outward along the box's own "up" direction. A Development Team design
/// decision — big enough to stay comfortably clickable/draggable at typical canvas zoom
/// without overlapping the element itself.</summary>
public const double HandleOffsetPoints = 20.0;

/// <summary>Sprint 4: how close (canvas-space points) a click/drag-start needs to be to the
/// handle's center to count as grabbing it, rather than falling through to a normal
/// select/move click on the element body.</summary>
public const double HandleHitRadiusPoints = 8.0;

private readonly TemplateLayoutDocument _document; private readonly TemplateLayoutDocument _document;
private readonly Func<TextElementLayout, (double Width, double Height)> _measureText; private readonly Func<TextElementLayout, (double Width, double Height)> _measureText;
private (double Dx, double Dy)? _dragOffset; private (double Dx, double Dy)? _dragOffset;
private bool _isRotating;


public CanvasElementEditor( public CanvasElementEditor(
TemplateLayoutDocument document, Func<TextElementLayout, (double Width, double Height)> measureText) TemplateLayoutDocument document, Func<TextElementLayout, (double Width, double Height)> measureText)
@@ -43,16 +59,16 @@ public sealed class CanvasElementEditor
return element; return element;
} }


/// <summary>Finds the topmost (highest z-order) element whose bounding box — its
/// (X, Y) origin plus the injected <see cref="_measureText"/> size — contains the given
/// <summary>Finds the topmost (highest z-order) element whose (possibly rotated) bounding
/// box — its (X, Y) origin plus the injected <see cref="_measureText"/> size, rotated by
/// <see cref="TextElementLayout.RotationAngle"/> around its own center — contains the given
/// canvas-space point, or <c>null</c> if none does.</summary> /// canvas-space point, or <c>null</c> if none does.</summary>
public TextElementLayout? HitTest(double xPoints, double yPoints) public TextElementLayout? HitTest(double xPoints, double yPoints)
{ {
foreach (var element in _document.Elements.OrderByDescending(e => e.ZOrder)) foreach (var element in _document.Elements.OrderByDescending(e => e.ZOrder))
{ {
var (width, height) = _measureText(element); var (width, height) = _measureText(element);
if (xPoints >= element.X && xPoints <= element.X + width
&& yPoints >= element.Y && yPoints <= element.Y + height)
if (IsPointInRotatedBounds(xPoints, yPoints, element, width, height))
{ {
return element; return element;
} }
@@ -61,6 +77,43 @@ public sealed class CanvasElementEditor
return null; 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
/// 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)
{
if (element.RotationAngle == 0)
{
return xPoints >= element.X && xPoints <= element.X + width
&& 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);

return ((dx * cos) - (dy * sin), (dx * sin) + (dy * cos));
}

/// <summary>Selects whatever element is at the given point (or clears selection if none), /// <summary>Selects whatever element is at the given point (or clears selection if none),
/// returning whether something was selected.</summary> /// returning whether something was selected.</summary>
public bool TrySelectAt(double xPoints, double yPoints) public bool TrySelectAt(double xPoints, double yPoints)
@@ -97,7 +150,103 @@ public sealed class CanvasElementEditor
Selected.Y = yPoints - _dragOffset.Value.Dy; Selected.Y = yPoints - _dragOffset.Value.Dy;
} }


public void EndDrag() => _dragOffset = null;
public void EndDrag()
{
_dragOffset = null;
_isRotating = false;
}


public bool IsDragging => _dragOffset is not null; public bool IsDragging => _dragOffset is not null;

/// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": the current
/// world-space (canvas-point) position of the selected element's rotate handle, or
/// <c>null</c> if nothing is selected. Sits <see cref="HandleOffsetPoints"/> beyond the
/// rotated bounding box's top edge, along the box's own (rotated) "up" direction, so the
/// handle visually orbits the element as it rotates rather than staying fixed above it.</summary>
public (double X, double Y)? HandlePosition()
{
if (Selected is null)
{
return null;
}

var (width, height) = _measureText(Selected);
return ComputeHandlePosition(Selected, width, height);
}

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));
}

/// <summary>Whether the given canvas-space point is within grab range of the currently
/// selected element's rotate handle. Always <c>false</c> when nothing is selected.</summary>
public bool HitTestHandle(double xPoints, double yPoints)
{
var handle = HandlePosition();
if (handle is null)
{
return false;
}

var dx = xPoints - handle.Value.X;
var dy = yPoints - handle.Value.Y;
return (dx * dx) + (dy * dy) <= HandleHitRadiusPoints * HandleHitRadiusPoints;
}

/// <summary>Starts a rotate-drag of the currently selected element. No-op if nothing is
/// selected.</summary>
public void BeginRotateDrag()
{
if (Selected is null)
{
return;
}

_isRotating = true;
_dragOffset = null;
}

public bool IsRotating => _isRotating;

/// <summary>Updates the selected element's <see cref="TextElementLayout.RotationAngle"/> from
/// the pointer's current position, via <c>atan2</c> of the pointer relative to the element's
/// bounding-box center. No-op if nothing is selected, a rotate-drag hasn't begun, or the
/// pointer is exactly on the center (an undefined direction — left at its last angle rather
/// than snapping arbitrarily).</summary>
public void RotateDragTo(double xPoints, double yPoints)
{
if (Selected is null || !_isRotating)
{
return;
}

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;

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 angleToPointerDegrees = Math.Atan2(dy, dx) * 180.0 / Math.PI;
Selected.RotationAngle = angleToPointerDegrees - 90.0;
}
} }

+ 15
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/ElementPreviewState.cs Просмотреть файл

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

/// <summary>One element's computed preview state for the canvas (Sprint 4). See
/// <see cref="AddressBlockPreviewCalculator"/>.</summary>
/// <param name="Visible">Whether the canvas should draw this element at all — <c>false</c> only
/// for a collapsible element whose resolved sample value is blank.</param>
/// <param name="EffectiveY">The Y position to draw at, after applying any address-line
/// collapsing from blank lines above it in the same X-aligned stack.</param>
/// <param name="IsUnmappedColumn">Sprint 4, "Collapse blank optional address lines consistently"
/// acceptance criterion — "operators can distinguish intentionally blank output from a mapping
/// 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);

+ 42
- 1
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs Просмотреть файл

@@ -41,6 +41,20 @@ public static class TemplateLayoutXmlSerializer
new XAttribute("color", element.Color.ToHex()), new XAttribute("color", element.Color.ToHex()),
new XAttribute("zOrder", element.ZOrder.ToString(CultureInfo.InvariantCulture))); 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)));
}

if (element.IsDynamic) if (element.IsDynamic)
{ {
textElement.Add(new XAttribute("column", element.ColumnName!)); textElement.Add(new XAttribute("column", element.ColumnName!));
@@ -159,7 +173,32 @@ public static class TemplateLayoutXmlSerializer


autoZOrder++; autoZOrder++;


if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText))
// Both default to the pre-Sprint-4 behavior when absent, so every template saved
// before this story loads and displays unchanged.
var collapsible = false;
var collapsibleRaw = (string?)node.Attribute("collapsible");
if (!string.IsNullOrWhiteSpace(collapsibleRaw) && !bool.TryParse(collapsibleRaw, out collapsible))
{
errorList.Add($"{label} has an invalid 'collapsible' value: '{collapsibleRaw}' (expected 'true' or 'false').");
}

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;
errorList.Add($"{label} has a non-numeric 'angle' value: '{angleRaw}'.");
}
}

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || (hasColumn == hasStaticText)
|| angle is null)
{ {
continue; continue;
} }
@@ -169,6 +208,8 @@ public static class TemplateLayoutXmlSerializer
: TextElementLayout.CreateDynamic(x.Value, y.Value, column!, font, size.Value, zOrder)); : TextElementLayout.CreateDynamic(x.Value, y.Value, column!, font, size.Value, zOrder));


elements[^1].Color = color; elements[^1].Color = color;
elements[^1].CollapseIfBlank = collapsible;
elements[^1].RotationAngle = angle.Value;
} }


if (errorList.Count > 0) if (errorList.Count > 0)


+ 14
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs Просмотреть файл

@@ -37,6 +37,20 @@ public sealed class TextElementLayout
/// <summary>Stacking order; 0 is bottom-most, matching the product's numeric z-order rule.</summary> /// <summary>Stacking order; 0 is bottom-most, matching the product's numeric z-order rule.</summary>
public int ZOrder { get; set; } public int ZOrder { get; set; }


/// <summary>Sprint 4, "Collapse blank optional address lines consistently": when <c>true</c>,
/// this element is skipped (and every other element sharing its X position shifts upward to
/// close the gap) for whichever record has a blank resolved value for it. Defaults to
/// <c>false</c> — the pre-Sprint-4 behavior of always drawing at a fixed position, blank text
/// or not. See <see cref="AddressLineCollapser"/> for the shared shift math the canvas
/// preview and the CLI render engine both apply.</summary>
public bool CollapseIfBlank { get; set; }

/// <summary>Sprint 4, "Set a rotation angle for text and dynamic field elements": degrees,
/// positive or negative, applied around the center of this element's own bounding box (not
/// its X/Y anchor). Defaults to <c>0</c> — every template/element predating this story renders
/// and displays unchanged.</summary>
public double RotationAngle { get; set; }

public string? StaticText { get; set; } public string? StaticText { get; set; }
public string? ColumnName { get; set; } public string? ColumnName { get; set; }




+ 23
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementPropertiesEditor.cs Просмотреть файл

@@ -84,4 +84,27 @@ public sealed class TextElementPropertiesEditor
Selected.ZOrder = ZOrderRule.Clamp(zOrder); Selected.ZOrder = ZOrderRule.Clamp(zOrder);
} }
} }

/// <summary>Sprint 4, "Collapse blank optional address lines consistently": toggles whether
/// this element collapses (and shifts elements below it in its X-aligned stack upward) for
/// any record where its resolved text is blank. No validation needed — any bool is valid.</summary>
public void SetCollapseIfBlank(bool collapseIfBlank)
{
if (Selected is not null)
{
Selected.CollapseIfBlank = collapseIfBlank;
}
}

/// <summary>Sprint 4, "Set a rotation angle for text and dynamic field elements": free-form
/// degrees, positive or negative — any finite value is accepted (unlike font size or z-order,
/// there is no invalid numeric range for a rotation angle; a caller could reasonably want e.g.
/// -270 instead of the equivalent 90).</summary>
public void SetRotationAngle(double angleDegrees)
{
if (Selected is not null && double.IsFinite(angleDegrees))
{
Selected.RotationAngle = angleDegrees;
}
}
} }

+ 110
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressBlockPreviewCalculatorTests.cs Просмотреть файл

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

namespace EnvelopeRenderer.Desktop.Tests;

public class AddressBlockPreviewCalculatorTests
{
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 Compute_NoCsvLoaded_NoDynamicElementCollapsesOrIsFlaggedUnmapped()
{
var name = TextElementLayout.CreateStatic(100, 300, "Name");
var address2 = TextElementLayout.CreateDynamic(100, 285, "Address2");
address2.CollapseIfBlank = true;
var elements = new List<TextElementLayout> { name, address2 };

var states = AddressBlockPreviewCalculator.Compute(elements, Array.Empty<string>(), null);

Assert.True(states[name.Id].Visible);
Assert.True(states[address2.Id].Visible); // can't know it's blank without sample data
Assert.False(states[address2.Id].IsUnmappedColumn); // no headers loaded at all yet
}

[Fact]
public void Compute_CollapsibleFieldBlankInSample_IsNotVisibleAndSubsequentLineShiftsUp()
{
var name = TextElementLayout.CreateStatic(100, 300, "Name");
var address2 = TextElementLayout.CreateDynamic(100, 285, "Address2");
address2.CollapseIfBlank = true;
var city = TextElementLayout.CreateDynamic(100, 270, "City");
var elements = new List<TextElementLayout> { name, address2, city };

var headers = new[] { "Address2", "City" };
var sample = Record(("Address2", ""), ("City", "Springfield"));

var states = AddressBlockPreviewCalculator.Compute(elements, headers, sample);

Assert.True(states[name.Id].Visible);
Assert.False(states[address2.Id].Visible);
Assert.False(states[address2.Id].IsUnmappedColumn);
Assert.True(states[city.Id].Visible);
Assert.Equal(285, states[city.Id].EffectiveY, precision: 6);
}

[Fact]
public void Compute_CollapsibleFieldPopulatedInSample_IsVisibleAtOriginalPosition()
{
var address2 = TextElementLayout.CreateDynamic(100, 285, "Address2");
address2.CollapseIfBlank = true;
var elements = new List<TextElementLayout> { address2 };

var states = AddressBlockPreviewCalculator.Compute(
elements, new[] { "Address2" }, Record(("Address2", "Apt 4B")));

Assert.True(states[address2.Id].Visible);
Assert.Equal(285, states[address2.Id].EffectiveY, precision: 6);
}

[Fact]
public void Compute_DynamicElementBoundToColumnNotInLoadedHeaders_IsFlaggedUnmapped()
{
var element = TextElementLayout.CreateDynamic(100, 300, "TypoedColumn");
var elements = new List<TextElementLayout> { element };

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

Assert.True(states[element.Id].IsUnmappedColumn);
}

[Fact]
public void Compute_UnmappedColumn_NeverCollapsesEvenIfConfiguredCollapsible()
{
// An unmapped column is a real mapping error, not "intentionally blank" — it must never
// silently disappear via collapsing, which would hide the mistake from the operator.
var element = TextElementLayout.CreateDynamic(100, 300, "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);
}

[Fact]
public void Compute_DynamicElementBoundToLoadedColumn_IsNotFlaggedUnmapped()
{
var element = TextElementLayout.CreateDynamic(100, 300, "Full Name");
var elements = new List<TextElementLayout> { element };

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

// Case-insensitive match, mirroring the CLI's own column matching.
Assert.False(states[element.Id].IsUnmappedColumn);
}

[Fact]
public void Compute_StaticElement_IsNeverFlaggedUnmapped()
{
var element = TextElementLayout.CreateStatic(100, 300, "Static label");
var elements = new List<TextElementLayout> { element };

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

Assert.False(states[element.Id].IsUnmappedColumn);
Assert.True(states[element.Id].Visible);
}
}

+ 92
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressLineCollapserTests.cs Просмотреть файл

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

namespace EnvelopeRenderer.Desktop.Tests;

public class AddressLineCollapserTests
{
[Fact]
public void Resolve_NoLineCollapses_EveryLineVisibleAtOriginalY()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.All(resolved, r => Assert.True(r.Visible));
Assert.Equal(300, resolved[0].EffectiveY, precision: 6);
Assert.Equal(285, resolved[1].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_SingleBlankLine_SubsequentLineShiftsUpByItsRowHeight()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, true),
new AddressLineCollapser.Line(100, 270, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.False(resolved[1].Visible);
Assert.True(resolved[2].Visible);
Assert.Equal(285, resolved[2].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_MultipleConsecutiveBlankLines_CollapseWithoutLeavingExtraGaps()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, true),
new AddressLineCollapser.Line(100, 270, true),
new AddressLineCollapser.Line(100, 255, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.False(resolved[1].Visible);
Assert.False(resolved[2].Visible);
Assert.True(resolved[3].Visible);
Assert.Equal(285, resolved[3].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_LinesAtDifferentX_DoNotAffectEachOther()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, true),
new AddressLineCollapser.Line(400, 300, false),
new AddressLineCollapser.Line(400, 285, false),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.False(resolved[1].Visible);
Assert.True(resolved[3].Visible);
Assert.Equal(285, resolved[3].EffectiveY, precision: 6);
}

[Fact]
public void Resolve_LastLineBlank_IsANoOp()
{
var lines = new[]
{
new AddressLineCollapser.Line(100, 300, false),
new AddressLineCollapser.Line(100, 285, true),
};

var resolved = AddressLineCollapser.Resolve(lines);

Assert.True(resolved[0].Visible);
Assert.False(resolved[1].Visible);
}
}

+ 193
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs Просмотреть файл

@@ -177,6 +177,199 @@ public class CanvasElementEditorTests
Assert.Null(editor.Selected); Assert.Null(editor.Selected);
} }


// Sprint 4, "Set a rotation angle...": rotated-bounding-box hit-testing.

[Fact]
public void HitTest_RotatedElement_PointInRotatedCornerButOutsideUnrotatedBox_StillHits()
{
var editor = CreateEditor(out _);
// Element at (100,100), fixed 20x10 box -> center (110,105), local half-extents (10,5).
var element = editor.AddStaticText(100, 100, "Hello");
element.RotationAngle = 45;

// Take a point just inside the box's local corner (9.9, 4.9) and rotate it by +45 degrees
// (the same forward rotation the drawn/rotated box itself undergoes) to get a concrete
// world-space point that is guaranteed inside the *rotated* box: center + R(45)*(9.9,4.9)
// = (110 + 3.54, 105 + 10.47) = (113.54, 115.47) — which has Y=115.47, outside the
// *unrotated* box's Y range of [100,110], proving this hit only succeeds because of the
// rotated-rectangle math, not the old plain-AABB check.
var worldPoint = (X: 113.54, Y: 115.47);
Assert.True(worldPoint.Y > 110, "Sanity check: point must be outside the unrotated AABB.");

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

Assert.Same(element, hit);
}

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

// (119.9, 100.1) sits just inside the unrotated box's bottom-right corner
// ([100,120] x [100,110]) — confirm that baseline actually hits with no rotation...
Assert.Same(element, editor.HitTest(119.9, 100.1));

// ...then confirm the same absolute point falls outside the box once it's rotated 90
// degrees about its own center (the box's footprint sweeps to a different area of the
// page; the center itself never moves, but this particular corner point does).
element.RotationAngle = 90;
Assert.Null(editor.HitTest(119.9, 100.1));
}

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

foreach (var angle in new double[] { 0, 30, 90, 180, -45 })
{
element.RotationAngle = angle;
Assert.Same(element, editor.HitTest(110, 105)); // bbox center never moves
}
}

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

[Fact]
public void HandlePosition_UnrotatedElement_IsAboveTopCenter()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello"); // box [100,120]x[100,110], center (110,105)

var handle = editor.HandlePosition();

Assert.NotNull(handle);
Assert.Equal(110, handle!.Value.X, precision: 6); // directly above center X
Assert.Equal(105 + 5 + CanvasElementEditor.HandleOffsetPoints, handle.Value.Y, precision: 6);
}

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

Assert.Null(editor.HandlePosition());
}

[Fact]
public void HandlePosition_RotatedElement_OrbitsWithTheElement()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello");
element.RotationAngle = 90; // CCW: "up" (0,1) rotates to (-1,0), i.e. handle swings left

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

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()
{
var editor = CreateEditor(out _);
editor.AddStaticText(100, 100, "Hello");
var handle = editor.HandlePosition()!.Value;

Assert.True(editor.HitTestHandle(handle.X, handle.Y));
}

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

Assert.False(editor.HitTestHandle(0, 0));
}

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

Assert.False(editor.HitTestHandle(110, 130));
}

[Fact]
public void RotateDragTo_PointerStraightUp_SetsAngleToZero()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello"); // center (110, 105)
editor.BeginRotateDrag();

editor.RotateDragTo(110, 200); // directly above center

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

[Fact]
public void RotateDragTo_PointerToTheLeft_SetsAngleTo90Degrees()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello"); // center (110, 105)
editor.BeginRotateDrag();

editor.RotateDragTo(0, 105); // directly left of center, same height

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

[Fact]
public void RotateDragTo_PointerToTheRight_SetsAngleToMinus90Degrees()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello"); // center (110, 105)
editor.BeginRotateDrag();

editor.RotateDragTo(220, 105); // directly right of center, same height

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

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

editor.RotateDragTo(0, 0);

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

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

editor.BeginRotateDrag();
Assert.True(editor.IsRotating);

editor.EndDrag();
Assert.False(editor.IsRotating);
}

[Fact]
public void BeginRotateDrag_ThenDragTo_DoesNotAlsoMoveTheElement()
{
// Rotating and moving are mutually exclusive per mouse-down interaction; BeginRotateDrag
// clears any pending move-drag offset so a stray DragTo call during a rotate can't also
// reposition the element.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello");
editor.BeginRotateDrag();

editor.DragTo(500, 500);

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

[Fact] [Fact]
public void Move_UpdatesTemplateLayoutDocumentInPlace_SoCanvasAndStoredStateAgree() public void Move_UpdatesTemplateLayoutDocumentInPlace_SoCanvasAndStoredStateAgree()
{ {


+ 81
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs Просмотреть файл

@@ -215,6 +215,87 @@ public class TemplateLayoutXmlSerializerTests : IDisposable
Assert.Equal(0, document!.Elements[0].ZOrder); Assert.Equal(0, document!.Elements[0].ZOrder);
} }


[Fact]
public void SaveThenLoad_RoundTripsCollapseIfBlankAndRotationAngle()
{
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
var element = TextElementLayout.CreateDynamic(10, 20, "Address2");
element.CollapseIfBlank = true;
element.RotationAngle = -37.25;
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 reloadedElement = reopened!.Elements.Single();
Assert.True(reloadedElement.CollapseIfBlank);
Assert.Equal(-37.25, reloadedElement.RotationAngle, precision: 6);
}

[Fact]
public void Save_DefaultCollapseIfBlankAndRotationAngle_OmitsAttributesEntirely()
{
// Keeps a template with no collapsible/rotated elements byte-for-byte unaffected by this
// sprint's change, matching the same "only write non-default" pattern already used for
// `column`.
var document = new TemplateLayoutDocument(CanvasSettings.CreateDefault());
document.Elements.Add(TextElementLayout.CreateStatic(0, 0, "plain"));

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

Assert.DoesNotContain("collapsible", xml);
Assert.DoesNotContain("angle=", xml);
}

[Fact]
public void Load_TemplateWithoutCollapsibleOrAngleAttributes_DefaultsToFalseAndZero()
{
// Matches a template saved before this sprint (or a hand-authored render-time template).
File.WriteAllText(
_tempPath,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" +
"<text x=\"120\" y=\"240\" font=\"Arial\" size=\"12\">Static label:</text>" +
"</envelopeTemplate>");

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

Assert.True(result, string.Join("; ", errors));
Assert.False(document!.Elements[0].CollapseIfBlank);
Assert.Equal(0, document.Elements[0].RotationAngle);
}

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

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

Assert.False(result);
Assert.Contains(errors, e => e.Contains("collapsible", StringComparison.OrdinalIgnoreCase));
}

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

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

Assert.False(result);
Assert.Contains(errors, e => e.Contains("angle", StringComparison.OrdinalIgnoreCase));
}

[Fact] [Fact]
public void Save_OverwritesExistingFile() public void Save_OverwritesExistingFile()
{ {


+ 28
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextElementLayoutTests.cs Просмотреть файл

@@ -43,4 +43,32 @@ public class TextElementLayoutTests


Assert.NotEqual(a.Id, b.Id); Assert.NotEqual(a.Id, b.Id);
} }

[Fact]
public void DefaultCollapseIfBlank_IsFalse()
{
var element = TextElementLayout.CreateDynamic(0, 0, "Address2");

Assert.False(element.CollapseIfBlank);
}

[Fact]
public void DefaultRotationAngle_IsZero()
{
var element = TextElementLayout.CreateStatic(0, 0, "a");

Assert.Equal(0, element.RotationAngle);
}

[Fact]
public void CollapseIfBlankAndRotationAngle_AreSettable()
{
var element = TextElementLayout.CreateStatic(0, 0, "a");

element.CollapseIfBlank = true;
element.RotationAngle = 45.5;

Assert.True(element.CollapseIfBlank);
Assert.Equal(45.5, element.RotationAngle, precision: 6);
}
} }

+ 50
- 0
code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs Просмотреть файл

@@ -186,10 +186,60 @@ public class TextElementPropertiesEditorTests
editor.SetColor(RgbColor.Black); editor.SetColor(RgbColor.Black);
editor.SetZOrder(1); editor.SetZOrder(1);
editor.SetColumnName("Full Name"); editor.SetColumnName("Full Name");
editor.SetCollapseIfBlank(true);
editor.SetRotationAngle(45);


Assert.Null(editor.Selected); Assert.Null(editor.Selected);
} }


[Fact]
public void SetCollapseIfBlank_UpdatesSelectedElement()
{
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateDynamic(0, 0, "Address2");
editor.Select(element);

editor.SetCollapseIfBlank(true);

Assert.True(element.CollapseIfBlank);

editor.SetCollapseIfBlank(false);

Assert.False(element.CollapseIfBlank);
}

[Fact]
public void SetRotationAngle_UpdatesSelectedElement_PositiveAndNegative()
{
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(0, 0, "a");
editor.Select(element);

editor.SetRotationAngle(-135.5);

Assert.Equal(-135.5, element.RotationAngle, precision: 6);

editor.SetRotationAngle(720);

Assert.Equal(720, element.RotationAngle, precision: 6);
}

[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void SetRotationAngle_NonFiniteValue_IsIgnored(double invalid)
{
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(0, 0, "a");
element.RotationAngle = 10;
editor.Select(element);

editor.SetRotationAngle(invalid);

Assert.Equal(10, element.RotationAngle, precision: 6);
}

[Fact] [Fact]
public void Select_ThenEdits_ThenSelectDifferentElement_EditsApplyToCorrectElement() public void Select_ThenEdits_ThenSelectDifferentElement_EditsApplyToCorrectElement()
{ {


+ 170
- 30
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs Просмотреть файл

@@ -6,16 +6,26 @@ namespace EnvelopeRenderer.Desktop.Views;
/// <summary> /// <summary>
/// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"): /// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"):
/// draws the page and its text elements, and lets the operator select/drag-reposition them with /// draws the page and its text elements, and lets the operator select/drag-reposition them with
/// the mouse. All coordinate math and add/select/drag state live in
/// <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/> (both framework-free and
/// unit tested in EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and
/// forwards mouse events, since that part genuinely cannot be extracted from WinForms.
/// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with
/// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element
/// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic
/// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/
/// drag/rotate state live in <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/>/
/// <see cref="AddressBlockPreviewCalculator"/> (all framework-free and unit tested in
/// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse
/// events, since that part genuinely cannot be extracted from WinForms.
/// </summary> /// </summary>
public sealed class TemplateCanvasControl : Control public sealed class TemplateCanvasControl : Control
{ {
private readonly TemplateLayoutDocument _document; private readonly TemplateLayoutDocument _document;
private readonly CanvasElementEditor _editor; private readonly CanvasElementEditor _editor;


/// <summary>The currently loaded CSV's headers and one representative sample record, used
/// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
/// empty/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
private IReadOnlyDictionary<string, string>? _csvSampleRecord;

public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? ElementsChanged; public event EventHandler? ElementsChanged;


@@ -51,6 +61,17 @@ public sealed class TemplateCanvasControl : Control
return element; return element;
} }


/// <summary>Sprint 4: supplies the loaded CSV's headers and one representative sample record
/// (typically the first loaded sample row) so the canvas can preview address-line collapsing
/// and flag mapping errors exactly the way a real render would. Pass an empty header list and
/// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
{
_csvHeaders = headers;
_csvSampleRecord = sampleRecord;
Invalidate();
}

/// <summary>Re-selects the given element (e.g. after the properties panel changes it) and /// <summary>Re-selects the given element (e.g. after the properties panel changes it) and
/// redraws — used so external edits stay visually in sync with the canvas.</summary> /// redraws — used so external edits stay visually in sync with the canvas.</summary>
public void NotifyElementChanged() public void NotifyElementChanged()
@@ -97,48 +118,146 @@ public sealed class TemplateCanvasControl : Control
g.FillRectangle(Brushes.White, pageRect); g.FillRectangle(Brushes.White, pageRect);
g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height); g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);


// Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
// time, run here against the loaded CSV's sample record so the canvas preview and the
// final PDF agree (the story's "Preview and final render must agree" conversation note).
var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);

foreach (var element in _document.Elements.OrderBy(el => el.ZOrder)) foreach (var element in _document.Elements.OrderBy(el => el.ZOrder))
{ {
DrawElement(g, transform, element, isSelected: ReferenceEquals(element, _editor.Selected));
var state = previewStates[element.Id];
if (!state.Visible)
{
continue;
}

DrawElement(g, transform, element, state.EffectiveY, state.IsUnmappedColumn,
isSelected: ReferenceEquals(element, _editor.Selected));
}

if (_editor.Selected is not null)
{
DrawRotateHandle(g, transform, _editor.Selected);
} }
} }


private void DrawElement(Graphics g, CanvasViewTransform transform, TextElementLayout element, bool isSelected)
private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
double effectiveY, bool isUnmappedColumn, bool isSelected)
{ {
using var font = ResolveFont(element.FontFamily, (float)element.FontSize); using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
var (width, height) = MeasureElement(element); var (width, height) = MeasureElement(element);


// Element (X, Y) is the bottom-left, baseline-ish origin in canvas space (points,
// bottom-left page origin); the drawn box spans up to (X + width, Y + height), so the
// pixel position to draw the string's top-left corner at is the transform of (X, Y + height).
var (drawX, drawY) = transform.ToPixels(element.X, element.Y + height);
// Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
// bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
// the pixel position to draw the string's top-left corner at is the transform of
// (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
// address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
var (drawX, drawY) = transform.ToPixels(element.X, effectiveY + height);


if (element.IsDynamic)
GraphicsState? savedState = null;
if (element.RotationAngle != 0)
{ {
// A visible placeholder representation distinct from static text (Sprint 3 Batch 3
// acceptance criterion), on top of the `{Column Name}` display text
// (TextElementLayout.DisplayText) already differing textually from static content —
// a light fill makes a bound field recognizable even at a glance/small zoom, before
// reading the text itself.
var boxWidth = width * transform.Scale;
var boxHeight = height * transform.Scale;
using var dynamicFill = new SolidBrush(System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue));
g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight);
var (centerXPx, centerYPx) = transform.ToPixels(element.X + (width / 2.0), effectiveY + (height / 2.0));
savedState = g.Save();
g.TranslateTransform((float)centerXPx, (float)centerYPx);
// GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
// Y-down pixel space. The stored RotationAngle uses the opposite convention —
// counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
// RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
// is negated here to keep the canvas rotating the same visual direction the final PDF
// will, per this story's "same rotation, around the same pivot, as shown in the
// designer canvas" acceptance criterion.
g.RotateTransform((float)-element.RotationAngle);
g.TranslateTransform((float)-centerXPx, (float)-centerYPx);
} }


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);
try
{
if (element.IsDynamic)
{
// A visible placeholder representation distinct from static text (Sprint 3 Batch 3
// acceptance criterion). Sprint 4: an unmapped column (bound to a name that isn't
// among the currently loaded CSV's headers — a real mapping problem) gets a
// visually distinct warning color instead of the normal "this is dynamic" blue, so
// an operator can tell a mapping error apart from a field that's merely blank for
// the current sample data (which draws with the ordinary blue fill, or is skipped
// entirely if also collapsible — see the caller's `Visible` check).
var boxWidth = width * transform.Scale;
var boxHeight = height * transform.Scale;
var fillColor = isUnmappedColumn
? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
: System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
using var dynamicFill = new SolidBrush(fillColor);
g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight);
}

using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);


if (isSelected)
if (isUnmappedColumn)
{
using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
g.DrawRectangle(
warnPen, (float)drawX - 1, (float)drawY - 1,
(float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
}
else if (element.CollapseIfBlank)
{
// A small marker for a configured-collapsible field, regardless of whether it
// happens to be blank right now — lets an operator see at a glance which fields
// are configured to collapse, without needing to select each one individually.
using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
var badgeY = (float)(drawY + (height * transform.Scale) + 2);
var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
}

if (isSelected)
{
var selWidth = width * transform.Scale;
var selHeight = height * transform.Scale;
using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
}
}
finally
{ {
var (selX, selY) = (drawX, drawY);
var selWidth = width * transform.Scale;
var selHeight = height * transform.Scale;
using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
g.DrawRectangle(pen, (float)selX - 2, (float)selY - 2, (float)selWidth + 4, (float)selHeight + 4);
if (savedState is not null)
{
g.Restore(savedState);
}
} }
} }


/// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
/// dot connected to the selected element's bounding-box center by a dotted line, at the
/// world-space position <see cref="CanvasElementEditor.HandlePosition"/> computes (which
/// already accounts for the element's current rotation) — the same position
/// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
/// what's draggable.</summary>
private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
{
var handle = _editor.HandlePosition();
if (handle is null)
{
return;
}

var (width, height) = MeasureElement(element);
var (centerPx, centerPy) = transform.ToPixels(element.X + (width / 2.0), element.Y + (height / 2.0));
var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);

using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);

const float radius = 5f;
using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
}

/// <summary>Measures an element's rendered size in canvas-space points, using a /// <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 /// <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 /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
@@ -180,6 +299,18 @@ public sealed class TemplateCanvasControl : Control
} }


var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);

// Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
// instead of a normal select/move — checked first (and only when something is already
// selected) so it never intercepts a normal click elsewhere on the canvas.
if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
{
_editor.BeginRotateDrag();
Capture = true;
Invalidate();
return;
}

var hit = _editor.TrySelectAt(x, y); var hit = _editor.TrySelectAt(x, y);
if (hit) if (hit)
{ {
@@ -194,13 +325,22 @@ public sealed class TemplateCanvasControl : Control
protected override void OnMouseMove(MouseEventArgs e) protected override void OnMouseMove(MouseEventArgs e)
{ {
base.OnMouseMove(e); base.OnMouseMove(e);
if (!_editor.IsDragging)
if (!_editor.IsDragging && !_editor.IsRotating)
{ {
return; return;
} }


var (x, y) = CurrentTransform().ToPoints(e.X, e.Y); var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
_editor.DragTo(x, y);

if (_editor.IsRotating)
{
_editor.RotateDragTo(x, y);
}
else
{
_editor.DragTo(x, y);
}

Invalidate(); Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty); ElementsChanged?.Invoke(this, EventArgs.Empty);
} }


+ 52
- 2
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs Просмотреть файл

@@ -105,8 +105,15 @@ public sealed class TemplateDesignerForm : Form
private readonly NumericUpDown _fontSizeInput = new() { DecimalPlaces = 1, Minimum = 1, Maximum = 1000m, Width = 80 }; 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 }; private readonly Button _colorButton = new() { Text = string.Empty, Width = 60, Height = 24, FlatStyle = FlatStyle.Popup };
private readonly NumericUpDown _zOrderInput = new() { DecimalPlaces = 0, Minimum = 0, Maximum = 100000m, Width = 80 }; private readonly NumericUpDown _zOrderInput = new() { DecimalPlaces = 0, Minimum = 0, Maximum = 100000m, Width = 80 };
// Sprint 4, "Set a rotation angle for text and dynamic field elements" — free-form degrees,
// positive or negative (see TextElementPropertiesEditor.SetRotationAngle), so the range is
// wide and not clamped to a single rotation's worth; an operator entering e.g. -270 instead
// of 90 is a legitimate, if unusual, choice.
private readonly NumericUpDown _angleInput = new() { DecimalPlaces = 1, Minimum = -3600m, Maximum = 3600m, Width = 80 };
// Sprint 4, "Collapse blank optional address lines consistently".
private readonly CheckBox _collapseIfBlankInput = new() { Text = "Collapse if blank", AutoSize = true };
// A plain Panel with explicit control positions (not a TableLayoutPanel) — deliberately // A plain Panel with explicit control positions (not a TableLayoutPanel) — deliberately
// simple/predictable for a fixed 6-row form rather than relying on auto-sizing cell layout.
// simple/predictable for a fixed-row form rather than relying on auto-sizing cell layout.
private readonly Panel _propertiesPanel = new() private readonly Panel _propertiesPanel = new()
{ {
Dock = DockStyle.Right, Dock = DockStyle.Right,
@@ -137,7 +144,16 @@ public sealed class TemplateDesignerForm : Form
_canvas = new TemplateCanvasControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12) }; _canvas = new TemplateCanvasControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12) };


Text = "Template Designer"; Text = "Template Designer";
MinimumSize = new Size(720, 480);
// Sprint 4: the properties panel grew from 7 rows to 9 (added Angle and Collapse-if-blank
// rows) — 480 was tuned for the original 7-row panel and started clipping the two new
// rows below the window's visible client area. The properties panel's own height is
// whatever's left over after the fixed-height rows above it (file/CSV toolbars, the
// 140px CSV preview grid, canvas settings, element toolbar — roughly 340px of fixed
// overhead), so the window needed enough *extra* height to cover the two added 40px rows
// 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);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;


Controls.Add(BuildLayout()); Controls.Add(BuildLayout());
@@ -360,11 +376,35 @@ public sealed class TemplateDesignerForm : Form
PopulateDynamicFieldColumnComboBox(); PopulateDynamicFieldColumnComboBox();
RefreshPropertiesPanel(); RefreshPropertiesPanel();


// Sprint 4: the canvas previews address-line collapsing and mapping-error highlighting
// against one representative sample record — the first loaded sample row, if any. A CSV
// with headers but zero data rows still loads successfully (Sprint 3's "Load CSV" story),
// so there may be no sample record to preview against; the canvas treats that the same as
// "no CSV loaded yet" for collapsing purposes (nothing to judge blankness against), while
// still using the headers to flag genuinely unmapped columns.
var sampleRecord = result.SampleRows.Count > 0 ? BuildSampleRecord(result.Headers, result.SampleRows[0]) : null;
_canvas.SetCsvPreviewContext(_loadedCsvHeaders, sampleRecord);

SetCsvStatus( SetCsvStatus(
$"Loaded '{path}' ({result.Headers.Count} column(s), {result.SampleRows.Count} sample row(s)).", $"Loaded '{path}' ({result.Headers.Count} column(s), {result.SampleRows.Count} sample row(s)).",
isError: false); isError: false);
} }


/// <summary>Builds a header -> value lookup for one positional sample row, the shape
/// <see cref="AddressBlockPreviewCalculator"/> expects — case-insensitive, matching how the
/// CLI's own <c>CsvRecordSource</c> resolves `column="..."` references.</summary>
private static IReadOnlyDictionary<string, string> BuildSampleRecord(
IReadOnlyList<string> headers, IReadOnlyList<string> row)
{
var record = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < headers.Count && i < row.Count; i++)
{
record[headers[i]] = row[i];
}

return record;
}

private void PopulateCsvPreviewGrid(CsvPreviewResult result) private void PopulateCsvPreviewGrid(CsvPreviewResult result)
{ {
_csvPreviewGrid.SuspendLayout(); _csvPreviewGrid.SuspendLayout();
@@ -428,12 +468,20 @@ public sealed class TemplateDesignerForm : Form
// stacking-order control. // stacking-order control.
AddPropertyRow(5 * rowHeight, "CSV column:", _rebindColumnComboBox, leftMargin, labelTop, inputTop); AddPropertyRow(5 * rowHeight, "CSV column:", _rebindColumnComboBox, leftMargin, labelTop, inputTop);
AddPropertyRow(6 * rowHeight, "Z-order (0 = bottom):", _zOrderInput, leftMargin, labelTop, inputTop); AddPropertyRow(6 * 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);
// 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);
_propertiesPanel.Controls.Add(_collapseIfBlankInput);


_xInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetX((double)_xInput.Value); _canvas.NotifyElementChanged(); } }; _xInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetX((double)_xInput.Value); _canvas.NotifyElementChanged(); } };
_yInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetY((double)_yInput.Value); _canvas.NotifyElementChanged(); } }; _yInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetY((double)_yInput.Value); _canvas.NotifyElementChanged(); } };
_fontFamilyInput.TextChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontFamily(_fontFamilyInput.Text); _canvas.NotifyElementChanged(); } }; _fontFamilyInput.TextChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontFamily(_fontFamilyInput.Text); _canvas.NotifyElementChanged(); } };
_fontSizeInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontSize((double)_fontSizeInput.Value); _canvas.NotifyElementChanged(); } }; _fontSizeInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetFontSize((double)_fontSizeInput.Value); _canvas.NotifyElementChanged(); } };
_zOrderInput.ValueChanged += (_, _) => { if (!_suppressEvents) { _propertiesEditor.SetZOrder((int)_zOrderInput.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(); } };
_colorButton.Click += (_, _) => OnColorButtonClick(); _colorButton.Click += (_, _) => OnColorButtonClick();
_rebindColumnComboBox.SelectedIndexChanged += (_, _) => _rebindColumnComboBox.SelectedIndexChanged += (_, _) =>
{ {
@@ -510,6 +558,8 @@ public sealed class TemplateDesignerForm : Form
_fontFamilyInput.Text = selected?.FontFamily ?? string.Empty; _fontFamilyInput.Text = selected?.FontFamily ?? string.Empty;
_fontSizeInput.Value = ClampToNumericRange(_fontSizeInput, (decimal)(selected?.FontSize ?? 12)); _fontSizeInput.Value = ClampToNumericRange(_fontSizeInput, (decimal)(selected?.FontSize ?? 12));
_zOrderInput.Value = ClampToNumericRange(_zOrderInput, selected?.ZOrder ?? 0); _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; var color = selected?.Color ?? RgbColor.Black;
_colorButton.BackColor = Color.FromArgb(color.R, color.G, color.B); _colorButton.BackColor = Color.FromArgb(color.R, color.G, color.B);
RefreshRebindColumnComboBox(selected); RefreshRebindColumnComboBox(selected);


+ 1
- 1
logs/process_improvement_log.md Просмотреть файл

@@ -5,5 +5,5 @@ Append-only log of insights about the **Scrum kit itself** (this repo's `process
| Date | Sprint | Insight | Affected file(s) | Recurrence | Status | Resolution | | Date | Sprint | Insight | Affected file(s) | Recurrence | Status | Resolution |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| | | | | 1st time / 2nd time / 3rd+ | Watching / Proposed / Applied / Rejected | | | | | | | 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 | Watching | Not yet actioned. Revisit at Sprint 4 review: if product-owner's review notes for that sprint are again written independently at review time (or if this pattern recurs and produces an actual missed AC), decide then whether `process/04_sprint_review.md` needs an explicit instruction that acceptance-criteria verification must be authored by product-owner at review time, not inherited from a story's own completion notes. |
| 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-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. | | 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. |

+ 10
- 4
state.md Просмотреть файл

@@ -6,11 +6,11 @@
**Leading agent:** `product-owner` **Leading agent:** `product-owner`
**Process file:** `process/01_backlog_refinement.md` **Process file:** `process/01_backlog_refinement.md`


**Sprint:** 3 closed. Sprint 4 not yet planned.
**Sprint goal (Sprint 3, closed):** Close the high-volume render throughput risk with a confirmed root cause and a measured mitigation, and deliver the first slice of CSV field mapping (load headers, bind a dynamic field, rebind it). Met in full.
**Current sprint backlog:** none active — see `backlog/sprints/sprint-3.md` (closed) and `backlog/sprints/sprint-3-retrospective.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`.


**Next action:** Sprint 3 retrospective complete (`backlog/sprints/sprint-3-retrospective.md`) — full follow-through on all 4 Sprint 2 retro actions confirmed, no drops; four Sprint 4 action items and one process "Watching" item logged (see `logs/process_improvement_log.md`, 2026-09-22 entry); no kit edits made. Mid-refinement, the user requested a new rotation capability for text/dynamic field elements. `product-owner` clarified scope with the user, then wrote and readied two new dependent stories in `backlog/epics/02_template_designer_gui_foundation.md` (reopened from Done): "Set a rotation angle for text and dynamic field elements" (8 points) and "Rotate elements by dragging a handle on the canvas" (5 points) — `dev-team` sized both with real code/vendor-API inspection, both pass the Definition of Ready. `backlog/backlog.md` records a placement recommendation: slot rotation after the two already-flagged Sprint 4 carryover items ("Collapse blank optional address lines," 5 pts; "Harden production configuration delivery," 2 pts), for a possible 20-point Sprint 4 (top of the 19-20 point velocity range — a ceiling to watch, not a target). **This remains a natural pause point — confirm with the user before starting Sprint 4 planning.** The backlog now has enough Ready work (20 points across 4 items) to fill Sprint 4; refinement's exit criteria are met whenever the user is ready to move to Phase 2.
**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.


## Phase reference ## Phase reference


@@ -47,3 +47,9 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-09-21 to 2026-09-22 | 3 - Sprint execution | `dev-team` completed all 4 committed batches in dependency order across 2 daily-scrum entries. Batch 1 (throughput investigation, 8 points): confirmed the Debenu-internal-document-model hypothesis via a scaled-down real-DLL probe, implemented a batch+merge mitigation in `DebenuPdfRenderer`, and re-ran the full 100k-record benchmark to completion at 315s (~47.5% under the 10-minute target) — closing the High-impact debt item opened at Sprint 2 close, while honestly logging a new, narrower Medium-impact file-size risk at the 1,000,000-record ceiling rather than overclaiming full closure. Batches 2-4 (CSV Integration chain, 10 points): loaded CSV headers into the desktop app, bound a dynamic text field to a real column, and added rebind-without-repositioning — all live-verified against the real built `.exe`. 223/223 tests passing (up from 195). | | 2026-09-21 to 2026-09-22 | 3 - Sprint execution | `dev-team` completed all 4 committed batches in dependency order across 2 daily-scrum entries. Batch 1 (throughput investigation, 8 points): confirmed the Debenu-internal-document-model hypothesis via a scaled-down real-DLL probe, implemented a batch+merge mitigation in `DebenuPdfRenderer`, and re-ran the full 100k-record benchmark to completion at 315s (~47.5% under the 10-minute target) — closing the High-impact debt item opened at Sprint 2 close, while honestly logging a new, narrower Medium-impact file-size risk at the 1,000,000-record ceiling rather than overclaiming full closure. Batches 2-4 (CSV Integration chain, 10 points): loaded CSV headers into the desktop app, bound a dynamic text field to a real column, and added rebind-without-repositioning — all live-verified against the real built `.exe`. 223/223 tests passing (up from 195). |
| 2026-09-22 | 4 - Sprint review | `product-owner` verified all 4 Sprint 3 stories against acceptance criteria (verdict: sprint goal met in full, no gaps). Updated `backlog/backlog.md`'s epic status table and added a "Sprint 3 Review outcome" section; added an independent confirmation note to the throughput story in `backlog/epics/05_cli_rendering_engine_and_debenu_integration.md`. Confirmed the new file-size technical debt item as open-but-non-blocking; unblocked "Collapse blank optional address lines" for Sprint 4. | | 2026-09-22 | 4 - Sprint review | `product-owner` verified all 4 Sprint 3 stories against acceptance criteria (verdict: sprint goal met in full, no gaps). Updated `backlog/backlog.md`'s epic status table and added a "Sprint 3 Review outcome" section; added an independent confirmation note to the throughput story in `backlog/epics/05_cli_rendering_engine_and_debenu_integration.md`. Confirmed the new file-size technical debt item as open-but-non-blocking; unblocked "Collapse blank optional address lines" for Sprint 4. |
| 2026-09-22 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-3-retrospective.md`). Confirmed full follow-through on all four Sprint 2 retro action items, the second clean full-follow-through sprint in a row — notably, the Sprint 2-named test-count arithmetic watch item did not recur. Named a new team strength ("verify spec/code before building," caught a stale token-format assumption pre-implementation) and logged one low-severity, first-occurrence process watch item (product-owner's Sprint 3 review leaning on dev-team's pre-written verification notes for 3 of 4 stories) to `logs/process_improvement_log.md` rather than the sprint plan, per the kit/team-insight split — no kit edit made, revisit at Sprint 4 review. Checked all five named anti-patterns (including the Sprint 1 status-theater near-miss); none found. | | 2026-09-22 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-3-retrospective.md`). Confirmed full follow-through on all four Sprint 2 retro action items, the second clean full-follow-through sprint in a row — notably, the Sprint 2-named test-count arithmetic watch item did not recur. Named a new team strength ("verify spec/code before building," caught a stale token-format assumption pre-implementation) and logged one low-severity, first-occurrence process watch item (product-owner's Sprint 3 review leaning on dev-team's pre-written verification notes for 3 of 4 stories) to `logs/process_improvement_log.md` rather than the sprint plan, per the kit/team-insight split — no kit edit made, revisit at Sprint 4 review. Checked all five named anti-patterns (including the Sprint 1 status-theater near-miss); none found. |
| 2026-09-22 | 1 - Backlog refinement | Mid-refinement, the user requested a new text/dynamic-field rotation capability. `product-owner` clarified scope directly with the user (angle range, UI interaction, pivot point), then wrote and readied two new dependent stories in `backlog/epics/02_template_designer_gui_foundation.md` (reopened from Done): "Set a rotation angle for text and dynamic field elements" (8 pts) and "Rotate elements by dragging a handle on the canvas" (5 pts). `dev-team` sized both with real code/vendor-API inspection (confirmed Debenu's `DrawRotatedText` rotates about an anchor point, not a center, so the center-pivot math is genuine new work). Both pass the Definition of Ready. |
| 2026-09-28 | 2 - Sprint planning | `scrum-master` facilitated Sprint 4 planning. Velocity range tightened to 18-20 points (Sprint 3 completed its exact 18-point commitment, zero scope change). Committed 18 points at the low end of the range rather than the ceiling: "Collapse blank optional address lines consistently" (5 pts, completes epic 3) plus the full rotation chain (8 + 5 pts, epic 2). "Harden production configuration delivery" (2 pts) deliberately left as an explicit stretch item. Recorded in `backlog/sprints/sprint-4.md`. |
| 2026-09-28 to 2026-09-29 | 3 - Sprint execution | `dev-team` completed all 3 committed batches in dependency order. Batch 1 (blank-line collapse, 5 points): new `AddressLineCollapser` row-height-shift geometry, `collapsible` XML attribute, and a dashed-orange canvas highlight distinguishing an unmapped column from a merely-blank one. Batch 2 (rotation angle property, 8 points): empirically confirmed against the real Debenu DLL that positive angle is counterclockwise, built `RotatedTextAnchorCalculator` for bounding-box-center pivot math, proved it with a pixel-ink-centroid regression test. Batch 3 (drag-to-rotate handle, 5 points): reused Batch 2's panel/canvas sync plumbing exactly as its sizing note predicted; live-verified with real Win32 mouse-drag input, saved-XML value matched the drag to full floating-point precision. 288/288 tests passing (up from 223). One honestly-reported testing-tooling glitch (not a product defect), correctly not logged as debt. |
| 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. |

Загрузка…
Отмена
Сохранить

Powered by TurnKey Linux.