Implementation: src/EnvelopeRenderer.Cli/Render. Sample:
sample-data/sample-envelope-template.xml.
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="120" y="240" font="Arial" size="12">Static label:</text>
<text x="120" y="225" font="Arial [Bold]" size="12" column="Full Name" />
<text x="120" y="210" font="Arial" size="12" column="Address 2" collapsible="true" />
<text x="200" y="600" font="Arial" size="20" angle="45">Rotated label</text>
<addressControl x="120" y="180" width="180" angle="15">
<line font="Arial" size="12" column="Full Name" />
<line font="Arial" size="12" column="Address 1" />
<line font="Arial" size="12" column="Address 2" />
<line font="Arial" size="12">
<run column="City" />
<run text=", " />
<run column="State" />
<run text=" " />
<run column="ZIP" />
</line>
</addressControl>
</envelopeTemplate>
<envelopeTemplate>pageWidth, pageHeight — required, PDF points. Not a named paper size: Debenu Quick PDF
10.13's only named-size function (SetPageSize) covers ISO/ANSI paper sizes and DL, but no US
envelope sizes, so this template format goes straight to SetPageDimensions(width, height)
instead and lets the operator supply exact points for any envelope. A #10 envelope
(4.125” x 9.5”) is pageWidth="297" pageHeight="684" (1” = 72pt).<text>One page is rendered per CSV record, in the order the CSV rows appear, each carrying every
<text> and <addressControl> element in the template.
x, y — required, PDF points, measured from the page's bottom-left corner (Debenu's
default coordinate origin — this template format doesn't call SetOrigin, so that default
holds).font — required. A TrueType font name as it appears in the Windows Fonts folder (e.g.
"Arial"), optionally with a style suffix: "Arial [Bold]", "Arial [Italic]",
"Arial [BoldItalic]". The font must be installed on the machine running the CLI; it is
always embedded in the output PDF so the reader doesn't need it installed too.size — required, positive, points.<text ...>Static label:</text>column="<CSV header>" — dynamic, pulled from that column per record. Column matching is
case-insensitive.<run> child elements, each either text="<literal>" (printed as-is) or
column="<CSV header>" (resolved per record, same case-insensitive matching as the
single-column form above), concatenated in document order at render time into the one
string the page draws — e.g.:
<text x="120" y="240" font="Arial" size="12">
<run text="Attn: " />
<run column="Full Name" />
<run text=" " />
<run column="Last Name" />
</text>
A <text> element with any <run> children must have no column attribute and no inline
text of its own (mixing the two forms is a template error, same style as the legacy
both-column-and-inline-text error). This is a genuinely new XML shape — it never appears in
any template saved before Sprint 5 — so it cannot be confused with, and never changes the
parsing of, the legacy single-run forms above; every pre-Sprint-5 template continues to parse
and render byte-for-byte identically. Formatting/transforming a run's resolved value (date
formatting, casing, truncation) remains out of scope — verbatim substitution only, matching
the single-column form's existing behavior exactly.
A column (on either a <text> element or a <run> child) that isn't a real CSV header fails
the whole run before any page is rendered, not partway through a large batch — evaluated per
token when a <text> element has multiple runs, so one bad field reference fails the run even
when it's mixed alongside other, valid runs on the same line.
Desktop designer editing convention (not part of the persisted format above): the Template
Designer's properties panel lets an operator compose mixed content by typing plain text with
{Column Name} bracket tokens (e.g. Attn: {Full Name}), parsed into the run sequence above when
the field loses focus. This is a editing-time convention only — the persisted XML always uses
the explicit <run> shape above, never inline {...} text. Known limitation: literal text that
itself contains a matched {...} pair not intended as a field token (e.g. typing note: {see below} as literal text) is indistinguishable from a real field token by this convention and will
be parsed as a field run bound to a column literally named “see below” — which then fails the
per-token pre-flight check above if no such column exists. This is an accepted trade-off of the
bracket convention (chosen as the faster-to-deliver option over a full protected-token-chip rich
editor, per this story's sizing note) rather than a silently-swallowed edge case.
collapsible — optional, true or false (default false). Sprint 4, “Collapse blank
optional address lines consistently”: when true and this element's resolved text is blank
for a given CSV record, the element is skipped entirely for that record's page, and every
other <text> element that shares its x position (rounded to 2 decimal places) — a
“vertical stack,” the closest thing this format has to an address block — shifts upward to
close the gap that line would have left. A non-collapsible blank field still renders nothing
visible at its fixed position, exactly as before this story (no change for templates that don't
opt in). See src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs
for the exact shift math (row height = the gap between a line and the one below it, using
original, not already-shifted, positions) and its design-decision rationale (X-position grouping
in lieu of an explicit block concept). The desktop designer's canvas previews the identical
math (EnvelopeRenderer.Desktop.Core.Design.AddressLineCollapser) against a loaded CSV's first
sample row, so what an operator sees while designing matches what the CLI renders.
column that doesn't exist
in the CSV's header row is always a hard failure for the whole run (see above) — it never
silently collapses, regardless of collapsible. Only a column that does exist, but whose
value is blank for a specific record, is eligible to collapse. The desktop designer surfaces
this distinction visually: a dynamic field bound to a column that isn't among the currently
loaded CSV's headers gets a dashed orange/red highlight (a real mapping problem), while a
collapsible field bound to a real column that's simply blank in the loaded sample data gets
no warning at all — it just doesn't render, exactly as intended.angle — optional, degrees, positive or negative, default 0. Sprint 4, “Set a rotation angle
for text and dynamic field elements”: rotates text by this many degrees. Positive is
counterclockwise, confirmed empirically against the real Debenu Quick PDF Library 10.13 DLL
(not assumed) — see src/EnvelopeRenderer.Cli/Render/RotatedTextAnchorCalculator.cs‘s
class remarks for the probe methodology and result.
Static text rotates around the center of its own bounding box, preserving the original Sprint 4
behavior. Internally this calls Debenu's DrawRotatedText(x, y, angle, text), which rotates
around the given (x, y) anchor, not a center — RotatedTextAnchorCalculator solves for the
different anchor point that keeps the unrotated bounding-box center fixed, using
GetTextWidth/GetTextAscent/GetTextDescent to measure that box.
Dynamic and mixed-content text (any <text> with a column attribute or at least one
field-run child) rotates around the fixed authored (x, y) anchor instead of a measured
bounding-box center. This Sprint 6 rule prevents record-to-record drift: resolved text width
can vary per CSV row, but the pivot cannot. The desktop canvas uses the same rule so the preview
and the rendered PDF agree on placement for variable-width rotated content. One more
empirically-confirmed vendor quirk: DrawRotatedText rejects a negative Angle outright
(despite rotation being mathematically periodic) — the renderer normalizes to Debenu's expected
[0, 360) range before that specific call; a negative angle in a template is fully supported
and unaffected by this internal normalization. An angle of 0 (including when the attribute
is absent) takes the exact same DrawText code path this renderer used before this story —
byte-for-byte unchanged output for every pre-existing template.
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); 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 (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>Sprint 6 adds a grouped Address Control: a movable address block with one anchor point and an
ordered list of child lines. The desktop designer saves it as an <addressControl> container,
and the CLI renders it directly; the desktop app is not required at render time.
x, y — required, PDF points. This is the control's single anchor: the baseline position of
the first line.width — required, positive, PDF points. The designer uses this as the group's resize box.
Current render output does not wrap or clip text to this width; each line still draws as one
resolved text string.lineSpacing — optional, positive multiplier, default 1.25. Line N+1's baseline is computed
from the previous line's font size: previousY - previousSize * lineSpacing.<line> elements are required; an address control must contain at least one line.<line> has required font and positive size attributes, and supports the same content
forms as <text>: inline literal text, column="CSV header", or explicit <run> children for
mixed static/field content.collapsible on a <line> is optional and defaults to true, unlike standalone <text>,
whose default remains false. Set collapsible="false" on a line that must keep its row even
when it resolves blank.angle — optional, degrees, positive or negative, default 0. Sprint 8, “Rotate the whole
Address Control as a single unit”: rotates the entire control — its box, every line's text,
and any per-line highlight/selection overlay in the desktop designer — together as one rigid
unit. Same positive is counterclockwise convention as standalone <text>'s own angle.
Unlike a standalone element, an Address Control's box geometry (width, and height derived
purely from each line's authored size/the control's lineSpacing) never depends on any
record's resolved text, so the whole control always rotates around its own fixed, authored box
center — no per-record pivot drift is possible here, and no fixed-anchor/bounding-box-center
branch choice like standalone text's is needed. Internally, each visible line's own
(collapse-adjusted) anchor is rotated as a rigid group around that fixed box center before being
handed to the same DrawRotatedText path standalone rotated text already uses — angle="0"
(including when the attribute is absent) takes the exact same unrotated code path as before this
story, so every template written before Sprint 8 renders byte-for-byte unchanged.At render time, the control expands into one draw per visible line, in child-line order. Blank
collapsible lines are omitted and following lines in the same control shift upward using the same
AddressLineCollapser rule described for standalone <text> elements; because every child line
shares the control's single x, the existing stack rule and the control boundary agree. Static,
field, and mixed-content lines all resolve per CSV record exactly like standalone text elements.
The rotation pivot is always computed from the control's authored, unrotated x/y/width/
height, never from any record's collapse-shifted line positions, so two records differing only
in whether a blank optional line collapses still rotate around the identical pivot.
CLI_CONTRACT.md. Without one, every render fails at the save
step (SaveToFile/SaveToString both return error code 999) even though every earlier step
(page creation, font embedding, text drawing) succeeds — verified directly against the vendor
DLL, not assumed.width/height (Sprint 9 wrap/clip) is standalone <text> only for now — <addressControl>
has no equivalent yet (its own width remains a resize-box value only, and it has no height
attribute at all). A dependent follow-on story extends the same wrap/clip engine to Address
Control lines; until then, a long Address Control line still runs unbounded exactly as before
this story.Powered by TurnKey Linux.