using System.Diagnostics; using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Win32; var options = Options.Parse(args); if (!File.Exists(options.DrawingPath)) throw new FileNotFoundException($"DWG not found: {options.DrawingPath}"); if (options.Exports.Count == 0) throw new ArgumentException("At least one --view-bounds-mm value is required."); Directory.CreateDirectory(options.OutputDir); var sourceDrawingPath = Path.GetFullPath(options.DrawingPath); var exportDrawingPath = PrepareExportDrawing(sourceDrawingPath, options); var app = StartOrConnectAutoCad(options.ProgId); SetProperty(app, "Visible", options.Visible); object? doc = null; var results = new List(); var cleanup = AnnotationCleanupResult.Empty; try { doc = OpenDocument(app, exportDrawingPath, readOnly: !options.RemoveAnnotations && !options.HasHighlights); WaitForQuiescent(app, TimeSpan.FromSeconds(30)); TryInvoke(doc, "SetVariable", "BACKGROUNDPLOT", 0); TryInvoke(doc, "SetVariable", "FILEDIA", 0); TryInvoke(doc, "SetVariable", "CMDDIA", 0); if (options.RemoveAnnotations) { cleanup = RemoveAnnotations(doc, options); Safe(() => Invoke(doc, "SaveAs", exportDrawingPath)); WaitForQuiescent(app, TimeSpan.FromSeconds(20)); Console.WriteLine($"annotation_cleanup candidates={cleanup.CandidateCount} deleted={cleanup.DeletedCount} dwg={exportDrawingPath}"); } var highlight = ApplyHighlights(doc, options); if (highlight.HighlightedCount > 0 || highlight.BoxCount > 0) { Safe(() => Invoke(doc, "SaveAs", exportDrawingPath)); WaitForQuiescent(app, TimeSpan.FromSeconds(20)); Console.WriteLine($"highlight handles={highlight.RequestedCount} highlighted={highlight.HighlightedCount} boxes={highlight.BoxCount} failed={highlight.FailedHandles.Count}"); } foreach (var export in options.Exports) { var outputPath = Path.GetFullPath(Path.Combine(options.OutputDir, export.OutputName)); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); var padded = export.Bounds.Expand(options.PaddingMm); var plotBounds = padded; PlotResult result; if (options.IncludeAnnotationsInBounds && !options.RemoveAnnotations) plotBounds = ExpandBoundsToConnectedAnnotations(doc, export.Bounds, padded, options); result = PlotWindowToPng(doc, outputPath, plotBounds, options); results.Add(new { name = export.Name, output_png = outputPath, bounds_mm = export.Bounds, padded_bounds_mm = padded, plot_bounds_mm = plotBounds, annotation_bounds_expanded = !BoundsNearlyEqual(padded, plotBounds), result.Device, result.Media, result.Ok }); Console.WriteLine($"exported name={export.Name} ok={result.Ok} png={outputPath}"); } } finally { if (doc != null && options.CloseDocument) Safe(() => Invoke(doc, "Close", false)); ReleaseCom(doc); ReleaseCom(app); } var reportPath = string.IsNullOrWhiteSpace(options.ReportPath) ? Path.Combine(options.OutputDir, "dwg-view-png-export-report.json") : options.ReportPath; File.WriteAllText(reportPath, JsonSerializer.Serialize(new { source_drawing_path = sourceDrawingPath, drawing_path = exportDrawingPath, working_dwg = options.WorkingDwgPath, remove_annotations = options.RemoveAnnotations, highlight_handles = options.HighlightHandles, highlight_boxes_mm = options.HighlightBoxes, highlight_color_index = options.HighlightColorIndex, highlight_lineweight = options.HighlightLineweight, include_annotations_in_bounds = options.IncludeAnnotationsInBounds, annotation_connect_tolerance_mm = options.AnnotationConnectToleranceMm, max_annotation_expansion_mm = options.MaxAnnotationExpansionMm, max_annotation_box_size_mm = options.MaxAnnotationBoxSizeMm, annotation_cleanup = cleanup, output_dir = Path.GetFullPath(options.OutputDir), padding_mm = options.PaddingMm, device = options.DeviceName, requested_media = options.MediaName, exports = results }, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine($"report={Path.GetFullPath(reportPath)}"); static string PrepareExportDrawing(string sourceDrawingPath, Options options) { if (!options.RemoveAnnotations && !options.HasHighlights) { options.WorkingDwgPath = ""; return sourceDrawingPath; } var workingDwg = options.WorkingDwgPath; if (string.IsNullOrWhiteSpace(workingDwg)) { var baseName = Path.GetFileNameWithoutExtension(sourceDrawingPath); workingDwg = Path.Combine(options.OutputDir, "_working", $"{baseName}.annotation-clean.dwg"); options.WorkingDwgPath = workingDwg; } if (string.IsNullOrWhiteSpace(workingDwg)) return sourceDrawingPath; var workingPath = Path.GetFullPath(workingDwg); if (string.Equals(sourceDrawingPath, workingPath, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("--working-dwg must not point at the source DWG."); Directory.CreateDirectory(Path.GetDirectoryName(workingPath)!); File.Copy(sourceDrawingPath, workingPath, overwrite: true); return workingPath; } static HighlightResult ApplyHighlights(object doc, Options options) { if (!options.HasHighlights) return HighlightResult.Empty; var failed = new List(); var highlighted = 0; foreach (var handle in options.HighlightHandles) { if (string.IsNullOrWhiteSpace(handle)) continue; object? entity = null; try { entity = Invoke(doc, "HandleToObject", handle); if (entity == null) { failed.Add(handle); continue; } TrySetProperty(entity, "Color", options.HighlightColorIndex); TrySetProperty(entity, "Lineweight", options.HighlightLineweight); Safe(() => Invoke(entity, "Highlight", true)); Safe(() => Invoke(entity, "Update")); highlighted++; } catch { failed.Add(handle); } finally { ReleaseCom(entity); } } var boxes = ApplyHighlightBoxes(doc, options); return new HighlightResult(options.HighlightHandles.Count, highlighted, boxes, failed); } static int ApplyHighlightBoxes(object doc, Options options) { if (options.HighlightBoxes.Count == 0) return 0; object? modelSpace = null; var added = 0; try { modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("ModelSpace unavailable."); foreach (var box in options.HighlightBoxes) { var expanded = box.Bounds.Expand(options.HighlightBoxPaddingMm); var points = new[] { new[] { expanded.MinX, expanded.MinY, 0.0 }, new[] { expanded.MaxX, expanded.MinY, 0.0 }, new[] { expanded.MaxX, expanded.MaxY, 0.0 }, new[] { expanded.MinX, expanded.MaxY, 0.0 } }; for (var i = 0; i < points.Length; i++) { object? line = null; try { line = Invoke(modelSpace, "AddLine", points[i], points[(i + 1) % points.Length]); if (line == null) continue; TrySetProperty(line, "Color", options.HighlightColorIndex); TrySetProperty(line, "Lineweight", options.HighlightLineweight); Safe(() => Invoke(line, "Update")); added++; } finally { ReleaseCom(line); } } } } finally { ReleaseCom(modelSpace); } return added / 4; } static AnnotationCleanupResult RemoveAnnotations(object doc, Options options) { var deletedByObjectName = new Dictionary(StringComparer.OrdinalIgnoreCase); var samples = new List(); var candidateCount = 0; var deletedCount = 0; foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" }) { var space = GetProperty(doc, spaceName); if (space == null) continue; try { var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture); for (var i = count - 1; i >= 0; i--) { var entity = TryInvoke(space, "Item", i); if (entity == null) continue; try { var objectName = ReadComString(entity, "ObjectName"); var layer = ReadComString(entity, "Layer"); var handle = ReadComString(entity, "Handle"); var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name")); if (!IsAnnotationEntity(entity, objectName, layer, name, out var reason)) continue; candidateCount++; if (!TryDeleteEntity(entity)) continue; deletedCount++; deletedByObjectName[objectName] = deletedByObjectName.GetValueOrDefault(objectName) + 1; if (samples.Count < options.MaxCleanupSamples) samples.Add(new AnnotationDeleteSample(spaceName, handle, objectName, layer, name, reason)); } finally { ReleaseCom(entity); } } } finally { ReleaseCom(space); } } return new AnnotationCleanupResult(candidateCount, deletedCount, deletedByObjectName, samples); } static bool TryDeleteEntity(object entity) { try { Invoke(entity, "Delete"); return true; } catch { return false; } } static BoundsMm ExpandBoundsToConnectedAnnotations( object doc, BoundsMm viewBounds, BoundsMm basePlotBounds, Options options) { var limitBounds = viewBounds.Expand(options.MaxAnnotationExpansionMm); var seedBounds = viewBounds.Expand(options.ViewSeedToleranceMm); var seedBoxes = new List(); var annotationBoxes = new List(); foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" }) { var space = GetProperty(doc, spaceName); if (space == null) continue; try { var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture); for (var i = 0; i < count; i++) { var entity = TryInvoke(space, "Item", i); if (entity == null) continue; var objectName = ReadComString(entity, "ObjectName"); var layer = ReadComString(entity, "Layer"); var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name")); try { var hasBounds = TryGetEntityBounds(entity, out var bounds); var isAnnotation = IsAnnotationEntityForBounds(objectName, layer, name); if (!hasBounds || !bounds.IsFinite || !bounds.Intersects(limitBounds)) continue; if (isAnnotation) { if (bounds.Width <= options.MaxAnnotationBoxSizeMm && bounds.Height <= options.MaxAnnotationBoxSizeMm) { annotationBoxes.Add(bounds); } } else if (bounds.Intersects(seedBounds)) { seedBoxes.Add(bounds); } } finally { ReleaseCom(entity); } } } finally { ReleaseCom(space); } } var connectedBoxes = seedBoxes.ToList(); if (connectedBoxes.Count == 0) connectedBoxes.Add(viewBounds); var remainingAnnotations = annotationBoxes.ToList(); var plotBounds = basePlotBounds; var included = 0; var changed = true; while (changed) { changed = false; for (var i = remainingAnnotations.Count - 1; i >= 0; i--) { var candidate = remainingAnnotations[i]; if (!connectedBoxes.Any(box => box.DistanceTo(candidate) <= options.AnnotationConnectToleranceMm)) continue; connectedBoxes.Add(candidate); plotBounds = plotBounds.Union(candidate.ClampTo(limitBounds)); remainingAnnotations.RemoveAt(i); included++; changed = true; } } Console.WriteLine( $"annotation_bounds connected_annotations={included} seed_entities={seedBoxes.Count} candidates={annotationBoxes.Count} base=({basePlotBounds.MinX:F3},{basePlotBounds.MinY:F3},{basePlotBounds.MaxX:F3},{basePlotBounds.MaxY:F3}) plot=({plotBounds.MinX:F3},{plotBounds.MinY:F3},{plotBounds.MaxX:F3},{plotBounds.MaxY:F3})"); return plotBounds; } static bool IsAnnotationEntityForBounds(string objectName, string layer, string name) { var type = objectName.ToLowerInvariant(); if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum")) return true; if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) || ContainsAny(type, "mtext", "attribute", "attdef")) return true; if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase)) return true; return LooksLikeAnnotationName(layer.ToLowerInvariant()) || LooksLikeAnnotationName(name.ToLowerInvariant()); } static bool TryGetEntityBounds(object entity, out BoundsMm bounds) { bounds = new BoundsMm(0, 0, 0, 0); try { object? minPoint = null; object? maxPoint = null; object?[] args = [minPoint, maxPoint]; var modifiers = new ParameterModifier(2); modifiers[0] = true; modifiers[1] = true; entity.GetType().InvokeMember( "GetBoundingBox", BindingFlags.InvokeMethod, null, entity, args, [modifiers], CultureInfo.InvariantCulture, null); var min = ToDoubleList(args[0]); var max = ToDoubleList(args[1]); if (min.Count < 2 || max.Count < 2) return false; bounds = new BoundsMm( Math.Min(min[0], max[0]), Math.Min(min[1], max[1]), Math.Max(min[0], max[0]), Math.Max(min[1], max[1])); return bounds.IsFinite; } catch { return false; } } static bool BoundsNearlyEqual(BoundsMm a, BoundsMm b) => Math.Abs(a.MinX - b.MinX) < 1e-6 && Math.Abs(a.MinY - b.MinY) < 1e-6 && Math.Abs(a.MaxX - b.MaxX) < 1e-6 && Math.Abs(a.MaxY - b.MaxY) < 1e-6; static bool IsAnnotationEntity(object entity, string objectName, string layer, string name, out string reason) { var type = objectName.ToLowerInvariant(); if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum")) { reason = "annotation_object_type"; return true; } if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) || ContainsAny(type, "mtext", "attribute", "attdef")) { reason = "text_annotation"; return true; } var layerText = layer.ToLowerInvariant(); if (LooksLikeAnnotationName(layerText)) { reason = "annotation_layer"; return true; } if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) && LooksLikeAnnotationName(name.ToLowerInvariant())) { reason = "annotation_block"; return true; } if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) && !layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase)) { reason = "non_ag4_annotation_block"; return true; } if (!layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase)) { if (IsLeaderPrimitive(entity, type)) { reason = "leader_primitive"; return true; } if (IsLeaderDotPrimitive(entity, type)) { reason = "leader_dot_primitive"; return true; } } reason = ""; return false; } static bool IsLeaderPrimitive(object entity, string objectName) { if (objectName.Contains("line", StringComparison.OrdinalIgnoreCase) && TryReadLineEndpoints(entity, out var start, out var end)) { return IsLongDiagonalSegment(start.X, start.Y, end.X, end.Y); } if (!objectName.Contains("polyline", StringComparison.OrdinalIgnoreCase)) return false; var coordinates = ToDoubleList(TryGetProperty(entity, "Coordinates")); if (coordinates.Count < 4) return false; for (var i = 0; i + 3 < coordinates.Count; i += 2) { if (IsLongDiagonalSegment(coordinates[i], coordinates[i + 1], coordinates[i + 2], coordinates[i + 3])) return true; } return false; } static bool IsLeaderDotPrimitive(object entity, string objectName) { if (objectName.Contains("point", StringComparison.OrdinalIgnoreCase)) return true; if (objectName.Contains("circle", StringComparison.OrdinalIgnoreCase)) { var radius = ReadComDouble(entity, "Radius"); return double.IsFinite(radius) && radius > 0 && radius <= 2.0; } return objectName.Contains("hatch", StringComparison.OrdinalIgnoreCase) && ReadComDouble(entity, "Area") is var area && double.IsFinite(area) && area > 0 && area <= 12.0; } static bool TryReadLineEndpoints(object entity, out Point2 start, out Point2 end) { start = default; end = default; var startValues = ToDoubleList(TryGetProperty(entity, "StartPoint")); var endValues = ToDoubleList(TryGetProperty(entity, "EndPoint")); if (startValues.Count < 2 || endValues.Count < 2) return false; start = new Point2(startValues[0], startValues[1]); end = new Point2(endValues[0], endValues[1]); return true; } static bool IsLongDiagonalSegment(double x1, double y1, double x2, double y2) { var dx = x2 - x1; var dy = y2 - y1; var length = Math.Sqrt(dx * dx + dy * dy); if (length < 15.0) return false; var angle = Math.Abs(Math.Atan2(dy, dx) * 180.0 / Math.PI); angle %= 180.0; var axisAngle = Math.Min(angle, 180.0 - angle); return axisAngle is >= 8.0 and <= 82.0; } static bool LooksLikeAnnotationName(string value) { if (string.IsNullOrWhiteSpace(value)) return false; return ContainsAny(value, "dim", "dimension", "anno", "annot", "annotation", "note", "text", "txt", "tol", "tolerance", "leader", "mleader", "balloon", "bubble", "item", "center", "centre", "symbol", "\u6807\u6ce8", "\u5c3a\u5bf8", "\u516c\u5dee", "\u5e8f\u53f7", "\u5f15\u7ebf", "\u6587\u5b57", "\u6ce8\u91ca", "\u4e2d\u5fc3\u7ebf", "\u7b26\u53f7"); } static bool ContainsAny(string value, params string[] needles) => needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase)); static string ReadComString(object target, string propertyName) { try { return GetProperty(target, propertyName)?.ToString() ?? ""; } catch { return ""; } } static double ReadComDouble(object target, string propertyName) { try { var value = GetProperty(target, propertyName); return value == null ? double.NaN : Convert.ToDouble(value, CultureInfo.InvariantCulture); } catch { return double.NaN; } } static object? TryGetProperty(object target, string name) { try { return GetProperty(target, name); } catch { return null; } } static List ToDoubleList(object? value) { if (value == null) return []; if (value is Array array) { var result = new List(); foreach (var item in array) { if (item != null) result.Add(Convert.ToDouble(item, CultureInfo.InvariantCulture)); } return result; } return []; } static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? ""; static PlotResult PlotWindowToPng(object doc, string outputPath, BoundsMm bounds, Options options) { var layout = GetProperty(doc, "ActiveLayout") ?? throw new InvalidOperationException("ActiveLayout unavailable."); var plot = GetProperty(doc, "Plot") ?? throw new InvalidOperationException("Plot unavailable."); try { SetProperty(layout, "ConfigName", options.DeviceName); TryInvoke(layout, "RefreshPlotDeviceInfo"); var media = string.IsNullOrWhiteSpace(options.MediaName) ? SelectLargestPixelMedia(layout) : options.MediaName; if (!string.IsNullOrWhiteSpace(media)) SetProperty(layout, "CanonicalMediaName", media); Invoke(layout, "SetWindowToPlot", new[] { bounds.MinX, bounds.MinY }, new[] { bounds.MaxX, bounds.MaxY }); SetProperty(layout, "PlotType", 4); // acWindow SetProperty(layout, "UseStandardScale", true); SetProperty(layout, "StandardScale", 0); // acScaleToFit SetProperty(layout, "CenterPlot", true); SetProperty(layout, "PlotWithPlotStyles", false); SetProperty(layout, "PlotWithLineweights", true); SetProperty(layout, "ScaleLineweights", false); SetProperty(layout, "PlotRotation", bounds.Width >= bounds.Height ? 1 : 0); // ac90degrees / ac0degrees TrySetProperty(layout, "StyleSheet", ""); TrySetProperty(layout, "PaperUnits", 1); // acMillimeters if (File.Exists(outputPath)) File.Delete(outputPath); Console.WriteLine($"plot name={Path.GetFileNameWithoutExtension(outputPath)} device={options.DeviceName} media={media}"); var ok = ToBool(TryInvoke(plot, "PlotToFile", outputPath, options.DeviceName)) ?? ToBool(TryInvoke(plot, "PlotToFile", outputPath)) ?? File.Exists(outputPath); WaitForFile(outputPath, TimeSpan.FromSeconds(20)); WaitForQuiescent(GetProperty(doc, "Application") ?? doc, TimeSpan.FromSeconds(20)); return new PlotResult(ok && File.Exists(outputPath), options.DeviceName, media); } finally { ReleaseCom(plot); ReleaseCom(layout); } } static string SelectLargestPixelMedia(object layout) { var names = ToStringList(Invoke(layout, "GetCanonicalMediaNames")); if (names.Count == 0) return ""; return names .Select(name => new { Name = name, Pixels = ParsePixelArea(name) }) .OrderByDescending(item => item.Pixels) .ThenByDescending(item => item.Name.Length) .First().Name; } static double ParsePixelArea(string mediaName) { var matches = Regex.Matches(mediaName, @"\d+(?:\.\d+)?"); if (matches.Count < 2) return 0; var values = matches .Select(match => double.Parse(match.Value, CultureInfo.InvariantCulture)) .ToList(); return values[^1] * values[^2]; } static List ToStringList(object? value) { if (value is null) return []; if (value is object[] objects) return objects.Where(item => item != null).Select(item => item.ToString() ?? "").Where(item => item.Length > 0).ToList(); if (value is Array array) { var result = new List(); foreach (var item in array) { var text = item?.ToString() ?? ""; if (!string.IsNullOrWhiteSpace(text)) result.Add(text); } return result; } return []; } static object StartOrConnectAutoCad(string progId) { object? app = null; try { app = GetActiveObjectFromProgId(progId); } catch { app = null; } if (app != null) return app; StartAutoCadFromRegistry(progId); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); while (DateTime.UtcNow < deadline) { try { return GetActiveObjectFromProgId(progId); } catch { Thread.Sleep(500); } } throw new InvalidOperationException($"AutoCAD startup or COM connection failed: {progId}"); } static object GetActiveObjectFromProgId(string progId) { var hr = CLSIDFromProgID(progId, out var clsid); if (hr < 0) Marshal.ThrowExceptionForHR(hr); return GetActiveObject(ref clsid, IntPtr.Zero); } static void StartAutoCadFromRegistry(string progId) { var clsid = ReadDefaultValue($@"{progId}\CLSID"); var command = string.IsNullOrWhiteSpace(clsid) ? "" : ReadDefaultValue($@"CLSID\{clsid}\LocalServer32"); var exe = ExtractExePath(command); if (string.IsNullOrWhiteSpace(exe) || !File.Exists(exe)) exe = @"D:\Program Files\Autodesk\AutoCAD 2020\acad.exe"; if (!File.Exists(exe)) throw new FileNotFoundException($"acad.exe not found. ProgID={progId}, LocalServer32={command}"); Process.Start(new ProcessStartInfo { FileName = exe, Arguments = "/Automation", UseShellExecute = true, WorkingDirectory = Path.GetDirectoryName(exe) ?? Environment.CurrentDirectory, WindowStyle = ProcessWindowStyle.Hidden }); } static string ReadDefaultValue(string subKey) { using var key = Registry.ClassesRoot.OpenSubKey(subKey); return key?.GetValue("")?.ToString() ?? ""; } static string ExtractExePath(string command) { if (string.IsNullOrWhiteSpace(command)) return ""; var trimmed = command.Trim(); if (trimmed.StartsWith('"')) { var end = trimmed.IndexOf('"', 1); return end > 1 ? trimmed[1..end] : ""; } var exeIndex = trimmed.IndexOf(".exe", StringComparison.OrdinalIgnoreCase); return exeIndex >= 0 ? trimmed[..(exeIndex + 4)] : trimmed; } static object OpenDocument(object app, string drawingPath, bool readOnly) { var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD.Application.Documents unavailable."); try { return TryInvoke(documents, "Open", drawingPath, readOnly) ?? TryInvoke(documents, "Open", drawingPath) ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {drawingPath}"); } finally { ReleaseCom(documents); } } static void WaitForQuiescent(object app, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { var state = TryInvoke(app, "GetAcadState"); if (state == null) return; try { if (ToBool(GetProperty(state, "IsQuiescent")) ?? true) return; } finally { ReleaseCom(state); } Thread.Sleep(250); } } static void WaitForFile(string path, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (File.Exists(path) && new FileInfo(path).Length > 0) return; Thread.Sleep(250); } } static object? Invoke(object target, string name, params object?[] args) => RetryCom(() => target.GetType().InvokeMember(name, BindingFlags.InvokeMethod, null, target, args, CultureInfo.InvariantCulture)); static object? TryInvoke(object target, string name, params object?[] args) { try { return Invoke(target, name, args); } catch { return null; } } static object? GetProperty(object target, string name) => RetryCom(() => target.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null, CultureInfo.InvariantCulture)); static void SetProperty(object target, string name, object? value) => RetryCom(() => { target.GetType().InvokeMember(name, BindingFlags.SetProperty, null, target, [value], CultureInfo.InvariantCulture); return true; }); static void TrySetProperty(object target, string name, object? value) { try { SetProperty(target, name, value); } catch { } } static T RetryCom(Func action) { Exception? last = null; for (var attempt = 0; attempt < 20; attempt++) { try { return action(); } catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { last = ex.InnerException; } catch (COMException ex) when (IsRetryableCom(ex)) { last = ex; } Thread.Sleep(100); } throw last ?? new COMException("COM call failed."); } static bool IsRetryableCom(Exception? ex) => ex is COMException com && (uint)com.ErrorCode is 0x80010001 or 0x8001010A or 0x800AC472; static bool? ToBool(object? value) { if (value == null) return null; if (value is bool b) return b; if (value is int i) return i != 0; return bool.TryParse(value.ToString(), out var parsed) ? parsed : null; } static void Safe(Action action) { try { action(); } catch { } } static void ReleaseCom(object? obj) { if (obj != null && Marshal.IsComObject(obj)) Safe(() => Marshal.FinalReleaseComObject(obj)); } [DllImport("ole32.dll", CharSet = CharSet.Unicode)] static extern int CLSIDFromProgID(string progId, out Guid clsid); [DllImport("oleaut32.dll", PreserveSig = false)] [return: MarshalAs(UnmanagedType.IUnknown)] static extern object GetActiveObject(ref Guid clsid, IntPtr reserved); sealed record PlotResult(bool Ok, string Device, string Media); sealed record AnnotationCleanupResult( int CandidateCount, int DeletedCount, Dictionary DeletedByObjectName, List Samples) { public static AnnotationCleanupResult Empty { get; } = new(0, 0, new Dictionary(), []); } sealed record AnnotationDeleteSample( string Space, string Handle, string ObjectName, string Layer, string Name, string Reason); readonly record struct Point2(double X, double Y); sealed record BoundsMm(double MinX, double MinY, double MaxX, double MaxY) { public double Width => MaxX - MinX; public double Height => MaxY - MinY; public bool IsFinite => double.IsFinite(MinX) && double.IsFinite(MinY) && double.IsFinite(MaxX) && double.IsFinite(MaxY); public BoundsMm Expand(double paddingMm) => new(MinX - paddingMm, MinY - paddingMm, MaxX + paddingMm, MaxY + paddingMm); public BoundsMm Union(BoundsMm other) => new( Math.Min(MinX, other.MinX), Math.Min(MinY, other.MinY), Math.Max(MaxX, other.MaxX), Math.Max(MaxY, other.MaxY)); public BoundsMm ClampTo(BoundsMm limit) => new( Math.Max(MinX, limit.MinX), Math.Max(MinY, limit.MinY), Math.Min(MaxX, limit.MaxX), Math.Min(MaxY, limit.MaxY)); public bool Intersects(BoundsMm other) => MinX <= other.MaxX && MaxX >= other.MinX && MinY <= other.MaxY && MaxY >= other.MinY; public double DistanceTo(BoundsMm other) { var dx = other.MinX > MaxX ? other.MinX - MaxX : MinX > other.MaxX ? MinX - other.MaxX : 0.0; var dy = other.MinY > MaxY ? other.MinY - MaxY : MinY > other.MaxY ? MinY - other.MaxY : 0.0; return Math.Sqrt(dx * dx + dy * dy); } } sealed record ExportRequest(string Name, BoundsMm Bounds, string OutputName); sealed record HighlightBoxRequest(string Name, BoundsMm Bounds); sealed record HighlightResult(int RequestedCount, int HighlightedCount, int BoxCount, List FailedHandles) { public static HighlightResult Empty { get; } = new(0, 0, 0, []); } sealed class Options { public string DrawingPath { get; private set; } = ""; public string WorkingDwgPath { get; set; } = ""; public string OutputDir { get; private set; } = ""; public string ReportPath { get; private set; } = ""; public string ProgId { get; private set; } = "AutoCAD.Application.23.1"; public string DeviceName { get; private set; } = "PublishToWeb PNG.pc3"; public string MediaName { get; private set; } = ""; public double PaddingMm { get; private set; } = 5.0; public bool Visible { get; private set; } public bool CloseDocument { get; private set; } = true; public bool RemoveAnnotations { get; private set; } public bool IncludeAnnotationsInBounds { get; private set; } public double AnnotationConnectToleranceMm { get; private set; } = 2.0; public double MaxAnnotationExpansionMm { get; private set; } = 80.0; public double MaxAnnotationBoxSizeMm { get; private set; } = 220.0; public double ViewSeedToleranceMm { get; private set; } = 0.5; public int MaxCleanupSamples { get; private set; } = 100; public int HighlightColorIndex { get; private set; } = 1; public int HighlightLineweight { get; private set; } = 70; public double HighlightBoxPaddingMm { get; private set; } = 2.0; public List HighlightHandles { get; } = []; public List HighlightBoxes { get; } = []; public bool HasHighlights => HighlightHandles.Count > 0 || HighlightBoxes.Count > 0; public List Exports { get; } = []; public static Options Parse(string[] args) { var options = new Options(); for (var i = 0; i < args.Length; i++) { var arg = args[i]; string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException($"Missing value after {arg}"); switch (arg.ToLowerInvariant()) { case "--drawing-path": case "--source-dwg": options.DrawingPath = Next(); break; case "--output-dir": options.OutputDir = Next(); break; case "--working-dwg": case "--copy-dwg-to": options.WorkingDwgPath = Next(); break; case "--report": options.ReportPath = Next(); break; case "--prog-id": options.ProgId = Next(); break; case "--device": case "--plotter": options.DeviceName = Next(); break; case "--media": options.MediaName = Next(); break; case "--padding-mm": options.PaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--remove-annotations": case "--clean-annotations": options.RemoveAnnotations = true; break; case "--include-annotations-in-bounds": case "--expand-bounds-to-connected-annotations": options.IncludeAnnotationsInBounds = true; break; case "--annotation-connect-tolerance-mm": options.AnnotationConnectToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--max-annotation-expansion-mm": options.MaxAnnotationExpansionMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--max-annotation-box-size-mm": options.MaxAnnotationBoxSizeMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--view-seed-tolerance-mm": options.ViewSeedToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--max-cleanup-samples": options.MaxCleanupSamples = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--highlight-handles": options.HighlightHandles.AddRange(ParseHandles(Next())); break; case "--highlight-color-index": options.HighlightColorIndex = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--highlight-lineweight": options.HighlightLineweight = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--highlight-boxes-mm": options.HighlightBoxes.Add(ParseHighlightBox(Next(), options.HighlightBoxes.Count + 1)); break; case "--highlight-box-padding-mm": options.HighlightBoxPaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--visible": options.Visible = true; break; case "--no-close": options.CloseDocument = false; break; case "--view-bounds-mm": options.Exports.Add(ParseExport(Next(), options.Exports.Count + 1)); break; } } if (string.IsNullOrWhiteSpace(options.DrawingPath)) throw new ArgumentException("--drawing-path is required."); if (string.IsNullOrWhiteSpace(options.OutputDir)) options.OutputDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(options.DrawingPath)) ?? ".", "view-png"); return options; } static ExportRequest ParseExport(string value, int index) { var name = $"view-{index:00}"; var boundsText = value; var nameSeparator = value.IndexOf('=', StringComparison.Ordinal); if (nameSeparator > 0) { name = SanitizeFileName(value[..nameSeparator].Trim()); boundsText = value[(nameSeparator + 1)..]; } var parts = boundsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length != 4) throw new ArgumentException("--view-bounds-mm requires optionalName=minX,minY,maxX,maxY."); var bounds = new BoundsMm( double.Parse(parts[0], CultureInfo.InvariantCulture), double.Parse(parts[1], CultureInfo.InvariantCulture), double.Parse(parts[2], CultureInfo.InvariantCulture), double.Parse(parts[3], CultureInfo.InvariantCulture)); return new ExportRequest(name, bounds, name + ".png"); } static HighlightBoxRequest ParseHighlightBox(string value, int index) { var export = ParseExport(value, index); return new HighlightBoxRequest(export.Name, export.Bounds); } static string SanitizeFileName(string value) { var invalid = Path.GetInvalidFileNameChars().ToHashSet(); var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray(); var result = new string(chars).Trim(); return string.IsNullOrWhiteSpace(result) ? "view" : result; } static IEnumerable ParseHandles(string value) => value.Split(new[] { ',', ';', '|', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(item => !string.IsNullOrWhiteSpace(item)) .Distinct(StringComparer.OrdinalIgnoreCase); }