From 9d4d024d600bfe52aaffcd509c0cf14ed48ecab7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Nov 2026 10:02:42 -0500 Subject: [PATCH] feat(reporting): view nest reports in a window to save or print Export Nest Report only wrote a file, so checking or printing a report meant finding it and opening another program. View Nest Report renders the same snapshot to a private temp folder and opens it in a WebView2 viewer with Save As and Print; closing the window deletes the temp copy. Export and View share the capture and write guards. Co-Authored-By: Claude Opus 5.5 --- .../Forms/NestReportExportTests.cs | 57 ++++++++ OpenNest/Forms/MainForm.Designer.cs | 12 +- OpenNest/Forms/MainForm.File.cs | 82 ++++++++--- OpenNest/Forms/MainForm.Status.cs | 1 + OpenNest/Forms/NestReportViewerForm.cs | 138 ++++++++++++++++++ docs/nest-reports.md | 11 +- 6 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 OpenNest/Forms/NestReportViewerForm.cs diff --git a/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs b/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs index 78458ba1..942d61e6 100644 --- a/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs +++ b/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs @@ -48,16 +48,20 @@ public class NestReportExportTests using var host = new TestMainForm(); host.Show(); var item = Menu(host, "mnuFileExportNestReport"); + var view = Menu(host, "mnuFileViewNestReport"); Assert.False(item.Enabled); + Assert.False(view.Enabled); using var form = new EditNestForm(new Nest("enabled job")) { MdiParent = host }; form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. form.Show(); Assert.True(item.Enabled); + Assert.True(view.Enabled); form.Close(); host.Activate(); Assert.False(item.Enabled); + Assert.False(view.Enabled); }); [Fact] @@ -232,6 +236,55 @@ public class NestReportExportTests } }); + [Fact] + public void ViewRendersATemporaryPdfForTheViewer() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + var nest = CreateJob("view job"); + using var form = new EditNestForm(nest) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + + Menu(host, "mnuFileViewNestReport").PerformClick(); + + Assert.Empty(host.DialogRequests); + Assert.Empty(host.Failures); + var path = Assert.Single(host.Viewed); + try + { + Assert.Equal("view job.report.pdf", Path.GetFileName(path)); + var bytes = File.ReadAllBytes(path); + Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(bytes, 0, 5)); + Assert.Equal(2, nest.Drawings.Count); + } + finally + { + NestReportViewerForm.DeleteReportFolder(path); + } + Assert.False(Directory.Exists(Path.GetDirectoryName(path))); + }); + + [Fact] + public void ViewRenderFailureOpensNoViewerAndLeavesNoTempFolder() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + var nest = CreateJob("view failure"); + nest.Notes = "em—dash"; // Fails font validation before layout. + using var form = new EditNestForm(nest) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + var reports = Path.Combine(Path.GetTempPath(), "OpenNest", "Reports"); + var before = Directory.Exists(reports) ? Directory.GetDirectories(reports).Length : 0; + + Menu(host, "mnuFileViewNestReport").PerformClick(); + + Assert.Empty(host.Viewed); + Assert.Contains("view failure", Assert.Single(host.Failures)); + Assert.Equal(before, Directory.Exists(reports) ? Directory.GetDirectories(reports).Length : 0); + }); + [Fact] public void BusyAfterDialogRejectsWriteWithoutTouchingDestination() => RunSta(() => { @@ -295,6 +348,7 @@ public class NestReportExportTests public DialogResult NextDialogResult { get; set; } = DialogResult.OK; public List Failures { get; } = []; public List Successes { get; } = []; + public List Viewed { get; } = []; // No startup migrations/automatic new document, and no settings save at close. protected override void OnLoad(EventArgs e) { } @@ -310,6 +364,9 @@ public class NestReportExportTests Failures.Add($"{targets?.Nest.Name}|{error.Message}"); internal override void ReportSuccess(string destination) => Successes.Add(destination); + + internal override void ShowReportViewer(string pdfPath, EditNestForm.NestReportTargets targets) => + Viewed.Add(pdfPath); } private static void RunSta(System.Action action) => diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index a9d35cfc..0736a389 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -50,6 +50,7 @@ namespace OpenNest.Forms toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileViewNestReport = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExportNestReport = new System.Windows.Forms.ToolStripMenuItem(); toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); @@ -217,7 +218,7 @@ namespace OpenNest.Forms // // mnuFile // - mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingLibrary, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit }); + mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingLibrary, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileViewNestReport, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit }); mnuFile.Name = "mnuFile"; mnuFile.DropDownOpening += FileMenu_Opening; mnuFile.Size = new System.Drawing.Size(37, 20); @@ -312,6 +313,14 @@ namespace OpenNest.Forms mnuFileExportAll.Text = "Export All"; mnuFileExportAll.Click += ExportAll_Click; // + // mnuFileViewNestReport + // + mnuFileViewNestReport.Enabled = false; + mnuFileViewNestReport.Name = "mnuFileViewNestReport"; + mnuFileViewNestReport.Size = new System.Drawing.Size(146, 22); + mnuFileViewNestReport.Text = "View Nest Report..."; + mnuFileViewNestReport.Click += ViewNestReport_Click; + // // mnuFileExportNestReport // mnuFileExportNestReport.Enabled = false; @@ -1500,6 +1509,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem mnuCloseAll; private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel; private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll; + private System.Windows.Forms.ToolStripMenuItem mnuFileViewNestReport; private System.Windows.Forms.ToolStripMenuItem mnuFileExportNestReport; private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem; diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs index 5e6a7076..8b05d9d3 100644 --- a/OpenNest/Forms/MainForm.File.cs +++ b/OpenNest/Forms/MainForm.File.cs @@ -315,30 +315,69 @@ namespace OpenNest.Forms private void ExportNestReport_Click(object sender, EventArgs e) { var form = activeForm; - if (form == null) + if (form == null || !TryCaptureReportTargets(form, out var targets)) return; - // The report never depends on a selected post or NC output; it only needs a - // quiescent nest so the snapshot is coherent. - Func isJobBusy = IsNestJobBusy; - EditNestForm.NestReportTargets targets; - try - { - targets = form.CaptureReportTargets(isJobBusy); - } - catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) - { - ReportFailure(null, ex); - return; - } - var destination = ShowReportSaveDialog(targets.SuggestedFileName); if (destination == null) return; // Cancel: nothing written, job untouched. + if (!TryWriteNestReport(form, targets, destination)) + return; + statusLabel1.Text = $"Saved nest report to {destination}"; + ReportSuccess(destination); + } + + /// Renders the report to a private temporary file and opens it in a viewer to save or print. + private void ViewNestReport_Click(object sender, EventArgs e) + { + var form = activeForm; + if (form == null || !TryCaptureReportTargets(form, out var targets)) + return; + + var path = Path.Combine(Path.GetTempPath(), "OpenNest", "Reports", + Guid.NewGuid().ToString("N"), targets.SuggestedFileName); try { - form.WriteNestReport(targets, destination, isJobBusy); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + ReportFailure(targets, ex); + return; + } + + if (!TryWriteNestReport(form, targets, path)) + { + NestReportViewerForm.DeleteReportFolder(path); + return; + } + ShowReportViewer(path, targets); + } + + // The report never depends on a selected post or NC output; it only needs a + // quiescent nest so the snapshot is coherent. + private bool TryCaptureReportTargets(EditNestForm form, out EditNestForm.NestReportTargets targets) + { + try + { + targets = form.CaptureReportTargets(IsNestJobBusy); + return true; + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + ReportFailure(null, ex); + targets = null; + return false; + } + } + + private bool TryWriteNestReport(EditNestForm form, EditNestForm.NestReportTargets targets, string destination) + { + try + { + form.WriteNestReport(targets, destination, IsNestJobBusy); + return true; } catch (Exception ex) when (ex is InvalidOperationException @@ -350,10 +389,15 @@ namespace OpenNest.Forms { // Failures describe the captured target; an active-document switch is irrelevant. ReportFailure(targets, ex); - return; + return false; } - statusLabel1.Text = $"Saved nest report to {destination}"; - ReportSuccess(destination); + } + + /// Opens the rendered report; internal so tests observe it without a WebView2 window. + internal virtual void ShowReportViewer(string pdfPath, EditNestForm.NestReportTargets targets) + { + var viewer = new NestReportViewerForm(pdfPath, targets.Nest.Name, targets.SuggestedFileName); + viewer.Show(this); } /// Returns the chosen path, or null on cancel. Overwrite consent is the dialog prompt. diff --git a/OpenNest/Forms/MainForm.Status.cs b/OpenNest/Forms/MainForm.Status.cs index 8c6cae16..70c951cf 100644 --- a/OpenNest/Forms/MainForm.Status.cs +++ b/OpenNest/Forms/MainForm.Status.cs @@ -23,6 +23,7 @@ namespace OpenNest.Forms mnuFileExport.Enabled = hasValue; mnuFileExportAll.Enabled = hasValue; mnuFileExportNestReport.Enabled = hasValue && !storage.IsSaving; + mnuFileViewNestReport.Enabled = mnuFileExportNestReport.Enabled; mnuEdit.Visible = hasValue; mnuView.Visible = hasValue; mnuNest.Visible = hasValue; diff --git a/OpenNest/Forms/NestReportViewerForm.cs b/OpenNest/Forms/NestReportViewerForm.cs new file mode 100644 index 00000000..e234a8ba --- /dev/null +++ b/OpenNest/Forms/NestReportViewerForm.cs @@ -0,0 +1,138 @@ +using System; +using System.Drawing; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Forms; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.WinForms; + +namespace OpenNest.Forms +{ + /// + /// Shows a rendered nest report PDF in WebView2 with Save As and Print. The PDF is a private + /// temporary copy owned by this window: saving copies it, and closing deletes it. + /// + public sealed class NestReportViewerForm : Form + { + private readonly string pdfPath; + private readonly string suggestedFileName; + private readonly WebView2 browser = new() { Dock = DockStyle.Fill }; + private readonly ToolStripButton printButton; + + public NestReportViewerForm(string pdfPath, string nestName, string suggestedFileName) + { + this.pdfPath = pdfPath ?? throw new ArgumentNullException(nameof(pdfPath)); + this.suggestedFileName = suggestedFileName; + Text = $"Nest Report - {nestName}"; + Icon = Properties.Resources.app_icon; + StartPosition = FormStartPosition.CenterParent; + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + Size = new Size(System.Math.Min(1000, area.Width), System.Math.Min(900, area.Height)); + + var saveButton = new ToolStripButton("Save As...") { Name = "btnSaveAs" }; + saveButton.Click += (_, _) => SaveAs(); + printButton = new ToolStripButton("Print...") { Name = "btnPrint", Enabled = false }; + printButton.Click += (_, _) => Print(); + var toolbar = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden }; + toolbar.Items.AddRange(new ToolStripItem[] { saveButton, printButton }); + + Controls.Add(browser); + Controls.Add(toolbar); + } + + /// The report file this window shows and deletes on close. + public string PdfPath => pdfPath; + + protected override async void OnLoad(EventArgs e) + { + base.OnLoad(e); + try + { + var dataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenNest", "WebView2"); + var environment = await CoreWebView2Environment.CreateAsync(null, dataFolder); + await browser.EnsureCoreWebView2Async(environment); + } + catch (Exception ex) when (ex is WebView2RuntimeNotFoundException or COMException or IOException + or UnauthorizedAccessException) + { + // Saving needs no viewer; only the preview and Print are unavailable. + MessageBox.Show(this, + "The report preview needs the Microsoft Edge WebView2 Runtime, which could not be started. " + + "Use Save As to keep the report.\n\n" + ex.Message, + Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (IsDisposed) + return; + var core = browser.CoreWebView2; + core.Settings.AreDevToolsEnabled = false; + core.Settings.IsStatusBarEnabled = false; + // The report is the only page this window shows. + core.NewWindowRequested += (_, args) => args.Handled = true; + core.Navigate(new Uri(pdfPath).AbsoluteUri); + printButton.Enabled = true; + } + + private void SaveAs() + { + using var dlg = new SaveFileDialog + { + Filter = "PDF report (*.pdf)|*.pdf", + FileName = suggestedFileName, + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true, + }; + if (dlg.ShowDialog(this) != DialogResult.OK) + return; + try + { + // Overwrite consent is the dialog prompt. + File.Copy(pdfPath, dlg.FileName, true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + MessageBox.Show(this, $"Could not save the nest report: {ex.Message}", Text, + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void Print() + { + try + { + browser.CoreWebView2?.ShowPrintUI(CoreWebView2PrintDialogKind.Browser); + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + MessageBox.Show(this, $"Could not print the nest report: {ex.Message}", Text, + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + // Release WebView2's hold on the file before deleting its private folder. + browser.Dispose(); + base.OnFormClosed(e); + DeleteReportFolder(pdfPath); + } + + /// Best effort: a leftover temp report is harmless. + internal static void DeleteReportFolder(string pdfPath) + { + try + { + var folder = Path.GetDirectoryName(pdfPath); + if (folder != null && Directory.Exists(folder)) + Directory.Delete(folder, true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + } +} diff --git a/docs/nest-reports.md b/docs/nest-reports.md index f0942490..99dc4d26 100644 --- a/docs/nest-reports.md +++ b/docs/nest-reports.md @@ -9,8 +9,15 @@ the dialog closes, the target and those conditions are revalidated; the snapshot is then captured synchronously on the UI thread and only that detached snapshot reaches the renderer. Export never changes the selected plate, dirty state, timestamps or quantities, and it never touches post selection, verification or -CNC output. Opening and printing use any normal PDF viewer; the report includes -no printer controls and there are no persisted report settings or templates. +CNC output. There are no persisted report settings or templates. + +**File -> View Nest Report...** runs the same guards and capture without a dialog, +renders to a private folder under `%TEMP%\OpenNest\Reports\` and opens a modeless +viewer window (WebView2's PDF viewer, sharing Help's WebView2 data folder) with +**Save As...**, which copies the rendered file to a chosen path, and **Print...**, +the WebView2 print dialog. Closing the window deletes its temporary folder; a +render failure opens no viewer and leaves no folder. Without the WebView2 Runtime +the window still offers Save As. The same two calls back the command and any future integration: