using System; using System.Collections.Generic; using System.IO; using System.Linq; using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.IO; using Xunit; namespace OpenNest.Tests.IO { public class SvgImportTests : IDisposable { private const double Precision = 0.002; private const string SquarePath = ""; private readonly string folder = Directory.CreateTempSubdirectory("svg-tests").FullName; public void Dispose() => Directory.Delete(folder, true); private string Write(string content) { var path = Path.Combine(folder, Guid.NewGuid() + ".svg"); File.WriteAllText(path, content); return path; } private static string Svg(string body, string attributes = "width='10in' height='10in' viewBox='0 0 10 10'") => $"{body}"; private CadImportResult Import(string content, CadImportOptions options = null) => CadImporter.Import(Write(content), options ?? new CadImportOptions()); private static (double Length, double Width) Size(CadImportResult result) => (result.Bounds.Length, result.Bounds.Width); [Theory] [InlineData("width='10in' height='10in' viewBox='0 0 100 100'", 1.0)] [InlineData("width='254mm' height='254mm' viewBox='0 0 10 10'", 10.0)] [InlineData("width='25.4cm' height='25.4cm' viewBox='0 0 10 10'", 10.0)] [InlineData("width='720pt' height='720pt' viewBox='0 0 10 10'", 10.0)] [InlineData("width='60pc' height='60pc' viewBox='0 0 10 10'", 10.0)] // Without a view box user units are CSS pixels, whatever unit the page is in. [InlineData("width='1in' height='1in'", 10.0 / 96)] public void DeclaredSizeGivesRealWorldScale(string attributes, double expected) { var (length, width) = Size(Import(Svg(SquarePath, attributes))); Assert.Equal(expected, length, 6); Assert.Equal(expected, width, 6); } [Fact] public void ConvertsToMillimeterDrawings() { var result = Import(Svg(SquarePath), new CadImportOptions { DrawingUnits = Units.Millimeters }); Assert.Equal(254, result.Bounds.Length, 6); } [Theory] [InlineData("viewBox='0 0 10 10'")] [InlineData("width='100%' height='100%' viewBox='0 0 10 10'")] [InlineData("width='100px' height='100px' viewBox='0 0 10 10'")] [InlineData("width='100' height='100' viewBox='0 0 10 10'")] [InlineData("")] public void UndeclaredSizeRefusesToGuess(string attributes) { var path = Write(Svg(SquarePath, attributes)); var error = Assert.Throws(() => CadImporter.Import(path)); Assert.Contains("real-world size", error.Message); Assert.Equal((10, 10), OpenNest.IO.Svg.UndeclaredSize(path)); } [Theory] [InlineData(96, 10.0 / 96)] [InlineData(72, 10.0 / 72)] [InlineData(1, 10.0)] public void ExplicitScaleAppliesToUndeclaredFiles(double unitsPerInch, double expected) { var result = Import( Svg(SquarePath, "viewBox='0 0 10 10'"), new CadImportOptions { SvgUserUnitsPerInch = unitsPerInch } ); Assert.Equal(expected, result.Bounds.Length, 6); } [Fact] public void DeclaredSizeBeatsExplicitScale() { var result = Import(Svg(SquarePath), new CadImportOptions { SvgUserUnitsPerInch = 96 }); Assert.Equal(10, result.Bounds.Length, 6); Assert.Null(OpenNest.IO.Svg.UndeclaredSize(Write(Svg(SquarePath)))); } [Fact] public void NonUniformPageFitsViewBoxUniformly() { var result = Import(Svg(SquarePath, "width='20in' height='10in' viewBox='0 0 10 10'")); Assert.Equal((10, 10), Size(result)); } [Fact] public void PreserveAspectRatioNoneStretches() { var result = Import(Svg( SquarePath, "width='20in' height='10in' viewBox='0 0 10 10' preserveAspectRatio='none'" )); Assert.Equal((20, 10), Size(result)); } [Fact] public void FlipsYSoTheDrawingIsNotMirrored() { var result = Import(Svg("")); // The wide edge is at the top of the page, so it is the high Y edge in OpenNest. var top = result.Entities.OfType().Single(l => System.Math.Abs(l.StartPoint.Y - l.EndPoint.Y) < 1e-9 ); Assert.Equal(result.Bounds.Top, top.StartPoint.Y, 6); } [Fact] public void BasicShapesImport() { var result = Import(Svg( "" + "" + "" + "" + "" )); Assert.Single(result.Entities.OfType()); Assert.Equal(1, result.Entities.OfType().Single().Radius, 9); Assert.Equal(10, result.Entities.OfType().Count()); } [Fact] public void RoundedRectKeepsItsCorners() { var result = Import(Svg("")); Assert.Equal(4, result.Entities.OfType().Count()); var corners = result.Entities.OfType().ToList(); Assert.Equal(4, corners.Count); Assert.All(corners, a => Assert.Equal(1, a.Radius, 6)); Assert.Equal((10, 6), Size(result)); AssertClosed(result.Entities); } [Fact] public void AttributeUnitsResolveAgainstCssPixels() { // Inside a 96-per-inch page, 1in is 96 user units: one real inch. var result = Import(Svg( "", "width='100in' height='100in' viewBox='0 0 9600 9600'" )); Assert.Equal(1, result.Entities.OfType().Single().Radius, 6); } [Theory] [InlineData("M0 0 10 0 10 10z", 3)] [InlineData("m0 0 10 0 0 10z", 3)] [InlineData("M0,0L10,0L10,10Z", 3)] [InlineData("M0 0h10v10h-10", 3)] [InlineData("M0-1.5.5.5", 1)] public void PathGrammar(string data, int lines) { Assert.Equal(lines, Import(Svg($"")).Entities.OfType().Count()); } [Fact] public void ImplicitLineAfterRelativeMoveIsRelative() { var result = Import(Svg("")); Assert.Equal((2, 2), Size(result)); } [Fact] public void CompactArcFlagsParse() { var result = Import(Svg("")); Assert.Equal(1, result.Entities.OfType().Single().Radius, 6); } [Theory] [InlineData("L1 1")] [InlineData("M0 0L1")] [InlineData("M0 0Q1 1")] [InlineData("M0 0A1 1 0 2 0 1 1")] [InlineData("M0 0Y1 1")] public void MalformedPathIsRejectedRatherThanPartlyImported(string data) { var path = Write(Svg($"")); Assert.Throws(() => CadImporter.Import(path)); } [Fact] public void CircularArcKeepsItsSweepDirection() { // Sweep 1 from (0,0) to (2,0) passes through y=-1 on the page, so y=+1 in OpenNest. var result = Import(Svg("")); var arc = result.Entities.OfType().Single(); Assert.Equal(1, arc.Radius, 6); Assert.Equal(1, result.Bounds.Top, 6); Assert.Equal(0, result.Bounds.Bottom, 6); } [Fact] public void LargeArcFlagTakesTheLongWay() { var result = Import(Svg("")); Assert.True(result.Entities.OfType().Single().SweepAngle() > System.Math.PI); } [Fact] public void TooSmallArcRadiusScalesUp() { var result = Import(Svg("")); Assert.Equal(5, result.Entities.OfType().Single().Radius, 6); } [Fact] public void TwoHalfArcsMakeAWholeHole() { var result = Import(Svg("")); Assert.Equal((10, 10), Size(result)); AssertArea(result, System.Math.PI * 25); } [Fact] public void IllustratorStyleBezierCircleFitsTheCircle() { const double k = 0.5522847498 * 5; var data = FormattableString.Invariant( $"M5 0C{5 + k} 0 10 {5 - k} 10 5S{5 + k} 10 5 10S0 {5 + k} 0 5S{5 - k} 0 5 0Z" ); var result = Import(Svg($"")); Assert.Equal(10, result.Bounds.Length, 2); AssertClosed(result.Entities); AssertArea(result, System.Math.PI * 25); } [Fact] public void QuadraticAndSmoothQuadraticStayOnTheCurve() { var result = Import(Svg("")); Assert.Equal(20, result.Bounds.Length, 3); Assert.Equal(10, result.Bounds.Width, 2); } [Fact] public void TransformsNestInOrder() { var result = Import(Svg( "" + "" )); Assert.Equal(102, result.Bounds.Left, 6); Assert.Equal(2, result.Bounds.Length, 6); } [Fact] public void RotateAboutPointKeepsThePoint() { var result = Import(Svg("")); Assert.Equal(0, result.Bounds.Left, 6); Assert.Equal(2, result.Bounds.Right, 6); } [Fact] public void UniformScaleKeepsACircle() { var result = Import(Svg("")); Assert.Equal(6, result.Entities.OfType().Single().Radius, 6); } [Fact] public void NonUniformScaleMakesACircleAnEllipse() { var result = Import(Svg("")); Assert.Empty(result.Entities.OfType()); Assert.Equal(20, result.Bounds.Length, 2); Assert.Equal(10, result.Bounds.Width, 2); AssertArea(result, System.Math.PI * 50); } [Fact] public void RotatedEllipseKeepsItsAxes() { var result = Import(Svg("")); Assert.Equal(6, result.Bounds.Length, 2); Assert.Equal(12, result.Bounds.Width, 2); } [Fact] public void EllipticalArcEndsWhereItsNeighborsStart() { var result = Import(Svg("")); AssertClosed(result.Entities); } [Fact] public void HiddenAndDefinitionContentIsIgnored() { var result = Import(Svg( "" + "" + "" + "" + "" + SquarePath )); Assert.Equal((10, 10), Size(result)); Assert.Empty(result.Warnings); } [Fact] public void UseCopiesTheTargetAtItsOffset() { var result = Import(Svg( "" + "" )); Assert.Equal(5, result.Bounds.Left, 6); Assert.Equal(12, result.Bounds.Right, 6); } [Fact] public void SelfReferencingUseDoesNotHang() { var result = Import(Svg("" + SquarePath)); Assert.Equal((10, 10), Size(result)); Assert.Contains(result.Warnings, w => w.Contains("")); } [Fact] public void UnsupportedContentIsReported() { var result = Import(Svg("hellox" + SquarePath)); Assert.Contains(result.Warnings, w => w.Contains("2 ")); Assert.Contains(result.Warnings, w => w.Contains("1 ")); } [Fact] public void LayersComeFromGroups() { var result = Import(Svg( "" + "" + "" + "" )); var names = result.Entities.Select(e => e.Layer.Name).Distinct().OrderBy(n => n); Assert.Equal(new[] { "One", "Three", "Two" }, names); Assert.All(result.Entities, e => Assert.True(e.Layer.IsVisible)); } [Fact] public void NonCutLayersAreDropped() { var result = Import(Svg( "" + "" + "" + "" + SquarePath )); Assert.Equal((10, 10), Size(result)); } [Fact] public void ImportedDrawingIsStableAcrossImports() { var path = Write(Svg(SquarePath)); var first = CadImporter.Import(path); var second = CadImporter.Import(path); Assert.Equal(first.Entities.Select(e => e.Id), second.Entities.Select(e => e.Id)); } [Fact] public void BuildsADrawingWithAProgram() { var drawing = CadImporter.ImportDrawing(Write(Svg(SquarePath))); Assert.NotEmpty(drawing.Program.Codes); Assert.Equal(100, drawing.Area, 6); Assert.Empty(drawing.SuppressedEntityIds); } [Fact] public void IllustratorEntityNamespacesParse() { var path = Write( "]>" + "" + SquarePath + "" ); Assert.Equal(10, CadImporter.Import(path).Bounds.Length, 6); } [Fact] public void EntityExpansionBombIsRejected() { var entities = ""; var previous = "a"; for (var i = 0; i < 9; i++) { var name = "e" + i; var refs = string.Concat(Enumerable.Repeat($"&{previous};", 10)); entities += $""; previous = name; } var path = Write($"&{previous};"); Assert.Throws(() => CadImporter.Import(path)); } [Fact] public void OtherXmlIsNotAnSvg() { var path = Write(""); Assert.Throws(() => CadImporter.Import(path)); } private static void AssertClosed(IEnumerable entities) { var shapes = ShapeBuilder.GetShapes(entities.ToList()); Assert.Single(shapes); Assert.True(shapes[0].IsClosed()); } private static void AssertArea(CadImportResult result, double expected) { var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null); Assert.Equal(expected, drawing.Area, 0.1); } } }