Parcourir la source

Address control case/anchor, page backgrounds, filled rectangles, render from designer

- Address Control: whole-control upper/lowercase and top/bottom vertical anchor
- Template page background (image or PDF page; file and page fixed or from CSV
  columns): vector PDF pages via CopyPageRangesEx when each record uses a
  distinct page, 300 DPI raster fallback otherwise; canvas renders PDF pages
  with DARenderPageToDC and always shows the background
- Filled rectangle (fill attribute on text elements) with design-view outline
  and a height drag handle
- CLI emits PROGRESS info lines; desktop shows a run log
- Designer: Render PDF button, Tab/Shift+Tab to cycle items
- Fix: saving a width-only text element no longer throws
- Serialize Debenu real-DLL tests (parallel runs made LoadFromFile flaky)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
master
Daniel Covington il y a 3 jours
Parent
révision
a647619bc1
49 fichiers modifiés avec 3539 ajouts et 43 suppressions
  1. +3
    -2
      code/CLI_CONTRACT.md
  2. +26
    -0
      code/TEMPLATE_FORMAT.md
  3. +3
    -0
      code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs
  4. +644
    -0
      code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs
  5. +21
    -0
      code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs
  6. +6
    -0
      code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj
  7. +175
    -0
      code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs
  8. +118
    -0
      code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs
  9. +6
    -0
      code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
  10. +10
    -0
      code/src/EnvelopeRenderer.Cli/Program.cs
  11. +9
    -1
      code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs
  12. +7
    -0
      code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs
  13. +31
    -0
      code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs
  14. +16
    -0
      code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs
  15. +217
    -1
      code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
  16. +22
    -1
      code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs
  17. +182
    -5
      code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
  18. +25
    -3
      code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs
  19. +27
    -0
      code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs
  20. +2
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs
  21. +8
    -1
      code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
  22. +120
    -3
      code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs
  23. +5
    -1
      code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs
  24. +30
    -3
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs
  25. +29
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs
  26. +1
    -1
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs
  27. +16
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs
  28. +69
    -1
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  29. +63
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs
  30. +36
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs
  31. +3
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs
  32. +149
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs
  33. +6
    -3
      code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs
  34. +8
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
  35. +1
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs
  36. +3
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs
  37. +11
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs
  38. +170
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs
  39. +110
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs
  40. +22
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs
  41. +99
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs
  42. +67
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs
  43. +4
    -0
      code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj
  44. +24
    -3
      code/src/EnvelopeRenderer.Desktop/MainForm.cs
  45. +223
    -0
      code/src/EnvelopeRenderer.Desktop/Views/PageBackgroundDialog.cs
  46. +136
    -0
      code/src/EnvelopeRenderer.Desktop/Views/PdfPageBitmapRenderer.cs
  47. +121
    -0
      code/src/EnvelopeRenderer.Desktop/Views/RenderRunDialog.cs
  48. +242
    -13
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  49. +213
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs

+ 3
- 2
code/CLI_CONTRACT.md Voir le fichier

@@ -44,11 +44,11 @@ one line per event to stdout in this fixed, space-separated, `key=value` format:
PROGRESS <kind> elapsedMs=<integer> completed=<integer> [reason=<text to end of line>]
```

- `<kind>` is one of `startup`, `render`, `complete`, `failure`.
- `<kind>` is one of `startup`, `render`, `info`, `complete`, `failure`.
- `elapsedMs` — milliseconds since the render phase started, as an integer. Always present.
- `completed` — number of CSV records successfully rendered to a page so far. Always present
(`0` for `startup` and for a failure that happened before any page was rendered).
- `reason` — present only on `failure`; a short, human-readable description of what went wrong.
- `reason` — present on `failure` (what went wrong) and on `info` (the message text); a short, human-readable description of what went wrong.
It is always the **last** field on the line and takes everything remaining on the line
verbatim (embedded newlines are stripped to a single space first), so it never needs quoting
or escaping and a consumer can safely `Split(' ', 5)`-style parse the fixed fields first.
@@ -66,6 +66,7 @@ Event kinds, each emitted at most as documented:
|---|---|---|
| `startup` | Immediately when the render phase begins, before template parsing, CSV header validation, or Debenu setup. | Exactly 1 |
| `render` | After a record is successfully rendered to a page. **Throttled** to at least once per second — see below — not once per row. | 0 or more |
| `info` | A human-readable progress message: template loaded, CSV headers read, PDF library ready, which background mode was chosen (and why per-page drawing was used instead of the vector path), rendering started, and saving. `reason` holds the message; `completed` is the running count. **Not throttled**; a handful per run. Consumers that do not know `info` can ignore it. | 0 or more |
| `complete` | The run finished successfully; `completed` is the final record count. | Exactly 1, only on success |
| `failure` | The run failed at any point during the render phase (template parse, CSV read, Debenu setup, or the per-record render/save loop); `completed` is however many records were successfully rendered before the failure, and `reason` summarizes the same problem reported to stderr. | Exactly 1, only on failure |



+ 26
- 0
code/TEMPLATE_FORMAT.md Voir le fichier

@@ -45,6 +45,12 @@ One page is rendered per CSV record, in the order the CSV rows appear, each carr
`"Arial [BoldItalic]"`. The font must be installed on the machine running the CLI; it is
always embedded in the output PDF so the reader doesn't need it installed too.
- `size` — required, positive, points.
- `fill` — optional `#RRGGBB`. Turns the element into a **filled rectangle** covering its box
(bottom-left at `x`,`y`, size `width` x `height`, both required with `fill`, no `angle`). It
draws over the page background and anything earlier in the file; the element's own text
(which may be empty, e.g. `<text x=".." y=".." font="Arial" size="12" width="120" height="40"
fill="#FFFFFF" />`) draws on top of it, and so does any element later in the file. Use white to
cover text in a background PDF.
- Content is either the legacy single-run shape or the Sprint 5 multi-run shape — never both:
- inline text content — static, printed as-is on every page, e.g. `<text ...>Static label:</text>`
- `column="<CSV header>"` — dynamic, pulled from that column per record. Column matching is
@@ -205,6 +211,26 @@ The rotation pivot is always computed from the control's authored, unrotated `x`
`height`, never from any record's collapse-shifted line positions, so two records differing only
in whether a blank optional line collapses still rotate around the identical pivot.

### Address Control text case and vertical anchor

| Attribute | Values | Default | Meaning |
|---|---|---|---|
| `case` | `none`, `upper`, `lower` | `none` | Upper/lowercases every line of the control after its runs resolve (literal and field text alike). |
| `verticalAnchor` | `top`, `bottom` | `top` | `top`: `y` is the first line baseline and the block grows down. `bottom`: `y` is the last *visible* line baseline and the block grows up, so collapsed blank lines never move the last line. |

## `<background>`

Optional, at most one, directly under `<envelopeTemplate>`. Draws an image or one PDF page behind every output page, stretched to the page size.

| Attribute | Meaning |
|---|---|
| `file` | Fixed path to `.pdf`, `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, `.tif` or `.tiff`. Relative paths resolve against the template's folder. |
| `fileColumn` | CSV column holding the file path per record (blank value = no background for that record). Use exactly one of `file` / `fileColumn`. |
| `page` | PDF page number (default `1`). |
| `pageColumn` | CSV column holding the PDF page number per record. Use at most one of `page` / `pageColumn`. |

When a run uses a different page of one PDF for every record (via `pageColumn`, any order), the output is built as a page-ordered copy of that PDF and each record's text is drawn on top of its own page, so the background stays vector. Otherwise (repeated pages, a fixed `page` with several records, or `fileColumn` files) PDF pages are drawn as a 300 DPI raster of the page. A missing file, or a page number beyond the PDF's page count, fails the render.

## Known gaps

- **Font resolution failure is blocking**, not a warning — matches the Definition of Done's


+ 3
- 0
code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs Voir le fichier

@@ -0,0 +1,3 @@
// The real-DLL tests all load the same native Debenu library into one process; running test classes
// in parallel made LoadFromFile intermittently fail. The whole suite takes seconds, so run serially.
[assembly: CollectionBehavior(DisableTestParallelization = true)]

+ 644
- 0
code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs Voir le fichier

@@ -0,0 +1,644 @@
using System.Drawing;
using DebenuPDFLibraryDLL1013;
using EnvelopeRenderer.Cli.Render;

#pragma warning disable CA1416

namespace EnvelopeRenderer.Cli.Tests;

/// <summary>Address Control text case / vertical anchor and the template-level page background:
/// parsing, engine orchestration (fake renderer), and a real-DLL render of image and PDF backgrounds.</summary>
public class BackgroundAndAddressOptionsTests
{
private static string WriteTemplate(string xml)
{
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml");
File.WriteAllText(path, xml);
return path;
}

private static string DllPath() => Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");

private sealed class RecordingRenderer : IPdfRenderer
{
public List<IReadOnlyList<TextDraw>> Pages { get; } = new();
public List<PageBackground?> Backgrounds { get; } = new();

public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error)
{
Pages.Add(draws);
Backgrounds.Add(background);
error = null;
return true;
}

public bool Save(string outputPath, out string? error)
{
error = null;
return true;
}

public void Dispose() { }
}

private static TemplateAddressControl Control(
AddressTextCase textCase = AddressTextCase.None,
AddressVerticalAnchor anchor = AddressVerticalAnchor.Top)
=> new(120, 500, 180, new[]
{
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Name") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Line2") }),
new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun("Mixed Case", null) }),
}, TextCase: textCase, VerticalAnchor: anchor);

private static RecordingRenderer RenderControl(TemplateAddressControl control, string line2)
{
var template = new TemplateDocument(297, 684, Array.Empty<TemplateElement>(), new[] { control });
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Name"] = "Alice Smith", ["Line2"] = line2 },
};
var renderer = new RecordingRenderer();
var result = RenderEngine.Render(template, new[] { "Name", "Line2" }, records, renderer, "out.pdf");
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
return renderer;
}

[Theory]
[InlineData(AddressTextCase.None, "Alice Smith", "Mixed Case")]
[InlineData(AddressTextCase.Upper, "ALICE SMITH", "MIXED CASE")]
[InlineData(AddressTextCase.Lower, "alice smith", "mixed case")]
public void AddressControl_TextCase_AppliesToFieldAndLiteralText(AddressTextCase textCase, string name, string literal)
{
var draws = RenderControl(Control(textCase), "x").Pages[0];

Assert.Equal(name, draws[0].Text);
Assert.Equal(literal, draws[2].Text);
}

[Fact]
public void AddressControl_TopAnchor_FirstLineOnAnchorAndBlockGrowsDown()
{
var draws = RenderControl(Control(), "Suite 4").Pages[0];

Assert.Equal(new[] { 500.0, 485.0, 470.0 }, draws.Select(d => d.Y).ToArray());
}

[Fact]
public void AddressControl_BottomAnchor_LastLineOnAnchorAndBlockGrowsUp()
{
var draws = RenderControl(Control(anchor: AddressVerticalAnchor.Bottom), "Suite 4").Pages[0];

Assert.Equal(new[] { 530.0, 515.0, 500.0 }, draws.Select(d => d.Y).ToArray());
}

[Fact]
public void AddressControl_BottomAnchor_CollapsedMiddleLine_KeepsLastVisibleLineOnAnchor()
{
var draws = RenderControl(Control(anchor: AddressVerticalAnchor.Bottom), "").Pages[0];

Assert.Equal(2, draws.Count);
Assert.Equal(new[] { 515.0, 500.0 }, draws.Select(d => d.Y).ToArray());
}

[Fact]
public void Parse_AddressControlCaseAndVerticalAnchor_ReadFromXml()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="1" y="2" width="180" case="UPPER" verticalAnchor="bottom">
<line font="Arial" size="12"><run column="Name" /></line>
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var control = Assert.Single(result.Document!.AddressControls);
Assert.Equal(AddressTextCase.Upper, control.TextCase);
Assert.Equal(AddressVerticalAnchor.Bottom, control.VerticalAnchor);
}

[Fact]
public void Parse_AddressControlBadCase_Fails()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<addressControl x="1" y="2" width="180" case="title">
<line font="Arial" size="12"><run column="Name" /></line>
</addressControl>
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("'case'"));
}

private static string TextTemplate(string backgroundXml)
=> $"""<envelopeTemplate pageWidth="297" pageHeight="684">{backgroundXml}<text x="1" y="2" font="Arial" size="12">Hi</text></envelopeTemplate>""";

[Fact]
public void Parse_Background_StaticFileAndPage_ResolvesAgainstTemplateFolder()
{
var path = WriteTemplate(TextTemplate("""<background file="forms/bg.pdf" page="2" />"""));

var background = TemplateXmlParser.Parse(path).Document!.Background;

Assert.NotNull(background);
Assert.Equal("forms/bg.pdf", background!.File);
Assert.Equal(2, background.Page);
Assert.Equal(Path.GetDirectoryName(path), background.BaseDirectory);
}

[Fact]
public void Parse_Background_ColumnBoundFileAndPage()
{
var path = WriteTemplate(TextTemplate("""<background fileColumn="Form" pageColumn="FormPage" />"""));

var background = TemplateXmlParser.Parse(path).Document!.Background;

Assert.Equal("Form", background!.FileColumn);
Assert.Equal("FormPage", background.PageColumn);
Assert.Null(background.File);
}

[Theory]
[InlineData("""<background />""", "exactly one of")]
[InlineData("""<background file="a.pdf" fileColumn="X" />""", "exactly one of")]
[InlineData("""<background file="a.txt" />""", "unsupported")]
[InlineData("""<background file="a.pdf" page="0" />""", "'page'")]
[InlineData("""<background file="a.pdf" page="2" pageColumn="P" />""", "both")]
public void Parse_Background_Invalid_Fails(string backgroundXml, string expected)
{
var result = TemplateXmlParser.Parse(WriteTemplate(TextTemplate(backgroundXml)));

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains(expected));
}

[Fact]
public void Parse_NoBackground_IsNull()
=> Assert.Null(TemplateXmlParser.Parse(WriteTemplate(TextTemplate(""))).Document!.Background);

private static TemplateDocument WithBackground(TemplateBackground background)
=> new(297, 684, new[] { TemplateElement.Static(1, 2, "Arial", 12, "Hi") },
Array.Empty<TemplateAddressControl>(), background);

[Fact]
public void Render_StaticBackground_PassedToEveryPage()
{
var template = WithBackground(new TemplateBackground("bg.pdf", null, 3, null, @"C:\forms"));
var renderer = new RecordingRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["A"] = "1" }, new Dictionary<string, string> { ["A"] = "2" },
};

var result = RenderEngine.Render(template, new[] { "A" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded);
Assert.All(renderer.Backgrounds, b => Assert.Equal(new PageBackground(@"C:\forms\bg.pdf", true, 3), b));
}

[Fact]
public void Render_ColumnBoundBackground_ResolvesPerRecord_BlankFileMeansNone()
{
var template = WithBackground(new TemplateBackground(null, "Form", 1, "Pg", @"C:\forms"));
var renderer = new RecordingRenderer();
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Form"] = "a.pdf", ["Pg"] = " 4 " },
new Dictionary<string, string> { ["Form"] = @"D:\abs\b.PNG", ["Pg"] = "junk" },
new Dictionary<string, string> { ["Form"] = "", ["Pg"] = "" },
};

var result = RenderEngine.Render(template, new[] { "Form", "Pg" }, records, renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(new PageBackground(@"C:\forms\a.pdf", true, 4), renderer.Backgrounds[0]);
Assert.Equal(new PageBackground(@"D:\abs\b.PNG", false, 1), renderer.Backgrounds[1]);
Assert.Null(renderer.Backgrounds[2]);
}

[Fact]
public void Render_BackgroundColumnMissingFromCsv_FailsBeforeRendering()
{
var template = WithBackground(new TemplateBackground(null, "Nope", 1, null, @"C:\forms"));
var renderer = new RecordingRenderer();

var result = RenderEngine.Render(template, new[] { "A" },
new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } },
renderer, "out.pdf");

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("'Nope'"));
Assert.Empty(renderer.Pages);
}

[Fact]
public void Render_BadPageValueForPdf_FailsWithRecordNumber()
{
var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms"));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Pg"] = "0" },
};

var result = RenderEngine.Render(template, new[] { "Pg" }, records, new RecordingRenderer(), "out.pdf");

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("Record 1") && e.Contains("'0'"));
}

// ---- real Debenu DLL ----

private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"bg-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
return dir;
}

private static string MakeTwoPageSourcePdf(string dir, string licenseKey)
{
var path = Path.Combine(dir, "source.pdf");
Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), licenseKey, out var renderer, out var error), error);
using (renderer)
{
// Big solid glyphs in distinct colors so a rendered output page shows which source page
// was used as its background.
var page1 = new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: new PdfColor(255, 0, 0)) };
var page2 = new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: new PdfColor(0, 0, 255)) };
Assert.True(renderer!.AddPage(200, 200, page1, out error), error);
Assert.True(renderer.AddPage(200, 200, page2, out error), error);
Assert.True(renderer.Save(path, out error), error);
}

return path;
}

/// <summary>Renders one page of a PDF and reports how many clearly red / clearly blue pixels it has.</summary>
private static (int Red, int Blue) InkColors(string pdfPath, string licenseKey, int page, string dir)
{
var pdf = new PDFLibrary(DllPath());
pdf.UnlockKey(licenseKey);
Assert.NotEqual(0, pdf.LoadFromFile(pdfPath, ""));
var png = Path.Combine(dir, $"page{page}-{Guid.NewGuid():N}.png");
Assert.NotEqual(0, pdf.RenderPageToFile(72, page, 0, png));
pdf.ReleaseLibrary();

using var bitmap = new Bitmap(png);
int red = 0, blue = 0;
for (var x = 0; x < bitmap.Width; x++)
{
for (var y = 0; y < bitmap.Height; y++)
{
var c = bitmap.GetPixel(x, y);
if (c.R > 180 && c.G < 90 && c.B < 90) { red++; }
if (c.B > 180 && c.R < 90 && c.G < 90) { blue++; }
}
}

return (red, blue);
}

[Fact]
public void RealRender_PdfBackgroundPage_IsDrawnAsBackground_AndPageColumnPicksThePage()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = TempDir();
try
{
MakeTwoPageSourcePdf(dir, key);
var template = WithBackground(new TemplateBackground(null, "Form", 1, "Pg", dir));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Form"] = "source.pdf", ["Pg"] = "1" },
new Dictionary<string, string> { ["Form"] = "source.pdf", ["Pg"] = "2" },
};
var output = Path.Combine(dir, "out.pdf");

Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
var result = RenderEngine.Render(template, new[] { "Form", "Pg" }, records, renderer!, output);
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
}

var page1 = InkColors(output, key, 1, dir);
var page2 = InkColors(output, key, 2, dir);
Assert.True(page1.Red > 500 && page1.Blue == 0, $"page 1 should carry source page 1 (red): {page1}");
Assert.True(page2.Blue > 500 && page2.Red == 0, $"page 2 should carry source page 2 (blue): {page2}");
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public void RealRender_PdfBackgroundPageOutOfRange_FailsClearly()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = TempDir();
try
{
MakeTwoPageSourcePdf(dir, key);
var template = WithBackground(new TemplateBackground("source.pdf", null, 9, null, dir));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["A"] = "1" },
};

Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
var result = RenderEngine.Render(template, new[] { "A" }, records, renderer!, Path.Combine(dir, "o.pdf"));
Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("has no page 9"));
}
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public void RealRender_MissingBackgroundFile_FailsClearly()
{
Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), null, out var renderer, out var error), error);
using (renderer)
{
var template = WithBackground(new TemplateBackground("missing.png", null, 1, null, Path.GetTempPath()));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["A"] = "1" },
};

var result = RenderEngine.Render(template, new[] { "A" }, records, renderer!, "unused.pdf");

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains("Background file not found"));
}
}

[Fact]
public void RealRender_ImageBackground_FillsThePage()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = TempDir();
try
{
var imagePath = Path.Combine(dir, "red.png");
using (var bitmap = new Bitmap(40, 40))
{
using var g = Graphics.FromImage(bitmap);
g.Clear(Color.Red);
bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png);
}

var output = Path.Combine(dir, "out.pdf");
Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
Assert.True(renderer!.AddPage(200, 200, Array.Empty<TextDraw>(),
new PageBackground(imagePath, false, 1), out error), error);
Assert.True(renderer.Save(output, out error), error);
}

var pdf = new PDFLibrary(DllPath());
pdf.UnlockKey(key);
Assert.NotEqual(0, pdf.LoadFromFile(output, ""));
var png = Path.Combine(dir, "page.png");
Assert.NotEqual(0, pdf.RenderPageToFile(72, 1, 0, png));
pdf.ReleaseLibrary();

using var rendered = new Bitmap(png);
var center = rendered.GetPixel(rendered.Width / 2, rendered.Height / 2);
var corner = rendered.GetPixel(2, 2);
Assert.True(center.R > 200 && center.G < 60 && center.B < 60, $"center was {center}");
Assert.True(corner.R > 200 && corner.G < 60 && corner.B < 60, $"corner was {corner}");
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

private sealed class SequenceRecordingRenderer : IPdfRenderer
{
public bool SupportsPdfBackgroundSequence => true;
public (string Path, IReadOnlyList<int> Pages)? Sequence { get; private set; }
public List<PageBackground?> Backgrounds { get; } = new();

public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error)
{
Sequence = (pdfPath, pagesInRecordOrder.ToList());
error = null;
return true;
}

public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error)
{
Backgrounds.Add(background);
error = null;
return true;
}

public bool Save(string outputPath, out string? error)
{
error = null;
return true;
}

public void Dispose() { }
}

private static List<IReadOnlyDictionary<string, string>> PageRecords(params string[] pages)
=> pages.Select(p => (IReadOnlyDictionary<string, string>)new Dictionary<string, string> { ["Pg"] = p }).ToList();

[Fact]
public void Render_DistinctPagesOfOnePdf_UsesVectorSequenceInRecordOrder()
{
var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms"));
var renderer = new SequenceRecordingRenderer();

var result = RenderEngine.Render(template, new[] { "Pg" }, PageRecords("47", "46", "1"), renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(@"C:\forms\a.pdf", renderer.Sequence!.Value.Path);
Assert.Equal(new[] { 47, 46, 1 }, renderer.Sequence.Value.Pages);
Assert.All(renderer.Backgrounds, Assert.Null);
}

[Fact]
public void Render_RepeatedPage_FallsBackToPerPageBackground()
{
var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms"));
var renderer = new SequenceRecordingRenderer();

var result = RenderEngine.Render(template, new[] { "Pg" }, PageRecords("2", "3", "2"), renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Null(renderer.Sequence);
Assert.Equal(new[] { 2, 3, 2 }, renderer.Backgrounds.Select(b => b!.Page).ToArray());
}

[Fact]
public void Render_ImageBackground_NeverUsesVectorSequence()
{
var template = WithBackground(new TemplateBackground("a.png", null, 1, null, @"C:\forms"));
var renderer = new SequenceRecordingRenderer();

RenderEngine.Render(template, new[] { "Pg" }, PageRecords("1"), renderer, "out.pdf");

Assert.Null(renderer.Sequence);
}

private static string SolidPdf(string dir, string licenseKey, params PdfColor[] pageColors)
{
var path = Path.Combine(dir, "source.pdf");
Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), licenseKey, out var renderer, out var error), error);
using (renderer)
{
foreach (var color in pageColors)
{
Assert.True(renderer!.AddPage(200, 200,
new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: color) }, out error), error);
}

Assert.True(renderer!.Save(path, out error), error);
}

return path;
}

private static string PdfText(string pdfPath, string licenseKey, int page)
{
var pdf = new PDFLibrary(DllPath());
pdf.UnlockKey(licenseKey);
Assert.NotEqual(0, pdf.LoadFromFile(pdfPath, ""));
pdf.SelectPage(page);
var text = pdf.GetPageText(0);
pdf.ReleaseLibrary();
return text;
}

[Fact]
public void RealRender_VectorSequence_ReordersPages_AndKeepsEachRecordsTextOnItsOwnPage()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = TempDir();
try
{
SolidPdf(dir, key, new PdfColor(255, 0, 0), new PdfColor(0, 0, 255), new PdfColor(0, 160, 0));
var template = new TemplateDocument(200, 200,
new[] { TemplateElement.Dynamic(10, 190, "Arial", 12, "Name") },
Array.Empty<TemplateAddressControl>(),
new TemplateBackground("source.pdf", null, 1, "Pg", dir));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Pg"] = "3", ["Name"] = "ALICE" },
new Dictionary<string, string> { ["Pg"] = "1", ["Name"] = "BOB" },
new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "CAROL" },
};
var output = Path.Combine(dir, "out.pdf");

Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
var result = RenderEngine.Render(template, new[] { "Pg", "Name" }, records, renderer!, output);
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
}

// Record order is 3,1,2: output page 1 is source page 3 (green), page 2 is red, page 3 is blue.
Assert.Contains("ALICE", PdfText(output, key, 1));
Assert.Contains("BOB", PdfText(output, key, 2));
Assert.Contains("CAROL", PdfText(output, key, 3));
Assert.DoesNotContain("BOB", PdfText(output, key, 1));
var first = InkColors(output, key, 1, dir);
var second = InkColors(output, key, 2, dir);
var third = InkColors(output, key, 3, dir);
Assert.True(first.Red == 0 && first.Blue == 0, $"page 1 should be the green source page: {first}");
Assert.True(second.Red > 500 && second.Blue == 0, $"page 2 should be the red source page: {second}");
Assert.True(third.Blue > 500 && third.Red == 0, $"page 3 should be the blue source page: {third}");
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public void RealRender_RepeatedPages_FallBackToRaster_EachRecordKeepsItsOwnText()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = TempDir();
try
{
SolidPdf(dir, key, new PdfColor(255, 0, 0), new PdfColor(0, 0, 255));
var template = new TemplateDocument(200, 200,
new[] { TemplateElement.Dynamic(10, 190, "Arial", 12, "Name") },
Array.Empty<TemplateAddressControl>(),
new TemplateBackground("source.pdf", null, 1, "Pg", dir));
var records = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "ALICE" },
new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "BOB" },
};
var output = Path.Combine(dir, "out.pdf");

Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
var result = RenderEngine.Render(template, new[] { "Pg", "Name" }, records, renderer!, output);
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
}

Assert.Contains("ALICE", PdfText(output, key, 1));
Assert.DoesNotContain("BOB", PdfText(output, key, 1));
Assert.Contains("BOB", PdfText(output, key, 2));
Assert.True(InkColors(output, key, 2, dir).Blue > 500);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}

+ 21
- 0
code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs Voir le fichier

@@ -110,4 +110,25 @@ public class ConsoleProgressReporterTests
Assert.Equal(2, lines.Length);
Assert.Equal("PROGRESS failure elapsedMs=100 completed=1 reason=Record 2: simulated page failure", lines[1]);
}

[Fact]
public void Info_WritesUnthrottledInfoLine_WithCurrentCompletedCount()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 5 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(7);
reporter.Info("first");
reporter.Info("second" + (char)10 + "line");

Assert.Equal(
new[]
{
"PROGRESS render elapsedMs=5 completed=7",
"PROGRESS info elapsedMs=5 completed=7 reason=first",
"PROGRESS info elapsedMs=5 completed=7 reason=second line",
},
Lines(writer));
}
}

+ 6
- 0
code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj Voir le fichier

@@ -27,4 +27,10 @@
<ProjectReference Include="..\EnvelopeRenderer.Cli\EnvelopeRenderer.Cli.csproj" />
</ItemGroup>

<ItemGroup>
<!-- The desktop canvas's PDF page renderer (System.Drawing + Debenu only) is compiled into this
test project so its real-DLL behavior can be tested without referencing the WinForms app. -->
<Compile Include="..\EnvelopeRenderer.Desktop\Views\PdfPageBitmapRenderer.cs" Link="Linked\PdfPageBitmapRenderer.cs" />
</ItemGroup>

</Project>

+ 175
- 0
code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs Voir le fichier

@@ -0,0 +1,175 @@
using System.Drawing;
using DebenuPDFLibraryDLL1013;
using EnvelopeRenderer.Cli.Render;

#pragma warning disable CA1416

namespace EnvelopeRenderer.Cli.Tests;

/// <summary>A &lt;text&gt; element with <c>fill</c> is a filled rectangle (cover-up) that its own text,
/// or any element with a higher render order, draws over.</summary>
public class FilledRectangleTests
{
private static string WriteTemplate(string xml)
{
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml");
File.WriteAllText(path, xml);
return path;
}

private static string DllPath() => Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");

[Fact]
public void Parse_FillWithBoxAndNoText_IsAPureRectangle()
{
var path = WriteTemplate("""
<envelopeTemplate pageWidth="297" pageHeight="684">
<text x="10" y="20" font="Arial" size="12" width="100" height="40" fill="#FFFFFF" />
</envelopeTemplate>
""");

var result = TemplateXmlParser.Parse(path);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
var element = Assert.Single(result.Document!.Elements);
Assert.True(element.HasFill);
Assert.Equal(new PdfColor(255, 255, 255), element.FillColor);
Assert.Equal(string.Empty, element.Runs[0].Literal);
}

[Theory]
[InlineData("""<text x="1" y="2" font="Arial" size="12" fill="#FFFFFF">Hi</text>""", "'width' and 'height'")]
[InlineData("""<text x="1" y="2" font="Arial" size="12" width="5" height="5" fill="nope">Hi</text>""", "'fill'")]
[InlineData("""<text x="1" y="2" font="Arial" size="12" width="5" height="5" fill="#000000" angle="10">Hi</text>""", "rotated")]
public void Parse_InvalidFill_Fails(string textXml, string expected)
{
var result = TemplateXmlParser.Parse(WriteTemplate(
$"""<envelopeTemplate pageWidth="297" pageHeight="684">{textXml}</envelopeTemplate>"""));

Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Contains(expected));
}

[Fact]
public void Render_FillElement_ProducesDrawWithFillColorAndBox_InRenderOrder()
{
var elements = new[]
{
new TemplateElement(10, 20, "Arial", 12, new[] { new TemplateTextRun(string.Empty, null) },
Width: 100, Height: 40, FillColor: new PdfColor(255, 255, 255)),
TemplateElement.Static(12, 30, "Arial", 12, "over") with { RenderOrder = 1 },
};
var template = new TemplateDocument(297, 684, elements, Array.Empty<TemplateAddressControl>());
var draws = new List<IReadOnlyList<TextDraw>>();
var renderer = new CapturingRenderer(draws);

var result = RenderEngine.Render(
template, new[] { "A" },
new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } },
renderer, "out.pdf");

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(new PdfColor(255, 255, 255), draws[0][0].FillColor);
Assert.Equal(100, draws[0][0].Width);
Assert.Equal("over", draws[0][1].Text);
Assert.Null(draws[0][1].FillColor);
}

private sealed class CapturingRenderer : IPdfRenderer
{
private readonly List<IReadOnlyList<TextDraw>> _draws;
public CapturingRenderer(List<IReadOnlyList<TextDraw>> draws) => _draws = draws;

public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error)
{
_draws.Add(draws);
error = null;
return true;
}

public bool Save(string outputPath, out string? error)
{
error = null;
return true;
}

public void Dispose() { }
}

[Fact]
public void RealRender_FilledRectangleCoversBackground_AndTextDrawsOverIt()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = Path.Combine(Path.GetTempPath(), $"rect-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var imagePath = Path.Combine(dir, "red.png");
using (var bitmap = new Bitmap(40, 40))
{
using var g = Graphics.FromImage(bitmap);
g.Clear(Color.Red);
bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png);
}

// 200x200 page, red background; a white 100x100 rectangle at (50,50) with black text in it.
var rectangle = new TemplateElement(50, 50, "Arial", 40,
new[] { new TemplateTextRun("MMMM", null) },
Width: 100, Height: 100, FillColor: new PdfColor(255, 255, 255));
var template = new TemplateDocument(200, 200, new[] { rectangle },
Array.Empty<TemplateAddressControl>(),
new TemplateBackground("red.png", null, 1, null, dir));
var output = Path.Combine(dir, "out.pdf");

Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
var result = RenderEngine.Render(
template, new[] { "A" },
new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } },
renderer!, output);
Assert.True(result.Succeeded, string.Join("; ", result.Errors));
}

var pdf = new PDFLibrary(DllPath());
pdf.UnlockKey(key);
Assert.NotEqual(0, pdf.LoadFromFile(output, ""));
var png = Path.Combine(dir, "page.png");
Assert.NotEqual(0, pdf.RenderPageToFile(72, 1, 0, png));
pdf.ReleaseLibrary();

using var rendered = new Bitmap(png);
var corner = rendered.GetPixel(5, 5);
Assert.True(corner.R > 200 && corner.G < 60, $"outside the rectangle the red background stays: {corner}");

// Inside the rectangle (PDF y=50..150 is image rows 50..150): white except where text draws.
int white = 0, dark = 0, red = 0;
for (var x = 55; x < 145; x++)
{
for (var y = 55; y < 145; y++)
{
var c = rendered.GetPixel(x, y);
if (c.R > 230 && c.G > 230 && c.B > 230) { white++; }
else if (c.R < 90 && c.G < 90 && c.B < 90) { dark++; }
else if (c.R > 200 && c.G < 60 && c.B < 60) { red++; }
}
}

Assert.Equal(0, red);
Assert.True(white > 3000, $"the rectangle should be mostly white: {white}");
Assert.True(dark > 100, $"text should draw over the rectangle: {dark}");
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}

+ 118
- 0
code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs Voir le fichier

@@ -0,0 +1,118 @@
using System.Drawing;
using EnvelopeRenderer.Cli.Render;
using EnvelopeRenderer.Desktop.Views;

#pragma warning disable CA1416

namespace EnvelopeRenderer.Cli.Tests;

/// <summary>The designer canvas renders a PDF background page with DARenderPageToDC.</summary>
public class PdfPageBitmapRendererTests
{
private static string DllPath() => Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");

private static string MakePdf(string dir, string key)
{
var path = Path.Combine(dir, "source.pdf");
Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error);
using (renderer)
{
// Page 1 red, page 2 blue; 200 x 100 points (2:1 aspect).
Assert.True(renderer!.AddPage(200, 100,
new[] { new TextDraw(0, 0, "Arial", 400, "W", Color: new PdfColor(255, 0, 0)) }, out error), error);
Assert.True(renderer.AddPage(200, 100,
new[] { new TextDraw(0, 0, "Arial", 400, "W", Color: new PdfColor(0, 0, 255)) }, out error), error);
Assert.True(renderer.Save(path, out error), error);
}

return path;
}

private static (int Red, int Blue) Count(Bitmap bitmap)
{
int red = 0, blue = 0;
for (var x = 0; x < bitmap.Width; x++)
{
for (var y = 0; y < bitmap.Height; y++)
{
var c = bitmap.GetPixel(x, y);
if (c.R > 180 && c.G < 90 && c.B < 90) { red++; }
if (c.B > 180 && c.R < 90 && c.G < 90) { blue++; }
}
}

return (red, blue);
}

[Fact]
public void Render_PageOfPdf_ReturnsBitmapOfRequestedWidthAndPageAspect_WithThatPagesContent()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = Path.Combine(Path.GetTempPath(), $"pdfbmp-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var pdf = MakePdf(dir, key);

using var first = PdfPageBitmapRenderer.Render(pdf, 1, 400, out var error1);
using var second = PdfPageBitmapRenderer.Render(pdf, 2, 400, out var error2);

Assert.True(first is not null, error1);
Assert.True(second is not null, error2);
Assert.Equal(400, first!.Width);
Assert.Equal(200, first.Height);
var page1 = Count(first);
var page2 = Count(second!);
Assert.True(page1.Red > 500 && page1.Blue == 0, $"page 1 should be red: {page1}");
Assert.True(page2.Blue > 500 && page2.Red == 0, $"page 2 should be blue: {page2}");
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public void Render_PageBeyondTheEnd_ReturnsNullWithReadableError()
{
var key = DebenuLicenseKey.Resolve();
if (key is null)
{
Console.WriteLine("SKIPPED: no Debenu license key available locally.");
return;
}

var dir = Path.Combine(Path.GetTempPath(), $"pdfbmp-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var pdf = MakePdf(dir, key);

var bitmap = PdfPageBitmapRenderer.Render(pdf, 9, 400, out var error);

Assert.Null(bitmap);
Assert.Contains("no page 9", error);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public void Render_MissingFile_ReturnsNullWithError()
{
var bitmap = PdfPageBitmapRenderer.Render(Path.Combine(Path.GetTempPath(), "nope.pdf"), 1, 400, out var error);

Assert.Null(bitmap);
Assert.Contains("not found", error);
}
}

+ 6
- 0
code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs Voir le fichier

@@ -25,8 +25,14 @@ public class RenderEngineTests
public bool FailOnPage { get; set; }
public bool FailOnSave { get; set; }

public List<PageBackground?> Backgrounds { get; } = new();

public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error)
=> ((IPdfRenderer)this).AddPage(pageWidth, pageHeight, draws, null, out error);

bool IPdfRenderer.AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error)
{
Backgrounds.Add(background);
if (FailOnPage)
{
error = "simulated page failure";


+ 10
- 0
code/src/EnvelopeRenderer.Cli/Program.cs Voir le fichier

@@ -61,6 +61,12 @@ static int RunRender(CliArguments args)
return ExitCodes.UnexpectedError;
}

var template = templateResult.Document!;
progress.Info(
$"Template loaded: {template.Elements.Count} text element(s), {template.AddressControls.Count} address control(s), " +
$"page {template.PageWidth} x {template.PageHeight} pt" +
(template.Background is null ? "." : ", with a page background."));

var csv = new CsvRecordSource(args.CsvPath);
IReadOnlyList<string> headers;
try
@@ -75,6 +81,8 @@ static int RunRender(CliArguments args)
return ExitCodes.UnexpectedError;
}

progress.Info($"CSV headers read: {headers.Count} column(s).");

var dllPath = Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");
@@ -87,6 +95,8 @@ static int RunRender(CliArguments args)
return ExitCodes.UnexpectedError;
}

progress.Info("PDF library ready.");

using (renderer)
{
var renderResult = RenderEngine.Render(


+ 9
- 1
code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs Voir le fichier

@@ -17,6 +17,7 @@ public sealed class ConsoleProgressReporter : IProgressReporter
private readonly Func<long> _elapsedMillisecondsProvider;
private readonly long _minIntervalMs;
private long? _lastEmittedAtMs;
private int _lastCompleted;

public ConsoleProgressReporter(
TextWriter output, Func<long> elapsedMillisecondsProvider, long minIntervalMs = 1000)
@@ -37,6 +38,7 @@ public sealed class ConsoleProgressReporter : IProgressReporter

public void ReportRenderProgress(int completed)
{
_lastCompleted = completed;
var elapsed = _elapsedMillisecondsProvider();

// Always emit the first call immediately (so a slow run shows *something* right away),
@@ -51,7 +53,13 @@ public sealed class ConsoleProgressReporter : IProgressReporter
Write("render", completed);
}

public void Complete(int completed) => Write("complete", completed);
public void Complete(int completed)
{
_lastCompleted = completed;
Write("complete", completed);
}

public void Info(string message) => Write("info", _lastCompleted, message);

public void Failure(int completed, string reason) => Write("failure", completed, reason);



+ 7
- 0
code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs Voir le fichier

@@ -26,4 +26,11 @@ public interface IProgressReporter
/// supplements — never replaces — the corresponding `ERROR: `-prefixed stderr line(s); the
/// exit code and stderr contract are unchanged by this event.</summary>
void Failure(int completed, string reason);

/// <summary>A human-readable "what is happening now" message (template loaded, background
/// mode chosen, saving...). Never throttled; emitted as a <c>PROGRESS info</c> line. Default
/// no-op so reporters that only care about the numeric events need not implement it.</summary>
void Info(string message)
{
}
}

+ 31
- 0
code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs Voir le fichier

@@ -0,0 +1,31 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>Letter-case transform applied to every line of an Address Control after its runs are
/// resolved for a record (literal and field text alike). <see cref="None"/> leaves text exactly as
/// authored/read, so every template written before this option renders unchanged.</summary>
public enum AddressTextCase
{
None = 0,
Upper = 1,
Lower = 2,
}

/// <summary>Which edge of the Address Control block its authored <c>y</c> anchor pins to.
/// <see cref="Top"/> (the long-standing behavior): <c>y</c> is the first line's baseline and the
/// block grows downward. <see cref="Bottom"/>: <c>y</c> is the last visible line's baseline and
/// the block grows upward, so a record with collapsed lines keeps its last line on the anchor.</summary>
public enum AddressVerticalAnchor
{
Top = 0,
Bottom = 1,
}

public static class AddressTextCaseExtensions
{
public static string Apply(this AddressTextCase textCase, string text) => textCase switch
{
AddressTextCase.Upper => text.ToUpperInvariant(),
AddressTextCase.Lower => text.ToLowerInvariant(),
_ => text,
};
}

+ 16
- 0
code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs Voir le fichier

@@ -71,4 +71,20 @@ public static class AddressLineCollapser

return result;
}

/// <summary>For a bottom-anchored Address Control: the vertical shift that puts the last
/// visible line's baseline on <paramref name="anchorY"/> (negative = move down). <c>0</c> when
/// no line is visible.</summary>
public static double BottomAnchorShift(IReadOnlyList<Resolved> resolved, double anchorY)
{
for (var i = resolved.Count - 1; i >= 0; i--)
{
if (resolved[i].Visible)
{
return anchorY - resolved[i].EffectiveY;
}
}

return 0;
}
}

+ 217
- 1
code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs Voir le fichier

@@ -49,6 +49,16 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
private readonly string _batchTempDirectory;
private PDFLibrary _pdf;
private readonly Dictionary<string, int> _fontHandles = new(StringComparer.OrdinalIgnoreCase);
// Background image cache, per Debenu instance (cleared whenever a batch reopens a fresh
// instance): image path -> image ID, so a background reused across thousands of records is
// embedded once per batch, not once per page.
private readonly Dictionary<string, int> _backgroundImages = new(StringComparer.OrdinalIgnoreCase);
// Vector PDF background sequence (see BeginPdfBackgroundSequence): while active, AddPage draws
// onto the next pre-built page of the working document instead of creating pages, and batching
// is off (the document already holds every page).
private bool _sequenceActive;
private int _sequenceNextPage;
private int _sequencePageCount;
private int _pagesInCurrentBatch;
private bool _disposed;

@@ -115,7 +125,77 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
}

public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error)
=> AddPage(pageWidth, pageHeight, draws, background: null, out error);

public bool SupportsPdfBackgroundSequence => true;

public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error)
{
if (_firstPageUsed || _sequenceActive)
{
error = "A PDF background sequence must be started before the first page.";
return false;
}

if (!File.Exists(pdfPath))
{
error = $"Background file not found: '{pdfPath}'.";
return false;
}

// Load the source PDF into the initial document, then copy the wanted pages, in record
// order, into a fresh document. The copy keeps each page's original content; the fresh
// document's own first page is always blank and is removed again.
if (_pdf.LoadFromFile(pdfPath, string.Empty) == 0)
{
error = $"Failed to open background PDF '{pdfPath}' (error code {_pdf.LastErrorCode()}).";
return false;
}

var sourceId = _pdf.SelectedDocument();
var sourcePageCount = _pdf.PageCount();
var badPage = pagesInRecordOrder.Select((p, i) => (Page: p, Record: i + 1)).FirstOrDefault(t => t.Page > sourcePageCount);
if (badPage.Page > 0)
{
error = $"Record {badPage.Record}: background PDF '{pdfPath}' has no page {badPage.Page} (it has {sourcePageCount}).";
return false;
}

_pdf.NewDocument();
if (_pdf.CopyPageRangesEx(sourceId, string.Join(",", pagesInRecordOrder), 1) == 0)
{
error = $"Failed to copy background PDF pages from '{pdfPath}' (error code {_pdf.LastErrorCode()}).";
return false;
}

_pdf.DeletePages(1, 1);
_sequencePageCount = _pdf.PageCount();
if (_sequencePageCount != pagesInRecordOrder.Count)
{
error = $"Background PDF copy has {_sequencePageCount} pages, expected {pagesInRecordOrder.Count}.";
return false;
}

_sequenceActive = true;
_sequenceNextPage = 1;
error = null;
return true;
}

public bool AddPage(
double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error)
{
if (_sequenceActive)
{
if (_sequenceNextPage > _sequencePageCount || _pdf.SelectPage(_sequenceNextPage++) == 0)
{
error = "No page left in the background PDF sequence for this record.";
return false;
}

return DrawTextDraws(draws, out error);
}

if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch)
{
if (!FlushCurrentBatchAndReopen(out error))
@@ -144,8 +224,36 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
return false;
}

if (background is not null && !TryDrawBackground(background, pageWidth, pageHeight, out error))
{
return false;
}

if (!DrawTextDraws(draws, out error))
{
return false;
}

_pagesInCurrentBatch++;
error = null;
return true;
}

private bool DrawTextDraws(IReadOnlyList<TextDraw> draws, out string? error)
{
foreach (var draw in draws)
{
if (draw.FillColor is { } fill && draw.Width is > 0 && draw.Height is > 0)
{
// Filled rectangle (bottom-left at X, Y), drawn before this draw's own text.
if (_pdf.SetFillColor(fill.R / 255.0, fill.G / 255.0, fill.B / 255.0) == 0
|| _pdf.DrawBox(draw.X, draw.Y + draw.Height.Value, draw.Width.Value, draw.Height.Value, DrawOptions: 1) == 0)
{
error = $"Failed to draw filled rectangle at ({draw.X}, {draw.Y}) (error code {_pdf.LastErrorCode()}).";
return false;
}
}

if (string.IsNullOrEmpty(draw.Text))
{
continue;
@@ -290,7 +398,114 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
}
}

_pagesInCurrentBatch++;
error = null;
return true;
}

/// <summary>DPI a PDF background page is rasterized at. See <see cref="TryDrawBackground"/>.</summary>
private const int PdfBackgroundDpi = 300;

private readonly Dictionary<(string Path, int Page), string> _rasterizedPdfPages = new();

/// <summary>Draws the record's background stretched over the whole page, before any text. An
/// image is added once per Debenu instance and re-selected afterwards. A PDF page is drawn as a
/// <see cref="PdfBackgroundDpi"/>-DPI raster of that page: Debenu 10.13's vector path for this
/// (<c>CapturePage</c> + <c>DrawCapturedPage</c>, also via the Document Analyzer variants) was
/// probed against the real DLL with the project's key and captured fine but every draw call
/// returned 0 (error code 0) and drew nothing, so the page is instead rendered once to a temp
/// PNG (<c>RenderPageToFile</c>, confirmed working) and then follows the image path � cached per
/// (file, page) so a background reused across thousands of records is rasterized once.</summary>
private bool TryDrawBackground(PageBackground background, double pageWidth, double pageHeight, out string? error)
{
if (!File.Exists(background.FilePath))
{
error = $"Background file not found: '{background.FilePath}'.";
return false;
}

var imagePath = background.FilePath;
if (background.IsPdf && !TryRasterizePdfPage(background, out imagePath!, out error))
{
return false;
}

if (_backgroundImages.TryGetValue(imagePath, out var imageId))
{
if (_pdf.SelectImage(imageId) == 0)
{
error = $"Failed to select background image '{imagePath}' (error code {_pdf.LastErrorCode()}).";
return false;
}
}
else
{
imageId = _pdf.AddImageFromFile(imagePath, Options: 0);
if (imageId == 0)
{
error = $"Failed to load background image '{imagePath}' (error code {_pdf.LastErrorCode()}).";
return false;
}

_backgroundImages[imagePath] = imageId;
}

if (_pdf.DrawImage(0, pageHeight, pageWidth, pageHeight) == 0)
{
error = $"Failed to draw background image '{imagePath}' (error code {_pdf.LastErrorCode()}).";
return false;
}

error = null;
return true;
}

private bool TryRasterizePdfPage(PageBackground background, out string? pngPath, out string? error)
{
var key = (background.FilePath, background.Page);
if (_rasterizedPdfPages.TryGetValue(key, out pngPath))
{
error = null;
return true;
}

// A scratch document keeps the render document selected/untouched while the source PDF is
// opened; it is dropped again straight after.
var renderDocument = _pdf.SelectedDocument();
var scratchDocument = _pdf.NewDocument();
try
{
if (_pdf.LoadFromFile(background.FilePath, string.Empty) == 0)
{
error = $"Failed to open background PDF '{background.FilePath}' (error code {_pdf.LastErrorCode()}).";
pngPath = null;
return false;
}

var pageCount = _pdf.PageCount();
if (background.Page > pageCount)
{
error = $"Background PDF '{background.FilePath}' has no page {background.Page} (it has {pageCount}).";
pngPath = null;
return false;
}

Directory.CreateDirectory(_batchTempDirectory);
pngPath = Path.Combine(_batchTempDirectory, $"background-{_rasterizedPdfPages.Count:D4}.png");
if (_pdf.RenderPageToFile(PdfBackgroundDpi, background.Page, Options: 0, pngPath) == 0)
{
error = $"Failed to render page {background.Page} of background PDF '{background.FilePath}' " +
$"(error code {_pdf.LastErrorCode()}).";
pngPath = null;
return false;
}
}
finally
{
_pdf.SelectDocument(renderDocument);
_pdf.RemoveDocument(scratchDocument);
}

_rasterizedPdfPages[key] = pngPath;
error = null;
return true;
}
@@ -351,6 +566,7 @@ public sealed class DebenuPdfRenderer : IPdfRenderer

_pdf = reopened!;
_fontHandles.Clear();
_backgroundImages.Clear();
_firstPageUsed = false;
_pagesInCurrentBatch = 0;
error = null;


+ 22
- 1
code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs Voir le fichier

@@ -10,6 +10,27 @@ public interface IPdfRenderer : IDisposable
/// <summary>Starts the next page at the given size (points) and draws its content. The
/// first call reuses the document's already-existing first page rather than creating a new
/// one — see <see cref="DebenuPdfRenderer"/> for why.</summary>
bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error);
bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error)
=> AddPage(pageWidth, pageHeight, draws, background: null, out error);

/// <summary>As above, additionally drawing <paramref name="background"/> (when non-null)
/// behind every text draw, stretched to the page size.</summary>
bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error);
/// <summary>True when <see cref="BeginPdfBackgroundSequence"/> is supported (the real Debenu
/// renderer); test fakes keep the default <c>false</c>.</summary>
bool SupportsPdfBackgroundSequence => false;

/// <summary>Vector PDF background: builds the output document up front as a copy of
/// <paramref name="pdfPath"/> pages in the given record order (<paramref name="pagesInRecordOrder"/>[i]
/// is the 1-based source page for record i; pages must all be different, since copies of the same
/// page share one content stream). Every following <see cref="AddPage(double,double,IReadOnlyList{TextDraw},PageBackground?,out string?)"/>
/// then draws that record's text onto its own page, over the original page content, instead of
/// starting a new page. Call before the first AddPage.</summary>
bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error)
{
error = "PDF background sequences are not supported by this renderer.";
return false;
}

bool Save(string outputPath, out string? error);
}

+ 182
- 5
code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs Voir le fichier

@@ -28,6 +28,7 @@ public static class RenderEngine
.Concat(template.AddressControls.SelectMany(c => c.Lines).SelectMany(l => l.Runs))
.Where(r => r.IsField)
.Select(r => r.ColumnName!)
.Concat(BackgroundColumns(template.Background))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(column => !csvHeaders.Contains(column, StringComparer.OrdinalIgnoreCase))
.ToList();
@@ -40,12 +41,62 @@ public static class RenderEngine
.ToArray());
}

// Vector PDF background: when every record uses a different page of one PDF, the renderer
// builds the output as a page-ordered copy of that PDF and each record's text is drawn onto
// its own page. Anything else (repeated pages, several files, images) keeps the per-page
// path below, where PDF pages are drawn as a raster.
var sequenceActive = false;
if (template.Background is not null && renderer.SupportsPdfBackgroundSequence)
{
var materialized = records as IReadOnlyList<IReadOnlyDictionary<string, string>> ?? records.ToList();
records = materialized;
progress?.Info($"Read {materialized.Count} record(s) from the CSV.");
if (TryPlanPdfSequence(template.Background, materialized, out var pdfPath, out var pages, out var planNote))
{
progress?.Info($"Background: vector PDF pages from '{pdfPath}' ({pages!.Count} distinct page(s), copied in record order).");
if (!renderer.BeginPdfBackgroundSequence(pdfPath!, pages!, out var beginError))
{
return RenderResult.Failure(0, beginError!);
}

progress?.Info("Background PDF pages copied; drawing text onto them.");
sequenceActive = true;
}
else
{
progress?.Info($"Background: per-page drawing ({planNote}); PDF pages are drawn as 300 DPI images.");
}
}
else if (template.Background is not null)
{
progress?.Info("Background: per-page drawing.");
}

progress?.Info("Rendering pages...");

var recordCount = 0;
foreach (var record in records)
{
var draws = BuildDraws(template, record);

if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, out var pageError))
if (sequenceActive)
{
if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, null, out var sequencePageError))
{
return RenderResult.Failure(recordCount, $"Record {recordCount + 1}: {sequencePageError}");
}

recordCount++;
progress?.ReportRenderProgress(recordCount);
continue;
}

if (!TryResolveBackground(template.Background, record, out var background, out var backgroundError))
{
return RenderResult.Failure(recordCount, $"Record {recordCount + 1}: {backgroundError}");
}

if (!renderer.AddPage(template.PageWidth, template.PageHeight, draws, background, out var pageError))
{
return RenderResult.Failure(recordCount, $"Record {recordCount + 1}: {pageError}");
}
@@ -59,6 +110,7 @@ public static class RenderEngine
return RenderResult.Failure(0, "CSV has no data rows — nothing to render.");
}

progress?.Info($"Rendered {recordCount} page(s); saving PDF to '{outputPath}'...");
if (!renderer.Save(outputPath, out var saveError))
{
return RenderResult.Failure(recordCount, saveError!);
@@ -67,6 +119,127 @@ public static class RenderEngine
return RenderResult.Success(recordCount);
}

/// <summary>True when every record resolves to a PDF background: all the same file, all
/// different pages. Any per-record problem (bad page value, blank file) returns false so the
/// normal path reports it against the right record.</summary>
private static bool TryPlanPdfSequence(
TemplateBackground background,
IReadOnlyList<IReadOnlyDictionary<string, string>> records,
out string? pdfPath,
out List<int>? pages,
out string reason)
{
pdfPath = null;
pages = null;
reason = string.Empty;
if (records.Count == 0)
{
reason = "no records";
return false;
}

var seen = new HashSet<int>();
var list = new List<int>(records.Count);
var recordNumber = 0;
foreach (var record in records)
{
recordNumber++;
if (!TryResolveBackground(background, record, out var resolved, out var resolveError))
{
reason = $"record {recordNumber}: {resolveError}";
return false;
}

if (resolved is null)
{
reason = $"record {recordNumber} has no background file";
return false;
}

if (!resolved.IsPdf)
{
reason = $"record {recordNumber} uses an image, not a PDF";
return false;
}

if (pdfPath is not null && !string.Equals(pdfPath, resolved.FilePath, StringComparison.OrdinalIgnoreCase))
{
reason = $"record {recordNumber} uses a different PDF file";
return false;
}

if (!seen.Add(resolved.Page))
{
reason = $"page {resolved.Page} is used by more than one record (first repeat at record {recordNumber})";
return false;
}

pdfPath ??= resolved.FilePath;
list.Add(resolved.Page);
}

pages = list;
return true;
}

private static IEnumerable<string> BackgroundColumns(TemplateBackground? background)
{
if (background?.FileColumn is { } fileColumn)
{
yield return fileColumn;
}

if (background?.PageColumn is { } pageColumn)
{
yield return pageColumn;
}
}

/// <summary>Resolves this record's background: a static <c>file</c>, or the record's
/// <c>fileColumn</c> value (blank = no background for this record). A PDF's page is the static
/// <c>page</c> or the record's <c>pageColumn</c> value, which must be a whole number &gt;= 1.
/// Relative paths resolve against the template's folder.</summary>
private static bool TryResolveBackground(
TemplateBackground? template, IReadOnlyDictionary<string, string> record,
out PageBackground? background, out string? error)
{
background = null;
error = null;
if (template is null)
{
return true;
}

var file = (template.FileColumn is null ? template.File : record[template.FileColumn])?.Trim();
if (string.IsNullOrEmpty(file))
{
return true;
}

if (!TemplateBackground.IsSupportedExtension(file))
{
error = $"Background file '{file}' has an unsupported type (expected .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff).";
return false;
}

var path = Path.IsPathRooted(file) ? file : Path.Combine(template.BaseDirectory, file);
var isPdf = TemplateBackground.IsPdf(path);
var page = template.Page;
if (isPdf && template.PageColumn is not null)
{
var raw = record[template.PageColumn].Trim();
if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out page) || page < 1)
{
error = $"Background page value '{raw}' in column '{template.PageColumn}' is not a whole number >= 1.";
return false;
}
}

background = new PageBackground(path, isPdf, page);
return true;
}

/// <summary>Resolves every element's text for this record, then runs the address-line
/// collapse math (Sprint 4: "Collapse blank optional address lines consistently") to decide
/// which lines are actually visible and at what (possibly shifted) Y — a blank line whose
@@ -111,7 +284,8 @@ public static class RenderEngine
UsesFixedRotationPivot: element.IsDynamic,
Width: element.Width,
Height: element.Height,
Color: element.Color),
Color: element.Color,
FillColor: element.HasFill ? element.FillColor : null),
}));
}

@@ -141,11 +315,11 @@ public static class RenderEngine
var resolvedText = new string[control.Lines.Count];
var collapseLines = new AddressLineCollapser.Line[control.Lines.Count];

var y = control.Y;
var y = control.TopBaselineY;
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
resolvedText[i] = ResolveText(line.Runs, record);
resolvedText[i] = control.TextCase.Apply(ResolveText(line.Runs, record));
collapseLines[i] = new AddressLineCollapser.Line(
control.X,
y,
@@ -154,6 +328,9 @@ public static class RenderEngine
}

var resolved = AddressLineCollapser.Resolve(collapseLines);
var bottomShift = control.VerticalAnchor == AddressVerticalAnchor.Bottom
? AddressLineCollapser.BottomAnchorShift(resolved, control.Y)
: 0;
var angle = control.RotationAngle;
var pivot = angle != 0 ? control.BoxCenter : default;

@@ -166,7 +343,7 @@ public static class RenderEngine

var line = control.Lines[i];
var drawX = control.X;
var drawY = resolved[i].EffectiveY;
var drawY = resolved[i].EffectiveY + bottomShift;
if (angle != 0)
{
(drawX, drawY) = RotatePointAroundPivot(drawX, drawY, pivot, angle);


+ 25
- 3
code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs Voir le fichier

@@ -19,7 +19,9 @@ public sealed record TemplateAddressControl(
IReadOnlyList<TemplateAddressControlLine> Lines,
double LineSpacingMultiplier = TemplateAddressControl.DefaultLineSpacingMultiplier,
int RenderOrder = 0,
double RotationAngle = 0)
double RotationAngle = 0,
AddressTextCase TextCase = AddressTextCase.None,
AddressVerticalAnchor VerticalAnchor = AddressVerticalAnchor.Top)
{
public const double DefaultLineSpacingMultiplier = 1.25;

@@ -47,6 +49,26 @@ public sealed record TemplateAddressControl(
}
}

/// <summary>Distance from the first line's baseline down to the last line's baseline.</summary>
public double LastLineOffset
{
get
{
var total = 0.0;
for (var i = 0; i < Lines.Count - 1; i++)
{
total += Lines[i].Size * LineSpacingMultiplier;
}

return total;
}
}

/// <summary>Baseline of the first line of the full, uncollapsed authored block. Equals
/// <see cref="Y"/> for a top-anchored control; for a bottom-anchored control <see cref="Y"/> is
/// the last line's baseline, so the block sits <see cref="LastLineOffset"/> higher.</summary>
public double TopBaselineY => VerticalAnchor == AddressVerticalAnchor.Bottom ? Y + LastLineOffset : Y;

/// <summary>The fixed pivot the whole control rotates around — the center of the same
/// authored box the designer canvas draws as this control's selection border (top edge at
/// <c>Y + tallest line's font size</c>, bottom edge at <c>Y - Height</c>, so the visible box
@@ -56,8 +78,8 @@ public sealed record TemplateAddressControl(
get
{
var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.Size);
var top = Y + topExtension;
var bottom = Y - Height;
var top = TopBaselineY + topExtension;
var bottom = TopBaselineY - Height;
return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0));
}
}


+ 27
- 0
code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs Voir le fichier

@@ -0,0 +1,27 @@
namespace EnvelopeRenderer.Cli.Render;

/// <summary>A page background (image or PDF page) drawn behind every output page, stretched to the
/// page size. The file comes from exactly one of <see cref="File"/> (static) or
/// <see cref="FileColumn"/> (per-record CSV value); for a PDF, the page number comes from
/// <see cref="Page"/> (static, default 1) or <see cref="PageColumn"/> (per-record). Relative file
/// paths resolve against <see cref="BaseDirectory"/> (the template's own folder). Whether a file is
/// a PDF or an image is decided by its extension.</summary>
public sealed record TemplateBackground(
string? File,
string? FileColumn,
int Page,
string? PageColumn,
string BaseDirectory)
{
public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff" };

public static bool IsSupportedExtension(string path)
=> IsPdf(path) || ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);

public static bool IsPdf(string path)
=> string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase);
}

/// <summary>One record's resolved background for the renderer: an absolute-or-relative-resolved
/// <see cref="FilePath"/>, whether it is a PDF, and (PDF only) the 1-based <see cref="Page"/>.</summary>
public sealed record PageBackground(string FilePath, bool IsPdf, int Page);

+ 2
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs Voir le fichier

@@ -10,7 +10,8 @@ public sealed record TemplateDocument(
double PageWidth,
double PageHeight,
IReadOnlyList<TemplateElement> Elements,
IReadOnlyList<TemplateAddressControl> AddressControls)
IReadOnlyList<TemplateAddressControl> AddressControls,
TemplateBackground? Background = null)
{
public TemplateDocument(double pageWidth, double pageHeight, IReadOnlyList<TemplateElement> elements)
: this(pageWidth, pageHeight, elements, Array.Empty<TemplateAddressControl>())


+ 8
- 1
code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs Voir le fichier

@@ -39,8 +39,15 @@ public sealed record TemplateElement(
int RenderOrder = 0,
double? Width = null,
double? Height = null,
PdfColor Color = default)
PdfColor Color = default,
PdfColor? FillColor = null)
{
/// <summary>True for a filled rectangle element: <see cref="FillColor"/> plus an explicit
/// <see cref="Width"/> and <see cref="Height"/> box. The rectangle is drawn first and the
/// element's own text (which may be empty) is drawn over it, so a blank element with a fill is
/// a pure cover-up and any element with a higher render order draws on top of it.</summary>
public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0;

/// <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
/// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when


+ 120
- 3
code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs Voir le fichier

@@ -52,6 +52,8 @@ public static class TemplateXmlParser
errors.Add("Template has no <text> or <addressControl> elements — nothing to render.");
}

var background = ParseBackground(root, path, errors);

var textIndex = 0;
var addressIndex = 0;
var renderOrder = 0;
@@ -112,6 +114,11 @@ public static class TemplateXmlParser
{
errors.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
}
else if (!hasColumn && !hasStaticText && node.Attribute("fill") is not null)
{
// A filled rectangle with no text of its own (a pure cover-up).
runs = new[] { new TemplateTextRun(string.Empty, null) };
}
else if (!hasColumn && !hasStaticText)
{
errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
@@ -144,6 +151,31 @@ public static class TemplateXmlParser
var width = ParseOptionalPositiveDouble(node, "width", label, errors);
var height = ParseOptionalPositiveDouble(node, "height", label, errors);

// Optional filled-rectangle background `fill="#RRGGBB"`: needs an explicit width and
// height box, and is not rotated.
PdfColor? fill = null;
var fillRaw = (string?)node.Attribute("fill");
if (!string.IsNullOrWhiteSpace(fillRaw))
{
if (!PdfColor.TryParseHex(fillRaw, out var parsedFill))
{
errors.Add($"{label} has an invalid 'fill' value: '{fillRaw}' (expected #RRGGBB).");
}
else
{
fill = parsedFill;
if (width is null || height is null)
{
errors.Add($"{label} has a 'fill' but needs both 'width' and 'height' for the rectangle.");
}

if (angle is not null && angle != 0)
{
errors.Add($"{label} has a 'fill' and an 'angle'; a filled rectangle cannot be rotated.");
}
}
}

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null
|| angle is null)
{
@@ -161,7 +193,8 @@ public static class TemplateXmlParser
renderOrder,
width,
height,
color));
color,
fill));
renderOrder++;
}

@@ -170,7 +203,7 @@ public static class TemplateXmlParser
return TemplateParseResult.Failure(errors.ToArray());
}

return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements, addressControls));
return TemplateParseResult.Success(new TemplateDocument(pageWidth!.Value, pageHeight!.Value, elements, addressControls, background));
}

private static TemplateAddressControl? ParseAddressControl(
@@ -194,6 +227,20 @@ public static class TemplateXmlParser
// <text>'s own `angle` attribute.
var angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0);

var textCase = AddressTextCase.None;
var caseRaw = (string?)node.Attribute("case");
if (!string.IsNullOrWhiteSpace(caseRaw) && !TryParseEnum(caseRaw, out textCase))
{
errors.Add($"{label} has an invalid 'case' value: '{caseRaw}' (expected 'none', 'upper' or 'lower').");
}

var verticalAnchor = AddressVerticalAnchor.Top;
var anchorRaw = (string?)node.Attribute("verticalAnchor");
if (!string.IsNullOrWhiteSpace(anchorRaw) && !TryParseEnum(anchorRaw, out verticalAnchor))
{
errors.Add($"{label} has an invalid 'verticalAnchor' value: '{anchorRaw}' (expected 'top' or 'bottom').");
}

var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList();
if (lineNodes.Count == 0)
{
@@ -216,7 +263,77 @@ public static class TemplateXmlParser
}

return new TemplateAddressControl(
x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value);
x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value,
textCase, verticalAnchor);
}

private static bool TryParseEnum<T>(string raw, out T value) where T : struct, Enum
=> Enum.TryParse(raw.Trim(), ignoreCase: true, out value) && Enum.IsDefined(value);

/// <summary>Optional <c>&lt;background file="..." | fileColumn="..." [page="N" | pageColumn="..."] /&gt;</c>
/// directly under the root. A static <c>file</c> must have a supported extension (.pdf or a common
/// image type) and is not checked for existence here � the renderer does that with a clear error.</summary>
private static TemplateBackground? ParseBackground(XElement root, string templatePath, List<string> errors)
{
var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList();
if (nodes.Count == 0)
{
return null;
}

const string label = "<background>";
if (nodes.Count > 1)
{
errors.Add("Template has more than one <background> element � use at most one.");
return null;
}

var node = nodes[0];
var file = ((string?)node.Attribute("file"))?.Trim();
var fileColumn = ((string?)node.Attribute("fileColumn"))?.Trim();
var pageColumn = ((string?)node.Attribute("pageColumn"))?.Trim();
var hasFile = !string.IsNullOrEmpty(file);
var hasFileColumn = !string.IsNullOrEmpty(fileColumn);
var hasPageColumn = !string.IsNullOrEmpty(pageColumn);
var ok = true;

if (hasFile == hasFileColumn)
{
errors.Add($"{label} needs exactly one of 'file' or 'fileColumn'.");
ok = false;
}
else if (hasFile && !TemplateBackground.IsSupportedExtension(file!))
{
errors.Add($"{label} has an unsupported 'file' type: '{file}' (expected .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff).");
ok = false;
}

var page = 1;
var pageRaw = (string?)node.Attribute("page");
if (pageRaw is not null && hasPageColumn)
{
errors.Add($"{label} has both 'page' and 'pageColumn' � use only one.");
ok = false;
}
else if (pageRaw is not null
&& (!int.TryParse(pageRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out page) || page < 1))
{
errors.Add($"{label} has an invalid 'page' value: '{pageRaw}' (expected a whole number >= 1).");
ok = false;
}

if (!ok)
{
return null;
}

var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? string.Empty;
return new TemplateBackground(
hasFile ? file : null,
hasFileColumn ? fileColumn : null,
page,
hasPageColumn ? pageColumn : null,
baseDirectory);
}

/// <summary>Optional <c>color="#RRGGBB"</c>; absent means black (the pre-color behavior).</summary>


+ 5
- 1
code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs Voir le fichier

@@ -14,6 +14,9 @@ namespace EnvelopeRenderer.Cli.Render;
/// <see cref="Text"/> wraps within this width, clipping any wrapped content that exceeds
/// <paramref name="Height"/>, instead of drawing as one unbounded line at the (X, Y) baseline.
/// <c>null</c> (the default) preserves every pre-Sprint-9 render exactly.</param>
/// <param name="FillColor">When set together with <paramref name="Width"/> and
/// <paramref name="Height"/>, a filled rectangle covering that box (bottom-left at X, Y) is drawn
/// before this draw's text, which may be empty. Not rotated.</param>
/// <param name="Height">Paired with <paramref name="Width"/> — see its remarks.</param>
public sealed record TextDraw(
double X,
@@ -25,4 +28,5 @@ public sealed record TextDraw(
bool UsesFixedRotationPivot = false,
double? Width = null,
double? Height = null,
PdfColor Color = default);
PdfColor Color = default,
PdfColor? FillColor = null);

+ 30
- 3
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs Voir le fichier

@@ -21,6 +21,12 @@ public sealed class AddressControlLayout
/// <see cref="BoxCenter"/>.</summary>
public double RotationAngle { get; set; }

/// <summary>Letter-case transform applied to every line resolved text.</summary>
public AddressTextCase TextCase { get; set; } = AddressTextCase.None;

/// <summary>Which edge <see cref="Y"/> pins to - see <see cref="AddressVerticalAnchor"/>.</summary>
public AddressVerticalAnchor VerticalAnchor { get; set; } = AddressVerticalAnchor.Top;

public List<AddressControlLineLayout> Lines { get; } = new();

public static AddressControlLayout CreateDefault(double x, double y, int zOrder = 0)
@@ -70,9 +76,30 @@ public sealed class AddressControlLayout
return true;
}

/// <summary>Distance from the first line baseline down to the last line baseline.</summary>
public double LastLineOffset
{
get
{
var total = 0.0;
for (var i = 0; i < Lines.Count - 1; i++)
{
total += Lines[i].FontSize * LineSpacingMultiplier;
}

return total;
}
}

/// <summary>Baseline of the first line of the full, uncollapsed authored block: <see cref="Y"/>
/// for a top-anchored control; <see cref="Y"/> + <see cref="LastLineOffset"/> for a
/// bottom-anchored one (where <see cref="Y"/> is the last line baseline). All box, pivot and
/// line-position geometry uses this, never <see cref="Y"/> directly.</summary>
public double TopBaselineY => VerticalAnchor == AddressVerticalAnchor.Bottom ? Y + LastLineOffset : Y;

public double BaselineYForLine(int index)
{
var y = Y;
var y = TopBaselineY;
for (var i = 0; i < index && i < Lines.Count; i++)
{
y -= Lines[i].FontSize * LineSpacingMultiplier;
@@ -112,8 +139,8 @@ public sealed class AddressControlLayout
get
{
var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.FontSize);
var top = Y + topExtension;
var bottom = Y - Height;
var top = TopBaselineY + topExtension;
var bottom = TopBaselineY - Height;
return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0));
}
}


+ 29
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs Voir le fichier

@@ -0,0 +1,29 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>Letter-case transform for every line of an Address Control, applied to the resolved
/// text (literal and field alike). <see cref="None"/> leaves text as authored / as read.</summary>
public enum AddressTextCase
{
None = 0,
Upper = 1,
Lower = 2,
}

/// <summary>Which edge of the Address Control block its <c>Y</c> anchor pins to: <see cref="Top"/>
/// = first line baseline (block grows down); <see cref="Bottom"/> = last visible line baseline
/// (block grows up).</summary>
public enum AddressVerticalAnchor
{
Top = 0,
Bottom = 1,
}

public static class AddressTextCaseExtensions
{
public static string Apply(this AddressTextCase textCase, string text) => textCase switch
{
AddressTextCase.Upper => text.ToUpperInvariant(),
AddressTextCase.Lower => text.ToLowerInvariant(),
_ => text,
};
}

+ 1
- 1
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs Voir le fichier

@@ -21,7 +21,7 @@ public static class AddressControlRotateHandle
public static (double X, double Y) LocalOrigin(AddressControlLayout control)
{
var maxFontSize = control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
return (control.X + (control.Width / 2.0), control.Y + maxFontSize);
return (control.X + (control.Width / 2.0), control.TopBaselineY + maxFontSize);
}

/// <summary>The handle's local (unrotated) position: the box's own top-center plus


+ 16
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs Voir le fichier

@@ -61,4 +61,20 @@ public static class AddressLineCollapser

return result;
}

/// <summary>For a bottom-anchored Address Control: the vertical shift that puts the last
/// visible line baseline on <paramref name="anchorY"/> (negative = move down). 0 when no line
/// is visible.</summary>
public static double BottomAnchorShift(IReadOnlyList<Resolved> resolved, double anchorY)
{
for (var i = resolved.Count - 1; i >= 0; i--)
{
if (resolved[i].Visible)
{
return anchorY - resolved[i].EffectiveY;
}
}

return 0;
}
}

+ 69
- 1
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs Voir le fichier

@@ -36,6 +36,7 @@ public sealed class CanvasElementEditor
private (double Dx, double Dy)? _dragOffset;
private bool _isRotating;
private bool _isResizingWidth;
private bool _isResizingHeight;

/// <summary>Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer
/// alongside <see cref="Selected"/>, not a replacement — every existing single-select code
@@ -323,6 +324,7 @@ public sealed class CanvasElementEditor
_dragOffset = null;
_isRotating = false;
_isResizingWidth = false;
_isResizingHeight = false;
return removed;
}

@@ -371,12 +373,62 @@ public sealed class CanvasElementEditor
_dragOffset = null;
_isRotating = false;
_isResizingWidth = false;
_isResizingHeight = false;
}

public bool IsDragging => _dragOffset is not null;

public bool IsResizingWidth => _isResizingWidth;

/// <summary>True during a width or a height resize-drag.</summary>
public bool IsResizing => _isResizingWidth || _isResizingHeight;

/// <summary>World-space position of the selected filled rectangle's height handle (top-edge
/// midpoint, rotated like the other handles), or <c>null</c> when nothing is selected or the
/// selection is not a filled rectangle (<see cref="TextElementLayout.HasFill"/>). Dragging it
/// sets <see cref="TextElementLayout.Height"/> just as the right-edge handle sets Width.</summary>
public (double X, double Y)? HeightHandlePosition()
{
if (Selected is not { HasFill: true })
{
return null;
}

var (width, height) = _measureText(Selected);
var pivot = RotationPivot(Selected, width, height);
return RotatePointAroundPivot(
Selected.X + (width / 2.0),
Selected.Y + height,
pivot,
Selected.RotationAngle);
}

public bool HitTestHeightHandle(double xPoints, double yPoints)
{
var handle = HeightHandlePosition();
if (handle is null)
{
return false;
}

var dx = xPoints - handle.Value.X;
var dy = yPoints - handle.Value.Y;
return (dx * dx) + (dy * dy) <= HandleHitRadiusPoints * HandleHitRadiusPoints;
}

/// <summary>Starts a height resize-drag of the selected filled rectangle. No-op otherwise.</summary>
public void BeginHeightResizeDrag()
{
if (Selected is not { HasFill: true })
{
return;
}

_isResizingHeight = true;
_isResizingWidth = false;
_dragOffset = null;
}

/// <summary>Post-Sprint-9 user-requested change (2026-10-27): the current world-space
/// (canvas-point) position of the selected element's width resize handle, or <c>null</c> if
/// nothing is selected. Sits at the right-edge midpoint (<see cref="TextElementLayout.X"/> +
@@ -448,11 +500,27 @@ public sealed class CanvasElementEditor
/// hasn't begun.</summary>
public void ResizeDragTo(double xPoints, double yPoints)
{
if (Selected is null || !_isResizingWidth)
if (Selected is null || !IsResizing)
{
return;
}

if (_isResizingHeight)
{
var (boxWidth, boxHeight) = _measureText(Selected);
var boxPivot = RotationPivot(Selected, boxWidth, boxHeight);
var (_, localY) = Selected.RotationAngle == 0
? (xPoints, yPoints)
: RotatePointAroundPivot(xPoints, yPoints, boxPivot, -Selected.RotationAngle);
var newHeight = localY - Selected.Y;
if (newHeight > 0)
{
Selected.Height = newHeight;
}

return;
}

var (width, height) = _measureText(Selected);
var pivot = RotationPivot(Selected, width, height);
var (localX, _) = Selected.RotationAngle == 0


+ 63
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs Voir le fichier

@@ -0,0 +1,63 @@
using System.Globalization;

namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>A template-level page background (image or PDF page) drawn behind every output page.
/// The file is either a fixed path (<see cref="File"/>) or read per record from a CSV column
/// (<see cref="FileColumn"/>) - exactly one is set. For a PDF the page is a fixed number
/// (<see cref="Page"/>) or read per record from <see cref="PageColumn"/>. Mirrors the CLI
/// <c>TemplateBackground</c> (independently implemented, per the desktop/CLI split).</summary>
public sealed class PageBackgroundLayout
{
public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff" };

public string? File { get; set; }
public string? FileColumn { get; set; }
public int Page { get; set; } = 1;
public string? PageColumn { get; set; }

public static bool IsPdf(string path)
=> string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase);

public static bool IsSupportedExtension(string path)
=> IsPdf(path) || ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);

/// <summary>The file to show for one record: the fixed <see cref="File"/>, or the record
/// <see cref="FileColumn"/> value; <c>null</c> when blank/unknown. Relative paths resolve against
/// <paramref name="baseDirectory"/> (the template folder) when given.</summary>
public string? ResolveFile(IReadOnlyDictionary<string, string>? record, string? baseDirectory)
{
string? value = File;
if (FileColumn is not null)
{
value = record is not null && record.TryGetValue(FileColumn, out var v) ? v : null;
}

value = value?.Trim();
if (string.IsNullOrEmpty(value))
{
return null;
}

return Path.IsPathRooted(value) || string.IsNullOrEmpty(baseDirectory)
? value
: Path.Combine(baseDirectory, value);
}

/// <summary>The 1-based PDF page for one record, or <c>null</c> when a per-record value is not a
/// whole number &gt;= 1.</summary>
public int? ResolvePage(IReadOnlyDictionary<string, string>? record)
{
if (PageColumn is null)
{
return Page;
}

return record is not null
&& record.TryGetValue(PageColumn, out var raw)
&& int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var page)
&& page >= 1
? page
: null;
}
}

+ 36
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs Voir le fichier

@@ -0,0 +1,36 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>Picks the next or previous item when stepping through the canvas items with the
/// keyboard (Tab / Shift+Tab), so an item hidden behind another one can still be selected. Items
/// are visited in a fixed order - z-order (bottom first), then top-to-bottom, then left-to-right -
/// and the walk wraps around at both ends. Framework-free so it is unit tested.</summary>
public static class SelectionCycler
{
public readonly record struct Item(Guid Id, int ZOrder, double X, double Y);

/// <summary>The id to select next, or <c>null</c> when there are no items. With no current
/// selection (or one that is not in <paramref name="items"/>), forward picks the first item and
/// backward picks the last.</summary>
public static Guid? Next(IEnumerable<Item> items, Guid? currentId, bool forward)
{
var ordered = items
.OrderBy(i => i.ZOrder)
.ThenByDescending(i => i.Y)
.ThenBy(i => i.X)
.ThenBy(i => i.Id)
.ToList();
if (ordered.Count == 0)
{
return null;
}

var index = currentId is { } id ? ordered.FindIndex(i => i.Id == id) : -1;
if (index < 0)
{
return forward ? ordered[0].Id : ordered[^1].Id;
}

var next = forward ? (index + 1) % ordered.Count : (index - 1 + ordered.Count) % ordered.Count;
return ordered[next].Id;
}
}

+ 3
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs Voir le fichier

@@ -19,6 +19,9 @@ public sealed class TemplateLayoutDocument
/// element kind is introduced.</summary>
public List<AddressControlLayout> AddressControls { get; } = new();

/// <summary>Optional page background image / PDF page drawn behind every page; null = none.</summary>
public PageBackgroundLayout? Background { get; set; }

public TemplateLayoutDocument(CanvasSettings canvas)
{
Canvas = canvas;


+ 149
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs Voir le fichier

@@ -35,6 +35,11 @@ public static class TemplateLayoutXmlSerializer
var addressItems = document.AddressControls
.Select(c => (ZOrder: c.ZOrder, Node: CreateAddressControl(c)));

if (document.Background is { } background)
{
root.Add(CreateBackground(background));
}

foreach (var item in textItems.Concat(addressItems).OrderBy(i => i.ZOrder))
{
root.Add(item.Node);
@@ -46,6 +51,87 @@ public static class TemplateLayoutXmlSerializer
new XDocument(root).Save(path);
}

private static XElement CreateBackground(PageBackgroundLayout background)
{
var node = new XElement("background");
if (background.FileColumn is not null)
{
node.Add(new XAttribute("fileColumn", background.FileColumn));
}
else if (background.File is not null)
{
node.Add(new XAttribute("file", background.File));
}

if (background.PageColumn is not null)
{
node.Add(new XAttribute("pageColumn", background.PageColumn));
}
else if (background.Page != 1)
{
node.Add(new XAttribute("page", background.Page.ToString(CultureInfo.InvariantCulture)));
}

return node;
}

private static PageBackgroundLayout? ParseBackground(XElement root, List<string> errors)
{
var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList();
if (nodes.Count == 0)
{
return null;
}

const string label = "<background>";
if (nodes.Count > 1)
{
errors.Add("Template has more than one <background> element - use at most one.");
return null;
}

var node = nodes[0];
var file = ((string?)node.Attribute("file"))?.Trim();
var fileColumn = ((string?)node.Attribute("fileColumn"))?.Trim();
var pageColumn = ((string?)node.Attribute("pageColumn"))?.Trim();
var hasFile = !string.IsNullOrEmpty(file);
var hasFileColumn = !string.IsNullOrEmpty(fileColumn);
var ok = true;

if (hasFile == hasFileColumn)
{
errors.Add($"{label} needs exactly one of 'file' or 'fileColumn'.");
ok = false;
}
else if (hasFile && !PageBackgroundLayout.IsSupportedExtension(file!))
{
errors.Add($"{label} has an unsupported 'file' type: '{file}'.");
ok = false;
}

var page = 1;
var pageRaw = (string?)node.Attribute("page");
if (pageRaw is not null
&& (!int.TryParse(pageRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out page) || page < 1))
{
errors.Add($"{label} has an invalid 'page' value: '{pageRaw}'.");
ok = false;
}

if (!ok)
{
return null;
}

return new PageBackgroundLayout
{
File = hasFile ? file : null,
FileColumn = hasFileColumn ? fileColumn : null,
Page = page,
PageColumn = string.IsNullOrEmpty(pageColumn) ? null : pageColumn,
};
}

private static XElement CreateTextElement(TextElementLayout element)
{
var textElement = new XElement(
@@ -75,12 +161,24 @@ public static class TemplateLayoutXmlSerializer
// template with no wrap/clip elements round-trips byte-for-byte identical to how it
// looked before this story — matching the same "optional, default preserves old behavior"
// convention as `collapsible`/`angle` above.
// Width and Height are independent (Height is an optional clip ceiling), so each is
// written only when set - a width-only element must not throw here.
if (element.HasBox)
{
textElement.Add(new XAttribute("width", element.Width!.Value.ToString(CultureInfo.InvariantCulture)));
}

if (element.HasHeightClip)
{
textElement.Add(new XAttribute("height", element.Height!.Value.ToString(CultureInfo.InvariantCulture)));
}

// Filled-rectangle background; the render CLI needs width and height alongside it.
if (element.FillColor is { } fill)
{
textElement.Add(new XAttribute("fill", fill.ToHex()));
}

AddRuns(textElement, element.Runs, useLegacySingleRunShape: true);
return textElement;
}
@@ -110,6 +208,16 @@ public static class TemplateLayoutXmlSerializer
node.Add(new XAttribute("angle", control.RotationAngle.ToString(CultureInfo.InvariantCulture)));
}

if (control.TextCase != AddressTextCase.None)
{
node.Add(new XAttribute("case", control.TextCase.ToString().ToLowerInvariant()));
}

if (control.VerticalAnchor != AddressVerticalAnchor.Top)
{
node.Add(new XAttribute("verticalAnchor", control.VerticalAnchor.ToString().ToLowerInvariant()));
}

foreach (var line in control.Lines)
{
var lineNode = new XElement(
@@ -193,6 +301,8 @@ public static class TemplateLayoutXmlSerializer
errorList.Add($"<envelopeTemplate> has an unrecognized 'canvasUnit' value: '{unitRaw}'.");
}

var background = ParseBackground(root, errorList);

var elements = new List<TextElementLayout>();
var addressControls = new List<AddressControlLayout>();
var renderNodes = root.Elements()
@@ -256,6 +366,11 @@ public static class TemplateLayoutXmlSerializer
{
errorList.Add($"{label} has both a 'column' attribute and inline text — use exactly one.");
}
else if (!hasColumn && !hasStaticText && node.Attribute("fill") is not null)
{
// A filled rectangle with no text of its own.
runs = new List<TextRun> { TextRun.ForLiteral(string.Empty) };
}
else if (!hasColumn && !hasStaticText)
{
errorList.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one.");
@@ -325,6 +440,20 @@ public static class TemplateLayoutXmlSerializer
var elementWidth = ParseOptionalPositiveDouble(node, "width", label, errorList);
var elementHeight = ParseOptionalPositiveDouble(node, "height", label, errorList);

RgbColor? fillColor = null;
var fillRaw = (string?)node.Attribute("fill");
if (!string.IsNullOrWhiteSpace(fillRaw))
{
if (RgbColor.TryParseHex(fillRaw, out var parsedFill))
{
fillColor = parsedFill;
}
else
{
errorList.Add($"{label} has an invalid 'fill' value: '{fillRaw}' (expected #RRGGBB).");
}
}

if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null
|| angle is null)
{
@@ -345,6 +474,7 @@ public static class TemplateLayoutXmlSerializer
elements[^1].RotationAngle = angle.Value;
elements[^1].Width = elementWidth;
elements[^1].Height = elementHeight;
elements[^1].FillColor = fillColor;
}

if (errorList.Count > 0)
@@ -357,6 +487,7 @@ public static class TemplateLayoutXmlSerializer
document = new TemplateLayoutDocument(canvas);
document.Elements.AddRange(elements);
document.AddressControls.AddRange(addressControls);
document.Background = background;
errors = Array.Empty<string>();
return true;
}
@@ -416,6 +547,22 @@ public static class TemplateLayoutXmlSerializer
}
}

var textCase = AddressTextCase.None;
var caseRaw = (string?)node.Attribute("case");
if (!string.IsNullOrWhiteSpace(caseRaw)
&& (!Enum.TryParse(caseRaw.Trim(), ignoreCase: true, out textCase) || !Enum.IsDefined(textCase)))
{
errors.Add($"{label} has an invalid 'case' value: '{caseRaw}' (expected 'none', 'upper' or 'lower').");
}

var verticalAnchor = AddressVerticalAnchor.Top;
var anchorRaw = (string?)node.Attribute("verticalAnchor");
if (!string.IsNullOrWhiteSpace(anchorRaw)
&& (!Enum.TryParse(anchorRaw.Trim(), ignoreCase: true, out verticalAnchor) || !Enum.IsDefined(verticalAnchor)))
{
errors.Add($"{label} has an invalid 'verticalAnchor' value: '{anchorRaw}' (expected 'top' or 'bottom').");
}

var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList();
if (lineNodes.Count == 0)
{
@@ -445,6 +592,8 @@ public static class TemplateLayoutXmlSerializer
ZOrder = zOrder,
LineSpacingMultiplier = lineSpacing,
RotationAngle = angle.Value,
TextCase = textCase,
VerticalAnchor = verticalAnchor,
};
control.Lines.AddRange(lines);
return control;


+ 6
- 3
code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs Voir le fichier

@@ -97,17 +97,20 @@ public static class TemplatePreviewBuilder
var resolvedText = new string[control.Lines.Count];
var collapseLines = new AddressLineCollapser.Line[control.Lines.Count];

var y = control.Y;
var y = control.TopBaselineY;
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
resolvedText[i] = TextResolver.TryResolve(line.Runs, csvHeaders, record) ?? string.Empty;
resolvedText[i] = control.TextCase.Apply(TextResolver.TryResolve(line.Runs, csvHeaders, record) ?? string.Empty);
collapseLines[i] = new AddressLineCollapser.Line(
control.X, y, line.CollapseIfBlank && string.IsNullOrWhiteSpace(resolvedText[i]));
y -= line.FontSize * control.LineSpacingMultiplier;
}

var resolved = AddressLineCollapser.Resolve(collapseLines);
var bottomShift = control.VerticalAnchor == AddressVerticalAnchor.Bottom
? AddressLineCollapser.BottomAnchorShift(resolved, control.Y)
: 0;
var angle = control.RotationAngle;
var pivot = angle != 0 ? control.BoxCenter : default;

@@ -120,7 +123,7 @@ public static class TemplatePreviewBuilder

var line = control.Lines[i];
var drawX = control.X;
var drawY = resolved[i].EffectiveY;
var drawY = resolved[i].EffectiveY + bottomShift;
if (angle != 0)
{
(drawX, drawY) = PointRotation.RotateAroundPivot(drawX, drawY, pivot, angle);


+ 8
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs Voir le fichier

@@ -39,6 +39,14 @@ public sealed class TextElementLayout
/// scope for this sprint's stories and is tracked as a known gap, not silently implemented.</summary>
public RgbColor Color { get; set; } = RgbColor.Black;

/// <summary>Optional filled-rectangle background covering this element box (needs both
/// <see cref="Width"/> and <see cref="Height"/>). The element text, which may be empty, draws
/// over it, and elements with a higher z-order draw over that. <c>null</c> = no fill.</summary>
public RgbColor? FillColor { get; set; }

/// <summary>True when <see cref="FillColor"/> is set and the box is fully defined.</summary>
public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0;

/// <summary>Stacking order; 0 is bottom-most, matching the product's numeric z-order rule.</summary>
public int ZOrder { get; set; }



+ 1
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs Voir le fichier

@@ -9,4 +9,5 @@ public enum ProgressEventKind
Render,
Complete,
Failure,
Info,
}

+ 3
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs Voir le fichier

@@ -78,6 +78,9 @@ public static class ProgressEventParser
case "failure":
kind = ProgressEventKind.Failure;
return true;
case "info":
kind = ProgressEventKind.Info;
return true;
default:
kind = default;
return false;


+ 11
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs Voir le fichier

@@ -11,6 +11,16 @@ namespace EnvelopeRenderer.Desktop.Core.Launch;
/// </summary>
public static class RenderProgressStatusFormatter
{
/// <summary>One line for the run log: elapsed time plus the same wording as the status line,
/// with a failure showing its reason. Every event kind is logged.</summary>
public static string FormatLogLine(ProgressEvent progressEvent)
{
var text = progressEvent.Kind == ProgressEventKind.Failure
? $"Render failed after {progressEvent.Completed} record(s): {progressEvent.Reason}"
: Format(progressEvent);
return $"[{ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)}] {text}";
}

public static string Format(ProgressEvent progressEvent) => progressEvent.Kind switch
{
ProgressEventKind.Startup => "Starting render...",
@@ -18,6 +28,7 @@ public static class RenderProgressStatusFormatter
$"{progressEvent.Completed} record(s) processed, {ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)} elapsed...",
ProgressEventKind.Complete => $"Finishing up... {progressEvent.Completed} record(s) rendered.",
ProgressEventKind.Failure => "Render failed. Finishing up...",
ProgressEventKind.Info => progressEvent.Reason ?? string.Empty,
_ => string.Empty,
};
}

+ 170
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs Voir le fichier

@@ -0,0 +1,170 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

/// <summary>Address Control text case / vertical anchor and the template page background: preview
/// parity with the CLI scenarios (BackgroundAndAddressOptionsTests), geometry, XML round trip.</summary>
public class AddressOptionsAndBackgroundTests
{
private static IReadOnlyDictionary<string, string> Record(params (string Key, string Value)[] pairs) =>
pairs.ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase);

private static AddressControlLayout Control(
AddressTextCase textCase = AddressTextCase.None,
AddressVerticalAnchor anchor = AddressVerticalAnchor.Top)
{
var control = AddressControlLayout.CreateDefault(120, 500);
control.Lines.Clear();
control.Lines.Add(AddressControlLineLayout.CreateField("Name", fontSize: 12));
control.Lines.Add(AddressControlLineLayout.CreateField("Line2", fontSize: 12));
var literal = new AddressControlLineLayout("Arial", 12);
literal.Runs.Clear();
literal.Runs.Add(TextRun.ForLiteral("Mixed Case"));
control.Lines.Add(literal);
control.TextCase = textCase;
control.VerticalAnchor = anchor;
return control;
}

private static IReadOnlyList<PreviewTextDraw> Preview(AddressControlLayout control, string line2)
{
var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points));
document.AddressControls.Add(control);
var result = TemplatePreviewBuilder.Build(
document, new[] { "Name", "Line2" }, Record(("Name", "Alice Smith"), ("Line2", line2)));
Assert.True(result.Success, result.Message);
return result.Draws;
}

[Theory]
[InlineData(AddressTextCase.None, "Alice Smith", "Mixed Case")]
[InlineData(AddressTextCase.Upper, "ALICE SMITH", "MIXED CASE")]
[InlineData(AddressTextCase.Lower, "alice smith", "mixed case")]
public void Preview_TextCase_AppliesToFieldAndLiteralText(AddressTextCase textCase, string name, string literal)
{
var draws = Preview(Control(textCase), "x");

Assert.Equal(name, draws[0].Text);
Assert.Equal(literal, draws[2].Text);
}

[Fact]
public void Preview_TopAnchor_BlockGrowsDownFromAnchor()
=> Assert.Equal(new[] { 500.0, 485.0, 470.0 }, Preview(Control(), "s").Select(d => d.Y).ToArray());

[Fact]
public void Preview_BottomAnchor_LastLineOnAnchorAndBlockGrowsUp()
=> Assert.Equal(
new[] { 530.0, 515.0, 500.0 },
Preview(Control(anchor: AddressVerticalAnchor.Bottom), "s").Select(d => d.Y).ToArray());

[Fact]
public void Preview_BottomAnchor_CollapsedMiddleLine_KeepsLastVisibleLineOnAnchor()
=> Assert.Equal(
new[] { 515.0, 500.0 },
Preview(Control(anchor: AddressVerticalAnchor.Bottom), "").Select(d => d.Y).ToArray());

[Fact]
public void BottomAnchor_BoxGeometry_ExtendsUpwardFromAnchor()
{
var top = Control();
var bottom = Control(anchor: AddressVerticalAnchor.Bottom);

Assert.Equal(500, top.TopBaselineY, precision: 6);
Assert.Equal(530, bottom.TopBaselineY, precision: 6);
Assert.Equal(top.BoxCenter.Y + 30, bottom.BoxCenter.Y, precision: 6);
Assert.Equal(500, bottom.BaselineYForLine(2), precision: 6);
}

private static string TempFile() => Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml");

[Fact]
public void Xml_RoundTrip_PreservesCaseAnchorAndBackground()
{
var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points));
document.AddressControls.Add(Control(AddressTextCase.Upper, AddressVerticalAnchor.Bottom));
document.Background = new PageBackgroundLayout { FileColumn = "Form", PageColumn = "Pg" };
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
var loaded = TemplateLayoutXmlSerializer.TryLoad(path, out var doc, out var errors);

Assert.True(loaded, string.Join("; ", errors));
var control = Assert.Single(doc!.AddressControls);
Assert.Equal(AddressTextCase.Upper, control.TextCase);
Assert.Equal(AddressVerticalAnchor.Bottom, control.VerticalAnchor);
Assert.Equal("Form", doc.Background!.FileColumn);
Assert.Equal("Pg", doc.Background.PageColumn);
Assert.Null(doc.Background.File);
}

[Fact]
public void Xml_Defaults_WriteNoNewAttributesOrBackground()
{
var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points));
document.AddressControls.Add(Control());
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
var xml = File.ReadAllText(path);

Assert.DoesNotContain("case=", xml);
Assert.DoesNotContain("verticalAnchor", xml);
Assert.DoesNotContain("<background", xml);
}

[Fact]
public void Xml_StaticBackgroundFileAndPage_RoundTrip()
{
var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points));
document.Elements.Add(TextElementLayout.CreateStatic(1, 2, "Hi"));
document.Background = new PageBackgroundLayout { File = @"C:\forms\bg.pdf", Page = 3 };
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var doc, out var errors), string.Join("; ", errors));

Assert.Equal(@"C:\forms\bg.pdf", doc!.Background!.File);
Assert.Equal(3, doc.Background.Page);
}

[Theory]
[InlineData("<background />")]
[InlineData("<background file=\"a.txt\" />")]
[InlineData("<background file=\"a.pdf\" fileColumn=\"X\" />")]
[InlineData("<background file=\"a.pdf\" page=\"0\" />")]
public void Xml_InvalidBackground_FailsToLoad(string backgroundXml)
{
var path = TempFile();
File.WriteAllText(path,
$"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">{backgroundXml}<text x=\"1\" y=\"2\" font=\"Arial\" size=\"12\">Hi</text></envelopeTemplate>");

Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors));
Assert.Contains(errors, e => e.Contains("<background>"));
}

[Fact]
public void Xml_InvalidCase_FailsToLoad()
{
var path = TempFile();
File.WriteAllText(path,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\"><addressControl x=\"1\" y=\"2\" width=\"180\" case=\"title\">"
+ "<line font=\"Arial\" size=\"12\"><run column=\"Name\" /></line></addressControl></envelopeTemplate>");

Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors));
Assert.Contains(errors, e => e.Contains("'case'"));
}

[Fact]
public void Background_ResolvesFileAndPagePerRecord()
{
var background = new PageBackgroundLayout { FileColumn = "Form", PageColumn = "Pg" };
var record = Record(("Form", " a.pdf "), ("Pg", " 2 "));

Assert.Equal(Path.Combine(@"C:\t", "a.pdf"), background.ResolveFile(record, @"C:\t"));
Assert.Equal(2, background.ResolvePage(record));
Assert.Null(background.ResolveFile(Record(("Form", "")), @"C:\t"));
Assert.Null(background.ResolvePage(Record(("Pg", "zero"))));
Assert.Equal(@"D:\x\b.png", new PageBackgroundLayout { File = @"D:\x\b.png" }.ResolveFile(null, @"C:\t"));
}
}

+ 110
- 0
code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs Voir le fichier

@@ -0,0 +1,110 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

/// <summary>A text element with a fill color and a box is a filled rectangle (cover-up) with
/// optional text over it; round trip through the template XML the render CLI reads.</summary>
public class FilledRectangleLayoutTests
{
private static string TempFile() => Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml");

private static TemplateLayoutDocument NewDocument() =>
new(new CanvasSettings(297, 684, CanvasUnit.Points));

[Fact]
public void HasFill_RequiresColorWidthAndHeight()
{
var element = TextElementLayout.CreateStatic(1, 2, string.Empty);

Assert.False(element.HasFill);
element.FillColor = new RgbColor(255, 255, 255);
Assert.False(element.HasFill);
element.Width = 100;
Assert.False(element.HasFill);
element.Height = 40;
Assert.True(element.HasFill);
}

[Fact]
public void Xml_PureRectangle_RoundTripsWithEmptyText()
{
var document = NewDocument();
var rectangle = TextElementLayout.CreateStatic(10, 20, string.Empty);
rectangle.Width = 120;
rectangle.Height = 40;
rectangle.FillColor = new RgbColor(255, 255, 255);
document.Elements.Add(rectangle);
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
Assert.Contains("fill=\"#FFFFFF\"", File.ReadAllText(path));
Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors));

var element = Assert.Single(loaded!.Elements);
Assert.Equal(new RgbColor(255, 255, 255), element.FillColor);
Assert.Equal(120, element.Width);
Assert.Equal(40, element.Height);
Assert.True(element.HasFill);
}

[Fact]
public void Xml_RectangleWithText_KeepsTextAndFill()
{
var document = NewDocument();
var element = TextElementLayout.CreateStatic(10, 20, "PAID");
element.Width = 100;
element.Height = 30;
element.FillColor = new RgbColor(255, 255, 0);
document.Elements.Add(element);
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors));

var reloaded = Assert.Single(loaded!.Elements);
Assert.Equal("PAID", reloaded.StaticText);
Assert.Equal(new RgbColor(255, 255, 0), reloaded.FillColor);
}

[Fact]
public void Xml_NoFill_WritesNoFillAttribute()
{
var document = NewDocument();
document.Elements.Add(TextElementLayout.CreateStatic(1, 2, "Hi"));
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);

Assert.DoesNotContain("fill=", File.ReadAllText(path));
}

[Fact]
public void Xml_WidthOnlyElement_SavesWithoutThrowing_AndOmitsHeight()
{
var document = NewDocument();
var element = TextElementLayout.CreateStatic(1, 2, "wrap me");
element.Width = 80;
document.Elements.Add(element);
var path = TempFile();

TemplateLayoutXmlSerializer.Save(document, path);
var xml = File.ReadAllText(path);

Assert.Contains("width=\"80\"", xml);
Assert.DoesNotContain("height=", xml.Replace("pageHeight", string.Empty));
Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors));
Assert.Equal(80, loaded!.Elements[0].Width);
Assert.Null(loaded.Elements[0].Height);
}

[Fact]
public void Xml_InvalidFill_FailsToLoad()
{
var path = TempFile();
File.WriteAllText(path,
"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\"><text x=\"1\" y=\"2\" font=\"Arial\" size=\"12\" width=\"5\" height=\"5\" fill=\"red\">Hi</text></envelopeTemplate>");

Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors));
Assert.Contains(errors, e => e.Contains("'fill'"));
}
}

+ 22
- 0
code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs Voir le fichier

@@ -85,4 +85,26 @@ public class ProgressEventParserTests
Assert.False(parsed);
Assert.Null(progressEvent);
}

[Fact]
public void TryParse_InfoLine_ParsesMessageAsReason()
{
var parsed = ProgressEventParser.TryParse(
"PROGRESS info elapsedMs=120 completed=0 reason=Background: vector PDF pages from 'a b.pdf'", out var progressEvent);

Assert.True(parsed);
Assert.Equal(ProgressEventKind.Info, progressEvent!.Kind);
Assert.Equal("Background: vector PDF pages from 'a b.pdf'", progressEvent.Reason);
}

[Fact]
public void FormatLogLine_InfoAndFailure_ShowTheirText()
{
var info = new ProgressEvent(ProgressEventKind.Info, 65_000, 0, "Saving PDF...");
var failure = new ProgressEvent(ProgressEventKind.Failure, 1_000, 3, "boom");

Assert.EndsWith("Saving PDF...", RenderProgressStatusFormatter.FormatLogLine(info));
Assert.Contains("boom", RenderProgressStatusFormatter.FormatLogLine(failure));
Assert.Contains("3 record(s)", RenderProgressStatusFormatter.FormatLogLine(failure));
}
}

+ 99
- 0
code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs Voir le fichier

@@ -0,0 +1,99 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

/// <summary>A filled rectangle gets a top-edge height handle that sets Height by dragging, just as
/// the right-edge handle sets Width.</summary>
public class RectangleHeightHandleTests
{
private static (double Width, double Height) BoxAwareSize(TextElementLayout e) =>
e.HasBox ? (e.Width!.Value, e.Height ?? 0) : (20.0, 10.0);

private static (CanvasElementEditor Editor, TextElementLayout Element) Rectangle()
{
var editor = new CanvasElementEditor(TemplateLayoutDocument.CreateDefault(), BoxAwareSize);
var element = editor.AddStaticText(100, 100, string.Empty);
element.Width = 120;
element.Height = 40;
element.FillColor = new RgbColor(255, 255, 255);
return (editor, element);
}

[Fact]
public void HeightHandle_SitsAtTopEdgeMidpoint()
{
var (editor, _) = Rectangle();

var handle = editor.HeightHandlePosition();

Assert.NotNull(handle);
Assert.Equal(160, handle!.Value.X, precision: 3); // 100 + 120 / 2
Assert.Equal(140, handle.Value.Y, precision: 3); // 100 + 40
Assert.True(editor.HitTestHeightHandle(160, 140));
Assert.False(editor.HitTestHeightHandle(160, 100));
}

[Fact]
public void HeightHandle_NotOfferedForPlainTextOrBoxWithoutFill()
{
var editor = new CanvasElementEditor(TemplateLayoutDocument.CreateDefault(), BoxAwareSize);
var element = editor.AddStaticText(100, 100, "Hi");
Assert.Null(editor.HeightHandlePosition());

element.Width = 120;
element.Height = 40;
Assert.Null(editor.HeightHandlePosition());
Assert.False(editor.HitTestHeightHandle(160, 140));
}

[Fact]
public void DraggingHeightHandle_SetsHeightFromVerticalDistanceToBottom_AndLeavesWidthAndPosition()
{
var (editor, element) = Rectangle();

editor.BeginHeightResizeDrag();
Assert.True(editor.IsResizing);
editor.ResizeDragTo(9999, 175); // 75 above the (100,100) bottom edge; X is irrelevant

Assert.Equal(75, element.Height!.Value, precision: 3);
Assert.Equal(120, element.Width!.Value, precision: 3);
Assert.Equal(100, element.X, precision: 6);
Assert.Equal(100, element.Y, precision: 6);
}

[Fact]
public void DraggingHeightHandleBelowTheBottomEdge_LeavesHeightUnchanged()
{
var (editor, element) = Rectangle();

editor.BeginHeightResizeDrag();
editor.ResizeDragTo(160, 90);

Assert.Equal(40, element.Height!.Value, precision: 3);
}

[Fact]
public void EndDrag_StopsHeightResize()
{
var (editor, element) = Rectangle();
editor.BeginHeightResizeDrag();

editor.EndDrag();
editor.ResizeDragTo(160, 200);

Assert.False(editor.IsResizing);
Assert.Equal(40, element.Height!.Value, precision: 3);
}

[Fact]
public void WidthHandleStillOnlyChangesWidth()
{
var (editor, element) = Rectangle();

editor.BeginResizeDrag();
editor.ResizeDragTo(250, 999);

Assert.Equal(150, element.Width!.Value, precision: 3);
Assert.Equal(40, element.Height!.Value, precision: 3);
}
}

+ 67
- 0
code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs Voir le fichier

@@ -0,0 +1,67 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Tests;

public class SelectionCyclerTests
{
private static readonly Guid A = Guid.Parse("00000000-0000-0000-0000-00000000000a");
private static readonly Guid B = Guid.Parse("00000000-0000-0000-0000-00000000000b");
private static readonly Guid C = Guid.Parse("00000000-0000-0000-0000-00000000000c");

private static SelectionCycler.Item[] Items() => new[]
{
new SelectionCycler.Item(C, ZOrder: 2, X: 0, Y: 0),
new SelectionCycler.Item(A, ZOrder: 0, X: 0, Y: 0),
new SelectionCycler.Item(B, ZOrder: 1, X: 0, Y: 0),
};

[Fact]
public void Forward_WalksBottomToTopByZOrder_AndWraps()
{
Assert.Equal(A, SelectionCycler.Next(Items(), null, forward: true));
Assert.Equal(B, SelectionCycler.Next(Items(), A, forward: true));
Assert.Equal(C, SelectionCycler.Next(Items(), B, forward: true));
Assert.Equal(A, SelectionCycler.Next(Items(), C, forward: true));
}

[Fact]
public void Backward_WalksTopToBottom_AndWraps_StartingAtTheLastItem()
{
Assert.Equal(C, SelectionCycler.Next(Items(), null, forward: false));
Assert.Equal(B, SelectionCycler.Next(Items(), C, forward: false));
Assert.Equal(A, SelectionCycler.Next(Items(), B, forward: false));
Assert.Equal(C, SelectionCycler.Next(Items(), A, forward: false));
}

[Fact]
public void SameZOrder_VisitsTopToBottomThenLeftToRight()
{
var items = new[]
{
new SelectionCycler.Item(A, 0, X: 50, Y: 10),
new SelectionCycler.Item(B, 0, X: 10, Y: 100),
new SelectionCycler.Item(C, 0, X: 10, Y: 10),
};

Assert.Equal(B, SelectionCycler.Next(items, null, forward: true)); // highest on the page first
Assert.Equal(C, SelectionCycler.Next(items, B, forward: true)); // then the lower-left one
Assert.Equal(A, SelectionCycler.Next(items, C, forward: true));
}

[Fact]
public void UnknownCurrentSelection_BehavesLikeNoSelection()
=> Assert.Equal(A, SelectionCycler.Next(Items(), Guid.NewGuid(), forward: true));

[Fact]
public void NoItems_ReturnsNull()
=> Assert.Null(SelectionCycler.Next(Array.Empty<SelectionCycler.Item>(), null, forward: true));

[Fact]
public void SingleItem_StaysOnIt()
{
var items = new[] { new SelectionCycler.Item(A, 0, 0, 0) };

Assert.Equal(A, SelectionCycler.Next(items, A, forward: true));
Assert.Equal(A, SelectionCycler.Next(items, A, forward: false));
}
}

+ 4
- 0
code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj Voir le fichier

@@ -11,6 +11,10 @@
<ItemGroup>
<ProjectReference Include="..\EnvelopeRenderer.Desktop.Core\EnvelopeRenderer.Desktop.Core.csproj" />

<!-- Vendor interop only (no CLI code): the designer canvas renders PDF page backgrounds with
Debenu's DARenderPageToDC. -->
<ProjectReference Include="..\EnvelopeRenderer.Debenu\EnvelopeRenderer.Debenu.csproj" />

<!--
Referenced only so `dotnet build`/`dotnet run` for this project always produce a freshly
built EnvelopeRenderer.Cli.exe copied next to EnvelopeRenderer.Desktop.exe — exactly where


+ 24
- 3
code/src/EnvelopeRenderer.Desktop/MainForm.cs Voir le fichier

@@ -32,6 +32,16 @@ public sealed class MainForm : Form
TextAlign = ContentAlignment.TopLeft,
};

private readonly TextBox _logTextBox = new()
{
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
WordWrap = true,
Height = 200,
Font = new Font(FontFamily.GenericMonospace, 9f),
};

private readonly CliProcessLauncher _launcher;

public MainForm() : this(new CliProcessLauncher())
@@ -44,7 +54,7 @@ public sealed class MainForm : Form
_launcher = launcher;

Text = "Envelope Renderer";
MinimumSize = new Size(640, 260);
MinimumSize = new Size(640, 480);
StartPosition = FormStartPosition.CenterScreen;

Controls.Add(BuildLayout());
@@ -56,7 +66,7 @@ public sealed class MainForm : Form
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 5,
RowCount = 6,
Padding = new Padding(12),
AutoSize = true,
};
@@ -97,6 +107,10 @@ public sealed class MainForm : Form
layout.Controls.Add(_statusLabel, 0, 4);
layout.SetColumnSpan(_statusLabel, 3);

_logTextBox.Dock = DockStyle.Fill;
layout.Controls.Add(_logTextBox, 0, 5);
layout.SetColumnSpan(_logTextBox, 3);

return layout;
}

@@ -163,6 +177,7 @@ public sealed class MainForm : Form
// operator can't launch a second render against the same output while one is in flight.
_renderButton.Enabled = false;
SetStatus("Starting render...", isError: false);
_logTextBox.Clear();

try
{
@@ -207,7 +222,13 @@ public sealed class MainForm : Form

private void OnRenderProgress(ProgressEvent progressEvent)
{
SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure);
// Info lines are log-only detail (they would flicker past in the one-line status).
if (progressEvent.Kind != ProgressEventKind.Info)
{
SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure);
}

_logTextBox.AppendText(RenderProgressStatusFormatter.FormatLogLine(progressEvent) + Environment.NewLine);
}

private void ShowLaunchFailure(string message)


+ 223
- 0
code/src/EnvelopeRenderer.Desktop/Views/PageBackgroundDialog.cs Voir le fichier

@@ -0,0 +1,223 @@
using EnvelopeRenderer.Desktop.Core.Design;

namespace EnvelopeRenderer.Desktop.Views;

/// <summary>Modal editor for the template page background: none, a fixed image / PDF file, or a
/// file read from a CSV column; for a PDF, a fixed page number or a page read from a CSV column.
/// <see cref="Result"/> is <c>null</c> when "No background" is chosen.</summary>
internal sealed class PageBackgroundDialog : Form
{
private readonly RadioButton _noneRadio = new() { Text = "No background", AutoSize = true };
private readonly RadioButton _fixedFileRadio = new() { Text = "Image or PDF file:", AutoSize = true };
private readonly RadioButton _columnFileRadio = new() { Text = "File path from CSV column:", AutoSize = true };
private readonly TextBox _filePathInput = new() { Width = 300 };
private readonly Button _browseButton = new() { Text = "Browse...", AutoSize = true };
private readonly ComboBox _fileColumnInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
private readonly RadioButton _fixedPageRadio = new() { Text = "PDF page number:", AutoSize = true };
private readonly RadioButton _columnPageRadio = new() { Text = "PDF page from CSV column:", AutoSize = true };
private readonly NumericUpDown _pageInput = new() { Minimum = 1, Maximum = 100000, Value = 1, Width = 70 };
private readonly ComboBox _pageColumnInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
private readonly Label _hint = new()
{
AutoSize = false,
Width = 470,
Height = 48,
Text = "The background is stretched to the page size behind every page. PDF pages are drawn as a "
+ "300 DPI image. Relative paths are read from the template's folder. A blank file value in the "
+ "CSV means no background for that record.",
};

public PageBackgroundLayout? Result { get; private set; }

public PageBackgroundDialog(PageBackgroundLayout? current, IReadOnlyList<string> csvHeaders)
{
Text = "Page Background";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(500, 330);

_fileColumnInput.Items.AddRange(csvHeaders.Cast<object>().ToArray());
_pageColumnInput.Items.AddRange(csvHeaders.Cast<object>().ToArray());
var hasColumns = csvHeaders.Count > 0;
_columnFileRadio.Enabled = hasColumns;
_columnPageRadio.Enabled = hasColumns;

_noneRadio.Location = new Point(14, 14);
_fixedFileRadio.Location = new Point(14, 44);
_filePathInput.Location = new Point(34, 68);
_browseButton.Location = new Point(342, 66);
_columnFileRadio.Location = new Point(14, 102);
_fileColumnInput.Location = new Point(34, 126);
_fixedPageRadio.Location = new Point(14, 164);
_pageInput.Location = new Point(190, 162);
_columnPageRadio.Location = new Point(14, 194);
_pageColumnInput.Location = new Point(34, 218);
_hint.Location = new Point(14, 252);

var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(330, 292) };
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(410, 292) };
AcceptButton = ok;
CancelButton = cancel;

Controls.AddRange(new Control[]
{
_noneRadio, _fixedFileRadio, _filePathInput, _browseButton, _columnFileRadio, _fileColumnInput,
_fixedPageRadio, _pageInput, _columnPageRadio, _pageColumnInput, _hint, ok, cancel,
});

_browseButton.Click += (_, _) =>
{
using var picker = new OpenFileDialog
{
Title = "Choose background image or PDF",
Filter = "Images and PDF|*.pdf;*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.tif;*.tiff|All files (*.*)|*.*",
CheckFileExists = true,
};
if (picker.ShowDialog(this) == DialogResult.OK)
{
_filePathInput.Text = picker.FileName;
_fixedFileRadio.Checked = true;
}
};

foreach (var radio in new[] { _noneRadio, _fixedFileRadio, _columnFileRadio, _fixedPageRadio, _columnPageRadio })
{
radio.CheckedChanged += (_, _) => SyncEnabled();
}

// Radio groups: file source (3) and page source (2) need separate containers so they do not
// share one exclusive group.
var pageGroup = new Panel { Location = new Point(0, 160), Size = new Size(500, 90) };
Controls.Remove(_fixedPageRadio);
Controls.Remove(_pageInput);
Controls.Remove(_columnPageRadio);
Controls.Remove(_pageColumnInput);
_fixedPageRadio.Location = new Point(14, 4);
_pageInput.Location = new Point(190, 2);
_columnPageRadio.Location = new Point(14, 34);
_pageColumnInput.Location = new Point(34, 58);
pageGroup.Controls.AddRange(new Control[] { _fixedPageRadio, _pageInput, _columnPageRadio, _pageColumnInput });
Controls.Add(pageGroup);

LoadFrom(current);
SyncEnabled();

FormClosing += (_, e) =>
{
if (DialogResult == DialogResult.OK && !TryBuildResult(out var message))
{
MessageBox.Show(this, message, "Page Background", MessageBoxButtons.OK, MessageBoxIcon.Warning);
e.Cancel = true;
}
};
}

private void LoadFrom(PageBackgroundLayout? current)
{
if (current is null)
{
_noneRadio.Checked = true;
_fixedPageRadio.Checked = true;
return;
}

if (current.FileColumn is not null)
{
_columnFileRadio.Checked = true;
SelectItem(_fileColumnInput, current.FileColumn);
}
else
{
_fixedFileRadio.Checked = true;
_filePathInput.Text = current.File ?? string.Empty;
}

if (current.PageColumn is not null)
{
_columnPageRadio.Checked = true;
SelectItem(_pageColumnInput, current.PageColumn);
}
else
{
_fixedPageRadio.Checked = true;
_pageInput.Value = Math.Clamp(current.Page, 1, 100000);
}
}

private static void SelectItem(ComboBox box, string value)
{
var index = box.Items.IndexOf(value);
if (index < 0)
{
box.Items.Add(value);
index = box.Items.Count - 1;
}

box.SelectedIndex = index;
}

private void SyncEnabled()
{
var hasBackground = !_noneRadio.Checked;
_filePathInput.Enabled = _fixedFileRadio.Checked;
_browseButton.Enabled = true;
_fileColumnInput.Enabled = _columnFileRadio.Checked;
_fixedPageRadio.Enabled = hasBackground;
_columnPageRadio.Enabled = hasBackground && _fileColumnInput.Items.Count > 0;
_pageInput.Enabled = hasBackground && _fixedPageRadio.Checked;
_pageColumnInput.Enabled = hasBackground && _columnPageRadio.Checked;
}

private bool TryBuildResult(out string message)
{
message = string.Empty;
if (_noneRadio.Checked)
{
Result = null;
return true;
}

var layout = new PageBackgroundLayout();
if (_fixedFileRadio.Checked)
{
var path = _filePathInput.Text.Trim();
if (path.Length == 0 || !PageBackgroundLayout.IsSupportedExtension(path))
{
message = "Choose a .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff file.";
return false;
}

layout.File = path;
}
else
{
if (_fileColumnInput.SelectedItem is not string column)
{
message = "Choose the CSV column that holds the background file path.";
return false;
}

layout.FileColumn = column;
}

if (_columnPageRadio.Checked)
{
if (_pageColumnInput.SelectedItem is not string pageColumn)
{
message = "Choose the CSV column that holds the PDF page number.";
return false;
}

layout.PageColumn = pageColumn;
}
else
{
layout.Page = (int)_pageInput.Value;
}

Result = layout;
return true;
}
}

+ 136
- 0
code/src/EnvelopeRenderer.Desktop/Views/PdfPageBitmapRenderer.cs Voir le fichier

@@ -0,0 +1,136 @@
using System.Drawing;
using DebenuPDFLibraryDLL1013;

namespace EnvelopeRenderer.Desktop.Views;

/// <summary>Renders one page of a PDF to a bitmap with Debenu's Document Analyzer
/// <c>DARenderPageToDC</c>, for showing a PDF page background on the designer canvas. Only the
/// vendor interop is shared with the CLI (EnvelopeRenderer.Debenu); this class has no CLI code.
/// Kept free of WinForms types (System.Drawing only) so it can be exercised from a test project.</summary>
internal static class PdfPageBitmapRenderer
{
/// <summary>Largest bitmap width rendered, in pixels - keeps memory bounded on a zoomed canvas.</summary>
public const int MaxPixelWidth = 2400;

/// <summary>Renders <paramref name="page"/> (1-based) of <paramref name="pdfPath"/> so its width
/// is <paramref name="targetPixelWidth"/> pixels (height follows the PDF page aspect ratio).
/// Returns <c>null</c> with a readable <paramref name="error"/> on failure.</summary>
public static Bitmap? Render(string pdfPath, int page, int targetPixelWidth, out string? error)
{
error = null;
if (!File.Exists(pdfPath))
{
error = $"file not found: {pdfPath}";
return null;
}

var dllPath = Path.Combine(
AppContext.BaseDirectory,
Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll");
var pdf = new PDFLibrary(dllPath);
if (!pdf.LibraryLoaded())
{
error = "the PDF library could not be loaded";
return null;
}

try
{
var key = ResolveLicenseKey();
if (!string.IsNullOrEmpty(key))
{
pdf.UnlockKey(key);
}

var file = pdf.DAOpenFileReadOnly(pdfPath, string.Empty);
if (file == 0)
{
error = "the PDF could not be opened";
return null;
}

try
{
var pageCount = pdf.DAGetPageCount(file);
if (page < 1 || page > pageCount)
{
error = $"the PDF has {pageCount} page(s), no page {page}";
return null;
}

var pageRef = pdf.DAFindPage(file, page);
var widthPoints = pdf.DAGetPageWidth(file, pageRef);
var heightPoints = pdf.DAGetPageHeight(file, pageRef);
if (pageRef == 0 || widthPoints <= 0 || heightPoints <= 0)
{
error = $"page {page} could not be read";
return null;
}

var pixelWidth = Math.Clamp(targetPixelWidth, 50, MaxPixelWidth);
var pixelHeight = Math.Max(1, (int)Math.Round(pixelWidth * heightPoints / widthPoints));
var dpi = pixelWidth / (widthPoints / 72.0);

var bitmap = new Bitmap(pixelWidth, pixelHeight);
using (var g = Graphics.FromImage(bitmap))
{
g.Clear(Color.White);
var dc = g.GetHdc();
int result;
try
{
result = pdf.DARenderPageToDC(file, pageRef, dpi, dc);
}
finally
{
g.ReleaseHdc(dc);
}

if (result == 0)
{
bitmap.Dispose();
error = "the page could not be rendered";
return null;
}
}

return bitmap;
}
finally
{
pdf.DACloseFile(file);
}
}
finally
{
pdf.ReleaseLibrary();
}
}

/// <summary>The DEBENU_LICENSE_KEY environment variable, else a key.txt in the app folder or any
/// parent (the same lookup the CLI uses, so one key file serves both).</summary>
private static string? ResolveLicenseKey()
{
var fromEnv = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv;
}

var directory = new DirectoryInfo(AppContext.BaseDirectory);
for (var i = 0; i < 10 && directory is not null; i++, directory = directory.Parent)
{
var candidate = Path.Combine(directory.FullName, "key.txt");
if (File.Exists(candidate))
{
var content = File.ReadAllText(candidate).Trim();
if (!string.IsNullOrEmpty(content))
{
return content;
}
}
}

return null;
}
}

+ 121
- 0
code/src/EnvelopeRenderer.Desktop/Views/RenderRunDialog.cs Voir le fichier

@@ -0,0 +1,121 @@
using System.Diagnostics;
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Views;

/// <summary>Modal window that runs the render CLI for one template + CSV + output PDF and shows its
/// progress log live, the same events the main window shows. Starts rendering when shown; Close is
/// disabled until the process has exited.</summary>
internal sealed class RenderRunDialog : Form
{
private readonly RenderLaunchInputs _inputs;
private readonly Label _statusLabel = new() { AutoSize = false, Height = 40, Dock = DockStyle.Top, TextAlign = ContentAlignment.MiddleLeft };
private readonly TextBox _logTextBox = new()
{
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
WordWrap = true,
Dock = DockStyle.Fill,
Font = new Font(FontFamily.GenericMonospace, 9f),
};

private readonly Button _openButton = new() { Text = "&Open PDF", AutoSize = true, Enabled = false };
private readonly Button _closeButton = new() { Text = "&Close", AutoSize = true, Enabled = false };
private bool _running;

public RenderRunDialog(RenderLaunchInputs inputs)
{
_inputs = inputs;
Text = "Render PDF";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(640, 420);
MinimumSize = new Size(480, 300);
ControlBox = false;

var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(8),
};
buttons.Controls.Add(_closeButton);
buttons.Controls.Add(_openButton);

Controls.Add(_logTextBox);
Controls.Add(buttons);
Controls.Add(_statusLabel);

_closeButton.Click += (_, _) => Close();
_openButton.Click += (_, _) => OpenOutput();
CancelButton = _closeButton;
Shown += async (_, _) => await RunAsync();
FormClosing += (_, e) =>
{
if (_running)
{
e.Cancel = true;
}
};
}

private async Task RunAsync()
{
_running = true;
SetStatus("Starting render...", isError: false);

var cliPath = CliExecutablePathResolver.Resolve(AppContext.BaseDirectory);
if (!cliPath.IsFound)
{
Finish(cliPath.Error!, isError: true, succeeded: false);
return;
}

var progress = new Progress<ProgressEvent>(OnProgress);
var result = await new CliProcessLauncher().RunAsync(
cliPath.Path!, CliArgumentListBuilder.Build(_inputs), progress);

Finish(RenderCompletionSummaryFormatter.Format(result), isError: !result.Succeeded, succeeded: result.Succeeded);
}

private void OnProgress(ProgressEvent progressEvent)
{
if (progressEvent.Kind != ProgressEventKind.Info)
{
SetStatus(
RenderProgressStatusFormatter.Format(progressEvent),
isError: progressEvent.Kind == ProgressEventKind.Failure);
}

_logTextBox.AppendText(RenderProgressStatusFormatter.FormatLogLine(progressEvent) + Environment.NewLine);
}

private void Finish(string summary, bool isError, bool succeeded)
{
_running = false;
SetStatus(summary, isError);
_logTextBox.AppendText(summary + Environment.NewLine);
_closeButton.Enabled = true;
_openButton.Enabled = succeeded && File.Exists(_inputs.OutputPath);
ControlBox = true;
}

private void SetStatus(string message, bool isError)
{
_statusLabel.Text = message;
_statusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
}

private void OpenOutput()
{
try
{
Process.Start(new ProcessStartInfo(_inputs.OutputPath!) { UseShellExecute = true });
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
MessageBox.Show(this, $"Could not open the PDF: {ex.Message}", "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}

+ 242
- 13
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs Voir le fichier

@@ -116,7 +116,7 @@ public sealed class TemplateCanvasControl : Control
/// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this
/// property existed.</summary>
public bool IsInteracting =>
_editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null
_editor.IsDragging || _editor.IsRotating || _editor.IsResizing || _addressControlDragOffset is not null
|| _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging;

public TextElementLayout? SelectedElement => _editor.Selected;
@@ -152,6 +152,25 @@ public sealed class TemplateCanvasControl : Control
return element;
}

/// <summary>Adds a filled rectangle (a text element with an empty content, a box and a fill
/// color) that covers whatever is under it and can have text typed into it. White by default,
/// like a cover-up patch.</summary>
public TextElementLayout AddRectangleElement()
{
var (x, y) = DefaultNewElementPosition();
var element = _editor.AddStaticText(x, y, text: string.Empty);
element.Width = DefaultRectangleWidth;
element.Height = DefaultRectangleHeight;
element.FillColor = new RgbColor(255, 255, 255);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return element;
}

public const double DefaultRectangleWidth = 120;
public const double DefaultRectangleHeight = 40;

public AddressControlLayout AddAddressControl()
{
var (x, y) = DefaultNewElementPosition();
@@ -472,6 +491,116 @@ public sealed class TemplateCanvasControl : Control
private CanvasViewTransform CurrentTransform() =>
CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);

/// <summary>Folder relative background paths resolve against (the template folder).</summary>
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public string? BackgroundBaseDirectory { get; set; }

private string? _backgroundImagePath;
private DateTime _backgroundImageStamp;
private Image? _backgroundImage;

/// <summary>Paints the template page background under everything else. Images are drawn
/// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page
/// shows a labelled placeholder - the real PDF page appears in the rendered output.</summary>
private void DrawPageBackground(Graphics g, RectangleF pageRect)
{
var background = _document.Background;
if (background is null)
{
return;
}

var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory);
if (file is null)
{
return;
}

if (PageBackgroundLayout.IsPdf(file))
{
var page = background.ResolvePage(_csvSampleRecord);
var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null;
if (pdfBitmap is not null)
{
g.DrawImage(pdfBitmap, pageRect);
return;
}

using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250));
g.FillRectangle(tint, pageRect);
using var font = new Font("Segoe UI", 9f);
g.DrawString(
$"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})",
font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6);
return;
}

var image = LoadBackgroundImage(file);
if (image is not null)
{
g.DrawImage(image, pageRect);
}
}

private string? _pdfBackgroundKey;
private int _pdfBackgroundWidth;
private Bitmap? _pdfBackgroundBitmap;
private string? _pdfBackgroundError;

/// <summary>The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file
/// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom),
/// so a repaint during drag or selection never re-renders the PDF.</summary>
private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error)
{
var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0;
var key = $"{path}|{page}|{stamp}";
var wanted = Math.Max(pageWidthPixels, 50);
var sameKey = _pdfBackgroundKey == key;
var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75;

if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth))
{
error = _pdfBackgroundError;
return _pdfBackgroundBitmap;
}

var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError);
_pdfBackgroundBitmap?.Dispose();
_pdfBackgroundBitmap = rendered;
_pdfBackgroundKey = key;
_pdfBackgroundWidth = rendered is null ? 0 : wanted;
_pdfBackgroundError = renderError;
error = renderError;
return rendered;
}

private Image? LoadBackgroundImage(string path)
{
try
{
var stamp = File.GetLastWriteTimeUtc(path);
if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp)
{
return _backgroundImage;
}

_backgroundImage?.Dispose();
_backgroundImage = null;
_backgroundImagePath = path;
_backgroundImageStamp = stamp;
// Copy through a MemoryStream so the file is not left locked by GDI+.
using var stream = new MemoryStream(File.ReadAllBytes(path));
using var loaded = Image.FromStream(stream);
_backgroundImage = new Bitmap(loaded);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException)
{
_backgroundImage = null;
}

return _backgroundImage;
}

protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
@@ -485,6 +614,7 @@ public sealed class TemplateCanvasControl : Control
var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);

g.FillRectangle(Brushes.White, pageRect);
DrawPageBackground(g, pageRect);
g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);

// Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
@@ -535,6 +665,7 @@ public sealed class TemplateCanvasControl : Control
if (_editor.Selected is not null)
{
DrawResizeHandle(g, transform, _editor.Selected);
DrawHeightHandle(g, transform);
DrawRotateHandle(g, transform, _editor.Selected);
}

@@ -729,8 +860,8 @@ public sealed class TemplateCanvasControl : Control
/// for standalone elements.</summary>
private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var top = control.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - control.Height;
var left = control.X;
var right = control.X + control.Width;

@@ -889,6 +1020,23 @@ public sealed class TemplateCanvasControl : Control
var boxHeight = height * transform.Scale;
using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));

if (element.HasFill && element.FillColor is { } rectFill)
{
// Filled rectangle: painted first so the element's own text (and any element with
// a higher z-order) draws over it, like the real render.
using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B));
g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);

// Design-view outline so a white (or page-coloured) rectangle is still visible and
// clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light
// dashed line together stay visible over any fill or background.
var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f);
using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash };
g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
}

if (element.HasBox)
{
// Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own
@@ -990,6 +1138,22 @@ public sealed class TemplateCanvasControl : Control
/// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white-
/// outline style already used for the Address Control's resize handle, deliberately distinct
/// from the rotate handle's round dot so the two are never confused at a glance.</summary>
private void DrawHeightHandle(Graphics g, CanvasViewTransform transform)
{
var handle = _editor.HeightHandlePosition();
if (handle is null)
{
return;
}

var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
using var outline = new Pen(System.Drawing.Color.White, 1);
g.FillRectangle(brush, rect);
g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
}

private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
{
var handle = _editor.ResizeHandlePosition();
@@ -1090,8 +1254,8 @@ public sealed class TemplateCanvasControl : Control
using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);

var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height);
var (left, top) = transform.ToPixels(control.X, control.TopBaselineY + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height);
var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
if (isPrimarySelected)
@@ -1113,7 +1277,7 @@ public sealed class TemplateCanvasControl : Control

var line = control.Lines[i];
using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText;
var text = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText);
var size = MeasureText(text, font);
using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale);
var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X;
@@ -1163,8 +1327,8 @@ public sealed class TemplateCanvasControl : Control

private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var top = control.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - control.Height;
return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
}

@@ -1206,7 +1370,17 @@ public sealed class TemplateCanvasControl : Control
lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
}

return AddressLineCollapser.Resolve(lines).ToArray();
var resolved = AddressLineCollapser.Resolve(lines).ToArray();
if (control.VerticalAnchor == AddressVerticalAnchor.Bottom)
{
var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y);
for (var i = 0; i < resolved.Length; i++)
{
resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift };
}
}

return resolved;
}

/// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
@@ -1469,6 +1643,14 @@ public sealed class TemplateCanvasControl : Control
// resize handle starts a resize-drag, checked with the same precedence as the rotate
// handle immediately below (only meaningful, and only checked, once something is already
// selected) so it is never confused with a normal select/move click.
if (_editor.HitTestHeightHandle(x, y))
{
_editor.BeginHeightResizeDrag();
Capture = true;
Invalidate();
return;
}

if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y))
{
_editor.BeginResizeDrag();
@@ -1563,8 +1745,8 @@ public sealed class TemplateCanvasControl : Control
? (xPoints, yPoints)
: PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);

var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var top = control.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - control.Height;
if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
{
continue;
@@ -1681,7 +1863,7 @@ public sealed class TemplateCanvasControl : Control
return;
}

if (_editor.IsResizingWidth)
if (_editor.IsResizing)
{
_editor.ResizeDragTo(x, y);
}
@@ -1740,8 +1922,55 @@ public sealed class TemplateCanvasControl : Control
RemoveSelectedElement();
e.Handled = true;
}
else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt)
{
// Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden
// behind other items. Ctrl+Tab still leaves the canvas as usual.
SelectNextItem(forward: !e.Shift);
e.Handled = true;
e.SuppressKeyPress = true;
}
}

/// <summary>Selects the next (or previous) item in z-order, so an item hidden behind another
/// one can be reached without clicking it. Wraps at both ends. Returns <c>false</c> when there
/// is nothing to select or the canvas is showing data (selection is disabled then).</summary>
public bool SelectNextItem(bool forward)
{
if (_showData || IsInteracting)
{
return false;
}

var items = _document.Elements
.Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y))
.Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y)))
.ToList();
Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id;
if (SelectionCycler.Next(items, current, forward) is not { } nextId)
{
return false;
}

ClearMultiSelection();
ClearAddressSelection();
var element = _document.Elements.FirstOrDefault(e => e.Id == nextId);
if (element is not null)
{
_editor.Select(element);
}
else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control)
{
SelectAddressControl(control, 0);
}

Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
return true;
}

protected override bool IsInputKey(Keys keyData) =>
keyData is Keys.Delete or Keys.Back || base.IsInputKey(keyData);
keyData is Keys.Delete or Keys.Back
|| ((keyData & ~Keys.Shift) == Keys.Tab)
|| base.IsInputKey(keyData);
}

+ 213
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs Voir le fichier

@@ -1,3 +1,4 @@
using EnvelopeRenderer.Desktop.Core.Launch;
using EnvelopeRenderer.Desktop.Core.Csv;
using EnvelopeRenderer.Desktop.Core.Design;

@@ -141,6 +142,13 @@ public sealed class TemplateDesignerForm : Form
private readonly NumericUpDown _addressWidthInput = new() { DecimalPlaces = 2, Minimum = 1, Maximum = 100000m, Width = 80 };
// Gap between Address Control lines as a multiple of the previous line's font size
// (AddressControlLayout.LineSpacingMultiplier, default 1.25); below 1.0 packs lines tighter.
private readonly ComboBox _addressCaseInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 };
private readonly ComboBox _addressAnchorInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 };
private readonly Button _renderPdfButton = new() { Text = "Render &PDF...", AutoSize = true };
private readonly Button _addRectangleButton =new() { Text = "Add Rec&tangle", AutoSize = true };
private readonly Button _fillButton = new() { Text = string.Empty, Width = 60, Height = 24, FlatStyle = FlatStyle.Popup };
private readonly Button _clearFillButton = new() { Text = "None", Width = 60, Height = 24 };
private readonly Button _backgroundButton =new() { Text = "&Background...", AutoSize = true };
private readonly NumericUpDown _addressLineSpacingInput = new() { DecimalPlaces = 2, Increment = 0.05m, Minimum = 0.5m, Maximum = 5m, Width = 80 };
private readonly ListBox _addressLineList = new() { Width = 210, Height = 72, IntegralHeight = false };
private readonly Button _addAddressLineButton = new() { Text = "+", Width = 34, Height = 24 };
@@ -378,6 +386,7 @@ public sealed class TemplateDesignerForm : Form
try
{
TemplateLayoutXmlSerializer.Save(_document, dialog.FileName);
_canvas.BackgroundBaseDirectory = Path.GetDirectoryName(dialog.FileName);
SetFileStatus($"Saved to '{dialog.FileName}'.", isError: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
@@ -415,6 +424,8 @@ public sealed class TemplateDesignerForm : Form
_document.Elements.AddRange(loaded.Elements);
_document.AddressControls.Clear();
_document.AddressControls.AddRange(loaded.AddressControls);
_document.Background = loaded.Background;
_canvas.BackgroundBaseDirectory = Path.GetDirectoryName(dialog.FileName);

_dimensionsEditor.Reset(_document.Canvas);
RefreshDimensionControls();
@@ -426,6 +437,108 @@ public sealed class TemplateDesignerForm : Form
isError: false);
}

/// <summary>Renders the template being designed, right now, against the loaded CSV: saves the
/// current layout to a temporary template file, asks where to save the PDF, then runs the render
/// CLI in a progress window. Unsaved edits are included; nothing is written next to the
/// operator's own template file.</summary>
private void RenderFromDesigner()
{
if (_currentCsvPath is null || !File.Exists(_currentCsvPath))
{
MessageBox.Show(
this, "Load a CSV first (Load CSV...) so there is data to render.",
"Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}

if (_document.Elements.Count == 0 && _document.AddressControls.Count == 0)
{
MessageBox.Show(
this, "The template has nothing to render yet. Add some text, an Address Control or a rectangle.",
"Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}

using var saveDialog = new SaveFileDialog
{
Filter = "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*",
Title = "Save Rendered PDF As",
DefaultExt = "pdf",
OverwritePrompt = true,
};
if (saveDialog.ShowDialog(this) != DialogResult.OK)
{
return;
}

var tempTemplate = Path.Combine(Path.GetTempPath(), $"EnvelopeRenderer-design-{Guid.NewGuid():N}.xml");
try
{
TemplateLayoutXmlSerializer.Save(BuildDocumentForRender(), tempTemplate);
using var dialog = new RenderRunDialog(new RenderLaunchInputs(tempTemplate, _currentCsvPath, saveDialog.FileName));
dialog.ShowDialog(this);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
MessageBox.Show(this, $"Could not prepare the template for rendering: {ex.Message}", "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
try
{
File.Delete(tempTemplate);
}
catch (IOException)
{
// A leftover temp file is not worth failing over.
}
}
}

/// <summary>A copy of the document for the temporary template: the same elements, but a relative
/// background file path is made absolute against the template folder, since the temporary file
/// lives in the temp folder and would otherwise not find it.</summary>
private TemplateLayoutDocument BuildDocumentForRender()
{
var copy = new TemplateLayoutDocument(_document.Canvas);
copy.Elements.AddRange(_document.Elements);
copy.AddressControls.AddRange(_document.AddressControls);
if (_document.Background is { } background)
{
var file = background.File;
if (!string.IsNullOrEmpty(file) && !Path.IsPathRooted(file) && !string.IsNullOrEmpty(_canvas.BackgroundBaseDirectory))
{
file = Path.Combine(_canvas.BackgroundBaseDirectory, file);
}

copy.Background = new PageBackgroundLayout
{
File = file,
FileColumn = background.FileColumn,
Page = background.Page,
PageColumn = background.PageColumn,
};
}

return copy;
}

/// <summary>Opens the page-background dialog and applies the result to the template.</summary>
private void EditBackground()
{
using var dialog = new PageBackgroundDialog(_document.Background, _loadedCsvHeaders);
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}

_document.Background = dialog.Result;
_canvas.Invalidate();
SetFileStatus(
_document.Background is null ? "Page background cleared." : "Page background set.",
isError: false);
}

private void SetFileStatus(string message, bool isError)
{
_fileStatusLabel.Text = message;
@@ -714,6 +827,60 @@ public sealed class TemplateDesignerForm : Form
_collapseIfBlankInput.Location = new Point(leftMargin, (15 * rowHeight) + labelTop);
_propertiesPanel.Controls.Add(_collapseIfBlankInput);

// Address Control only: letter case of every line, and which edge the Y anchor pins to.
_addressCaseInput.Items.AddRange(new object[] { "As typed", "UPPERCASE", "lowercase" });
_addressAnchorInput.Items.AddRange(new object[] { "Top", "Bottom" });
_addressCaseInput.SelectedIndex = 0;
_addressAnchorInput.SelectedIndex = 0;
var caseLabel = new Label { Text = "Address text case:", AutoSize = true, Location = new Point(leftMargin, (16 * rowHeight) + labelTop) };
var anchorLabel = new Label { Text = "Y anchors to:", AutoSize = true, Location = new Point(leftMargin + 108, (16 * rowHeight) + labelTop) };
_addressCaseInput.Location = new Point(leftMargin, (16 * rowHeight) + inputTop);
_addressAnchorInput.Location = new Point(leftMargin + 108, (16 * rowHeight) + inputTop);
_propertiesPanel.Controls.Add(caseLabel);
_propertiesPanel.Controls.Add(anchorLabel);
_propertiesPanel.Controls.Add(_addressCaseInput);
_propertiesPanel.Controls.Add(_addressAnchorInput);
// Rectangle fill: a filled box behind the element's own text (needs a width and height).
var fillLabel = new Label { Text = "Fill (rectangle):", AutoSize = true, Location = new Point(leftMargin, (17 * rowHeight) + labelTop) };
_fillButton.Location = new Point(leftMargin, (17 * rowHeight) + inputTop);
_clearFillButton.Location = new Point(leftMargin + 68, (17 * rowHeight) + inputTop);
_propertiesPanel.Controls.Add(fillLabel);
_propertiesPanel.Controls.Add(_fillButton);
_propertiesPanel.Controls.Add(_clearFillButton);
_fillButton.Click += (_, _) => OnFillButtonClick();
_clearFillButton.Click += (_, _) =>
{
if (_canvas.SelectedElement is { } selectedElement)
{
selectedElement.FillColor = null;
_canvas.NotifyElementChanged();
RefreshPropertiesPanel();
}
};
_propertiesPanel.AutoScroll = true;
_addressCaseInput.SelectedIndexChanged += (_, _) =>
{
if (!_suppressEvents && _canvas.SelectedAddressControl is { } control)
{
control.TextCase = (AddressTextCase)_addressCaseInput.SelectedIndex;
_canvas.NotifyElementChanged();
}
};
_addressAnchorInput.SelectedIndexChanged += (_, _) =>
{
if (!_suppressEvents && _canvas.SelectedAddressControl is { } control)
{
// Keep the block where it is on the page: re-express Y for the new anchor edge.
var topBaseline = control.TopBaselineY;
control.VerticalAnchor = (AddressVerticalAnchor)_addressAnchorInput.SelectedIndex;
control.Y = control.VerticalAnchor == AddressVerticalAnchor.Bottom
? topBaseline - control.LastLineOffset
: topBaseline;
_canvas.NotifyElementChanged();
RefreshPropertiesPanel();
}
};

// Commits on Leave (not every keystroke, unlike the simpler FontFamily text box) — parsing
// a full run sequence and re-syncing the rebind combo/selection label on every keystroke
// while an operator is still mid-edit (e.g. typing an unfinished "{Full N") would be both
@@ -1027,6 +1194,38 @@ public sealed class TemplateDesignerForm : Form
}
}

private void OnFillButtonClick()
{
if (_canvas.SelectedElement is not { } selectedElement)
{
return;
}

var current = selectedElement.FillColor ?? new RgbColor(255, 255, 255);
using var dialog = new ColorDialog { Color = Color.FromArgb(current.R, current.G, current.B) };
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}

selectedElement.FillColor = new RgbColor(dialog.Color.R, dialog.Color.G, dialog.Color.B);

// The render needs both dimensions to draw the rectangle, so give the element a default box
// when it has none yet (existing dimensions are kept).
if (selectedElement.Width is not > 0)
{
selectedElement.Width = TemplateCanvasControl.DefaultRectangleWidth;
}

if (selectedElement.Height is not > 0)
{
selectedElement.Height = TemplateCanvasControl.DefaultRectangleHeight;
}

_canvas.NotifyElementChanged();
RefreshPropertiesPanel();
}

private void OnCanvasSelectionChanged()
{
_propertiesEditor.Select(_canvas.SelectedElement);
@@ -1109,6 +1308,14 @@ public sealed class TemplateDesignerForm : Form
_addressLineSpacingInput.Value = ClampToNumericRange(
_addressLineSpacingInput, (decimal)(selectedAddressControl?.LineSpacingMultiplier ?? AddressControlLayout.DefaultLineSpacingMultiplier));
_addressLineSpacingInput.Enabled = selectedAddressControl is not null;
_addressCaseInput.SelectedIndex = (int)(selectedAddressControl?.TextCase ?? AddressTextCase.None);
_addressCaseInput.Enabled = selectedAddressControl is not null;
_addressAnchorInput.SelectedIndex = (int)(selectedAddressControl?.VerticalAnchor ?? AddressVerticalAnchor.Top);
_addressAnchorInput.Enabled = selectedAddressControl is not null;
var fill = selected?.FillColor;
_fillButton.BackColor = fill is { } f ? Color.FromArgb(f.R, f.G, f.B) : SystemColors.Control;
_fillButton.Enabled = selected is not null;
_clearFillButton.Enabled = fill is not null;
RefreshAddressLineList(selectedAddressControl);

_fontFamilyInput.Text = selected?.FontFamily ?? selectedAddressLine?.FontFamily ?? string.Empty;
@@ -1238,6 +1445,12 @@ public sealed class TemplateDesignerForm : Form
_dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3);
panel.Controls.Add(_dynamicFieldColumnComboBox);
panel.Controls.Add(_addAddressControlButton);
_addRectangleButton.Click += (_, _) => _canvas.AddRectangleElement();
panel.Controls.Add(_addRectangleButton);
_renderPdfButton.Click += (_, _) => RenderFromDesigner();
panel.Controls.Add(_renderPdfButton);
_backgroundButton.Click += (_, _) => EditBackground();
panel.Controls.Add(_backgroundButton);
_deleteSelectedButton.Click += (_, _) =>
{
if (_canvas.RemoveSelectedElement())


Chargement…
Annuler
Enregistrer

Powered by TurnKey Linux.