浏览代码

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 天前
父节点
当前提交
9c91dd0530
共有 10 个文件被更改,包括 127 次插入 和 177 次删除
  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 查看文件

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


+ 49
- 0
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("""
<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 查看文件

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


+ 37
- 0
code/src/EnvelopeRenderer.Cli/Render/PdfColor.cs 查看文件

@@ -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 查看文件

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



+ 2
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControlLine.cs 查看文件

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

+ 2
- 1
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)
{
/// <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


+ 22
- 2
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);
}

/// <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 `&lt;text&gt;` element's `&lt;run&gt;` children into an ordered


+ 2
- 1
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);

+ 0
- 164
code/src/EnvelopeRenderer.Desktop/Views/TemplatePreviewControl.cs 查看文件

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