The CLI hardcoded black and never parsed the template's color attribute, so colors chosen in the designer were saved but ignored. Parse an optional color="#RRGGBB" on <text> and address-control <line> (invalid values fail before any page renders), carry it through TemplateElement/TextDraw, and apply it per draw with SetTextColor (SetFillColor only affects shapes). Absent color stays black, so existing templates render unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>master
| @@ -109,6 +109,9 @@ editor, per this story's sizing note) rather than a silently-swallowed edge case | |||
| 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. | |||
| - `color` — optional, `#RRGGBB` hex, default black (`#000000`). Applied as the PDF text color via | |||
| Debenu's `SetTextColor` for this element; also valid on an address-control `<line>`. An invalid value | |||
| fails the run before any page renders, like any other malformed attribute. | |||
| - `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 | |||
| @@ -0,0 +1,49 @@ | |||
| using EnvelopeRenderer.Cli.Render; | |||
| namespace EnvelopeRenderer.Cli.Tests; | |||
| public class TemplateXmlParserColorTests | |||
| { | |||
| private static string WriteTemplate(string xml) | |||
| { | |||
| var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml"); | |||
| File.WriteAllText(path, xml); | |||
| return path; | |||
| } | |||
| [Fact] | |||
| public void Parse_TextAndAddressLineColor_AreReadAndCarriedToDraws() | |||
| { | |||
| var path = WriteTemplate(""" | |||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||
| <text x="10" y="200" font="Arial" size="12" color="#FF8000">Orange</text> | |||
| <text x="10" y="180" font="Arial" size="12">Default</text> | |||
| <addressControl x="10" y="150" width="200"> | |||
| <line font="Arial" size="12" color="#0000FF" column="Full Name" /> | |||
| </addressControl> | |||
| </envelopeTemplate> | |||
| """); | |||
| var result = TemplateXmlParser.Parse(path); | |||
| Assert.True(result.Succeeded); | |||
| Assert.Equal(new PdfColor(255, 128, 0), result.Document!.Elements[0].Color); | |||
| Assert.Equal(PdfColor.Black, result.Document.Elements[1].Color); | |||
| Assert.Equal(new PdfColor(0, 0, 255), result.Document.AddressControls[0].Lines[0].Color); | |||
| } | |||
| [Fact] | |||
| public void Parse_InvalidColor_Fails() | |||
| { | |||
| var path = WriteTemplate(""" | |||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||
| <text x="10" y="200" font="Arial" size="12" color="red">Bad</text> | |||
| </envelopeTemplate> | |||
| """); | |||
| var result = TemplateXmlParser.Parse(path); | |||
| Assert.False(result.Succeeded); | |||
| Assert.Contains(result.Errors, e => e.Contains("'color'")); | |||
| } | |||
| } | |||
| @@ -144,12 +144,6 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||
| return false; | |||
| } | |||
| if (_pdf.SetFillColor(0, 0, 0) == 0) | |||
| { | |||
| error = $"Failed to set text fill color (error code {_pdf.LastErrorCode()})."; | |||
| return false; | |||
| } | |||
| foreach (var draw in draws) | |||
| { | |||
| if (string.IsNullOrEmpty(draw.Text)) | |||
| @@ -162,6 +156,12 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||
| return false; | |||
| } | |||
| if (_pdf.SetTextColor(draw.Color.R / 255.0, draw.Color.G / 255.0, draw.Color.B / 255.0) == 0) | |||
| { | |||
| error = $"Failed to set text fill color (error code {_pdf.LastErrorCode()})."; | |||
| return false; | |||
| } | |||
| if (_pdf.SelectFont(fontHandle) == 0) | |||
| { | |||
| error = $"Failed to select font '{draw.FontName}' (error code {_pdf.LastErrorCode()})."; | |||
| @@ -0,0 +1,37 @@ | |||
| using System.Globalization; | |||
| namespace EnvelopeRenderer.Cli.Render; | |||
| /// <summary>Text color for a draw, parsed from a template's optional <c>color="#RRGGBB"</c> | |||
| /// attribute (the same shape the desktop designer writes). <c>default</c> is black, so every | |||
| /// template written without a color renders exactly as before. Kept independent of the desktop's | |||
| /// own <c>RgbColor</c> per this project's CLI/desktop process split.</summary> | |||
| public readonly record struct PdfColor(byte R, byte G, byte B) | |||
| { | |||
| public static PdfColor Black => default; | |||
| public static bool TryParseHex(string? hex, out PdfColor color) | |||
| { | |||
| color = default; | |||
| if (hex is null) | |||
| { | |||
| return false; | |||
| } | |||
| var text = hex.Trim(); | |||
| if (text.Length != 7 || text[0] != '#') | |||
| { | |||
| return false; | |||
| } | |||
| if (!byte.TryParse(text.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) | |||
| || !byte.TryParse(text.AsSpan(3, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) | |||
| || !byte.TryParse(text.AsSpan(5, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) | |||
| { | |||
| return false; | |||
| } | |||
| color = new PdfColor(r, g, b); | |||
| return true; | |||
| } | |||
| } | |||
| @@ -110,7 +110,8 @@ public static class RenderEngine | |||
| element.Angle, | |||
| UsesFixedRotationPivot: element.IsDynamic, | |||
| Width: element.Width, | |||
| Height: element.Height), | |||
| Height: element.Height, | |||
| Color: element.Color), | |||
| })); | |||
| } | |||
| @@ -178,7 +179,8 @@ public static class RenderEngine | |||
| line.Size, | |||
| resolvedText[i], | |||
| Angle: angle, | |||
| UsesFixedRotationPivot: angle != 0 || line.IsDynamic); | |||
| UsesFixedRotationPivot: angle != 0 || line.IsDynamic, | |||
| Color: line.Color); | |||
| } | |||
| } | |||
| @@ -7,7 +7,8 @@ public sealed record TemplateAddressControlLine( | |||
| string FontName, | |||
| double Size, | |||
| IReadOnlyList<TemplateTextRun> Runs, | |||
| bool Collapsible = true) | |||
| bool Collapsible = true, | |||
| PdfColor Color = default) | |||
| { | |||
| public bool IsDynamic => Runs.Any(r => r.IsField); | |||
| } | |||
| @@ -38,7 +38,8 @@ public sealed record TemplateElement( | |||
| double Angle = 0, | |||
| int RenderOrder = 0, | |||
| double? Width = null, | |||
| double? Height = null) | |||
| double? Height = null, | |||
| PdfColor Color = default) | |||
| { | |||
| /// <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 | |||
| @@ -136,6 +136,7 @@ public static class TemplateXmlParser | |||
| } | |||
| var angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0); | |||
| var color = ParseOptionalColor(node, label, errors); | |||
| // Sprint 9, "Add an adjustable width and height with text wrapping...": absent by | |||
| // default (no box, no wrap) so every template written before this story parses and | |||
| @@ -159,7 +160,8 @@ public static class TemplateXmlParser | |||
| angle.Value, | |||
| renderOrder, | |||
| width, | |||
| height)); | |||
| height, | |||
| color)); | |||
| renderOrder++; | |||
| } | |||
| @@ -217,6 +219,23 @@ public static class TemplateXmlParser | |||
| x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value); | |||
| } | |||
| /// <summary>Optional <c>color="#RRGGBB"</c>; absent means black (the pre-color behavior).</summary> | |||
| private static PdfColor ParseOptionalColor(XElement node, string label, List<string> errors) | |||
| { | |||
| var raw = (string?)node.Attribute("color"); | |||
| if (string.IsNullOrWhiteSpace(raw)) | |||
| { | |||
| return PdfColor.Black; | |||
| } | |||
| if (!PdfColor.TryParseHex(raw, out var color)) | |||
| { | |||
| errors.Add($"{label} has an invalid 'color' value: '{raw}' (expected #RRGGBB)."); | |||
| } | |||
| return color; | |||
| } | |||
| private static TemplateAddressControlLine? ParseAddressControlLine( | |||
| XElement node, string label, List<string> errors) | |||
| { | |||
| @@ -234,13 +253,14 @@ public static class TemplateXmlParser | |||
| errors.Add($"{label} has an invalid 'collapsible' value: '{collapsibleRaw}' (expected 'true' or 'false')."); | |||
| } | |||
| var color = ParseOptionalColor(node, label, errors); | |||
| var runs = ParseContentRuns(node, label, errors); | |||
| if (size is null || string.IsNullOrWhiteSpace(font) || runs is null) | |||
| { | |||
| return null; | |||
| } | |||
| return new TemplateAddressControlLine(font, size.Value, runs, collapsible); | |||
| return new TemplateAddressControlLine(font, size.Value, runs, collapsible, color); | |||
| } | |||
| /// <summary>Sprint 5: parses a `<text>` element's `<run>` children into an ordered | |||
| @@ -24,4 +24,5 @@ public sealed record TextDraw( | |||
| double Angle = 0, | |||
| bool UsesFixedRotationPivot = false, | |||
| double? Width = null, | |||
| double? Height = null); | |||
| double? Height = null, | |||
| PdfColor Color = default); | |||
| @@ -1,164 +0,0 @@ | |||
| using System.Drawing.Drawing2D; | |||
| using EnvelopeRenderer.Desktop.Core.Design; | |||
| namespace EnvelopeRenderer.Desktop.Views; | |||
| /// <summary> | |||
| /// Sprint 7, "Render an accurate, record-specific preview of the current template": a dedicated, | |||
| /// read-only preview surface — separate from the editable design canvas | |||
| /// (<see cref="TemplateCanvasControl"/>, which keeps showing bracket tokens for editing) — that | |||
| /// draws the current template using one selected CSV record's real, resolved values. All | |||
| /// resolution/collapse/pivot logic lives in <see cref="TemplatePreviewBuilder"/> (framework-free, | |||
| /// unit tested); this class only does the GDI+ drawing, since that part genuinely cannot be | |||
| /// extracted from WinForms. | |||
| /// </summary> | |||
| public sealed class TemplatePreviewControl : Control | |||
| { | |||
| private readonly TemplateLayoutDocument _document; | |||
| private IReadOnlyList<string> _csvHeaders = Array.Empty<string>(); | |||
| private IReadOnlyDictionary<string, string>? _record; | |||
| public TemplatePreviewControl(TemplateLayoutDocument document) | |||
| { | |||
| _document = document; | |||
| DoubleBuffered = true; | |||
| BackColor = SystemColors.ControlDark; | |||
| SetStyle(ControlStyles.ResizeRedraw, true); | |||
| } | |||
| /// <summary>Supplies the CSV headers and the currently selected/navigated-to record this | |||
| /// panel should preview. Pass an empty header list and a <c>null</c> record to show the | |||
| /// "nothing to preview yet" message (e.g. before any CSV is loaded).</summary> | |||
| public void SetContext(IReadOnlyList<string> csvHeaders, IReadOnlyDictionary<string, string>? record) | |||
| { | |||
| _csvHeaders = csvHeaders; | |||
| _record = record; | |||
| Invalidate(); | |||
| } | |||
| private CanvasViewTransform CurrentTransform() => | |||
| CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height); | |||
| protected override void OnPaint(PaintEventArgs e) | |||
| { | |||
| base.OnPaint(e); | |||
| var g = e.Graphics; | |||
| g.SmoothingMode = SmoothingMode.AntiAlias; | |||
| g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; | |||
| var transform = CurrentTransform(); | |||
| var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints); | |||
| var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0); | |||
| var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom); | |||
| g.FillRectangle(Brushes.White, pageRect); | |||
| g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height); | |||
| // Re-resolves against the live, shared TemplateLayoutDocument on every repaint (rather | |||
| // than caching a previously-built draw list), which is what makes "auto-refresh after a | |||
| // layout edit, a field remapping, or a different record being selected" free — the caller | |||
| // only ever needs to call Invalidate() (see TemplateDesignerForm's ElementsChanged | |||
| // subscription), never to explicitly rebuild anything. | |||
| var result = TemplatePreviewBuilder.Build(_document, _csvHeaders, _record); | |||
| if (!result.Success) | |||
| { | |||
| DrawMessage(g, pageRect, result.Message ?? "Preview unavailable."); | |||
| return; | |||
| } | |||
| foreach (var draw in result.Draws) | |||
| { | |||
| DrawItem(g, transform, draw); | |||
| } | |||
| } | |||
| private static void DrawItem(Graphics g, CanvasViewTransform transform, PreviewTextDraw draw) | |||
| { | |||
| if (draw.Text.Length == 0) | |||
| { | |||
| return; | |||
| } | |||
| using var font = ResolveFont(draw.FontFamily, (float)draw.FontSize); | |||
| // Sprint 9, "Add an adjustable width and height with text wrapping...": a boxed draw's | |||
| // size is the box itself (authored geometry), matching TemplateCanvasControl.MeasureElement's | |||
| // same box-aware rule, so this preview and the editing canvas agree on the effective size. | |||
| var hasBox = draw.Width is > 0 && draw.Height is > 0; | |||
| var size = hasBox ? (Width: draw.Width!.Value, Height: draw.Height!.Value) : MeasureText(draw.Text, font); | |||
| var (drawX, drawY) = transform.ToPixels(draw.X, draw.Y + size.Height); | |||
| GraphicsState? savedState = null; | |||
| if (draw.Angle != 0) | |||
| { | |||
| var (pivotX, pivotY) = RotationPivotCalculator.Compute(draw.IsDynamic, draw.X, draw.Y, size.Width, size.Height); | |||
| var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY); | |||
| savedState = g.Save(); | |||
| g.TranslateTransform((float)pivotXPx, (float)pivotYPx); | |||
| // See TemplateCanvasControl.DrawElement's remarks: GDI+'s RotateTransform is | |||
| // visually clockwise-positive in this Y-down pixel space, the opposite of the | |||
| // counterclockwise-positive convention this project stores/renders with, hence the | |||
| // negation — keeping the preview's rotation direction consistent with both the | |||
| // design canvas and the final PDF. | |||
| g.RotateTransform((float)-draw.Angle); | |||
| g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx); | |||
| } | |||
| try | |||
| { | |||
| using var brush = new SolidBrush(Color.FromArgb(draw.Color.R, draw.Color.G, draw.Color.B)); | |||
| if (hasBox) | |||
| { | |||
| var boxRect = new RectangleF( | |||
| (float)drawX, (float)drawY, (float)(size.Width * transform.Scale), (float)(size.Height * transform.Scale)); | |||
| g.DrawString(draw.Text, font, brush, boxRect); | |||
| } | |||
| else | |||
| { | |||
| g.DrawString(draw.Text, font, brush, (float)drawX, (float)drawY); | |||
| } | |||
| } | |||
| finally | |||
| { | |||
| if (savedState is not null) | |||
| { | |||
| g.Restore(savedState); | |||
| } | |||
| } | |||
| } | |||
| private static void DrawMessage(Graphics g, RectangleF pageRect, string message) | |||
| { | |||
| using var font = new Font(FontFamily.GenericSansSerif, 10f, GraphicsUnit.Point); | |||
| using var brush = new SolidBrush(Color.Firebrick); | |||
| using var format = new StringFormat | |||
| { | |||
| Alignment = StringAlignment.Center, | |||
| LineAlignment = StringAlignment.Center, | |||
| }; | |||
| var textRect = RectangleF.Inflate(pageRect, -8, -8); | |||
| g.DrawString(message, font, brush, textRect, format); | |||
| } | |||
| private static (double Width, double Height) MeasureText(string text, Font font) | |||
| { | |||
| using var bitmap = new Bitmap(1, 1); | |||
| using var g = Graphics.FromImage(bitmap); | |||
| g.PageUnit = GraphicsUnit.Point; | |||
| var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font); | |||
| return (size.Width, size.Height); | |||
| } | |||
| /// <summary>Falls back to a generic sans-serif font if the requested family isn't installed — | |||
| /// same design-time leniency as <see cref="TemplateCanvasControl"/>'s own font resolution.</summary> | |||
| private static Font ResolveFont(string familyName, float size) | |||
| { | |||
| try | |||
| { | |||
| return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point); | |||
| } | |||
| catch (ArgumentException) | |||
| { | |||
| return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point); | |||
| } | |||
| } | |||
| } | |||
Powered by TurnKey Linux.