浏览代码

Sprint 10 Batch 1: wrap/clip indicator, plus delete and resize-handle fixes

Sprint 10 backlog refinement/planning: re-confirmed the epic-4 "rotated
wrap/indicator" story after Sprint 9 shipped rotation composition as a
byproduct; renamed and re-sized it down to the indicator alone (3 pts).
Committed it alongside epic 6's long-deferred multi-select/align pair.

Batch 1 shipped: a live wrap/clip indicator (dashed-orange outline) on the
canvas and preview panel, backed by a new framework-free WrapClipDetector.

Mid-batch, live-build feedback surfaced two real gaps, fixed together:
- No way to delete a placed element or Address Control - added
  CanvasElementEditor.RemoveSelected()/TemplateCanvasControl.
  RemoveSelectedElement(), wired to Delete/Backspace and a toolbar button.
- The resize handle should control only box width, with font size set
  elsewhere - removed the Sprint 8 uniform-font-scale handle behavior
  entirely. HasBox now means "Width is set" alone; Height is independent
  and optional (new HasHeightClip flag). DebenuPdfRenderer gained a
  "Width alone, auto-height, no clip" render path using Debenu's
  DrawWrappedText/GetWrappedText+DrawRotatedMultiLineText.

Verified via a real licensed end-to-end CLI render (unrotated and rotated
auto-height wrap, no clipping) and a reflection-driven built-form harness
(real mouse-driven resize drag, real Delete key, real canvas screenshot).
Test suite grew from 476/476 to 488/488.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 4 天前
父节点
当前提交
a4a9420878
共有 19 个文件被更改,包括 823 次插入304 次删除
  1. +84
    -1
      backlog/backlog.md
  2. +8
    -0
      backlog/epics/02_template_designer_gui_foundation.md
  3. +71
    -54
      backlog/epics/04_live_preview_and_record_navigation.md
  4. +36
    -0
      backlog/sprints/sprint-10.md
  5. +24
    -15
      code/TEMPLATE_FORMAT.md
  6. +114
    -2
      code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs
  7. +22
    -1
      code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
  8. +51
    -17
      code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
  9. +9
    -5
      code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
  10. +49
    -93
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  11. +9
    -4
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
  12. +24
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs
  13. +81
    -87
      code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
  14. +22
    -3
      code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
  15. +6
    -2
      code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
  16. +61
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs
  17. +124
    -9
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  18. +14
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  19. +14
    -11
      state.md

+ 84
- 1
backlog/backlog.md 查看文件

@@ -8,7 +8,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (7 of 7 stories — Sprint 2, 4, 6) |
| 3 | CSV Integration and Field Mapping | `epics/03_csv_integration_and_field_mapping.md` | Done (4 of 4 stories — Sprint 4) |
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Support rotated wrapped text..." (8 pts, flagged for re-sizing — largely already delivered as a byproduct) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |
@@ -200,6 +200,89 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
geometry concept, with a possible further split recommended at sizing time if it doesn't fit one
sprint even alone.

### Sprint 10 planning outcome (2026-10-27)

`scrum-master` facilitated with `product-owner`/`dev-team` input, immediately following Sprint 10
backlog refinement above.

- Capacity signal now has nine data points (Sprints 1-9: 20, 19, 18, 18, 15, 18, 18, 13, 13). The
18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented,
deliberate under-commits (Sprint 5's large/risky story, Sprint 8's fresh-priority story, Sprint
9's fresh-priority story), not capacity misses.
- **Committed 13 points**: "Add a live wrap/clip indicator for text elements" (3 pts, epic 4) paired
with epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) +
align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair, pulled whole
per this team's established practice. Full plan: `backlog/sprints/sprint-10.md`.
- **Reasoning for finally pulling epic 6's pair now, explicit rather than default:** it has been
Ready and waiting since Sprint 7 — deferred at Sprint 8 planning (Address Control rotation was a
fresher, more urgent human request) and again at Sprint 9 planning (the wrap/clip core story was
both fresher and ahead in table order). Neither reason applies with the same force this sprint:
the wrap/clip chain's own next step is now only 3 points, leaving real spare capacity, and no
new, fresher request has displaced epic 6 a third time. Continuing to defer a Ready, sized,
3-sprint-old pair without a stated reason would itself become the anti-pattern this team's own
practice exists to avoid.
- **Why pairing (not a concern the "two large/novel stories" rule blocks):** the established
caution is about stacking *two* large, first-of-its-kind, architecturally risky stories in one
sprint (e.g., Sprint 8's rejected rotation-drag-handle + multi-select combination). Here, only
one side of the pairing — multi-select's new selection model — carries that character; the
indicator story is small, additive, and touches no shared foundational state (it only reads
existing box/rotation data to decide what to draw). This is a small-plus-one-large combination,
not two-large, so the rule's concern does not apply.
- **Checked for anything else to round out capacity — nothing else fits.** "...Address Control
lines" (8 pts) depends on the indicator landing first and reusing its design, so pulling both in
the same sprint would mean building the indicator and then immediately extending a still-warm
design under time pressure rather than letting it settle — deferred to Sprint 11. "Undo and redo
layout changes" (13 pts) alone would total 16 with the indicator, a reasonable combination in
principle, but was not chosen over the multi-select/align pair given the pair's own 3-sprint-old
deferral making it the more overdue commitment. "Complete the first text-only operator workflow"
(5 pts) remains explicitly excluded pending its own stale-label re-verification.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry
behavior, both in `logs/impediment_log.md`) — still non-blocking for this sprint's committed
items.

### Sprint 10 backlog refinement outcome (2026-10-27)

`product-owner` ran Sprint 10 backlog refinement after the human product owner confirmed the
post-Sprint-9-retrospective pause point with "continue," folding in the Sprint 9 retrospective's
own flagged action item directly rather than deferring it.

- **Re-confirmed/re-sized "Support rotated wrapped text and a live wrap/clip indicator for text
elements" as the retrospective asked**, via real code inspection and a real, licensed end-to-end
test rather than assumption. Confirmed `DebenuPdfRenderer.AddPage`'s box branch already calls
`DrawRotatedTextBox` for any rotated boxed draw, and both `TemplateCanvasControl.DrawElement`'s
and `TemplatePreviewControl.DrawItem`'s box-mode draw already run inside the same rotation
transform every other element uses — rotation composition was a byproduct of Sprint 9's own
implementation shape, not deferred work as the original estimate assumed. Proved this with a
real template (20°-rotated, 150x80pt boxed static element) rendered through the actual built
`EnvelopeRenderer.Cli.exe`, visually confirming correct wrapped-and-rotated output, plus a new
permanent regression test (`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_...`).
- **Renamed and re-sized the story**: "Support rotated wrapped text and a live wrap/clip indicator
for text elements" (8 pts) becomes "Add a live wrap/clip indicator for text elements" (**3
pts**) — its former rotation-composition ACs are already satisfied, so all that remains is the
passive visual indicator itself. Updated its dependency references in the Address Control
wrap/clip story and "Warn on text overflow before render." Full detail:
`epics/04_live_preview_and_record_navigation.md`.
- **Real finding worth naming, not just the estimate change:** verifying exact wrap/clip behavior
under rotation via automated pixel-row counting turned out to be unreliable (a heavily-clipped
rotated block can occupy *more* rasterized pixel rows than a taller, unclipped one, since
rotation angle affects vertical footprint as much as line count does) — discovered while writing
the new regression test, and documented directly in that test's own remarks so a future session
doesn't rediscover the same dead end. The test was simplified to assert real content renders
correctly rather than a specific row-count relationship; the stronger correctness evidence
remains the direct screenshot inspection already performed.
- **No other Ready items needed re-verification.** Epic 6's multi-select/align pair (10 pts) and
undo/redo (13 pts) were already re-verified at Sprint 9's own refinement with no material change
found; nothing has changed in that area since.
- **Recommended Sprint 10 candidate:** "Add a live wrap/clip indicator for text elements" (3 pts)
as a small, low-risk, self-contained pull — it has no dependency beyond Sprint 9's already-Done
story. Given its small size, `scrum-master`/`dev-team` should weigh at planning whether to pair
it with another Ready item to round out capacity, per this team's established practice of not
leaving obviously-available capacity idle without a stated reason.
- No changes to the two open impediments (template asset path strategy; UNC timeout/retry
behavior, both in `logs/impediment_log.md`) — still non-blocking. "Complete the first text-only
operator workflow" (epic 1, 5 pts) remains flagged as needing re-verification before ever being
pulled — not resolved in this refinement, carried forward as-is.

### Sprint 9 Review outcome (2026-10-27)

- The committed story (13/13 points) is Done; verified against all 6 acceptance criteria with


+ 8
- 0
backlog/epics/02_template_designer_gui_foundation.md 查看文件

@@ -45,6 +45,14 @@ As a **print operator**, I want to place static and dynamic text placeholders on

**Post-Sprint-8 user-reported fix (2026-10-26, handled outside formal ceremony):** User reported that dragging or rotating a dynamic/mixed-content element on the canvas felt visibly choppy, not smooth like a static element. Root cause confirmed by code inspection before writing anything down: `TemplateCanvasControl.OnMouseMove` fires `ElementsChanged` on every single mouse-move tick of a drag or rotate gesture, and `TemplateDesignerForm`'s handler responded by running the full `RefreshPropertiesPanel()` — which, for an element bound to a single CSV column (`HasSingleColumnRun`), also calls `RefreshRebindColumnComboBox`, clearing and fully repopulating the rebind combo box from every loaded CSV header (plus a `.ToList().FindIndex(...)` re-scan) on every tick. A purely static element skips that path entirely (`isSingleColumn` is false), which is exactly why the choppiness was specific to dynamic/mixed elements and easy to miss during Sprint 2's original static-vs-dynamic drag verification. Fixed by adding `TemplateCanvasControl.IsInteracting` (true while any move/resize/rotate gesture is active) and a new cheap `TemplateDesignerForm.RefreshInteractivePositionFields()` that syncs only the X/Y/width/angle fields a gesture can actually change; the full panel refresh now runs once, on `OnMouseUp`, instead of on every tick. Live-verified with a reflection-driven harness (same technique as prior sprints) against the real built assembly, using the real 22-header sample CSV and a dynamic placeholder bound to "Full Name": before the fix, 60 synthetic drag ticks took 88.56 ms total (1.48 ms/tick) and enumerated the header list 120 times *during* the drag; after the fix, the same 60 ticks took 1.32 ms total (0.022 ms/tick, a ~67x improvement) with 0 header-list enumerations during the drag and exactly one full refresh firing after mouse-up — confirmed identically for the rotate gesture. Position and angle were confirmed to still update correctly on every tick (the element visibly moved/rotated throughout), so the fix removes only the redundant expensive work, not the live feedback itself. Full test suite re-run: 433/433 passing, no regression. Not logged as technical debt (resolved same-day, not left open); documented here per this team's established pattern for same-day post-review/ad-hoc user feedback (see `state.md`'s history for the 2026-10-19 precedents).

**Post-Sprint-9 user-requested correction and gap fix (2026-10-27, handled outside formal ceremony, mid-Sprint-10):** User reported two issues while reviewing the live build: (1) no way to delete a placed element or Address Control at all; (2) the canvas resize handle should control box width only, with font size set exclusively elsewhere — reversing the Sprint 8 "handle scales font size when there's no box" behavior above, not just refining it. Clarified the exact intended behavior with the user via one round of questions before changing anything.

*Delete:* added `CanvasElementEditor.RemoveSelected()` (Desktop.Core) and `TemplateCanvasControl.RemoveSelectedElement()` (handles both a standalone element and a whole Address Control — never a drilled-into address line, which already has its own `RemoveSelectedAddressLine`). Wired to both the Delete/Backspace key on the canvas (`OnKeyDown`, guarded against firing mid-gesture) and a new "Delete Selected" toolbar button, enabled only while something is selected. `TemplateCanvasControl` needed `ControlStyles.Selectable`/`TabStop`/an explicit `Focus()` call in `OnMouseDown` to receive key events at all — a plain `Control` (unlike `UserControl`) isn't focusable by default, which is the root cause this gap existed in the first place.

*Resize handle:* removed the Sprint 8 uniform-font-scale behavior entirely rather than keeping it conditional. `CanvasElementEditor.ResizeDragTo` now only ever sets `TextElementLayout.Width` from the pointer's horizontal distance to the fixed (X, Y) anchor — never `Height`, never `FontSize`. The handle itself moved from the box's top-right corner to the right-edge midpoint, mirroring the Address Control's own width-only handle position, so its placement communicates "width only" rather than implying a corner-drag scale. `Width`'s own gating logic changed as a direct consequence (see the dependent "Add an adjustable width and height..." story's own post-Sprint-9 note in epic 4): `TextElementLayout.HasBox` now means "Width is set" alone, with `Height` independent and optional (a `HasHeightClip` flag), rather than requiring both together as Sprint 9 originally shipped.

Tests: removed 8 obsolete font-scale-specific tests, added 15 new ones (3 `RemoveSelected*` tests; the rest updated/added resize-handle and `HasBox`/`HasHeightClip` semantic tests across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, and the CLI's `TemplateXmlParserTests`/`DebenuPdfRendererWrapTests`). Full suite 476/476 -> 488/488. Live-verified via a reflection-driven built-form harness: a real mouse-driven drag on the real resize handle grew `Width` to 578.9pt while `Height` stayed null and `FontSize` stayed exactly 12; the real Delete key removed the selected element and cleared selection; a fresh narrow-boxed (120pt) element's canvas screenshot shows genuine multi-line wrap plus the dashed-orange wrap indicator (see the epic 4 indicator story) correctly appearing. One disclosed evidence-depth caveat: the toolbar "Delete Selected" button's own `PerformClick()` did not reliably fire in this synthetic reflection harness (a known WinForms quirk for a button never shown in a real top-level window with a running message loop) — the underlying `RemoveSelectedElement()` method was proven correct via a direct call returning `true` and removing the control, and the button's `Click` wiring is structurally identical to every other already-shipped, real-world-proven toolbar button in this form, but the button's own click-to-delete path specifically was not re-observed through a real mouse click.

**Estimate:** 5 points
**Dependencies:** None



+ 71
- 54
backlog/epics/04_live_preview_and_record_navigation.md 查看文件

@@ -162,6 +162,23 @@ avoidable, not a genuine environment constraint — flagged for the retrospectiv
retroactively (those sprints' shipped behavior was still verified correct by other means at the
time).

**Post-Sprint-9 semantics correction (2026-10-27, user-requested, handled outside formal ceremony,
mid-Sprint-10):** the human product owner reported the canvas resize handle should control only
box width, with font size set exclusively via the properties panel. This required decoupling
`Width` and `Height`'s gating logic from how this story originally shipped them: `HasBox` now
means "Width is set" alone (not "both Width and Height"), with `Height` independent and optional
(a new `HasHeightClip` flag) — present alone with `Width`, it's a clip ceiling exactly as this
story originally specified; present without `Width`, it has no effect; absent entirely, wrapped
content grows to whatever height it needs (no clip). `DebenuPdfRenderer.AddPage` gained a new
"Width alone" branch using Debenu's `DrawWrappedText` (unrotated) or a `GetWrappedText`-then-
`DrawRotatedMultiLineText` composition (rotated, no native "rotated wrap, no height" primitive
exists) — both confirmed via a real, licensed end-to-end CLI render showing correct multi-line
wrap with no clipping, unrotated and rotated. This did not change this story's own original ACs
(width+height together still wrap-then-clip exactly as shipped) — it only changes what happens
when just one dimension is set, which this story's original acceptance criteria didn't fully
specify. Full detail, including the resize-handle side of this change: `epics/02_template_
designer_gui_foundation.md`'s matching post-Sprint-9 note.

**Card**
As a **print operator**, I want to give a static or dynamic text element a real width and height
so that long or variable-length content wraps within a defined box instead of running off
@@ -268,67 +285,64 @@ real PDF) without requiring the polish layer first.
are all Done). Should land before the Address Control extension story, the rotated-wrap/indicator
story immediately below, and before "Warn on text overflow before render."

### Support rotated wrapped text and a live wrap/clip indicator for text elements - Status: Ready
### Add a live wrap/clip indicator for text elements - Status: Ready (re-scoped and re-sized, 2026-10-27 — see note)

**Card**
As a **print operator**, I want a rotated text element's wrapped/clipped content to behave exactly
like an unrotated one, and to see at a glance when a box is currently wrapping or clipping content,
so that I don't have to guess or rotate back to check.

**Conversation notes**
- Split out from the story above during Sprint 9 refinement (`dev-team` sizing, 2026-10-26): the
combined scope (persisted geometry + native wrap/clip render + rotation composition + a new
visual indicator) was too large to responsibly size as one story and land in a single sprint.
This story is the "polish" layer on top of the previous story's core capability, the same
fallback-split shape "Mix static text and CSV fields" itself once offered (core vertical slice
now, polish layer next) rather than a new pattern invented here.
- Rotation and wrapping have never coexisted in this product before. Confirmed by code inspection:
Debenu exposes `DPLDrawRotatedMultiLineText` alongside the unrotated wrap primitives found in the
previous story's sizing note — the empirical question for `dev-team` to resolve here is whether
that entry point (or `DPLGetWrappedText` to pre-compute line breaks, then feeding each line
through the existing per-line rotated-anchor `TextDraw` path Sprint 6 already established) is the
right fit, mirroring how the original rotation story empirically tested `DrawRotatedText` before
committing to a design.
As a **print operator**, I want to see at a glance when a box is currently wrapping or clipping
content, so that I don't have to guess or eyeball the preview to notice.

**Re-scoping note (Sprint 10 refinement, 2026-10-27, `product-owner`/`dev-team`):** this story was
originally "Support rotated wrapped text and a live wrap/clip indicator," carrying an 8-point
estimate that assumed rotated wrap composition was unconfirmed, undelivered work. Confirmed by
direct inspection and a real, licensed end-to-end test during Sprint 9 execution that this is not
the case: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox` whenever
`draw.Angle != 0`, and `TemplateCanvasControl.DrawElement`/`TemplatePreviewControl.DrawItem` both
run their box-mode draw *inside* the same rotation transform every other element already uses — so
rotation composition was a byproduct of Sprint 9's own implementation shape, not deferred work.
Proven, not assumed: a hand-authored template with a 20°-rotated, 150x80pt boxed static element,
rendered through the real `EnvelopeRenderer.Cli.exe` against the real sample CSV and visually
inspected, shows the text correctly wrapped onto 4 lines rotating together as one rigid unit (see
the previous story's development-verification note); a new regression test,
`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_WrapsOntoMultipleLinesAsOneRigidRotatedUnit`,
locks this in. **This story's former AC1 (rotated wrap consistency across all three surfaces) and
AC3/AC4 (rigid-unit rotation, no regression) are therefore already satisfied — confirmed, not
re-verified from scratch here.** The only genuinely remaining scope is the live visual indicator
(the former AC2), which does not exist in any form yet. Renamed accordingly so the story's title
matches its actual remaining scope.
- The box, its wrapped lines, and the wrap/clip indicator itself must all rotate together as one
rigid unit — the same rigid-group approach Address Control rotation (Sprint 8) already
established, reused here rather than invented fresh.
rigid unit — already true for the box/lines (confirmed above); the new indicator must be drawn
inside the same existing rotation transform so it inherits this for free, not reinvent it.
- A visible on-canvas/preview indicator (e.g., a colored border or badge, in the same visual
language as the existing dashed-orange unmapped-column warning) shows when the *current* sample
record's content is actually wrapping and/or being clipped for the selected element — the "warn
this is happening" half of the original request, distinct from and in addition to the full-CSV
pre-render validation pass in "Warn on text overflow before render" below.
this is happening" half of the original "make the boxes longer" request, distinct from and in
addition to the full-CSV pre-render validation pass in "Warn on text overflow before render"
below.
- Detecting "is this wrapping/clipping right now" for the indicator is a measurement question, not
a drawing one: on the canvas/preview (GDI+), compare the resolved text's natural (unwrapped)
measured size against the box; whether GDI+ exposes a direct "would this clip" query or this
needs a manual line-count comparison (mirroring how the CLI could ask Debenu's
`GetWrappedTextLineCount`, though the CLI has no live indicator to build here — this story is
canvas/preview only) is a Development Team implementation detail to confirm empirically, not
decided here.

**Confirmation (Acceptance Criteria)**
- [ ] A rotated element with a set width/height wraps and rotates consistently across all three
surfaces (CLI render, canvas, preview panel) — the box and its wrapped lines rotate together
as one rigid unit, never independently.
- [ ] The canvas and preview panel visibly indicate, for the currently previewed sample record,
when the element's content is wrapping and/or being clipped, in both rotated and unrotated
states.
- [ ] The wrap/clip indicator itself rotates together with the box rather than staying axis-aligned
while the box it describes is rotated.
- [ ] No regression to the previous story's unrotated wrap/clip behavior or to any other rotation
acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8).

**Estimate:** 8 points (`dev-team`, 2026-10-26) — smaller than the previous story since the
persisted model, the native-call investigation pattern, and the shared text-resolution routine are
already established; the genuinely new work here is narrower: one more empirical vendor-API
question (rotated wrap composition) plus a new, but visually-precedented (dashed-orange-style),
indicator. Comparable to "Rotate elements by dragging a handle on the canvas" (5 points) in shape
but larger, since rotation there had a direct existing pattern to copy for its bidirectional-sync
plumbing, whereas rotated wrap composition here has no existing precedent in this codebase at all.

**Re-sizing flag (Sprint 9, 2026-10-27, `dev-team`):** while implementing the previous story, the
"one more empirical vendor-API question" this estimate was built around got answered as a
byproduct, not deferred to this story as planned — `DebenuPdfRenderer` already calls Debenu's
native `DrawRotatedTextBox` for a rotated boxed draw (confirmed via a real, licensed probe:
rotation and wrapping compose correctly in one native call, no manual pre-wrap-then-rotate-per-line
synthesis needed). AC1 (rotated wrap consistency across CLI/canvas/preview) is very likely already
satisfied by the previous story's shipped code, not new work. What's left for this story is
narrower than sized: primarily the live wrap/clip visual indicator (AC2/AC3) and confirming/testing
the rotated case explicitly rather than building it. Not re-sized here — flagged for
`product-owner`/`dev-team` to re-confirm at the next backlog refinement before this is committed to
a sprint, rather than silently shipping at a stale, likely-inflated estimate.
- [ ] No regression to the previous story's rotated or unrotated wrap/clip behavior, or to any
other rotation acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8, Sprint 9).

**Estimate:** 3 points (`dev-team`, 2026-10-27, re-sized down from 8) — the rotation-composition
work the original 8-point estimate was sized around turned out to already exist as a byproduct of
the previous story's implementation shape (confirmed above, not assumed). What's left is narrow:
a measurement comparison (resolved text size vs. box) plus a small, visually-precedented
(dashed-orange-style) drawing addition on the canvas and preview panel, both of which already share
one drawing/rotation code path per element. Comparable to "Snap elements to grid and guides" (5
points) in shape but smaller, since that story added new interactive drag behavior across two
gesture types, while this one only adds a passive visual check plus drawing.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
dynamic text elements" (above, same epic — Done, Sprint 9).

@@ -389,9 +403,13 @@ Sized the same as "Group lines into a single, movable Address Control" reused Sp
but still carried genuine new composite-element scope — the same shape here: real reuse, real
residual scope, not a trivial pass-through.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
dynamic text elements" and "Support rotated wrapped text and a live wrap/clip indicator for text
elements" (both above, same epic) — sequenced after both so Address Control's rotation-composed
wrap/clip and indicator can directly reuse a proven pattern rather than prove it here first.
dynamic text elements" and "Add a live wrap/clip indicator for text elements" (both above, same
epic) — sequenced after both so Address Control's rotation-composed wrap/clip and indicator can
directly reuse a proven pattern rather than prove it here first. Note (2026-10-27): rotation
composition itself is already proven for standalone elements (see the indicator story's own
re-scoping note) — this dependency is really about reusing the indicator's design once it exists,
not about waiting on rotated-wrap correctness, which Address Control's own Sprint 8 rigid-group
rotation should compose with just as cleanly.

### Render an accurate, record-specific preview of the current template - Status: Done

@@ -549,6 +567,5 @@ scoped — re-confirm once their actual data model and wrap/clip-detection API s
same discipline "Investigate and address high-volume render throughput degradation" used for its
own assumption-qualified estimate.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
dynamic text elements," "Support rotated wrapped text and a live wrap/clip indicator for text
elements," and "Add an adjustable width and height with text wrapping to Address Control lines"
(all three above, same epic).
dynamic text elements," "Add a live wrap/clip indicator for text elements," and "Add an adjustable
width and height with text wrapping to Address Control lines" (all three above, same epic).

+ 36
- 0
backlog/sprints/sprint-10.md 查看文件

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

**Sprint:** 10 **Dates:** 2026-10-28 - 2026-11-01
**Sprint Goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator — the first new selection model this designer has ever had, plus a small, low-risk finish to already-shipped work.

## Committed Items

| Story | Size | Status | Tasks |
|---|---|---|---|
| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping) <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. |

+ 24
- 15
code/TEMPLATE_FORMAT.md 查看文件

@@ -132,23 +132,32 @@ editor, per this story's sizing note) rather than a silently-swallowed edge case
and unaffected by this internal normalization. An `angle` of `0` (including when the attribute
is absent) takes the exact same `DrawText` code path this renderer used before this story —
byte-for-byte unchanged output for every pre-existing template.
- `width`, `height` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an
adjustable width and height with text wrapping...": when **both** are present, the element's
content wraps within `width` and any wrapped content exceeding `height` is clipped (cut off),
rather than growing the box or shrinking the text. A one-sided value (only `width` or only
`height`) is accepted but has no effect — wrap-without-a-clip-boundary and clip-without-a-wrap-
width both have no well-defined meaning, so it's treated the same as neither being present.
Internally this calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or
- `width` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an adjustable
width and height with text wrapping...", refined post-Sprint-9 (2026-10-27) per direct user
feedback: `width` **alone** is enough to wrap — the desktop canvas's resize handle only ever
sets `width` (never `height`, never font size, which stays a separate, panel-only control).
Wrapped content grows to whatever height it needs, with no clip, unless `height` is *also*
present (see below). An element with no `width` (every template written before Sprint 9) takes
the exact same plain `DrawText`/`DrawRotatedText` code path this renderer always used —
byte-for-byte unchanged output.
- `height` — optional, positive, PDF points, default absent, **independent** of `width` and only
ever set via the properties panel (never the canvas resize handle). Present together with
`width`, it becomes an explicit clip ceiling: wrapped content exceeding `height` is clipped (cut
off) rather than growing the box or shrinking the text. Present *without* `width`, it has no
effect (there is no wrap boundary for it to clip against). Internally: `width` + `height` both
present calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or
`DrawRotatedTextBox` when `angle` is also non-zero — confirmed empirically to handle rotation
and wrapping together in one native call, not something this renderer has to compose from
separate steps) rather than a hand-rolled line-breaking algorithm; `top` is `y + height`, since
`(x, y)` is the box's bottom-left corner for a boxed element (unlike the plain baseline-`y`
meaning `y` has for an unboxed element). An element with no `width`/`height` (every template
written before this story) takes the exact same plain `DrawText`/`DrawRotatedText` code path
this renderer always used — byte-for-byte unchanged output. The desktop designer's canvas and
and wrapping together in one native call); `width` alone (no `height`) calls `DrawWrappedText`
(unrotated) or pre-wraps via `GetWrappedText` and draws via `DrawRotatedMultiLineText` (rotated —
no native "rotated wrapped text, no height" primitive exists), with the auto-height Debenu's own
`GetWrappedTextHeight` reports for that width/text/font. `(x, y)` is the box's bottom-left corner
for any boxed element (`top` is always `y` + the effective height, explicit or auto) — unlike
the plain baseline-`y` meaning `y` has for an unboxed element. The desktop designer's canvas and
preview panel approximate the same wrap/clip visually using GDI+'s own native rectangle-bounded
text drawing — a documented design-time approximation, not a pixel-for-point guarantee, since
GDI+ and Debenu are two independent text-layout engines with no shared line-breaking code path.
text drawing (using the same auto-height logic via `MeasureString` with a width constraint when
no explicit `height` is set) — a documented design-time approximation, not a pixel-for-point
guarantee, since GDI+ and Debenu are two independent text-layout engines with no shared
line-breaking code path.

## `<addressControl>`



+ 114
- 2
code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs 查看文件

@@ -57,6 +57,37 @@ public class DebenuPdfRendererWrapTests
}
}

[Fact]
public void AddPage_WidthOnly_NoHeight_Unrotated_Succeeds()
{
// Post-Sprint-9 user-requested change (2026-10-27): Width alone (no Height) means "wrap,
// auto-height, no clip" - uses DrawWrappedText, not DrawTextBox.
var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError);
Assert.True(created, createError);

using (renderer)
{
var draw = new TextDraw(20, 300, "Arial", 12, LongText, Width: 120);
Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError);
}
}

[Fact]
public void AddPage_WidthOnly_NoHeight_Rotated_Succeeds()
{
// Post-Sprint-9: no native "rotated wrapped text, no height" primitive exists, so this
// path pre-wraps via GetWrappedText and draws via DrawRotatedMultiLineText - confirms that
// composition succeeds end to end.
var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError);
Assert.True(created, createError);

using (renderer)
{
var draw = new TextDraw(150, 150, "Arial", 12, LongText, Angle: 30, Width: 120);
Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError);
}
}

[Fact]
public void AddPage_BoxTooShortToFitAllWrappedContent_StillSucceeds_ClippingRatherThanFailing()
{
@@ -153,7 +184,7 @@ public class DebenuPdfRendererWrapTests
/// <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.
}
}
}
}

+ 22
- 1
code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs 查看文件

@@ -252,8 +252,10 @@ public class TemplateXmlParserTests
}

[Fact]
public void Parse_OnlyWidthAttribute_HasBoxIsFalse()
public void Parse_OnlyWidthAttribute_HasBoxIsTrue_HasHeightClipIsFalse()
{
// Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no
// clip ceiling) — see TemplateElement.HasBox's remarks.
var path = WriteTemplate("""
<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);
}



+ 51
- 17
code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs 查看文件

@@ -175,27 +175,61 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
return false;
}

if (draw.Width is > 0 && draw.Height is > 0)
if (draw.Width is > 0)
{
// Sprint 9, "Add an adjustable width and height with text wrapping...": Debenu's
// own native DrawTextBox/DrawRotatedTextBox already wrap to Width and clip to
// Height in one call — confirmed empirically against the real DLL (a box too
// short to fit every wrapped line returns fewer lines than
// GetWrappedTextLineCount reports for the same width, rather than growing the box
// or throwing), so no hand-rolled line-breaking is needed here. (X, Y) is this
// draw's box bottom-left corner (matching every other box-shaped element in this
// codebase, e.g. Address Control), so Debenu's own `Top` parameter is Y + Height.
var top = draw.Y + draw.Height.Value;
var boxResult = draw.Angle == 0
? _pdf.DrawTextBox(draw.X, top, draw.Width.Value, draw.Height.Value, draw.Text, Options: 0)
: _pdf.DrawRotatedTextBox(
draw.X, top, draw.Width.Value, draw.Height.Value,
((draw.Angle % 360) + 360) % 360, draw.Text, Options: 0);
// Sprint 9, "Add an adjustable width and height with text wrapping...", extended
// post-Sprint-9 (2026-10-27) so Width alone (no Height) means "wrap, auto-height,
// no clip" — the canvas resize handle only ever sets Width; Height remains an
// independent, optional explicit clip ceiling set separately via the properties
// panel. (X, Y) is this draw's box bottom-left corner (matching every other
// box-shaped element in this codebase, e.g. Address Control); Debenu's own `Top`
// parameter is always Y + <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;
}


+ 9
- 5
code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs 查看文件

@@ -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


+ 49
- 93
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs 查看文件

@@ -31,27 +31,11 @@ public sealed class CanvasElementEditor
/// space points) when snapping is enabled but no explicit size has been set.</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


+ 9
- 4
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs 查看文件

@@ -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") —


+ 24
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs 查看文件

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

+ 81
- 87
code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs 查看文件

@@ -62,6 +62,48 @@ public class CanvasElementEditorTests
Assert.Equal($"{{{columnName}}}", element.DisplayText);
}

// Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone element.

[Fact]
public void RemoveSelected_RemovesElementFromDocumentAndClearsSelection()
{
var editor = CreateEditor(out var document);
var element = editor.AddStaticText(10, 20, "Hello");

var removed = editor.RemoveSelected();

Assert.True(removed);
Assert.DoesNotContain(element, document.Elements);
Assert.Null(editor.Selected);
}

[Fact]
public void RemoveSelected_NoSelection_ReturnsFalse_DocumentUnchanged()
{
var editor = CreateEditor(out var document);
editor.AddStaticText(10, 20, "Hello");
editor.Select(null);

var removed = editor.RemoveSelected();

Assert.False(removed);
Assert.Single(document.Elements);
}

[Fact]
public void RemoveSelected_DoesNotAffectOtherElements()
{
var editor = CreateEditor(out var document);
var keep = editor.AddStaticText(10, 20, "Keep");
var removeMe = editor.AddStaticText(30, 40, "Remove");
editor.Select(removeMe);

editor.RemoveSelected();

Assert.Contains(keep, document.Elements);
Assert.DoesNotContain(removeMe, document.Elements);
}

[Fact]
public void AddMultipleElements_AssignsIncreasingZOrder()
{
@@ -424,14 +466,13 @@ public class CanvasElementEditorTests
Assert.Equal(100, element.Y, precision: 6);
}

// Post-Sprint-8 user-requested feature: resize a standalone static/dynamic element by
// dragging a canvas handle. A standalone element has no independent Width/Height of its own
// — its box is purely derived from measuring its text at the current FontSize — so "resize"
// here means uniformly scaling FontSize, the only property that actually changes what
// renders.
// Post-Sprint-9 user-requested change (2026-10-27): the canvas resize handle controls only
// Width — font size is set exclusively via the properties panel's Font size field, never by
// dragging (the earlier Sprint 8 uniform-font-scale handle behavior has been removed
// entirely, not just made conditional).

[Fact]
public void ResizeHandlePosition_UnrotatedElement_IsAtTopRightCorner()
public void ResizeHandlePosition_UnrotatedElement_IsAtRightEdgeMidpoint()
{
var editor = CreateEditor(out _);
editor.AddStaticText(100, 100, "Hello"); // box [100,120]x[100,110]
@@ -439,8 +480,8 @@ public class CanvasElementEditorTests
var handle = editor.ResizeHandlePosition();

Assert.NotNull(handle);
Assert.Equal(120, handle!.Value.X, precision: 6);
Assert.Equal(110, handle.Value.Y, precision: 6);
Assert.Equal(120, handle!.Value.X, precision: 6); // right edge
Assert.Equal(105, handle.Value.Y, precision: 6); // vertically centered
}

[Fact]
@@ -492,20 +533,20 @@ public class CanvasElementEditorTests
{
var editor = CreateEditor(out _);

Assert.False(editor.HitTestResizeHandle(120, 110));
Assert.False(editor.HitTestResizeHandle(120, 105));
}

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

editor.BeginResizeDrag();
Assert.True(editor.IsResizingFontSize);
Assert.True(editor.IsResizingWidth);

editor.EndDrag();
Assert.False(editor.IsResizingFontSize);
Assert.False(editor.IsResizingWidth);
}

[Fact]
@@ -522,53 +563,44 @@ public class CanvasElementEditorTests
}

[Fact]
public void ResizeDragTo_DraggingTwiceTheOriginalDiagonalFromAnchor_DoublesFontSize()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // anchor (100,100), diagonal sqrt(500)
editor.BeginResizeDrag();

var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
editor.ResizeDragTo(100 + (2 * originalDiagonal), 100); // straight right, 2x the original distance

Assert.Equal(24, element.FontSize, precision: 3);
}

[Fact]
public void ResizeDragTo_DraggingHalfTheOriginalDiagonalFromAnchor_HalvesFontSize()
public void ResizeDragTo_SetsWidthFromHorizontalDistanceToAnchor()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
editor.BeginResizeDrag();

var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
editor.ResizeDragTo(100 + (0.5 * originalDiagonal), 100);
editor.ResizeDragTo(180, 999); // 80 right of the (100,100) anchor; Y is irrelevant

Assert.Equal(6, element.FontSize, precision: 3);
Assert.Equal(80, element.Width!.Value, precision: 3);
}

[Fact]
public void ResizeDragTo_DraggingVeryClose_ClampsToMinFontSize()
public void ResizeDragTo_NeverTouchesHeightOrFontSize()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
element.Height = 55; // pre-existing explicit clip ceiling
editor.BeginResizeDrag();

editor.ResizeDragTo(100.0001, 100); // tiny but nonzero distance from the anchor
editor.ResizeDragTo(180, 999);

Assert.Equal(CanvasElementEditor.MinFontSize, element.FontSize, precision: 3);
Assert.Equal(55, element.Height!.Value, precision: 3);
Assert.Equal(12, element.FontSize, precision: 3);
}

[Fact]
public void ResizeDragTo_DraggingVeryFar_ClampsToMaxFontSize()
public void ResizeDragTo_DraggingPastTheAnchor_LeavesWidthUnchanged()
{
// Only a positive resulting width is applied — dragging back past the anchor simply stops
// shrinking rather than going negative or to zero.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
element.Width = 20;
editor.BeginResizeDrag();

editor.ResizeDragTo(1_000_000, 100);
editor.ResizeDragTo(80, 999); // X (80) is left of the anchor (100)

Assert.Equal(CanvasElementEditor.MaxFontSize, element.FontSize, precision: 3);
Assert.Equal(20, element.Width!.Value, precision: 3);
}

[Fact]
@@ -579,75 +611,37 @@ public class CanvasElementEditorTests

editor.ResizeDragTo(1000, 1000);

Assert.Equal(12, element.FontSize, precision: 6);
Assert.Null(element.Width);
}

[Fact]
public void ResizeDragTo_DynamicElement_ScalesFontSize_SameAsStatic()
public void ResizeDragTo_DynamicElement_SetsWidth_SameAsStatic()
{
var editor = CreateEditor(out _);
var element = editor.AddDynamicPlaceholder(100, 100, "Full Name", fontSize: 12);
editor.BeginResizeDrag();

var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
editor.ResizeDragTo(100 + (2 * originalDiagonal), 100);
editor.ResizeDragTo(180, 999);

Assert.Equal(24, element.FontSize, precision: 3);
Assert.Equal(80, element.Width!.Value, precision: 3);
}

[Fact]
public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsFontSizeUnchanged()
public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsWidthUnchanged()
{
// Proves the rotate-point-backward-into-local-space math round-trips correctly: dragging
// to exactly where the handle currently sits (already accounting for rotation) must be a
// no-op scale change, not an accidental jump caused by comparing a rotated world point
// no-op width change, not an accidental jump caused by comparing a rotated world point
// against unrotated anchor coordinates.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // FixedSize: 20x10
element.RotationAngle = 90;
var handle = editor.ResizeHandlePosition()!.Value;
editor.BeginResizeDrag();

editor.ResizeDragTo(handle.X, handle.Y);

Assert.Equal(12, element.FontSize, precision: 3);
}

// Sprint 9, "Add an adjustable width and height with text wrapping...": once an element
// already has a box, the same corner handle resizes Width/Height directly instead of
// scaling FontSize.

[Fact]
public void ResizeDragTo_ElementHasBox_UpdatesWidthAndHeightDirectly_NotFontSize()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
element.Width = 20;
element.Height = 10;
editor.BeginResizeDrag();

editor.ResizeDragTo(180, 160); // 80 right, 60 up from the (100,100) anchor

Assert.Equal(80, element.Width!.Value, precision: 3);
Assert.Equal(60, element.Height!.Value, precision: 3);
Assert.Equal(12, element.FontSize, precision: 3); // unaffected in box mode
}

[Fact]
public void ResizeDragTo_ElementHasBox_DraggingPastTheAnchor_LeavesDimensionUnchanged()
{
// Only a positive resulting width/height is applied — dragging back past the anchor on
// one axis simply stops shrinking that axis rather than going negative or to zero.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
element.Width = 20;
element.Height = 10;
editor.BeginResizeDrag();

editor.ResizeDragTo(80, 160); // X (80) is now left of the anchor (100)

Assert.Equal(20, element.Width!.Value, precision: 3); // unchanged - would have gone negative
Assert.Equal(60, element.Height!.Value, precision: 3); // Y direction still applied normally
Assert.Equal(20, element.Width!.Value, precision: 3);
}

[Fact]
@@ -655,9 +649,9 @@ public class CanvasElementEditorTests
{
// CanvasElementEditor itself has no opinion on measurement - it just uses whatever
// _measureText(element) returns. Production wiring (TemplateCanvasControl.MeasureElement)
// is box-aware (returns Width/Height directly instead of measuring text) once this story
// lands; this test proves the handle position correctly follows a box-aware measurer,
// using a purpose-built fake rather than duplicating that production logic here.
// is box-aware once this story lands; this test proves the handle position correctly
// follows a box-aware measurer, using a purpose-built fake rather than duplicating that
// production logic here.
var document = TemplateLayoutDocument.CreateDefault();
var editor = new CanvasElementEditor(document, BoxAwareSize);
var element = editor.AddStaticText(100, 100, "Hi");
@@ -667,11 +661,11 @@ public class CanvasElementEditorTests
var handle = editor.ResizeHandlePosition();

Assert.NotNull(handle);
Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200
Assert.Equal(250, handle.Value.Y, precision: 3); // 100 + 150
Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200 (right edge)
Assert.Equal(175, handle.Value.Y, precision: 3); // 100 + 150/2 (vertical midpoint)

static (double Width, double Height) BoxAwareSize(TextElementLayout e) =>
e.HasBox ? (e.Width!.Value, e.Height!.Value) : (20.0, 10.0);
e.HasBox ? (e.Width!.Value, e.Height ?? 0) : (20.0, 10.0);
}

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


+ 22
- 3
code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs 查看文件

@@ -286,10 +286,10 @@ public class TemplateLayoutXmlSerializerTests : IDisposable
}

[Fact]
public void Load_OnlyOneOfWidthOrHeightSet_HasBoxIsFalse()
public void Load_OnlyWidthSet_HasBoxIsTrue_HasHeightClipIsFalse()
{
// A one-sided value doesn't count as having a box (see TextElementLayout.HasBox's
// remarks) — it's accepted (not a parse error), just inert.
// Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no
// clip ceiling) — see TextElementLayout.HasBox's remarks.
File.WriteAllText(
_tempPath,
"<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);
}



+ 6
- 2
code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs 查看文件

@@ -277,8 +277,11 @@ public class TextElementPropertiesEditorTests
[Theory]
[InlineData(0)]
[InlineData(-5)]
public void SetHeight_NonPositiveValue_ClearsTheBox(double nonPositive)
public void SetHeight_NonPositiveValue_ClearsTheHeightClipOnly_NotTheBox(double nonPositive)
{
// Post-Sprint-9 user-requested change: Height is independent of Width - clearing it drops
// the explicit clip ceiling (HasHeightClip) but the box itself (HasBox, driven by Width
// alone) stays intact.
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(0, 0, "a");
element.Width = 100;
@@ -288,7 +291,8 @@ public class TextElementPropertiesEditorTests
editor.SetHeight(nonPositive);

Assert.Null(element.Height);
Assert.False(element.HasBox);
Assert.False(element.HasHeightClip);
Assert.True(element.HasBox);
}

[Fact]


+ 61
- 0
code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs 查看文件

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

namespace EnvelopeRenderer.Desktop.Tests;

public class WrapClipDetectorTests
{
[Fact]
public void Detect_NaturalWidthWithinBox_WrappedHeightWithinBox_NeitherFlagIsSet()
{
var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);

Assert.False(isWrapping);
Assert.False(isClipping);
}

[Fact]
public void Detect_NaturalWidthExceedsBoxWidth_IsWrappingIsTrue()
{
var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);

Assert.True(isWrapping);
Assert.False(isClipping);
}

[Fact]
public void Detect_WrappedHeightExceedsBoxHeight_IsClippingIsTrue()
{
var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 80, boxWidth: 100, boxHeight: 50);

Assert.True(isWrapping);
Assert.True(isClipping);
}

[Fact]
public void Detect_NaturalWidthExactlyEqualsBoxWidth_IsNotWrapping()
{
// Exactly fitting is not "overflow" - only strictly exceeding counts.
var (isWrapping, _) = WrapClipDetector.Detect(naturalWidth: 100, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);

Assert.False(isWrapping);
}

[Fact]
public void Detect_WrappedHeightExactlyEqualsBoxHeight_IsNotClipping()
{
var (_, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 50, boxWidth: 100, boxHeight: 50);

Assert.False(isClipping);
}

[Fact]
public void Detect_ClippingWithoutWrapping_IsPossible()
{
// A single line taller than the box (very large font, short box) clips without ever
// needing to wrap onto a second line.
var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 40, wrappedHeight: 60, boxWidth: 100, boxHeight: 50);

Assert.False(isWrapping);
Assert.True(isClipping);
}
}

+ 124
- 9
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs 查看文件

@@ -47,6 +47,12 @@ public sealed class TemplateCanvasControl : Control
DoubleBuffered = true;
BackColor = SystemColors.ControlDark;
SetStyle(ControlStyles.ResizeRedraw, true);
// Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by
// default, so it never received key events at all — needed for the Delete key to remove
// the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on
// the reported gap this fixes).
SetStyle(ControlStyles.Selectable, true);
TabStop = true;
}

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

+ 14
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs 查看文件

@@ -65,6 +65,9 @@ public sealed class TemplateDesignerForm : Form
private readonly Button _addStaticTextButton = new() { Text = "Add Stati&c Text", AutoSize = true };
private readonly Button _addDynamicPlaceholderButton = new() { Text = "Add &Dynamic Placeholder", AutoSize = true, Enabled = false };
private readonly Button _addAddressControlButton = new() { Text = "Add &Address Control", AutoSize = true };
// Post-Sprint-9 user-reported gap: there was previously no way to delete a placed element or
// Address Control at all. Enabled only while something is selected (see RefreshSelectionLabel).
private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false };
private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." };

// CSV field mapping (Sprint 3, Batches 2-4).
@@ -1190,6 +1193,15 @@ public sealed class TemplateDesignerForm : Form
_dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3);
panel.Controls.Add(_dynamicFieldColumnComboBox);
panel.Controls.Add(_addAddressControlButton);
_deleteSelectedButton.Click += (_, _) =>
{
if (_canvas.RemoveSelectedElement())
{
RefreshPropertiesPanel();
RefreshSelectionLabel();
}
};
panel.Controls.Add(_deleteSelectedButton);
_selectionLabel.Margin = new Padding(18, 6, 3, 3);
panel.Controls.Add(_selectionLabel);

@@ -1208,6 +1220,7 @@ public sealed class TemplateDesignerForm : Form
if (selected is not null)
{
_selectionLabel.Text = $"Selected: {selected.DisplayText} at ({selected.X:0.#}, {selected.Y:0.#}) pt.";
_deleteSelectedButton.Enabled = true;
return;
}

@@ -1215,6 +1228,7 @@ public sealed class TemplateDesignerForm : Form
_selectionLabel.Text = selectedAddress is null
? "No element selected."
: $"Selected address control at ({selectedAddress.X:0.#}, {selectedAddress.Y:0.#}) pt.";
_deleteSelectedButton.Enabled = selectedAddress is not null;
}

private Control BuildCanvasSettingsPanel()


+ 14
- 11
state.md 查看文件

@@ -2,23 +2,23 @@

> The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff."

**Phase:** 1 - Backlog refinement (next sprint cycle)
**Leading agent:** `product-owner`
**Process file:** `process/01_backlog_refinement.md`
**Phase:** 3 - Sprint execution
**Leading agent:** `dev-team`
**Process file:** `process/03_sprint_execution.md`

**Sprint:** 9 executed, reviewed, and retrospected (2026-10-27). 13/13 points Done, sprint goal met in full.
**Sprint goal:** Give static and dynamic text elements a real, independently adjustable width and height, so that content too long to fit wraps onto additional lines and is clipped (not silently overrun) once it exceeds the box — using Debenu's native wrap/box-drawing primitives for the real PDF and GDI+'s native rectangle-wrap for a close design-time approximation on the canvas and preview panel, directly answering the human product owner's "make the boxes longer" request. **Met in full**, per Sprint Review.
**Current sprint backlog:** `backlog/sprints/sprint-9.md`
**Sprint:** 10 planned (2026-10-27), not yet executed.
**Sprint goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator.
**Current sprint backlog:** `backlog/sprints/sprint-10.md`

**Sprint 9 execution, review, and retrospective summary:** The committed story ("Add an adjustable width and height with text wrapping to static and dynamic text elements," 13 pts) is Done. Real vendor-DLL investigation (licensed probe against the actual Debenu library, done *before* writing implementation code) found native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points, confirmed via real rendered screenshots to wrap, clip (a too-short box returns fewer lines than exist, not an error), and compose rotation with wrapping natively in one call. `DebenuPdfRenderer.AddPage` now branches to these calls when a draw carries a box; canvas (`TemplateCanvasControl`) and preview (`TemplatePreviewBuilder`/`TemplatePreviewControl`) approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation, since the desktop process shares no code path with the CLI's Debenu engine). The resize-handle coexistence question was resolved more simply than planned: the existing font-size handle became context-sensitive (box mode vs. font-scale mode, chosen once at drag-start via `HasBox`) rather than needing a second handle. Properties panel gained "Box width"/"Box height" fields. Tests: 448/448 -> 476/476 (23 new). Live-verified at three layers: a standalone licensed vendor-DLL probe with screenshots; a true end-to-end run (hand-authored template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output PDF rasterized and visually inspected showing genuine 4-line wrap and genuine clip to "WILLIAM EDWARD"); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real content TextBox, real Box width/height panel fields, real mouse-driven resize-handle drag, real canvas screenshot). Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/backlog.md`'s Sprint 9 refinement/planning/Review outcome notes, `backlog/sprints/sprint-9-retrospective.md`.
**Recent history (Sprint 9, closed 2026-10-27):** Static/dynamic text elements shipped real adjustable width/height with native Debenu wrap/clip (13/13 points, sprint goal met in full); test suite 448/448 -> 476/476. Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/sprints/sprint-9-retrospective.md`. A **Proposed** (not yet applied) process-improvement log entry awaits human review: the project's own Debenu license key (`key.txt`, repo root) was overlooked by at least two prior sprints' searches — logged in `logs/process_improvement_log.md` (2026-10-27).

**Real de-risking finding with a downstream effect:** Debenu composes rotation and wrapping natively in one `DrawRotatedTextBox` call, meaning "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, previously estimated 8 pts) likely already has its rotation half satisfied by this sprint's shipped code. Not re-sized unilaterally — flagged for `product-owner`/`dev-team` to re-confirm at the next backlog refinement before it is ever committed to a sprint.
**Sprint 10 backlog refinement (2026-10-27):** Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found its rotation-composition scope was already delivered as a byproduct of Sprint 9's implementation (confirmed: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`, and both canvas/preview box-mode draws already run inside the existing rotation transform). Renamed to "Add a live wrap/clip indicator for text elements" and re-sized down to **3 points** — only the passive visual indicator itself remains. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`.

**Process-improvement finding, Proposed (not yet applied — awaiting human product owner review):** the project's own Debenu license key (`key.txt` at the repo root, one level above `code/`) was found this sprint after at least two prior points (this project's own `DebenuPdfRendererIntegrationTests`/`DebenuPdfRendererRotationTests` design, and the Sprint 7 Sprint Review) concluded or implied no key was available, having only searched under `code/` rather than the actual repo root the resolver's own 10-level upward walk reaches. Logged in `logs/process_improvement_log.md` (2026-10-27) as a documentation-nudge proposal (no code change needed) for `CLI_CONTRACT.md`'s "Debenu license key" section or `AGENTS.md` — status **Proposed**, needs a human decision on exact wording/placement before being applied.
**Sprint 10 planning (2026-10-27):** Committed 13 points: the now-3-point indicator story plus epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) + align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair — finally pulled after being Ready and deferred twice (Sprint 8, Sprint 9) for fresher, more urgent requests; neither reason applies a third time. Pairing the small indicator story with multi-select's genuinely novel selection-model work does not trip the established "two large/novel stories" caution, since only one side of the pairing carries that risk profile. Full reasoning: `backlog/backlog.md`'s Sprint 10 planning outcome note.

**Retrospective outcome (`scrum-master`, `backlog/sprints/sprint-9-retrospective.md`):** Ninth sprint, clean delivery, no drops (one item — built-form-smoke discipline — showed genuine follow-through in substance despite its literal multi-batch trigger not arising this single-story sprint; two items correctly carry forward unchanged since untriggered for a third sprint running: epic 6's built-form-smoke rule, and the Sprint 6 Address Control interaction-code extraction evaluation). One near-miss named honestly: the live built-form GUI smoke was initially dropped from the story's own task list mid-execution (only the CLI-side check was recorded), caught and completed within the same session before Sprint Review — flagged as a new action item so "the CLI check feels sufficient" doesn't recur as reasoning for skipping a GUI-touching story's own DoD-required smoke.
**Sprint 10 Batch 1 complete (2026-10-27), plus two ad-hoc corrections mid-batch:** "Add a live wrap/clip indicator for text elements" (3/13 points) shipped — a new framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel, drawn inside the existing per-element rotation transform. Mid-batch, the human product owner raised two live-build issues, addressed together before continuing: (1) **no way to delete a placed element or Address Control** — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) **the resize handle should control only box width, font size set elsewhere** — removed the Sprint 8 uniform-font-scale handle behavior entirely; `TextElementLayout.HasBox`/`TemplateElement.HasBox` now mean "Width is set" alone, with `Height` independent and optional (new `HasHeightClip` flag), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path (`DrawWrappedText`/`GetWrappedText`+`DrawRotatedMultiLineText`). Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) showed correct auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator correctly appearing. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness (a WinForms quirk absent a real shown window) — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`.

**Next action:** Natural post-retrospective pause per `AGENTS.md`. When the user says to continue, start Sprint 10 backlog refinement as `product-owner`, folding in the Sprint 9 retrospective's action items above (especially: re-confirm/re-size "Support rotated wrapped text..." before ever committing it). Ready-and-waiting work for Sprint 10: "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, ~8 pts pending re-sizing) and "...Address Control lines" (epic 4, 8 pts, depends on the former); epic 6's multi-select/align pair (10 pts) and "Undo and redo layout changes" (13 pts) remain Ready and unchanged. Still open, non-blocking: "Warn on text overflow before render" (epic 4, provisional 5 pts, blocked on both wrap/clip stories above landing first); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as a stale onboarding-era placeholder needing re-verification before ever being pulled; the Proposed process-improvement log entry above awaiting a human decision.
**Next action:** Continue Sprint 10 as `dev-team`: Batch 2 "Select multiple elements at once on the canvas" (5 pts, epic 6, no dependency but sequenced next as the epic-6 pair's foundation), then Batch 3 "Align and distribute multiple elements" (5 pts, depends on Batch 2), per `backlog/sprints/sprint-10.md`. Key design note for Batch 2, not to be rediscovered mid-sprint: add multi-select as an *additive* layer alongside the existing single-selection state (`CanvasElementEditor.Selected` for standalone elements, `TemplateCanvasControl._selectedAddressControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected. This is a natural mid-sprint checkpoint (a large, first-of-its-kind feature remains, plus the human product owner just gave live-build feedback twice this session) — worth confirming with the user before continuing into the multi-select implementation. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision.

## Phase reference

@@ -89,3 +89,6 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed the sprint's single committed story, "Add an adjustable width and height with text wrapping to static and dynamic text elements" (13/13 points). A licensed real-DLL probe against the actual Debenu library, run before any implementation code, found and confirmed (via real rendered screenshots) native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points that wrap, clip, and compose rotation with wrapping natively. `TextElementLayout`/`TemplateElement` gained nullable `Width`/`Height` (optional `width`/`height` XML attributes, both sides independently implemented); `DebenuPdfRenderer.AddPage` branches to the native calls when a draw has a box; canvas and preview panel approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation). The resize-handle coexistence question was resolved by making the existing font-size handle context-sensitive (box mode vs. font-scale mode, chosen once at drag-start) rather than adding a second handle. Properties panel gained Box width/height fields. Tests: 448/448 -> 476/476 (23 new, including a licensed pixel-based clipping test). Live-verified at three layers: the vendor-DLL probe itself; a true end-to-end run (real template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output rasterized and visually confirmed to wrap/clip correctly); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real panel fields, real mouse-driven resize drag, real canvas screenshot) — the last of these was initially skipped mid-execution and caught/completed before Sprint Review. Found, not assumed: the project's own Debenu license key (`key.txt`, repo root) had been overlooked by at least two prior sprints' searches (which only checked under `code/`). Full detail: `backlog/sprints/sprint-9.md`. |
| 2026-10-27 | 4 - Sprint review | `product-owner` verified the Sprint 9 story against acceptance criteria (verdict: sprint goal met in full). Independently re-ran the full test suite (476/476 confirmed directly via `dotnet test`) and read the actual `DebenuPdfRenderer.AddPage` box branch, `TemplateLayoutXmlSerializer`/`TemplateXmlParser` persistence, and new test files directly, confirming the claims rather than trusting dev-team's report alone. Agreed the dependent "Support rotated wrapped text..." story (previously 8 pts) needs re-confirmation, not silent reuse, given this sprint's own de-risking finding. Updated `backlog/backlog.md`'s epic table and added a "Sprint 9 Review outcome" section; added a Development verification note to the story in `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-9-retrospective.md`). Ninth sprint, clean delivery, no drops (one item showed genuine follow-through in substance despite its literal trigger not arising; two correctly carry forward unchanged, one now a third sprint running). Named one honest near-miss: the built-form GUI smoke was initially dropped from the story's own task list mid-execution, caught and completed before review — logged as a new action item. Logged a **Proposed** (not yet applied) process-improvement entry in `logs/process_improvement_log.md`: the project's own Debenu license key was overlooked by at least two prior sprints' searches, a recurring (2+ occurrence) verification-depth gap meeting `AGENTS.md`'s kit-level bar — recommended fix is a documentation nudge in `CLI_CONTRACT.md`/`AGENTS.md`, awaiting a human product owner decision on wording/placement. All anti-patterns checked; none found. |
| 2026-10-27 | 1 - Backlog refinement | `product-owner` ran Sprint 10 backlog refinement after the user confirmed the post-Sprint-9-retrospective pause point with "continue." Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found rotation composition was already delivered as a byproduct of Sprint 9's implementation (`DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`; canvas/preview box-mode draws already run inside the existing rotation transform), proven with a real rendered screenshot of a 20°-rotated, wrapped, boxed element. Renamed to "Add a live wrap/clip indicator for text elements," re-sized down to 3 points. Also found and documented that automated pixel-row-count clip verification is unreliable under rotation, simplifying a new regression test accordingly. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 2 - Sprint planning | `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has nine data points (20, 19, 18, 18, 15, 18, 18, 13, 13). Committed 13 points: the 3-point indicator story plus epic 6's multi-select/align pair (10 pts), finally pulled after being deferred twice (Sprint 8, Sprint 9) for fresher requests that no longer apply. Reasoned explicitly that pairing a small, additive story with one large/novel story (multi-select's new selection model) does not trip the established "two large/novel stories" caution, since only one side carries that risk profile. Recorded in `backlog/sprints/sprint-10.md`; full reasoning in `backlog/backlog.md`'s Sprint 10 planning outcome note. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |

正在加载...
取消
保存

Powered by TurnKey Linux.