From a4a9420878f3fe522333f54dc9460649896090a1 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Tue, 8 Sep 2026 14:14:05 -0400 Subject: [PATCH] Sprint 10 Batch 1: wrap/clip indicator, plus delete and resize-handle fixes Sprint 10 backlog refinement/planning: re-confirmed the epic-4 "rotated wrap/indicator" story after Sprint 9 shipped rotation composition as a byproduct; renamed and re-sized it down to the indicator alone (3 pts). Committed it alongside epic 6's long-deferred multi-select/align pair. Batch 1 shipped: a live wrap/clip indicator (dashed-orange outline) on the canvas and preview panel, backed by a new framework-free WrapClipDetector. Mid-batch, live-build feedback surfaced two real gaps, fixed together: - No way to delete a placed element or Address Control - added CanvasElementEditor.RemoveSelected()/TemplateCanvasControl. RemoveSelectedElement(), wired to Delete/Backspace and a toolbar button. - The resize handle should control only box width, with font size set elsewhere - removed the Sprint 8 uniform-font-scale handle behavior entirely. HasBox now means "Width is set" alone; Height is independent and optional (new HasHeightClip flag). DebenuPdfRenderer gained a "Width alone, auto-height, no clip" render path using Debenu's DrawWrappedText/GetWrappedText+DrawRotatedMultiLineText. Verified via a real licensed end-to-end CLI render (unrotated and rotated auto-height wrap, no clipping) and a reflection-driven built-form harness (real mouse-driven resize drag, real Delete key, real canvas screenshot). Test suite grew from 476/476 to 488/488. Co-Authored-By: Claude Sonnet 5 --- backlog/backlog.md | 85 ++++++++- .../02_template_designer_gui_foundation.md | 8 + .../04_live_preview_and_record_navigation.md | 125 +++++++------ backlog/sprints/sprint-10.md | 36 ++++ code/TEMPLATE_FORMAT.md | 39 ++-- .../DebenuPdfRendererWrapTests.cs | 116 +++++++++++- .../TemplateXmlParserTests.cs | 23 ++- .../Render/DebenuPdfRenderer.cs | 68 +++++-- .../Render/TemplateElement.cs | 14 +- .../Design/CanvasElementEditor.cs | 142 +++++---------- .../Design/TextElementLayout.cs | 13 +- .../Design/WrapClipDetector.cs | 24 +++ .../CanvasElementEditorTests.cs | 168 +++++++++--------- .../TemplateLayoutXmlSerializerTests.cs | 25 ++- .../TextElementPropertiesEditorTests.cs | 8 +- .../WrapClipDetectorTests.cs | 61 +++++++ .../Views/TemplateCanvasControl.cs | 133 +++++++++++++- .../Views/TemplateDesignerForm.cs | 14 ++ state.md | 25 +-- 19 files changed, 823 insertions(+), 304 deletions(-) create mode 100644 backlog/sprints/sprint-10.md create mode 100644 code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs diff --git a/backlog/backlog.md b/backlog/backlog.md index 6505388..7c74b45 100644 --- a/backlog/backlog.md +++ b/backlog/backlog.md @@ -8,7 +8,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i | 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (7 of 7 stories — Sprint 2, 4, 6) | | 3 | CSV Integration and Field Mapping | `epics/03_csv_integration_and_field_mapping.md` | Done (4 of 4 stories — Sprint 4) | | 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) | -| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Support rotated wrapped text..." (8 pts, flagged for re-sizing — largely already delivered as a byproduct) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) | +| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) | | 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) | | 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) | | 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) | @@ -200,6 +200,89 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i geometry concept, with a possible further split recommended at sizing time if it doesn't fit one sprint even alone. +### Sprint 10 planning outcome (2026-10-27) + +`scrum-master` facilitated with `product-owner`/`dev-team` input, immediately following Sprint 10 +backlog refinement above. + +- Capacity signal now has nine data points (Sprints 1-9: 20, 19, 18, 18, 15, 18, 18, 13, 13). The + 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented, + deliberate under-commits (Sprint 5's large/risky story, Sprint 8's fresh-priority story, Sprint + 9's fresh-priority story), not capacity misses. +- **Committed 13 points**: "Add a live wrap/clip indicator for text elements" (3 pts, epic 4) paired + with epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) + + align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair, pulled whole + per this team's established practice. Full plan: `backlog/sprints/sprint-10.md`. +- **Reasoning for finally pulling epic 6's pair now, explicit rather than default:** it has been + Ready and waiting since Sprint 7 — deferred at Sprint 8 planning (Address Control rotation was a + fresher, more urgent human request) and again at Sprint 9 planning (the wrap/clip core story was + both fresher and ahead in table order). Neither reason applies with the same force this sprint: + the wrap/clip chain's own next step is now only 3 points, leaving real spare capacity, and no + new, fresher request has displaced epic 6 a third time. Continuing to defer a Ready, sized, + 3-sprint-old pair without a stated reason would itself become the anti-pattern this team's own + practice exists to avoid. +- **Why pairing (not a concern the "two large/novel stories" rule blocks):** the established + caution is about stacking *two* large, first-of-its-kind, architecturally risky stories in one + sprint (e.g., Sprint 8's rejected rotation-drag-handle + multi-select combination). Here, only + one side of the pairing — multi-select's new selection model — carries that character; the + indicator story is small, additive, and touches no shared foundational state (it only reads + existing box/rotation data to decide what to draw). This is a small-plus-one-large combination, + not two-large, so the rule's concern does not apply. +- **Checked for anything else to round out capacity — nothing else fits.** "...Address Control + lines" (8 pts) depends on the indicator landing first and reusing its design, so pulling both in + the same sprint would mean building the indicator and then immediately extending a still-warm + design under time pressure rather than letting it settle — deferred to Sprint 11. "Undo and redo + layout changes" (13 pts) alone would total 16 with the indicator, a reasonable combination in + principle, but was not chosen over the multi-select/align pair given the pair's own 3-sprint-old + deferral making it the more overdue commitment. "Complete the first text-only operator workflow" + (5 pts) remains explicitly excluded pending its own stale-label re-verification. +- No changes to the two open impediments (template asset path strategy; UNC timeout/retry + behavior, both in `logs/impediment_log.md`) — still non-blocking for this sprint's committed + items. + +### Sprint 10 backlog refinement outcome (2026-10-27) + +`product-owner` ran Sprint 10 backlog refinement after the human product owner confirmed the +post-Sprint-9-retrospective pause point with "continue," folding in the Sprint 9 retrospective's +own flagged action item directly rather than deferring it. + +- **Re-confirmed/re-sized "Support rotated wrapped text and a live wrap/clip indicator for text + elements" as the retrospective asked**, via real code inspection and a real, licensed end-to-end + test rather than assumption. Confirmed `DebenuPdfRenderer.AddPage`'s box branch already calls + `DrawRotatedTextBox` for any rotated boxed draw, and both `TemplateCanvasControl.DrawElement`'s + and `TemplatePreviewControl.DrawItem`'s box-mode draw already run inside the same rotation + transform every other element uses — rotation composition was a byproduct of Sprint 9's own + implementation shape, not deferred work as the original estimate assumed. Proved this with a + real template (20°-rotated, 150x80pt boxed static element) rendered through the actual built + `EnvelopeRenderer.Cli.exe`, visually confirming correct wrapped-and-rotated output, plus a new + permanent regression test (`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_...`). +- **Renamed and re-sized the story**: "Support rotated wrapped text and a live wrap/clip indicator + for text elements" (8 pts) becomes "Add a live wrap/clip indicator for text elements" (**3 + pts**) — its former rotation-composition ACs are already satisfied, so all that remains is the + passive visual indicator itself. Updated its dependency references in the Address Control + wrap/clip story and "Warn on text overflow before render." Full detail: + `epics/04_live_preview_and_record_navigation.md`. +- **Real finding worth naming, not just the estimate change:** verifying exact wrap/clip behavior + under rotation via automated pixel-row counting turned out to be unreliable (a heavily-clipped + rotated block can occupy *more* rasterized pixel rows than a taller, unclipped one, since + rotation angle affects vertical footprint as much as line count does) — discovered while writing + the new regression test, and documented directly in that test's own remarks so a future session + doesn't rediscover the same dead end. The test was simplified to assert real content renders + correctly rather than a specific row-count relationship; the stronger correctness evidence + remains the direct screenshot inspection already performed. +- **No other Ready items needed re-verification.** Epic 6's multi-select/align pair (10 pts) and + undo/redo (13 pts) were already re-verified at Sprint 9's own refinement with no material change + found; nothing has changed in that area since. +- **Recommended Sprint 10 candidate:** "Add a live wrap/clip indicator for text elements" (3 pts) + as a small, low-risk, self-contained pull — it has no dependency beyond Sprint 9's already-Done + story. Given its small size, `scrum-master`/`dev-team` should weigh at planning whether to pair + it with another Ready item to round out capacity, per this team's established practice of not + leaving obviously-available capacity idle without a stated reason. +- No changes to the two open impediments (template asset path strategy; UNC timeout/retry + behavior, both in `logs/impediment_log.md`) — still non-blocking. "Complete the first text-only + operator workflow" (epic 1, 5 pts) remains flagged as needing re-verification before ever being + pulled — not resolved in this refinement, carried forward as-is. + ### Sprint 9 Review outcome (2026-10-27) - The committed story (13/13 points) is Done; verified against all 6 acceptance criteria with diff --git a/backlog/epics/02_template_designer_gui_foundation.md b/backlog/epics/02_template_designer_gui_foundation.md index c4d5416..f3e9904 100644 --- a/backlog/epics/02_template_designer_gui_foundation.md +++ b/backlog/epics/02_template_designer_gui_foundation.md @@ -45,6 +45,14 @@ As a **print operator**, I want to place static and dynamic text placeholders on **Post-Sprint-8 user-reported fix (2026-10-26, handled outside formal ceremony):** User reported that dragging or rotating a dynamic/mixed-content element on the canvas felt visibly choppy, not smooth like a static element. Root cause confirmed by code inspection before writing anything down: `TemplateCanvasControl.OnMouseMove` fires `ElementsChanged` on every single mouse-move tick of a drag or rotate gesture, and `TemplateDesignerForm`'s handler responded by running the full `RefreshPropertiesPanel()` — which, for an element bound to a single CSV column (`HasSingleColumnRun`), also calls `RefreshRebindColumnComboBox`, clearing and fully repopulating the rebind combo box from every loaded CSV header (plus a `.ToList().FindIndex(...)` re-scan) on every tick. A purely static element skips that path entirely (`isSingleColumn` is false), which is exactly why the choppiness was specific to dynamic/mixed elements and easy to miss during Sprint 2's original static-vs-dynamic drag verification. Fixed by adding `TemplateCanvasControl.IsInteracting` (true while any move/resize/rotate gesture is active) and a new cheap `TemplateDesignerForm.RefreshInteractivePositionFields()` that syncs only the X/Y/width/angle fields a gesture can actually change; the full panel refresh now runs once, on `OnMouseUp`, instead of on every tick. Live-verified with a reflection-driven harness (same technique as prior sprints) against the real built assembly, using the real 22-header sample CSV and a dynamic placeholder bound to "Full Name": before the fix, 60 synthetic drag ticks took 88.56 ms total (1.48 ms/tick) and enumerated the header list 120 times *during* the drag; after the fix, the same 60 ticks took 1.32 ms total (0.022 ms/tick, a ~67x improvement) with 0 header-list enumerations during the drag and exactly one full refresh firing after mouse-up — confirmed identically for the rotate gesture. Position and angle were confirmed to still update correctly on every tick (the element visibly moved/rotated throughout), so the fix removes only the redundant expensive work, not the live feedback itself. Full test suite re-run: 433/433 passing, no regression. Not logged as technical debt (resolved same-day, not left open); documented here per this team's established pattern for same-day post-review/ad-hoc user feedback (see `state.md`'s history for the 2026-10-19 precedents). +**Post-Sprint-9 user-requested correction and gap fix (2026-10-27, handled outside formal ceremony, mid-Sprint-10):** User reported two issues while reviewing the live build: (1) no way to delete a placed element or Address Control at all; (2) the canvas resize handle should control box width only, with font size set exclusively elsewhere — reversing the Sprint 8 "handle scales font size when there's no box" behavior above, not just refining it. Clarified the exact intended behavior with the user via one round of questions before changing anything. + +*Delete:* added `CanvasElementEditor.RemoveSelected()` (Desktop.Core) and `TemplateCanvasControl.RemoveSelectedElement()` (handles both a standalone element and a whole Address Control — never a drilled-into address line, which already has its own `RemoveSelectedAddressLine`). Wired to both the Delete/Backspace key on the canvas (`OnKeyDown`, guarded against firing mid-gesture) and a new "Delete Selected" toolbar button, enabled only while something is selected. `TemplateCanvasControl` needed `ControlStyles.Selectable`/`TabStop`/an explicit `Focus()` call in `OnMouseDown` to receive key events at all — a plain `Control` (unlike `UserControl`) isn't focusable by default, which is the root cause this gap existed in the first place. + +*Resize handle:* removed the Sprint 8 uniform-font-scale behavior entirely rather than keeping it conditional. `CanvasElementEditor.ResizeDragTo` now only ever sets `TextElementLayout.Width` from the pointer's horizontal distance to the fixed (X, Y) anchor — never `Height`, never `FontSize`. The handle itself moved from the box's top-right corner to the right-edge midpoint, mirroring the Address Control's own width-only handle position, so its placement communicates "width only" rather than implying a corner-drag scale. `Width`'s own gating logic changed as a direct consequence (see the dependent "Add an adjustable width and height..." story's own post-Sprint-9 note in epic 4): `TextElementLayout.HasBox` now means "Width is set" alone, with `Height` independent and optional (a `HasHeightClip` flag), rather than requiring both together as Sprint 9 originally shipped. + +Tests: removed 8 obsolete font-scale-specific tests, added 15 new ones (3 `RemoveSelected*` tests; the rest updated/added resize-handle and `HasBox`/`HasHeightClip` semantic tests across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, and the CLI's `TemplateXmlParserTests`/`DebenuPdfRendererWrapTests`). Full suite 476/476 -> 488/488. Live-verified via a reflection-driven built-form harness: a real mouse-driven drag on the real resize handle grew `Width` to 578.9pt while `Height` stayed null and `FontSize` stayed exactly 12; the real Delete key removed the selected element and cleared selection; a fresh narrow-boxed (120pt) element's canvas screenshot shows genuine multi-line wrap plus the dashed-orange wrap indicator (see the epic 4 indicator story) correctly appearing. One disclosed evidence-depth caveat: the toolbar "Delete Selected" button's own `PerformClick()` did not reliably fire in this synthetic reflection harness (a known WinForms quirk for a button never shown in a real top-level window with a running message loop) — the underlying `RemoveSelectedElement()` method was proven correct via a direct call returning `true` and removing the control, and the button's `Click` wiring is structurally identical to every other already-shipped, real-world-proven toolbar button in this form, but the button's own click-to-delete path specifically was not re-observed through a real mouse click. + **Estimate:** 5 points **Dependencies:** None diff --git a/backlog/epics/04_live_preview_and_record_navigation.md b/backlog/epics/04_live_preview_and_record_navigation.md index 089463b..ff152f7 100644 --- a/backlog/epics/04_live_preview_and_record_navigation.md +++ b/backlog/epics/04_live_preview_and_record_navigation.md @@ -162,6 +162,23 @@ avoidable, not a genuine environment constraint — flagged for the retrospectiv retroactively (those sprints' shipped behavior was still verified correct by other means at the time). +**Post-Sprint-9 semantics correction (2026-10-27, user-requested, handled outside formal ceremony, +mid-Sprint-10):** the human product owner reported the canvas resize handle should control only +box width, with font size set exclusively via the properties panel. This required decoupling +`Width` and `Height`'s gating logic from how this story originally shipped them: `HasBox` now +means "Width is set" alone (not "both Width and Height"), with `Height` independent and optional +(a new `HasHeightClip` flag) — present alone with `Width`, it's a clip ceiling exactly as this +story originally specified; present without `Width`, it has no effect; absent entirely, wrapped +content grows to whatever height it needs (no clip). `DebenuPdfRenderer.AddPage` gained a new +"Width alone" branch using Debenu's `DrawWrappedText` (unrotated) or a `GetWrappedText`-then- +`DrawRotatedMultiLineText` composition (rotated, no native "rotated wrap, no height" primitive +exists) — both confirmed via a real, licensed end-to-end CLI render showing correct multi-line +wrap with no clipping, unrotated and rotated. This did not change this story's own original ACs +(width+height together still wrap-then-clip exactly as shipped) — it only changes what happens +when just one dimension is set, which this story's original acceptance criteria didn't fully +specify. Full detail, including the resize-handle side of this change: `epics/02_template_ +designer_gui_foundation.md`'s matching post-Sprint-9 note. + **Card** As a **print operator**, I want to give a static or dynamic text element a real width and height so that long or variable-length content wraps within a defined box instead of running off @@ -268,67 +285,64 @@ real PDF) without requiring the polish layer first. are all Done). Should land before the Address Control extension story, the rotated-wrap/indicator story immediately below, and before "Warn on text overflow before render." -### Support rotated wrapped text and a live wrap/clip indicator for text elements - Status: Ready +### Add a live wrap/clip indicator for text elements - Status: Ready (re-scoped and re-sized, 2026-10-27 — see note) **Card** -As a **print operator**, I want a rotated text element's wrapped/clipped content to behave exactly -like an unrotated one, and to see at a glance when a box is currently wrapping or clipping content, -so that I don't have to guess or rotate back to check. - -**Conversation notes** -- Split out from the story above during Sprint 9 refinement (`dev-team` sizing, 2026-10-26): the - combined scope (persisted geometry + native wrap/clip render + rotation composition + a new - visual indicator) was too large to responsibly size as one story and land in a single sprint. - This story is the "polish" layer on top of the previous story's core capability, the same - fallback-split shape "Mix static text and CSV fields" itself once offered (core vertical slice - now, polish layer next) rather than a new pattern invented here. -- Rotation and wrapping have never coexisted in this product before. Confirmed by code inspection: - Debenu exposes `DPLDrawRotatedMultiLineText` alongside the unrotated wrap primitives found in the - previous story's sizing note — the empirical question for `dev-team` to resolve here is whether - that entry point (or `DPLGetWrappedText` to pre-compute line breaks, then feeding each line - through the existing per-line rotated-anchor `TextDraw` path Sprint 6 already established) is the - right fit, mirroring how the original rotation story empirically tested `DrawRotatedText` before - committing to a design. +As a **print operator**, I want to see at a glance when a box is currently wrapping or clipping +content, so that I don't have to guess or eyeball the preview to notice. + +**Re-scoping note (Sprint 10 refinement, 2026-10-27, `product-owner`/`dev-team`):** this story was +originally "Support rotated wrapped text and a live wrap/clip indicator," carrying an 8-point +estimate that assumed rotated wrap composition was unconfirmed, undelivered work. Confirmed by +direct inspection and a real, licensed end-to-end test during Sprint 9 execution that this is not +the case: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox` whenever +`draw.Angle != 0`, and `TemplateCanvasControl.DrawElement`/`TemplatePreviewControl.DrawItem` both +run their box-mode draw *inside* the same rotation transform every other element already uses — so +rotation composition was a byproduct of Sprint 9's own implementation shape, not deferred work. +Proven, not assumed: a hand-authored template with a 20°-rotated, 150x80pt boxed static element, +rendered through the real `EnvelopeRenderer.Cli.exe` against the real sample CSV and visually +inspected, shows the text correctly wrapped onto 4 lines rotating together as one rigid unit (see +the previous story's development-verification note); a new regression test, +`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_WrapsOntoMultipleLinesAsOneRigidRotatedUnit`, +locks this in. **This story's former AC1 (rotated wrap consistency across all three surfaces) and +AC3/AC4 (rigid-unit rotation, no regression) are therefore already satisfied — confirmed, not +re-verified from scratch here.** The only genuinely remaining scope is the live visual indicator +(the former AC2), which does not exist in any form yet. Renamed accordingly so the story's title +matches its actual remaining scope. - The box, its wrapped lines, and the wrap/clip indicator itself must all rotate together as one - rigid unit — the same rigid-group approach Address Control rotation (Sprint 8) already - established, reused here rather than invented fresh. + rigid unit — already true for the box/lines (confirmed above); the new indicator must be drawn + inside the same existing rotation transform so it inherits this for free, not reinvent it. - A visible on-canvas/preview indicator (e.g., a colored border or badge, in the same visual language as the existing dashed-orange unmapped-column warning) shows when the *current* sample record's content is actually wrapping and/or being clipped for the selected element — the "warn - this is happening" half of the original request, distinct from and in addition to the full-CSV - pre-render validation pass in "Warn on text overflow before render" below. + this is happening" half of the original "make the boxes longer" request, distinct from and in + addition to the full-CSV pre-render validation pass in "Warn on text overflow before render" + below. +- Detecting "is this wrapping/clipping right now" for the indicator is a measurement question, not + a drawing one: on the canvas/preview (GDI+), compare the resolved text's natural (unwrapped) + measured size against the box; whether GDI+ exposes a direct "would this clip" query or this + needs a manual line-count comparison (mirroring how the CLI could ask Debenu's + `GetWrappedTextLineCount`, though the CLI has no live indicator to build here — this story is + canvas/preview only) is a Development Team implementation detail to confirm empirically, not + decided here. **Confirmation (Acceptance Criteria)** -- [ ] A rotated element with a set width/height wraps and rotates consistently across all three - surfaces (CLI render, canvas, preview panel) — the box and its wrapped lines rotate together - as one rigid unit, never independently. - [ ] The canvas and preview panel visibly indicate, for the currently previewed sample record, when the element's content is wrapping and/or being clipped, in both rotated and unrotated states. - [ ] The wrap/clip indicator itself rotates together with the box rather than staying axis-aligned while the box it describes is rotated. -- [ ] No regression to the previous story's unrotated wrap/clip behavior or to any other rotation - acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8). - -**Estimate:** 8 points (`dev-team`, 2026-10-26) — smaller than the previous story since the -persisted model, the native-call investigation pattern, and the shared text-resolution routine are -already established; the genuinely new work here is narrower: one more empirical vendor-API -question (rotated wrap composition) plus a new, but visually-precedented (dashed-orange-style), -indicator. Comparable to "Rotate elements by dragging a handle on the canvas" (5 points) in shape -but larger, since rotation there had a direct existing pattern to copy for its bidirectional-sync -plumbing, whereas rotated wrap composition here has no existing precedent in this codebase at all. - -**Re-sizing flag (Sprint 9, 2026-10-27, `dev-team`):** while implementing the previous story, the -"one more empirical vendor-API question" this estimate was built around got answered as a -byproduct, not deferred to this story as planned — `DebenuPdfRenderer` already calls Debenu's -native `DrawRotatedTextBox` for a rotated boxed draw (confirmed via a real, licensed probe: -rotation and wrapping compose correctly in one native call, no manual pre-wrap-then-rotate-per-line -synthesis needed). AC1 (rotated wrap consistency across CLI/canvas/preview) is very likely already -satisfied by the previous story's shipped code, not new work. What's left for this story is -narrower than sized: primarily the live wrap/clip visual indicator (AC2/AC3) and confirming/testing -the rotated case explicitly rather than building it. Not re-sized here — flagged for -`product-owner`/`dev-team` to re-confirm at the next backlog refinement before this is committed to -a sprint, rather than silently shipping at a stale, likely-inflated estimate. +- [ ] No regression to the previous story's rotated or unrotated wrap/clip behavior, or to any + other rotation acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8, Sprint 9). + +**Estimate:** 3 points (`dev-team`, 2026-10-27, re-sized down from 8) — the rotation-composition +work the original 8-point estimate was sized around turned out to already exist as a byproduct of +the previous story's implementation shape (confirmed above, not assumed). What's left is narrow: +a measurement comparison (resolved text size vs. box) plus a small, visually-precedented +(dashed-orange-style) drawing addition on the canvas and preview panel, both of which already share +one drawing/rotation code path per element. Comparable to "Snap elements to grid and guides" (5 +points) in shape but smaller, since that story added new interactive drag behavior across two +gesture types, while this one only adds a passive visual check plus drawing. **Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and dynamic text elements" (above, same epic — Done, Sprint 9). @@ -389,9 +403,13 @@ Sized the same as "Group lines into a single, movable Address Control" reused Sp but still carried genuine new composite-element scope — the same shape here: real reuse, real residual scope, not a trivial pass-through. **Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and -dynamic text elements" and "Support rotated wrapped text and a live wrap/clip indicator for text -elements" (both above, same epic) — sequenced after both so Address Control's rotation-composed -wrap/clip and indicator can directly reuse a proven pattern rather than prove it here first. +dynamic text elements" and "Add a live wrap/clip indicator for text elements" (both above, same +epic) — sequenced after both so Address Control's rotation-composed wrap/clip and indicator can +directly reuse a proven pattern rather than prove it here first. Note (2026-10-27): rotation +composition itself is already proven for standalone elements (see the indicator story's own +re-scoping note) — this dependency is really about reusing the indicator's design once it exists, +not about waiting on rotated-wrap correctness, which Address Control's own Sprint 8 rigid-group +rotation should compose with just as cleanly. ### Render an accurate, record-specific preview of the current template - Status: Done @@ -549,6 +567,5 @@ scoped — re-confirm once their actual data model and wrap/clip-detection API s same discipline "Investigate and address high-volume render throughput degradation" used for its own assumption-qualified estimate. **Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and -dynamic text elements," "Support rotated wrapped text and a live wrap/clip indicator for text -elements," and "Add an adjustable width and height with text wrapping to Address Control lines" -(all three above, same epic). +dynamic text elements," "Add a live wrap/clip indicator for text elements," and "Add an adjustable +width and height with text wrapping to Address Control lines" (all three above, same epic). diff --git a/backlog/sprints/sprint-10.md b/backlog/sprints/sprint-10.md new file mode 100644 index 0000000..9cd1220 --- /dev/null +++ b/backlog/sprints/sprint-10.md @@ -0,0 +1,36 @@ +# Sprint Backlog + +**Sprint:** 10 **Dates:** 2026-10-28 - 2026-11-01 +**Sprint Goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator — the first new selection model this designer has ever had, plus a small, low-risk finish to already-shipped work. + +## Committed Items + +| Story | Size | Status | Tasks | +|---|---|---|---| +| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping)
- [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free
- [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free)
- [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly | +| Select multiple elements at once on the canvas | 5 points | Not Started | - [ ] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected
- [ ] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release
- [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest
- [ ] Visible selection indication for every selected item, not just the primary one
- [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning)
- [ ] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes)
- [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core)
- [ ] Live built-form smoke (touches the properties panel's multi-item behavior) | +| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height)
- [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection
- [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise
- [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free)
- [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) | + +## Notes +- **Capacity signal:** completed totals across Sprints 1-9 are **20, 19, 18, 18, 15, 18, 18, 13, 13** — nine data points. The 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented, deliberate under-commits (a large/risky story, then two fresh-priority stories), not capacity misses. +- **Why epic 6's pair is finally committed this sprint, explicit reasoning:** Ready and waiting since Sprint 7, deferred twice for fresher, more urgent human requests (Sprint 8's Address Control rotation reversal; Sprint 9's wrap/clip request). Neither reason applies a third time — continuing to defer a Ready, sized, 3-sprint-old pair without a stated reason would itself become the anti-pattern this team's practice exists to avoid. +- **Why pairing it with the small indicator story doesn't trip the "two large/novel stories" rule:** that caution is about stacking *two* large, first-of-its-kind, architecturally risky stories in one sprint (e.g., Sprint 8's rejected rotation-drag-handle + multi-select combination). Only multi-select carries that character here; the indicator story is small, additive, and touches no shared foundational state. +- 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 9 completed its one committed story, 13/13 points). +- Not committed: "Add an adjustable width and height with text wrapping to Address Control lines" (8 pts, epic 4 — depends on this sprint's indicator story landing and its design settling first, deferred to Sprint 11 rather than built under time pressure in the same sprint); "Undo and redo layout changes" (13 pts, epic 6 — a reasonable alternative pairing with the indicator, not chosen given the multi-select/align pair's own 3-sprint-old deferral making it the more overdue commitment); "Complete the first text-only operator workflow" (5 pts, epic 1 — stale label, needs re-verification before ever being pulled). + +## Execution Order + +Sequenced by dependency and risk: the small, independent indicator story first (quick, de-risks nothing else but clears easily), then the hard-dependent multi-select/align pair in order. + +| Batch | Story | Why it's gated here | +|---|---|---| +| 1 | Add a live wrap/clip indicator for text elements | No dependency on anything else this sprint; small and independent. | +| 2 | Select multiple elements at once on the canvas | No dependency on Batch 1, but sequenced next as the foundational half of the epic-6 pair. | +| 3 | Align and distribute multiple elements | Strictly depends on Batch 2's multi-selection existing — there is nothing to align/distribute without it. | + +## Daily Scrum Log + +| Day | Date | Completed | Planned | Blocked/At risk | +|---|---|---|---|---| +| 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. | diff --git a/code/TEMPLATE_FORMAT.md b/code/TEMPLATE_FORMAT.md index 8db3e56..77b75a5 100644 --- a/code/TEMPLATE_FORMAT.md +++ b/code/TEMPLATE_FORMAT.md @@ -132,23 +132,32 @@ editor, per this story's sizing note) rather than a silently-swallowed edge case 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. -- `width`, `height` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an - adjustable width and height with text wrapping...": when **both** are present, the element's - content wraps within `width` and any wrapped content exceeding `height` is clipped (cut off), - rather than growing the box or shrinking the text. A one-sided value (only `width` or only - `height`) is accepted but has no effect — wrap-without-a-clip-boundary and clip-without-a-wrap- - width both have no well-defined meaning, so it's treated the same as neither being present. - Internally this calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or +- `width` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an adjustable + width and height with text wrapping...", refined post-Sprint-9 (2026-10-27) per direct user + feedback: `width` **alone** is enough to wrap — the desktop canvas's resize handle only ever + sets `width` (never `height`, never font size, which stays a separate, panel-only control). + Wrapped content grows to whatever height it needs, with no clip, unless `height` is *also* + present (see below). An element with no `width` (every template written before Sprint 9) takes + the exact same plain `DrawText`/`DrawRotatedText` code path this renderer always used — + byte-for-byte unchanged output. +- `height` — optional, positive, PDF points, default absent, **independent** of `width` and only + ever set via the properties panel (never the canvas resize handle). Present together with + `width`, it becomes an explicit clip ceiling: wrapped content exceeding `height` is clipped (cut + off) rather than growing the box or shrinking the text. Present *without* `width`, it has no + effect (there is no wrap boundary for it to clip against). Internally: `width` + `height` both + present calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or `DrawRotatedTextBox` when `angle` is also non-zero — confirmed empirically to handle rotation - and wrapping together in one native call, not something this renderer has to compose from - separate steps) rather than a hand-rolled line-breaking algorithm; `top` is `y + height`, since - `(x, y)` is the box's bottom-left corner for a boxed element (unlike the plain baseline-`y` - meaning `y` has for an unboxed element). An element with no `width`/`height` (every template - written before this story) takes the exact same plain `DrawText`/`DrawRotatedText` code path - this renderer always used — byte-for-byte unchanged output. The desktop designer's canvas and + and wrapping together in one native call); `width` alone (no `height`) calls `DrawWrappedText` + (unrotated) or pre-wraps via `GetWrappedText` and draws via `DrawRotatedMultiLineText` (rotated — + no native "rotated wrapped text, no height" primitive exists), with the auto-height Debenu's own + `GetWrappedTextHeight` reports for that width/text/font. `(x, y)` is the box's bottom-left corner + for any boxed element (`top` is always `y` + the effective height, explicit or auto) — unlike + the plain baseline-`y` meaning `y` has for an unboxed element. The desktop designer's canvas and preview panel approximate the same wrap/clip visually using GDI+'s own native rectangle-bounded - text drawing — a documented design-time approximation, not a pixel-for-point guarantee, since - GDI+ and Debenu are two independent text-layout engines with no shared line-breaking code path. + text drawing (using the same auto-height logic via `MeasureString` with a width constraint when + no explicit `height` is set) — a documented design-time approximation, not a pixel-for-point + guarantee, since GDI+ and Debenu are two independent text-layout engines with no shared + line-breaking code path. ## `` diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs index d6cedc1..0fa42e8 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs @@ -57,6 +57,37 @@ public class DebenuPdfRendererWrapTests } } + [Fact] + public void AddPage_WidthOnly_NoHeight_Unrotated_Succeeds() + { + // Post-Sprint-9 user-requested change (2026-10-27): Width alone (no Height) means "wrap, + // auto-height, no clip" - uses DrawWrappedText, not DrawTextBox. + var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError); + Assert.True(created, createError); + + using (renderer) + { + var draw = new TextDraw(20, 300, "Arial", 12, LongText, Width: 120); + Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError); + } + } + + [Fact] + public void AddPage_WidthOnly_NoHeight_Rotated_Succeeds() + { + // Post-Sprint-9: no native "rotated wrapped text, no height" primitive exists, so this + // path pre-wraps via GetWrappedText and draws via DrawRotatedMultiLineText - confirms that + // composition succeeds end to end. + var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError); + Assert.True(created, createError); + + using (renderer) + { + var draw = new TextDraw(150, 150, "Arial", 12, LongText, Angle: 30, Width: 120); + Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError); + } + } + [Fact] public void AddPage_BoxTooShortToFitAllWrappedContent_StillSucceeds_ClippingRatherThanFailing() { @@ -153,7 +184,7 @@ public class DebenuPdfRendererWrapTests /// Renders one box-wrapped draw to a rasterized image and counts how many distinct /// horizontal pixel rows contain any "ink" (dark pixel) — a cheap, real proxy for "how many /// text lines are visibly present" without needing to parse compressed PDF content streams. - private static int InkRowCount(string dllPath, string licenseKey, string workDir, string label, double height) + private static int InkRowCount(string dllPath, string licenseKey, string workDir, string label, double? height, double angle = 0) { var created = DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError); Assert.True(created, createError); @@ -161,7 +192,7 @@ public class DebenuPdfRendererWrapTests var pdfPath = Path.Combine(workDir, $"{label}.pdf"); using (renderer) { - var draw = new TextDraw(20, 300, "Arial", 12, LongText, Width: 120, Height: height); + var draw = new TextDraw(20, 300, "Arial", 12, LongText, Angle: angle, Width: 120, Height: height); Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError); Assert.True(renderer.Save(pdfPath, out var saveError), saveError); } @@ -195,4 +226,85 @@ public class DebenuPdfRendererWrapTests return inkRows; } + + [Fact] + public void AddPage_WithLicenseKey_WidthOnlyAutoHeight_DrawsAllLinesLikeATallExplicitBox_NotAShortOne() + { + // Post-Sprint-9 user-requested change: Width alone (no Height) must behave like a tall + // box (grows to fit every wrapped line, no clip) - not like a short one. + 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(), $"wrap-autoheight-license-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var autoHeightInkRows = InkRowCount(dllPath, licenseKey, workDir, "auto", height: null); + var shortInkRows = InkRowCount(dllPath, licenseKey, workDir, "short", height: 20); + + Assert.True( + autoHeightInkRows > shortInkRows * 2, + $"Expected the auto-height (Width-only) box ({autoHeightInkRows} ink rows) to draw visibly more wrapped lines than a short explicit-height box ({shortInkRows} ink rows) - i.e. to NOT clip."); + } + finally + { + try + { + Directory.Delete(workDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup only, matching this project's established pattern. + } + } + } + + [Fact] + public void AddPage_WithLicenseKey_RotatedBox_WrapsOntoMultipleLinesAsOneRigidRotatedUnit() + { + // Confirms, via the real render pipeline and real rasterized pixels (not just a return + // code), that a rotated box genuinely draws wrapped content rather than collapsing to + // nothing or a single sliver — Debenu's DrawRotatedTextBox composes rotation and wrapping + // natively in one call, so this is a regression guard for that finding, not new production + // code. Note: pixel-row counting is not a reliable proxy for "how many lines clipped" once + // rotation is involved (a rotated block's vertical pixel footprint depends on angle as + // much as line count, confirmed by direct measurement while writing this test — a short, + // heavily-clipped rotated box can occupy *more* rasterized rows than a taller one, not + // fewer), so this test only asserts real content was drawn, not a specific row count + // relationship; the actual wrap+rotate+clip visual correctness for a realistic case was + // separately confirmed by direct screenshot inspection during this story's development + // (see the epic 4 development-verification note). + 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(), $"wrap-rotate-license-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var rotatedInkRows = InkRowCount(dllPath, licenseKey, workDir, "tall-rotated", height: 200, angle: 30); + + Assert.True(rotatedInkRows > 20, $"Expected the rotated box to draw multiple wrapped lines' worth of ink; got {rotatedInkRows} ink rows."); + } + finally + { + try + { + Directory.Delete(workDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup only, matching this project's established pattern. + } + } + } } diff --git a/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs index 1e508fa..da407c7 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs @@ -252,8 +252,10 @@ public class TemplateXmlParserTests } [Fact] - public void Parse_OnlyWidthAttribute_HasBoxIsFalse() + public void Parse_OnlyWidthAttribute_HasBoxIsTrue_HasHeightClipIsFalse() { + // Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no + // clip ceiling) — see TemplateElement.HasBox's remarks. var path = WriteTemplate(""" Hi @@ -266,6 +268,25 @@ public class TemplateXmlParserTests var element = result.Document!.Elements[0]; Assert.Equal(100, element.Width); Assert.Null(element.Height); + Assert.True(element.HasBox); + Assert.False(element.HasHeightClip); + } + + [Fact] + public void Parse_OnlyHeightAttribute_HasBoxIsFalse() + { + var path = WriteTemplate(""" + + Hi + + """); + + var result = TemplateXmlParser.Parse(path); + + Assert.True(result.Succeeded); + var element = result.Document!.Elements[0]; + Assert.Null(element.Width); + Assert.Equal(50, element.Height); Assert.False(element.HasBox); } diff --git a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs index a4dbf36..b3a65d4 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs @@ -175,27 +175,61 @@ public sealed class DebenuPdfRenderer : IPdfRenderer return false; } - if (draw.Width is > 0 && draw.Height is > 0) + if (draw.Width is > 0) { - // Sprint 9, "Add an adjustable width and height with text wrapping...": Debenu's - // own native DrawTextBox/DrawRotatedTextBox already wrap to Width and clip to - // Height in one call — confirmed empirically against the real DLL (a box too - // short to fit every wrapped line returns fewer lines than - // GetWrappedTextLineCount reports for the same width, rather than growing the box - // or throwing), so no hand-rolled line-breaking is needed here. (X, Y) is this - // draw's box bottom-left corner (matching every other box-shaped element in this - // codebase, e.g. Address Control), so Debenu's own `Top` parameter is Y + Height. - var top = draw.Y + draw.Height.Value; - var boxResult = draw.Angle == 0 - ? _pdf.DrawTextBox(draw.X, top, draw.Width.Value, draw.Height.Value, draw.Text, Options: 0) - : _pdf.DrawRotatedTextBox( - draw.X, top, draw.Width.Value, draw.Height.Value, - ((draw.Angle % 360) + 360) % 360, draw.Text, Options: 0); + // Sprint 9, "Add an adjustable width and height with text wrapping...", extended + // post-Sprint-9 (2026-10-27) so Width alone (no Height) means "wrap, auto-height, + // no clip" — the canvas resize handle only ever sets Width; Height remains an + // independent, optional explicit clip ceiling set separately via the properties + // panel. (X, Y) is this draw's box bottom-left corner (matching every other + // box-shaped element in this codebase, e.g. Address Control); Debenu's own `Top` + // parameter is always Y + , whether that height is the explicit + // Height or (when absent) the wrapped height Debenu itself reports for this exact + // width/text/font via GetWrappedTextHeight — so the anchor convention never + // depends on which case applies. + var wrapWidth = draw.Width.Value; + var normalizedAngle = ((draw.Angle % 360) + 360) % 360; + int boxResult; + + if (draw.Height is > 0) + { + // Explicit clip ceiling: Debenu's own native DrawTextBox/DrawRotatedTextBox + // already wrap to Width and clip to Height in one call — confirmed empirically + // against the real DLL (a box too short to fit every wrapped line returns + // fewer lines than GetWrappedTextLineCount reports for the same width, rather + // than growing the box or throwing), so no hand-rolled line-breaking is needed. + var top = draw.Y + draw.Height.Value; + boxResult = draw.Angle == 0 + ? _pdf.DrawTextBox(draw.X, top, wrapWidth, draw.Height.Value, draw.Text, Options: 0) + : _pdf.DrawRotatedTextBox(draw.X, top, wrapWidth, draw.Height.Value, normalizedAngle, draw.Text, Options: 0); + } + else if (draw.Angle == 0) + { + // No clip ceiling, unrotated: DrawWrappedText wraps to Width and grows as tall + // as needed, with no separate measure-then-draw step. + var autoHeight = _pdf.GetWrappedTextHeight(wrapWidth, draw.Text); + boxResult = _pdf.DrawWrappedText(draw.X, draw.Y + autoHeight, wrapWidth, draw.Text); + } + else + { + // No clip ceiling, rotated: there is no native "rotated wrapped text, no + // height" primitive, so pre-wrap via GetWrappedText (Debenu's own wrap + // algorithm — the same one DrawWrappedText/DrawTextBox use internally, so this + // stays consistent with the unrotated case above) and draw the result via + // DrawRotatedMultiLineText, confirmed empirically to rotate the whole wrapped + // block as one rigid unit. A private-use-area delimiter (never realistic + // address-text content) avoids any chance of colliding with a literal + // character in the actual text. + const string delimiter = "\uE000"; // literal escape - Unicode Private Use Area, never realistic in real address text + var wrappedText = _pdf.GetWrappedText(wrapWidth, delimiter, draw.Text); + var autoHeight = _pdf.GetWrappedTextHeight(wrapWidth, draw.Text); + boxResult = _pdf.DrawRotatedMultiLineText(draw.X, draw.Y + autoHeight, normalizedAngle, delimiter, wrappedText); + } if (boxResult == 0) { - error = $"Failed to draw wrapped text box with font '{draw.FontName}' at " + - $"({draw.X}, {draw.Y}), size {draw.Width}x{draw.Height} " + + error = $"Failed to draw wrapped text with font '{draw.FontName}' at " + + $"({draw.X}, {draw.Y}), width {draw.Width} " + $"(error code {_pdf.LastErrorCode()})."; return false; } diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs index 2464038..5b49238 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs @@ -40,11 +40,15 @@ public sealed record TemplateElement( double? Width = null, double? Height = null) { - /// True only when both and are set — a - /// one-sided value (e.g. a width with no height) is treated as "no box" rather than guessing - /// a missing dimension, since wrap-without-a-clip-boundary or clip-without-a-wrap-width both - /// have no well-defined meaning for this story. - public bool HasBox => Width is > 0 && Height is > 0; + /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has + /// been set — is independent and optional, no longer required for + /// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when + /// wanted, is an explicit clip ceiling set separately via the properties panel). + public bool HasBox => Width is > 0; + + /// True only when an explicit clip ceiling is also set, on top of the wrap + /// already establishes. + public bool HasHeightClip => Height is > 0; /// Back-compat convenience factory matching the pre-Sprint-5 "pure static text" /// shape — builds the equivalent one-literal-run list. (A second diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs index cf660ef..da0bea1 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs @@ -31,27 +31,11 @@ public sealed class CanvasElementEditor /// space points) when snapping is enabled but no explicit size has been set. public const double DefaultGridSizePoints = 10.0; - /// Post-Sprint-8 user-requested feature (resize a standalone static/dynamic text - /// element by dragging a canvas handle): a static/dynamic element has no independent - /// Width/Height of its own — its box is purely derived from measuring - /// at its current - /// (see TemplateCanvasControl.MeasureElement) — so "resize" here means uniformly - /// scaling , the only property that actually changes - /// what renders. These bounds mirror the properties panel's existing "Font size (pt)" - /// NumericUpDown (Minimum 1, Maximum 1000) so a drag can never produce a value the - /// panel would then silently re-clamp on its next refresh. - public const double MinFontSize = 1.0; - - public const double MaxFontSize = 1000.0; - private readonly TemplateLayoutDocument _document; private readonly Func _measureText; private (double Dx, double Dy)? _dragOffset; private bool _isRotating; - private bool _isResizingFontSize; - private bool _resizeIsBoxMode; - private double _resizeOriginalFontSize; - private double _resizeOriginalDiagonal; + private bool _isResizingWidth; public CanvasElementEditor( TemplateLayoutDocument document, Func measureText) @@ -164,6 +148,24 @@ public sealed class CanvasElementEditor public void Select(TextElementLayout? element) => Selected = element; + /// Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone + /// element at all. Removes the currently selected element from the document and clears the + /// selection; a no-op if nothing is selected. + public bool RemoveSelected() + { + if (Selected is null) + { + return false; + } + + var removed = _document.Elements.Remove(Selected); + Selected = null; + _dragOffset = null; + _isRotating = false; + _isResizingWidth = false; + return removed; + } + /// Starts a drag of the currently selected element, recording the offset between /// the grab point and the element's origin so moves it smoothly rather /// than snapping its origin to the mouse position. @@ -208,20 +210,21 @@ public sealed class CanvasElementEditor { _dragOffset = null; _isRotating = false; - _isResizingFontSize = false; + _isResizingWidth = false; } public bool IsDragging => _dragOffset is not null; - public bool IsResizingFontSize => _isResizingFontSize; + public bool IsResizingWidth => _isResizingWidth; - /// Post-Sprint-8 user-requested feature: the current world-space (canvas-point) - /// position of the selected element's font-size resize handle, or null if nothing is - /// selected. Sits at the box's own top-right corner ( + - /// width, + height) — the corner diagonally opposite the - /// element's fixed (X, Y) anchor, which never moves as font size changes — rotated around the - /// same pivot uses so it orbits correctly for a rotated - /// element. + /// Post-Sprint-9 user-requested change (2026-10-27): the current world-space + /// (canvas-point) position of the selected element's width resize handle, or null if + /// nothing is selected. Sits at the right-edge midpoint ( + + /// width, vertically centered), mirroring the Address Control's own width-only handle + /// position for visual consistency — the handle's placement itself communicates "this only + /// changes width," unlike the pre-Sprint-9-fix corner position that also implied a height/font + /// change — rotated around the same pivot uses so it orbits + /// correctly for a rotated element. public (double X, double Y)? ResizeHandlePosition() { if (Selected is null) @@ -238,13 +241,13 @@ public sealed class CanvasElementEditor var pivot = RotationPivot(element, width, height); return RotatePointAroundPivot( element.X + width, - element.Y + height, + element.Y + (height / 2.0), pivot, element.RotationAngle); } /// Whether the given canvas-space point is within grab range of the currently - /// selected element's font-size resize handle. Always false when nothing is + /// selected element's width resize handle. Always false when nothing is /// selected. public bool HitTestResizeHandle(double xPoints, double yPoints) { @@ -260,19 +263,11 @@ public sealed class CanvasElementEditor } /// Starts a resize-drag of the currently selected element via its corner handle. - /// Sprint 9: the handle is context-sensitive — captured once at drag-start via - /// so a mid-drag box-clearing edit elsewhere can't - /// change the interpretation partway through a single gesture. When the element already has - /// a box (), the drag directly resizes - /// / — the box is - /// now the element's real geometry, so its own corner should size to the pointer, not scale - /// the font uniformly. Otherwise (the pre-Sprint-9 case, and still the default for any - /// element with no box) the drag scales uniformly, - /// capturing the current font size and measured-box diagonal so can - /// compute an absolute scale factor from the pointer's distance to the element's fixed - /// (X, Y) anchor — the same "recompute from scratch every tick" style - /// already uses, rather than accumulating incremental deltas, which - /// would drift. No-op if nothing is selected. + /// Post-Sprint-9 user-requested change (2026-10-27): the handle controls only + /// — font size is set exclusively via the properties + /// panel's own Font size field, never by dragging, and the earlier Sprint 8 uniform-font-scale + /// behavior this method used to have for an unboxed element has been removed entirely, not + /// just made conditional. No-op if nothing is selected. public void BeginResizeDrag() { if (Selected is null) @@ -280,74 +275,35 @@ public sealed class CanvasElementEditor return; } - _resizeIsBoxMode = Selected.HasBox; - if (!_resizeIsBoxMode) - { - var (width, height) = _measureText(Selected); - _resizeOriginalDiagonal = Math.Sqrt((width * width) + (height * height)); - _resizeOriginalFontSize = Selected.FontSize; - } - - _isResizingFontSize = true; + _isResizingWidth = true; _dragOffset = null; } - /// Updates the selected element from the pointer's current position — either its - /// / (box mode) or - /// its (uniform scale mode), per the mode captured at - /// . In box mode, the pointer's local-space offset from the - /// element's fixed (X, Y) anchor directly becomes the new width/height (only applied when - /// positive, so a drag that overshoots back past the anchor simply stops shrinking rather than - /// producing a negative or zero size). In font-scale mode, dragging farther from the anchor - /// grows the text, dragging closer shrinks it, uniformly, relative to the diagonal captured at - /// drag-start. No-op if nothing is selected, a resize-drag hasn't begun, or (font-scale mode - /// only) the captured original diagonal was degenerate. + /// Updates the selected element's from the + /// pointer's current local-space horizontal offset from the element's fixed (X, Y) anchor — + /// only applied when positive, so a drag that overshoots back past the anchor simply stops + /// shrinking rather than producing a negative or zero width. Never touches + /// or — see + /// 's remarks. No-op if nothing is selected or a resize-drag + /// hasn't begun. public void ResizeDragTo(double xPoints, double yPoints) { - if (Selected is null || !_isResizingFontSize) + if (Selected is null || !_isResizingWidth) { return; } var (width, height) = _measureText(Selected); var pivot = RotationPivot(Selected, width, height); - var (localX, localY) = Selected.RotationAngle == 0 + var (localX, _) = Selected.RotationAngle == 0 ? (xPoints, yPoints) : RotatePointAroundPivot(xPoints, yPoints, pivot, -Selected.RotationAngle); - if (_resizeIsBoxMode) - { - var newWidth = localX - Selected.X; - var newHeight = localY - Selected.Y; - if (newWidth > 0) - { - Selected.Width = newWidth; - } - - if (newHeight > 0) - { - Selected.Height = newHeight; - } - - return; - } - - if (_resizeOriginalDiagonal <= 0) + var newWidth = localX - Selected.X; + if (newWidth > 0) { - return; + Selected.Width = newWidth; } - - var dx = localX - Selected.X; - var dy = localY - Selected.Y; - var distance = Math.Sqrt((dx * dx) + (dy * dy)); - if (distance <= 0) - { - return; - } - - var scale = distance / _resizeOriginalDiagonal; - var newFontSize = Math.Clamp(_resizeOriginalFontSize * scale, MinFontSize, MaxFontSize); - Selected.FontSize = newFontSize; } /// Sprint 4, "Rotate elements by dragging a handle on the canvas": the current diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs index 1449a67..4142088 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs @@ -67,10 +67,15 @@ public sealed class TextElementLayout /// is also set. public double? Height { get; set; } - /// True only when both and are set — a - /// one-sided value doesn't count as having a box (wrap-without-a-clip-boundary and - /// clip-without-a-wrap-width both have no well-defined meaning for this story). - public bool HasBox => Width is > 0 && Height is > 0; + /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has + /// been set — is independent and optional, no longer required for + /// wrapping to apply (the canvas resize handle only ever sets Width; Height, when wanted, is + /// an explicit clip ceiling set separately via the properties panel). + public bool HasBox => Width is > 0; + + /// True only when an explicit clip ceiling is also set, on top of the wrap + /// already establishes. + public bool HasHeightClip => Height is > 0; /// Sprint 5: the element's content as an ordered run sequence. Always has at least /// one entry (an empty single literal run is this model's equivalent of "no content yet") — diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs new file mode 100644 index 0000000..d29ecb9 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs @@ -0,0 +1,24 @@ +namespace EnvelopeRenderer.Desktop.Core.Design; + +/// +/// Sprint 10, "Add a live wrap/clip indicator for text elements": pure, framework-free comparison +/// behind the canvas/preview panel's live wrap/clip warning — deciding *whether* a boxed +/// element's current content is wrapping and/or being clipped is just arithmetic once the caller +/// has already measured the text (GDI+, a WinForms-only concern, stays out of this class so the +/// decision itself is unit-testable without a WinForms host, mirroring how +/// takes an injected measurement delegate for the same reason). +/// +public static class WrapClipDetector +{ + /// The text's own unconstrained (single-line) measured width. + /// The text's measured height once wrapped to . + /// The element's authored box width. + /// The element's authored box height. + public static (bool IsWrapping, bool IsClipping) Detect( + double naturalWidth, double wrappedHeight, double boxWidth, double boxHeight) + { + var isWrapping = naturalWidth > boxWidth; + var isClipping = wrappedHeight > boxHeight; + return (isWrapping, isClipping); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs index 19faaab..a9506aa 100644 --- a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs +++ b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs @@ -62,6 +62,48 @@ public class CanvasElementEditorTests Assert.Equal($"{{{columnName}}}", element.DisplayText); } + // Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone element. + + [Fact] + public void RemoveSelected_RemovesElementFromDocumentAndClearsSelection() + { + var editor = CreateEditor(out var document); + var element = editor.AddStaticText(10, 20, "Hello"); + + var removed = editor.RemoveSelected(); + + Assert.True(removed); + Assert.DoesNotContain(element, document.Elements); + Assert.Null(editor.Selected); + } + + [Fact] + public void RemoveSelected_NoSelection_ReturnsFalse_DocumentUnchanged() + { + var editor = CreateEditor(out var document); + editor.AddStaticText(10, 20, "Hello"); + editor.Select(null); + + var removed = editor.RemoveSelected(); + + Assert.False(removed); + Assert.Single(document.Elements); + } + + [Fact] + public void RemoveSelected_DoesNotAffectOtherElements() + { + var editor = CreateEditor(out var document); + var keep = editor.AddStaticText(10, 20, "Keep"); + var removeMe = editor.AddStaticText(30, 40, "Remove"); + editor.Select(removeMe); + + editor.RemoveSelected(); + + Assert.Contains(keep, document.Elements); + Assert.DoesNotContain(removeMe, document.Elements); + } + [Fact] public void AddMultipleElements_AssignsIncreasingZOrder() { @@ -424,14 +466,13 @@ public class CanvasElementEditorTests Assert.Equal(100, element.Y, precision: 6); } - // Post-Sprint-8 user-requested feature: resize a standalone static/dynamic element by - // dragging a canvas handle. A standalone element has no independent Width/Height of its own - // — its box is purely derived from measuring its text at the current FontSize — so "resize" - // here means uniformly scaling FontSize, the only property that actually changes what - // renders. + // Post-Sprint-9 user-requested change (2026-10-27): the canvas resize handle controls only + // Width — font size is set exclusively via the properties panel's Font size field, never by + // dragging (the earlier Sprint 8 uniform-font-scale handle behavior has been removed + // entirely, not just made conditional). [Fact] - public void ResizeHandlePosition_UnrotatedElement_IsAtTopRightCorner() + public void ResizeHandlePosition_UnrotatedElement_IsAtRightEdgeMidpoint() { var editor = CreateEditor(out _); editor.AddStaticText(100, 100, "Hello"); // box [100,120]x[100,110] @@ -439,8 +480,8 @@ public class CanvasElementEditorTests var handle = editor.ResizeHandlePosition(); Assert.NotNull(handle); - Assert.Equal(120, handle!.Value.X, precision: 6); - Assert.Equal(110, handle.Value.Y, precision: 6); + Assert.Equal(120, handle!.Value.X, precision: 6); // right edge + Assert.Equal(105, handle.Value.Y, precision: 6); // vertically centered } [Fact] @@ -492,20 +533,20 @@ public class CanvasElementEditorTests { var editor = CreateEditor(out _); - Assert.False(editor.HitTestResizeHandle(120, 110)); + Assert.False(editor.HitTestResizeHandle(120, 105)); } [Fact] - public void BeginResizeDrag_SetsIsResizingFontSize_AndEndDragClearsIt() + public void BeginResizeDrag_SetsIsResizingWidth_AndEndDragClearsIt() { var editor = CreateEditor(out _); editor.AddStaticText(100, 100, "Hello"); editor.BeginResizeDrag(); - Assert.True(editor.IsResizingFontSize); + Assert.True(editor.IsResizingWidth); editor.EndDrag(); - Assert.False(editor.IsResizingFontSize); + Assert.False(editor.IsResizingWidth); } [Fact] @@ -522,53 +563,44 @@ public class CanvasElementEditorTests } [Fact] - public void ResizeDragTo_DraggingTwiceTheOriginalDiagonalFromAnchor_DoublesFontSize() - { - var editor = CreateEditor(out _); - var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // anchor (100,100), diagonal sqrt(500) - editor.BeginResizeDrag(); - - var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0)); - editor.ResizeDragTo(100 + (2 * originalDiagonal), 100); // straight right, 2x the original distance - - Assert.Equal(24, element.FontSize, precision: 3); - } - - [Fact] - public void ResizeDragTo_DraggingHalfTheOriginalDiagonalFromAnchor_HalvesFontSize() + public void ResizeDragTo_SetsWidthFromHorizontalDistanceToAnchor() { var editor = CreateEditor(out _); var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); editor.BeginResizeDrag(); - var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0)); - editor.ResizeDragTo(100 + (0.5 * originalDiagonal), 100); + editor.ResizeDragTo(180, 999); // 80 right of the (100,100) anchor; Y is irrelevant - Assert.Equal(6, element.FontSize, precision: 3); + Assert.Equal(80, element.Width!.Value, precision: 3); } [Fact] - public void ResizeDragTo_DraggingVeryClose_ClampsToMinFontSize() + public void ResizeDragTo_NeverTouchesHeightOrFontSize() { var editor = CreateEditor(out _); var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); + element.Height = 55; // pre-existing explicit clip ceiling editor.BeginResizeDrag(); - editor.ResizeDragTo(100.0001, 100); // tiny but nonzero distance from the anchor + editor.ResizeDragTo(180, 999); - Assert.Equal(CanvasElementEditor.MinFontSize, element.FontSize, precision: 3); + Assert.Equal(55, element.Height!.Value, precision: 3); + Assert.Equal(12, element.FontSize, precision: 3); } [Fact] - public void ResizeDragTo_DraggingVeryFar_ClampsToMaxFontSize() + public void ResizeDragTo_DraggingPastTheAnchor_LeavesWidthUnchanged() { + // Only a positive resulting width is applied — dragging back past the anchor simply stops + // shrinking rather than going negative or to zero. var editor = CreateEditor(out _); var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); + element.Width = 20; editor.BeginResizeDrag(); - editor.ResizeDragTo(1_000_000, 100); + editor.ResizeDragTo(80, 999); // X (80) is left of the anchor (100) - Assert.Equal(CanvasElementEditor.MaxFontSize, element.FontSize, precision: 3); + Assert.Equal(20, element.Width!.Value, precision: 3); } [Fact] @@ -579,75 +611,37 @@ public class CanvasElementEditorTests editor.ResizeDragTo(1000, 1000); - Assert.Equal(12, element.FontSize, precision: 6); + Assert.Null(element.Width); } [Fact] - public void ResizeDragTo_DynamicElement_ScalesFontSize_SameAsStatic() + public void ResizeDragTo_DynamicElement_SetsWidth_SameAsStatic() { var editor = CreateEditor(out _); var element = editor.AddDynamicPlaceholder(100, 100, "Full Name", fontSize: 12); editor.BeginResizeDrag(); - var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0)); - editor.ResizeDragTo(100 + (2 * originalDiagonal), 100); + editor.ResizeDragTo(180, 999); - Assert.Equal(24, element.FontSize, precision: 3); + Assert.Equal(80, element.Width!.Value, precision: 3); } [Fact] - public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsFontSizeUnchanged() + public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsWidthUnchanged() { // Proves the rotate-point-backward-into-local-space math round-trips correctly: dragging // to exactly where the handle currently sits (already accounting for rotation) must be a - // no-op scale change, not an accidental jump caused by comparing a rotated world point + // no-op width change, not an accidental jump caused by comparing a rotated world point // against unrotated anchor coordinates. var editor = CreateEditor(out _); - var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); + var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // FixedSize: 20x10 element.RotationAngle = 90; var handle = editor.ResizeHandlePosition()!.Value; editor.BeginResizeDrag(); editor.ResizeDragTo(handle.X, handle.Y); - Assert.Equal(12, element.FontSize, precision: 3); - } - - // Sprint 9, "Add an adjustable width and height with text wrapping...": once an element - // already has a box, the same corner handle resizes Width/Height directly instead of - // scaling FontSize. - - [Fact] - public void ResizeDragTo_ElementHasBox_UpdatesWidthAndHeightDirectly_NotFontSize() - { - var editor = CreateEditor(out _); - var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); - element.Width = 20; - element.Height = 10; - editor.BeginResizeDrag(); - - editor.ResizeDragTo(180, 160); // 80 right, 60 up from the (100,100) anchor - - Assert.Equal(80, element.Width!.Value, precision: 3); - Assert.Equal(60, element.Height!.Value, precision: 3); - Assert.Equal(12, element.FontSize, precision: 3); // unaffected in box mode - } - - [Fact] - public void ResizeDragTo_ElementHasBox_DraggingPastTheAnchor_LeavesDimensionUnchanged() - { - // Only a positive resulting width/height is applied — dragging back past the anchor on - // one axis simply stops shrinking that axis rather than going negative or to zero. - var editor = CreateEditor(out _); - var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); - element.Width = 20; - element.Height = 10; - editor.BeginResizeDrag(); - - editor.ResizeDragTo(80, 160); // X (80) is now left of the anchor (100) - - Assert.Equal(20, element.Width!.Value, precision: 3); // unchanged - would have gone negative - Assert.Equal(60, element.Height!.Value, precision: 3); // Y direction still applied normally + Assert.Equal(20, element.Width!.Value, precision: 3); } [Fact] @@ -655,9 +649,9 @@ public class CanvasElementEditorTests { // CanvasElementEditor itself has no opinion on measurement - it just uses whatever // _measureText(element) returns. Production wiring (TemplateCanvasControl.MeasureElement) - // is box-aware (returns Width/Height directly instead of measuring text) once this story - // lands; this test proves the handle position correctly follows a box-aware measurer, - // using a purpose-built fake rather than duplicating that production logic here. + // is box-aware once this story lands; this test proves the handle position correctly + // follows a box-aware measurer, using a purpose-built fake rather than duplicating that + // production logic here. var document = TemplateLayoutDocument.CreateDefault(); var editor = new CanvasElementEditor(document, BoxAwareSize); var element = editor.AddStaticText(100, 100, "Hi"); @@ -667,11 +661,11 @@ public class CanvasElementEditorTests var handle = editor.ResizeHandlePosition(); Assert.NotNull(handle); - Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200 - Assert.Equal(250, handle.Value.Y, precision: 3); // 100 + 150 + Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200 (right edge) + Assert.Equal(175, handle.Value.Y, precision: 3); // 100 + 150/2 (vertical midpoint) static (double Width, double Height) BoxAwareSize(TextElementLayout e) => - e.HasBox ? (e.Width!.Value, e.Height!.Value) : (20.0, 10.0); + e.HasBox ? (e.Width!.Value, e.Height ?? 0) : (20.0, 10.0); } // Sprint 7, "Snap elements to grid and guides". diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs index cba058f..30c5b12 100644 --- a/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs +++ b/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs @@ -286,10 +286,10 @@ public class TemplateLayoutXmlSerializerTests : IDisposable } [Fact] - public void Load_OnlyOneOfWidthOrHeightSet_HasBoxIsFalse() + public void Load_OnlyWidthSet_HasBoxIsTrue_HasHeightClipIsFalse() { - // A one-sided value doesn't count as having a box (see TextElementLayout.HasBox's - // remarks) — it's accepted (not a parse error), just inert. + // Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no + // clip ceiling) — see TextElementLayout.HasBox's remarks. File.WriteAllText( _tempPath, "" + @@ -301,6 +301,25 @@ public class TemplateLayoutXmlSerializerTests : IDisposable Assert.True(result, string.Join("; ", errors)); Assert.Equal(100, document!.Elements[0].Width); Assert.Null(document.Elements[0].Height); + Assert.True(document.Elements[0].HasBox); + Assert.False(document.Elements[0].HasHeightClip); + } + + [Fact] + public void Load_OnlyHeightSet_HasBoxIsFalse() + { + // A height with no width is inert - there is no wrap boundary for it to clip against. + File.WriteAllText( + _tempPath, + "" + + "Hi" + + ""); + + var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors); + + Assert.True(result, string.Join("; ", errors)); + Assert.Null(document!.Elements[0].Width); + Assert.Equal(50, document.Elements[0].Height); Assert.False(document.Elements[0].HasBox); } diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs index c635f0b..c99c511 100644 --- a/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs +++ b/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs @@ -277,8 +277,11 @@ public class TextElementPropertiesEditorTests [Theory] [InlineData(0)] [InlineData(-5)] - public void SetHeight_NonPositiveValue_ClearsTheBox(double nonPositive) + public void SetHeight_NonPositiveValue_ClearsTheHeightClipOnly_NotTheBox(double nonPositive) { + // Post-Sprint-9 user-requested change: Height is independent of Width - clearing it drops + // the explicit clip ceiling (HasHeightClip) but the box itself (HasBox, driven by Width + // alone) stays intact. var editor = new TextElementPropertiesEditor(); var element = TextElementLayout.CreateStatic(0, 0, "a"); element.Width = 100; @@ -288,7 +291,8 @@ public class TextElementPropertiesEditorTests editor.SetHeight(nonPositive); Assert.Null(element.Height); - Assert.False(element.HasBox); + Assert.False(element.HasHeightClip); + Assert.True(element.HasBox); } [Fact] diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs new file mode 100644 index 0000000..8e4f8ab --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs @@ -0,0 +1,61 @@ +using EnvelopeRenderer.Desktop.Core.Design; + +namespace EnvelopeRenderer.Desktop.Tests; + +public class WrapClipDetectorTests +{ + [Fact] + public void Detect_NaturalWidthWithinBox_WrappedHeightWithinBox_NeitherFlagIsSet() + { + var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 15, boxWidth: 100, boxHeight: 50); + + Assert.False(isWrapping); + Assert.False(isClipping); + } + + [Fact] + public void Detect_NaturalWidthExceedsBoxWidth_IsWrappingIsTrue() + { + var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 15, boxWidth: 100, boxHeight: 50); + + Assert.True(isWrapping); + Assert.False(isClipping); + } + + [Fact] + public void Detect_WrappedHeightExceedsBoxHeight_IsClippingIsTrue() + { + var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 80, boxWidth: 100, boxHeight: 50); + + Assert.True(isWrapping); + Assert.True(isClipping); + } + + [Fact] + public void Detect_NaturalWidthExactlyEqualsBoxWidth_IsNotWrapping() + { + // Exactly fitting is not "overflow" - only strictly exceeding counts. + var (isWrapping, _) = WrapClipDetector.Detect(naturalWidth: 100, wrappedHeight: 15, boxWidth: 100, boxHeight: 50); + + Assert.False(isWrapping); + } + + [Fact] + public void Detect_WrappedHeightExactlyEqualsBoxHeight_IsNotClipping() + { + var (_, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 50, boxWidth: 100, boxHeight: 50); + + Assert.False(isClipping); + } + + [Fact] + public void Detect_ClippingWithoutWrapping_IsPossible() + { + // A single line taller than the box (very large font, short box) clips without ever + // needing to wrap onto a second line. + var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 40, wrappedHeight: 60, boxWidth: 100, boxHeight: 50); + + Assert.False(isWrapping); + Assert.True(isClipping); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs index a0b00e5..5b01f16 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs @@ -47,6 +47,12 @@ public sealed class TemplateCanvasControl : Control DoubleBuffered = true; BackColor = SystemColors.ControlDark; SetStyle(ControlStyles.ResizeRedraw, true); + // Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by + // default, so it never received key events at all — needed for the Delete key to remove + // the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on + // the reported gap this fixes). + SetStyle(ControlStyles.Selectable, true); + TabStop = true; } /// Sprint 7, "Snap elements to grid and guides": mirrors @@ -88,7 +94,7 @@ public sealed class TemplateCanvasControl : Control /// Mirrors the exact condition already used inline before this /// property existed. public bool IsInteracting => - _editor.IsDragging || _editor.IsRotating || _editor.IsResizingFontSize || _addressControlDragOffset is not null + _editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null || _isResizingAddressControl || _isRotatingAddressControl; public TextElementLayout? SelectedElement => _editor.Selected; @@ -231,6 +237,41 @@ public sealed class TemplateCanvasControl : Control SelectionChanged?.Invoke(this, EventArgs.Empty); } + /// Post-Sprint-9 user-reported gap: there was no way to delete a placed element or + /// Address Control at all once added. Removes whichever is currently selected (a standalone + /// element or a whole Address Control — never just a drilled-into address line, which has its + /// own dedicated ) and clears the selection. Returns + /// false (a no-op) if nothing is selected. + public bool RemoveSelectedElement() + { + if (_selectedAddressControl is not null) + { + var removed = _document.AddressControls.Remove(_selectedAddressControl); + ClearSelection(); + if (removed) + { + ElementsChanged?.Invoke(this, EventArgs.Empty); + } + + return removed; + } + + if (_editor.Selected is not null) + { + var removed = _editor.RemoveSelected(); + Invalidate(); + SelectionChanged?.Invoke(this, EventArgs.Empty); + if (removed) + { + ElementsChanged?.Invoke(this, EventArgs.Empty); + } + + return removed; + } + + return false; + } + private (double X, double Y) DefaultNewElementPosition() { // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly @@ -401,6 +442,20 @@ public sealed class TemplateCanvasControl : Control // tier scope for a later story, not required here. var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight); g.DrawString(element.DisplayText, font, brush, boxRect); + + // Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange + // outline, the same visual language as the unmapped-column warning below, drawn + // whenever the element's *current* content is actually wrapping and/or being + // clipped — reusing the same box-mode text this method just drew, not a second + // divergent measurement. + var (isWrapping, isClipping) = MeasureWrapClip(element, font); + if (isWrapping || isClipping) + { + using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot }; + g.DrawRectangle( + wrapClipPen, (float)drawX - 1, (float)drawY - 1, + (float)(width * transform.Scale) + 2, (float)boxHeight + 2); + } } else { @@ -710,17 +765,23 @@ public sealed class TemplateCanvasControl : Control /// measuring context so the result is directly comparable to /// the point-based coordinates stores — this is a design-time /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point - /// parity with the final PDF. Sprint 9, "Add an adjustable width and height with text - /// wrapping...": once an element has a box (), its size - /// *is* the box — authored geometry, not something to (re-)measure from text — so every - /// downstream consumer of this method (hit-testing, the rotate handle, the resize handle, and - /// itself) automatically treats the box as the element's real - /// bounding box with no changes needed at those call sites. + /// parity with the final PDF. Sprint 9/10, "Add an adjustable width and height with text + /// wrapping...": once an element has a box ( — Width + /// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure + /// from text; its height is the explicit clip ceiling when + /// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font + /// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing, + /// the rotate handle, the resize handle, and itself) automatically + /// treats this as the element's real bounding box with no changes needed at those call sites. private static (double Width, double Height) MeasureElement(TextElementLayout element) { if (element.HasBox) { - return (element.Width!.Value, element.Height!.Value); + using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize); + var effectiveHeight = element.HasHeightClip + ? element.Height!.Value + : MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value); + return (element.Width!.Value, effectiveHeight); } using var bitmap = new Bitmap(1, 1); @@ -732,6 +793,40 @@ public sealed class TemplateCanvasControl : Control return (size.Width, size.Height); } + /// Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap + /// to points — the auto-height a boxed + /// element with no explicit clip ceiling uses, mirroring + /// how the CLI asks Debenu's own GetWrappedTextHeight for the equivalent value. + private static double MeasureWrappedHeight(string text, Font font, double width) + { + using var bitmap = new Bitmap(1, 1); + using var g = Graphics.FromImage(bitmap); + g.PageUnit = GraphicsUnit.Point; + var displayText = string.IsNullOrEmpty(text) ? " " : text; + var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width))); + return size.Height; + } + + /// Sprint 10, "Add a live wrap/clip indicator for text elements": compares the + /// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide + /// whether the *current* content is actually wrapping and/or being clipped right now — pure + /// arithmetic lives in ; this just does the GDI+ measurement + /// that decision needs. + private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font) + { + using var bitmap = new Bitmap(1, 1); + using var g = Graphics.FromImage(bitmap); + g.PageUnit = GraphicsUnit.Point; + var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText; + var naturalWidth = g.MeasureString(text, font).Width; + var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value); + + // No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) — + // never flag clipping in that case, regardless of how tall the wrapped content grows. + var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity; + return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight); + } + private static (double Width, double Height) MeasureText(string text, Font font) { using var bitmap = new Bitmap(1, 1); @@ -786,6 +881,9 @@ public sealed class TemplateCanvasControl : Control protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); + // Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all — + // see the constructor's ControlStyles.Selectable remarks. + Focus(); if (e.Button != MouseButtons.Left) { return; @@ -973,7 +1071,7 @@ public sealed class TemplateCanvasControl : Control return; } - if (_editor.IsResizingFontSize) + if (_editor.IsResizingWidth) { _editor.ResizeDragTo(x, y); } @@ -1010,4 +1108,21 @@ public sealed class TemplateCanvasControl : Control ElementsChanged?.Invoke(this, EventArgs.Empty); } } + + /// Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or + /// Address Control is currently selected — there was no way to delete a placed item at all + /// before this. Only fires while not mid-gesture, so an accidental key press during a drag + /// can't delete out from under an in-progress move/resize/rotate. + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting) + { + RemoveSelectedElement(); + e.Handled = true; + } + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Delete or Keys.Back || base.IsInputKey(keyData); } diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs index a7e0230..03d5bb6 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs @@ -65,6 +65,9 @@ public sealed class TemplateDesignerForm : Form private readonly Button _addStaticTextButton = new() { Text = "Add Stati&c Text", AutoSize = true }; private readonly Button _addDynamicPlaceholderButton = new() { Text = "Add &Dynamic Placeholder", AutoSize = true, Enabled = false }; private readonly Button _addAddressControlButton = new() { Text = "Add &Address Control", AutoSize = true }; + // Post-Sprint-9 user-reported gap: there was previously no way to delete a placed element or + // Address Control at all. Enabled only while something is selected (see RefreshSelectionLabel). + private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false }; private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." }; // CSV field mapping (Sprint 3, Batches 2-4). @@ -1190,6 +1193,15 @@ public sealed class TemplateDesignerForm : Form _dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3); panel.Controls.Add(_dynamicFieldColumnComboBox); panel.Controls.Add(_addAddressControlButton); + _deleteSelectedButton.Click += (_, _) => + { + if (_canvas.RemoveSelectedElement()) + { + RefreshPropertiesPanel(); + RefreshSelectionLabel(); + } + }; + panel.Controls.Add(_deleteSelectedButton); _selectionLabel.Margin = new Padding(18, 6, 3, 3); panel.Controls.Add(_selectionLabel); @@ -1208,6 +1220,7 @@ public sealed class TemplateDesignerForm : Form if (selected is not null) { _selectionLabel.Text = $"Selected: {selected.DisplayText} at ({selected.X:0.#}, {selected.Y:0.#}) pt."; + _deleteSelectedButton.Enabled = true; return; } @@ -1215,6 +1228,7 @@ public sealed class TemplateDesignerForm : Form _selectionLabel.Text = selectedAddress is null ? "No element selected." : $"Selected address control at ({selectedAddress.X:0.#}, {selectedAddress.Y:0.#}) pt."; + _deleteSelectedButton.Enabled = selectedAddress is not null; } private Control BuildCanvasSettingsPanel() diff --git a/state.md b/state.md index 77c7f63..043b390 100644 --- a/state.md +++ b/state.md @@ -2,23 +2,23 @@ > The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff." -**Phase:** 1 - Backlog refinement (next sprint cycle) -**Leading agent:** `product-owner` -**Process file:** `process/01_backlog_refinement.md` +**Phase:** 3 - Sprint execution +**Leading agent:** `dev-team` +**Process file:** `process/03_sprint_execution.md` -**Sprint:** 9 executed, reviewed, and retrospected (2026-10-27). 13/13 points Done, sprint goal met in full. -**Sprint goal:** Give static and dynamic text elements a real, independently adjustable width and height, so that content too long to fit wraps onto additional lines and is clipped (not silently overrun) once it exceeds the box — using Debenu's native wrap/box-drawing primitives for the real PDF and GDI+'s native rectangle-wrap for a close design-time approximation on the canvas and preview panel, directly answering the human product owner's "make the boxes longer" request. **Met in full**, per Sprint Review. -**Current sprint backlog:** `backlog/sprints/sprint-9.md` +**Sprint:** 10 planned (2026-10-27), not yet executed. +**Sprint goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator. +**Current sprint backlog:** `backlog/sprints/sprint-10.md` -**Sprint 9 execution, review, and retrospective summary:** The committed story ("Add an adjustable width and height with text wrapping to static and dynamic text elements," 13 pts) is Done. Real vendor-DLL investigation (licensed probe against the actual Debenu library, done *before* writing implementation code) found native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points, confirmed via real rendered screenshots to wrap, clip (a too-short box returns fewer lines than exist, not an error), and compose rotation with wrapping natively in one call. `DebenuPdfRenderer.AddPage` now branches to these calls when a draw carries a box; canvas (`TemplateCanvasControl`) and preview (`TemplatePreviewBuilder`/`TemplatePreviewControl`) approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation, since the desktop process shares no code path with the CLI's Debenu engine). The resize-handle coexistence question was resolved more simply than planned: the existing font-size handle became context-sensitive (box mode vs. font-scale mode, chosen once at drag-start via `HasBox`) rather than needing a second handle. Properties panel gained "Box width"/"Box height" fields. Tests: 448/448 -> 476/476 (23 new). Live-verified at three layers: a standalone licensed vendor-DLL probe with screenshots; a true end-to-end run (hand-authored template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output PDF rasterized and visually inspected showing genuine 4-line wrap and genuine clip to "WILLIAM EDWARD"); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real content TextBox, real Box width/height panel fields, real mouse-driven resize-handle drag, real canvas screenshot). Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/backlog.md`'s Sprint 9 refinement/planning/Review outcome notes, `backlog/sprints/sprint-9-retrospective.md`. +**Recent history (Sprint 9, closed 2026-10-27):** Static/dynamic text elements shipped real adjustable width/height with native Debenu wrap/clip (13/13 points, sprint goal met in full); test suite 448/448 -> 476/476. Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/sprints/sprint-9-retrospective.md`. A **Proposed** (not yet applied) process-improvement log entry awaits human review: the project's own Debenu license key (`key.txt`, repo root) was overlooked by at least two prior sprints' searches — logged in `logs/process_improvement_log.md` (2026-10-27). -**Real de-risking finding with a downstream effect:** Debenu composes rotation and wrapping natively in one `DrawRotatedTextBox` call, meaning "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, previously estimated 8 pts) likely already has its rotation half satisfied by this sprint's shipped code. Not re-sized unilaterally — flagged for `product-owner`/`dev-team` to re-confirm at the next backlog refinement before it is ever committed to a sprint. +**Sprint 10 backlog refinement (2026-10-27):** Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found its rotation-composition scope was already delivered as a byproduct of Sprint 9's implementation (confirmed: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`, and both canvas/preview box-mode draws already run inside the existing rotation transform). Renamed to "Add a live wrap/clip indicator for text elements" and re-sized down to **3 points** — only the passive visual indicator itself remains. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`. -**Process-improvement finding, Proposed (not yet applied — awaiting human product owner review):** the project's own Debenu license key (`key.txt` at the repo root, one level above `code/`) was found this sprint after at least two prior points (this project's own `DebenuPdfRendererIntegrationTests`/`DebenuPdfRendererRotationTests` design, and the Sprint 7 Sprint Review) concluded or implied no key was available, having only searched under `code/` rather than the actual repo root the resolver's own 10-level upward walk reaches. Logged in `logs/process_improvement_log.md` (2026-10-27) as a documentation-nudge proposal (no code change needed) for `CLI_CONTRACT.md`'s "Debenu license key" section or `AGENTS.md` — status **Proposed**, needs a human decision on exact wording/placement before being applied. +**Sprint 10 planning (2026-10-27):** Committed 13 points: the now-3-point indicator story plus epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) + align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair — finally pulled after being Ready and deferred twice (Sprint 8, Sprint 9) for fresher, more urgent requests; neither reason applies a third time. Pairing the small indicator story with multi-select's genuinely novel selection-model work does not trip the established "two large/novel stories" caution, since only one side of the pairing carries that risk profile. Full reasoning: `backlog/backlog.md`'s Sprint 10 planning outcome note. -**Retrospective outcome (`scrum-master`, `backlog/sprints/sprint-9-retrospective.md`):** Ninth sprint, clean delivery, no drops (one item — built-form-smoke discipline — showed genuine follow-through in substance despite its literal multi-batch trigger not arising this single-story sprint; two items correctly carry forward unchanged since untriggered for a third sprint running: epic 6's built-form-smoke rule, and the Sprint 6 Address Control interaction-code extraction evaluation). One near-miss named honestly: the live built-form GUI smoke was initially dropped from the story's own task list mid-execution (only the CLI-side check was recorded), caught and completed within the same session before Sprint Review — flagged as a new action item so "the CLI check feels sufficient" doesn't recur as reasoning for skipping a GUI-touching story's own DoD-required smoke. +**Sprint 10 Batch 1 complete (2026-10-27), plus two ad-hoc corrections mid-batch:** "Add a live wrap/clip indicator for text elements" (3/13 points) shipped — a new framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel, drawn inside the existing per-element rotation transform. Mid-batch, the human product owner raised two live-build issues, addressed together before continuing: (1) **no way to delete a placed element or Address Control** — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) **the resize handle should control only box width, font size set elsewhere** — removed the Sprint 8 uniform-font-scale handle behavior entirely; `TextElementLayout.HasBox`/`TemplateElement.HasBox` now mean "Width is set" alone, with `Height` independent and optional (new `HasHeightClip` flag), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path (`DrawWrappedText`/`GetWrappedText`+`DrawRotatedMultiLineText`). Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) showed correct auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator correctly appearing. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness (a WinForms quirk absent a real shown window) — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. -**Next action:** Natural post-retrospective pause per `AGENTS.md`. When the user says to continue, start Sprint 10 backlog refinement as `product-owner`, folding in the Sprint 9 retrospective's action items above (especially: re-confirm/re-size "Support rotated wrapped text..." before ever committing it). Ready-and-waiting work for Sprint 10: "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, ~8 pts pending re-sizing) and "...Address Control lines" (epic 4, 8 pts, depends on the former); epic 6's multi-select/align pair (10 pts) and "Undo and redo layout changes" (13 pts) remain Ready and unchanged. Still open, non-blocking: "Warn on text overflow before render" (epic 4, provisional 5 pts, blocked on both wrap/clip stories above landing first); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as a stale onboarding-era placeholder needing re-verification before ever being pulled; the Proposed process-improvement log entry above awaiting a human decision. +**Next action:** Continue Sprint 10 as `dev-team`: Batch 2 "Select multiple elements at once on the canvas" (5 pts, epic 6, no dependency but sequenced next as the epic-6 pair's foundation), then Batch 3 "Align and distribute multiple elements" (5 pts, depends on Batch 2), per `backlog/sprints/sprint-10.md`. Key design note for Batch 2, not to be rediscovered mid-sprint: add multi-select as an *additive* layer alongside the existing single-selection state (`CanvasElementEditor.Selected` for standalone elements, `TemplateCanvasControl._selectedAddressControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected. This is a natural mid-sprint checkpoint (a large, first-of-its-kind feature remains, plus the human product owner just gave live-build feedback twice this session) — worth confirming with the user before continuing into the multi-select implementation. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision. ## Phase reference @@ -89,3 +89,6 @@ After phase 5, loop back to phase 1 for the next sprint. | 2026-10-27 | 3 - Sprint execution | `dev-team` completed the sprint's single committed story, "Add an adjustable width and height with text wrapping to static and dynamic text elements" (13/13 points). A licensed real-DLL probe against the actual Debenu library, run before any implementation code, found and confirmed (via real rendered screenshots) native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points that wrap, clip, and compose rotation with wrapping natively. `TextElementLayout`/`TemplateElement` gained nullable `Width`/`Height` (optional `width`/`height` XML attributes, both sides independently implemented); `DebenuPdfRenderer.AddPage` branches to the native calls when a draw has a box; canvas and preview panel approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation). The resize-handle coexistence question was resolved by making the existing font-size handle context-sensitive (box mode vs. font-scale mode, chosen once at drag-start) rather than adding a second handle. Properties panel gained Box width/height fields. Tests: 448/448 -> 476/476 (23 new, including a licensed pixel-based clipping test). Live-verified at three layers: the vendor-DLL probe itself; a true end-to-end run (real template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output rasterized and visually confirmed to wrap/clip correctly); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real panel fields, real mouse-driven resize drag, real canvas screenshot) — the last of these was initially skipped mid-execution and caught/completed before Sprint Review. Found, not assumed: the project's own Debenu license key (`key.txt`, repo root) had been overlooked by at least two prior sprints' searches (which only checked under `code/`). Full detail: `backlog/sprints/sprint-9.md`. | | 2026-10-27 | 4 - Sprint review | `product-owner` verified the Sprint 9 story against acceptance criteria (verdict: sprint goal met in full). Independently re-ran the full test suite (476/476 confirmed directly via `dotnet test`) and read the actual `DebenuPdfRenderer.AddPage` box branch, `TemplateLayoutXmlSerializer`/`TemplateXmlParser` persistence, and new test files directly, confirming the claims rather than trusting dev-team's report alone. Agreed the dependent "Support rotated wrapped text..." story (previously 8 pts) needs re-confirmation, not silent reuse, given this sprint's own de-risking finding. Updated `backlog/backlog.md`'s epic table and added a "Sprint 9 Review outcome" section; added a Development verification note to the story in `backlog/epics/04_live_preview_and_record_navigation.md`. | | 2026-10-27 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-9-retrospective.md`). Ninth sprint, clean delivery, no drops (one item showed genuine follow-through in substance despite its literal trigger not arising; two correctly carry forward unchanged, one now a third sprint running). Named one honest near-miss: the built-form GUI smoke was initially dropped from the story's own task list mid-execution, caught and completed before review — logged as a new action item. Logged a **Proposed** (not yet applied) process-improvement entry in `logs/process_improvement_log.md`: the project's own Debenu license key was overlooked by at least two prior sprints' searches, a recurring (2+ occurrence) verification-depth gap meeting `AGENTS.md`'s kit-level bar — recommended fix is a documentation nudge in `CLI_CONTRACT.md`/`AGENTS.md`, awaiting a human product owner decision on wording/placement. All anti-patterns checked; none found. | +| 2026-10-27 | 1 - Backlog refinement | `product-owner` ran Sprint 10 backlog refinement after the user confirmed the post-Sprint-9-retrospective pause point with "continue." Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found rotation composition was already delivered as a byproduct of Sprint 9's implementation (`DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`; canvas/preview box-mode draws already run inside the existing rotation transform), proven with a real rendered screenshot of a 20°-rotated, wrapped, boxed element. Renamed to "Add a live wrap/clip indicator for text elements," re-sized down to 3 points. Also found and documented that automated pixel-row-count clip verification is unreliable under rotation, simplifying a new regression test accordingly. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`. | +| 2026-10-27 | 2 - Sprint planning | `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has nine data points (20, 19, 18, 18, 15, 18, 18, 13, 13). Committed 13 points: the 3-point indicator story plus epic 6's multi-select/align pair (10 pts), finally pulled after being deferred twice (Sprint 8, Sprint 9) for fresher requests that no longer apply. Reasoned explicitly that pairing a small, additive story with one large/novel story (multi-select's new selection model) does not trip the established "two large/novel stories" caution, since only one side carries that risk profile. Recorded in `backlog/sprints/sprint-10.md`; full reasoning in `backlog/backlog.md`'s Sprint 10 planning outcome note. | +| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |