From a647619bc19f9648607a4e4d659e08eb6e859660 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Wed, 30 Sep 2026 13:48:42 -0400 Subject: [PATCH] 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 --- code/CLI_CONTRACT.md | 5 +- code/TEMPLATE_FORMAT.md | 26 + .../AssemblyInfo.cs | 3 + .../BackgroundAndAddressOptionsTests.cs | 644 ++++++++++++++++++ .../ConsoleProgressReporterTests.cs | 21 + .../EnvelopeRenderer.Cli.Tests.csproj | 6 + .../FilledRectangleTests.cs | 175 +++++ .../PdfPageBitmapRendererTests.cs | 118 ++++ .../RenderEngineTests.cs | 6 + code/src/EnvelopeRenderer.Cli/Program.cs | 10 + .../Progress/ConsoleProgressReporter.cs | 10 +- .../Progress/IProgressReporter.cs | 7 + .../Render/AddressControlEnums.cs | 31 + .../Render/AddressLineCollapser.cs | 16 + .../Render/DebenuPdfRenderer.cs | 218 +++++- .../Render/IPdfRenderer.cs | 23 +- .../Render/RenderEngine.cs | 187 ++++- .../Render/TemplateAddressControl.cs | 28 +- .../Render/TemplateBackground.cs | 27 + .../Render/TemplateDocument.cs | 3 +- .../Render/TemplateElement.cs | 9 +- .../Render/TemplateXmlParser.cs | 123 +++- .../EnvelopeRenderer.Cli/Render/TextDraw.cs | 6 +- .../Design/AddressControlLayout.cs | 33 +- .../Design/AddressControlOptions.cs | 29 + .../Design/AddressControlRotateHandle.cs | 2 +- .../Design/AddressLineCollapser.cs | 16 + .../Design/CanvasElementEditor.cs | 70 +- .../Design/PageBackgroundLayout.cs | 63 ++ .../Design/SelectionCycler.cs | 36 + .../Design/TemplateLayoutDocument.cs | 3 + .../Design/TemplateLayoutXmlSerializer.cs | 149 ++++ .../Design/TemplatePreviewBuilder.cs | 9 +- .../Design/TextElementLayout.cs | 8 + .../Launch/ProgressEventKind.cs | 1 + .../Launch/ProgressEventParser.cs | 3 + .../Launch/RenderProgressStatusFormatter.cs | 11 + .../AddressOptionsAndBackgroundTests.cs | 170 +++++ .../FilledRectangleLayoutTests.cs | 110 +++ .../ProgressEventParserTests.cs | 22 + .../RectangleHeightHandleTests.cs | 99 +++ .../SelectionCyclerTests.cs | 67 ++ .../EnvelopeRenderer.Desktop.csproj | 4 + code/src/EnvelopeRenderer.Desktop/MainForm.cs | 27 +- .../Views/PageBackgroundDialog.cs | 223 ++++++ .../Views/PdfPageBitmapRenderer.cs | 136 ++++ .../Views/RenderRunDialog.cs | 121 ++++ .../Views/TemplateCanvasControl.cs | 255 ++++++- .../Views/TemplateDesignerForm.cs | 213 ++++++ 49 files changed, 3539 insertions(+), 43 deletions(-) create mode 100644 code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs create mode 100644 code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs create mode 100644 code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs create mode 100644 code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs create mode 100644 code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs create mode 100644 code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs create mode 100644 code/src/EnvelopeRenderer.Desktop/Views/PageBackgroundDialog.cs create mode 100644 code/src/EnvelopeRenderer.Desktop/Views/PdfPageBitmapRenderer.cs create mode 100644 code/src/EnvelopeRenderer.Desktop/Views/RenderRunDialog.cs diff --git a/code/CLI_CONTRACT.md b/code/CLI_CONTRACT.md index 67587c0..a2df317 100644 --- a/code/CLI_CONTRACT.md +++ b/code/CLI_CONTRACT.md @@ -44,11 +44,11 @@ one line per event to stdout in this fixed, space-separated, `key=value` format: PROGRESS elapsedMs= completed= [reason=] ``` -- `` is one of `startup`, `render`, `complete`, `failure`. +- `` 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 | diff --git a/code/TEMPLATE_FORMAT.md b/code/TEMPLATE_FORMAT.md index 45a4325..9296219 100644 --- a/code/TEMPLATE_FORMAT.md +++ b/code/TEMPLATE_FORMAT.md @@ -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. ``) 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. `Static label:` - `column=""` — 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. | + +## `` + +Optional, at most one, directly under ``. 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 diff --git a/code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs b/code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..acc524a --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/AssemblyInfo.cs @@ -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)] diff --git a/code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs new file mode 100644 index 0000000..d9381a5 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/BackgroundAndAddressOptionsTests.cs @@ -0,0 +1,644 @@ +using System.Drawing; +using DebenuPDFLibraryDLL1013; +using EnvelopeRenderer.Cli.Render; + +#pragma warning disable CA1416 + +namespace EnvelopeRenderer.Cli.Tests; + +/// 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. +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> Pages { get; } = new(); + public List Backgrounds { get; } = new(); + + public bool AddPage(double w, double h, IReadOnlyList 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(), new[] { control }); + var records = new List> + { + new Dictionary { ["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(""" + + + + + + """); + + 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(""" + + + + + + """); + + var result = TemplateXmlParser.Parse(path); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Contains("'case'")); + } + + private static string TextTemplate(string backgroundXml) + => $"""{backgroundXml}Hi"""; + + [Fact] + public void Parse_Background_StaticFileAndPage_ResolvesAgainstTemplateFolder() + { + var path = WriteTemplate(TextTemplate("""""")); + + 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("""""")); + + var background = TemplateXmlParser.Parse(path).Document!.Background; + + Assert.Equal("Form", background!.FileColumn); + Assert.Equal("FormPage", background.PageColumn); + Assert.Null(background.File); + } + + [Theory] + [InlineData("""""", "exactly one of")] + [InlineData("""""", "exactly one of")] + [InlineData("""""", "unsupported")] + [InlineData("""""", "'page'")] + [InlineData("""""", "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(), 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> + { + new Dictionary { ["A"] = "1" }, new Dictionary { ["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> + { + new Dictionary { ["Form"] = "a.pdf", ["Pg"] = " 4 " }, + new Dictionary { ["Form"] = @"D:\abs\b.PNG", ["Pg"] = "junk" }, + new Dictionary { ["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> { new Dictionary { ["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> + { + new Dictionary { ["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; + } + + /// Renders one page of a PDF and reports how many clearly red / clearly blue pixels it has. + 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> + { + new Dictionary { ["Form"] = "source.pdf", ["Pg"] = "1" }, + new Dictionary { ["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> + { + new Dictionary { ["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> + { + new Dictionary { ["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(), + 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 Pages)? Sequence { get; private set; } + public List Backgrounds { get; } = new(); + + public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList pagesInRecordOrder, out string? error) + { + Sequence = (pdfPath, pagesInRecordOrder.ToList()); + error = null; + return true; + } + + public bool AddPage(double w, double h, IReadOnlyList 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> PageRecords(params string[] pages) + => pages.Select(p => (IReadOnlyDictionary)new Dictionary { ["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(), + new TemplateBackground("source.pdf", null, 1, "Pg", dir)); + var records = new List> + { + new Dictionary { ["Pg"] = "3", ["Name"] = "ALICE" }, + new Dictionary { ["Pg"] = "1", ["Name"] = "BOB" }, + new Dictionary { ["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(), + new TemplateBackground("source.pdf", null, 1, "Pg", dir)); + var records = new List> + { + new Dictionary { ["Pg"] = "2", ["Name"] = "ALICE" }, + new Dictionary { ["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); + } + } +} diff --git a/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs index 3fc5bea..5f2eed8 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs @@ -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)); + } } diff --git a/code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj b/code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj index faa0878..18d955e 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj +++ b/code/src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj @@ -27,4 +27,10 @@ + + + + + \ No newline at end of file diff --git a/code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs new file mode 100644 index 0000000..112d834 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/FilledRectangleTests.cs @@ -0,0 +1,175 @@ +using System.Drawing; +using DebenuPDFLibraryDLL1013; +using EnvelopeRenderer.Cli.Render; + +#pragma warning disable CA1416 + +namespace EnvelopeRenderer.Cli.Tests; + +/// A <text> element with fill is a filled rectangle (cover-up) that its own text, +/// or any element with a higher render order, draws over. +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(""" + + + + """); + + 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("""Hi""", "'width' and 'height'")] + [InlineData("""Hi""", "'fill'")] + [InlineData("""Hi""", "rotated")] + public void Parse_InvalidFill_Fails(string textXml, string expected) + { + var result = TemplateXmlParser.Parse(WriteTemplate( + $"""{textXml}""")); + + 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()); + var draws = new List>(); + var renderer = new CapturingRenderer(draws); + + var result = RenderEngine.Render( + template, new[] { "A" }, + new List> { new Dictionary { ["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> _draws; + public CapturingRenderer(List> draws) => _draws = draws; + + public bool AddPage(double w, double h, IReadOnlyList 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(), + 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> { new Dictionary { ["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); + } + } +} diff --git a/code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs new file mode 100644 index 0000000..d3be9ae --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/PdfPageBitmapRendererTests.cs @@ -0,0 +1,118 @@ +using System.Drawing; +using EnvelopeRenderer.Cli.Render; +using EnvelopeRenderer.Desktop.Views; + +#pragma warning disable CA1416 + +namespace EnvelopeRenderer.Cli.Tests; + +/// The designer canvas renders a PDF background page with DARenderPageToDC. +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); + } +} diff --git a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs index d0d100d..480c51c 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs @@ -25,8 +25,14 @@ public class RenderEngineTests public bool FailOnPage { get; set; } public bool FailOnSave { get; set; } + public List Backgrounds { get; } = new(); + public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, out string? error) + => ((IPdfRenderer)this).AddPage(pageWidth, pageHeight, draws, null, out error); + + bool IPdfRenderer.AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, PageBackground? background, out string? error) { + Backgrounds.Add(background); if (FailOnPage) { error = "simulated page failure"; diff --git a/code/src/EnvelopeRenderer.Cli/Program.cs b/code/src/EnvelopeRenderer.Cli/Program.cs index 1c36d24..9c1d288 100644 --- a/code/src/EnvelopeRenderer.Cli/Program.cs +++ b/code/src/EnvelopeRenderer.Cli/Program.cs @@ -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 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( diff --git a/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs index 4f620fc..4f08506 100644 --- a/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs +++ b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs @@ -17,6 +17,7 @@ public sealed class ConsoleProgressReporter : IProgressReporter private readonly Func _elapsedMillisecondsProvider; private readonly long _minIntervalMs; private long? _lastEmittedAtMs; + private int _lastCompleted; public ConsoleProgressReporter( TextWriter output, Func 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); diff --git a/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs index af5e42d..d9b232b 100644 --- a/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs +++ b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs @@ -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. void Failure(int completed, string reason); + + /// A human-readable "what is happening now" message (template loaded, background + /// mode chosen, saving...). Never throttled; emitted as a PROGRESS info line. Default + /// no-op so reporters that only care about the numeric events need not implement it. + void Info(string message) + { + } } diff --git a/code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs b/code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs new file mode 100644 index 0000000..69e18bc --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Render/AddressControlEnums.cs @@ -0,0 +1,31 @@ +namespace EnvelopeRenderer.Cli.Render; + +/// Letter-case transform applied to every line of an Address Control after its runs are +/// resolved for a record (literal and field text alike). leaves text exactly as +/// authored/read, so every template written before this option renders unchanged. +public enum AddressTextCase +{ + None = 0, + Upper = 1, + Lower = 2, +} + +/// Which edge of the Address Control block its authored y anchor pins to. +/// (the long-standing behavior): y is the first line's baseline and the +/// block grows downward. : y 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. +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, + }; +} diff --git a/code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs b/code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs index af4e30c..b05f8a6 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/AddressLineCollapser.cs @@ -71,4 +71,20 @@ public static class AddressLineCollapser return result; } + + /// For a bottom-anchored Address Control: the vertical shift that puts the last + /// visible line's baseline on (negative = move down). 0 when + /// no line is visible. + public static double BottomAnchorShift(IReadOnlyList resolved, double anchorY) + { + for (var i = resolved.Count - 1; i >= 0; i--) + { + if (resolved[i].Visible) + { + return anchorY - resolved[i].EffectiveY; + } + } + + return 0; + } } diff --git a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs index 202955d..57ad675 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs @@ -49,6 +49,16 @@ public sealed class DebenuPdfRenderer : IPdfRenderer private readonly string _batchTempDirectory; private PDFLibrary _pdf; private readonly Dictionary _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 _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 draws, out string? error) + => AddPage(pageWidth, pageHeight, draws, background: null, out error); + + public bool SupportsPdfBackgroundSequence => true; + + public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList 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 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 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; + } + + /// DPI a PDF background page is rasterized at. See . + private const int PdfBackgroundDpi = 300; + + private readonly Dictionary<(string Path, int Page), string> _rasterizedPdfPages = new(); + + /// 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 + /// -DPI raster of that page: Debenu 10.13's vector path for this + /// (CapturePage + DrawCapturedPage, 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 (RenderPageToFile, confirmed working) and then follows the image path � cached per + /// (file, page) so a background reused across thousands of records is rasterized once. + 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; diff --git a/code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs b/code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs index e2716ee..8f9e741 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/IPdfRenderer.cs @@ -10,6 +10,27 @@ public interface IPdfRenderer : IDisposable /// 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 for why. - bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, out string? error); + bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, out string? error) + => AddPage(pageWidth, pageHeight, draws, background: null, out error); + + /// As above, additionally drawing (when non-null) + /// behind every text draw, stretched to the page size. + bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, PageBackground? background, out string? error); + /// True when is supported (the real Debenu + /// renderer); test fakes keep the default false. + bool SupportsPdfBackgroundSequence => false; + + /// Vector PDF background: builds the output document up front as a copy of + /// pages in the given record order ([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 + /// 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. + bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList pagesInRecordOrder, out string? error) + { + error = "PDF background sequences are not supported by this renderer."; + return false; + } + bool Save(string outputPath, out string? error); } diff --git a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs index 4f05e50..ba8a39c 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs @@ -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> ?? 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); } + /// 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. + private static bool TryPlanPdfSequence( + TemplateBackground background, + IReadOnlyList> records, + out string? pdfPath, + out List? pages, + out string reason) + { + pdfPath = null; + pages = null; + reason = string.Empty; + if (records.Count == 0) + { + reason = "no records"; + return false; + } + + var seen = new HashSet(); + var list = new List(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 BackgroundColumns(TemplateBackground? background) + { + if (background?.FileColumn is { } fileColumn) + { + yield return fileColumn; + } + + if (background?.PageColumn is { } pageColumn) + { + yield return pageColumn; + } + } + + /// Resolves this record's background: a static file, or the record's + /// fileColumn value (blank = no background for this record). A PDF's page is the static + /// page or the record's pageColumn value, which must be a whole number >= 1. + /// Relative paths resolve against the template's folder. + private static bool TryResolveBackground( + TemplateBackground? template, IReadOnlyDictionary 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; + } + /// 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); diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs index 439930f..e0999fa 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateAddressControl.cs @@ -19,7 +19,9 @@ public sealed record TemplateAddressControl( IReadOnlyList 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( } } + /// Distance from the first line's baseline down to the last line's baseline. + public double LastLineOffset + { + get + { + var total = 0.0; + for (var i = 0; i < Lines.Count - 1; i++) + { + total += Lines[i].Size * LineSpacingMultiplier; + } + + return total; + } + } + + /// Baseline of the first line of the full, uncollapsed authored block. Equals + /// for a top-anchored control; for a bottom-anchored control is + /// the last line's baseline, so the block sits higher. + public double TopBaselineY => VerticalAnchor == AddressVerticalAnchor.Bottom ? Y + LastLineOffset : Y; + /// 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 /// Y + tallest line's font size, bottom edge at Y - Height, 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)); } } diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs new file mode 100644 index 0000000..533db88 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateBackground.cs @@ -0,0 +1,27 @@ +namespace EnvelopeRenderer.Cli.Render; + +/// A page background (image or PDF page) drawn behind every output page, stretched to the +/// page size. The file comes from exactly one of (static) or +/// (per-record CSV value); for a PDF, the page number comes from +/// (static, default 1) or (per-record). Relative file +/// paths resolve against (the template's own folder). Whether a file is +/// a PDF or an image is decided by its extension. +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); +} + +/// One record's resolved background for the renderer: an absolute-or-relative-resolved +/// , whether it is a PDF, and (PDF only) the 1-based . +public sealed record PageBackground(string FilePath, bool IsPdf, int Page); diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs index f4268fd..29c7e3e 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateDocument.cs @@ -10,7 +10,8 @@ public sealed record TemplateDocument( double PageWidth, double PageHeight, IReadOnlyList Elements, - IReadOnlyList AddressControls) + IReadOnlyList AddressControls, + TemplateBackground? Background = null) { public TemplateDocument(double pageWidth, double pageHeight, IReadOnlyList elements) : this(pageWidth, pageHeight, elements, Array.Empty()) diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs index 3b54656..5858781 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs @@ -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) { + /// True for a filled rectangle element: plus an explicit + /// and 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. + public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0; + /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has /// been set — is independent and optional, no longer required for /// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs index 2c6ef41..e982052 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateXmlParser.cs @@ -52,6 +52,8 @@ public static class TemplateXmlParser errors.Add("Template has no or 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 // '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(string raw, out T value) where T : struct, Enum + => Enum.TryParse(raw.Trim(), ignoreCase: true, out value) && Enum.IsDefined(value); + + /// Optional <background file="..." | fileColumn="..." [page="N" | pageColumn="..."] /> + /// directly under the root. A static file 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. + private static TemplateBackground? ParseBackground(XElement root, string templatePath, List errors) + { + var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList(); + if (nodes.Count == 0) + { + return null; + } + + const string label = ""; + if (nodes.Count > 1) + { + errors.Add("Template has more than one 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); } /// Optional color="#RRGGBB"; absent means black (the pre-color behavior). diff --git a/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs b/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs index e9a61b7..8fc46fc 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/TextDraw.cs @@ -14,6 +14,9 @@ namespace EnvelopeRenderer.Cli.Render; /// wraps within this width, clipping any wrapped content that exceeds /// , instead of drawing as one unbounded line at the (X, Y) baseline. /// null (the default) preserves every pre-Sprint-9 render exactly. +/// When set together with and +/// , a filled rectangle covering that box (bottom-left at X, Y) is drawn +/// before this draw's text, which may be empty. Not rotated. /// Paired with — see its remarks. 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); diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs index fff370c..d900d36 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlLayout.cs @@ -21,6 +21,12 @@ public sealed class AddressControlLayout /// . public double RotationAngle { get; set; } + /// Letter-case transform applied to every line resolved text. + public AddressTextCase TextCase { get; set; } = AddressTextCase.None; + + /// Which edge pins to - see . + public AddressVerticalAnchor VerticalAnchor { get; set; } = AddressVerticalAnchor.Top; + public List Lines { get; } = new(); public static AddressControlLayout CreateDefault(double x, double y, int zOrder = 0) @@ -70,9 +76,30 @@ public sealed class AddressControlLayout return true; } + /// Distance from the first line baseline down to the last line baseline. + public double LastLineOffset + { + get + { + var total = 0.0; + for (var i = 0; i < Lines.Count - 1; i++) + { + total += Lines[i].FontSize * LineSpacingMultiplier; + } + + return total; + } + } + + /// Baseline of the first line of the full, uncollapsed authored block: + /// for a top-anchored control; + for a + /// bottom-anchored one (where is the last line baseline). All box, pivot and + /// line-position geometry uses this, never directly. + 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)); } } diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs new file mode 100644 index 0000000..e7b0197 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlOptions.cs @@ -0,0 +1,29 @@ +namespace EnvelopeRenderer.Desktop.Core.Design; + +/// Letter-case transform for every line of an Address Control, applied to the resolved +/// text (literal and field alike). leaves text as authored / as read. +public enum AddressTextCase +{ + None = 0, + Upper = 1, + Lower = 2, +} + +/// Which edge of the Address Control block its Y anchor pins to: +/// = first line baseline (block grows down); = last visible line baseline +/// (block grows up). +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, + }; +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs index 8d0dd07..8f8bd0d 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressControlRotateHandle.cs @@ -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); } /// The handle's local (unrotated) position: the box's own top-center plus diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs index d4fed3f..62184d4 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/AddressLineCollapser.cs @@ -61,4 +61,20 @@ public static class AddressLineCollapser return result; } + + /// For a bottom-anchored Address Control: the vertical shift that puts the last + /// visible line baseline on (negative = move down). 0 when no line + /// is visible. + public static double BottomAnchorShift(IReadOnlyList resolved, double anchorY) + { + for (var i = resolved.Count - 1; i >= 0; i--) + { + if (resolved[i].Visible) + { + return anchorY - resolved[i].EffectiveY; + } + } + + return 0; + } } diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs index 69cacaa..6e88dce 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs @@ -36,6 +36,7 @@ public sealed class CanvasElementEditor private (double Dx, double Dy)? _dragOffset; private bool _isRotating; private bool _isResizingWidth; + private bool _isResizingHeight; /// Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer /// alongside , 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; + /// True during a width or a height resize-drag. + public bool IsResizing => _isResizingWidth || _isResizingHeight; + + /// World-space position of the selected filled rectangle's height handle (top-edge + /// midpoint, rotated like the other handles), or null when nothing is selected or the + /// selection is not a filled rectangle (). Dragging it + /// sets just as the right-edge handle sets Width. + 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; + } + + /// Starts a height resize-drag of the selected filled rectangle. No-op otherwise. + public void BeginHeightResizeDrag() + { + if (Selected is not { HasFill: true }) + { + return; + } + + _isResizingHeight = true; + _isResizingWidth = false; + _dragOffset = null; + } + /// 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 null if /// nothing is selected. Sits at the right-edge midpoint ( + @@ -448,11 +500,27 @@ public sealed class CanvasElementEditor /// hasn't begun. 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 diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs new file mode 100644 index 0000000..ca3aa0c --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/PageBackgroundLayout.cs @@ -0,0 +1,63 @@ +using System.Globalization; + +namespace EnvelopeRenderer.Desktop.Core.Design; + +/// A template-level page background (image or PDF page) drawn behind every output page. +/// The file is either a fixed path () or read per record from a CSV column +/// () - exactly one is set. For a PDF the page is a fixed number +/// () or read per record from . Mirrors the CLI +/// TemplateBackground (independently implemented, per the desktop/CLI split). +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); + + /// The file to show for one record: the fixed , or the record + /// value; null when blank/unknown. Relative paths resolve against + /// (the template folder) when given. + public string? ResolveFile(IReadOnlyDictionary? 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); + } + + /// The 1-based PDF page for one record, or null when a per-record value is not a + /// whole number >= 1. + public int? ResolvePage(IReadOnlyDictionary? 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; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs new file mode 100644 index 0000000..42a4663 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/SelectionCycler.cs @@ -0,0 +1,36 @@ +namespace EnvelopeRenderer.Desktop.Core.Design; + +/// 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. +public static class SelectionCycler +{ + public readonly record struct Item(Guid Id, int ZOrder, double X, double Y); + + /// The id to select next, or null when there are no items. With no current + /// selection (or one that is not in ), forward picks the first item and + /// backward picks the last. + public static Guid? Next(IEnumerable 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; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs index 7ead66b..648c3d0 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutDocument.cs @@ -19,6 +19,9 @@ public sealed class TemplateLayoutDocument /// element kind is introduced. public List AddressControls { get; } = new(); + /// Optional page background image / PDF page drawn behind every page; null = none. + public PageBackgroundLayout? Background { get; set; } + public TemplateLayoutDocument(CanvasSettings canvas) { Canvas = canvas; diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs index 1c603c2..754f24a 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplateLayoutXmlSerializer.cs @@ -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 errors) + { + var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList(); + if (nodes.Count == 0) + { + return null; + } + + const string label = ""; + if (nodes.Count > 1) + { + errors.Add("Template has more than one 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($" has an unrecognized 'canvasUnit' value: '{unitRaw}'."); } + var background = ParseBackground(root, errorList); + var elements = new List(); var addressControls = new List(); 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.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(); 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; diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs index bf129ac..f1ca307 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TemplatePreviewBuilder.cs @@ -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); diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs index 4142088..29e910b 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs @@ -39,6 +39,14 @@ public sealed class TextElementLayout /// scope for this sprint's stories and is tracked as a known gap, not silently implemented. public RgbColor Color { get; set; } = RgbColor.Black; + /// Optional filled-rectangle background covering this element box (needs both + /// and ). The element text, which may be empty, draws + /// over it, and elements with a higher z-order draw over that. null = no fill. + public RgbColor? FillColor { get; set; } + + /// True when is set and the box is fully defined. + public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0; + /// Stacking order; 0 is bottom-most, matching the product's numeric z-order rule. public int ZOrder { get; set; } diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs index 835af2a..a37911c 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs @@ -9,4 +9,5 @@ public enum ProgressEventKind Render, Complete, Failure, + Info, } diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs index d8a1cbf..ec82e26 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs @@ -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; diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs index 993f692..b6f99e8 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs @@ -11,6 +11,16 @@ namespace EnvelopeRenderer.Desktop.Core.Launch; /// public static class RenderProgressStatusFormatter { + /// 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. + 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, }; } diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs new file mode 100644 index 0000000..b09f7ac --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/AddressOptionsAndBackgroundTests.cs @@ -0,0 +1,170 @@ +using EnvelopeRenderer.Desktop.Core.Design; + +namespace EnvelopeRenderer.Desktop.Tests; + +/// Address Control text case / vertical anchor and the template page background: preview +/// parity with the CLI scenarios (BackgroundAndAddressOptionsTests), geometry, XML round trip. +public class AddressOptionsAndBackgroundTests +{ + private static IReadOnlyDictionary 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 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("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void Xml_InvalidBackground_FailsToLoad(string backgroundXml) + { + var path = TempFile(); + File.WriteAllText(path, + $"{backgroundXml}Hi"); + + Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors)); + Assert.Contains(errors, e => e.Contains("")); + } + + [Fact] + public void Xml_InvalidCase_FailsToLoad() + { + var path = TempFile(); + File.WriteAllText(path, + "" + + ""); + + 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")); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs new file mode 100644 index 0000000..73c9bf5 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/FilledRectangleLayoutTests.cs @@ -0,0 +1,110 @@ +using EnvelopeRenderer.Desktop.Core.Design; + +namespace EnvelopeRenderer.Desktop.Tests; + +/// 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. +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, + "Hi"); + + Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors)); + Assert.Contains(errors, e => e.Contains("'fill'")); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs index 752abed..c7cbebf 100644 --- a/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs +++ b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs @@ -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)); + } } diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs new file mode 100644 index 0000000..b02eeee --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/RectangleHeightHandleTests.cs @@ -0,0 +1,99 @@ +using EnvelopeRenderer.Desktop.Core.Design; + +namespace EnvelopeRenderer.Desktop.Tests; + +/// A filled rectangle gets a top-edge height handle that sets Height by dragging, just as +/// the right-edge handle sets Width. +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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs new file mode 100644 index 0000000..6d15e28 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/SelectionCyclerTests.cs @@ -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(), 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)); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj index 33ad457..dc27577 100644 --- a/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj +++ b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj @@ -11,6 +11,10 @@ + + +