Przeglądaj źródła

Render text color in the CLI PDF output

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
Daniel Covington 4 dni temu
rodzic
commit
9c91dd0530
10 zmienionych plików z 127 dodań i 177 usunięć
  1. +3
    -0
      code/TEMPLATE_FORMAT.md
  2. +49
    -0
      code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserColorTests.cs
  3. +6
    -6
      code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
  4. +37
    -0
      code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs
  5. +4
    -2
      code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
  6. +2
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs
  7. +2
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
  8. +22
    -2
      code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs
  9. +2
    -1
      code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs
  10. +0
    -164
      code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs

+ 3
- 0
code/TEMPLATE_FORMAT.md Wyświetl plik

@@ -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


+ 49
- 0
code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserColorTests.cs Wyświetl plik

@@ -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'"));
}
}

+ 6
- 6
code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs Wyświetl plik

@@ -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()}).";


+ 37
- 0
code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs Wyświetl plik

@@ -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;
}
}

+ 4
- 2
code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs Wyświetl plik

@@ -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);
} }
} }




+ 2
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs Wyświetl plik

@@ -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);
} }

+ 2
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs Wyświetl plik

@@ -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


+ 22
- 2
code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs Wyświetl plik

@@ -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 `&lt;text&gt;` element's `&lt;run&gt;` children into an ordered /// <summary>Sprint 5: parses a `&lt;text&gt;` element's `&lt;run&gt;` children into an ordered


+ 2
- 1
code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs Wyświetl plik

@@ -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);

+ 0
- 164
code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs Wyświetl plik

@@ -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);
}
}
}

Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.