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 <noreply@anthropic.com>master
| @@ -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 | |||
| @@ -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 | |||
| @@ -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). | |||
| @@ -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) <br> - [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 <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [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 <br> - [ ] 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 <br> - [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [ ] Visible selection indication for every selected item, not just the primary one <br> - [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [ ] 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) <br> - [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [ ] 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) <br> - [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [ ] 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. | | |||
| @@ -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. | |||
| ## `<addressControl>` | |||
| @@ -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 | |||
| /// <summary>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.</summary> | |||
| 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. | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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(""" | |||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||
| <text x="1" y="1" font="Arial" size="12" width="100">Hi</text> | |||
| @@ -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(""" | |||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||
| <text x="1" y="1" font="Arial" size="12" height="50">Hi</text> | |||
| </envelopeTemplate> | |||
| """); | |||
| 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); | |||
| } | |||
| @@ -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 + <effective height>, 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; | |||
| } | |||
| @@ -40,11 +40,15 @@ public sealed record TemplateElement( | |||
| double? Width = null, | |||
| double? Height = null) | |||
| { | |||
| /// <summary>True only when both <see cref="Width"/> and <see cref="Height"/> 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.</summary> | |||
| public bool HasBox => Width is > 0 && Height is > 0; | |||
| /// <summary>Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has | |||
| /// been set — <see cref="Height"/> 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).</summary> | |||
| public bool HasBox => Width is > 0; | |||
| /// <summary>True only when an explicit clip ceiling is also set, on top of the wrap | |||
| /// <see cref="HasBox"/> already establishes.</summary> | |||
| public bool HasHeightClip => Height is > 0; | |||
| /// <summary>Back-compat convenience factory matching the pre-Sprint-5 "pure static text" | |||
| /// shape — builds the equivalent one-literal-run <see cref="Runs"/> list. (A second | |||
| @@ -31,27 +31,11 @@ public sealed class CanvasElementEditor | |||
| /// space points) when snapping is enabled but no explicit size has been set.</summary> | |||
| public const double DefaultGridSizePoints = 10.0; | |||
| /// <summary>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 | |||
| /// <see cref="TextElementLayout.DisplayText"/> at its current <see cref="TextElementLayout.FontSize"/> | |||
| /// (see <c>TemplateCanvasControl.MeasureElement</c>) — so "resize" here means uniformly | |||
| /// scaling <see cref="TextElementLayout.FontSize"/>, the only property that actually changes | |||
| /// what renders. These bounds mirror the properties panel's existing "Font size (pt)" | |||
| /// <c>NumericUpDown</c> (Minimum 1, Maximum 1000) so a drag can never produce a value the | |||
| /// panel would then silently re-clamp on its next refresh.</summary> | |||
| public const double MinFontSize = 1.0; | |||
| public const double MaxFontSize = 1000.0; | |||
| private readonly TemplateLayoutDocument _document; | |||
| private readonly Func<TextElementLayout, (double Width, double Height)> _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<TextElementLayout, (double Width, double Height)> measureText) | |||
| @@ -164,6 +148,24 @@ public sealed class CanvasElementEditor | |||
| public void Select(TextElementLayout? element) => Selected = element; | |||
| /// <summary>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.</summary> | |||
| 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; | |||
| } | |||
| /// <summary>Starts a drag of the currently selected element, recording the offset between | |||
| /// the grab point and the element's origin so <see cref="DragTo"/> moves it smoothly rather | |||
| /// than snapping its origin to the mouse position.</summary> | |||
| @@ -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; | |||
| /// <summary>Post-Sprint-8 user-requested feature: the current world-space (canvas-point) | |||
| /// position of the selected element's font-size resize handle, or <c>null</c> if nothing is | |||
| /// selected. Sits at the box's own top-right corner (<see cref="TextElementLayout.X"/> + | |||
| /// width, <see cref="TextElementLayout.Y"/> + height) — the corner diagonally opposite the | |||
| /// element's fixed (X, Y) anchor, which never moves as font size changes — rotated around the | |||
| /// same pivot <see cref="HandlePosition"/> uses so it orbits correctly for a rotated | |||
| /// element.</summary> | |||
| /// <summary>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 <c>null</c> if | |||
| /// nothing is selected. Sits at the right-edge midpoint (<see cref="TextElementLayout.X"/> + | |||
| /// 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 <see cref="HandlePosition"/> uses so it orbits | |||
| /// correctly for a rotated element.</summary> | |||
| 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); | |||
| } | |||
| /// <summary>Whether the given canvas-space point is within grab range of the currently | |||
| /// selected element's font-size resize handle. Always <c>false</c> when nothing is | |||
| /// selected element's width resize handle. Always <c>false</c> when nothing is | |||
| /// selected.</summary> | |||
| public bool HitTestResizeHandle(double xPoints, double yPoints) | |||
| { | |||
| @@ -260,19 +263,11 @@ public sealed class CanvasElementEditor | |||
| } | |||
| /// <summary>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 | |||
| /// <see cref="TextElementLayout.HasBox"/> 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 (<see cref="TextElementLayout.HasBox"/>), the drag directly resizes | |||
| /// <see cref="TextElementLayout.Width"/>/<see cref="TextElementLayout.Height"/> — 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 <see cref="TextElementLayout.FontSize"/> uniformly, | |||
| /// capturing the current font size and measured-box diagonal so <see cref="ResizeDragTo"/> 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 | |||
| /// <see cref="RotateDragTo"/> already uses, rather than accumulating incremental deltas, which | |||
| /// would drift. No-op if nothing is selected.</summary> | |||
| /// Post-Sprint-9 user-requested change (2026-10-27): the handle controls only | |||
| /// <see cref="TextElementLayout.Width"/> — 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.</summary> | |||
| 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; | |||
| } | |||
| /// <summary>Updates the selected element from the pointer's current position — either its | |||
| /// <see cref="TextElementLayout.Width"/>/<see cref="TextElementLayout.Height"/> (box mode) or | |||
| /// its <see cref="TextElementLayout.FontSize"/> (uniform scale mode), per the mode captured at | |||
| /// <see cref="BeginResizeDrag"/>. 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.</summary> | |||
| /// <summary>Updates the selected element's <see cref="TextElementLayout.Width"/> 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 | |||
| /// <see cref="TextElementLayout.Height"/> or <see cref="TextElementLayout.FontSize"/> — see | |||
| /// <see cref="BeginResizeDrag"/>'s remarks. No-op if nothing is selected or a resize-drag | |||
| /// hasn't begun.</summary> | |||
| 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; | |||
| } | |||
| /// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": the current | |||
| @@ -67,10 +67,15 @@ public sealed class TextElementLayout | |||
| /// <see cref="Width"/> is also set.</summary> | |||
| public double? Height { get; set; } | |||
| /// <summary>True only when both <see cref="Width"/> and <see cref="Height"/> 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).</summary> | |||
| public bool HasBox => Width is > 0 && Height is > 0; | |||
| /// <summary>Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has | |||
| /// been set — <see cref="Height"/> 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).</summary> | |||
| public bool HasBox => Width is > 0; | |||
| /// <summary>True only when an explicit clip ceiling is also set, on top of the wrap | |||
| /// <see cref="HasBox"/> already establishes.</summary> | |||
| public bool HasHeightClip => Height is > 0; | |||
| /// <summary>Sprint 5: the element's content as an ordered run sequence. Always has at least | |||
| /// one entry (an empty single literal run is this model's equivalent of "no content yet") — | |||
| @@ -0,0 +1,24 @@ | |||
| namespace EnvelopeRenderer.Desktop.Core.Design; | |||
| /// <summary> | |||
| /// 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 | |||
| /// <see cref="CanvasElementEditor"/> takes an injected measurement delegate for the same reason). | |||
| /// </summary> | |||
| public static class WrapClipDetector | |||
| { | |||
| /// <param name="naturalWidth">The text's own unconstrained (single-line) measured width.</param> | |||
| /// <param name="wrappedHeight">The text's measured height once wrapped to <paramref name="boxWidth"/>.</param> | |||
| /// <param name="boxWidth">The element's authored box width.</param> | |||
| /// <param name="boxHeight">The element's authored box height.</param> | |||
| 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); | |||
| } | |||
| } | |||
| @@ -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". | |||
| @@ -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, | |||
| "<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" + | |||
| @@ -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, | |||
| "<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">" + | |||
| "<text x=\"1\" y=\"1\" font=\"Arial\" size=\"12\" height=\"50\">Hi</text>" + | |||
| "</envelopeTemplate>"); | |||
| 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); | |||
| } | |||
| @@ -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] | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| /// <summary>Sprint 7, "Snap elements to grid and guides": mirrors | |||
| @@ -88,7 +94,7 @@ public sealed class TemplateCanvasControl : Control | |||
| /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this | |||
| /// property existed.</summary> | |||
| 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); | |||
| } | |||
| /// <summary>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 <see cref="RemoveSelectedAddressLine"/>) and clears the selection. Returns | |||
| /// <c>false</c> (a no-op) if nothing is selected.</summary> | |||
| 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 | |||
| /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to | |||
| /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time | |||
| /// 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 (<see cref="TextElementLayout.HasBox"/>), 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 | |||
| /// <see cref="DrawElement"/> itself) automatically treats the box as the element's real | |||
| /// bounding box with no changes needed at those call sites.</summary> | |||
| /// parity with the final PDF. Sprint 9/10, "Add an adjustable width and height with text | |||
| /// wrapping...": once an element has a box (<see cref="TextElementLayout.HasBox"/> — 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 <see cref="TextElementLayout.HasHeightClip"/> | |||
| /// 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 <see cref="DrawElement"/> itself) automatically | |||
| /// treats this as the element's real bounding box with no changes needed at those call sites.</summary> | |||
| 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); | |||
| } | |||
| /// <summary>Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap | |||
| /// <paramref name="text"/> to <paramref name="width"/> points — the auto-height a boxed | |||
| /// element with no explicit <see cref="TextElementLayout.Height"/> clip ceiling uses, mirroring | |||
| /// how the CLI asks Debenu's own <c>GetWrappedTextHeight</c> for the equivalent value.</summary> | |||
| 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; | |||
| } | |||
| /// <summary>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 <see cref="WrapClipDetector"/>; this just does the GDI+ measurement | |||
| /// that decision needs.</summary> | |||
| 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); | |||
| } | |||
| } | |||
| /// <summary>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.</summary> | |||
| 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); | |||
| } | |||
| @@ -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() | |||
| @@ -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`. | | |||
Powered by TurnKey Linux.