- Address Control: whole-control upper/lowercase and top/bottom vertical anchor - Template page background (image or PDF page; file and page fixed or from CSV columns): vector PDF pages via CopyPageRangesEx when each record uses a distinct page, 300 DPI raster fallback otherwise; canvas renders PDF pages with DARenderPageToDC and always shows the background - Filled rectangle (fill attribute on text elements) with design-view outline and a height drag handle - CLI emits PROGRESS info lines; desktop shows a run log - Designer: Render PDF button, Tab/Shift+Tab to cycle items - Fix: saving a width-only text element no longer throws - Serialize Debenu real-DLL tests (parallel runs made LoadFromFile flaky) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>master
| @@ -44,11 +44,11 @@ one line per event to stdout in this fixed, space-separated, `key=value` format: | |||||
| PROGRESS <kind> elapsedMs=<integer> completed=<integer> [reason=<text to end of line>] | PROGRESS <kind> elapsedMs=<integer> completed=<integer> [reason=<text to end of line>] | ||||
| ``` | ``` | ||||
| - `<kind>` is one of `startup`, `render`, `complete`, `failure`. | |||||
| - `<kind>` is one of `startup`, `render`, `info`, `complete`, `failure`. | |||||
| - `elapsedMs` — milliseconds since the render phase started, as an integer. Always present. | - `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 | - `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). | (`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 | 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 | 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. | 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 | | | `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 | | | `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 | | | `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 | | | `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 | | ||||
| @@ -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 | `"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. | always embedded in the output PDF so the reader doesn't need it installed too. | ||||
| - `size` — required, positive, points. | - `size` — required, positive, points. | ||||
| - `fill` — optional `#RRGGBB`. Turns the element into a **filled rectangle** covering its box | |||||
| (bottom-left at `x`,`y`, size `width` x `height`, both required with `fill`, no `angle`). It | |||||
| draws over the page background and anything earlier in the file; the element's own text | |||||
| (which may be empty, e.g. `<text x=".." y=".." font="Arial" size="12" width="120" height="40" | |||||
| fill="#FFFFFF" />`) draws on top of it, and so does any element later in the file. Use white to | |||||
| cover text in a background PDF. | |||||
| - Content is either the legacy single-run shape or the Sprint 5 multi-run shape — never both: | - Content is either the legacy single-run shape or the Sprint 5 multi-run shape — never both: | ||||
| - inline text content — static, printed as-is on every page, e.g. `<text ...>Static label:</text>` | - inline text content — static, printed as-is on every page, e.g. `<text ...>Static label:</text>` | ||||
| - `column="<CSV header>"` — dynamic, pulled from that column per record. Column matching is | - `column="<CSV header>"` — dynamic, pulled from that column per record. Column matching is | ||||
| @@ -205,6 +211,26 @@ The rotation pivot is always computed from the control's authored, unrotated `x` | |||||
| `height`, never from any record's collapse-shifted line positions, so two records differing only | `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. | in whether a blank optional line collapses still rotate around the identical pivot. | ||||
| ### Address Control text case and vertical anchor | |||||
| | Attribute | Values | Default | Meaning | | |||||
| |---|---|---|---| | |||||
| | `case` | `none`, `upper`, `lower` | `none` | Upper/lowercases every line of the control after its runs resolve (literal and field text alike). | | |||||
| | `verticalAnchor` | `top`, `bottom` | `top` | `top`: `y` is the first line baseline and the block grows down. `bottom`: `y` is the last *visible* line baseline and the block grows up, so collapsed blank lines never move the last line. | | |||||
| ## `<background>` | |||||
| Optional, at most one, directly under `<envelopeTemplate>`. Draws an image or one PDF page behind every output page, stretched to the page size. | |||||
| | Attribute | Meaning | | |||||
| |---|---| | |||||
| | `file` | Fixed path to `.pdf`, `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, `.tif` or `.tiff`. Relative paths resolve against the template's folder. | | |||||
| | `fileColumn` | CSV column holding the file path per record (blank value = no background for that record). Use exactly one of `file` / `fileColumn`. | | |||||
| | `page` | PDF page number (default `1`). | | |||||
| | `pageColumn` | CSV column holding the PDF page number per record. Use at most one of `page` / `pageColumn`. | | |||||
| When a run uses a different page of one PDF for every record (via `pageColumn`, any order), the output is built as a page-ordered copy of that PDF and each record's text is drawn on top of its own page, so the background stays vector. Otherwise (repeated pages, a fixed `page` with several records, or `fileColumn` files) PDF pages are drawn as a 300 DPI raster of the page. A missing file, or a page number beyond the PDF's page count, fails the render. | |||||
| ## Known gaps | ## Known gaps | ||||
| - **Font resolution failure is blocking**, not a warning — matches the Definition of Done's | - **Font resolution failure is blocking**, not a warning — matches the Definition of Done's | ||||
| @@ -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)] | |||||
| @@ -0,0 +1,644 @@ | |||||
| using System.Drawing; | |||||
| using DebenuPDFLibraryDLL1013; | |||||
| using EnvelopeRenderer.Cli.Render; | |||||
| #pragma warning disable CA1416 | |||||
| namespace EnvelopeRenderer.Cli.Tests; | |||||
| /// <summary>Address Control text case / vertical anchor and the template-level page background: | |||||
| /// parsing, engine orchestration (fake renderer), and a real-DLL render of image and PDF backgrounds.</summary> | |||||
| public class BackgroundAndAddressOptionsTests | |||||
| { | |||||
| private static string WriteTemplate(string xml) | |||||
| { | |||||
| var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml"); | |||||
| File.WriteAllText(path, xml); | |||||
| return path; | |||||
| } | |||||
| private static string DllPath() => Path.Combine( | |||||
| AppContext.BaseDirectory, | |||||
| Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | |||||
| private sealed class RecordingRenderer : IPdfRenderer | |||||
| { | |||||
| public List<IReadOnlyList<TextDraw>> Pages { get; } = new(); | |||||
| public List<PageBackground?> Backgrounds { get; } = new(); | |||||
| public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error) | |||||
| { | |||||
| Pages.Add(draws); | |||||
| Backgrounds.Add(background); | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public bool Save(string outputPath, out string? error) | |||||
| { | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public void Dispose() { } | |||||
| } | |||||
| private static TemplateAddressControl Control( | |||||
| AddressTextCase textCase = AddressTextCase.None, | |||||
| AddressVerticalAnchor anchor = AddressVerticalAnchor.Top) | |||||
| => new(120, 500, 180, new[] | |||||
| { | |||||
| new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Name") }), | |||||
| new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun(null, "Line2") }), | |||||
| new TemplateAddressControlLine("Arial", 12, new[] { new TemplateTextRun("Mixed Case", null) }), | |||||
| }, TextCase: textCase, VerticalAnchor: anchor); | |||||
| private static RecordingRenderer RenderControl(TemplateAddressControl control, string line2) | |||||
| { | |||||
| var template = new TemplateDocument(297, 684, Array.Empty<TemplateElement>(), new[] { control }); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Name"] = "Alice Smith", ["Line2"] = line2 }, | |||||
| }; | |||||
| var renderer = new RecordingRenderer(); | |||||
| var result = RenderEngine.Render(template, new[] { "Name", "Line2" }, records, renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| return renderer; | |||||
| } | |||||
| [Theory] | |||||
| [InlineData(AddressTextCase.None, "Alice Smith", "Mixed Case")] | |||||
| [InlineData(AddressTextCase.Upper, "ALICE SMITH", "MIXED CASE")] | |||||
| [InlineData(AddressTextCase.Lower, "alice smith", "mixed case")] | |||||
| public void AddressControl_TextCase_AppliesToFieldAndLiteralText(AddressTextCase textCase, string name, string literal) | |||||
| { | |||||
| var draws = RenderControl(Control(textCase), "x").Pages[0]; | |||||
| Assert.Equal(name, draws[0].Text); | |||||
| Assert.Equal(literal, draws[2].Text); | |||||
| } | |||||
| [Fact] | |||||
| public void AddressControl_TopAnchor_FirstLineOnAnchorAndBlockGrowsDown() | |||||
| { | |||||
| var draws = RenderControl(Control(), "Suite 4").Pages[0]; | |||||
| Assert.Equal(new[] { 500.0, 485.0, 470.0 }, draws.Select(d => d.Y).ToArray()); | |||||
| } | |||||
| [Fact] | |||||
| public void AddressControl_BottomAnchor_LastLineOnAnchorAndBlockGrowsUp() | |||||
| { | |||||
| var draws = RenderControl(Control(anchor: AddressVerticalAnchor.Bottom), "Suite 4").Pages[0]; | |||||
| Assert.Equal(new[] { 530.0, 515.0, 500.0 }, draws.Select(d => d.Y).ToArray()); | |||||
| } | |||||
| [Fact] | |||||
| public void AddressControl_BottomAnchor_CollapsedMiddleLine_KeepsLastVisibleLineOnAnchor() | |||||
| { | |||||
| var draws = RenderControl(Control(anchor: AddressVerticalAnchor.Bottom), "").Pages[0]; | |||||
| Assert.Equal(2, draws.Count); | |||||
| Assert.Equal(new[] { 515.0, 500.0 }, draws.Select(d => d.Y).ToArray()); | |||||
| } | |||||
| [Fact] | |||||
| public void Parse_AddressControlCaseAndVerticalAnchor_ReadFromXml() | |||||
| { | |||||
| var path = WriteTemplate(""" | |||||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||||
| <addressControl x="1" y="2" width="180" case="UPPER" verticalAnchor="bottom"> | |||||
| <line font="Arial" size="12"><run column="Name" /></line> | |||||
| </addressControl> | |||||
| </envelopeTemplate> | |||||
| """); | |||||
| var result = TemplateXmlParser.Parse(path); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| var control = Assert.Single(result.Document!.AddressControls); | |||||
| Assert.Equal(AddressTextCase.Upper, control.TextCase); | |||||
| Assert.Equal(AddressVerticalAnchor.Bottom, control.VerticalAnchor); | |||||
| } | |||||
| [Fact] | |||||
| public void Parse_AddressControlBadCase_Fails() | |||||
| { | |||||
| var path = WriteTemplate(""" | |||||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||||
| <addressControl x="1" y="2" width="180" case="title"> | |||||
| <line font="Arial" size="12"><run column="Name" /></line> | |||||
| </addressControl> | |||||
| </envelopeTemplate> | |||||
| """); | |||||
| var result = TemplateXmlParser.Parse(path); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains("'case'")); | |||||
| } | |||||
| private static string TextTemplate(string backgroundXml) | |||||
| => $"""<envelopeTemplate pageWidth="297" pageHeight="684">{backgroundXml}<text x="1" y="2" font="Arial" size="12">Hi</text></envelopeTemplate>"""; | |||||
| [Fact] | |||||
| public void Parse_Background_StaticFileAndPage_ResolvesAgainstTemplateFolder() | |||||
| { | |||||
| var path = WriteTemplate(TextTemplate("""<background file="forms/bg.pdf" page="2" />""")); | |||||
| var background = TemplateXmlParser.Parse(path).Document!.Background; | |||||
| Assert.NotNull(background); | |||||
| Assert.Equal("forms/bg.pdf", background!.File); | |||||
| Assert.Equal(2, background.Page); | |||||
| Assert.Equal(Path.GetDirectoryName(path), background.BaseDirectory); | |||||
| } | |||||
| [Fact] | |||||
| public void Parse_Background_ColumnBoundFileAndPage() | |||||
| { | |||||
| var path = WriteTemplate(TextTemplate("""<background fileColumn="Form" pageColumn="FormPage" />""")); | |||||
| var background = TemplateXmlParser.Parse(path).Document!.Background; | |||||
| Assert.Equal("Form", background!.FileColumn); | |||||
| Assert.Equal("FormPage", background.PageColumn); | |||||
| Assert.Null(background.File); | |||||
| } | |||||
| [Theory] | |||||
| [InlineData("""<background />""", "exactly one of")] | |||||
| [InlineData("""<background file="a.pdf" fileColumn="X" />""", "exactly one of")] | |||||
| [InlineData("""<background file="a.txt" />""", "unsupported")] | |||||
| [InlineData("""<background file="a.pdf" page="0" />""", "'page'")] | |||||
| [InlineData("""<background file="a.pdf" page="2" pageColumn="P" />""", "both")] | |||||
| public void Parse_Background_Invalid_Fails(string backgroundXml, string expected) | |||||
| { | |||||
| var result = TemplateXmlParser.Parse(WriteTemplate(TextTemplate(backgroundXml))); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains(expected)); | |||||
| } | |||||
| [Fact] | |||||
| public void Parse_NoBackground_IsNull() | |||||
| => Assert.Null(TemplateXmlParser.Parse(WriteTemplate(TextTemplate(""))).Document!.Background); | |||||
| private static TemplateDocument WithBackground(TemplateBackground background) | |||||
| => new(297, 684, new[] { TemplateElement.Static(1, 2, "Arial", 12, "Hi") }, | |||||
| Array.Empty<TemplateAddressControl>(), background); | |||||
| [Fact] | |||||
| public void Render_StaticBackground_PassedToEveryPage() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("bg.pdf", null, 3, null, @"C:\forms")); | |||||
| var renderer = new RecordingRenderer(); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["A"] = "1" }, new Dictionary<string, string> { ["A"] = "2" }, | |||||
| }; | |||||
| var result = RenderEngine.Render(template, new[] { "A" }, records, renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded); | |||||
| Assert.All(renderer.Backgrounds, b => Assert.Equal(new PageBackground(@"C:\forms\bg.pdf", true, 3), b)); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_ColumnBoundBackground_ResolvesPerRecord_BlankFileMeansNone() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground(null, "Form", 1, "Pg", @"C:\forms")); | |||||
| var renderer = new RecordingRenderer(); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Form"] = "a.pdf", ["Pg"] = " 4 " }, | |||||
| new Dictionary<string, string> { ["Form"] = @"D:\abs\b.PNG", ["Pg"] = "junk" }, | |||||
| new Dictionary<string, string> { ["Form"] = "", ["Pg"] = "" }, | |||||
| }; | |||||
| var result = RenderEngine.Render(template, new[] { "Form", "Pg" }, records, renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| Assert.Equal(new PageBackground(@"C:\forms\a.pdf", true, 4), renderer.Backgrounds[0]); | |||||
| Assert.Equal(new PageBackground(@"D:\abs\b.PNG", false, 1), renderer.Backgrounds[1]); | |||||
| Assert.Null(renderer.Backgrounds[2]); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_BackgroundColumnMissingFromCsv_FailsBeforeRendering() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground(null, "Nope", 1, null, @"C:\forms")); | |||||
| var renderer = new RecordingRenderer(); | |||||
| var result = RenderEngine.Render(template, new[] { "A" }, | |||||
| new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } }, | |||||
| renderer, "out.pdf"); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains("'Nope'")); | |||||
| Assert.Empty(renderer.Pages); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_BadPageValueForPdf_FailsWithRecordNumber() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms")); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Pg"] = "0" }, | |||||
| }; | |||||
| var result = RenderEngine.Render(template, new[] { "Pg" }, records, new RecordingRenderer(), "out.pdf"); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains("Record 1") && e.Contains("'0'")); | |||||
| } | |||||
| // ---- real Debenu DLL ---- | |||||
| private static string TempDir() | |||||
| { | |||||
| var dir = Path.Combine(Path.GetTempPath(), $"bg-{Guid.NewGuid():N}"); | |||||
| Directory.CreateDirectory(dir); | |||||
| return dir; | |||||
| } | |||||
| private static string MakeTwoPageSourcePdf(string dir, string licenseKey) | |||||
| { | |||||
| var path = Path.Combine(dir, "source.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), licenseKey, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| // Big solid glyphs in distinct colors so a rendered output page shows which source page | |||||
| // was used as its background. | |||||
| var page1 = new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: new PdfColor(255, 0, 0)) }; | |||||
| var page2 = new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: new PdfColor(0, 0, 255)) }; | |||||
| Assert.True(renderer!.AddPage(200, 200, page1, out error), error); | |||||
| Assert.True(renderer.AddPage(200, 200, page2, out error), error); | |||||
| Assert.True(renderer.Save(path, out error), error); | |||||
| } | |||||
| return path; | |||||
| } | |||||
| /// <summary>Renders one page of a PDF and reports how many clearly red / clearly blue pixels it has.</summary> | |||||
| private static (int Red, int Blue) InkColors(string pdfPath, string licenseKey, int page, string dir) | |||||
| { | |||||
| var pdf = new PDFLibrary(DllPath()); | |||||
| pdf.UnlockKey(licenseKey); | |||||
| Assert.NotEqual(0, pdf.LoadFromFile(pdfPath, "")); | |||||
| var png = Path.Combine(dir, $"page{page}-{Guid.NewGuid():N}.png"); | |||||
| Assert.NotEqual(0, pdf.RenderPageToFile(72, page, 0, png)); | |||||
| pdf.ReleaseLibrary(); | |||||
| using var bitmap = new Bitmap(png); | |||||
| int red = 0, blue = 0; | |||||
| for (var x = 0; x < bitmap.Width; x++) | |||||
| { | |||||
| for (var y = 0; y < bitmap.Height; y++) | |||||
| { | |||||
| var c = bitmap.GetPixel(x, y); | |||||
| if (c.R > 180 && c.G < 90 && c.B < 90) { red++; } | |||||
| if (c.B > 180 && c.R < 90 && c.G < 90) { blue++; } | |||||
| } | |||||
| } | |||||
| return (red, blue); | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_PdfBackgroundPage_IsDrawnAsBackground_AndPageColumnPicksThePage() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = TempDir(); | |||||
| try | |||||
| { | |||||
| MakeTwoPageSourcePdf(dir, key); | |||||
| var template = WithBackground(new TemplateBackground(null, "Form", 1, "Pg", dir)); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Form"] = "source.pdf", ["Pg"] = "1" }, | |||||
| new Dictionary<string, string> { ["Form"] = "source.pdf", ["Pg"] = "2" }, | |||||
| }; | |||||
| var output = Path.Combine(dir, "out.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var result = RenderEngine.Render(template, new[] { "Form", "Pg" }, records, renderer!, output); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| } | |||||
| var page1 = InkColors(output, key, 1, dir); | |||||
| var page2 = InkColors(output, key, 2, dir); | |||||
| Assert.True(page1.Red > 500 && page1.Blue == 0, $"page 1 should carry source page 1 (red): {page1}"); | |||||
| Assert.True(page2.Blue > 500 && page2.Red == 0, $"page 2 should carry source page 2 (blue): {page2}"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_PdfBackgroundPageOutOfRange_FailsClearly() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = TempDir(); | |||||
| try | |||||
| { | |||||
| MakeTwoPageSourcePdf(dir, key); | |||||
| var template = WithBackground(new TemplateBackground("source.pdf", null, 9, null, dir)); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["A"] = "1" }, | |||||
| }; | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var result = RenderEngine.Render(template, new[] { "A" }, records, renderer!, Path.Combine(dir, "o.pdf")); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains("has no page 9")); | |||||
| } | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_MissingBackgroundFile_FailsClearly() | |||||
| { | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), null, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("missing.png", null, 1, null, Path.GetTempPath())); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["A"] = "1" }, | |||||
| }; | |||||
| var result = RenderEngine.Render(template, new[] { "A" }, records, renderer!, "unused.pdf"); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains("Background file not found")); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_ImageBackground_FillsThePage() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = TempDir(); | |||||
| try | |||||
| { | |||||
| var imagePath = Path.Combine(dir, "red.png"); | |||||
| using (var bitmap = new Bitmap(40, 40)) | |||||
| { | |||||
| using var g = Graphics.FromImage(bitmap); | |||||
| g.Clear(Color.Red); | |||||
| bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png); | |||||
| } | |||||
| var output = Path.Combine(dir, "out.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| Assert.True(renderer!.AddPage(200, 200, Array.Empty<TextDraw>(), | |||||
| new PageBackground(imagePath, false, 1), out error), error); | |||||
| Assert.True(renderer.Save(output, out error), error); | |||||
| } | |||||
| var pdf = new PDFLibrary(DllPath()); | |||||
| pdf.UnlockKey(key); | |||||
| Assert.NotEqual(0, pdf.LoadFromFile(output, "")); | |||||
| var png = Path.Combine(dir, "page.png"); | |||||
| Assert.NotEqual(0, pdf.RenderPageToFile(72, 1, 0, png)); | |||||
| pdf.ReleaseLibrary(); | |||||
| using var rendered = new Bitmap(png); | |||||
| var center = rendered.GetPixel(rendered.Width / 2, rendered.Height / 2); | |||||
| var corner = rendered.GetPixel(2, 2); | |||||
| Assert.True(center.R > 200 && center.G < 60 && center.B < 60, $"center was {center}"); | |||||
| Assert.True(corner.R > 200 && corner.G < 60 && corner.B < 60, $"corner was {corner}"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| private sealed class SequenceRecordingRenderer : IPdfRenderer | |||||
| { | |||||
| public bool SupportsPdfBackgroundSequence => true; | |||||
| public (string Path, IReadOnlyList<int> Pages)? Sequence { get; private set; } | |||||
| public List<PageBackground?> Backgrounds { get; } = new(); | |||||
| public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error) | |||||
| { | |||||
| Sequence = (pdfPath, pagesInRecordOrder.ToList()); | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error) | |||||
| { | |||||
| Backgrounds.Add(background); | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public bool Save(string outputPath, out string? error) | |||||
| { | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public void Dispose() { } | |||||
| } | |||||
| private static List<IReadOnlyDictionary<string, string>> PageRecords(params string[] pages) | |||||
| => pages.Select(p => (IReadOnlyDictionary<string, string>)new Dictionary<string, string> { ["Pg"] = p }).ToList(); | |||||
| [Fact] | |||||
| public void Render_DistinctPagesOfOnePdf_UsesVectorSequenceInRecordOrder() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms")); | |||||
| var renderer = new SequenceRecordingRenderer(); | |||||
| var result = RenderEngine.Render(template, new[] { "Pg" }, PageRecords("47", "46", "1"), renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| Assert.Equal(@"C:\forms\a.pdf", renderer.Sequence!.Value.Path); | |||||
| Assert.Equal(new[] { 47, 46, 1 }, renderer.Sequence.Value.Pages); | |||||
| Assert.All(renderer.Backgrounds, Assert.Null); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_RepeatedPage_FallsBackToPerPageBackground() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("a.pdf", null, 1, "Pg", @"C:\forms")); | |||||
| var renderer = new SequenceRecordingRenderer(); | |||||
| var result = RenderEngine.Render(template, new[] { "Pg" }, PageRecords("2", "3", "2"), renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| Assert.Null(renderer.Sequence); | |||||
| Assert.Equal(new[] { 2, 3, 2 }, renderer.Backgrounds.Select(b => b!.Page).ToArray()); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_ImageBackground_NeverUsesVectorSequence() | |||||
| { | |||||
| var template = WithBackground(new TemplateBackground("a.png", null, 1, null, @"C:\forms")); | |||||
| var renderer = new SequenceRecordingRenderer(); | |||||
| RenderEngine.Render(template, new[] { "Pg" }, PageRecords("1"), renderer, "out.pdf"); | |||||
| Assert.Null(renderer.Sequence); | |||||
| } | |||||
| private static string SolidPdf(string dir, string licenseKey, params PdfColor[] pageColors) | |||||
| { | |||||
| var path = Path.Combine(dir, "source.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), licenseKey, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| foreach (var color in pageColors) | |||||
| { | |||||
| Assert.True(renderer!.AddPage(200, 200, | |||||
| new[] { new TextDraw(10, 20, "Arial", 220, "W", Color: color) }, out error), error); | |||||
| } | |||||
| Assert.True(renderer!.Save(path, out error), error); | |||||
| } | |||||
| return path; | |||||
| } | |||||
| private static string PdfText(string pdfPath, string licenseKey, int page) | |||||
| { | |||||
| var pdf = new PDFLibrary(DllPath()); | |||||
| pdf.UnlockKey(licenseKey); | |||||
| Assert.NotEqual(0, pdf.LoadFromFile(pdfPath, "")); | |||||
| pdf.SelectPage(page); | |||||
| var text = pdf.GetPageText(0); | |||||
| pdf.ReleaseLibrary(); | |||||
| return text; | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_VectorSequence_ReordersPages_AndKeepsEachRecordsTextOnItsOwnPage() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = TempDir(); | |||||
| try | |||||
| { | |||||
| SolidPdf(dir, key, new PdfColor(255, 0, 0), new PdfColor(0, 0, 255), new PdfColor(0, 160, 0)); | |||||
| var template = new TemplateDocument(200, 200, | |||||
| new[] { TemplateElement.Dynamic(10, 190, "Arial", 12, "Name") }, | |||||
| Array.Empty<TemplateAddressControl>(), | |||||
| new TemplateBackground("source.pdf", null, 1, "Pg", dir)); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Pg"] = "3", ["Name"] = "ALICE" }, | |||||
| new Dictionary<string, string> { ["Pg"] = "1", ["Name"] = "BOB" }, | |||||
| new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "CAROL" }, | |||||
| }; | |||||
| var output = Path.Combine(dir, "out.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var result = RenderEngine.Render(template, new[] { "Pg", "Name" }, records, renderer!, output); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| } | |||||
| // Record order is 3,1,2: output page 1 is source page 3 (green), page 2 is red, page 3 is blue. | |||||
| Assert.Contains("ALICE", PdfText(output, key, 1)); | |||||
| Assert.Contains("BOB", PdfText(output, key, 2)); | |||||
| Assert.Contains("CAROL", PdfText(output, key, 3)); | |||||
| Assert.DoesNotContain("BOB", PdfText(output, key, 1)); | |||||
| var first = InkColors(output, key, 1, dir); | |||||
| var second = InkColors(output, key, 2, dir); | |||||
| var third = InkColors(output, key, 3, dir); | |||||
| Assert.True(first.Red == 0 && first.Blue == 0, $"page 1 should be the green source page: {first}"); | |||||
| Assert.True(second.Red > 500 && second.Blue == 0, $"page 2 should be the red source page: {second}"); | |||||
| Assert.True(third.Blue > 500 && third.Red == 0, $"page 3 should be the blue source page: {third}"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_RepeatedPages_FallBackToRaster_EachRecordKeepsItsOwnText() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = TempDir(); | |||||
| try | |||||
| { | |||||
| SolidPdf(dir, key, new PdfColor(255, 0, 0), new PdfColor(0, 0, 255)); | |||||
| var template = new TemplateDocument(200, 200, | |||||
| new[] { TemplateElement.Dynamic(10, 190, "Arial", 12, "Name") }, | |||||
| Array.Empty<TemplateAddressControl>(), | |||||
| new TemplateBackground("source.pdf", null, 1, "Pg", dir)); | |||||
| var records = new List<IReadOnlyDictionary<string, string>> | |||||
| { | |||||
| new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "ALICE" }, | |||||
| new Dictionary<string, string> { ["Pg"] = "2", ["Name"] = "BOB" }, | |||||
| }; | |||||
| var output = Path.Combine(dir, "out.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var result = RenderEngine.Render(template, new[] { "Pg", "Name" }, records, renderer!, output); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| } | |||||
| Assert.Contains("ALICE", PdfText(output, key, 1)); | |||||
| Assert.DoesNotContain("BOB", PdfText(output, key, 1)); | |||||
| Assert.Contains("BOB", PdfText(output, key, 2)); | |||||
| Assert.True(InkColors(output, key, 2, dir).Blue > 500); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -110,4 +110,25 @@ public class ConsoleProgressReporterTests | |||||
| Assert.Equal(2, lines.Length); | Assert.Equal(2, lines.Length); | ||||
| Assert.Equal("PROGRESS failure elapsedMs=100 completed=1 reason=Record 2: simulated page failure", lines[1]); | 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)); | |||||
| } | |||||
| } | } | ||||
| @@ -27,4 +27,10 @@ | |||||
| <ProjectReference Include="..\EnvelopeRenderer.Cli\EnvelopeRenderer.Cli.csproj" /> | <ProjectReference Include="..\EnvelopeRenderer.Cli\EnvelopeRenderer.Cli.csproj" /> | ||||
| </ItemGroup> | </ItemGroup> | ||||
| <ItemGroup> | |||||
| <!-- The desktop canvas's PDF page renderer (System.Drawing + Debenu only) is compiled into this | |||||
| test project so its real-DLL behavior can be tested without referencing the WinForms app. --> | |||||
| <Compile Include="..\EnvelopeRenderer.Desktop\Views\PdfPageBitmapRenderer.cs" Link="Linked\PdfPageBitmapRenderer.cs" /> | |||||
| </ItemGroup> | |||||
| </Project> | </Project> | ||||
| @@ -0,0 +1,175 @@ | |||||
| using System.Drawing; | |||||
| using DebenuPDFLibraryDLL1013; | |||||
| using EnvelopeRenderer.Cli.Render; | |||||
| #pragma warning disable CA1416 | |||||
| namespace EnvelopeRenderer.Cli.Tests; | |||||
| /// <summary>A <text> element with <c>fill</c> is a filled rectangle (cover-up) that its own text, | |||||
| /// or any element with a higher render order, draws over.</summary> | |||||
| public class FilledRectangleTests | |||||
| { | |||||
| private static string WriteTemplate(string xml) | |||||
| { | |||||
| var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml"); | |||||
| File.WriteAllText(path, xml); | |||||
| return path; | |||||
| } | |||||
| private static string DllPath() => Path.Combine( | |||||
| AppContext.BaseDirectory, | |||||
| Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | |||||
| [Fact] | |||||
| public void Parse_FillWithBoxAndNoText_IsAPureRectangle() | |||||
| { | |||||
| var path = WriteTemplate(""" | |||||
| <envelopeTemplate pageWidth="297" pageHeight="684"> | |||||
| <text x="10" y="20" font="Arial" size="12" width="100" height="40" fill="#FFFFFF" /> | |||||
| </envelopeTemplate> | |||||
| """); | |||||
| var result = TemplateXmlParser.Parse(path); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| var element = Assert.Single(result.Document!.Elements); | |||||
| Assert.True(element.HasFill); | |||||
| Assert.Equal(new PdfColor(255, 255, 255), element.FillColor); | |||||
| Assert.Equal(string.Empty, element.Runs[0].Literal); | |||||
| } | |||||
| [Theory] | |||||
| [InlineData("""<text x="1" y="2" font="Arial" size="12" fill="#FFFFFF">Hi</text>""", "'width' and 'height'")] | |||||
| [InlineData("""<text x="1" y="2" font="Arial" size="12" width="5" height="5" fill="nope">Hi</text>""", "'fill'")] | |||||
| [InlineData("""<text x="1" y="2" font="Arial" size="12" width="5" height="5" fill="#000000" angle="10">Hi</text>""", "rotated")] | |||||
| public void Parse_InvalidFill_Fails(string textXml, string expected) | |||||
| { | |||||
| var result = TemplateXmlParser.Parse(WriteTemplate( | |||||
| $"""<envelopeTemplate pageWidth="297" pageHeight="684">{textXml}</envelopeTemplate>""")); | |||||
| Assert.False(result.Succeeded); | |||||
| Assert.Contains(result.Errors, e => e.Contains(expected)); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_FillElement_ProducesDrawWithFillColorAndBox_InRenderOrder() | |||||
| { | |||||
| var elements = new[] | |||||
| { | |||||
| new TemplateElement(10, 20, "Arial", 12, new[] { new TemplateTextRun(string.Empty, null) }, | |||||
| Width: 100, Height: 40, FillColor: new PdfColor(255, 255, 255)), | |||||
| TemplateElement.Static(12, 30, "Arial", 12, "over") with { RenderOrder = 1 }, | |||||
| }; | |||||
| var template = new TemplateDocument(297, 684, elements, Array.Empty<TemplateAddressControl>()); | |||||
| var draws = new List<IReadOnlyList<TextDraw>>(); | |||||
| var renderer = new CapturingRenderer(draws); | |||||
| var result = RenderEngine.Render( | |||||
| template, new[] { "A" }, | |||||
| new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } }, | |||||
| renderer, "out.pdf"); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| Assert.Equal(new PdfColor(255, 255, 255), draws[0][0].FillColor); | |||||
| Assert.Equal(100, draws[0][0].Width); | |||||
| Assert.Equal("over", draws[0][1].Text); | |||||
| Assert.Null(draws[0][1].FillColor); | |||||
| } | |||||
| private sealed class CapturingRenderer : IPdfRenderer | |||||
| { | |||||
| private readonly List<IReadOnlyList<TextDraw>> _draws; | |||||
| public CapturingRenderer(List<IReadOnlyList<TextDraw>> draws) => _draws = draws; | |||||
| public bool AddPage(double w, double h, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error) | |||||
| { | |||||
| _draws.Add(draws); | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public bool Save(string outputPath, out string? error) | |||||
| { | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public void Dispose() { } | |||||
| } | |||||
| [Fact] | |||||
| public void RealRender_FilledRectangleCoversBackground_AndTextDrawsOverIt() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = Path.Combine(Path.GetTempPath(), $"rect-{Guid.NewGuid():N}"); | |||||
| Directory.CreateDirectory(dir); | |||||
| try | |||||
| { | |||||
| var imagePath = Path.Combine(dir, "red.png"); | |||||
| using (var bitmap = new Bitmap(40, 40)) | |||||
| { | |||||
| using var g = Graphics.FromImage(bitmap); | |||||
| g.Clear(Color.Red); | |||||
| bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png); | |||||
| } | |||||
| // 200x200 page, red background; a white 100x100 rectangle at (50,50) with black text in it. | |||||
| var rectangle = new TemplateElement(50, 50, "Arial", 40, | |||||
| new[] { new TemplateTextRun("MMMM", null) }, | |||||
| Width: 100, Height: 100, FillColor: new PdfColor(255, 255, 255)); | |||||
| var template = new TemplateDocument(200, 200, new[] { rectangle }, | |||||
| Array.Empty<TemplateAddressControl>(), | |||||
| new TemplateBackground("red.png", null, 1, null, dir)); | |||||
| var output = Path.Combine(dir, "out.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| var result = RenderEngine.Render( | |||||
| template, new[] { "A" }, | |||||
| new List<IReadOnlyDictionary<string, string>> { new Dictionary<string, string> { ["A"] = "1" } }, | |||||
| renderer!, output); | |||||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||||
| } | |||||
| var pdf = new PDFLibrary(DllPath()); | |||||
| pdf.UnlockKey(key); | |||||
| Assert.NotEqual(0, pdf.LoadFromFile(output, "")); | |||||
| var png = Path.Combine(dir, "page.png"); | |||||
| Assert.NotEqual(0, pdf.RenderPageToFile(72, 1, 0, png)); | |||||
| pdf.ReleaseLibrary(); | |||||
| using var rendered = new Bitmap(png); | |||||
| var corner = rendered.GetPixel(5, 5); | |||||
| Assert.True(corner.R > 200 && corner.G < 60, $"outside the rectangle the red background stays: {corner}"); | |||||
| // Inside the rectangle (PDF y=50..150 is image rows 50..150): white except where text draws. | |||||
| int white = 0, dark = 0, red = 0; | |||||
| for (var x = 55; x < 145; x++) | |||||
| { | |||||
| for (var y = 55; y < 145; y++) | |||||
| { | |||||
| var c = rendered.GetPixel(x, y); | |||||
| if (c.R > 230 && c.G > 230 && c.B > 230) { white++; } | |||||
| else if (c.R < 90 && c.G < 90 && c.B < 90) { dark++; } | |||||
| else if (c.R > 200 && c.G < 60 && c.B < 60) { red++; } | |||||
| } | |||||
| } | |||||
| Assert.Equal(0, red); | |||||
| Assert.True(white > 3000, $"the rectangle should be mostly white: {white}"); | |||||
| Assert.True(dark > 100, $"text should draw over the rectangle: {dark}"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,118 @@ | |||||
| using System.Drawing; | |||||
| using EnvelopeRenderer.Cli.Render; | |||||
| using EnvelopeRenderer.Desktop.Views; | |||||
| #pragma warning disable CA1416 | |||||
| namespace EnvelopeRenderer.Cli.Tests; | |||||
| /// <summary>The designer canvas renders a PDF background page with DARenderPageToDC.</summary> | |||||
| public class PdfPageBitmapRendererTests | |||||
| { | |||||
| private static string DllPath() => Path.Combine( | |||||
| AppContext.BaseDirectory, | |||||
| Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | |||||
| private static string MakePdf(string dir, string key) | |||||
| { | |||||
| var path = Path.Combine(dir, "source.pdf"); | |||||
| Assert.True(DebenuPdfRenderer.TryCreate(DllPath(), key, out var renderer, out var error), error); | |||||
| using (renderer) | |||||
| { | |||||
| // Page 1 red, page 2 blue; 200 x 100 points (2:1 aspect). | |||||
| Assert.True(renderer!.AddPage(200, 100, | |||||
| new[] { new TextDraw(0, 0, "Arial", 400, "W", Color: new PdfColor(255, 0, 0)) }, out error), error); | |||||
| Assert.True(renderer.AddPage(200, 100, | |||||
| new[] { new TextDraw(0, 0, "Arial", 400, "W", Color: new PdfColor(0, 0, 255)) }, out error), error); | |||||
| Assert.True(renderer.Save(path, out error), error); | |||||
| } | |||||
| return path; | |||||
| } | |||||
| private static (int Red, int Blue) Count(Bitmap bitmap) | |||||
| { | |||||
| int red = 0, blue = 0; | |||||
| for (var x = 0; x < bitmap.Width; x++) | |||||
| { | |||||
| for (var y = 0; y < bitmap.Height; y++) | |||||
| { | |||||
| var c = bitmap.GetPixel(x, y); | |||||
| if (c.R > 180 && c.G < 90 && c.B < 90) { red++; } | |||||
| if (c.B > 180 && c.R < 90 && c.G < 90) { blue++; } | |||||
| } | |||||
| } | |||||
| return (red, blue); | |||||
| } | |||||
| [Fact] | |||||
| public void Render_PageOfPdf_ReturnsBitmapOfRequestedWidthAndPageAspect_WithThatPagesContent() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = Path.Combine(Path.GetTempPath(), $"pdfbmp-{Guid.NewGuid():N}"); | |||||
| Directory.CreateDirectory(dir); | |||||
| try | |||||
| { | |||||
| var pdf = MakePdf(dir, key); | |||||
| using var first = PdfPageBitmapRenderer.Render(pdf, 1, 400, out var error1); | |||||
| using var second = PdfPageBitmapRenderer.Render(pdf, 2, 400, out var error2); | |||||
| Assert.True(first is not null, error1); | |||||
| Assert.True(second is not null, error2); | |||||
| Assert.Equal(400, first!.Width); | |||||
| Assert.Equal(200, first.Height); | |||||
| var page1 = Count(first); | |||||
| var page2 = Count(second!); | |||||
| Assert.True(page1.Red > 500 && page1.Blue == 0, $"page 1 should be red: {page1}"); | |||||
| Assert.True(page2.Blue > 500 && page2.Red == 0, $"page 2 should be blue: {page2}"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void Render_PageBeyondTheEnd_ReturnsNullWithReadableError() | |||||
| { | |||||
| var key = DebenuLicenseKey.Resolve(); | |||||
| if (key is null) | |||||
| { | |||||
| Console.WriteLine("SKIPPED: no Debenu license key available locally."); | |||||
| return; | |||||
| } | |||||
| var dir = Path.Combine(Path.GetTempPath(), $"pdfbmp-{Guid.NewGuid():N}"); | |||||
| Directory.CreateDirectory(dir); | |||||
| try | |||||
| { | |||||
| var pdf = MakePdf(dir, key); | |||||
| var bitmap = PdfPageBitmapRenderer.Render(pdf, 9, 400, out var error); | |||||
| Assert.Null(bitmap); | |||||
| Assert.Contains("no page 9", error); | |||||
| } | |||||
| finally | |||||
| { | |||||
| Directory.Delete(dir, recursive: true); | |||||
| } | |||||
| } | |||||
| [Fact] | |||||
| public void Render_MissingFile_ReturnsNullWithError() | |||||
| { | |||||
| var bitmap = PdfPageBitmapRenderer.Render(Path.Combine(Path.GetTempPath(), "nope.pdf"), 1, 400, out var error); | |||||
| Assert.Null(bitmap); | |||||
| Assert.Contains("not found", error); | |||||
| } | |||||
| } | |||||
| @@ -25,8 +25,14 @@ public class RenderEngineTests | |||||
| public bool FailOnPage { get; set; } | public bool FailOnPage { get; set; } | ||||
| public bool FailOnSave { get; set; } | public bool FailOnSave { get; set; } | ||||
| public List<PageBackground?> Backgrounds { get; } = new(); | |||||
| public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error) | public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error) | ||||
| => ((IPdfRenderer)this).AddPage(pageWidth, pageHeight, draws, null, out error); | |||||
| bool IPdfRenderer.AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error) | |||||
| { | { | ||||
| Backgrounds.Add(background); | |||||
| if (FailOnPage) | if (FailOnPage) | ||||
| { | { | ||||
| error = "simulated page failure"; | error = "simulated page failure"; | ||||
| @@ -61,6 +61,12 @@ static int RunRender(CliArguments args) | |||||
| return ExitCodes.UnexpectedError; | 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); | var csv = new CsvRecordSource(args.CsvPath); | ||||
| IReadOnlyList<string> headers; | IReadOnlyList<string> headers; | ||||
| try | try | ||||
| @@ -75,6 +81,8 @@ static int RunRender(CliArguments args) | |||||
| return ExitCodes.UnexpectedError; | return ExitCodes.UnexpectedError; | ||||
| } | } | ||||
| progress.Info($"CSV headers read: {headers.Count} column(s)."); | |||||
| var dllPath = Path.Combine( | var dllPath = Path.Combine( | ||||
| AppContext.BaseDirectory, | AppContext.BaseDirectory, | ||||
| Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | ||||
| @@ -87,6 +95,8 @@ static int RunRender(CliArguments args) | |||||
| return ExitCodes.UnexpectedError; | return ExitCodes.UnexpectedError; | ||||
| } | } | ||||
| progress.Info("PDF library ready."); | |||||
| using (renderer) | using (renderer) | ||||
| { | { | ||||
| var renderResult = RenderEngine.Render( | var renderResult = RenderEngine.Render( | ||||
| @@ -17,6 +17,7 @@ public sealed class ConsoleProgressReporter : IProgressReporter | |||||
| private readonly Func<long> _elapsedMillisecondsProvider; | private readonly Func<long> _elapsedMillisecondsProvider; | ||||
| private readonly long _minIntervalMs; | private readonly long _minIntervalMs; | ||||
| private long? _lastEmittedAtMs; | private long? _lastEmittedAtMs; | ||||
| private int _lastCompleted; | |||||
| public ConsoleProgressReporter( | public ConsoleProgressReporter( | ||||
| TextWriter output, Func<long> elapsedMillisecondsProvider, long minIntervalMs = 1000) | TextWriter output, Func<long> elapsedMillisecondsProvider, long minIntervalMs = 1000) | ||||
| @@ -37,6 +38,7 @@ public sealed class ConsoleProgressReporter : IProgressReporter | |||||
| public void ReportRenderProgress(int completed) | public void ReportRenderProgress(int completed) | ||||
| { | { | ||||
| _lastCompleted = completed; | |||||
| var elapsed = _elapsedMillisecondsProvider(); | var elapsed = _elapsedMillisecondsProvider(); | ||||
| // Always emit the first call immediately (so a slow run shows *something* right away), | // 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); | 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); | public void Failure(int completed, string reason) => Write("failure", completed, reason); | ||||
| @@ -26,4 +26,11 @@ public interface IProgressReporter | |||||
| /// supplements — never replaces — the corresponding `ERROR: `-prefixed stderr line(s); the | /// supplements — never replaces — the corresponding `ERROR: `-prefixed stderr line(s); the | ||||
| /// exit code and stderr contract are unchanged by this event.</summary> | /// exit code and stderr contract are unchanged by this event.</summary> | ||||
| void Failure(int completed, string reason); | void Failure(int completed, string reason); | ||||
| /// <summary>A human-readable "what is happening now" message (template loaded, background | |||||
| /// mode chosen, saving...). Never throttled; emitted as a <c>PROGRESS info</c> line. Default | |||||
| /// no-op so reporters that only care about the numeric events need not implement it.</summary> | |||||
| void Info(string message) | |||||
| { | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,31 @@ | |||||
| namespace EnvelopeRenderer.Cli.Render; | |||||
| /// <summary>Letter-case transform applied to every line of an Address Control after its runs are | |||||
| /// resolved for a record (literal and field text alike). <see cref="None"/> leaves text exactly as | |||||
| /// authored/read, so every template written before this option renders unchanged.</summary> | |||||
| public enum AddressTextCase | |||||
| { | |||||
| None = 0, | |||||
| Upper = 1, | |||||
| Lower = 2, | |||||
| } | |||||
| /// <summary>Which edge of the Address Control block its authored <c>y</c> anchor pins to. | |||||
| /// <see cref="Top"/> (the long-standing behavior): <c>y</c> is the first line's baseline and the | |||||
| /// block grows downward. <see cref="Bottom"/>: <c>y</c> is the last visible line's baseline and | |||||
| /// the block grows upward, so a record with collapsed lines keeps its last line on the anchor.</summary> | |||||
| public enum AddressVerticalAnchor | |||||
| { | |||||
| Top = 0, | |||||
| Bottom = 1, | |||||
| } | |||||
| public static class AddressTextCaseExtensions | |||||
| { | |||||
| public static string Apply(this AddressTextCase textCase, string text) => textCase switch | |||||
| { | |||||
| AddressTextCase.Upper => text.ToUpperInvariant(), | |||||
| AddressTextCase.Lower => text.ToLowerInvariant(), | |||||
| _ => text, | |||||
| }; | |||||
| } | |||||
| @@ -71,4 +71,20 @@ public static class AddressLineCollapser | |||||
| return result; | return result; | ||||
| } | } | ||||
| /// <summary>For a bottom-anchored Address Control: the vertical shift that puts the last | |||||
| /// visible line's baseline on <paramref name="anchorY"/> (negative = move down). <c>0</c> when | |||||
| /// no line is visible.</summary> | |||||
| public static double BottomAnchorShift(IReadOnlyList<Resolved> resolved, double anchorY) | |||||
| { | |||||
| for (var i = resolved.Count - 1; i >= 0; i--) | |||||
| { | |||||
| if (resolved[i].Visible) | |||||
| { | |||||
| return anchorY - resolved[i].EffectiveY; | |||||
| } | |||||
| } | |||||
| return 0; | |||||
| } | |||||
| } | } | ||||
| @@ -49,6 +49,16 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| private readonly string _batchTempDirectory; | private readonly string _batchTempDirectory; | ||||
| private PDFLibrary _pdf; | private PDFLibrary _pdf; | ||||
| private readonly Dictionary<string, int> _fontHandles = new(StringComparer.OrdinalIgnoreCase); | private readonly Dictionary<string, int> _fontHandles = new(StringComparer.OrdinalIgnoreCase); | ||||
| // Background image cache, per Debenu instance (cleared whenever a batch reopens a fresh | |||||
| // instance): image path -> image ID, so a background reused across thousands of records is | |||||
| // embedded once per batch, not once per page. | |||||
| private readonly Dictionary<string, int> _backgroundImages = new(StringComparer.OrdinalIgnoreCase); | |||||
| // Vector PDF background sequence (see BeginPdfBackgroundSequence): while active, AddPage draws | |||||
| // onto the next pre-built page of the working document instead of creating pages, and batching | |||||
| // is off (the document already holds every page). | |||||
| private bool _sequenceActive; | |||||
| private int _sequenceNextPage; | |||||
| private int _sequencePageCount; | |||||
| private int _pagesInCurrentBatch; | private int _pagesInCurrentBatch; | ||||
| private bool _disposed; | private bool _disposed; | ||||
| @@ -115,7 +125,77 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| } | } | ||||
| public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error) | public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error) | ||||
| => AddPage(pageWidth, pageHeight, draws, background: null, out error); | |||||
| public bool SupportsPdfBackgroundSequence => true; | |||||
| public bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error) | |||||
| { | |||||
| if (_firstPageUsed || _sequenceActive) | |||||
| { | |||||
| error = "A PDF background sequence must be started before the first page."; | |||||
| return false; | |||||
| } | |||||
| if (!File.Exists(pdfPath)) | |||||
| { | |||||
| error = $"Background file not found: '{pdfPath}'."; | |||||
| return false; | |||||
| } | |||||
| // Load the source PDF into the initial document, then copy the wanted pages, in record | |||||
| // order, into a fresh document. The copy keeps each page's original content; the fresh | |||||
| // document's own first page is always blank and is removed again. | |||||
| if (_pdf.LoadFromFile(pdfPath, string.Empty) == 0) | |||||
| { | |||||
| error = $"Failed to open background PDF '{pdfPath}' (error code {_pdf.LastErrorCode()})."; | |||||
| return false; | |||||
| } | |||||
| var sourceId = _pdf.SelectedDocument(); | |||||
| var sourcePageCount = _pdf.PageCount(); | |||||
| var badPage = pagesInRecordOrder.Select((p, i) => (Page: p, Record: i + 1)).FirstOrDefault(t => t.Page > sourcePageCount); | |||||
| if (badPage.Page > 0) | |||||
| { | |||||
| error = $"Record {badPage.Record}: background PDF '{pdfPath}' has no page {badPage.Page} (it has {sourcePageCount})."; | |||||
| return false; | |||||
| } | |||||
| _pdf.NewDocument(); | |||||
| if (_pdf.CopyPageRangesEx(sourceId, string.Join(",", pagesInRecordOrder), 1) == 0) | |||||
| { | |||||
| error = $"Failed to copy background PDF pages from '{pdfPath}' (error code {_pdf.LastErrorCode()})."; | |||||
| return false; | |||||
| } | |||||
| _pdf.DeletePages(1, 1); | |||||
| _sequencePageCount = _pdf.PageCount(); | |||||
| if (_sequencePageCount != pagesInRecordOrder.Count) | |||||
| { | |||||
| error = $"Background PDF copy has {_sequencePageCount} pages, expected {pagesInRecordOrder.Count}."; | |||||
| return false; | |||||
| } | |||||
| _sequenceActive = true; | |||||
| _sequenceNextPage = 1; | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| public bool AddPage( | |||||
| double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error) | |||||
| { | { | ||||
| if (_sequenceActive) | |||||
| { | |||||
| if (_sequenceNextPage > _sequencePageCount || _pdf.SelectPage(_sequenceNextPage++) == 0) | |||||
| { | |||||
| error = "No page left in the background PDF sequence for this record."; | |||||
| return false; | |||||
| } | |||||
| return DrawTextDraws(draws, out error); | |||||
| } | |||||
| if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch) | if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch) | ||||
| { | { | ||||
| if (!FlushCurrentBatchAndReopen(out error)) | if (!FlushCurrentBatchAndReopen(out error)) | ||||
| @@ -144,8 +224,36 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (background is not null && !TryDrawBackground(background, pageWidth, pageHeight, out error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (!DrawTextDraws(draws, out error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| _pagesInCurrentBatch++; | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| private bool DrawTextDraws(IReadOnlyList<TextDraw> draws, out string? error) | |||||
| { | |||||
| foreach (var draw in draws) | 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)) | if (string.IsNullOrEmpty(draw.Text)) | ||||
| { | { | ||||
| continue; | continue; | ||||
| @@ -290,7 +398,114 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| } | } | ||||
| } | } | ||||
| _pagesInCurrentBatch++; | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| /// <summary>DPI a PDF background page is rasterized at. See <see cref="TryDrawBackground"/>.</summary> | |||||
| private const int PdfBackgroundDpi = 300; | |||||
| private readonly Dictionary<(string Path, int Page), string> _rasterizedPdfPages = new(); | |||||
| /// <summary>Draws the record's background stretched over the whole page, before any text. An | |||||
| /// image is added once per Debenu instance and re-selected afterwards. A PDF page is drawn as a | |||||
| /// <see cref="PdfBackgroundDpi"/>-DPI raster of that page: Debenu 10.13's vector path for this | |||||
| /// (<c>CapturePage</c> + <c>DrawCapturedPage</c>, also via the Document Analyzer variants) was | |||||
| /// probed against the real DLL with the project's key and captured fine but every draw call | |||||
| /// returned 0 (error code 0) and drew nothing, so the page is instead rendered once to a temp | |||||
| /// PNG (<c>RenderPageToFile</c>, confirmed working) and then follows the image path � cached per | |||||
| /// (file, page) so a background reused across thousands of records is rasterized once.</summary> | |||||
| private bool TryDrawBackground(PageBackground background, double pageWidth, double pageHeight, out string? error) | |||||
| { | |||||
| if (!File.Exists(background.FilePath)) | |||||
| { | |||||
| error = $"Background file not found: '{background.FilePath}'."; | |||||
| return false; | |||||
| } | |||||
| var imagePath = background.FilePath; | |||||
| if (background.IsPdf && !TryRasterizePdfPage(background, out imagePath!, out error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (_backgroundImages.TryGetValue(imagePath, out var imageId)) | |||||
| { | |||||
| if (_pdf.SelectImage(imageId) == 0) | |||||
| { | |||||
| error = $"Failed to select background image '{imagePath}' (error code {_pdf.LastErrorCode()})."; | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| imageId = _pdf.AddImageFromFile(imagePath, Options: 0); | |||||
| if (imageId == 0) | |||||
| { | |||||
| error = $"Failed to load background image '{imagePath}' (error code {_pdf.LastErrorCode()})."; | |||||
| return false; | |||||
| } | |||||
| _backgroundImages[imagePath] = imageId; | |||||
| } | |||||
| if (_pdf.DrawImage(0, pageHeight, pageWidth, pageHeight) == 0) | |||||
| { | |||||
| error = $"Failed to draw background image '{imagePath}' (error code {_pdf.LastErrorCode()})."; | |||||
| return false; | |||||
| } | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| private bool TryRasterizePdfPage(PageBackground background, out string? pngPath, out string? error) | |||||
| { | |||||
| var key = (background.FilePath, background.Page); | |||||
| if (_rasterizedPdfPages.TryGetValue(key, out pngPath)) | |||||
| { | |||||
| error = null; | |||||
| return true; | |||||
| } | |||||
| // A scratch document keeps the render document selected/untouched while the source PDF is | |||||
| // opened; it is dropped again straight after. | |||||
| var renderDocument = _pdf.SelectedDocument(); | |||||
| var scratchDocument = _pdf.NewDocument(); | |||||
| try | |||||
| { | |||||
| if (_pdf.LoadFromFile(background.FilePath, string.Empty) == 0) | |||||
| { | |||||
| error = $"Failed to open background PDF '{background.FilePath}' (error code {_pdf.LastErrorCode()})."; | |||||
| pngPath = null; | |||||
| return false; | |||||
| } | |||||
| var pageCount = _pdf.PageCount(); | |||||
| if (background.Page > pageCount) | |||||
| { | |||||
| error = $"Background PDF '{background.FilePath}' has no page {background.Page} (it has {pageCount})."; | |||||
| pngPath = null; | |||||
| return false; | |||||
| } | |||||
| Directory.CreateDirectory(_batchTempDirectory); | |||||
| pngPath = Path.Combine(_batchTempDirectory, $"background-{_rasterizedPdfPages.Count:D4}.png"); | |||||
| if (_pdf.RenderPageToFile(PdfBackgroundDpi, background.Page, Options: 0, pngPath) == 0) | |||||
| { | |||||
| error = $"Failed to render page {background.Page} of background PDF '{background.FilePath}' " + | |||||
| $"(error code {_pdf.LastErrorCode()})."; | |||||
| pngPath = null; | |||||
| return false; | |||||
| } | |||||
| } | |||||
| finally | |||||
| { | |||||
| _pdf.SelectDocument(renderDocument); | |||||
| _pdf.RemoveDocument(scratchDocument); | |||||
| } | |||||
| _rasterizedPdfPages[key] = pngPath; | |||||
| error = null; | error = null; | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -351,6 +566,7 @@ public sealed class DebenuPdfRenderer : IPdfRenderer | |||||
| _pdf = reopened!; | _pdf = reopened!; | ||||
| _fontHandles.Clear(); | _fontHandles.Clear(); | ||||
| _backgroundImages.Clear(); | |||||
| _firstPageUsed = false; | _firstPageUsed = false; | ||||
| _pagesInCurrentBatch = 0; | _pagesInCurrentBatch = 0; | ||||
| error = null; | error = null; | ||||
| @@ -10,6 +10,27 @@ public interface IPdfRenderer : IDisposable | |||||
| /// <summary>Starts the next page at the given size (points) and draws its content. The | /// <summary>Starts the next page at the given size (points) and draws its content. The | ||||
| /// first call reuses the document's already-existing first page rather than creating a new | /// first call reuses the document's already-existing first page rather than creating a new | ||||
| /// one — see <see cref="DebenuPdfRenderer"/> for why.</summary> | /// one — see <see cref="DebenuPdfRenderer"/> for why.</summary> | ||||
| bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error); | |||||
| bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, out string? error) | |||||
| => AddPage(pageWidth, pageHeight, draws, background: null, out error); | |||||
| /// <summary>As above, additionally drawing <paramref name="background"/> (when non-null) | |||||
| /// behind every text draw, stretched to the page size.</summary> | |||||
| bool AddPage(double pageWidth, double pageHeight, IReadOnlyList<TextDraw> draws, PageBackground? background, out string? error); | |||||
| /// <summary>True when <see cref="BeginPdfBackgroundSequence"/> is supported (the real Debenu | |||||
| /// renderer); test fakes keep the default <c>false</c>.</summary> | |||||
| bool SupportsPdfBackgroundSequence => false; | |||||
| /// <summary>Vector PDF background: builds the output document up front as a copy of | |||||
| /// <paramref name="pdfPath"/> pages in the given record order (<paramref name="pagesInRecordOrder"/>[i] | |||||
| /// is the 1-based source page for record i; pages must all be different, since copies of the same | |||||
| /// page share one content stream). Every following <see cref="AddPage(double,double,IReadOnlyList{TextDraw},PageBackground?,out string?)"/> | |||||
| /// then draws that record's text onto its own page, over the original page content, instead of | |||||
| /// starting a new page. Call before the first AddPage.</summary> | |||||
| bool BeginPdfBackgroundSequence(string pdfPath, IReadOnlyList<int> pagesInRecordOrder, out string? error) | |||||
| { | |||||
| error = "PDF background sequences are not supported by this renderer."; | |||||
| return false; | |||||
| } | |||||
| bool Save(string outputPath, out string? error); | bool Save(string outputPath, out string? error); | ||||
| } | } | ||||
| @@ -28,6 +28,7 @@ public static class RenderEngine | |||||
| .Concat(template.AddressControls.SelectMany(c => c.Lines).SelectMany(l => l.Runs)) | .Concat(template.AddressControls.SelectMany(c => c.Lines).SelectMany(l => l.Runs)) | ||||
| .Where(r => r.IsField) | .Where(r => r.IsField) | ||||
| .Select(r => r.ColumnName!) | .Select(r => r.ColumnName!) | ||||
| .Concat(BackgroundColumns(template.Background)) | |||||
| .Distinct(StringComparer.OrdinalIgnoreCase) | .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
| .Where(column => !csvHeaders.Contains(column, StringComparer.OrdinalIgnoreCase)) | .Where(column => !csvHeaders.Contains(column, StringComparer.OrdinalIgnoreCase)) | ||||
| .ToList(); | .ToList(); | ||||
| @@ -40,12 +41,62 @@ public static class RenderEngine | |||||
| .ToArray()); | .ToArray()); | ||||
| } | } | ||||
| // Vector PDF background: when every record uses a different page of one PDF, the renderer | |||||
| // builds the output as a page-ordered copy of that PDF and each record's text is drawn onto | |||||
| // its own page. Anything else (repeated pages, several files, images) keeps the per-page | |||||
| // path below, where PDF pages are drawn as a raster. | |||||
| var sequenceActive = false; | |||||
| if (template.Background is not null && renderer.SupportsPdfBackgroundSequence) | |||||
| { | |||||
| var materialized = records as IReadOnlyList<IReadOnlyDictionary<string, string>> ?? records.ToList(); | |||||
| records = materialized; | |||||
| progress?.Info($"Read {materialized.Count} record(s) from the CSV."); | |||||
| if (TryPlanPdfSequence(template.Background, materialized, out var pdfPath, out var pages, out var planNote)) | |||||
| { | |||||
| progress?.Info($"Background: vector PDF pages from '{pdfPath}' ({pages!.Count} distinct page(s), copied in record order)."); | |||||
| if (!renderer.BeginPdfBackgroundSequence(pdfPath!, pages!, out var beginError)) | |||||
| { | |||||
| return RenderResult.Failure(0, beginError!); | |||||
| } | |||||
| progress?.Info("Background PDF pages copied; drawing text onto them."); | |||||
| sequenceActive = true; | |||||
| } | |||||
| else | |||||
| { | |||||
| progress?.Info($"Background: per-page drawing ({planNote}); PDF pages are drawn as 300 DPI images."); | |||||
| } | |||||
| } | |||||
| else if (template.Background is not null) | |||||
| { | |||||
| progress?.Info("Background: per-page drawing."); | |||||
| } | |||||
| progress?.Info("Rendering pages..."); | |||||
| var recordCount = 0; | var recordCount = 0; | ||||
| foreach (var record in records) | foreach (var record in records) | ||||
| { | { | ||||
| var draws = BuildDraws(template, record); | 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}"); | 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."); | 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)) | if (!renderer.Save(outputPath, out var saveError)) | ||||
| { | { | ||||
| return RenderResult.Failure(recordCount, saveError!); | return RenderResult.Failure(recordCount, saveError!); | ||||
| @@ -67,6 +119,127 @@ public static class RenderEngine | |||||
| return RenderResult.Success(recordCount); | return RenderResult.Success(recordCount); | ||||
| } | } | ||||
| /// <summary>True when every record resolves to a PDF background: all the same file, all | |||||
| /// different pages. Any per-record problem (bad page value, blank file) returns false so the | |||||
| /// normal path reports it against the right record.</summary> | |||||
| private static bool TryPlanPdfSequence( | |||||
| TemplateBackground background, | |||||
| IReadOnlyList<IReadOnlyDictionary<string, string>> records, | |||||
| out string? pdfPath, | |||||
| out List<int>? pages, | |||||
| out string reason) | |||||
| { | |||||
| pdfPath = null; | |||||
| pages = null; | |||||
| reason = string.Empty; | |||||
| if (records.Count == 0) | |||||
| { | |||||
| reason = "no records"; | |||||
| return false; | |||||
| } | |||||
| var seen = new HashSet<int>(); | |||||
| var list = new List<int>(records.Count); | |||||
| var recordNumber = 0; | |||||
| foreach (var record in records) | |||||
| { | |||||
| recordNumber++; | |||||
| if (!TryResolveBackground(background, record, out var resolved, out var resolveError)) | |||||
| { | |||||
| reason = $"record {recordNumber}: {resolveError}"; | |||||
| return false; | |||||
| } | |||||
| if (resolved is null) | |||||
| { | |||||
| reason = $"record {recordNumber} has no background file"; | |||||
| return false; | |||||
| } | |||||
| if (!resolved.IsPdf) | |||||
| { | |||||
| reason = $"record {recordNumber} uses an image, not a PDF"; | |||||
| return false; | |||||
| } | |||||
| if (pdfPath is not null && !string.Equals(pdfPath, resolved.FilePath, StringComparison.OrdinalIgnoreCase)) | |||||
| { | |||||
| reason = $"record {recordNumber} uses a different PDF file"; | |||||
| return false; | |||||
| } | |||||
| if (!seen.Add(resolved.Page)) | |||||
| { | |||||
| reason = $"page {resolved.Page} is used by more than one record (first repeat at record {recordNumber})"; | |||||
| return false; | |||||
| } | |||||
| pdfPath ??= resolved.FilePath; | |||||
| list.Add(resolved.Page); | |||||
| } | |||||
| pages = list; | |||||
| return true; | |||||
| } | |||||
| private static IEnumerable<string> BackgroundColumns(TemplateBackground? background) | |||||
| { | |||||
| if (background?.FileColumn is { } fileColumn) | |||||
| { | |||||
| yield return fileColumn; | |||||
| } | |||||
| if (background?.PageColumn is { } pageColumn) | |||||
| { | |||||
| yield return pageColumn; | |||||
| } | |||||
| } | |||||
| /// <summary>Resolves this record's background: a static <c>file</c>, or the record's | |||||
| /// <c>fileColumn</c> value (blank = no background for this record). A PDF's page is the static | |||||
| /// <c>page</c> or the record's <c>pageColumn</c> value, which must be a whole number >= 1. | |||||
| /// Relative paths resolve against the template's folder.</summary> | |||||
| private static bool TryResolveBackground( | |||||
| TemplateBackground? template, IReadOnlyDictionary<string, string> record, | |||||
| out PageBackground? background, out string? error) | |||||
| { | |||||
| background = null; | |||||
| error = null; | |||||
| if (template is null) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| var file = (template.FileColumn is null ? template.File : record[template.FileColumn])?.Trim(); | |||||
| if (string.IsNullOrEmpty(file)) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| if (!TemplateBackground.IsSupportedExtension(file)) | |||||
| { | |||||
| error = $"Background file '{file}' has an unsupported type (expected .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff)."; | |||||
| return false; | |||||
| } | |||||
| var path = Path.IsPathRooted(file) ? file : Path.Combine(template.BaseDirectory, file); | |||||
| var isPdf = TemplateBackground.IsPdf(path); | |||||
| var page = template.Page; | |||||
| if (isPdf && template.PageColumn is not null) | |||||
| { | |||||
| var raw = record[template.PageColumn].Trim(); | |||||
| if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, | |||||
| System.Globalization.CultureInfo.InvariantCulture, out page) || page < 1) | |||||
| { | |||||
| error = $"Background page value '{raw}' in column '{template.PageColumn}' is not a whole number >= 1."; | |||||
| return false; | |||||
| } | |||||
| } | |||||
| background = new PageBackground(path, isPdf, page); | |||||
| return true; | |||||
| } | |||||
| /// <summary>Resolves every element's text for this record, then runs the address-line | /// <summary>Resolves every element's text for this record, then runs the address-line | ||||
| /// collapse math (Sprint 4: "Collapse blank optional address lines consistently") to decide | /// 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 | /// 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, | UsesFixedRotationPivot: element.IsDynamic, | ||||
| Width: element.Width, | Width: element.Width, | ||||
| Height: element.Height, | 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 resolvedText = new string[control.Lines.Count]; | ||||
| var collapseLines = new AddressLineCollapser.Line[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++) | for (var i = 0; i < control.Lines.Count; i++) | ||||
| { | { | ||||
| var line = control.Lines[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( | collapseLines[i] = new AddressLineCollapser.Line( | ||||
| control.X, | control.X, | ||||
| y, | y, | ||||
| @@ -154,6 +328,9 @@ public static class RenderEngine | |||||
| } | } | ||||
| var resolved = AddressLineCollapser.Resolve(collapseLines); | var resolved = AddressLineCollapser.Resolve(collapseLines); | ||||
| var bottomShift = control.VerticalAnchor == AddressVerticalAnchor.Bottom | |||||
| ? AddressLineCollapser.BottomAnchorShift(resolved, control.Y) | |||||
| : 0; | |||||
| var angle = control.RotationAngle; | var angle = control.RotationAngle; | ||||
| var pivot = angle != 0 ? control.BoxCenter : default; | var pivot = angle != 0 ? control.BoxCenter : default; | ||||
| @@ -166,7 +343,7 @@ public static class RenderEngine | |||||
| var line = control.Lines[i]; | var line = control.Lines[i]; | ||||
| var drawX = control.X; | var drawX = control.X; | ||||
| var drawY = resolved[i].EffectiveY; | |||||
| var drawY = resolved[i].EffectiveY + bottomShift; | |||||
| if (angle != 0) | if (angle != 0) | ||||
| { | { | ||||
| (drawX, drawY) = RotatePointAroundPivot(drawX, drawY, pivot, angle); | (drawX, drawY) = RotatePointAroundPivot(drawX, drawY, pivot, angle); | ||||
| @@ -19,7 +19,9 @@ public sealed record TemplateAddressControl( | |||||
| IReadOnlyList<TemplateAddressControlLine> Lines, | IReadOnlyList<TemplateAddressControlLine> Lines, | ||||
| double LineSpacingMultiplier = TemplateAddressControl.DefaultLineSpacingMultiplier, | double LineSpacingMultiplier = TemplateAddressControl.DefaultLineSpacingMultiplier, | ||||
| int RenderOrder = 0, | int RenderOrder = 0, | ||||
| double RotationAngle = 0) | |||||
| double RotationAngle = 0, | |||||
| AddressTextCase TextCase = AddressTextCase.None, | |||||
| AddressVerticalAnchor VerticalAnchor = AddressVerticalAnchor.Top) | |||||
| { | { | ||||
| public const double DefaultLineSpacingMultiplier = 1.25; | public const double DefaultLineSpacingMultiplier = 1.25; | ||||
| @@ -47,6 +49,26 @@ public sealed record TemplateAddressControl( | |||||
| } | } | ||||
| } | } | ||||
| /// <summary>Distance from the first line's baseline down to the last line's baseline.</summary> | |||||
| public double LastLineOffset | |||||
| { | |||||
| get | |||||
| { | |||||
| var total = 0.0; | |||||
| for (var i = 0; i < Lines.Count - 1; i++) | |||||
| { | |||||
| total += Lines[i].Size * LineSpacingMultiplier; | |||||
| } | |||||
| return total; | |||||
| } | |||||
| } | |||||
| /// <summary>Baseline of the first line of the full, uncollapsed authored block. Equals | |||||
| /// <see cref="Y"/> for a top-anchored control; for a bottom-anchored control <see cref="Y"/> is | |||||
| /// the last line's baseline, so the block sits <see cref="LastLineOffset"/> higher.</summary> | |||||
| public double TopBaselineY => VerticalAnchor == AddressVerticalAnchor.Bottom ? Y + LastLineOffset : Y; | |||||
| /// <summary>The fixed pivot the whole control rotates around — the center of the same | /// <summary>The fixed pivot the whole control rotates around — the center of the same | ||||
| /// authored box the designer canvas draws as this control's selection border (top edge at | /// authored box the designer canvas draws as this control's selection border (top edge at | ||||
| /// <c>Y + tallest line's font size</c>, bottom edge at <c>Y - Height</c>, so the visible box | /// <c>Y + tallest line's font size</c>, bottom edge at <c>Y - Height</c>, so the visible box | ||||
| @@ -56,8 +78,8 @@ public sealed record TemplateAddressControl( | |||||
| get | get | ||||
| { | { | ||||
| var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.Size); | 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)); | return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0)); | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,27 @@ | |||||
| namespace EnvelopeRenderer.Cli.Render; | |||||
| /// <summary>A page background (image or PDF page) drawn behind every output page, stretched to the | |||||
| /// page size. The file comes from exactly one of <see cref="File"/> (static) or | |||||
| /// <see cref="FileColumn"/> (per-record CSV value); for a PDF, the page number comes from | |||||
| /// <see cref="Page"/> (static, default 1) or <see cref="PageColumn"/> (per-record). Relative file | |||||
| /// paths resolve against <see cref="BaseDirectory"/> (the template's own folder). Whether a file is | |||||
| /// a PDF or an image is decided by its extension.</summary> | |||||
| public sealed record TemplateBackground( | |||||
| string? File, | |||||
| string? FileColumn, | |||||
| int Page, | |||||
| string? PageColumn, | |||||
| string BaseDirectory) | |||||
| { | |||||
| public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff" }; | |||||
| public static bool IsSupportedExtension(string path) | |||||
| => IsPdf(path) || ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); | |||||
| public static bool IsPdf(string path) | |||||
| => string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase); | |||||
| } | |||||
| /// <summary>One record's resolved background for the renderer: an absolute-or-relative-resolved | |||||
| /// <see cref="FilePath"/>, whether it is a PDF, and (PDF only) the 1-based <see cref="Page"/>.</summary> | |||||
| public sealed record PageBackground(string FilePath, bool IsPdf, int Page); | |||||
| @@ -10,7 +10,8 @@ public sealed record TemplateDocument( | |||||
| double PageWidth, | double PageWidth, | ||||
| double PageHeight, | double PageHeight, | ||||
| IReadOnlyList<TemplateElement> Elements, | IReadOnlyList<TemplateElement> Elements, | ||||
| IReadOnlyList<TemplateAddressControl> AddressControls) | |||||
| IReadOnlyList<TemplateAddressControl> AddressControls, | |||||
| TemplateBackground? Background = null) | |||||
| { | { | ||||
| public TemplateDocument(double pageWidth, double pageHeight, IReadOnlyList<TemplateElement> elements) | public TemplateDocument(double pageWidth, double pageHeight, IReadOnlyList<TemplateElement> elements) | ||||
| : this(pageWidth, pageHeight, elements, Array.Empty<TemplateAddressControl>()) | : this(pageWidth, pageHeight, elements, Array.Empty<TemplateAddressControl>()) | ||||
| @@ -39,8 +39,15 @@ public sealed record TemplateElement( | |||||
| int RenderOrder = 0, | int RenderOrder = 0, | ||||
| double? Width = null, | double? Width = null, | ||||
| double? Height = null, | double? Height = null, | ||||
| PdfColor Color = default) | |||||
| PdfColor Color = default, | |||||
| PdfColor? FillColor = null) | |||||
| { | { | ||||
| /// <summary>True for a filled rectangle element: <see cref="FillColor"/> plus an explicit | |||||
| /// <see cref="Width"/> and <see cref="Height"/> box. The rectangle is drawn first and the | |||||
| /// element's own text (which may be empty) is drawn over it, so a blank element with a fill is | |||||
| /// a pure cover-up and any element with a higher render order draws on top of it.</summary> | |||||
| public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0; | |||||
| /// <summary>Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has | /// <summary>Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has | ||||
| /// been set — <see cref="Height"/> is independent and optional, no longer required for | /// been set — <see cref="Height"/> is independent and optional, no longer required for | ||||
| /// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when | /// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when | ||||
| @@ -52,6 +52,8 @@ public static class TemplateXmlParser | |||||
| errors.Add("Template has no <text> or <addressControl> elements — nothing to render."); | errors.Add("Template has no <text> or <addressControl> elements — nothing to render."); | ||||
| } | } | ||||
| var background = ParseBackground(root, path, errors); | |||||
| var textIndex = 0; | var textIndex = 0; | ||||
| var addressIndex = 0; | var addressIndex = 0; | ||||
| var renderOrder = 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."); | 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) | else if (!hasColumn && !hasStaticText) | ||||
| { | { | ||||
| errors.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one."); | 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 width = ParseOptionalPositiveDouble(node, "width", label, errors); | ||||
| var height = ParseOptionalPositiveDouble(node, "height", 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 | if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null | ||||
| || angle is null) | || angle is null) | ||||
| { | { | ||||
| @@ -161,7 +193,8 @@ public static class TemplateXmlParser | |||||
| renderOrder, | renderOrder, | ||||
| width, | width, | ||||
| height, | height, | ||||
| color)); | |||||
| color, | |||||
| fill)); | |||||
| renderOrder++; | renderOrder++; | ||||
| } | } | ||||
| @@ -170,7 +203,7 @@ public static class TemplateXmlParser | |||||
| return TemplateParseResult.Failure(errors.ToArray()); | 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( | private static TemplateAddressControl? ParseAddressControl( | ||||
| @@ -194,6 +227,20 @@ public static class TemplateXmlParser | |||||
| // <text>'s own `angle` attribute. | // <text>'s own `angle` attribute. | ||||
| var angle = ParseOptionalDouble(node, "angle", label, errors, defaultValue: 0); | 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(); | var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList(); | ||||
| if (lineNodes.Count == 0) | if (lineNodes.Count == 0) | ||||
| { | { | ||||
| @@ -216,7 +263,77 @@ public static class TemplateXmlParser | |||||
| } | } | ||||
| return new TemplateAddressControl( | return new TemplateAddressControl( | ||||
| x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value); | |||||
| x.Value, y.Value, width.Value, lines, lineSpacing.Value, renderOrder, angle.Value, | |||||
| textCase, verticalAnchor); | |||||
| } | |||||
| private static bool TryParseEnum<T>(string raw, out T value) where T : struct, Enum | |||||
| => Enum.TryParse(raw.Trim(), ignoreCase: true, out value) && Enum.IsDefined(value); | |||||
| /// <summary>Optional <c><background file="..." | fileColumn="..." [page="N" | pageColumn="..."] /></c> | |||||
| /// directly under the root. A static <c>file</c> must have a supported extension (.pdf or a common | |||||
| /// image type) and is not checked for existence here � the renderer does that with a clear error.</summary> | |||||
| private static TemplateBackground? ParseBackground(XElement root, string templatePath, List<string> errors) | |||||
| { | |||||
| var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList(); | |||||
| if (nodes.Count == 0) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| const string label = "<background>"; | |||||
| if (nodes.Count > 1) | |||||
| { | |||||
| errors.Add("Template has more than one <background> element � use at most one."); | |||||
| return null; | |||||
| } | |||||
| var node = nodes[0]; | |||||
| var file = ((string?)node.Attribute("file"))?.Trim(); | |||||
| var fileColumn = ((string?)node.Attribute("fileColumn"))?.Trim(); | |||||
| var pageColumn = ((string?)node.Attribute("pageColumn"))?.Trim(); | |||||
| var hasFile = !string.IsNullOrEmpty(file); | |||||
| var hasFileColumn = !string.IsNullOrEmpty(fileColumn); | |||||
| var hasPageColumn = !string.IsNullOrEmpty(pageColumn); | |||||
| var ok = true; | |||||
| if (hasFile == hasFileColumn) | |||||
| { | |||||
| errors.Add($"{label} needs exactly one of 'file' or 'fileColumn'."); | |||||
| ok = false; | |||||
| } | |||||
| else if (hasFile && !TemplateBackground.IsSupportedExtension(file!)) | |||||
| { | |||||
| errors.Add($"{label} has an unsupported 'file' type: '{file}' (expected .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff)."); | |||||
| ok = false; | |||||
| } | |||||
| var page = 1; | |||||
| var pageRaw = (string?)node.Attribute("page"); | |||||
| if (pageRaw is not null && hasPageColumn) | |||||
| { | |||||
| errors.Add($"{label} has both 'page' and 'pageColumn' � use only one."); | |||||
| ok = false; | |||||
| } | |||||
| else if (pageRaw is not null | |||||
| && (!int.TryParse(pageRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out page) || page < 1)) | |||||
| { | |||||
| errors.Add($"{label} has an invalid 'page' value: '{pageRaw}' (expected a whole number >= 1)."); | |||||
| ok = false; | |||||
| } | |||||
| if (!ok) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? string.Empty; | |||||
| return new TemplateBackground( | |||||
| hasFile ? file : null, | |||||
| hasFileColumn ? fileColumn : null, | |||||
| page, | |||||
| hasPageColumn ? pageColumn : null, | |||||
| baseDirectory); | |||||
| } | } | ||||
| /// <summary>Optional <c>color="#RRGGBB"</c>; absent means black (the pre-color behavior).</summary> | /// <summary>Optional <c>color="#RRGGBB"</c>; absent means black (the pre-color behavior).</summary> | ||||
| @@ -14,6 +14,9 @@ namespace EnvelopeRenderer.Cli.Render; | |||||
| /// <see cref="Text"/> wraps within this width, clipping any wrapped content that exceeds | /// <see cref="Text"/> wraps within this width, clipping any wrapped content that exceeds | ||||
| /// <paramref name="Height"/>, instead of drawing as one unbounded line at the (X, Y) baseline. | /// <paramref name="Height"/>, instead of drawing as one unbounded line at the (X, Y) baseline. | ||||
| /// <c>null</c> (the default) preserves every pre-Sprint-9 render exactly.</param> | /// <c>null</c> (the default) preserves every pre-Sprint-9 render exactly.</param> | ||||
| /// <param name="FillColor">When set together with <paramref name="Width"/> and | |||||
| /// <paramref name="Height"/>, a filled rectangle covering that box (bottom-left at X, Y) is drawn | |||||
| /// before this draw's text, which may be empty. Not rotated.</param> | |||||
| /// <param name="Height">Paired with <paramref name="Width"/> — see its remarks.</param> | /// <param name="Height">Paired with <paramref name="Width"/> — see its remarks.</param> | ||||
| public sealed record TextDraw( | public sealed record TextDraw( | ||||
| double X, | double X, | ||||
| @@ -25,4 +28,5 @@ public sealed record TextDraw( | |||||
| bool UsesFixedRotationPivot = false, | bool UsesFixedRotationPivot = false, | ||||
| double? Width = null, | double? Width = null, | ||||
| double? Height = null, | double? Height = null, | ||||
| PdfColor Color = default); | |||||
| PdfColor Color = default, | |||||
| PdfColor? FillColor = null); | |||||
| @@ -21,6 +21,12 @@ public sealed class AddressControlLayout | |||||
| /// <see cref="BoxCenter"/>.</summary> | /// <see cref="BoxCenter"/>.</summary> | ||||
| public double RotationAngle { get; set; } | public double RotationAngle { get; set; } | ||||
| /// <summary>Letter-case transform applied to every line resolved text.</summary> | |||||
| public AddressTextCase TextCase { get; set; } = AddressTextCase.None; | |||||
| /// <summary>Which edge <see cref="Y"/> pins to - see <see cref="AddressVerticalAnchor"/>.</summary> | |||||
| public AddressVerticalAnchor VerticalAnchor { get; set; } = AddressVerticalAnchor.Top; | |||||
| public List<AddressControlLineLayout> Lines { get; } = new(); | public List<AddressControlLineLayout> Lines { get; } = new(); | ||||
| public static AddressControlLayout CreateDefault(double x, double y, int zOrder = 0) | public static AddressControlLayout CreateDefault(double x, double y, int zOrder = 0) | ||||
| @@ -70,9 +76,30 @@ public sealed class AddressControlLayout | |||||
| return true; | return true; | ||||
| } | } | ||||
| /// <summary>Distance from the first line baseline down to the last line baseline.</summary> | |||||
| public double LastLineOffset | |||||
| { | |||||
| get | |||||
| { | |||||
| var total = 0.0; | |||||
| for (var i = 0; i < Lines.Count - 1; i++) | |||||
| { | |||||
| total += Lines[i].FontSize * LineSpacingMultiplier; | |||||
| } | |||||
| return total; | |||||
| } | |||||
| } | |||||
| /// <summary>Baseline of the first line of the full, uncollapsed authored block: <see cref="Y"/> | |||||
| /// for a top-anchored control; <see cref="Y"/> + <see cref="LastLineOffset"/> for a | |||||
| /// bottom-anchored one (where <see cref="Y"/> is the last line baseline). All box, pivot and | |||||
| /// line-position geometry uses this, never <see cref="Y"/> directly.</summary> | |||||
| public double TopBaselineY => VerticalAnchor == AddressVerticalAnchor.Bottom ? Y + LastLineOffset : Y; | |||||
| public double BaselineYForLine(int index) | public double BaselineYForLine(int index) | ||||
| { | { | ||||
| var y = Y; | |||||
| var y = TopBaselineY; | |||||
| for (var i = 0; i < index && i < Lines.Count; i++) | for (var i = 0; i < index && i < Lines.Count; i++) | ||||
| { | { | ||||
| y -= Lines[i].FontSize * LineSpacingMultiplier; | y -= Lines[i].FontSize * LineSpacingMultiplier; | ||||
| @@ -112,8 +139,8 @@ public sealed class AddressControlLayout | |||||
| get | get | ||||
| { | { | ||||
| var topExtension = Lines.Count == 0 ? 0 : Lines.Max(l => l.FontSize); | 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)); | return (X + (Width / 2.0), bottom + ((top - bottom) / 2.0)); | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,29 @@ | |||||
| namespace EnvelopeRenderer.Desktop.Core.Design; | |||||
| /// <summary>Letter-case transform for every line of an Address Control, applied to the resolved | |||||
| /// text (literal and field alike). <see cref="None"/> leaves text as authored / as read.</summary> | |||||
| public enum AddressTextCase | |||||
| { | |||||
| None = 0, | |||||
| Upper = 1, | |||||
| Lower = 2, | |||||
| } | |||||
| /// <summary>Which edge of the Address Control block its <c>Y</c> anchor pins to: <see cref="Top"/> | |||||
| /// = first line baseline (block grows down); <see cref="Bottom"/> = last visible line baseline | |||||
| /// (block grows up).</summary> | |||||
| public enum AddressVerticalAnchor | |||||
| { | |||||
| Top = 0, | |||||
| Bottom = 1, | |||||
| } | |||||
| public static class AddressTextCaseExtensions | |||||
| { | |||||
| public static string Apply(this AddressTextCase textCase, string text) => textCase switch | |||||
| { | |||||
| AddressTextCase.Upper => text.ToUpperInvariant(), | |||||
| AddressTextCase.Lower => text.ToLowerInvariant(), | |||||
| _ => text, | |||||
| }; | |||||
| } | |||||
| @@ -21,7 +21,7 @@ public static class AddressControlRotateHandle | |||||
| public static (double X, double Y) LocalOrigin(AddressControlLayout control) | public static (double X, double Y) LocalOrigin(AddressControlLayout control) | ||||
| { | { | ||||
| var maxFontSize = control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize); | var maxFontSize = control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize); | ||||
| return (control.X + (control.Width / 2.0), control.Y + maxFontSize); | |||||
| return (control.X + (control.Width / 2.0), control.TopBaselineY + maxFontSize); | |||||
| } | } | ||||
| /// <summary>The handle's local (unrotated) position: the box's own top-center plus | /// <summary>The handle's local (unrotated) position: the box's own top-center plus | ||||
| @@ -61,4 +61,20 @@ public static class AddressLineCollapser | |||||
| return result; | return result; | ||||
| } | } | ||||
| /// <summary>For a bottom-anchored Address Control: the vertical shift that puts the last | |||||
| /// visible line baseline on <paramref name="anchorY"/> (negative = move down). 0 when no line | |||||
| /// is visible.</summary> | |||||
| public static double BottomAnchorShift(IReadOnlyList<Resolved> resolved, double anchorY) | |||||
| { | |||||
| for (var i = resolved.Count - 1; i >= 0; i--) | |||||
| { | |||||
| if (resolved[i].Visible) | |||||
| { | |||||
| return anchorY - resolved[i].EffectiveY; | |||||
| } | |||||
| } | |||||
| return 0; | |||||
| } | |||||
| } | } | ||||
| @@ -36,6 +36,7 @@ public sealed class CanvasElementEditor | |||||
| private (double Dx, double Dy)? _dragOffset; | private (double Dx, double Dy)? _dragOffset; | ||||
| private bool _isRotating; | private bool _isRotating; | ||||
| private bool _isResizingWidth; | private bool _isResizingWidth; | ||||
| private bool _isResizingHeight; | |||||
| /// <summary>Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer | /// <summary>Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer | ||||
| /// alongside <see cref="Selected"/>, not a replacement — every existing single-select code | /// alongside <see cref="Selected"/>, not a replacement — every existing single-select code | ||||
| @@ -323,6 +324,7 @@ public sealed class CanvasElementEditor | |||||
| _dragOffset = null; | _dragOffset = null; | ||||
| _isRotating = false; | _isRotating = false; | ||||
| _isResizingWidth = false; | _isResizingWidth = false; | ||||
| _isResizingHeight = false; | |||||
| return removed; | return removed; | ||||
| } | } | ||||
| @@ -371,12 +373,62 @@ public sealed class CanvasElementEditor | |||||
| _dragOffset = null; | _dragOffset = null; | ||||
| _isRotating = false; | _isRotating = false; | ||||
| _isResizingWidth = false; | _isResizingWidth = false; | ||||
| _isResizingHeight = false; | |||||
| } | } | ||||
| public bool IsDragging => _dragOffset is not null; | public bool IsDragging => _dragOffset is not null; | ||||
| public bool IsResizingWidth => _isResizingWidth; | public bool IsResizingWidth => _isResizingWidth; | ||||
| /// <summary>True during a width or a height resize-drag.</summary> | |||||
| public bool IsResizing => _isResizingWidth || _isResizingHeight; | |||||
| /// <summary>World-space position of the selected filled rectangle's height handle (top-edge | |||||
| /// midpoint, rotated like the other handles), or <c>null</c> when nothing is selected or the | |||||
| /// selection is not a filled rectangle (<see cref="TextElementLayout.HasFill"/>). Dragging it | |||||
| /// sets <see cref="TextElementLayout.Height"/> just as the right-edge handle sets Width.</summary> | |||||
| public (double X, double Y)? HeightHandlePosition() | |||||
| { | |||||
| if (Selected is not { HasFill: true }) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| var (width, height) = _measureText(Selected); | |||||
| var pivot = RotationPivot(Selected, width, height); | |||||
| return RotatePointAroundPivot( | |||||
| Selected.X + (width / 2.0), | |||||
| Selected.Y + height, | |||||
| pivot, | |||||
| Selected.RotationAngle); | |||||
| } | |||||
| public bool HitTestHeightHandle(double xPoints, double yPoints) | |||||
| { | |||||
| var handle = HeightHandlePosition(); | |||||
| if (handle is null) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| var dx = xPoints - handle.Value.X; | |||||
| var dy = yPoints - handle.Value.Y; | |||||
| return (dx * dx) + (dy * dy) <= HandleHitRadiusPoints * HandleHitRadiusPoints; | |||||
| } | |||||
| /// <summary>Starts a height resize-drag of the selected filled rectangle. No-op otherwise.</summary> | |||||
| public void BeginHeightResizeDrag() | |||||
| { | |||||
| if (Selected is not { HasFill: true }) | |||||
| { | |||||
| return; | |||||
| } | |||||
| _isResizingHeight = true; | |||||
| _isResizingWidth = false; | |||||
| _dragOffset = null; | |||||
| } | |||||
| /// <summary>Post-Sprint-9 user-requested change (2026-10-27): the current world-space | /// <summary>Post-Sprint-9 user-requested change (2026-10-27): the current world-space | ||||
| /// (canvas-point) position of the selected element's width resize handle, or <c>null</c> if | /// (canvas-point) position of the selected element's width resize handle, or <c>null</c> if | ||||
| /// nothing is selected. Sits at the right-edge midpoint (<see cref="TextElementLayout.X"/> + | /// nothing is selected. Sits at the right-edge midpoint (<see cref="TextElementLayout.X"/> + | ||||
| @@ -448,11 +500,27 @@ public sealed class CanvasElementEditor | |||||
| /// hasn't begun.</summary> | /// hasn't begun.</summary> | ||||
| public void ResizeDragTo(double xPoints, double yPoints) | public void ResizeDragTo(double xPoints, double yPoints) | ||||
| { | { | ||||
| if (Selected is null || !_isResizingWidth) | |||||
| if (Selected is null || !IsResizing) | |||||
| { | { | ||||
| return; | 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 (width, height) = _measureText(Selected); | ||||
| var pivot = RotationPivot(Selected, width, height); | var pivot = RotationPivot(Selected, width, height); | ||||
| var (localX, _) = Selected.RotationAngle == 0 | var (localX, _) = Selected.RotationAngle == 0 | ||||
| @@ -0,0 +1,63 @@ | |||||
| using System.Globalization; | |||||
| namespace EnvelopeRenderer.Desktop.Core.Design; | |||||
| /// <summary>A template-level page background (image or PDF page) drawn behind every output page. | |||||
| /// The file is either a fixed path (<see cref="File"/>) or read per record from a CSV column | |||||
| /// (<see cref="FileColumn"/>) - exactly one is set. For a PDF the page is a fixed number | |||||
| /// (<see cref="Page"/>) or read per record from <see cref="PageColumn"/>. Mirrors the CLI | |||||
| /// <c>TemplateBackground</c> (independently implemented, per the desktop/CLI split).</summary> | |||||
| public sealed class PageBackgroundLayout | |||||
| { | |||||
| public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff" }; | |||||
| public string? File { get; set; } | |||||
| public string? FileColumn { get; set; } | |||||
| public int Page { get; set; } = 1; | |||||
| public string? PageColumn { get; set; } | |||||
| public static bool IsPdf(string path) | |||||
| => string.Equals(Path.GetExtension(path), ".pdf", StringComparison.OrdinalIgnoreCase); | |||||
| public static bool IsSupportedExtension(string path) | |||||
| => IsPdf(path) || ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); | |||||
| /// <summary>The file to show for one record: the fixed <see cref="File"/>, or the record | |||||
| /// <see cref="FileColumn"/> value; <c>null</c> when blank/unknown. Relative paths resolve against | |||||
| /// <paramref name="baseDirectory"/> (the template folder) when given.</summary> | |||||
| public string? ResolveFile(IReadOnlyDictionary<string, string>? record, string? baseDirectory) | |||||
| { | |||||
| string? value = File; | |||||
| if (FileColumn is not null) | |||||
| { | |||||
| value = record is not null && record.TryGetValue(FileColumn, out var v) ? v : null; | |||||
| } | |||||
| value = value?.Trim(); | |||||
| if (string.IsNullOrEmpty(value)) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| return Path.IsPathRooted(value) || string.IsNullOrEmpty(baseDirectory) | |||||
| ? value | |||||
| : Path.Combine(baseDirectory, value); | |||||
| } | |||||
| /// <summary>The 1-based PDF page for one record, or <c>null</c> when a per-record value is not a | |||||
| /// whole number >= 1.</summary> | |||||
| public int? ResolvePage(IReadOnlyDictionary<string, string>? record) | |||||
| { | |||||
| if (PageColumn is null) | |||||
| { | |||||
| return Page; | |||||
| } | |||||
| return record is not null | |||||
| && record.TryGetValue(PageColumn, out var raw) | |||||
| && int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var page) | |||||
| && page >= 1 | |||||
| ? page | |||||
| : null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| namespace EnvelopeRenderer.Desktop.Core.Design; | |||||
| /// <summary>Picks the next or previous item when stepping through the canvas items with the | |||||
| /// keyboard (Tab / Shift+Tab), so an item hidden behind another one can still be selected. Items | |||||
| /// are visited in a fixed order - z-order (bottom first), then top-to-bottom, then left-to-right - | |||||
| /// and the walk wraps around at both ends. Framework-free so it is unit tested.</summary> | |||||
| public static class SelectionCycler | |||||
| { | |||||
| public readonly record struct Item(Guid Id, int ZOrder, double X, double Y); | |||||
| /// <summary>The id to select next, or <c>null</c> when there are no items. With no current | |||||
| /// selection (or one that is not in <paramref name="items"/>), forward picks the first item and | |||||
| /// backward picks the last.</summary> | |||||
| public static Guid? Next(IEnumerable<Item> items, Guid? currentId, bool forward) | |||||
| { | |||||
| var ordered = items | |||||
| .OrderBy(i => i.ZOrder) | |||||
| .ThenByDescending(i => i.Y) | |||||
| .ThenBy(i => i.X) | |||||
| .ThenBy(i => i.Id) | |||||
| .ToList(); | |||||
| if (ordered.Count == 0) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| var index = currentId is { } id ? ordered.FindIndex(i => i.Id == id) : -1; | |||||
| if (index < 0) | |||||
| { | |||||
| return forward ? ordered[0].Id : ordered[^1].Id; | |||||
| } | |||||
| var next = forward ? (index + 1) % ordered.Count : (index - 1 + ordered.Count) % ordered.Count; | |||||
| return ordered[next].Id; | |||||
| } | |||||
| } | |||||
| @@ -19,6 +19,9 @@ public sealed class TemplateLayoutDocument | |||||
| /// element kind is introduced.</summary> | /// element kind is introduced.</summary> | ||||
| public List<AddressControlLayout> AddressControls { get; } = new(); | public List<AddressControlLayout> AddressControls { get; } = new(); | ||||
| /// <summary>Optional page background image / PDF page drawn behind every page; null = none.</summary> | |||||
| public PageBackgroundLayout? Background { get; set; } | |||||
| public TemplateLayoutDocument(CanvasSettings canvas) | public TemplateLayoutDocument(CanvasSettings canvas) | ||||
| { | { | ||||
| Canvas = canvas; | Canvas = canvas; | ||||
| @@ -35,6 +35,11 @@ public static class TemplateLayoutXmlSerializer | |||||
| var addressItems = document.AddressControls | var addressItems = document.AddressControls | ||||
| .Select(c => (ZOrder: c.ZOrder, Node: CreateAddressControl(c))); | .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)) | foreach (var item in textItems.Concat(addressItems).OrderBy(i => i.ZOrder)) | ||||
| { | { | ||||
| root.Add(item.Node); | root.Add(item.Node); | ||||
| @@ -46,6 +51,87 @@ public static class TemplateLayoutXmlSerializer | |||||
| new XDocument(root).Save(path); | new XDocument(root).Save(path); | ||||
| } | } | ||||
| private static XElement CreateBackground(PageBackgroundLayout background) | |||||
| { | |||||
| var node = new XElement("background"); | |||||
| if (background.FileColumn is not null) | |||||
| { | |||||
| node.Add(new XAttribute("fileColumn", background.FileColumn)); | |||||
| } | |||||
| else if (background.File is not null) | |||||
| { | |||||
| node.Add(new XAttribute("file", background.File)); | |||||
| } | |||||
| if (background.PageColumn is not null) | |||||
| { | |||||
| node.Add(new XAttribute("pageColumn", background.PageColumn)); | |||||
| } | |||||
| else if (background.Page != 1) | |||||
| { | |||||
| node.Add(new XAttribute("page", background.Page.ToString(CultureInfo.InvariantCulture))); | |||||
| } | |||||
| return node; | |||||
| } | |||||
| private static PageBackgroundLayout? ParseBackground(XElement root, List<string> errors) | |||||
| { | |||||
| var nodes = root.Elements().Where(e => e.Name.LocalName == "background").ToList(); | |||||
| if (nodes.Count == 0) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| const string label = "<background>"; | |||||
| if (nodes.Count > 1) | |||||
| { | |||||
| errors.Add("Template has more than one <background> element - use at most one."); | |||||
| return null; | |||||
| } | |||||
| var node = nodes[0]; | |||||
| var file = ((string?)node.Attribute("file"))?.Trim(); | |||||
| var fileColumn = ((string?)node.Attribute("fileColumn"))?.Trim(); | |||||
| var pageColumn = ((string?)node.Attribute("pageColumn"))?.Trim(); | |||||
| var hasFile = !string.IsNullOrEmpty(file); | |||||
| var hasFileColumn = !string.IsNullOrEmpty(fileColumn); | |||||
| var ok = true; | |||||
| if (hasFile == hasFileColumn) | |||||
| { | |||||
| errors.Add($"{label} needs exactly one of 'file' or 'fileColumn'."); | |||||
| ok = false; | |||||
| } | |||||
| else if (hasFile && !PageBackgroundLayout.IsSupportedExtension(file!)) | |||||
| { | |||||
| errors.Add($"{label} has an unsupported 'file' type: '{file}'."); | |||||
| ok = false; | |||||
| } | |||||
| var page = 1; | |||||
| var pageRaw = (string?)node.Attribute("page"); | |||||
| if (pageRaw is not null | |||||
| && (!int.TryParse(pageRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out page) || page < 1)) | |||||
| { | |||||
| errors.Add($"{label} has an invalid 'page' value: '{pageRaw}'."); | |||||
| ok = false; | |||||
| } | |||||
| if (!ok) | |||||
| { | |||||
| return null; | |||||
| } | |||||
| return new PageBackgroundLayout | |||||
| { | |||||
| File = hasFile ? file : null, | |||||
| FileColumn = hasFileColumn ? fileColumn : null, | |||||
| Page = page, | |||||
| PageColumn = string.IsNullOrEmpty(pageColumn) ? null : pageColumn, | |||||
| }; | |||||
| } | |||||
| private static XElement CreateTextElement(TextElementLayout element) | private static XElement CreateTextElement(TextElementLayout element) | ||||
| { | { | ||||
| var textElement = new XElement( | 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 | // 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" | // looked before this story — matching the same "optional, default preserves old behavior" | ||||
| // convention as `collapsible`/`angle` above. | // 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) | if (element.HasBox) | ||||
| { | { | ||||
| textElement.Add(new XAttribute("width", element.Width!.Value.ToString(CultureInfo.InvariantCulture))); | 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))); | 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); | AddRuns(textElement, element.Runs, useLegacySingleRunShape: true); | ||||
| return textElement; | return textElement; | ||||
| } | } | ||||
| @@ -110,6 +208,16 @@ public static class TemplateLayoutXmlSerializer | |||||
| node.Add(new XAttribute("angle", control.RotationAngle.ToString(CultureInfo.InvariantCulture))); | 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) | foreach (var line in control.Lines) | ||||
| { | { | ||||
| var lineNode = new XElement( | var lineNode = new XElement( | ||||
| @@ -193,6 +301,8 @@ public static class TemplateLayoutXmlSerializer | |||||
| errorList.Add($"<envelopeTemplate> has an unrecognized 'canvasUnit' value: '{unitRaw}'."); | errorList.Add($"<envelopeTemplate> has an unrecognized 'canvasUnit' value: '{unitRaw}'."); | ||||
| } | } | ||||
| var background = ParseBackground(root, errorList); | |||||
| var elements = new List<TextElementLayout>(); | var elements = new List<TextElementLayout>(); | ||||
| var addressControls = new List<AddressControlLayout>(); | var addressControls = new List<AddressControlLayout>(); | ||||
| var renderNodes = root.Elements() | 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."); | errorList.Add($"{label} has both a 'column' attribute and inline text — use exactly one."); | ||||
| } | } | ||||
| else if (!hasColumn && !hasStaticText && node.Attribute("fill") is not null) | |||||
| { | |||||
| // A filled rectangle with no text of its own. | |||||
| runs = new List<TextRun> { TextRun.ForLiteral(string.Empty) }; | |||||
| } | |||||
| else if (!hasColumn && !hasStaticText) | else if (!hasColumn && !hasStaticText) | ||||
| { | { | ||||
| errorList.Add($"{label} has neither a 'column' attribute nor inline text — use exactly one."); | 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 elementWidth = ParseOptionalPositiveDouble(node, "width", label, errorList); | ||||
| var elementHeight = ParseOptionalPositiveDouble(node, "height", 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 | if (x is null || y is null || size is null || string.IsNullOrWhiteSpace(font) || runs is null | ||||
| || angle is null) | || angle is null) | ||||
| { | { | ||||
| @@ -345,6 +474,7 @@ public static class TemplateLayoutXmlSerializer | |||||
| elements[^1].RotationAngle = angle.Value; | elements[^1].RotationAngle = angle.Value; | ||||
| elements[^1].Width = elementWidth; | elements[^1].Width = elementWidth; | ||||
| elements[^1].Height = elementHeight; | elements[^1].Height = elementHeight; | ||||
| elements[^1].FillColor = fillColor; | |||||
| } | } | ||||
| if (errorList.Count > 0) | if (errorList.Count > 0) | ||||
| @@ -357,6 +487,7 @@ public static class TemplateLayoutXmlSerializer | |||||
| document = new TemplateLayoutDocument(canvas); | document = new TemplateLayoutDocument(canvas); | ||||
| document.Elements.AddRange(elements); | document.Elements.AddRange(elements); | ||||
| document.AddressControls.AddRange(addressControls); | document.AddressControls.AddRange(addressControls); | ||||
| document.Background = background; | |||||
| errors = Array.Empty<string>(); | errors = Array.Empty<string>(); | ||||
| return true; | 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(); | var lineNodes = node.Elements().Where(e => e.Name.LocalName == "line").ToList(); | ||||
| if (lineNodes.Count == 0) | if (lineNodes.Count == 0) | ||||
| { | { | ||||
| @@ -445,6 +592,8 @@ public static class TemplateLayoutXmlSerializer | |||||
| ZOrder = zOrder, | ZOrder = zOrder, | ||||
| LineSpacingMultiplier = lineSpacing, | LineSpacingMultiplier = lineSpacing, | ||||
| RotationAngle = angle.Value, | RotationAngle = angle.Value, | ||||
| TextCase = textCase, | |||||
| VerticalAnchor = verticalAnchor, | |||||
| }; | }; | ||||
| control.Lines.AddRange(lines); | control.Lines.AddRange(lines); | ||||
| return control; | return control; | ||||
| @@ -97,17 +97,20 @@ public static class TemplatePreviewBuilder | |||||
| var resolvedText = new string[control.Lines.Count]; | var resolvedText = new string[control.Lines.Count]; | ||||
| var collapseLines = new AddressLineCollapser.Line[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++) | for (var i = 0; i < control.Lines.Count; i++) | ||||
| { | { | ||||
| var line = control.Lines[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( | collapseLines[i] = new AddressLineCollapser.Line( | ||||
| control.X, y, line.CollapseIfBlank && string.IsNullOrWhiteSpace(resolvedText[i])); | control.X, y, line.CollapseIfBlank && string.IsNullOrWhiteSpace(resolvedText[i])); | ||||
| y -= line.FontSize * control.LineSpacingMultiplier; | y -= line.FontSize * control.LineSpacingMultiplier; | ||||
| } | } | ||||
| var resolved = AddressLineCollapser.Resolve(collapseLines); | var resolved = AddressLineCollapser.Resolve(collapseLines); | ||||
| var bottomShift = control.VerticalAnchor == AddressVerticalAnchor.Bottom | |||||
| ? AddressLineCollapser.BottomAnchorShift(resolved, control.Y) | |||||
| : 0; | |||||
| var angle = control.RotationAngle; | var angle = control.RotationAngle; | ||||
| var pivot = angle != 0 ? control.BoxCenter : default; | var pivot = angle != 0 ? control.BoxCenter : default; | ||||
| @@ -120,7 +123,7 @@ public static class TemplatePreviewBuilder | |||||
| var line = control.Lines[i]; | var line = control.Lines[i]; | ||||
| var drawX = control.X; | var drawX = control.X; | ||||
| var drawY = resolved[i].EffectiveY; | |||||
| var drawY = resolved[i].EffectiveY + bottomShift; | |||||
| if (angle != 0) | if (angle != 0) | ||||
| { | { | ||||
| (drawX, drawY) = PointRotation.RotateAroundPivot(drawX, drawY, pivot, angle); | (drawX, drawY) = PointRotation.RotateAroundPivot(drawX, drawY, pivot, angle); | ||||
| @@ -39,6 +39,14 @@ public sealed class TextElementLayout | |||||
| /// scope for this sprint's stories and is tracked as a known gap, not silently implemented.</summary> | /// scope for this sprint's stories and is tracked as a known gap, not silently implemented.</summary> | ||||
| public RgbColor Color { get; set; } = RgbColor.Black; | public RgbColor Color { get; set; } = RgbColor.Black; | ||||
| /// <summary>Optional filled-rectangle background covering this element box (needs both | |||||
| /// <see cref="Width"/> and <see cref="Height"/>). The element text, which may be empty, draws | |||||
| /// over it, and elements with a higher z-order draw over that. <c>null</c> = no fill.</summary> | |||||
| public RgbColor? FillColor { get; set; } | |||||
| /// <summary>True when <see cref="FillColor"/> is set and the box is fully defined.</summary> | |||||
| public bool HasFill => FillColor is not null && Width is > 0 && Height is > 0; | |||||
| /// <summary>Stacking order; 0 is bottom-most, matching the product's numeric z-order rule.</summary> | /// <summary>Stacking order; 0 is bottom-most, matching the product's numeric z-order rule.</summary> | ||||
| public int ZOrder { get; set; } | public int ZOrder { get; set; } | ||||
| @@ -9,4 +9,5 @@ public enum ProgressEventKind | |||||
| Render, | Render, | ||||
| Complete, | Complete, | ||||
| Failure, | Failure, | ||||
| Info, | |||||
| } | } | ||||
| @@ -78,6 +78,9 @@ public static class ProgressEventParser | |||||
| case "failure": | case "failure": | ||||
| kind = ProgressEventKind.Failure; | kind = ProgressEventKind.Failure; | ||||
| return true; | return true; | ||||
| case "info": | |||||
| kind = ProgressEventKind.Info; | |||||
| return true; | |||||
| default: | default: | ||||
| kind = default; | kind = default; | ||||
| return false; | return false; | ||||
| @@ -11,6 +11,16 @@ namespace EnvelopeRenderer.Desktop.Core.Launch; | |||||
| /// </summary> | /// </summary> | ||||
| public static class RenderProgressStatusFormatter | public static class RenderProgressStatusFormatter | ||||
| { | { | ||||
| /// <summary>One line for the run log: elapsed time plus the same wording as the status line, | |||||
| /// with a failure showing its reason. Every event kind is logged.</summary> | |||||
| public static string FormatLogLine(ProgressEvent progressEvent) | |||||
| { | |||||
| var text = progressEvent.Kind == ProgressEventKind.Failure | |||||
| ? $"Render failed after {progressEvent.Completed} record(s): {progressEvent.Reason}" | |||||
| : Format(progressEvent); | |||||
| return $"[{ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)}] {text}"; | |||||
| } | |||||
| public static string Format(ProgressEvent progressEvent) => progressEvent.Kind switch | public static string Format(ProgressEvent progressEvent) => progressEvent.Kind switch | ||||
| { | { | ||||
| ProgressEventKind.Startup => "Starting render...", | ProgressEventKind.Startup => "Starting render...", | ||||
| @@ -18,6 +28,7 @@ public static class RenderProgressStatusFormatter | |||||
| $"{progressEvent.Completed} record(s) processed, {ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)} elapsed...", | $"{progressEvent.Completed} record(s) processed, {ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)} elapsed...", | ||||
| ProgressEventKind.Complete => $"Finishing up... {progressEvent.Completed} record(s) rendered.", | ProgressEventKind.Complete => $"Finishing up... {progressEvent.Completed} record(s) rendered.", | ||||
| ProgressEventKind.Failure => "Render failed. Finishing up...", | ProgressEventKind.Failure => "Render failed. Finishing up...", | ||||
| ProgressEventKind.Info => progressEvent.Reason ?? string.Empty, | |||||
| _ => string.Empty, | _ => string.Empty, | ||||
| }; | }; | ||||
| } | } | ||||
| @@ -0,0 +1,170 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Design; | |||||
| namespace EnvelopeRenderer.Desktop.Tests; | |||||
| /// <summary>Address Control text case / vertical anchor and the template page background: preview | |||||
| /// parity with the CLI scenarios (BackgroundAndAddressOptionsTests), geometry, XML round trip.</summary> | |||||
| public class AddressOptionsAndBackgroundTests | |||||
| { | |||||
| private static IReadOnlyDictionary<string, string> Record(params (string Key, string Value)[] pairs) => | |||||
| pairs.ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase); | |||||
| private static AddressControlLayout Control( | |||||
| AddressTextCase textCase = AddressTextCase.None, | |||||
| AddressVerticalAnchor anchor = AddressVerticalAnchor.Top) | |||||
| { | |||||
| var control = AddressControlLayout.CreateDefault(120, 500); | |||||
| control.Lines.Clear(); | |||||
| control.Lines.Add(AddressControlLineLayout.CreateField("Name", fontSize: 12)); | |||||
| control.Lines.Add(AddressControlLineLayout.CreateField("Line2", fontSize: 12)); | |||||
| var literal = new AddressControlLineLayout("Arial", 12); | |||||
| literal.Runs.Clear(); | |||||
| literal.Runs.Add(TextRun.ForLiteral("Mixed Case")); | |||||
| control.Lines.Add(literal); | |||||
| control.TextCase = textCase; | |||||
| control.VerticalAnchor = anchor; | |||||
| return control; | |||||
| } | |||||
| private static IReadOnlyList<PreviewTextDraw> Preview(AddressControlLayout control, string line2) | |||||
| { | |||||
| var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points)); | |||||
| document.AddressControls.Add(control); | |||||
| var result = TemplatePreviewBuilder.Build( | |||||
| document, new[] { "Name", "Line2" }, Record(("Name", "Alice Smith"), ("Line2", line2))); | |||||
| Assert.True(result.Success, result.Message); | |||||
| return result.Draws; | |||||
| } | |||||
| [Theory] | |||||
| [InlineData(AddressTextCase.None, "Alice Smith", "Mixed Case")] | |||||
| [InlineData(AddressTextCase.Upper, "ALICE SMITH", "MIXED CASE")] | |||||
| [InlineData(AddressTextCase.Lower, "alice smith", "mixed case")] | |||||
| public void Preview_TextCase_AppliesToFieldAndLiteralText(AddressTextCase textCase, string name, string literal) | |||||
| { | |||||
| var draws = Preview(Control(textCase), "x"); | |||||
| Assert.Equal(name, draws[0].Text); | |||||
| Assert.Equal(literal, draws[2].Text); | |||||
| } | |||||
| [Fact] | |||||
| public void Preview_TopAnchor_BlockGrowsDownFromAnchor() | |||||
| => Assert.Equal(new[] { 500.0, 485.0, 470.0 }, Preview(Control(), "s").Select(d => d.Y).ToArray()); | |||||
| [Fact] | |||||
| public void Preview_BottomAnchor_LastLineOnAnchorAndBlockGrowsUp() | |||||
| => Assert.Equal( | |||||
| new[] { 530.0, 515.0, 500.0 }, | |||||
| Preview(Control(anchor: AddressVerticalAnchor.Bottom), "s").Select(d => d.Y).ToArray()); | |||||
| [Fact] | |||||
| public void Preview_BottomAnchor_CollapsedMiddleLine_KeepsLastVisibleLineOnAnchor() | |||||
| => Assert.Equal( | |||||
| new[] { 515.0, 500.0 }, | |||||
| Preview(Control(anchor: AddressVerticalAnchor.Bottom), "").Select(d => d.Y).ToArray()); | |||||
| [Fact] | |||||
| public void BottomAnchor_BoxGeometry_ExtendsUpwardFromAnchor() | |||||
| { | |||||
| var top = Control(); | |||||
| var bottom = Control(anchor: AddressVerticalAnchor.Bottom); | |||||
| Assert.Equal(500, top.TopBaselineY, precision: 6); | |||||
| Assert.Equal(530, bottom.TopBaselineY, precision: 6); | |||||
| Assert.Equal(top.BoxCenter.Y + 30, bottom.BoxCenter.Y, precision: 6); | |||||
| Assert.Equal(500, bottom.BaselineYForLine(2), precision: 6); | |||||
| } | |||||
| private static string TempFile() => Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml"); | |||||
| [Fact] | |||||
| public void Xml_RoundTrip_PreservesCaseAnchorAndBackground() | |||||
| { | |||||
| var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points)); | |||||
| document.AddressControls.Add(Control(AddressTextCase.Upper, AddressVerticalAnchor.Bottom)); | |||||
| document.Background = new PageBackgroundLayout { FileColumn = "Form", PageColumn = "Pg" }; | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| var loaded = TemplateLayoutXmlSerializer.TryLoad(path, out var doc, out var errors); | |||||
| Assert.True(loaded, string.Join("; ", errors)); | |||||
| var control = Assert.Single(doc!.AddressControls); | |||||
| Assert.Equal(AddressTextCase.Upper, control.TextCase); | |||||
| Assert.Equal(AddressVerticalAnchor.Bottom, control.VerticalAnchor); | |||||
| Assert.Equal("Form", doc.Background!.FileColumn); | |||||
| Assert.Equal("Pg", doc.Background.PageColumn); | |||||
| Assert.Null(doc.Background.File); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_Defaults_WriteNoNewAttributesOrBackground() | |||||
| { | |||||
| var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points)); | |||||
| document.AddressControls.Add(Control()); | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| var xml = File.ReadAllText(path); | |||||
| Assert.DoesNotContain("case=", xml); | |||||
| Assert.DoesNotContain("verticalAnchor", xml); | |||||
| Assert.DoesNotContain("<background", xml); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_StaticBackgroundFileAndPage_RoundTrip() | |||||
| { | |||||
| var document = new TemplateLayoutDocument(new CanvasSettings(297, 684, CanvasUnit.Points)); | |||||
| document.Elements.Add(TextElementLayout.CreateStatic(1, 2, "Hi")); | |||||
| document.Background = new PageBackgroundLayout { File = @"C:\forms\bg.pdf", Page = 3 }; | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var doc, out var errors), string.Join("; ", errors)); | |||||
| Assert.Equal(@"C:\forms\bg.pdf", doc!.Background!.File); | |||||
| Assert.Equal(3, doc.Background.Page); | |||||
| } | |||||
| [Theory] | |||||
| [InlineData("<background />")] | |||||
| [InlineData("<background file=\"a.txt\" />")] | |||||
| [InlineData("<background file=\"a.pdf\" fileColumn=\"X\" />")] | |||||
| [InlineData("<background file=\"a.pdf\" page=\"0\" />")] | |||||
| public void Xml_InvalidBackground_FailsToLoad(string backgroundXml) | |||||
| { | |||||
| var path = TempFile(); | |||||
| File.WriteAllText(path, | |||||
| $"<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\">{backgroundXml}<text x=\"1\" y=\"2\" font=\"Arial\" size=\"12\">Hi</text></envelopeTemplate>"); | |||||
| Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors)); | |||||
| Assert.Contains(errors, e => e.Contains("<background>")); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_InvalidCase_FailsToLoad() | |||||
| { | |||||
| var path = TempFile(); | |||||
| File.WriteAllText(path, | |||||
| "<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\"><addressControl x=\"1\" y=\"2\" width=\"180\" case=\"title\">" | |||||
| + "<line font=\"Arial\" size=\"12\"><run column=\"Name\" /></line></addressControl></envelopeTemplate>"); | |||||
| Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors)); | |||||
| Assert.Contains(errors, e => e.Contains("'case'")); | |||||
| } | |||||
| [Fact] | |||||
| public void Background_ResolvesFileAndPagePerRecord() | |||||
| { | |||||
| var background = new PageBackgroundLayout { FileColumn = "Form", PageColumn = "Pg" }; | |||||
| var record = Record(("Form", " a.pdf "), ("Pg", " 2 ")); | |||||
| Assert.Equal(Path.Combine(@"C:\t", "a.pdf"), background.ResolveFile(record, @"C:\t")); | |||||
| Assert.Equal(2, background.ResolvePage(record)); | |||||
| Assert.Null(background.ResolveFile(Record(("Form", "")), @"C:\t")); | |||||
| Assert.Null(background.ResolvePage(Record(("Pg", "zero")))); | |||||
| Assert.Equal(@"D:\x\b.png", new PageBackgroundLayout { File = @"D:\x\b.png" }.ResolveFile(null, @"C:\t")); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,110 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Design; | |||||
| namespace EnvelopeRenderer.Desktop.Tests; | |||||
| /// <summary>A text element with a fill color and a box is a filled rectangle (cover-up) with | |||||
| /// optional text over it; round trip through the template XML the render CLI reads.</summary> | |||||
| public class FilledRectangleLayoutTests | |||||
| { | |||||
| private static string TempFile() => Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xml"); | |||||
| private static TemplateLayoutDocument NewDocument() => | |||||
| new(new CanvasSettings(297, 684, CanvasUnit.Points)); | |||||
| [Fact] | |||||
| public void HasFill_RequiresColorWidthAndHeight() | |||||
| { | |||||
| var element = TextElementLayout.CreateStatic(1, 2, string.Empty); | |||||
| Assert.False(element.HasFill); | |||||
| element.FillColor = new RgbColor(255, 255, 255); | |||||
| Assert.False(element.HasFill); | |||||
| element.Width = 100; | |||||
| Assert.False(element.HasFill); | |||||
| element.Height = 40; | |||||
| Assert.True(element.HasFill); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_PureRectangle_RoundTripsWithEmptyText() | |||||
| { | |||||
| var document = NewDocument(); | |||||
| var rectangle = TextElementLayout.CreateStatic(10, 20, string.Empty); | |||||
| rectangle.Width = 120; | |||||
| rectangle.Height = 40; | |||||
| rectangle.FillColor = new RgbColor(255, 255, 255); | |||||
| document.Elements.Add(rectangle); | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| Assert.Contains("fill=\"#FFFFFF\"", File.ReadAllText(path)); | |||||
| Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors)); | |||||
| var element = Assert.Single(loaded!.Elements); | |||||
| Assert.Equal(new RgbColor(255, 255, 255), element.FillColor); | |||||
| Assert.Equal(120, element.Width); | |||||
| Assert.Equal(40, element.Height); | |||||
| Assert.True(element.HasFill); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_RectangleWithText_KeepsTextAndFill() | |||||
| { | |||||
| var document = NewDocument(); | |||||
| var element = TextElementLayout.CreateStatic(10, 20, "PAID"); | |||||
| element.Width = 100; | |||||
| element.Height = 30; | |||||
| element.FillColor = new RgbColor(255, 255, 0); | |||||
| document.Elements.Add(element); | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors)); | |||||
| var reloaded = Assert.Single(loaded!.Elements); | |||||
| Assert.Equal("PAID", reloaded.StaticText); | |||||
| Assert.Equal(new RgbColor(255, 255, 0), reloaded.FillColor); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_NoFill_WritesNoFillAttribute() | |||||
| { | |||||
| var document = NewDocument(); | |||||
| document.Elements.Add(TextElementLayout.CreateStatic(1, 2, "Hi")); | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| Assert.DoesNotContain("fill=", File.ReadAllText(path)); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_WidthOnlyElement_SavesWithoutThrowing_AndOmitsHeight() | |||||
| { | |||||
| var document = NewDocument(); | |||||
| var element = TextElementLayout.CreateStatic(1, 2, "wrap me"); | |||||
| element.Width = 80; | |||||
| document.Elements.Add(element); | |||||
| var path = TempFile(); | |||||
| TemplateLayoutXmlSerializer.Save(document, path); | |||||
| var xml = File.ReadAllText(path); | |||||
| Assert.Contains("width=\"80\"", xml); | |||||
| Assert.DoesNotContain("height=", xml.Replace("pageHeight", string.Empty)); | |||||
| Assert.True(TemplateLayoutXmlSerializer.TryLoad(path, out var loaded, out var errors), string.Join("; ", errors)); | |||||
| Assert.Equal(80, loaded!.Elements[0].Width); | |||||
| Assert.Null(loaded.Elements[0].Height); | |||||
| } | |||||
| [Fact] | |||||
| public void Xml_InvalidFill_FailsToLoad() | |||||
| { | |||||
| var path = TempFile(); | |||||
| File.WriteAllText(path, | |||||
| "<envelopeTemplate pageWidth=\"297\" pageHeight=\"684\"><text x=\"1\" y=\"2\" font=\"Arial\" size=\"12\" width=\"5\" height=\"5\" fill=\"red\">Hi</text></envelopeTemplate>"); | |||||
| Assert.False(TemplateLayoutXmlSerializer.TryLoad(path, out _, out var errors)); | |||||
| Assert.Contains(errors, e => e.Contains("'fill'")); | |||||
| } | |||||
| } | |||||
| @@ -85,4 +85,26 @@ public class ProgressEventParserTests | |||||
| Assert.False(parsed); | Assert.False(parsed); | ||||
| Assert.Null(progressEvent); | 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)); | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,99 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Design; | |||||
| namespace EnvelopeRenderer.Desktop.Tests; | |||||
| /// <summary>A filled rectangle gets a top-edge height handle that sets Height by dragging, just as | |||||
| /// the right-edge handle sets Width.</summary> | |||||
| public class RectangleHeightHandleTests | |||||
| { | |||||
| private static (double Width, double Height) BoxAwareSize(TextElementLayout e) => | |||||
| e.HasBox ? (e.Width!.Value, e.Height ?? 0) : (20.0, 10.0); | |||||
| private static (CanvasElementEditor Editor, TextElementLayout Element) Rectangle() | |||||
| { | |||||
| var editor = new CanvasElementEditor(TemplateLayoutDocument.CreateDefault(), BoxAwareSize); | |||||
| var element = editor.AddStaticText(100, 100, string.Empty); | |||||
| element.Width = 120; | |||||
| element.Height = 40; | |||||
| element.FillColor = new RgbColor(255, 255, 255); | |||||
| return (editor, element); | |||||
| } | |||||
| [Fact] | |||||
| public void HeightHandle_SitsAtTopEdgeMidpoint() | |||||
| { | |||||
| var (editor, _) = Rectangle(); | |||||
| var handle = editor.HeightHandlePosition(); | |||||
| Assert.NotNull(handle); | |||||
| Assert.Equal(160, handle!.Value.X, precision: 3); // 100 + 120 / 2 | |||||
| Assert.Equal(140, handle.Value.Y, precision: 3); // 100 + 40 | |||||
| Assert.True(editor.HitTestHeightHandle(160, 140)); | |||||
| Assert.False(editor.HitTestHeightHandle(160, 100)); | |||||
| } | |||||
| [Fact] | |||||
| public void HeightHandle_NotOfferedForPlainTextOrBoxWithoutFill() | |||||
| { | |||||
| var editor = new CanvasElementEditor(TemplateLayoutDocument.CreateDefault(), BoxAwareSize); | |||||
| var element = editor.AddStaticText(100, 100, "Hi"); | |||||
| Assert.Null(editor.HeightHandlePosition()); | |||||
| element.Width = 120; | |||||
| element.Height = 40; | |||||
| Assert.Null(editor.HeightHandlePosition()); | |||||
| Assert.False(editor.HitTestHeightHandle(160, 140)); | |||||
| } | |||||
| [Fact] | |||||
| public void DraggingHeightHandle_SetsHeightFromVerticalDistanceToBottom_AndLeavesWidthAndPosition() | |||||
| { | |||||
| var (editor, element) = Rectangle(); | |||||
| editor.BeginHeightResizeDrag(); | |||||
| Assert.True(editor.IsResizing); | |||||
| editor.ResizeDragTo(9999, 175); // 75 above the (100,100) bottom edge; X is irrelevant | |||||
| Assert.Equal(75, element.Height!.Value, precision: 3); | |||||
| Assert.Equal(120, element.Width!.Value, precision: 3); | |||||
| Assert.Equal(100, element.X, precision: 6); | |||||
| Assert.Equal(100, element.Y, precision: 6); | |||||
| } | |||||
| [Fact] | |||||
| public void DraggingHeightHandleBelowTheBottomEdge_LeavesHeightUnchanged() | |||||
| { | |||||
| var (editor, element) = Rectangle(); | |||||
| editor.BeginHeightResizeDrag(); | |||||
| editor.ResizeDragTo(160, 90); | |||||
| Assert.Equal(40, element.Height!.Value, precision: 3); | |||||
| } | |||||
| [Fact] | |||||
| public void EndDrag_StopsHeightResize() | |||||
| { | |||||
| var (editor, element) = Rectangle(); | |||||
| editor.BeginHeightResizeDrag(); | |||||
| editor.EndDrag(); | |||||
| editor.ResizeDragTo(160, 200); | |||||
| Assert.False(editor.IsResizing); | |||||
| Assert.Equal(40, element.Height!.Value, precision: 3); | |||||
| } | |||||
| [Fact] | |||||
| public void WidthHandleStillOnlyChangesWidth() | |||||
| { | |||||
| var (editor, element) = Rectangle(); | |||||
| editor.BeginResizeDrag(); | |||||
| editor.ResizeDragTo(250, 999); | |||||
| Assert.Equal(150, element.Width!.Value, precision: 3); | |||||
| Assert.Equal(40, element.Height!.Value, precision: 3); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,67 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Design; | |||||
| namespace EnvelopeRenderer.Desktop.Tests; | |||||
| public class SelectionCyclerTests | |||||
| { | |||||
| private static readonly Guid A = Guid.Parse("00000000-0000-0000-0000-00000000000a"); | |||||
| private static readonly Guid B = Guid.Parse("00000000-0000-0000-0000-00000000000b"); | |||||
| private static readonly Guid C = Guid.Parse("00000000-0000-0000-0000-00000000000c"); | |||||
| private static SelectionCycler.Item[] Items() => new[] | |||||
| { | |||||
| new SelectionCycler.Item(C, ZOrder: 2, X: 0, Y: 0), | |||||
| new SelectionCycler.Item(A, ZOrder: 0, X: 0, Y: 0), | |||||
| new SelectionCycler.Item(B, ZOrder: 1, X: 0, Y: 0), | |||||
| }; | |||||
| [Fact] | |||||
| public void Forward_WalksBottomToTopByZOrder_AndWraps() | |||||
| { | |||||
| Assert.Equal(A, SelectionCycler.Next(Items(), null, forward: true)); | |||||
| Assert.Equal(B, SelectionCycler.Next(Items(), A, forward: true)); | |||||
| Assert.Equal(C, SelectionCycler.Next(Items(), B, forward: true)); | |||||
| Assert.Equal(A, SelectionCycler.Next(Items(), C, forward: true)); | |||||
| } | |||||
| [Fact] | |||||
| public void Backward_WalksTopToBottom_AndWraps_StartingAtTheLastItem() | |||||
| { | |||||
| Assert.Equal(C, SelectionCycler.Next(Items(), null, forward: false)); | |||||
| Assert.Equal(B, SelectionCycler.Next(Items(), C, forward: false)); | |||||
| Assert.Equal(A, SelectionCycler.Next(Items(), B, forward: false)); | |||||
| Assert.Equal(C, SelectionCycler.Next(Items(), A, forward: false)); | |||||
| } | |||||
| [Fact] | |||||
| public void SameZOrder_VisitsTopToBottomThenLeftToRight() | |||||
| { | |||||
| var items = new[] | |||||
| { | |||||
| new SelectionCycler.Item(A, 0, X: 50, Y: 10), | |||||
| new SelectionCycler.Item(B, 0, X: 10, Y: 100), | |||||
| new SelectionCycler.Item(C, 0, X: 10, Y: 10), | |||||
| }; | |||||
| Assert.Equal(B, SelectionCycler.Next(items, null, forward: true)); // highest on the page first | |||||
| Assert.Equal(C, SelectionCycler.Next(items, B, forward: true)); // then the lower-left one | |||||
| Assert.Equal(A, SelectionCycler.Next(items, C, forward: true)); | |||||
| } | |||||
| [Fact] | |||||
| public void UnknownCurrentSelection_BehavesLikeNoSelection() | |||||
| => Assert.Equal(A, SelectionCycler.Next(Items(), Guid.NewGuid(), forward: true)); | |||||
| [Fact] | |||||
| public void NoItems_ReturnsNull() | |||||
| => Assert.Null(SelectionCycler.Next(Array.Empty<SelectionCycler.Item>(), null, forward: true)); | |||||
| [Fact] | |||||
| public void SingleItem_StaysOnIt() | |||||
| { | |||||
| var items = new[] { new SelectionCycler.Item(A, 0, 0, 0) }; | |||||
| Assert.Equal(A, SelectionCycler.Next(items, A, forward: true)); | |||||
| Assert.Equal(A, SelectionCycler.Next(items, A, forward: false)); | |||||
| } | |||||
| } | |||||
| @@ -11,6 +11,10 @@ | |||||
| <ItemGroup> | <ItemGroup> | ||||
| <ProjectReference Include="..\EnvelopeRenderer.Desktop.Core\EnvelopeRenderer.Desktop.Core.csproj" /> | <ProjectReference Include="..\EnvelopeRenderer.Desktop.Core\EnvelopeRenderer.Desktop.Core.csproj" /> | ||||
| <!-- Vendor interop only (no CLI code): the designer canvas renders PDF page backgrounds with | |||||
| Debenu's DARenderPageToDC. --> | |||||
| <ProjectReference Include="..\EnvelopeRenderer.Debenu\EnvelopeRenderer.Debenu.csproj" /> | |||||
| <!-- | <!-- | ||||
| Referenced only so `dotnet build`/`dotnet run` for this project always produce a freshly | Referenced only so `dotnet build`/`dotnet run` for this project always produce a freshly | ||||
| built EnvelopeRenderer.Cli.exe copied next to EnvelopeRenderer.Desktop.exe — exactly where | built EnvelopeRenderer.Cli.exe copied next to EnvelopeRenderer.Desktop.exe — exactly where | ||||
| @@ -32,6 +32,16 @@ public sealed class MainForm : Form | |||||
| TextAlign = ContentAlignment.TopLeft, | TextAlign = ContentAlignment.TopLeft, | ||||
| }; | }; | ||||
| private readonly TextBox _logTextBox = new() | |||||
| { | |||||
| Multiline = true, | |||||
| ReadOnly = true, | |||||
| ScrollBars = ScrollBars.Vertical, | |||||
| WordWrap = true, | |||||
| Height = 200, | |||||
| Font = new Font(FontFamily.GenericMonospace, 9f), | |||||
| }; | |||||
| private readonly CliProcessLauncher _launcher; | private readonly CliProcessLauncher _launcher; | ||||
| public MainForm() : this(new CliProcessLauncher()) | public MainForm() : this(new CliProcessLauncher()) | ||||
| @@ -44,7 +54,7 @@ public sealed class MainForm : Form | |||||
| _launcher = launcher; | _launcher = launcher; | ||||
| Text = "Envelope Renderer"; | Text = "Envelope Renderer"; | ||||
| MinimumSize = new Size(640, 260); | |||||
| MinimumSize = new Size(640, 480); | |||||
| StartPosition = FormStartPosition.CenterScreen; | StartPosition = FormStartPosition.CenterScreen; | ||||
| Controls.Add(BuildLayout()); | Controls.Add(BuildLayout()); | ||||
| @@ -56,7 +66,7 @@ public sealed class MainForm : Form | |||||
| { | { | ||||
| Dock = DockStyle.Fill, | Dock = DockStyle.Fill, | ||||
| ColumnCount = 3, | ColumnCount = 3, | ||||
| RowCount = 5, | |||||
| RowCount = 6, | |||||
| Padding = new Padding(12), | Padding = new Padding(12), | ||||
| AutoSize = true, | AutoSize = true, | ||||
| }; | }; | ||||
| @@ -97,6 +107,10 @@ public sealed class MainForm : Form | |||||
| layout.Controls.Add(_statusLabel, 0, 4); | layout.Controls.Add(_statusLabel, 0, 4); | ||||
| layout.SetColumnSpan(_statusLabel, 3); | layout.SetColumnSpan(_statusLabel, 3); | ||||
| _logTextBox.Dock = DockStyle.Fill; | |||||
| layout.Controls.Add(_logTextBox, 0, 5); | |||||
| layout.SetColumnSpan(_logTextBox, 3); | |||||
| return layout; | return layout; | ||||
| } | } | ||||
| @@ -163,6 +177,7 @@ public sealed class MainForm : Form | |||||
| // operator can't launch a second render against the same output while one is in flight. | // operator can't launch a second render against the same output while one is in flight. | ||||
| _renderButton.Enabled = false; | _renderButton.Enabled = false; | ||||
| SetStatus("Starting render...", isError: false); | SetStatus("Starting render...", isError: false); | ||||
| _logTextBox.Clear(); | |||||
| try | try | ||||
| { | { | ||||
| @@ -207,7 +222,13 @@ public sealed class MainForm : Form | |||||
| private void OnRenderProgress(ProgressEvent progressEvent) | private void OnRenderProgress(ProgressEvent progressEvent) | ||||
| { | { | ||||
| SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure); | |||||
| // Info lines are log-only detail (they would flicker past in the one-line status). | |||||
| if (progressEvent.Kind != ProgressEventKind.Info) | |||||
| { | |||||
| SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure); | |||||
| } | |||||
| _logTextBox.AppendText(RenderProgressStatusFormatter.FormatLogLine(progressEvent) + Environment.NewLine); | |||||
| } | } | ||||
| private void ShowLaunchFailure(string message) | private void ShowLaunchFailure(string message) | ||||
| @@ -0,0 +1,223 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Design; | |||||
| namespace EnvelopeRenderer.Desktop.Views; | |||||
| /// <summary>Modal editor for the template page background: none, a fixed image / PDF file, or a | |||||
| /// file read from a CSV column; for a PDF, a fixed page number or a page read from a CSV column. | |||||
| /// <see cref="Result"/> is <c>null</c> when "No background" is chosen.</summary> | |||||
| internal sealed class PageBackgroundDialog : Form | |||||
| { | |||||
| private readonly RadioButton _noneRadio = new() { Text = "No background", AutoSize = true }; | |||||
| private readonly RadioButton _fixedFileRadio = new() { Text = "Image or PDF file:", AutoSize = true }; | |||||
| private readonly RadioButton _columnFileRadio = new() { Text = "File path from CSV column:", AutoSize = true }; | |||||
| private readonly TextBox _filePathInput = new() { Width = 300 }; | |||||
| private readonly Button _browseButton = new() { Text = "Browse...", AutoSize = true }; | |||||
| private readonly ComboBox _fileColumnInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 }; | |||||
| private readonly RadioButton _fixedPageRadio = new() { Text = "PDF page number:", AutoSize = true }; | |||||
| private readonly RadioButton _columnPageRadio = new() { Text = "PDF page from CSV column:", AutoSize = true }; | |||||
| private readonly NumericUpDown _pageInput = new() { Minimum = 1, Maximum = 100000, Value = 1, Width = 70 }; | |||||
| private readonly ComboBox _pageColumnInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 }; | |||||
| private readonly Label _hint = new() | |||||
| { | |||||
| AutoSize = false, | |||||
| Width = 470, | |||||
| Height = 48, | |||||
| Text = "The background is stretched to the page size behind every page. PDF pages are drawn as a " | |||||
| + "300 DPI image. Relative paths are read from the template's folder. A blank file value in the " | |||||
| + "CSV means no background for that record.", | |||||
| }; | |||||
| public PageBackgroundLayout? Result { get; private set; } | |||||
| public PageBackgroundDialog(PageBackgroundLayout? current, IReadOnlyList<string> csvHeaders) | |||||
| { | |||||
| Text = "Page Background"; | |||||
| FormBorderStyle = FormBorderStyle.FixedDialog; | |||||
| MaximizeBox = false; | |||||
| MinimizeBox = false; | |||||
| StartPosition = FormStartPosition.CenterParent; | |||||
| ClientSize = new Size(500, 330); | |||||
| _fileColumnInput.Items.AddRange(csvHeaders.Cast<object>().ToArray()); | |||||
| _pageColumnInput.Items.AddRange(csvHeaders.Cast<object>().ToArray()); | |||||
| var hasColumns = csvHeaders.Count > 0; | |||||
| _columnFileRadio.Enabled = hasColumns; | |||||
| _columnPageRadio.Enabled = hasColumns; | |||||
| _noneRadio.Location = new Point(14, 14); | |||||
| _fixedFileRadio.Location = new Point(14, 44); | |||||
| _filePathInput.Location = new Point(34, 68); | |||||
| _browseButton.Location = new Point(342, 66); | |||||
| _columnFileRadio.Location = new Point(14, 102); | |||||
| _fileColumnInput.Location = new Point(34, 126); | |||||
| _fixedPageRadio.Location = new Point(14, 164); | |||||
| _pageInput.Location = new Point(190, 162); | |||||
| _columnPageRadio.Location = new Point(14, 194); | |||||
| _pageColumnInput.Location = new Point(34, 218); | |||||
| _hint.Location = new Point(14, 252); | |||||
| var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(330, 292) }; | |||||
| var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(410, 292) }; | |||||
| AcceptButton = ok; | |||||
| CancelButton = cancel; | |||||
| Controls.AddRange(new Control[] | |||||
| { | |||||
| _noneRadio, _fixedFileRadio, _filePathInput, _browseButton, _columnFileRadio, _fileColumnInput, | |||||
| _fixedPageRadio, _pageInput, _columnPageRadio, _pageColumnInput, _hint, ok, cancel, | |||||
| }); | |||||
| _browseButton.Click += (_, _) => | |||||
| { | |||||
| using var picker = new OpenFileDialog | |||||
| { | |||||
| Title = "Choose background image or PDF", | |||||
| Filter = "Images and PDF|*.pdf;*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.tif;*.tiff|All files (*.*)|*.*", | |||||
| CheckFileExists = true, | |||||
| }; | |||||
| if (picker.ShowDialog(this) == DialogResult.OK) | |||||
| { | |||||
| _filePathInput.Text = picker.FileName; | |||||
| _fixedFileRadio.Checked = true; | |||||
| } | |||||
| }; | |||||
| foreach (var radio in new[] { _noneRadio, _fixedFileRadio, _columnFileRadio, _fixedPageRadio, _columnPageRadio }) | |||||
| { | |||||
| radio.CheckedChanged += (_, _) => SyncEnabled(); | |||||
| } | |||||
| // Radio groups: file source (3) and page source (2) need separate containers so they do not | |||||
| // share one exclusive group. | |||||
| var pageGroup = new Panel { Location = new Point(0, 160), Size = new Size(500, 90) }; | |||||
| Controls.Remove(_fixedPageRadio); | |||||
| Controls.Remove(_pageInput); | |||||
| Controls.Remove(_columnPageRadio); | |||||
| Controls.Remove(_pageColumnInput); | |||||
| _fixedPageRadio.Location = new Point(14, 4); | |||||
| _pageInput.Location = new Point(190, 2); | |||||
| _columnPageRadio.Location = new Point(14, 34); | |||||
| _pageColumnInput.Location = new Point(34, 58); | |||||
| pageGroup.Controls.AddRange(new Control[] { _fixedPageRadio, _pageInput, _columnPageRadio, _pageColumnInput }); | |||||
| Controls.Add(pageGroup); | |||||
| LoadFrom(current); | |||||
| SyncEnabled(); | |||||
| FormClosing += (_, e) => | |||||
| { | |||||
| if (DialogResult == DialogResult.OK && !TryBuildResult(out var message)) | |||||
| { | |||||
| MessageBox.Show(this, message, "Page Background", MessageBoxButtons.OK, MessageBoxIcon.Warning); | |||||
| e.Cancel = true; | |||||
| } | |||||
| }; | |||||
| } | |||||
| private void LoadFrom(PageBackgroundLayout? current) | |||||
| { | |||||
| if (current is null) | |||||
| { | |||||
| _noneRadio.Checked = true; | |||||
| _fixedPageRadio.Checked = true; | |||||
| return; | |||||
| } | |||||
| if (current.FileColumn is not null) | |||||
| { | |||||
| _columnFileRadio.Checked = true; | |||||
| SelectItem(_fileColumnInput, current.FileColumn); | |||||
| } | |||||
| else | |||||
| { | |||||
| _fixedFileRadio.Checked = true; | |||||
| _filePathInput.Text = current.File ?? string.Empty; | |||||
| } | |||||
| if (current.PageColumn is not null) | |||||
| { | |||||
| _columnPageRadio.Checked = true; | |||||
| SelectItem(_pageColumnInput, current.PageColumn); | |||||
| } | |||||
| else | |||||
| { | |||||
| _fixedPageRadio.Checked = true; | |||||
| _pageInput.Value = Math.Clamp(current.Page, 1, 100000); | |||||
| } | |||||
| } | |||||
| private static void SelectItem(ComboBox box, string value) | |||||
| { | |||||
| var index = box.Items.IndexOf(value); | |||||
| if (index < 0) | |||||
| { | |||||
| box.Items.Add(value); | |||||
| index = box.Items.Count - 1; | |||||
| } | |||||
| box.SelectedIndex = index; | |||||
| } | |||||
| private void SyncEnabled() | |||||
| { | |||||
| var hasBackground = !_noneRadio.Checked; | |||||
| _filePathInput.Enabled = _fixedFileRadio.Checked; | |||||
| _browseButton.Enabled = true; | |||||
| _fileColumnInput.Enabled = _columnFileRadio.Checked; | |||||
| _fixedPageRadio.Enabled = hasBackground; | |||||
| _columnPageRadio.Enabled = hasBackground && _fileColumnInput.Items.Count > 0; | |||||
| _pageInput.Enabled = hasBackground && _fixedPageRadio.Checked; | |||||
| _pageColumnInput.Enabled = hasBackground && _columnPageRadio.Checked; | |||||
| } | |||||
| private bool TryBuildResult(out string message) | |||||
| { | |||||
| message = string.Empty; | |||||
| if (_noneRadio.Checked) | |||||
| { | |||||
| Result = null; | |||||
| return true; | |||||
| } | |||||
| var layout = new PageBackgroundLayout(); | |||||
| if (_fixedFileRadio.Checked) | |||||
| { | |||||
| var path = _filePathInput.Text.Trim(); | |||||
| if (path.Length == 0 || !PageBackgroundLayout.IsSupportedExtension(path)) | |||||
| { | |||||
| message = "Choose a .pdf, .png, .jpg, .jpeg, .bmp, .gif, .tif or .tiff file."; | |||||
| return false; | |||||
| } | |||||
| layout.File = path; | |||||
| } | |||||
| else | |||||
| { | |||||
| if (_fileColumnInput.SelectedItem is not string column) | |||||
| { | |||||
| message = "Choose the CSV column that holds the background file path."; | |||||
| return false; | |||||
| } | |||||
| layout.FileColumn = column; | |||||
| } | |||||
| if (_columnPageRadio.Checked) | |||||
| { | |||||
| if (_pageColumnInput.SelectedItem is not string pageColumn) | |||||
| { | |||||
| message = "Choose the CSV column that holds the PDF page number."; | |||||
| return false; | |||||
| } | |||||
| layout.PageColumn = pageColumn; | |||||
| } | |||||
| else | |||||
| { | |||||
| layout.Page = (int)_pageInput.Value; | |||||
| } | |||||
| Result = layout; | |||||
| return true; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,136 @@ | |||||
| using System.Drawing; | |||||
| using DebenuPDFLibraryDLL1013; | |||||
| namespace EnvelopeRenderer.Desktop.Views; | |||||
| /// <summary>Renders one page of a PDF to a bitmap with Debenu's Document Analyzer | |||||
| /// <c>DARenderPageToDC</c>, for showing a PDF page background on the designer canvas. Only the | |||||
| /// vendor interop is shared with the CLI (EnvelopeRenderer.Debenu); this class has no CLI code. | |||||
| /// Kept free of WinForms types (System.Drawing only) so it can be exercised from a test project.</summary> | |||||
| internal static class PdfPageBitmapRenderer | |||||
| { | |||||
| /// <summary>Largest bitmap width rendered, in pixels - keeps memory bounded on a zoomed canvas.</summary> | |||||
| public const int MaxPixelWidth = 2400; | |||||
| /// <summary>Renders <paramref name="page"/> (1-based) of <paramref name="pdfPath"/> so its width | |||||
| /// is <paramref name="targetPixelWidth"/> pixels (height follows the PDF page aspect ratio). | |||||
| /// Returns <c>null</c> with a readable <paramref name="error"/> on failure.</summary> | |||||
| public static Bitmap? Render(string pdfPath, int page, int targetPixelWidth, out string? error) | |||||
| { | |||||
| error = null; | |||||
| if (!File.Exists(pdfPath)) | |||||
| { | |||||
| error = $"file not found: {pdfPath}"; | |||||
| return null; | |||||
| } | |||||
| var dllPath = Path.Combine( | |||||
| AppContext.BaseDirectory, | |||||
| Environment.Is64BitProcess ? "DebenuPDFLibrary64DLL1013.dll" : "DebenuPDFLibraryDLL1013.dll"); | |||||
| var pdf = new PDFLibrary(dllPath); | |||||
| if (!pdf.LibraryLoaded()) | |||||
| { | |||||
| error = "the PDF library could not be loaded"; | |||||
| return null; | |||||
| } | |||||
| try | |||||
| { | |||||
| var key = ResolveLicenseKey(); | |||||
| if (!string.IsNullOrEmpty(key)) | |||||
| { | |||||
| pdf.UnlockKey(key); | |||||
| } | |||||
| var file = pdf.DAOpenFileReadOnly(pdfPath, string.Empty); | |||||
| if (file == 0) | |||||
| { | |||||
| error = "the PDF could not be opened"; | |||||
| return null; | |||||
| } | |||||
| try | |||||
| { | |||||
| var pageCount = pdf.DAGetPageCount(file); | |||||
| if (page < 1 || page > pageCount) | |||||
| { | |||||
| error = $"the PDF has {pageCount} page(s), no page {page}"; | |||||
| return null; | |||||
| } | |||||
| var pageRef = pdf.DAFindPage(file, page); | |||||
| var widthPoints = pdf.DAGetPageWidth(file, pageRef); | |||||
| var heightPoints = pdf.DAGetPageHeight(file, pageRef); | |||||
| if (pageRef == 0 || widthPoints <= 0 || heightPoints <= 0) | |||||
| { | |||||
| error = $"page {page} could not be read"; | |||||
| return null; | |||||
| } | |||||
| var pixelWidth = Math.Clamp(targetPixelWidth, 50, MaxPixelWidth); | |||||
| var pixelHeight = Math.Max(1, (int)Math.Round(pixelWidth * heightPoints / widthPoints)); | |||||
| var dpi = pixelWidth / (widthPoints / 72.0); | |||||
| var bitmap = new Bitmap(pixelWidth, pixelHeight); | |||||
| using (var g = Graphics.FromImage(bitmap)) | |||||
| { | |||||
| g.Clear(Color.White); | |||||
| var dc = g.GetHdc(); | |||||
| int result; | |||||
| try | |||||
| { | |||||
| result = pdf.DARenderPageToDC(file, pageRef, dpi, dc); | |||||
| } | |||||
| finally | |||||
| { | |||||
| g.ReleaseHdc(dc); | |||||
| } | |||||
| if (result == 0) | |||||
| { | |||||
| bitmap.Dispose(); | |||||
| error = "the page could not be rendered"; | |||||
| return null; | |||||
| } | |||||
| } | |||||
| return bitmap; | |||||
| } | |||||
| finally | |||||
| { | |||||
| pdf.DACloseFile(file); | |||||
| } | |||||
| } | |||||
| finally | |||||
| { | |||||
| pdf.ReleaseLibrary(); | |||||
| } | |||||
| } | |||||
| /// <summary>The DEBENU_LICENSE_KEY environment variable, else a key.txt in the app folder or any | |||||
| /// parent (the same lookup the CLI uses, so one key file serves both).</summary> | |||||
| private static string? ResolveLicenseKey() | |||||
| { | |||||
| var fromEnv = Environment.GetEnvironmentVariable("DEBENU_LICENSE_KEY"); | |||||
| if (!string.IsNullOrWhiteSpace(fromEnv)) | |||||
| { | |||||
| return fromEnv; | |||||
| } | |||||
| var directory = new DirectoryInfo(AppContext.BaseDirectory); | |||||
| for (var i = 0; i < 10 && directory is not null; i++, directory = directory.Parent) | |||||
| { | |||||
| var candidate = Path.Combine(directory.FullName, "key.txt"); | |||||
| if (File.Exists(candidate)) | |||||
| { | |||||
| var content = File.ReadAllText(candidate).Trim(); | |||||
| if (!string.IsNullOrEmpty(content)) | |||||
| { | |||||
| return content; | |||||
| } | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,121 @@ | |||||
| using System.Diagnostics; | |||||
| using EnvelopeRenderer.Desktop.Core.Launch; | |||||
| namespace EnvelopeRenderer.Desktop.Views; | |||||
| /// <summary>Modal window that runs the render CLI for one template + CSV + output PDF and shows its | |||||
| /// progress log live, the same events the main window shows. Starts rendering when shown; Close is | |||||
| /// disabled until the process has exited.</summary> | |||||
| internal sealed class RenderRunDialog : Form | |||||
| { | |||||
| private readonly RenderLaunchInputs _inputs; | |||||
| private readonly Label _statusLabel = new() { AutoSize = false, Height = 40, Dock = DockStyle.Top, TextAlign = ContentAlignment.MiddleLeft }; | |||||
| private readonly TextBox _logTextBox = new() | |||||
| { | |||||
| Multiline = true, | |||||
| ReadOnly = true, | |||||
| ScrollBars = ScrollBars.Vertical, | |||||
| WordWrap = true, | |||||
| Dock = DockStyle.Fill, | |||||
| Font = new Font(FontFamily.GenericMonospace, 9f), | |||||
| }; | |||||
| private readonly Button _openButton = new() { Text = "&Open PDF", AutoSize = true, Enabled = false }; | |||||
| private readonly Button _closeButton = new() { Text = "&Close", AutoSize = true, Enabled = false }; | |||||
| private bool _running; | |||||
| public RenderRunDialog(RenderLaunchInputs inputs) | |||||
| { | |||||
| _inputs = inputs; | |||||
| Text = "Render PDF"; | |||||
| StartPosition = FormStartPosition.CenterParent; | |||||
| ClientSize = new Size(640, 420); | |||||
| MinimumSize = new Size(480, 300); | |||||
| ControlBox = false; | |||||
| var buttons = new FlowLayoutPanel | |||||
| { | |||||
| Dock = DockStyle.Bottom, | |||||
| FlowDirection = FlowDirection.RightToLeft, | |||||
| AutoSize = true, | |||||
| Padding = new Padding(8), | |||||
| }; | |||||
| buttons.Controls.Add(_closeButton); | |||||
| buttons.Controls.Add(_openButton); | |||||
| Controls.Add(_logTextBox); | |||||
| Controls.Add(buttons); | |||||
| Controls.Add(_statusLabel); | |||||
| _closeButton.Click += (_, _) => Close(); | |||||
| _openButton.Click += (_, _) => OpenOutput(); | |||||
| CancelButton = _closeButton; | |||||
| Shown += async (_, _) => await RunAsync(); | |||||
| FormClosing += (_, e) => | |||||
| { | |||||
| if (_running) | |||||
| { | |||||
| e.Cancel = true; | |||||
| } | |||||
| }; | |||||
| } | |||||
| private async Task RunAsync() | |||||
| { | |||||
| _running = true; | |||||
| SetStatus("Starting render...", isError: false); | |||||
| var cliPath = CliExecutablePathResolver.Resolve(AppContext.BaseDirectory); | |||||
| if (!cliPath.IsFound) | |||||
| { | |||||
| Finish(cliPath.Error!, isError: true, succeeded: false); | |||||
| return; | |||||
| } | |||||
| var progress = new Progress<ProgressEvent>(OnProgress); | |||||
| var result = await new CliProcessLauncher().RunAsync( | |||||
| cliPath.Path!, CliArgumentListBuilder.Build(_inputs), progress); | |||||
| Finish(RenderCompletionSummaryFormatter.Format(result), isError: !result.Succeeded, succeeded: result.Succeeded); | |||||
| } | |||||
| private void OnProgress(ProgressEvent progressEvent) | |||||
| { | |||||
| if (progressEvent.Kind != ProgressEventKind.Info) | |||||
| { | |||||
| SetStatus( | |||||
| RenderProgressStatusFormatter.Format(progressEvent), | |||||
| isError: progressEvent.Kind == ProgressEventKind.Failure); | |||||
| } | |||||
| _logTextBox.AppendText(RenderProgressStatusFormatter.FormatLogLine(progressEvent) + Environment.NewLine); | |||||
| } | |||||
| private void Finish(string summary, bool isError, bool succeeded) | |||||
| { | |||||
| _running = false; | |||||
| SetStatus(summary, isError); | |||||
| _logTextBox.AppendText(summary + Environment.NewLine); | |||||
| _closeButton.Enabled = true; | |||||
| _openButton.Enabled = succeeded && File.Exists(_inputs.OutputPath); | |||||
| ControlBox = true; | |||||
| } | |||||
| private void SetStatus(string message, bool isError) | |||||
| { | |||||
| _statusLabel.Text = message; | |||||
| _statusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen; | |||||
| } | |||||
| private void OpenOutput() | |||||
| { | |||||
| try | |||||
| { | |||||
| Process.Start(new ProcessStartInfo(_inputs.OutputPath!) { UseShellExecute = true }); | |||||
| } | |||||
| catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) | |||||
| { | |||||
| MessageBox.Show(this, $"Could not open the PDF: {ex.Message}", "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Warning); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -116,7 +116,7 @@ public sealed class TemplateCanvasControl : Control | |||||
| /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this | /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this | ||||
| /// property existed.</summary> | /// property existed.</summary> | ||||
| public bool IsInteracting => | public bool IsInteracting => | ||||
| _editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null | |||||
| _editor.IsDragging || _editor.IsRotating || _editor.IsResizing || _addressControlDragOffset is not null | |||||
| || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging; | || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging; | ||||
| public TextElementLayout? SelectedElement => _editor.Selected; | public TextElementLayout? SelectedElement => _editor.Selected; | ||||
| @@ -152,6 +152,25 @@ public sealed class TemplateCanvasControl : Control | |||||
| return element; | return element; | ||||
| } | } | ||||
| /// <summary>Adds a filled rectangle (a text element with an empty content, a box and a fill | |||||
| /// color) that covers whatever is under it and can have text typed into it. White by default, | |||||
| /// like a cover-up patch.</summary> | |||||
| public TextElementLayout AddRectangleElement() | |||||
| { | |||||
| var (x, y) = DefaultNewElementPosition(); | |||||
| var element = _editor.AddStaticText(x, y, text: string.Empty); | |||||
| element.Width = DefaultRectangleWidth; | |||||
| element.Height = DefaultRectangleHeight; | |||||
| element.FillColor = new RgbColor(255, 255, 255); | |||||
| Invalidate(); | |||||
| SelectionChanged?.Invoke(this, EventArgs.Empty); | |||||
| ElementsChanged?.Invoke(this, EventArgs.Empty); | |||||
| return element; | |||||
| } | |||||
| public const double DefaultRectangleWidth = 120; | |||||
| public const double DefaultRectangleHeight = 40; | |||||
| public AddressControlLayout AddAddressControl() | public AddressControlLayout AddAddressControl() | ||||
| { | { | ||||
| var (x, y) = DefaultNewElementPosition(); | var (x, y) = DefaultNewElementPosition(); | ||||
| @@ -472,6 +491,116 @@ public sealed class TemplateCanvasControl : Control | |||||
| private CanvasViewTransform CurrentTransform() => | private CanvasViewTransform CurrentTransform() => | ||||
| CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height); | CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height); | ||||
| /// <summary>Folder relative background paths resolve against (the template folder).</summary> | |||||
| [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] | |||||
| public string? BackgroundBaseDirectory { get; set; } | |||||
| private string? _backgroundImagePath; | |||||
| private DateTime _backgroundImageStamp; | |||||
| private Image? _backgroundImage; | |||||
| /// <summary>Paints the template page background under everything else. Images are drawn | |||||
| /// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page | |||||
| /// shows a labelled placeholder - the real PDF page appears in the rendered output.</summary> | |||||
| private void DrawPageBackground(Graphics g, RectangleF pageRect) | |||||
| { | |||||
| var background = _document.Background; | |||||
| if (background is null) | |||||
| { | |||||
| return; | |||||
| } | |||||
| var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory); | |||||
| if (file is null) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (PageBackgroundLayout.IsPdf(file)) | |||||
| { | |||||
| var page = background.ResolvePage(_csvSampleRecord); | |||||
| var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null; | |||||
| if (pdfBitmap is not null) | |||||
| { | |||||
| g.DrawImage(pdfBitmap, pageRect); | |||||
| return; | |||||
| } | |||||
| using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250)); | |||||
| g.FillRectangle(tint, pageRect); | |||||
| using var font = new Font("Segoe UI", 9f); | |||||
| g.DrawString( | |||||
| $"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})", | |||||
| font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6); | |||||
| return; | |||||
| } | |||||
| var image = LoadBackgroundImage(file); | |||||
| if (image is not null) | |||||
| { | |||||
| g.DrawImage(image, pageRect); | |||||
| } | |||||
| } | |||||
| private string? _pdfBackgroundKey; | |||||
| private int _pdfBackgroundWidth; | |||||
| private Bitmap? _pdfBackgroundBitmap; | |||||
| private string? _pdfBackgroundError; | |||||
| /// <summary>The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file | |||||
| /// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom), | |||||
| /// so a repaint during drag or selection never re-renders the PDF.</summary> | |||||
| private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error) | |||||
| { | |||||
| var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0; | |||||
| var key = $"{path}|{page}|{stamp}"; | |||||
| var wanted = Math.Max(pageWidthPixels, 50); | |||||
| var sameKey = _pdfBackgroundKey == key; | |||||
| var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75; | |||||
| if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth)) | |||||
| { | |||||
| error = _pdfBackgroundError; | |||||
| return _pdfBackgroundBitmap; | |||||
| } | |||||
| var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError); | |||||
| _pdfBackgroundBitmap?.Dispose(); | |||||
| _pdfBackgroundBitmap = rendered; | |||||
| _pdfBackgroundKey = key; | |||||
| _pdfBackgroundWidth = rendered is null ? 0 : wanted; | |||||
| _pdfBackgroundError = renderError; | |||||
| error = renderError; | |||||
| return rendered; | |||||
| } | |||||
| private Image? LoadBackgroundImage(string path) | |||||
| { | |||||
| try | |||||
| { | |||||
| var stamp = File.GetLastWriteTimeUtc(path); | |||||
| if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp) | |||||
| { | |||||
| return _backgroundImage; | |||||
| } | |||||
| _backgroundImage?.Dispose(); | |||||
| _backgroundImage = null; | |||||
| _backgroundImagePath = path; | |||||
| _backgroundImageStamp = stamp; | |||||
| // Copy through a MemoryStream so the file is not left locked by GDI+. | |||||
| using var stream = new MemoryStream(File.ReadAllBytes(path)); | |||||
| using var loaded = Image.FromStream(stream); | |||||
| _backgroundImage = new Bitmap(loaded); | |||||
| } | |||||
| catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException) | |||||
| { | |||||
| _backgroundImage = null; | |||||
| } | |||||
| return _backgroundImage; | |||||
| } | |||||
| protected override void OnPaint(PaintEventArgs e) | protected override void OnPaint(PaintEventArgs e) | ||||
| { | { | ||||
| base.OnPaint(e); | base.OnPaint(e); | ||||
| @@ -485,6 +614,7 @@ public sealed class TemplateCanvasControl : Control | |||||
| var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom); | var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom); | ||||
| g.FillRectangle(Brushes.White, pageRect); | g.FillRectangle(Brushes.White, pageRect); | ||||
| DrawPageBackground(g, pageRect); | |||||
| g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height); | g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height); | ||||
| // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so | // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so | ||||
| @@ -535,6 +665,7 @@ public sealed class TemplateCanvasControl : Control | |||||
| if (_editor.Selected is not null) | if (_editor.Selected is not null) | ||||
| { | { | ||||
| DrawResizeHandle(g, transform, _editor.Selected); | DrawResizeHandle(g, transform, _editor.Selected); | ||||
| DrawHeightHandle(g, transform); | |||||
| DrawRotateHandle(g, transform, _editor.Selected); | DrawRotateHandle(g, transform, _editor.Selected); | ||||
| } | } | ||||
| @@ -729,8 +860,8 @@ public sealed class TemplateCanvasControl : Control | |||||
| /// for standalone elements.</summary> | /// for standalone elements.</summary> | ||||
| private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control) | private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control) | ||||
| { | { | ||||
| var top = control.Y + MaxLineFontSize(control); | |||||
| var bottom = control.Y - control.Height; | |||||
| var top = control.TopBaselineY + MaxLineFontSize(control); | |||||
| var bottom = control.TopBaselineY - control.Height; | |||||
| var left = control.X; | var left = control.X; | ||||
| var right = control.X + control.Width; | var right = control.X + control.Width; | ||||
| @@ -889,6 +1020,23 @@ public sealed class TemplateCanvasControl : Control | |||||
| var boxHeight = height * transform.Scale; | var boxHeight = height * transform.Scale; | ||||
| using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B)); | using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B)); | ||||
| if (element.HasFill && element.FillColor is { } rectFill) | |||||
| { | |||||
| // Filled rectangle: painted first so the element's own text (and any element with | |||||
| // a higher z-order) draws over it, like the real render. | |||||
| using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B)); | |||||
| g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight); | |||||
| // Design-view outline so a white (or page-coloured) rectangle is still visible and | |||||
| // clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light | |||||
| // dashed line together stay visible over any fill or background. | |||||
| var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight); | |||||
| using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f); | |||||
| using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash }; | |||||
| g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height); | |||||
| g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height); | |||||
| } | |||||
| if (element.HasBox) | if (element.HasBox) | ||||
| { | { | ||||
| // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own | // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own | ||||
| @@ -990,6 +1138,22 @@ public sealed class TemplateCanvasControl : Control | |||||
| /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white- | /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white- | ||||
| /// outline style already used for the Address Control's resize handle, deliberately distinct | /// outline style already used for the Address Control's resize handle, deliberately distinct | ||||
| /// from the rotate handle's round dot so the two are never confused at a glance.</summary> | /// from the rotate handle's round dot so the two are never confused at a glance.</summary> | ||||
| private void DrawHeightHandle(Graphics g, CanvasViewTransform transform) | |||||
| { | |||||
| var handle = _editor.HeightHandlePosition(); | |||||
| if (handle is null) | |||||
| { | |||||
| return; | |||||
| } | |||||
| var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y); | |||||
| var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8); | |||||
| using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen); | |||||
| using var outline = new Pen(System.Drawing.Color.White, 1); | |||||
| g.FillRectangle(brush, rect); | |||||
| g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height); | |||||
| } | |||||
| private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element) | private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element) | ||||
| { | { | ||||
| var handle = _editor.ResizeHandlePosition(); | var handle = _editor.ResizeHandlePosition(); | ||||
| @@ -1090,8 +1254,8 @@ public sealed class TemplateCanvasControl : Control | |||||
| using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash }; | using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash }; | ||||
| using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1); | using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1); | ||||
| var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control)); | |||||
| var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height); | |||||
| var (left, top) = transform.ToPixels(control.X, control.TopBaselineY + MaxLineFontSize(control)); | |||||
| var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height); | |||||
| var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom); | var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom); | ||||
| g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height); | g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height); | ||||
| if (isPrimarySelected) | if (isPrimarySelected) | ||||
| @@ -1113,7 +1277,7 @@ public sealed class TemplateCanvasControl : Control | |||||
| var line = control.Lines[i]; | var line = control.Lines[i]; | ||||
| using var font = ResolveFont(line.FontFamily, (float)line.FontSize); | using var font = ResolveFont(line.FontFamily, (float)line.FontSize); | ||||
| var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText; | |||||
| var text = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText); | |||||
| var size = MeasureText(text, font); | var size = MeasureText(text, font); | ||||
| using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale); | using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale); | ||||
| var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X; | var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X; | ||||
| @@ -1163,8 +1327,8 @@ public sealed class TemplateCanvasControl : Control | |||||
| private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control) | private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control) | ||||
| { | { | ||||
| var top = control.Y + MaxLineFontSize(control); | |||||
| var bottom = control.Y - control.Height; | |||||
| var top = control.TopBaselineY + MaxLineFontSize(control); | |||||
| var bottom = control.TopBaselineY - control.Height; | |||||
| return (control.X + control.Width, bottom + ((top - bottom) / 2.0)); | return (control.X + control.Width, bottom + ((top - bottom) / 2.0)); | ||||
| } | } | ||||
| @@ -1206,7 +1370,17 @@ public sealed class TemplateCanvasControl : Control | |||||
| lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse); | lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse); | ||||
| } | } | ||||
| return AddressLineCollapser.Resolve(lines).ToArray(); | |||||
| var resolved = AddressLineCollapser.Resolve(lines).ToArray(); | |||||
| if (control.VerticalAnchor == AddressVerticalAnchor.Bottom) | |||||
| { | |||||
| var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y); | |||||
| for (var i = 0; i < resolved.Length; i++) | |||||
| { | |||||
| resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift }; | |||||
| } | |||||
| } | |||||
| return resolved; | |||||
| } | } | ||||
| /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by | /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by | ||||
| @@ -1469,6 +1643,14 @@ public sealed class TemplateCanvasControl : Control | |||||
| // resize handle starts a resize-drag, checked with the same precedence as the rotate | // resize handle starts a resize-drag, checked with the same precedence as the rotate | ||||
| // handle immediately below (only meaningful, and only checked, once something is already | // handle immediately below (only meaningful, and only checked, once something is already | ||||
| // selected) so it is never confused with a normal select/move click. | // selected) so it is never confused with a normal select/move click. | ||||
| if (_editor.HitTestHeightHandle(x, y)) | |||||
| { | |||||
| _editor.BeginHeightResizeDrag(); | |||||
| Capture = true; | |||||
| Invalidate(); | |||||
| return; | |||||
| } | |||||
| if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y)) | if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y)) | ||||
| { | { | ||||
| _editor.BeginResizeDrag(); | _editor.BeginResizeDrag(); | ||||
| @@ -1563,8 +1745,8 @@ public sealed class TemplateCanvasControl : Control | |||||
| ? (xPoints, yPoints) | ? (xPoints, yPoints) | ||||
| : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle); | : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle); | ||||
| var top = control.Y + MaxLineFontSize(control); | |||||
| var bottom = control.Y - control.Height; | |||||
| var top = control.TopBaselineY + MaxLineFontSize(control); | |||||
| var bottom = control.TopBaselineY - control.Height; | |||||
| if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top) | if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top) | ||||
| { | { | ||||
| continue; | continue; | ||||
| @@ -1681,7 +1863,7 @@ public sealed class TemplateCanvasControl : Control | |||||
| return; | return; | ||||
| } | } | ||||
| if (_editor.IsResizingWidth) | |||||
| if (_editor.IsResizing) | |||||
| { | { | ||||
| _editor.ResizeDragTo(x, y); | _editor.ResizeDragTo(x, y); | ||||
| } | } | ||||
| @@ -1740,8 +1922,55 @@ public sealed class TemplateCanvasControl : Control | |||||
| RemoveSelectedElement(); | RemoveSelectedElement(); | ||||
| e.Handled = true; | e.Handled = true; | ||||
| } | } | ||||
| else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt) | |||||
| { | |||||
| // Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden | |||||
| // behind other items. Ctrl+Tab still leaves the canvas as usual. | |||||
| SelectNextItem(forward: !e.Shift); | |||||
| e.Handled = true; | |||||
| e.SuppressKeyPress = true; | |||||
| } | |||||
| } | |||||
| /// <summary>Selects the next (or previous) item in z-order, so an item hidden behind another | |||||
| /// one can be reached without clicking it. Wraps at both ends. Returns <c>false</c> when there | |||||
| /// is nothing to select or the canvas is showing data (selection is disabled then).</summary> | |||||
| public bool SelectNextItem(bool forward) | |||||
| { | |||||
| if (_showData || IsInteracting) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| var items = _document.Elements | |||||
| .Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y)) | |||||
| .Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y))) | |||||
| .ToList(); | |||||
| Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id; | |||||
| if (SelectionCycler.Next(items, current, forward) is not { } nextId) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| ClearMultiSelection(); | |||||
| ClearAddressSelection(); | |||||
| var element = _document.Elements.FirstOrDefault(e => e.Id == nextId); | |||||
| if (element is not null) | |||||
| { | |||||
| _editor.Select(element); | |||||
| } | |||||
| else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control) | |||||
| { | |||||
| SelectAddressControl(control, 0); | |||||
| } | |||||
| Invalidate(); | |||||
| SelectionChanged?.Invoke(this, EventArgs.Empty); | |||||
| return true; | |||||
| } | } | ||||
| protected override bool IsInputKey(Keys keyData) => | protected override bool IsInputKey(Keys keyData) => | ||||
| keyData is Keys.Delete or Keys.Back || base.IsInputKey(keyData); | |||||
| keyData is Keys.Delete or Keys.Back | |||||
| || ((keyData & ~Keys.Shift) == Keys.Tab) | |||||
| || base.IsInputKey(keyData); | |||||
| } | } | ||||
| @@ -1,3 +1,4 @@ | |||||
| using EnvelopeRenderer.Desktop.Core.Launch; | |||||
| using EnvelopeRenderer.Desktop.Core.Csv; | using EnvelopeRenderer.Desktop.Core.Csv; | ||||
| using EnvelopeRenderer.Desktop.Core.Design; | using EnvelopeRenderer.Desktop.Core.Design; | ||||
| @@ -141,6 +142,13 @@ public sealed class TemplateDesignerForm : Form | |||||
| private readonly NumericUpDown _addressWidthInput = new() { DecimalPlaces = 2, Minimum = 1, Maximum = 100000m, Width = 80 }; | private readonly NumericUpDown _addressWidthInput = new() { DecimalPlaces = 2, Minimum = 1, Maximum = 100000m, Width = 80 }; | ||||
| // Gap between Address Control lines as a multiple of the previous line's font size | // Gap between Address Control lines as a multiple of the previous line's font size | ||||
| // (AddressControlLayout.LineSpacingMultiplier, default 1.25); below 1.0 packs lines tighter. | // (AddressControlLayout.LineSpacingMultiplier, default 1.25); below 1.0 packs lines tighter. | ||||
| private readonly ComboBox _addressCaseInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 }; | |||||
| private readonly ComboBox _addressAnchorInput = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 }; | |||||
| private readonly Button _renderPdfButton = new() { Text = "Render &PDF...", AutoSize = true }; | |||||
| private readonly Button _addRectangleButton =new() { Text = "Add Rec&tangle", AutoSize = true }; | |||||
| private readonly Button _fillButton = new() { Text = string.Empty, Width = 60, Height = 24, FlatStyle = FlatStyle.Popup }; | |||||
| private readonly Button _clearFillButton = new() { Text = "None", Width = 60, Height = 24 }; | |||||
| private readonly Button _backgroundButton =new() { Text = "&Background...", AutoSize = true }; | |||||
| private readonly NumericUpDown _addressLineSpacingInput = new() { DecimalPlaces = 2, Increment = 0.05m, Minimum = 0.5m, Maximum = 5m, Width = 80 }; | private readonly NumericUpDown _addressLineSpacingInput = new() { DecimalPlaces = 2, Increment = 0.05m, Minimum = 0.5m, Maximum = 5m, Width = 80 }; | ||||
| private readonly ListBox _addressLineList = new() { Width = 210, Height = 72, IntegralHeight = false }; | private readonly ListBox _addressLineList = new() { Width = 210, Height = 72, IntegralHeight = false }; | ||||
| private readonly Button _addAddressLineButton = new() { Text = "+", Width = 34, Height = 24 }; | private readonly Button _addAddressLineButton = new() { Text = "+", Width = 34, Height = 24 }; | ||||
| @@ -378,6 +386,7 @@ public sealed class TemplateDesignerForm : Form | |||||
| try | try | ||||
| { | { | ||||
| TemplateLayoutXmlSerializer.Save(_document, dialog.FileName); | TemplateLayoutXmlSerializer.Save(_document, dialog.FileName); | ||||
| _canvas.BackgroundBaseDirectory = Path.GetDirectoryName(dialog.FileName); | |||||
| SetFileStatus($"Saved to '{dialog.FileName}'.", isError: false); | SetFileStatus($"Saved to '{dialog.FileName}'.", isError: false); | ||||
| } | } | ||||
| catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) | catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) | ||||
| @@ -415,6 +424,8 @@ public sealed class TemplateDesignerForm : Form | |||||
| _document.Elements.AddRange(loaded.Elements); | _document.Elements.AddRange(loaded.Elements); | ||||
| _document.AddressControls.Clear(); | _document.AddressControls.Clear(); | ||||
| _document.AddressControls.AddRange(loaded.AddressControls); | _document.AddressControls.AddRange(loaded.AddressControls); | ||||
| _document.Background = loaded.Background; | |||||
| _canvas.BackgroundBaseDirectory = Path.GetDirectoryName(dialog.FileName); | |||||
| _dimensionsEditor.Reset(_document.Canvas); | _dimensionsEditor.Reset(_document.Canvas); | ||||
| RefreshDimensionControls(); | RefreshDimensionControls(); | ||||
| @@ -426,6 +437,108 @@ public sealed class TemplateDesignerForm : Form | |||||
| isError: false); | isError: false); | ||||
| } | } | ||||
| /// <summary>Renders the template being designed, right now, against the loaded CSV: saves the | |||||
| /// current layout to a temporary template file, asks where to save the PDF, then runs the render | |||||
| /// CLI in a progress window. Unsaved edits are included; nothing is written next to the | |||||
| /// operator's own template file.</summary> | |||||
| private void RenderFromDesigner() | |||||
| { | |||||
| if (_currentCsvPath is null || !File.Exists(_currentCsvPath)) | |||||
| { | |||||
| MessageBox.Show( | |||||
| this, "Load a CSV first (Load CSV...) so there is data to render.", | |||||
| "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Information); | |||||
| return; | |||||
| } | |||||
| if (_document.Elements.Count == 0 && _document.AddressControls.Count == 0) | |||||
| { | |||||
| MessageBox.Show( | |||||
| this, "The template has nothing to render yet. Add some text, an Address Control or a rectangle.", | |||||
| "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Information); | |||||
| return; | |||||
| } | |||||
| using var saveDialog = new SaveFileDialog | |||||
| { | |||||
| Filter = "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*", | |||||
| Title = "Save Rendered PDF As", | |||||
| DefaultExt = "pdf", | |||||
| OverwritePrompt = true, | |||||
| }; | |||||
| if (saveDialog.ShowDialog(this) != DialogResult.OK) | |||||
| { | |||||
| return; | |||||
| } | |||||
| var tempTemplate = Path.Combine(Path.GetTempPath(), $"EnvelopeRenderer-design-{Guid.NewGuid():N}.xml"); | |||||
| try | |||||
| { | |||||
| TemplateLayoutXmlSerializer.Save(BuildDocumentForRender(), tempTemplate); | |||||
| using var dialog = new RenderRunDialog(new RenderLaunchInputs(tempTemplate, _currentCsvPath, saveDialog.FileName)); | |||||
| dialog.ShowDialog(this); | |||||
| } | |||||
| catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) | |||||
| { | |||||
| MessageBox.Show(this, $"Could not prepare the template for rendering: {ex.Message}", "Render PDF", MessageBoxButtons.OK, MessageBoxIcon.Error); | |||||
| } | |||||
| finally | |||||
| { | |||||
| try | |||||
| { | |||||
| File.Delete(tempTemplate); | |||||
| } | |||||
| catch (IOException) | |||||
| { | |||||
| // A leftover temp file is not worth failing over. | |||||
| } | |||||
| } | |||||
| } | |||||
| /// <summary>A copy of the document for the temporary template: the same elements, but a relative | |||||
| /// background file path is made absolute against the template folder, since the temporary file | |||||
| /// lives in the temp folder and would otherwise not find it.</summary> | |||||
| private TemplateLayoutDocument BuildDocumentForRender() | |||||
| { | |||||
| var copy = new TemplateLayoutDocument(_document.Canvas); | |||||
| copy.Elements.AddRange(_document.Elements); | |||||
| copy.AddressControls.AddRange(_document.AddressControls); | |||||
| if (_document.Background is { } background) | |||||
| { | |||||
| var file = background.File; | |||||
| if (!string.IsNullOrEmpty(file) && !Path.IsPathRooted(file) && !string.IsNullOrEmpty(_canvas.BackgroundBaseDirectory)) | |||||
| { | |||||
| file = Path.Combine(_canvas.BackgroundBaseDirectory, file); | |||||
| } | |||||
| copy.Background = new PageBackgroundLayout | |||||
| { | |||||
| File = file, | |||||
| FileColumn = background.FileColumn, | |||||
| Page = background.Page, | |||||
| PageColumn = background.PageColumn, | |||||
| }; | |||||
| } | |||||
| return copy; | |||||
| } | |||||
| /// <summary>Opens the page-background dialog and applies the result to the template.</summary> | |||||
| private void EditBackground() | |||||
| { | |||||
| using var dialog = new PageBackgroundDialog(_document.Background, _loadedCsvHeaders); | |||||
| if (dialog.ShowDialog(this) != DialogResult.OK) | |||||
| { | |||||
| return; | |||||
| } | |||||
| _document.Background = dialog.Result; | |||||
| _canvas.Invalidate(); | |||||
| SetFileStatus( | |||||
| _document.Background is null ? "Page background cleared." : "Page background set.", | |||||
| isError: false); | |||||
| } | |||||
| private void SetFileStatus(string message, bool isError) | private void SetFileStatus(string message, bool isError) | ||||
| { | { | ||||
| _fileStatusLabel.Text = message; | _fileStatusLabel.Text = message; | ||||
| @@ -714,6 +827,60 @@ public sealed class TemplateDesignerForm : Form | |||||
| _collapseIfBlankInput.Location = new Point(leftMargin, (15 * rowHeight) + labelTop); | _collapseIfBlankInput.Location = new Point(leftMargin, (15 * rowHeight) + labelTop); | ||||
| _propertiesPanel.Controls.Add(_collapseIfBlankInput); | _propertiesPanel.Controls.Add(_collapseIfBlankInput); | ||||
| // Address Control only: letter case of every line, and which edge the Y anchor pins to. | |||||
| _addressCaseInput.Items.AddRange(new object[] { "As typed", "UPPERCASE", "lowercase" }); | |||||
| _addressAnchorInput.Items.AddRange(new object[] { "Top", "Bottom" }); | |||||
| _addressCaseInput.SelectedIndex = 0; | |||||
| _addressAnchorInput.SelectedIndex = 0; | |||||
| var caseLabel = new Label { Text = "Address text case:", AutoSize = true, Location = new Point(leftMargin, (16 * rowHeight) + labelTop) }; | |||||
| var anchorLabel = new Label { Text = "Y anchors to:", AutoSize = true, Location = new Point(leftMargin + 108, (16 * rowHeight) + labelTop) }; | |||||
| _addressCaseInput.Location = new Point(leftMargin, (16 * rowHeight) + inputTop); | |||||
| _addressAnchorInput.Location = new Point(leftMargin + 108, (16 * rowHeight) + inputTop); | |||||
| _propertiesPanel.Controls.Add(caseLabel); | |||||
| _propertiesPanel.Controls.Add(anchorLabel); | |||||
| _propertiesPanel.Controls.Add(_addressCaseInput); | |||||
| _propertiesPanel.Controls.Add(_addressAnchorInput); | |||||
| // Rectangle fill: a filled box behind the element's own text (needs a width and height). | |||||
| var fillLabel = new Label { Text = "Fill (rectangle):", AutoSize = true, Location = new Point(leftMargin, (17 * rowHeight) + labelTop) }; | |||||
| _fillButton.Location = new Point(leftMargin, (17 * rowHeight) + inputTop); | |||||
| _clearFillButton.Location = new Point(leftMargin + 68, (17 * rowHeight) + inputTop); | |||||
| _propertiesPanel.Controls.Add(fillLabel); | |||||
| _propertiesPanel.Controls.Add(_fillButton); | |||||
| _propertiesPanel.Controls.Add(_clearFillButton); | |||||
| _fillButton.Click += (_, _) => OnFillButtonClick(); | |||||
| _clearFillButton.Click += (_, _) => | |||||
| { | |||||
| if (_canvas.SelectedElement is { } selectedElement) | |||||
| { | |||||
| selectedElement.FillColor = null; | |||||
| _canvas.NotifyElementChanged(); | |||||
| RefreshPropertiesPanel(); | |||||
| } | |||||
| }; | |||||
| _propertiesPanel.AutoScroll = true; | |||||
| _addressCaseInput.SelectedIndexChanged += (_, _) => | |||||
| { | |||||
| if (!_suppressEvents && _canvas.SelectedAddressControl is { } control) | |||||
| { | |||||
| control.TextCase = (AddressTextCase)_addressCaseInput.SelectedIndex; | |||||
| _canvas.NotifyElementChanged(); | |||||
| } | |||||
| }; | |||||
| _addressAnchorInput.SelectedIndexChanged += (_, _) => | |||||
| { | |||||
| if (!_suppressEvents && _canvas.SelectedAddressControl is { } control) | |||||
| { | |||||
| // Keep the block where it is on the page: re-express Y for the new anchor edge. | |||||
| var topBaseline = control.TopBaselineY; | |||||
| control.VerticalAnchor = (AddressVerticalAnchor)_addressAnchorInput.SelectedIndex; | |||||
| control.Y = control.VerticalAnchor == AddressVerticalAnchor.Bottom | |||||
| ? topBaseline - control.LastLineOffset | |||||
| : topBaseline; | |||||
| _canvas.NotifyElementChanged(); | |||||
| RefreshPropertiesPanel(); | |||||
| } | |||||
| }; | |||||
| // Commits on Leave (not every keystroke, unlike the simpler FontFamily text box) — parsing | // Commits on Leave (not every keystroke, unlike the simpler FontFamily text box) — parsing | ||||
| // a full run sequence and re-syncing the rebind combo/selection label on every keystroke | // a full run sequence and re-syncing the rebind combo/selection label on every keystroke | ||||
| // while an operator is still mid-edit (e.g. typing an unfinished "{Full N") would be both | // while an operator is still mid-edit (e.g. typing an unfinished "{Full N") would be both | ||||
| @@ -1027,6 +1194,38 @@ public sealed class TemplateDesignerForm : Form | |||||
| } | } | ||||
| } | } | ||||
| private void OnFillButtonClick() | |||||
| { | |||||
| if (_canvas.SelectedElement is not { } selectedElement) | |||||
| { | |||||
| return; | |||||
| } | |||||
| var current = selectedElement.FillColor ?? new RgbColor(255, 255, 255); | |||||
| using var dialog = new ColorDialog { Color = Color.FromArgb(current.R, current.G, current.B) }; | |||||
| if (dialog.ShowDialog(this) != DialogResult.OK) | |||||
| { | |||||
| return; | |||||
| } | |||||
| selectedElement.FillColor = new RgbColor(dialog.Color.R, dialog.Color.G, dialog.Color.B); | |||||
| // The render needs both dimensions to draw the rectangle, so give the element a default box | |||||
| // when it has none yet (existing dimensions are kept). | |||||
| if (selectedElement.Width is not > 0) | |||||
| { | |||||
| selectedElement.Width = TemplateCanvasControl.DefaultRectangleWidth; | |||||
| } | |||||
| if (selectedElement.Height is not > 0) | |||||
| { | |||||
| selectedElement.Height = TemplateCanvasControl.DefaultRectangleHeight; | |||||
| } | |||||
| _canvas.NotifyElementChanged(); | |||||
| RefreshPropertiesPanel(); | |||||
| } | |||||
| private void OnCanvasSelectionChanged() | private void OnCanvasSelectionChanged() | ||||
| { | { | ||||
| _propertiesEditor.Select(_canvas.SelectedElement); | _propertiesEditor.Select(_canvas.SelectedElement); | ||||
| @@ -1109,6 +1308,14 @@ public sealed class TemplateDesignerForm : Form | |||||
| _addressLineSpacingInput.Value = ClampToNumericRange( | _addressLineSpacingInput.Value = ClampToNumericRange( | ||||
| _addressLineSpacingInput, (decimal)(selectedAddressControl?.LineSpacingMultiplier ?? AddressControlLayout.DefaultLineSpacingMultiplier)); | _addressLineSpacingInput, (decimal)(selectedAddressControl?.LineSpacingMultiplier ?? AddressControlLayout.DefaultLineSpacingMultiplier)); | ||||
| _addressLineSpacingInput.Enabled = selectedAddressControl is not null; | _addressLineSpacingInput.Enabled = selectedAddressControl is not null; | ||||
| _addressCaseInput.SelectedIndex = (int)(selectedAddressControl?.TextCase ?? AddressTextCase.None); | |||||
| _addressCaseInput.Enabled = selectedAddressControl is not null; | |||||
| _addressAnchorInput.SelectedIndex = (int)(selectedAddressControl?.VerticalAnchor ?? AddressVerticalAnchor.Top); | |||||
| _addressAnchorInput.Enabled = selectedAddressControl is not null; | |||||
| var fill = selected?.FillColor; | |||||
| _fillButton.BackColor = fill is { } f ? Color.FromArgb(f.R, f.G, f.B) : SystemColors.Control; | |||||
| _fillButton.Enabled = selected is not null; | |||||
| _clearFillButton.Enabled = fill is not null; | |||||
| RefreshAddressLineList(selectedAddressControl); | RefreshAddressLineList(selectedAddressControl); | ||||
| _fontFamilyInput.Text = selected?.FontFamily ?? selectedAddressLine?.FontFamily ?? string.Empty; | _fontFamilyInput.Text = selected?.FontFamily ?? selectedAddressLine?.FontFamily ?? string.Empty; | ||||
| @@ -1238,6 +1445,12 @@ public sealed class TemplateDesignerForm : Form | |||||
| _dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3); | _dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3); | ||||
| panel.Controls.Add(_dynamicFieldColumnComboBox); | panel.Controls.Add(_dynamicFieldColumnComboBox); | ||||
| panel.Controls.Add(_addAddressControlButton); | panel.Controls.Add(_addAddressControlButton); | ||||
| _addRectangleButton.Click += (_, _) => _canvas.AddRectangleElement(); | |||||
| panel.Controls.Add(_addRectangleButton); | |||||
| _renderPdfButton.Click += (_, _) => RenderFromDesigner(); | |||||
| panel.Controls.Add(_renderPdfButton); | |||||
| _backgroundButton.Click += (_, _) => EditBackground(); | |||||
| panel.Controls.Add(_backgroundButton); | |||||
| _deleteSelectedButton.Click += (_, _) => | _deleteSelectedButton.Click += (_, _) => | ||||
| { | { | ||||
| if (_canvas.RemoveSelectedElement()) | if (_canvas.RemoveSelectedElement()) | ||||
Powered by TurnKey Linux.