using System; using System.Collections.Generic; using System.Drawing; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.IO { /// /// Converts an SVG file's path geometry into entities: centerlines only, since stroke /// widths and fills do not describe a cut. Anything else is reported as a warning. /// internal sealed class SvgReader { private const double CssPixelsPerInch = 96; private const double MillimetersPerInch = 25.4; private const int MinCurveSegments = 8; private const int MaxCurveSegments = 512; // Bounded so a hostile file's entities cannot expand without limit. private const long MaxCharactersFromEntities = 1_000_000; private static readonly Regex Length = new Regex( @"^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z%]*)\s*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1) ); private static readonly Regex HiddenStyle = new Regex( @"(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1) ); private static readonly HashSet IgnoredElements = new HashSet { "clipPath", "defs", "desc", "filter", "linearGradient", "marker", "mask", "metadata", "pattern", "radialGradient", "script", "style", "symbol", "title", }; private readonly XElement root; private readonly double tolerance; private readonly XNamespace svgNamespace; private readonly Dictionary elementsById; private readonly Dictionary layers = new Dictionary( StringComparer.OrdinalIgnoreCase ); private readonly Dictionary skipped = new Dictionary(); private readonly List lines = new List(); private readonly List arcs = new List(); private readonly List circles = new List(); private readonly HashSet activeUses = new HashSet(); private SvgReader(XElement root, double tolerance) { this.root = root; this.tolerance = tolerance; svgNamespace = root.Name.Namespace; elementsById = root .DescendantsAndSelf() .Where(e => e.Attribute("id") != null) .GroupBy(e => e.Attribute("id").Value) .ToDictionary(g => g.Key, g => g.First()); } public static DxfImportResult Read(string path, CadImportOptions options) { var root = Load(path); var perInch = DrawingUnitsPerInch(options.DrawingUnits); var scale = DeclaredScale(root) ?? ExplicitScale(options.SvgUserUnitsPerInch) ?? throw new InvalidDataException( $"\"{Path.GetFileName(path)}\" does not state its real-world size, " + "so its scale would be a guess. Choose how many of its units make an inch." ); var tolerance = options.DrawingUnits == Units.Millimeters ? 0.025 : 0.001; var reader = new SvgReader(root, tolerance); var transform = SvgTransform.Scale(scale.X * perInch, -scale.Y * perInch); return reader.Convert(transform); } public static (double Width, double Height)? UndeclaredSize(string path) { var root = Load(path); if (DeclaredScale(root) != null) return null; var entities = new SvgReader(root, 0.001) .Convert(SvgTransform.Scale(1, -1)) .Entities; if (entities.Count == 0) return (0, 0); var bounds = entities.GetBoundingBox(); return (bounds.Length, bounds.Width); } private static XElement Load(string path) { // Older Illustrator files declare their namespaces as DTD entities, so a DTD has to // parse; it just may not fetch anything or expand without limit. var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = null, MaxCharactersFromEntities = MaxCharactersFromEntities, }; try { using var reader = XmlReader.Create(path, settings); var root = XDocument.Load(reader).Root; if (root?.Name.LocalName != "svg") throw new InvalidDataException($"\"{Path.GetFileName(path)}\" is not an SVG file."); return root; } catch (XmlException ex) { throw new InvalidDataException( $"\"{Path.GetFileName(path)}\" is not valid SVG: {ex.Message}", ex ); } } private static double DrawingUnitsPerInch(Units units) => units == Units.Millimeters ? MillimetersPerInch : 1; private static Vector? ExplicitScale(double? userUnitsPerInch) { if (userUnitsPerInch == null) return null; if (!(userUnitsPerInch > 0) || double.IsInfinity(userUnitsPerInch.Value)) throw new ArgumentOutOfRangeException( nameof(userUnitsPerInch), userUnitsPerInch, "SVG units per inch must be positive." ); return new Vector(1 / userUnitsPerInch.Value, 1 / userUnitsPerInch.Value); } /// /// Inches per user unit, from the root's width and height. Only absolute units count: /// "px" is left out because applications disagree on what it means (a CSS pixel is /// 1/96 in, but Illustrator writes px at 72 per inch). /// private static Vector? DeclaredScale(XElement root) { var width = PhysicalInches(root.Attribute("width")?.Value); var height = PhysicalInches(root.Attribute("height")?.Value); if (width == null && height == null) return null; var viewBox = ParseViewBox(root.Attribute("viewBox")?.Value); if (viewBox == null) return new Vector(1 / CssPixelsPerInch, 1 / CssPixelsPerInch); var scaleX = width / viewBox.Value.Width; var scaleY = height / viewBox.Value.Height; var aspect = root.Attribute("preserveAspectRatio")?.Value.Trim() ?? string.Empty; if (scaleX != null && scaleY != null && aspect.StartsWith("none", StringComparison.Ordinal)) return new Vector(scaleX.Value, scaleY.Value); // The default aspect handling fits the view box uniformly inside the page. var scale = System.Math.Min(scaleX ?? double.MaxValue, scaleY ?? double.MaxValue); return new Vector(scale, scale); } private static double? PhysicalInches(string text) { if (text == null) return null; var match = Length.Match(text); if (!match.Success) return null; var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); if (value <= 0) return null; return match.Groups[2].Value switch { "in" => value, "cm" => value / 2.54, "mm" => value / MillimetersPerInch, "Q" => value / (MillimetersPerInch * 4), "pt" => value / 72, "pc" => value / 6, _ => null, }; } private static (double Width, double Height)? ParseViewBox(string text) { if (string.IsNullOrWhiteSpace(text)) return null; var values = ParseNumbers(text); return values.Length == 4 && values[2] > 0 && values[3] > 0 ? (values[2], values[3]) : null; } internal static double[] ParseNumbers(string text) { var scanner = new PathScanner(text); var values = new List(); while (scanner.HasNumber) values.Add(scanner.ReadNumber()); return values.ToArray(); } private DxfImportResult Convert(SvgTransform transform) { WalkChildren(root, transform, Layer.Default); var raw = new List(); raw.AddRange(circles.CloneAll()); raw.AddRange(lines.CloneAll()); raw.AddRange(arcs.CloneAll()); GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(arcs); GeometryOptimizer.Deduplicate(circles); GeometryOptimizer.Deduplicate(circles, arcs); var entities = new List(); entities.AddRange(circles); entities.AddRange(lines); entities.AddRange(arcs); return new DxfImportResult { Entities = entities, RawEntities = raw, Warnings = skipped .OrderBy(s => s.Key, StringComparer.Ordinal) .Select(s => $"Skipped {s.Value} <{s.Key}> element(s); convert them to paths to import them.") .ToList(), }; } private void WalkChildren(XElement parent, SvgTransform transform, Layer layer) { foreach (var child in parent.Elements()) Walk(child, transform, layer); } private void Walk(XElement element, SvgTransform parent, Layer parentLayer) { if (element.Name.Namespace != svgNamespace) return; var name = element.Name.LocalName; if (IgnoredElements.Contains(name) || IsHidden(element)) return; var transform = parent.Then(SvgTransform.Parse(element.Attribute("transform")?.Value)); switch (name) { case "g": case "a": case "switch": var layer = LayerOf(element, parentLayer); if (!IsSkippedLayer(layer.Name)) WalkChildren(element, transform, layer); break; case "use": WalkUse(element, transform, parentLayer); break; case "path": ReadPath(element.Attribute("d")?.Value, transform, parentLayer); break; case "rect": ReadShape(element, RectPath(element), transform, parentLayer); break; case "line": ReadShape(element, LinePath(element), transform, parentLayer); break; case "polyline": ReadPath(PointsPath(element, close: false), transform, parentLayer); break; case "polygon": ReadPath(PointsPath(element, close: true), transform, parentLayer); break; case "circle": ReadEllipse(element, Measure(element, "r"), Measure(element, "r"), transform, parentLayer); break; case "ellipse": ReadEllipse(element, Measure(element, "rx"), Measure(element, "ry"), transform, parentLayer); break; default: Skip(name); break; } } private void Skip(string name) { skipped.TryGetValue(name, out var count); skipped[name] = count + 1; } private static bool IsHidden(XElement element) { var style = element.Attribute("style")?.Value; return element.Attribute("display")?.Value.Trim() == "none" || element.Attribute("visibility")?.Value.Trim() is "hidden" or "collapse" || (style != null && HiddenStyle.IsMatch(style)); } /// /// A group names a layer through OpenNest's data-layer, an Inkscape layer label, or an /// id on a top-level group (how Illustrator writes its layers). /// private Layer LayerOf(XElement group, Layer parent) { var name = group.Attribute("data-layer")?.Value; name ??= group.Attributes().FirstOrDefault(a => a.Name.LocalName == "groupmode") != null ? group.Attributes().FirstOrDefault(a => a.Name.LocalName == "label")?.Value : null; if (name == null && group.Parent == root) name = group.Attribute("id")?.Value; if (string.IsNullOrWhiteSpace(name)) return parent; if (!layers.TryGetValue(name, out var layer)) { layer = new Layer(name) { Color = Color.White, IsVisible = true }; layers.Add(name, layer); } return layer; } // Rapids and the plate outline are what OpenNest's own export adds around the part. private static bool IsSkippedLayer(string name) => Dxf.IsNonCutLayer(name) || string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(name, Svg.PlateLayerName, StringComparison.OrdinalIgnoreCase); private void WalkUse(XElement use, SvgTransform transform, Layer layer) { var href = use.Attributes().FirstOrDefault(a => a.Name.LocalName == "href")?.Value; if (href == null || !href.StartsWith('#') || !elementsById.TryGetValue(href[1..], out var target)) return; if (target.Name.LocalName == "symbol" || !activeUses.Add(use)) { Skip("use"); return; } var placed = transform.Then(SvgTransform.Translate( Measure(use, "x") ?? 0, Measure(use, "y") ?? 0 )); Walk(target, placed, layer); activeUses.Remove(use); } /// The attribute in user units, or null when missing or relative to the viewport. private static double? Measure(XElement element, string attribute) { var text = element.Attribute(attribute)?.Value; if (text == null) return null; var match = Length.Match(text); if (!match.Success) return null; var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); return match.Groups[2].Value switch { "" or "px" => value, "in" => value * CssPixelsPerInch, "cm" => value * CssPixelsPerInch / 2.54, "mm" => value * CssPixelsPerInch / MillimetersPerInch, "Q" => value * CssPixelsPerInch / (MillimetersPerInch * 4), "pt" => value * CssPixelsPerInch / 72, "pc" => value * CssPixelsPerInch / 6, _ => null, }; } private static bool HasRelativeSize(XElement element, params string[] attributes) => attributes.Any(a => element.Attribute(a) != null && Measure(element, a) == null); private void ReadShape(XElement element, string data, SvgTransform transform, Layer layer) { if (data == null) Skip(element.Name.LocalName); else ReadPath(data, transform, layer); } private static string RectPath(XElement rect) { if (HasRelativeSize(rect, "x", "y", "width", "height", "rx", "ry")) return null; var x = Measure(rect, "x") ?? 0; var y = Measure(rect, "y") ?? 0; var width = Measure(rect, "width") ?? 0; var height = Measure(rect, "height") ?? 0; if (width <= 0 || height <= 0) return string.Empty; var rx = Measure(rect, "rx") ?? Measure(rect, "ry") ?? 0; var ry = Measure(rect, "ry") ?? rx; rx = System.Math.Min(rx, width / 2); ry = System.Math.Min(ry, height / 2); var data = new StringBuilder(); void Add(FormattableString text) => data.Append(FormattableString.Invariant(text)); if (rx <= 0 || ry <= 0) { Add($"M{x:R} {y:R}H{x + width:R}V{y + height:R}H{x:R}Z"); return data.ToString(); } var corner = FormattableString.Invariant($"A{rx:R} {ry:R} 0 0 1 "); Add($"M{x + rx:R} {y:R}H{x + width - rx:R}"); data.Append(corner); Add($"{x + width:R} {y + ry:R}V{y + height - ry:R}"); data.Append(corner); Add($"{x + width - rx:R} {y + height:R}H{x + rx:R}"); data.Append(corner); Add($"{x:R} {y + height - ry:R}V{y + ry:R}"); data.Append(corner); Add($"{x + rx:R} {y:R}Z"); return data.ToString(); } private static string LinePath(XElement line) { if (HasRelativeSize(line, "x1", "y1", "x2", "y2")) return null; var x1 = Measure(line, "x1") ?? 0; var y1 = Measure(line, "y1") ?? 0; var x2 = Measure(line, "x2") ?? 0; var y2 = Measure(line, "y2") ?? 0; return FormattableString.Invariant($"M{x1:R} {y1:R}L{x2:R} {y2:R}"); } private static string PointsPath(XElement element, bool close) { var points = element.Attribute("points")?.Value; if (string.IsNullOrWhiteSpace(points)) return null; // Point lists use the same number grammar as path data. return "M" + points + (close ? "Z" : string.Empty); } private void ReadEllipse( XElement element, double? rx, double? ry, SvgTransform transform, Layer layer ) { if (rx == null || ry == null || HasRelativeSize(element, "cx", "cy")) { Skip(element.Name.LocalName); return; } if (rx <= 0 || ry <= 0) return; var center = new Vector(Measure(element, "cx") ?? 0, Measure(element, "cy") ?? 0); if (rx.Value.IsEqualTo(ry.Value) && transform.IsSimilarity) AddCircle(transform.Apply(center), rx.Value * transform.ScaleFactor, layer); else AddEllipticalArc(center, rx.Value, ry.Value, 0, 0, Angle.TwoPI, transform, layer); } private void AddCircle(Vector center, double radius, Layer layer) => circles.Add(Tag(new Circle(center, radius), layer)); private void ReadPath(string data, SvgTransform t, Layer layer) { if (string.IsNullOrWhiteSpace(data)) return; var scanner = new PathScanner(data); var current = Vector.Zero; var start = Vector.Zero; var control = Vector.Zero; var previous = '\0'; var command = '\0'; while (true) { if (!scanner.HasCommand && !scanner.HasNumber) break; if (scanner.HasCommand) command = scanner.ReadCommand(); else if (command is '\0' or 'Z' or 'z') throw scanner.Error(); else if (command == 'M') command = 'L'; else if (command == 'm') command = 'l'; if (previous == '\0' && command is not ('M' or 'm')) throw scanner.Error(); var relative = char.IsLower(command); var origin = relative ? current : Vector.Zero; Vector Next() => scanner.ReadPoint() + origin; switch (char.ToUpperInvariant(command)) { case 'M': current = start = Next(); break; case 'Z': AddLine(current, start, t, layer); current = start; break; case 'L': { var end = Next(); AddLine(current, end, t, layer); current = end; break; } case 'H': { var end = new Vector(scanner.ReadNumber() + origin.X, current.Y); AddLine(current, end, t, layer); current = end; break; } case 'V': { var end = new Vector(current.X, scanner.ReadNumber() + origin.Y); AddLine(current, end, t, layer); current = end; break; } case 'C': { var c1 = Next(); control = Next(); var end = Next(); AddCubic(current, c1, control, end, t, layer); current = end; break; } case 'S': { var c1 = previous is 'C' or 'c' or 'S' or 's' ? current * 2 - control : current; control = Next(); var end = Next(); AddCubic(current, c1, control, end, t, layer); current = end; break; } case 'Q': { control = Next(); var end = Next(); AddQuadratic(current, control, end, t, layer); current = end; break; } case 'T': { control = previous is 'Q' or 'q' or 'T' or 't' ? current * 2 - control : current; var end = Next(); AddQuadratic(current, control, end, t, layer); current = end; break; } case 'A': { var rx = System.Math.Abs(scanner.ReadNumber()); var ry = System.Math.Abs(scanner.ReadNumber()); var rotation = scanner.ReadNumber() * System.Math.PI / 180; var large = scanner.ReadFlag(); var sweep = scanner.ReadFlag(); var end = Next(); AddSvgArc(current, rx, ry, rotation, large, sweep, end, t, layer); current = end; break; } default: throw scanner.Error(); } previous = command; } } private void AddLine(Vector from, Vector to, SvgTransform t, Layer layer) { var start = t.Apply(from); var end = t.Apply(to); if (start.DistanceTo(end) >= Tolerance.Epsilon) lines.Add(Tag(new Line(start, end), layer)); } private void AddQuadratic(Vector p0, Vector c, Vector p1, SvgTransform t, Layer layer) => AddCubic(p0, p0 + (c - p0) * (2.0 / 3), p1 + (c - p1) * (2.0 / 3), p1, t, layer); /// /// Samples the curve within half the tolerance and fits arcs and lines through the /// samples within the other half, so the result stays within the tolerance. /// private void AddCubic(Vector p0, Vector c1, Vector c2, Vector p1, SvgTransform t, Layer layer) { var a = t.Apply(p0); var b = t.Apply(c1); var c = t.Apply(c2); var d = t.Apply(p1); var bend = System.Math.Max(Length2(a - b * 2 + c), Length2(b - c * 2 + d)); if (bend < Tolerance.Epsilon) { AddLine(p0, p1, t, layer); return; } // A cubic's chord error over n segments is at most 6 * bend / (8 * n^2). var segments = (int)System.Math.Ceiling(System.Math.Sqrt(6 * bend / (8 * (tolerance / 2)))); segments = System.Math.Clamp(segments, MinCurveSegments, MaxCurveSegments); var points = new List(segments + 1) { a }; for (var i = 1; i < segments; i++) { var u = (double)i / segments; var v = 1 - u; points.Add( a * (v * v * v) + b * (3 * v * v * u) + c * (3 * v * u * u) + d * (u * u * u) ); } points.Add(d); AddFitted(SplineConverter.Convert(points, false, tolerance / 2), layer); } private static double Length2(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y); /// The SVG endpoint-to-center arc conversion (SVG 2 appendix B.2.4). private void AddSvgArc( Vector from, double rx, double ry, double rotation, bool large, bool sweep, Vector to, SvgTransform t, Layer layer ) { if (from.DistanceTo(to) < Tolerance.Epsilon) return; if (rx < Tolerance.Epsilon || ry < Tolerance.Epsilon) { AddLine(from, to, t, layer); return; } var cos = System.Math.Cos(rotation); var sin = System.Math.Sin(rotation); var halfX = (from.X - to.X) / 2; var halfY = (from.Y - to.Y) / 2; var x = cos * halfX + sin * halfY; var y = -sin * halfX + cos * halfY; // Radii too small to reach both endpoints scale up until they just do. var fit = x * x / (rx * rx) + y * y / (ry * ry); if (fit > 1) { rx *= System.Math.Sqrt(fit); ry *= System.Math.Sqrt(fit); } var numerator = rx * rx * ry * ry - rx * rx * y * y - ry * ry * x * x; var denominator = rx * rx * y * y + ry * ry * x * x; var factor = System.Math.Sqrt(System.Math.Max(0, numerator / denominator)); if (large == sweep) factor = -factor; var centerX = factor * rx * y / ry; var centerY = -factor * ry * x / rx; var center = new Vector( cos * centerX - sin * centerY + (from.X + to.X) / 2, sin * centerX + cos * centerY + (from.Y + to.Y) / 2 ); var startAngle = System.Math.Atan2((y - centerY) / ry, (x - centerX) / rx); var endAngle = System.Math.Atan2((-y - centerY) / ry, (-x - centerX) / rx); var delta = endAngle - startAngle; if (sweep && delta < 0) delta += Angle.TwoPI; else if (!sweep && delta > 0) delta -= Angle.TwoPI; AddEllipticalArc(center, rx, ry, rotation, startAngle, delta, t, layer); } /// /// Adds part of an ellipse after the transform, which can change its axes: the output /// axes are where the transformed ellipse is widest and narrowest, then the parameter /// is shifted to them and mirrored when the transform flipped the ellipse's direction. /// private void AddEllipticalArc( Vector center, double rx, double ry, double rotation, double start, double delta, SvgTransform t, Layer layer ) { var cos = System.Math.Cos(rotation); var sin = System.Math.Sin(rotation); var axisX = t.ApplyLinear(new Vector(rx * cos, rx * sin)); var axisY = t.ApplyLinear(new Vector(-ry * sin, ry * cos)); var offset = 0.5 * System.Math.Atan2( 2 * (axisX.X * axisY.X + axisX.Y * axisY.Y), axisX.X * axisX.X + axisX.Y * axisX.Y - axisY.X * axisY.X - axisY.Y * axisY.Y ); var major = axisX * System.Math.Cos(offset) + axisY * System.Math.Sin(offset); var minor = axisY * System.Math.Cos(offset) - axisX * System.Math.Sin(offset); var semiMajor = Length2(major); var semiMinor = Length2(minor); if (semiMajor < Tolerance.Epsilon || semiMinor < Tolerance.Epsilon) return; var direction = major.X * minor.Y - major.Y * minor.X >= 0 ? 1 : -1; var from = direction * (start - offset); var to = direction * (start + delta - offset); AddFitted( EllipseConverter.Convert( t.Apply(center), semiMajor, semiMinor, System.Math.Atan2(major.Y, major.X), System.Math.Min(from, to), System.Math.Max(from, to), tolerance ), layer ); } private void AddFitted(List entities, Layer layer) { foreach (var entity in entities) { if (entity is Line line) lines.Add(Tag(line, layer)); else if (entity is Arc arc) arcs.Add(Tag(arc, layer)); } } private static T Tag(T entity, Layer layer) where T : Entity { entity.Layer = layer; entity.Color = layer.Color; return entity; } /// Reads SVG's number grammar, where separators are optional ("1-2.5.5"). private sealed class PathScanner { private readonly string text; private int position; public PathScanner(string text) { this.text = text; } public bool HasCommand { get { SkipSeparators(); return position < text.Length && char.IsAsciiLetter(text[position]); } } public bool HasNumber { get { SkipSeparators(); return position < text.Length && (char.IsAsciiDigit(text[position]) || text[position] is '+' or '-' or '.'); } } public char ReadCommand() => text[position++]; public InvalidDataException Error() => new InvalidDataException($"Malformed SVG path data at position {position}."); public Vector ReadPoint() { var x = ReadNumber(); var y = ReadNumber(); return new Vector(x, y); } public bool ReadFlag() { SkipSeparators(); if (position >= text.Length || text[position] is not ('0' or '1')) throw Error(); return text[position++] == '1'; } public double ReadNumber() { SkipSeparators(); var begin = position; if (position < text.Length && text[position] is '+' or '-') position++; var digits = SkipDigits(); if (position < text.Length && text[position] == '.') { position++; digits += SkipDigits(); } if (digits == 0) throw Error(); if (position < text.Length && text[position] is 'e' or 'E') { var mark = position++; if (position < text.Length && text[position] is '+' or '-') position++; if (SkipDigits() == 0) position = mark; } return double.Parse( text.AsSpan(begin, position - begin), NumberStyles.Float, CultureInfo.InvariantCulture ); } private int SkipDigits() { var begin = position; while (position < text.Length && char.IsAsciiDigit(text[position])) position++; return position - begin; } private void SkipSeparators() { while ( position < text.Length && (char.IsWhiteSpace(text[position]) || text[position] == ',') ) position++; } } } }