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 | 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 | 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. | 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 | - `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 | 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 | 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; | 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) | foreach (var draw in draws) | ||||
| { | { | ||||
| if (string.IsNullOrEmpty(draw.Text)) | if (string.IsNullOrEmpty(draw.Text)) | ||||
| @@ -162,6 +156,12 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| return false; | 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) | if (_pdf.SelectFont(fontHandle) == 0) | ||||
| { | { | ||||
| error = $"Failed to select font '{draw.FontName}' (error code {_pdf.LastErrorCode()})."; | 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, | element.Angle, | ||||
| UsesFixedRotationPivot: element.IsDynamic, | UsesFixedRotationPivot: element.IsDynamic, | ||||
| Width: element.Width, | Width: element.Width, | ||||
| Height: element.Height), | |||||
| Height: element.Height, | |||||
| Color: element.Color), | |||||
| })); | })); | ||||
| } | } | ||||
| @@ -178,7 +179,8 @@ public static class RenderEngine | |||||
| line.Size, | line.Size, | ||||
| resolvedText[i], | resolvedText[i], | ||||
| Angle: angle, | 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, | string FontName, | ||||
| double Size, | double Size, | ||||
| IReadOnlyList<TemplateTextRun> Runs, | IReadOnlyList<TemplateTextRun> Runs, | ||||
| bool Collapsible = true) | |||||
| bool Collapsible = true, | |||||
| PdfColor Color = default) | |||||
| { | { | ||||
| public bool IsDynamic => Runs.Any(r => r.IsField); | public bool IsDynamic => Runs.Any(r => r.IsField); | ||||
| } | } | ||||
| @@ -38,7 +38,8 @@ public sealed record TemplateElement( | |||||
| double Angle = 0, | double Angle = 0, | ||||
| int RenderOrder = 0, | int RenderOrder = 0, | ||||
| double? Width = null, | 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 | /// <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 | /// 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 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 | // 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 | // default (no box, no wrap) so every template written before this story parses and | ||||
| @@ -159,7 +160,8 @@ public static class TemplateXmlParser | |||||
| angle.Value, | angle.Value, | ||||
| renderOrder, | renderOrder, | ||||
| width, | width, | ||||
| height)); | |||||
| height, | |||||
| color)); | |||||
| renderOrder++; | renderOrder++; | ||||
| } | } | ||||
| @@ -217,6 +219,23 @@ public static class TemplateXmlParser | |||||
| x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value); | 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( | private static TemplateAddressControlLine? ParseAddressControlLine( | ||||
| XElement node, string label, List<string> errors) | 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')."); | 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); | var runs = ParseContentRuns(node, label, errors); | ||||
| if (size is null || string.IsNullOrWhiteSpace(font) || runs is null) | if (size is null || string.IsNullOrWhiteSpace(font) || runs is null) | ||||
| { | { | ||||
| return 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 | /// <summary>Sprint 5: parses a `<text>` element's `<run>` children into an ordered | ||||
| @@ -24,4 +24,5 @@ public sealed record TextDraw( | |||||
| double Angle = 0, | double Angle = 0, | ||||
| bool UsesFixedRotationPivot = false, | bool UsesFixedRotationPivot = false, | ||||
| double? Width = null, | 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.