You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

48KB

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: Done

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

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

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

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

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

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

Group lines into a single, movable Address Control - Status: Done

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

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

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

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

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

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

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

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

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

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

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

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

Conversation notes

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

Confirmation (Acceptance Criteria)

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

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

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

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

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

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

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

Conversation notes

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

Confirmation (Acceptance Criteria)

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

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

Powered by TurnKey Linux.