diff --git a/.gitignore b/.gitignore index a241951..3491a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -213,3 +213,6 @@ docs/superpowers/ # Launch settings **/Properties/launchSettings.json + +# Local test config (contains user-specific paths to proprietary test assets) +OpenNest.Tests/test-config.json diff --git a/OpenNest.IO/ChrFont.cs b/OpenNest.IO/ChrFont.cs new file mode 100644 index 0000000..4e8c9f6 --- /dev/null +++ b/OpenNest.IO/ChrFont.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using OpenNest.Geometry; + +namespace OpenNest.IO +{ + public class ChrFont + { + private readonly Dictionary glyphs = new(); + + public string Name { get; internal set; } + public string Version { get; internal set; } + public double CapHeight { get; internal set; } = 5000; + + internal void AddGlyph(int charCode, ChrGlyph glyph) + { + glyphs[charCode] = glyph; + } + + public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode); + + public ChrGlyph GetGlyph(int charCode) => + glyphs.TryGetValue(charCode, out var g) ? g : null; + + public double MeasureTextWidth(string text, double height) + { + var scale = height / CapHeight; + double width = 0; + foreach (var ch in text) + { + var glyph = GetGlyph(ch); + if (glyph == null) + { + var space = GetGlyph(' '); + width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale; + continue; + } + width += glyph.AdvanceWidth * scale; + } + return width; + } + + public List RenderText(string text, double height, Vector position, Layer layer = null) + { + var scale = height / CapHeight; + var entities = new List(); + var cursorX = position.X; + + foreach (var ch in text) + { + var glyph = GetGlyph(ch); + if (glyph == null) + { + var space = GetGlyph(' '); + cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale; + continue; + } + + var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer); + entities.AddRange(glyphEntities); + cursorX += glyph.AdvanceWidth * scale; + } + + return entities; + } + + public static ChrFont Read(string path, byte? xorKey = null) + { + var raw = File.ReadAllBytes(path); + + // The whole file is obfuscated with a single-byte XOR. Different + // GravoStyle versions use different keys (0x2F in older releases, + // 0xCF in 7000-series). The font name at offset 0 is ASCII stored + // as UTF-16LE, so the high byte of its first character is 0x00 in + // plaintext — which means raw[1] is exactly the XOR key. Detect it + // from the file unless the caller forces a specific key. + var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F); + + var data = new byte[raw.Length]; + for (var i = 0; i < raw.Length; i++) + data[i] = (byte)(raw[i] ^ key); + + return Parse(data); + } + + private static ChrFont Parse(byte[] data) + { + var font = new ChrFont(); + + font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim(); + font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim(); + + var charTable = new List<(int charCode, int offset)>(); + var i = 0x40; + while (i + 5 < data.Length) + { + var charCode = data[i] | (data[i + 1] << 8); + var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24); + + if (charCode < 0x20 || offset == 0 || offset >= data.Length) + break; + + charTable.Add((charCode, offset)); + i += 6; + } + + for (var c = 0; c < charTable.Count; c++) + { + var (charCode, offset) = charTable[c]; + + var nextOffset = c + 1 < charTable.Count + ? FindNextOffset(charTable, offset, data.Length) + : data.Length; + + var glyph = ParseGlyph(data, offset, nextOffset); + if (glyph != null) + font.AddGlyph(charCode, glyph); + } + + if (font.glyphs.Count > 0) + { + foreach (var g in font.glyphs.Values) + { + if (g.CapHeight > 0) + { + font.CapHeight = g.CapHeight; + break; + } + } + } + + return font; + } + + private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength) + { + var best = fileLength; + foreach (var (_, off) in table) + { + if (off > currentOffset && off < best) + best = off; + } + return best; + } + + private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset) + { + if (offset + 92 > data.Length) + return null; + + var glyph = new ChrGlyph(); + glyph.CapHeight = ReadBE16(data, offset + 15 * 2); + var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2)); + glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing; + + var strokeStart = offset + 92; + var pos = strokeStart; + var currentStroke = new List(); + + while (pos + 5 < endOffset) + { + var cmd = ReadBE16(data, pos); + var x = ReadBE16(data, pos + 2); + var y = ReadBE16(data, pos + 4); + pos += 6; + + if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000) + break; + + if (cmd < -1000) + break; + + var type = cmd switch + { + 1 => ChrPointType.Vertex, + 4 => ChrPointType.Control, + 5 => ChrPointType.EndPoint, + _ => ChrPointType.Vertex, + }; + + currentStroke.Add(new ChrStrokePoint(type, x, y)); + + if (type == ChrPointType.EndPoint) + { + if (currentStroke.Count > 0) + glyph.Strokes.Add(currentStroke); + currentStroke = new List(); + } + } + + if (currentStroke.Count > 0) + glyph.Strokes.Add(currentStroke); + + return glyph; + } + + private static int ReadBE16(byte[] data, int offset) + { + var val = (data[offset] << 8) | data[offset + 1]; + if (val > 32767) val -= 65536; + return val; + } + } + + internal enum ChrPointType + { + Vertex, + Control, + EndPoint, + } + + internal struct ChrStrokePoint + { + public ChrPointType Type; + public double X; + public double Y; + + public ChrStrokePoint(ChrPointType type, double x, double y) + { + Type = type; + X = x; + Y = y; + } + } + + public class ChrGlyph + { + internal readonly List> Strokes = new(); + + public double AdvanceWidth { get; internal set; } + public double CapHeight { get; internal set; } + + private const int ArcSamples = 16; + + public List ToEntities(double scale, double offsetX, double offsetY, Layer layer = null) + { + var entities = new List(); + layer ??= Layer.Default; + + foreach (var stroke in Strokes) + { + if (stroke.Count < 2) continue; + + var segments = BuildSegments(stroke); + foreach (var seg in segments) + { + if (seg.Points.Count < 2) continue; + + var scaled = new List(seg.Points.Count); + foreach (var pt in seg.Points) + scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY)); + + var converted = PointsToLines(scaled); + + foreach (var e in converted) + { + e.Layer = layer; + entities.Add(e); + } + } + } + + return entities; + } + + private static List PointsToLines(List points) + { + var entities = new List(); + for (var i = 0; i < points.Count - 1; i++) + { + if (points[i].DistanceTo(points[i + 1]) < 0.001) + continue; + entities.Add(new Line(points[i], points[i + 1])); + } + return entities; + } + + private static List BuildSegments(List stroke) + { + var segments = new List(); + var current = new StrokeSegment(); + + var i = 0; + while (i < stroke.Count) + { + var pt = stroke[i]; + + if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint) + { + if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control) + { + var p0 = new Vector(pt.X, pt.Y); + var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y); + var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1]; + var p1 = new Vector(p2End.X, p2End.Y); + + SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples); + current.HasCurves = true; + i += 2; + } + else + { + current.Points.Add(new Vector(pt.X, pt.Y)); + i++; + } + } + else + { + i++; + } + } + + if (current.Points.Count >= 2) + segments.Add(current); + + return segments; + } + + private class StrokeSegment + { + public readonly List Points = new(); + public bool HasCurves; + } + + private static void SampleCircularArc(List output, Vector p0, Vector pMid, Vector p1, int samples) + { + if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01) + output.Add(p0); + + double ax = p0.X, ay = p0.Y; + double bx = pMid.X, by = pMid.Y; + double cx = p1.X, cy = p1.Y; + + var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + + if (System.Math.Abs(d) < 1e-6) + { + output.Add(pMid); + output.Add(p1); + return; + } + + var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d; + var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d; + var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy)); + + var a0 = System.Math.Atan2(ay - uy, ax - ux); + var am = System.Math.Atan2(by - uy, bx - ux); + var a1 = System.Math.Atan2(cy - uy, cx - ux); + + var ccwSweep = a1 - a0; + while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI; + + var midRel = am - a0; + while (midRel < 0) midRel += 2 * System.Math.PI; + + var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI; + + for (var i = 1; i <= samples; i++) + { + var t = (double)i / samples; + var angle = a0 + sweep * t; + output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle))); + } + } + } +} diff --git a/OpenNest.Tests/IO/ChrFontTests.cs b/OpenNest.Tests/IO/ChrFontTests.cs new file mode 100644 index 0000000..9581a2a --- /dev/null +++ b/OpenNest.Tests/IO/ChrFontTests.cs @@ -0,0 +1,180 @@ +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.IO; + +public class ChrFontTests +{ + private ChrFont LoadFont() + { + var path = TestConfig.GetExistingPath("ChrFontPath"); + Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found"); + return ChrFont.Read(path); + } + + [SkippableFact] + public void Read_ParsesFontName() + { + var font = LoadFont(); + Assert.Equal("US BLOCK 1L", font.Name); + } + + [SkippableFact] + public void Read_ParsesVersion() + { + var font = LoadFont(); + Assert.StartsWith("C1.", font.Version); + } + + [SkippableFact] + public void Read_HasAsciiGlyphs() + { + var font = LoadFont(); + Assert.True(font.HasGlyph('A')); + Assert.True(font.HasGlyph('Z')); + Assert.True(font.HasGlyph('0')); + Assert.True(font.HasGlyph(' ')); + } + + [SkippableFact] + public void Read_HasExtendedGlyphs() + { + var font = LoadFont(); + Assert.True(font.HasGlyph(0xC7)); // C-cedilla + } + + [SkippableFact] + public void Glyph_L_ProducesLines() + { + var font = LoadFont(); + var glyph = font.GetGlyph('L'); + Assert.NotNull(glyph); + + var entities = glyph.ToEntities(1.0, 0, 0); + Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}"); + Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type)); + } + + [SkippableFact] + public void Glyph_O_ProducesEntities() + { + var font = LoadFont(); + var glyph = font.GetGlyph('O'); + Assert.NotNull(glyph); + + var entities = glyph.ToEntities(1.0, 0, 0); + Assert.True(entities.Count > 0); + } + + [SkippableFact] + public void RenderText_ProducesEntities() + { + var font = LoadFont(); + var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0)); + Assert.True(entities.Count > 0, "RenderText should produce entities"); + } + + [SkippableFact] + public void RenderText_ScalesCorrectly() + { + var font = LoadFont(); + + var small = font.RenderText("A", 0.5, Vector.Zero); + var large = font.RenderText("A", 2.0, Vector.Zero); + + var smallBox = small.GetBoundingBox(); + var largeBox = large.GetBoundingBox(); + + Assert.True(largeBox.Width > smallBox.Width); + Assert.True(largeBox.Length > smallBox.Length); + } + + [SkippableFact] + public void RenderText_AdvancesCursor() + { + var font = LoadFont(); + var abEntities = font.RenderText("AB", 1.0, Vector.Zero); + var aEntities = font.RenderText("A", 1.0, Vector.Zero); + + var abBox = abEntities.GetBoundingBox(); + var aBox = aEntities.GetBoundingBox(); + + Assert.True(abBox.Length > aBox.Length * 1.5, + $"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})"); + } + + [SkippableFact] + public void RenderText_MatchesGravographReference() + { + var font = LoadFont(); + + var height = 5.08; + var centerX = 50.8; + var centerY = 34.925; + + var entities = font.RenderText("Text", height, Vector.Zero); + var rawBox = entities.GetBoundingBox(); + var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2; + var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2; + foreach (var e in entities) + e.Offset(new Vector(shiftX, shiftY)); + Assert.True(entities.Count > 0, "Should produce entities for 'Text'"); + + var box = entities.GetBoundingBox(); + + var refLeft = 43.53; + var refRight = 58.07; + var refBottom = 32.39; + var refTop = 37.47; + + var tolerance = 0.5; + + Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance, + $"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}"); + Assert.True(System.Math.Abs(box.Right - refRight) < tolerance, + $"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}"); + Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance, + $"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}"); + Assert.True(System.Math.Abs(box.Top - refTop) < tolerance, + $"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}"); + + var actualCapHeight = box.Top - box.Bottom; + Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5, + $"Cap height: ours={actualCapHeight:F2}, expected={height:F2}"); + } + + [SkippableFact] + public void MeasureTextWidth_IsConsistent() + { + var font = LoadFont(); + var height = 5.08; + var measuredWidth = font.MeasureTextWidth("Text", height); + var entities = font.RenderText("Text", height, Vector.Zero); + var box = entities.GetBoundingBox(); + + Assert.True(measuredWidth >= box.Length, + $"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}"); + Assert.True(measuredWidth - box.Length < 2.0, + $"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}"); + } + + [SkippableFact] + public void Glyph_t_HasCurveAtBottom() + { + var font = LoadFont(); + var glyph = font.GetGlyph('t'); + Assert.NotNull(glyph); + + var entities = glyph.ToEntities(1.0, 0, 0); + var lines = entities.Cast().ToList(); + + Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}"); + + var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList(); + Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}"); + + var lastCurve = curveLines[^1]; + Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X, + $"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}"); + } +} diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj index 10e5115..9f465c4 100644 --- a/OpenNest.Tests/OpenNest.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -14,6 +14,7 @@ + @@ -38,6 +39,9 @@ PreserveNewest + + PreserveNewest + diff --git a/OpenNest.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs index b9ba445..8c5e747 100644 --- a/OpenNest.Tests/TestHelpers.cs +++ b/OpenNest.Tests/TestHelpers.cs @@ -1,8 +1,37 @@ +using System.Text.Json; using OpenNest.CNC; using OpenNest.Geometry; namespace OpenNest.Tests; +internal static class TestConfig +{ + private static readonly Lazy> Config = new(() => + { + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 6; i++) + { + var path = Path.Combine(dir, "test-config.json"); + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json) ?? new(); + } + dir = Path.GetDirectoryName(dir)!; + } + return new(); + }); + + public static string? Get(string key) => + Config.Value.TryGetValue(key, out var val) ? val : null; + + public static string? GetExistingPath(string key) + { + var path = Get(key); + return path != null && File.Exists(path) ? path : null; + } +} + internal static class TestHelpers { public static Part MakePartAt(double x, double y, double size = 1) diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs index 53f62d4..5171714 100644 --- a/OpenNest/Controls/EntityView.cs +++ b/OpenNest/Controls/EntityView.cs @@ -40,6 +40,7 @@ namespace OpenNest.Controls public event EventHandler LinePicked; public event EventHandler PickCancelled; + public event EventHandler TextConvertRequested; private bool isPickingBendLine; public bool IsPickingBendLine @@ -76,6 +77,13 @@ namespace OpenNest.Controls if (line != null) LinePicked?.Invoke(this, line); } + + if (e.Button == MouseButtons.Right) + { + var text = HitTestText(e.Location); + if (text != null) + ShowTextContextMenu(text, e.Location); + } } protected override void OnPaint(PaintEventArgs e) @@ -328,6 +336,41 @@ namespace OpenNest.Controls return bestLine; } + private CadText HitTestText(Point controlPoint) + { + if (Texts == null || Texts.Count == 0) + return null; + + var worldPoint = PointControlToWorld(controlPoint); + var tolerance = LengthGuiToWorld(8); + + foreach (var text in Texts) + { + if (string.IsNullOrEmpty(text.Value)) + continue; + + var estimatedWidth = text.Height * text.Value.Length * 0.6; + var minX = text.Position.X - tolerance; + var maxX = text.Position.X + estimatedWidth + tolerance; + var minY = text.Position.Y - tolerance; + var maxY = text.Position.Y + text.Height + tolerance; + + if (worldPoint.X >= minX && worldPoint.X <= maxX && + worldPoint.Y >= minY && worldPoint.Y <= maxY) + return text; + } + + return null; + } + + private void ShowTextContextMenu(CadText text, Point location) + { + var menu = new ContextMenuStrip(); + var item = menu.Items.Add($"Convert \"{text.Value}\" to Geometry"); + item.Click += (s, e) => TextConvertRequested?.Invoke(this, text); + menu.Show(this, location); + } + private void DrawEntityLabels(Graphics g) { for (var i = 0; i < Entities.Count; i++) diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 95fd09c..392bb63 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -45,6 +45,7 @@ namespace OpenNest.Forms filterPanel.AddBendLineClicked += OnAddBendLineClicked; entityView1.LinePicked += OnLinePicked; entityView1.PickCancelled += OnPickCancelled; + entityView1.TextConvertRequested += OnTextConvertRequested; btnSplit.Click += OnSplitClicked; numQuantity.ValueChanged += OnQuantityChanged; txtCustomer.TextChanged += OnCustomerChanged; @@ -463,6 +464,115 @@ namespace OpenNest.Forms filterPanel.SetPickMode(false); } + private void OnTextConvertRequested(object sender, Controls.CadText text) + { + var item = CurrentItem; + if (item == null) return; + + var font = LoadChrFont(); + if (font == null) return; + + var layer = new Geometry.Layer("ENGRAVE") + { + Color = System.Drawing.Color.Cyan, + IsVisible = true, + }; + + var entities = font.RenderText(text.Value, text.Height, Geometry.Vector.Zero, layer); + if (entities.Count > 0) + { + var box = entities.GetBoundingBox(); + var shiftX = text.HAlign switch + { + System.Drawing.StringAlignment.Center => text.Position.X - (box.Left + box.Right) / 2, + System.Drawing.StringAlignment.Far => text.Position.X - box.Right, + _ => text.Position.X - box.Left, + }; + var shiftY = text.VAlign switch + { + System.Drawing.StringAlignment.Center => text.Position.Y - (box.Top + box.Bottom) / 2, + System.Drawing.StringAlignment.Near => text.Position.Y - box.Top, + _ => text.Position.Y - box.Bottom, + }; + var shift = new Geometry.Vector(shiftX, shiftY); + foreach (var e in entities) + e.Offset(shift); + } + if (entities.Count == 0) + { + MessageBox.Show($"No geometry produced for \"{text.Value}\".", "Convert Text", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + item.Entities.AddRange(entities); + item.Texts.Remove(text); + item.EntityCount = item.Entities.Count; + item.Bounds = item.Entities.GetBoundingBox(); + + entityView1.Entities.Clear(); + entityView1.Entities.AddRange(item.Entities); + entityView1.Texts = item.Texts; + filterPanel.LoadItem(item.Entities, item.Bends); + entityView1.Invalidate(); + staleProgram = true; + lblEntityCount.Text = $"{item.EntityCount} entities"; + } + + private ChrFont cachedChrFont; + private string cachedChrFontPath; + + private ChrFont LoadChrFont() + { + if (cachedChrFont != null) + return cachedChrFont; + + // Look for .CHR files next to the app, then prompt + var appDir = System.IO.Path.GetDirectoryName(Application.ExecutablePath); + var candidates = Directory.GetFiles(appDir, "*.CHR", SearchOption.TopDirectoryOnly); + + string fontPath; + if (candidates.Length == 1) + { + fontPath = candidates[0]; + } + else if (candidates.Length > 1) + { + fontPath = PromptForChrFile(appDir); + } + else + { + fontPath = PromptForChrFile(null); + } + + if (fontPath == null) + return null; + + try + { + cachedChrFont = ChrFont.Read(fontPath); + cachedChrFontPath = fontPath; + return cachedChrFont; + } + catch (Exception ex) + { + MessageBox.Show($"Error loading font: {ex.Message}", "Font Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + return null; + } + } + + private static string PromptForChrFile(string initialDir) + { + using var dlg = new OpenFileDialog + { + Title = "Select Engraving Font (.CHR)", + Filter = "Gravograph Font (*.CHR)|*.CHR", + InitialDirectory = initialDir ?? "", + }; + return dlg.ShowDialog() == DialogResult.OK ? dlg.FileName : null; + } + private void OnDragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop))