diff --git a/code/TEMPLATE_FORMAT.md b/code/TEMPLATE_FORMAT.md index 77b75a5..45a4325 100644 --- a/code/TEMPLATE_FORMAT.md +++ b/code/TEMPLATE_FORMAT.md @@ -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 ``. 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 diff --git a/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserColorTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserColorTests.cs new file mode 100644 index 0000000..a65212a --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserColorTests.cs @@ -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(""" + + Orange + Default + + + + + """); + + 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(""" + + Bad + + """); + + var result = TemplateXmlParser.Parse(path); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Contains("'color'")); + } +} diff --git a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs index b3a65d4..202955d 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs @@ -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()})."; diff --git a/code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs b/code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs new file mode 100644 index 0000000..e9a5e81 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs @@ -0,0 +1,37 @@ +using System.Globalization; + +namespace EnvelopeRenderer.Cli.Render; + +/// Text color for a draw, parsed from a template's optional color="#RRGGBB" +/// attribute (the same shape the desktop designer writes). default is black, so every +/// template written without a color renders exactly as before. Kept independent of the desktop's +/// own RgbColor per this project's CLI/desktop process split. +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; + } +} diff --git a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs index 8d377ff..4f05e50 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs @@ -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); } } diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs index 46563da..abd4fd8 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs @@ -7,7 +7,8 @@ public sealed record TemplateAddressControlLine( string FontName, double Size, IReadOnlyList Runs, - bool Collapsible = true) + bool Collapsible = true, + PdfColor Color = default) { public bool IsDynamic => Runs.Any(r => r.IsField); } diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs index 5b49238..3b54656 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs @@ -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) { /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has /// been set — is independent and optional, no longer required for diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs index b3eaa17..2c6ef41 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs @@ -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); } + /// Optional color="#RRGGBB"; absent means black (the pre-color behavior). + private static PdfColor ParseOptionalColor(XElement node, string label, List 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 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); } /// Sprint 5: parses a `<text>` element's `<run>` children into an ordered diff --git a/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs b/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs index a8c4e1f..e9a61b7 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs @@ -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); diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs deleted file mode 100644 index 755ccaf..0000000 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Drawing.Drawing2D; -using EnvelopeRenderer.Desktop.Core.Design; - -namespace EnvelopeRenderer.Desktop.Views; - -/// -/// Sprint 7, "Render an accurate, record-specific preview of the current template": a dedicated, -/// read-only preview surface — separate from the editable design canvas -/// (, 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 (framework-free, -/// unit tested); this class only does the GDI+ drawing, since that part genuinely cannot be -/// extracted from WinForms. -/// -public sealed class TemplatePreviewControl : Control -{ - private readonly TemplateLayoutDocument _document; - private IReadOnlyList _csvHeaders = Array.Empty(); - private IReadOnlyDictionary? _record; - - public TemplatePreviewControl(TemplateLayoutDocument document) - { - _document = document; - DoubleBuffered = true; - BackColor = SystemColors.ControlDark; - SetStyle(ControlStyles.ResizeRedraw, true); - } - - /// Supplies the CSV headers and the currently selected/navigated-to record this - /// panel should preview. Pass an empty header list and a null record to show the - /// "nothing to preview yet" message (e.g. before any CSV is loaded). - public void SetContext(IReadOnlyList csvHeaders, IReadOnlyDictionary? 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); - } - - /// Falls back to a generic sans-serif font if the requested family isn't installed — - /// same design-time leniency as 's own font resolution. - 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); - } - } -}