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, 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
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
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.
<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.