diff --git a/OpenNest.IO.Tests/DrawingPackageFixture.cs b/OpenNest.IO.Tests/DrawingPackageFixture.cs
new file mode 100644
index 00000000..d2b990bf
--- /dev/null
+++ b/OpenNest.IO.Tests/DrawingPackageFixture.cs
@@ -0,0 +1,48 @@
+using OpenNest.Bending;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// A code-built, neutrally named library drawing: a rounded outside, a hole, a scribe
+/// mark, a bend, source entities with one suppressed, and a nonzero source offset.
+///
+internal static class DrawingPackageFixture
+{
+ public static Drawing Build()
+ {
+ var entities = new List
+ {
+ new Line(0, 0, 10, 0),
+ new Line(10, 0, 10, 4),
+ new Arc(8, 4, 2, 0, System.Math.PI / 2),
+ new Line(8, 6, 0, 6),
+ new Line(0, 6, 0, 0),
+ new Circle(3, 3, 1),
+ new Line(5, 2, 7, 2) { Layer = SpecialLayers.Scribe },
+ new Line(20, 20, 21, 21),
+ };
+ var drawing = new Drawing("Synthetic bracket", ConvertGeometry.ToProgram(entities.Take(7).ToList()))
+ {
+ Customer = "Synthetic customer",
+ Material = new Material("Synthetic Steel") { Grade = "Grade A", Density = 0.284 },
+ Priority = 3,
+ SourceEntities = entities,
+ };
+ drawing.SuppressedEntityIds.Add(entities[7].Id);
+ drawing.Source.Path = "synthetic.dxf";
+ drawing.Source.Offset = new Vector(3.5, -2.25);
+ drawing.Bends.Add(new Bend
+ {
+ StartPoint = new Vector(0, 3),
+ EndPoint = new Vector(10, 3),
+ Direction = BendDirection.Up,
+ Angle = 90,
+ Radius = 0.125,
+ NoteText = "UP 90",
+ });
+ drawing.Quantity.Required = 12;
+ return drawing;
+ }
+}
diff --git a/OpenNest.IO.Tests/DrawingPackageTests.cs b/OpenNest.IO.Tests/DrawingPackageTests.cs
new file mode 100644
index 00000000..28430835
--- /dev/null
+++ b/OpenNest.IO.Tests/DrawingPackageTests.cs
@@ -0,0 +1,174 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+public class DrawingPackageTests
+{
+ [Fact]
+ public void Package_RoundTripsTheDrawingAndUnits()
+ {
+ var drawing = DrawingPackageFixture.Build();
+
+ var contents = DrawingPackage.Read(DrawingPackage.Write(drawing, Units.Millimeters));
+
+ Assert.Empty(contents.Warnings);
+ Assert.Equal(Units.Millimeters, contents.Units);
+ var copy = contents.Drawing;
+ Assert.NotSame(drawing, copy);
+ Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
+ Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
+ Assert.Equal(drawing.Name, copy.Name);
+ Assert.Equal(drawing.Customer, copy.Customer);
+ Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
+ (copy.Material.Name, copy.Material.Grade, copy.Material.Density));
+ Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
+ Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
+ copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
+ Assert.True(drawing.SuppressedEntityIds.SetEquals(copy.SuppressedEntityIds));
+ var bend = Assert.Single(copy.Bends);
+ Assert.Equal((new Vector(0, 3), new Vector(10, 3), "UP 90"), (bend.StartPoint, bend.EndPoint, bend.NoteText));
+ // A library drawing has no nest demand of its own.
+ Assert.Equal(0, copy.Quantity.Required);
+ }
+
+ [Fact]
+ public void Package_DoesNotChangeTheDrawing()
+ {
+ var drawing = DrawingPackageFixture.Build();
+ var program = NestWriter.GetProgramText(drawing.Program);
+
+ DrawingPackage.Write(drawing, Units.Inches);
+
+ Assert.Equal(12, drawing.Quantity.Required);
+ Assert.Equal(program, NestWriter.GetProgramText(drawing.Program));
+ Assert.Equal("Synthetic bracket", drawing.Name);
+ }
+
+ [Fact]
+ public void Package_IsCanonical_SameContentSameBytes()
+ {
+ // Source entity ids are content, so the same drawing instance is written twice.
+ var drawing = DrawingPackageFixture.Build();
+ var first = DrawingPackage.Write(drawing, Units.Inches);
+ drawing.Quantity.Required = 40;
+ Thread.Sleep(2100); // ZIP times have two-second resolution; prove they are not recorded.
+
+ var second = DrawingPackage.Write(drawing, Units.Inches);
+
+ Assert.Equal(Sha(first), Sha(second));
+ Assert.Equal(first, DrawingPackage.Write(DrawingPackage.Read(first).Drawing, Units.Inches));
+ using var archive = new ZipArchive(new MemoryStream(first));
+ Assert.All(archive.Entries, e => Assert.Equal(new DateTime(1980, 1, 1), e.LastWriteTime.DateTime));
+ Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("bestfits/", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Package_ChangedContentOrUnits_ChangesBytes()
+ {
+ var drawing = DrawingPackageFixture.Build();
+ var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
+
+ Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Millimeters)));
+ drawing.Customer = "Other customer";
+ var renamed = Sha(DrawingPackage.Write(drawing, Units.Inches));
+ Assert.NotEqual(baseline, renamed);
+ drawing.Bends[0].EndPoint = new Vector(10, 3.5);
+ Assert.NotEqual(renamed, Sha(DrawingPackage.Write(drawing, Units.Inches)));
+ }
+
+ [Fact]
+ public void Write_RejectsUndefinedUnits()
+ {
+ Assert.Throws(() => DrawingPackage.Write(DrawingPackageFixture.Build(), (Units)9));
+ }
+
+ [Fact]
+ public void Read_RefusesNestsThatAreNotOneDrawingWithoutPlates()
+ {
+ var empty = new Nest("empty");
+ var two = new Nest("two");
+ two.Drawings.Add(DrawingPackageFixture.Build());
+ var second = DrawingPackageFixture.Build();
+ second.Name = "Second";
+ two.Drawings.Add(second);
+ // The writer omits an empty plate, so the plate carries a part.
+ var withPlate = new Nest("plate");
+ var placed = DrawingPackageFixture.Build();
+ withPlate.Drawings.Add(placed);
+ var plate = new Plate(48, 96);
+ plate.Parts.Add(new Part(placed));
+ withPlate.Plates.Add(plate);
+
+ Assert.Throws(() => DrawingPackage.Read(Archive(empty)));
+ Assert.Throws(() => DrawingPackage.Read(Archive(two)));
+ Assert.Throws(() => DrawingPackage.Read(Archive(withPlate)));
+ }
+
+ [Theory]
+ [InlineData("Feet")]
+ [InlineData("1")]
+ [InlineData("Inches,Millimeters")]
+ [InlineData("")]
+ public void Read_RefusesUnsupportedUnits(string units)
+ {
+ var package = EditNestJson(DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches),
+ root => root["units"] = units);
+
+ Assert.Throws(() => DrawingPackage.Read(package));
+ }
+
+ [Fact]
+ public void Read_RefusesMalformedArchives()
+ {
+ var valid = DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches);
+ var noNestJson = Rewrite(valid, name => name == "nest.json" ? null : name);
+ var badJson = Rewrite(valid, name => name, (name, bytes) => name == "nest.json" ? Encoding.UTF8.GetBytes("{") : bytes);
+
+ Assert.Throws(() => DrawingPackage.Read(Encoding.UTF8.GetBytes("not a zip")));
+ Assert.Throws(() => DrawingPackage.Read(noNestJson));
+ Assert.Throws(() => DrawingPackage.Read(badJson));
+ }
+
+ private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
+
+ private static byte[] Archive(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ new NestWriter(nest).Write(stream);
+ return stream.ToArray();
+ }
+
+ private static byte[] EditNestJson(byte[] package, Action edit) =>
+ Rewrite(package, name => name, (name, bytes) =>
+ {
+ if (name != "nest.json") return bytes;
+ var root = JsonNode.Parse(Encoding.UTF8.GetString(bytes))!.AsObject();
+ edit(root);
+ return Encoding.UTF8.GetBytes(root.ToJsonString());
+ });
+
+ private static byte[] Rewrite(byte[] package, Func rename, Func? transform = null)
+ {
+ using var source = new ZipArchive(new MemoryStream(package));
+ using var output = new MemoryStream();
+ using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var entry in source.Entries)
+ {
+ var name = rename(entry.FullName);
+ if (name is null) continue;
+ using var input = entry.Open();
+ using var copy = new MemoryStream();
+ input.CopyTo(copy);
+ var bytes = transform is null ? copy.ToArray() : transform(entry.FullName, copy.ToArray());
+ using var stream = target.CreateEntry(name).Open();
+ stream.Write(bytes);
+ }
+ }
+ return output.ToArray();
+ }
+}
diff --git a/OpenNest.IO.Tests/ZeroPlateNestTests.cs b/OpenNest.IO.Tests/ZeroPlateNestTests.cs
new file mode 100644
index 00000000..fc9bc352
--- /dev/null
+++ b/OpenNest.IO.Tests/ZeroPlateNestTests.cs
@@ -0,0 +1,45 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// Characterizes a single-drawing, zero-plate .nest archive through the unchanged
+/// writer and reader, which drawing packages rely on.
+///
+public class ZeroPlateNestTests
+{
+ [Fact]
+ public void SingleDrawingWithoutPlates_RoundTripsTheDrawing()
+ {
+ var drawing = DrawingPackageFixture.Build();
+ var nest = new Nest(drawing.Name) { Units = Units.Millimeters };
+ nest.Drawings.Add(drawing);
+ using var stream = new MemoryStream();
+
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ var reader = new NestReader(stream);
+ var restored = reader.Read();
+
+ Assert.Empty(reader.Warnings);
+ Assert.Empty(restored.Plates);
+ Assert.Equal(Units.Millimeters, restored.Units);
+ var copy = Assert.Single(restored.Drawings);
+ Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
+ Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
+ Assert.Equal(drawing.Name, copy.Name);
+ Assert.Equal(drawing.Customer, copy.Customer);
+ Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
+ (copy.Material.Name, copy.Material.Grade, copy.Material.Density));
+ Assert.Equal(drawing.Priority, copy.Priority);
+ Assert.Equal(drawing.Quantity.Required, copy.Quantity.Required);
+ Assert.Equal(drawing.Source.Path, copy.Source.Path);
+ Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
+ Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
+ copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
+ Assert.True(drawing.SuppressedEntityIds.SetEquals(copy.SuppressedEntityIds));
+ var bend = Assert.Single(copy.Bends);
+ Assert.Equal((new Vector(0, 3), new Vector(10, 3), (double?)90, (double?)0.125, "UP 90"),
+ (bend.StartPoint, bend.EndPoint, bend.Angle, bend.Radius, bend.NoteText));
+ }
+}
diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs
new file mode 100644
index 00000000..51e70101
--- /dev/null
+++ b/OpenNest.IO/DrawingPackage.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace OpenNest.IO
+{
+ ///
+ /// A drawing library package: a .nest archive holding exactly one drawing and no
+ /// plates, written and read by the ordinary /.
+ /// Packages are canonical, so the same drawing content always has the same bytes and
+ /// SHA-256: archive timestamps and nest dates are fixed, the nest-specific required
+ /// quantity is zero, and derived best-fit caches are left out.
+ ///
+ public static class DrawingPackage
+ {
+ // The earliest time a ZIP entry can record.
+ private static readonly DateTimeOffset EntryTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ private const string FixedDate = "2000-01-01T00:00:00.0000000";
+
+ /// Packages without changing it.
+ public static byte[] Write(Drawing drawing, Units units)
+ {
+ ArgumentNullException.ThrowIfNull(drawing);
+ if (!Enum.IsDefined(units))
+ throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
+
+ var nest = new Nest(drawing.Name ?? "") { Units = units };
+ nest.Drawings.Add(drawing);
+ using var written = new MemoryStream();
+ new NestWriter(nest).Write(written);
+ return Canonicalize(written.ToArray());
+ }
+
+ ///
+ /// Reads a package. Throws unless it is a .nest
+ /// archive with exactly one drawing, no plates and supported units.
+ ///
+ public static DrawingPackageContents Read(byte[] package)
+ {
+ ArgumentNullException.ThrowIfNull(package);
+ try
+ {
+ var units = CheckShape(package);
+ var reader = new NestReader(new MemoryStream(package, writable: false));
+ var nest = reader.Read();
+ if (nest.Plates.Count != 0 || nest.Drawings.Count != 1)
+ throw new InvalidDataException("A drawing package must hold exactly one drawing and no plates.");
+ return new DrawingPackageContents(nest.Drawings.Single(), units, reader.Warnings.ToList());
+ }
+ catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is ArgumentException
+ || (ex is IOException && ex is not InvalidDataException))
+ {
+ throw new InvalidDataException("The drawing package is not a readable .nest archive.", ex);
+ }
+ }
+
+ // Checks the archive's own declaration before the lenient nest reader sees it: that
+ // reader defaults unknown units and drops empty plates, which a package must not hide.
+ private static Units CheckShape(byte[] package)
+ {
+ using var archive = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
+ var entry = archive.GetEntry("nest.json")
+ ?? throw new InvalidDataException("The drawing package has no nest.json.");
+ var root = JsonNode.Parse(ReadText(entry)) as JsonObject
+ ?? throw new InvalidDataException("The drawing package's nest.json is not an object.");
+
+ if (root["plates"] is JsonArray plates && plates.Count != 0)
+ throw new InvalidDataException("A drawing package must not contain plates.");
+ if (root["drawings"] is not JsonArray drawings || drawings.Count != 1)
+ throw new InvalidDataException("A drawing package must contain exactly one drawing.");
+
+ var text = root["units"] is JsonValue value && value.TryGetValue(out var s) ? s : "";
+ if (text.Length == 0 || !text.All(char.IsAsciiLetter)
+ || !Enum.TryParse(text, ignoreCase: true, out var units) || !Enum.IsDefined(units))
+ throw new InvalidDataException($"The drawing package has unsupported units '{text}'.");
+ return units;
+ }
+
+ private static byte[] Canonicalize(byte[] archive)
+ {
+ using var source = new ZipArchive(new MemoryStream(archive, writable: false), ZipArchiveMode.Read);
+ using var output = new MemoryStream();
+ using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var entry in source.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal))
+ {
+ if (entry.FullName.StartsWith("bestfits/", StringComparison.Ordinal))
+ continue;
+
+ byte[] bytes;
+ if (entry.FullName == "nest.json")
+ {
+ bytes = Encoding.UTF8.GetBytes(CanonicalNestJson(ReadText(entry)));
+ }
+ else
+ {
+ using var input = entry.Open();
+ using var copy = new MemoryStream();
+ input.CopyTo(copy);
+ bytes = copy.ToArray();
+ }
+
+ var written = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
+ written.LastWriteTime = EntryTimestamp;
+ using var stream = written.Open();
+ stream.Write(bytes, 0, bytes.Length);
+ }
+ }
+
+ return output.ToArray();
+ }
+
+ private static string CanonicalNestJson(string json)
+ {
+ var root = JsonNode.Parse(json)!.AsObject();
+ root["dateCreated"] = FixedDate;
+ root["dateLastModified"] = FixedDate;
+ if (root["drawings"] is JsonArray drawings)
+ {
+ foreach (var drawing in drawings.OfType())
+ if (drawing["quantity"] is JsonObject quantity)
+ quantity["required"] = 0;
+ }
+
+ return root.ToJsonString(NestFormat.JsonOptions);
+ }
+
+ // StreamReader honours the byte-order mark the nest writer emits.
+ private static string ReadText(ZipArchiveEntry entry)
+ {
+ using var stream = entry.Open();
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+ }
+
+ /// The drawing read from a package, the units it was drawn in, and reader warnings.
+ public sealed class DrawingPackageContents
+ {
+ public DrawingPackageContents(Drawing drawing, Units units, IReadOnlyList warnings)
+ {
+ Drawing = drawing;
+ Units = units;
+ Warnings = warnings;
+ }
+
+ public Drawing Drawing { get; }
+
+ public Units Units { get; }
+
+ public IReadOnlyList Warnings { get; }
+ }
+}