From ab1f3a28d195760f7fd19a6e7b7144f9169dc625 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 29 Sep 2026 22:31:26 -0400 Subject: [PATCH] fix(import): abort failed CAD text extraction --- .../Forms/CadConverterImportFailureTests.cs | 120 ++++++++++++++++++ OpenNest/Forms/CadConverterForm.cs | 29 +++-- 2 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Forms/CadConverterImportFailureTests.cs diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterImportFailureTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterImportFailureTests.cs new file mode 100644 index 0000000..9caba4b --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/CadConverterImportFailureTests.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.IO; +using CSMath; +using OpenNest.Controls; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +public class CadConverterImportFailureTests +{ + [Fact] + public void MTextRegexHasExplicitTimeout() + { + var regex = Assert.IsType(typeof(CadConverterForm) + .GetField("MTextFormattingPattern", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)); + Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout); + Assert.Equal(RegexOptions.None, regex.Options); + } + + [Theory] + [InlineData(@"{\H0.2;Label}", "Label")] + [InlineData(@"\unterminated", @"\unterminated")] + [InlineData(" {Plain} ", "Plain")] + [InlineData("", "")] + public void MTextStrippingKeepsItsExistingRules(string input, string expected) => ArchUnitsTests.RunSta(() => + { + using var form = new ImportTestForm(); + Assert.Equal(expected, form.StripMTextFormatting(input)); + }); + + [Fact] + public void SuccessfulExtractionPreservesTextAndControlCodeSemantics() => ArchUnitsTests.RunSta(() => + { + using var form = new ImportTestForm(); + var doc = Document(@"{\H0.2;Hole %%c}", "Angle %%d"); + doc.Entities.Add(new TextEntity { Value = "%%p %%P %%d %%D %%c %%C %%%" }); + var texts = form.ExtractTexts(doc); + Assert.Equal(new[] { "Hole ⌀", "Angle °", "± ± ° ° ⌀ ⌀ %" }, texts.Select(t => t.Value)); + Assert.Equal(2, form.SuccessfulMTexts); + }); + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ExtractionTimeoutPublishesNeitherRawTextNorPartialFile(int successesBeforeFailure) => ArchUnitsTests.RunSta(() => + { + var directory = Path.Combine(Path.GetTempPath(), "OpenNest-CadText-" + Guid.NewGuid()); + Directory.CreateDirectory(directory); + try + { + var priorPath = Path.Combine(directory, "previous.dxf"); + var failedPath = Path.Combine(directory, "failed.dxf"); + DxfWriter.Write(priorPath, Document("Previous text"), false); + DxfWriter.Write(failedPath, Document(@"{\H0.2;First text}", "RAW PRIVATE TEXT"), false); + var originalBytes = File.ReadAllBytes(failedPath); + using var form = new ImportTestForm(); + form.AddFile(priorPath); + var fileList = Assert.IsType(form.Controls.Find("fileList", true).Single()); + var prior = Assert.Single(fileList.Items); + var selection = fileList.SelectedIndex; + Assert.Same(prior, fileList.SelectedItem); + Assert.Empty(form.Notices); + + form.SuccessfulMTexts = 0; + form.FailAfter = successesBeforeFailure; + form.AddFile(failedPath); + + Assert.Same(prior, Assert.Single(fileList.Items)); + Assert.Same(prior, fileList.SelectedItem); + Assert.Equal(selection, fileList.SelectedIndex); + Assert.Equal(new[] { "Previous text" }, prior.Texts.Select(t => t.Value)); + Assert.Equal(successesBeforeFailure, form.SuccessfulMTexts); + var notice = Assert.Single(form.Notices); + Assert.Equal(failedPath, notice.File); + Assert.Contains("text extraction timed out", notice.Error.Message); + Assert.IsType(notice.Error.InnerException); + Assert.DoesNotContain("RAW PRIVATE TEXT", notice.Error.Message); + Assert.Equal(originalBytes, File.ReadAllBytes(failedPath)); + + form.FailAfter = null; + form.AddFile(failedPath); + Assert.Equal(2, fileList.Items.Count); + Assert.Same(prior, fileList.SelectedItem); + Assert.Equal(new[] { "First text", "RAW PRIVATE TEXT" }, + fileList.Items.Single(item => item.Path == failedPath).Texts.Select(t => t.Value)); + Assert.Single(form.Notices); + } + finally { Directory.Delete(directory, true); } + }); + + private static CadDocument Document(params string[] values) + { + var doc = new CadDocument(); + doc.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + foreach (var value in values) + doc.Entities.Add(new MText { Value = value, Height = 0.2, InsertPoint = new XYZ(0, 1, 0) }); + return doc; + } + + private sealed class ImportTestForm : CadConverterForm + { + public int? FailAfter { get; set; } + public int SuccessfulMTexts { get; set; } + public List<(string File, Exception Error)> Notices { get; } = new(); + + internal override string StripMTextFormatting(string text) + { + if (SuccessfulMTexts == FailAfter) + throw new RegexMatchTimeoutException("RAW PRIVATE TEXT", "MText", TimeSpan.FromSeconds(1)); + var result = base.StripMTextFormatting(text); + SuccessfulMTexts++; + return result; + } + + internal override void ReportImportFailure(string file, Exception error) => Notices.Add((file, error)); + } +} diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 12647f5..a576ee9 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Drawing; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; @@ -18,6 +19,12 @@ namespace OpenNest.Forms { public partial class CadConverterForm : Form { + private static readonly Regex MTextFormattingPattern = new( + @"\\[A-Za-z][^;]*;", + RegexOptions.None, + TimeSpan.FromSeconds(1) + ); + private SimplifierViewerForm simplifierViewer; private bool staleProgram = true; @@ -101,12 +108,22 @@ namespace OpenNest.Forms else fileList.AddItem(item); } + catch (RegexMatchTimeoutException ex) + { + ReportImportFailure(file, new InvalidDataException( + "CAD text extraction timed out. The file was not imported.", ex)); + } catch (Exception ex) { - MessageBox.Show($"Error importing \"{file}\": {ex.Message}"); + ReportImportFailure(file, ex); } } + internal virtual void ReportImportFailure(string file, Exception error) + { + MessageBox.Show($"Error importing \"{file}\": {error.Message}"); + } + public void AddFiles(IEnumerable files) { var fileArray = files.ToArray(); @@ -974,7 +991,7 @@ namespace OpenNest.Forms #endregion - private static List ExtractTexts(ACadSharp.CadDocument doc) + internal List ExtractTexts(ACadSharp.CadDocument doc) { var texts = new List(); if (doc == null) @@ -1093,15 +1110,11 @@ namespace OpenNest.Forms return (h, v); } - private static string StripMTextFormatting(string text) + internal virtual string StripMTextFormatting(string text) { if (string.IsNullOrEmpty(text)) return text; - var result = System.Text.RegularExpressions.Regex.Replace( - text, - @"\\[A-Za-z][^;]*;", - "" - ); + var result = MTextFormattingPattern.Replace(text, ""); result = result.Replace("{", "").Replace("}", ""); return result.Trim(); }