feat(reporting): add one-plate nest report PDF library slice
Add OpenNest.Reporting (net8.0, PDFsharp-MigraDoc 6.2.4) with a detached report snapshot and a PDF writer for an empty/demand-only job or a single plate layout: Letter portrait summary with vector part thumbnails and a landscape plate page with a vector sheet diagram, both with Page X of Y. - Snapshot uses reference identity for document-local R### IDs, recounts nested quantities from placements x copies with checked wide integers, excludes cutoffs from accounting, and retains no live domain objects. - Geometry keeps Cut/Display material paths, drops rapid/scribe/lead paths, preserves native arcs, holes and intentional tab gaps, and rejects malformed programs with plate/part identification. - Open contours and cutoffs are stroked per contour so PDFsharp cannot join a tab gap to the next contour. - Bundled DejaVu Sans via a custom resolver (including MigraDoc's error font); unsupported text fails with the field and code point. - Layouts beyond this slice (multiple plates, page overflow, labels that do not fit) fail with NotSupportedException before the destination is replaced; output is rendered to a temporary sibling and moved atomically.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
internal static class AtomicReportFile
|
||||
{
|
||||
internal static void Write(string destination, Action<Stream> render)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
|
||||
ArgumentNullException.ThrowIfNull(render);
|
||||
var target = Path.GetFullPath(destination);
|
||||
var temporary = Path.Combine(Path.GetDirectoryName(target)!,
|
||||
"." + Path.GetFileName(target) + "." + Guid.NewGuid().ToString("N") + ".tmp");
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
render(stream);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// The sibling stays on the same filesystem. Never truncate/delete the target first.
|
||||
File.Move(temporary, target, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
DejaVu Sans 2.37 (regular and bold)
|
||||
Source: https://dejavu-fonts.github.io/
|
||||
Distributed unmodified from Ubuntu fonts-dejavu-core 2.37-8.
|
||||
|
||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
License: bitstream-vera
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.Globalization;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.DocumentObjectModel.Tables;
|
||||
using MigraDoc.Rendering;
|
||||
using PdfSharp.Drawing;
|
||||
using PdfSharp.Pdf;
|
||||
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a detached <see cref="NestReportSnapshot"/> to PDF: MigraDoc owns text, tables and
|
||||
/// pagination; PDFsharp draws vector thumbnails and the sheet diagram into reserved layout areas.
|
||||
/// </summary>
|
||||
public static class NestPdfWriter
|
||||
{
|
||||
// Slice 1 layout limits. Anything beyond them fails before the destination is touched.
|
||||
private const double Margin = 36;
|
||||
private const double BottomMargin = 50;
|
||||
private const double ThumbnailWidth = 54;
|
||||
private const double ThumbnailHeight = 36;
|
||||
private const double DiagramHeight = 320;
|
||||
private const double DiagramInset = 5;
|
||||
private const double LabelFontSize = 7;
|
||||
private const double LabelPadding = 1;
|
||||
|
||||
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
|
||||
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
|
||||
|
||||
public static void Write(NestReportSnapshot snapshot, string destination) =>
|
||||
Write(snapshot, destination, null);
|
||||
|
||||
/// <param name="wrapOutput">Test seam for injecting late write failures.</param>
|
||||
internal static void Write(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
|
||||
ValidateText(snapshot);
|
||||
if (snapshot.Plates.Length > 1)
|
||||
throw new NotSupportedException($"A report with {snapshot.Plates.Length} plate layouts is not supported by this report slice; it supports at most one layout.");
|
||||
|
||||
ReportFonts.Initialize();
|
||||
var document = new Document();
|
||||
document.Info.Title = snapshot.Name;
|
||||
var normal = document.Styles[StyleNames.Normal]
|
||||
?? throw new InvalidOperationException("MigraDoc normal style is unavailable.");
|
||||
normal.Font.Name = ReportFonts.Family;
|
||||
normal.Font.Size = 9;
|
||||
var diagrams = new List<Table>();
|
||||
AddSummary(document, snapshot);
|
||||
foreach (var plate in snapshot.Plates)
|
||||
diagrams.Add(AddPlate(document, snapshot, plate));
|
||||
|
||||
var renderer = new PdfDocumentRenderer { Document = document };
|
||||
renderer.RenderDocument();
|
||||
using var pdf = renderer.PdfDocument;
|
||||
// PDFsharp 6.2.4 disallows reading the page count after Save.
|
||||
var pageCount = pdf.PageCount;
|
||||
var areas = LocateDiagrams(renderer, pageCount, diagrams);
|
||||
for (var index = 0; index < areas.Count; index++)
|
||||
{
|
||||
var (page, area) = areas[index];
|
||||
if (page != index + 2)
|
||||
throw new NotSupportedException("A summary longer than one page is not supported by this report slice.");
|
||||
using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]);
|
||||
DrawPlate(gfx, snapshot, snapshot.Plates[index], area);
|
||||
}
|
||||
if (pageCount != 1 + snapshot.Plates.Length)
|
||||
throw new NotSupportedException(snapshot.Plates.Length == 0
|
||||
? "A summary longer than one page is not supported by this report slice."
|
||||
: "A plate page longer than one page is not supported by this report slice.");
|
||||
|
||||
pdf.Info.Title = snapshot.Name;
|
||||
pdf.Info.Creator = "OpenNest";
|
||||
pdf.Info.CreationDate = snapshot.GeneratedAt.UtcDateTime;
|
||||
AtomicReportFile.Write(destination, stream => pdf.Save(wrapOutput?.Invoke(stream) ?? stream, false));
|
||||
}
|
||||
|
||||
private static void ValidateText(NestReportSnapshot snapshot)
|
||||
{
|
||||
ReportFonts.ValidateText(snapshot.Name, "Nest name");
|
||||
ReportFonts.ValidateText(snapshot.Customer, "Customer");
|
||||
ReportFonts.ValidateText(snapshot.Notes, "Notes");
|
||||
ReportFonts.ValidateText(snapshot.Material, "Material");
|
||||
ReportFonts.ValidateText(snapshot.Grade, "Material grade");
|
||||
foreach (var drawing in snapshot.Drawings)
|
||||
ReportFonts.ValidateText(drawing.Name, $"Drawing {drawing.Id} name");
|
||||
}
|
||||
|
||||
private static void AddSummary(Document document, NestReportSnapshot snapshot)
|
||||
{
|
||||
var section = AddSection(document, Orientation.Portrait);
|
||||
var title = section.AddParagraph("Nest Report");
|
||||
title.Format.Font.Size = 14;
|
||||
title.Format.Font.Bold = true;
|
||||
title.Format.SpaceAfter = Unit.FromPoint(6);
|
||||
|
||||
var info = AddTable(section, 130, 410);
|
||||
info.Borders.Visible = false;
|
||||
AddInfoRow(info, "Nest:", snapshot.Name);
|
||||
if (snapshot.Customer.Length > 0)
|
||||
AddInfoRow(info, "Customer:", snapshot.Customer);
|
||||
if (snapshot.Notes.Length > 0)
|
||||
AddInfoRow(info, "Notes:", snapshot.Notes);
|
||||
AddInfoRow(info, "Material:", Material(snapshot));
|
||||
AddInfoRow(info, "Thickness:", Thickness(snapshot));
|
||||
AddInfoRow(info, "Units:", snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)");
|
||||
AddInfoRow(info, "Generated:", snapshot.GeneratedAt.ToString("yyyy-MM-dd HH:mm zzz", Invariant));
|
||||
AddInfoRow(info, "Distinct layouts:", snapshot.Plates.Length.ToString(Invariant));
|
||||
AddInfoRow(info, "Total physical sheets:", snapshot.TotalSheets.ToString(Invariant));
|
||||
|
||||
AddHeading(section, "Plates");
|
||||
if (snapshot.Plates.Length == 0)
|
||||
section.AddParagraph("No plates in this job.");
|
||||
else
|
||||
{
|
||||
var plates = AddTable(section, 50, 150, 70, 80, 80);
|
||||
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization");
|
||||
foreach (var plate in snapshot.Plates)
|
||||
AddRow(plates, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
|
||||
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
|
||||
}
|
||||
|
||||
AddHeading(section, "Parts");
|
||||
if (snapshot.Drawings.Length == 0)
|
||||
{
|
||||
section.AddParagraph("No parts in this job.");
|
||||
return;
|
||||
}
|
||||
var parts = AddTable(section, 36, 62, 170, 50, 50, 52, 40, 80);
|
||||
AddHeader(parts, "ID", "Part", "Drawing", "Required", "Nested", "Shortage", "Extra", "Plates");
|
||||
foreach (var drawing in snapshot.Drawings)
|
||||
{
|
||||
var row = AddRow(parts, drawing.Id, null, drawing.Name, drawing.Required.ToString(Invariant),
|
||||
drawing.Nested.ToString(Invariant), drawing.Shortage.ToString(Invariant),
|
||||
drawing.Extra.ToString(Invariant), drawing.Plates.Length == 0 ? "-" : string.Join(", ", drawing.Plates));
|
||||
var image = row.Cells[1].AddParagraph().AddImage(Thumbnail(drawing.Geometry));
|
||||
image.Width = Unit.FromPoint(ThumbnailWidth);
|
||||
image.Height = Unit.FromPoint(ThumbnailHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private static Table AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate)
|
||||
{
|
||||
var section = AddSection(document, Orientation.Landscape);
|
||||
var title = section.AddParagraph($"Plate {plate.Number} of {snapshot.Plates.Length}");
|
||||
title.Format.Font.Size = 13;
|
||||
title.Format.Font.Bold = true;
|
||||
|
||||
var info = AddTable(section, 80, 280, 90, 270);
|
||||
info.Borders.Visible = false;
|
||||
AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate));
|
||||
AddInfoRow(info, "Material:", Material(snapshot), "Copies:", plate.Copies.ToString(Invariant));
|
||||
AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:",
|
||||
$"{Number(plate.PartSpacing)} {snapshot.Units}");
|
||||
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:",
|
||||
Percent(plate.Utilization) + " (net part area / full sheet)");
|
||||
|
||||
// An empty fixed-height row reserves the vector diagram area in MigraDoc's flow.
|
||||
var diagram = AddTable(section, 720);
|
||||
diagram.Borders.Visible = false;
|
||||
diagram.TopPadding = diagram.BottomPadding = Unit.Zero;
|
||||
var reserved = diagram.AddRow();
|
||||
reserved.HeightRule = RowHeightRule.Exactly;
|
||||
reserved.Height = Unit.FromPoint(DiagramHeight);
|
||||
diagram.Format.SpaceBefore = Unit.FromPoint(4);
|
||||
|
||||
var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs.");
|
||||
legend.Format.Font.Size = 7.5;
|
||||
legend.Format.SpaceBefore = Unit.FromPoint(4);
|
||||
var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
|
||||
approval.Format.Font.Size = 7.5;
|
||||
approval.Format.SpaceAfter = Unit.FromPoint(4);
|
||||
|
||||
var names = snapshot.Drawings.ToDictionary(drawing => drawing.Id, drawing => drawing.Name, StringComparer.Ordinal);
|
||||
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
|
||||
var parts = AddTable(section, 50, 330, 100, 140);
|
||||
AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)");
|
||||
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
|
||||
{
|
||||
var count = perSheet[drawing.Id];
|
||||
AddRow(parts, drawing.Id, names[drawing.Id], count.ToString(Invariant),
|
||||
checked(count * plate.Copies).ToString(Invariant));
|
||||
}
|
||||
return diagram;
|
||||
}
|
||||
|
||||
private static List<(int Page, XRect Area)> LocateDiagrams(PdfDocumentRenderer renderer, int pageCount, List<Table> diagrams)
|
||||
{
|
||||
var found = new (int Page, XRect Area)?[diagrams.Count];
|
||||
for (var page = 1; page <= pageCount; page++)
|
||||
{
|
||||
var renderInfos = renderer.DocumentRenderer?.GetRenderInfoFromPage(page)
|
||||
?? throw new InvalidOperationException("MigraDoc render information is unavailable.");
|
||||
foreach (var info in renderInfos)
|
||||
{
|
||||
var index = diagrams.FindIndex(table => ReferenceEquals(table, info.DocumentObject));
|
||||
if (index < 0)
|
||||
continue;
|
||||
if (found[index] != null)
|
||||
throw new NotSupportedException($"A diagram for plate {index + 1} split across pages is not supported by this report slice.");
|
||||
var area = info.LayoutInfo.ContentArea;
|
||||
found[index] = (page, new XRect(area.X.Point, area.Y.Point, area.Width.Point, area.Height.Point));
|
||||
}
|
||||
}
|
||||
return found.Select((item, index) => item
|
||||
?? throw new NotSupportedException($"Plate {index + 1}: a missing diagram area is not supported by this report slice.")).ToList();
|
||||
}
|
||||
|
||||
private static void DrawPlate(XGraphics gfx, NestReportSnapshot snapshot, ReportPlate plate, XRect area)
|
||||
{
|
||||
var inner = new XRect(area.X + DiagramInset, area.Y + DiagramInset,
|
||||
area.Width - 2 * DiagramInset, DiagramHeight - 2 * DiagramInset);
|
||||
var fit = Fit.Create(plate.Bounds, inner);
|
||||
var font = new XFont(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold);
|
||||
|
||||
// Validate every label before drawing: dense-label callouts are a later slice.
|
||||
var labels = new List<(string Id, XRect Box)>();
|
||||
for (var index = 0; index < plate.Parts.Length; index++)
|
||||
{
|
||||
var part = plate.Parts[index];
|
||||
var bounds = fit.Rect(part.Geometry.Bounds);
|
||||
var size = gfx.MeasureString(part.ReportId, font);
|
||||
var width = size.Width + 2 * LabelPadding;
|
||||
var height = size.Height + 2 * LabelPadding;
|
||||
if (width > bounds.Width || height > bounds.Height)
|
||||
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({part.ReportId}): a label that does not fit inside its part at {LabelFontSize} pt is not supported by this report slice.");
|
||||
var box = new XRect(bounds.X + (bounds.Width - width) / 2, bounds.Y + (bounds.Height - height) / 2, width, height);
|
||||
foreach (var other in labels)
|
||||
{
|
||||
if (box.IntersectsWith(other.Box))
|
||||
throw new NotSupportedException($"Plate {plate.Number}: overlapping labels {other.Id} and {part.ReportId} are not supported by this report slice.");
|
||||
}
|
||||
labels.Add((part.ReportId, box));
|
||||
}
|
||||
|
||||
gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
|
||||
var outline = new XPen(XColors.Black, 0.6);
|
||||
foreach (var part in plate.Parts)
|
||||
DrawGeometry(gfx, part.Geometry, fit, outline);
|
||||
var cutoff = new XPen(XColors.Black, 0.75) { DashStyle = XDashStyle.Dash };
|
||||
foreach (var geometry in plate.Cutoffs)
|
||||
StrokeContours(gfx, geometry, fit, cutoff);
|
||||
foreach (var (id, box) in labels)
|
||||
{
|
||||
gfx.DrawRectangle(XBrushes.White, box);
|
||||
gfx.DrawString(id, font, XBrushes.Black, box, XStringFormats.Center);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Thumbnail(ReportGeometry geometry)
|
||||
{
|
||||
using var document = new PdfDocument();
|
||||
var page = document.AddPage();
|
||||
page.Width = XUnit.FromPoint(ThumbnailWidth);
|
||||
page.Height = XUnit.FromPoint(ThumbnailHeight);
|
||||
using (var gfx = XGraphics.FromPdfPage(page))
|
||||
{
|
||||
var fit = Fit.Create(geometry.Bounds, new XRect(2, 2, ThumbnailWidth - 4, ThumbnailHeight - 4));
|
||||
DrawGeometry(gfx, geometry, fit, new XPen(XColors.Black, 0.5));
|
||||
}
|
||||
using var stream = new MemoryStream();
|
||||
document.Save(stream, false);
|
||||
// MigraDoc embeds a PDF image source as a vector form XObject, not a raster image.
|
||||
return "base64:" + Convert.ToBase64String(stream.ToArray());
|
||||
}
|
||||
|
||||
private static void DrawGeometry(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
|
||||
{
|
||||
if (geometry.StrokeOnly)
|
||||
{
|
||||
StrokeContours(gfx, geometry, fit, pen);
|
||||
return;
|
||||
}
|
||||
// Every contour is closed here, so CloseFigure separates the subpaths.
|
||||
var path = new XGraphicsPath();
|
||||
foreach (var contour in geometry.Contours)
|
||||
{
|
||||
AddContour(path, contour, fit);
|
||||
path.CloseFigure();
|
||||
}
|
||||
path.FillMode = XFillMode.Alternate; // Even-odd: holes stay unfilled.
|
||||
gfx.DrawPath(pen, new XSolidBrush(Fill), path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDFsharp's StartFigure does not emit a move-to after an open figure, which would draw a
|
||||
/// false segment across a tab gap to the next contour. Stroke each contour as its own path.
|
||||
/// </summary>
|
||||
private static void StrokeContours(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
|
||||
{
|
||||
foreach (var contour in geometry.Contours)
|
||||
{
|
||||
var path = new XGraphicsPath();
|
||||
AddContour(path, contour, fit);
|
||||
if (contour.Closed)
|
||||
path.CloseFigure();
|
||||
gfx.DrawPath(pen, path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddContour(XGraphicsPath path, ReportContour contour, Fit fit)
|
||||
{
|
||||
foreach (var segment in contour.Segments)
|
||||
{
|
||||
if (segment.Center == null)
|
||||
{
|
||||
path.AddLine(fit.Point(segment.Start), fit.Point(segment.End));
|
||||
continue;
|
||||
}
|
||||
var center = fit.Point(segment.Center);
|
||||
var radius = segment.Radius * fit.Scale;
|
||||
var box = new XRect(center.X - radius, center.Y - radius, 2 * radius, 2 * radius);
|
||||
// Page Y points down, so model angles and sweeps change sign.
|
||||
path.AddArc(box, -Degrees(segment.StartAngle), -Degrees(segment.SweepAngle));
|
||||
}
|
||||
}
|
||||
|
||||
private static double Degrees(double radians) => radians * 180 / System.Math.PI;
|
||||
|
||||
private static Section AddSection(Document document, Orientation orientation)
|
||||
{
|
||||
var section = document.AddSection();
|
||||
var setup = section.PageSetup;
|
||||
setup.PageFormat = PageFormat.Letter;
|
||||
setup.Orientation = orientation;
|
||||
setup.TopMargin = setup.LeftMargin = setup.RightMargin = Unit.FromPoint(Margin);
|
||||
setup.BottomMargin = Unit.FromPoint(BottomMargin);
|
||||
setup.FooterDistance = Unit.FromPoint(20);
|
||||
var footer = section.Footers.Primary.AddParagraph("Page ");
|
||||
footer.AddPageField();
|
||||
footer.AddText(" of ");
|
||||
footer.AddNumPagesField();
|
||||
footer.AddText(" | Diagram fitted to page; not a dimensioned cutting drawing.");
|
||||
footer.Format.Font.Size = 7.5;
|
||||
return section;
|
||||
}
|
||||
|
||||
private static void AddHeading(Section section, string text)
|
||||
{
|
||||
var heading = section.AddParagraph(text);
|
||||
heading.Format.Font.Bold = true;
|
||||
heading.Format.Font.Size = 11;
|
||||
heading.Format.SpaceBefore = Unit.FromPoint(10);
|
||||
heading.Format.SpaceAfter = Unit.FromPoint(4);
|
||||
}
|
||||
|
||||
private static Table AddTable(Section section, params double[] widths)
|
||||
{
|
||||
var table = section.AddTable();
|
||||
table.Borders.Width = 0.5;
|
||||
table.Borders.Color = Colors.Gray;
|
||||
table.LeftPadding = table.RightPadding = Unit.FromPoint(3);
|
||||
table.TopPadding = table.BottomPadding = Unit.FromPoint(1.5);
|
||||
foreach (var width in widths)
|
||||
table.AddColumn(Unit.FromPoint(width));
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void AddHeader(Table table, params string[] cells)
|
||||
{
|
||||
var row = AddRow(table, cells);
|
||||
row.HeadingFormat = true;
|
||||
row.Format.Font.Bold = true;
|
||||
row.Shading.Color = Colors.LightGray;
|
||||
}
|
||||
|
||||
private static void AddInfoRow(Table table, params string[] cells)
|
||||
{
|
||||
var row = AddRow(table, cells);
|
||||
for (var index = 0; index < cells.Length; index += 2)
|
||||
row.Cells[index].Format.Font.Bold = true;
|
||||
}
|
||||
|
||||
/// <summary>Adds text cells; a null cell is left empty for non-text content.</summary>
|
||||
private static Row AddRow(Table table, params string?[] cells)
|
||||
{
|
||||
var row = table.AddRow();
|
||||
row.VerticalAlignment = VerticalAlignment.Center;
|
||||
for (var index = 0; index < cells.Length; index++)
|
||||
{
|
||||
if (cells[index] == null)
|
||||
continue;
|
||||
var paragraph = row.Cells[index].AddParagraph();
|
||||
var lines = cells[index]!.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\t', ' ').Split('\n');
|
||||
for (var line = 0; line < lines.Length; line++)
|
||||
{
|
||||
if (line > 0)
|
||||
paragraph.AddLineBreak();
|
||||
paragraph.AddText(lines[line]);
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string Material(NestReportSnapshot snapshot)
|
||||
{
|
||||
var material = $"{snapshot.Material} {snapshot.Grade}".Trim();
|
||||
return material.Length == 0 ? "Not specified" : material;
|
||||
}
|
||||
|
||||
// Zero thickness means "unset" in a new nest; never present it as a genuine value.
|
||||
private static string Thickness(NestReportSnapshot snapshot) =>
|
||||
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
|
||||
|
||||
private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) =>
|
||||
$"{Number(plate.Bounds.Length)} x {Number(plate.Bounds.Width)} {snapshot.Units}";
|
||||
|
||||
private static string Number(double value) => value.ToString("0.####", Invariant);
|
||||
|
||||
private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%";
|
||||
|
||||
/// <summary>One aspect-preserving model-to-page transform, centered, with the Y axis flipped.</summary>
|
||||
private readonly record struct Fit(ReportBounds Bounds, double Scale, double X, double Y)
|
||||
{
|
||||
public static Fit Create(ReportBounds bounds, XRect area)
|
||||
{
|
||||
var scales = new List<double>(2);
|
||||
if (bounds.Length > 0)
|
||||
scales.Add(area.Width / bounds.Length);
|
||||
if (bounds.Width > 0)
|
||||
scales.Add(area.Height / bounds.Width);
|
||||
var scale = scales.Count == 0 ? 1 : scales.Min();
|
||||
if (!double.IsFinite(scale) || scale <= 0)
|
||||
throw new InvalidOperationException("Report geometry cannot be fitted to the page.");
|
||||
return new Fit(bounds, scale,
|
||||
area.X + (area.Width - bounds.Length * scale) / 2,
|
||||
area.Y + (area.Height - bounds.Width * scale) / 2);
|
||||
}
|
||||
|
||||
public XPoint Point(ReportPoint point) =>
|
||||
new(X + (point.X - Bounds.Left) * Scale, Y + (Bounds.Top - point.Y) * Scale);
|
||||
|
||||
public XRect Rect(ReportBounds box)
|
||||
{
|
||||
var low = Point(new ReportPoint(box.Left, box.Top));
|
||||
return new XRect(low.X, low.Y, box.Length * Scale, box.Width * Scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
public static class NestReportBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Capture synchronously while the caller keeps the nest stable. No quantity refresh,
|
||||
/// domain cloning, or event-wired temporary plates are used.
|
||||
/// </summary>
|
||||
public static NestReportSnapshot Capture(Nest nest, DateTimeOffset generatedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var ordered = new List<DrawingCount>();
|
||||
var byReference = new Dictionary<Drawing, DrawingCount>(ReferenceEqualityComparer.Instance);
|
||||
var plates = ImmutableArray.CreateBuilder<ReportPlate>();
|
||||
|
||||
DrawingCount Register(Drawing drawing)
|
||||
{
|
||||
if (byReference.TryGetValue(drawing, out var existing))
|
||||
return existing;
|
||||
var item = new DrawingCount(drawing, "R" + (ordered.Count + 1).ToString("D3", CultureInfo.InvariantCulture));
|
||||
byReference.Add(drawing, item);
|
||||
ordered.Add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
for (var index = 0; index < nest.Plates.Count; index++)
|
||||
{
|
||||
var number = index + 1;
|
||||
var plate = nest.Plates[index];
|
||||
if (plate == null)
|
||||
throw new InvalidOperationException($"Plate {number}: missing plate.");
|
||||
if (!PositiveFinite(plate.Size.Length) || !PositiveFinite(plate.Size.Width)
|
||||
|| !PositiveFinite(plate.Area()))
|
||||
throw new InvalidOperationException($"Plate {number}: sheet dimensions must be finite and positive.");
|
||||
if (plate.Quantity <= 0)
|
||||
throw new InvalidOperationException($"Plate {number}: copies must be a positive integer.");
|
||||
if (!double.IsFinite(plate.PartSpacing) || plate.PartSpacing < 0)
|
||||
throw new InvalidOperationException($"Plate {number}: part spacing must be finite and nonnegative.");
|
||||
|
||||
var box = plate.BoundingBox(false);
|
||||
var bounds = new ReportBounds(box.Left, box.Bottom, box.Right, box.Top);
|
||||
if (!double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Bottom)
|
||||
|| !double.IsFinite(bounds.Right) || !double.IsFinite(bounds.Top))
|
||||
throw new InvalidOperationException($"Plate {number}: sheet bounds are not finite.");
|
||||
var parts = ImmutableArray.CreateBuilder<ReportPlacement>();
|
||||
var cutoffs = ImmutableArray.CreateBuilder<ReportGeometry>();
|
||||
var perSheet = new Dictionary<DrawingCount, long>();
|
||||
var area = 0.0;
|
||||
for (var partIndex = 0; partIndex < plate.Parts.Count; partIndex++)
|
||||
{
|
||||
var part = plate.Parts[partIndex];
|
||||
var context = $"Plate {number}, part {partIndex + 1} ('{part?.BaseDrawing?.Name}')";
|
||||
if (part?.BaseDrawing == null)
|
||||
throw new InvalidOperationException($"{context}: missing part or drawing reference.");
|
||||
var geometry = NestReportGeometry.Capture(part.Program, part.Location, context);
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
{
|
||||
cutoffs.Add(geometry);
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = Register(part.BaseDrawing);
|
||||
parts.Add(new ReportPlacement(item.Id, geometry));
|
||||
perSheet.TryGetValue(item, out var count);
|
||||
perSheet[item] = checked(count + 1);
|
||||
var partArea = part.BaseDrawing.Area;
|
||||
if (!double.IsFinite(partArea) || partArea < 0)
|
||||
throw new InvalidOperationException($"{context}: net part area is invalid.");
|
||||
area += partArea;
|
||||
}
|
||||
|
||||
foreach (var (item, count) in perSheet)
|
||||
{
|
||||
item.Nested = checked(item.Nested + checked(count * plate.Quantity));
|
||||
item.Plates.Add(number);
|
||||
}
|
||||
var utilization = area / plate.Area();
|
||||
if (!double.IsFinite(utilization))
|
||||
throw new InvalidOperationException($"Plate {number}: utilization is not finite.");
|
||||
plates.Add(new ReportPlate(number, bounds, plate.Quantity, plate.PartSpacing,
|
||||
utilization, parts.ToImmutable(), cutoffs.ToImmutable()));
|
||||
}
|
||||
|
||||
// OrderBy is stable, so the original enumeration breaks equal name/path ties.
|
||||
foreach (var drawing in nest.Drawings
|
||||
.Where(drawing => !drawing.IsCutOff)
|
||||
.OrderBy(drawing => drawing.Name ?? "", StringComparer.Ordinal)
|
||||
.ThenBy(drawing => drawing.Source?.Path ?? "", StringComparer.Ordinal))
|
||||
Register(drawing);
|
||||
|
||||
var drawings = ImmutableArray.CreateBuilder<ReportDrawing>();
|
||||
foreach (var item in ordered)
|
||||
{
|
||||
var drawing = item.Drawing;
|
||||
var context = $"Drawing {item.Id} ('{drawing.Name}')";
|
||||
var required = (long)drawing.Quantity.Required;
|
||||
if (required < 0)
|
||||
throw new InvalidOperationException($"{context}: required quantity cannot be negative.");
|
||||
var geometry = NestReportGeometry.Capture(drawing.Program, new Vector(), context);
|
||||
drawings.Add(new ReportDrawing(item.Id, drawing.Name ?? "", required, item.Nested,
|
||||
System.Math.Max(checked(required - item.Nested), 0),
|
||||
System.Math.Max(checked(item.Nested - required), 0), item.Plates.ToImmutableArray(), geometry));
|
||||
}
|
||||
|
||||
var units = nest.Units switch
|
||||
{
|
||||
Units.Inches => "in",
|
||||
Units.Millimeters => "mm",
|
||||
_ => throw new InvalidOperationException("Nest: unsupported units."),
|
||||
};
|
||||
if (!double.IsFinite(nest.Thickness) || nest.Thickness < 0)
|
||||
throw new InvalidOperationException("Nest: thickness must be finite and nonnegative.");
|
||||
return new NestReportSnapshot(nest.Name ?? "", nest.Customer ?? "", nest.Notes ?? "",
|
||||
nest.Material?.Name ?? "", nest.Material?.Grade ?? "", nest.Thickness, units, generatedAt,
|
||||
drawings.ToImmutable(), plates.ToImmutable());
|
||||
}
|
||||
|
||||
private static bool PositiveFinite(double value) => double.IsFinite(value) && value > 0;
|
||||
|
||||
private sealed class DrawingCount(Drawing drawing, string id)
|
||||
{
|
||||
public Drawing Drawing { get; } = drawing;
|
||||
public string Id { get; } = id;
|
||||
public long Nested { get; set; }
|
||||
public List<int> Plates { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Collections.Immutable;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
internal static class NestReportGeometry
|
||||
{
|
||||
internal static ReportGeometry Capture(Program? program, Vector location, string context)
|
||||
{
|
||||
ValidatePoint(location, context);
|
||||
var current = new Vector();
|
||||
// ConvertProgram recurses without cycle/null guards and silently skips unknown codes.
|
||||
// Validate first; never clone through SubProgramCall's rotating setters.
|
||||
ValidateProgram(program, ref current, new HashSet<Program>(ReferenceEqualityComparer.Instance), context);
|
||||
var entities = ConvertProgram.ToGeometry(program!);
|
||||
var contours = ImmutableArray.CreateBuilder<ReportContour>();
|
||||
var segments = ImmutableArray.CreateBuilder<ReportSegment>();
|
||||
var left = double.PositiveInfinity;
|
||||
var bottom = double.PositiveInfinity;
|
||||
var right = double.NegativeInfinity;
|
||||
var top = double.NegativeInfinity;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (segments.Count == 0)
|
||||
return;
|
||||
var closed = SamePoint(segments[0].Start, segments[^1].End);
|
||||
contours.Add(new ReportContour(segments.ToImmutable(), closed));
|
||||
segments.Clear();
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (!SpecialLayers.IsMaterial(entity.Layer)
|
||||
|| entity.Layer == SpecialLayers.Leadin || entity.Layer == SpecialLayers.Leadout)
|
||||
{
|
||||
Flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
ReportSegment segment;
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
if (line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y)
|
||||
continue;
|
||||
segment = new ReportSegment(Point(line.StartPoint, location, context),
|
||||
Point(line.EndPoint, location, context), null, 0, 0, 0);
|
||||
break;
|
||||
case Arc arc:
|
||||
segment = new ReportSegment(Point(arc.StartPoint(), location, context),
|
||||
Point(arc.EndPoint(), location, context), Point(arc.Center, location, context),
|
||||
arc.Radius, arc.StartAngle, arc.SweepAngle() * (arc.IsReversed ? -1 : 1));
|
||||
break;
|
||||
case Circle circle:
|
||||
Flush();
|
||||
var start = Point(new Vector(circle.Center.X + circle.Radius, circle.Center.Y), location, context);
|
||||
segment = new ReportSegment(start, start, Point(circle.Center, location, context),
|
||||
circle.Radius, 0, circle.Rotation == RotationType.CW ? -2 * System.Math.PI : 2 * System.Math.PI);
|
||||
break;
|
||||
default:
|
||||
throw Error(context, $"unsupported geometry '{entity.GetType().Name}'.");
|
||||
}
|
||||
|
||||
if (segment.Center != null && (!double.IsFinite(segment.Radius) || segment.Radius <= 0
|
||||
|| !double.IsFinite(segment.StartAngle) || !double.IsFinite(segment.SweepAngle)
|
||||
|| segment.SweepAngle == 0 || System.Math.Abs(segment.SweepAngle) > 2 * System.Math.PI))
|
||||
throw Error(context, "invalid arc/circle data.");
|
||||
if (segments.Count > 0 && !SamePoint(segments[^1].End, segment.Start))
|
||||
Flush();
|
||||
segments.Add(segment);
|
||||
if (SamePoint(segments[0].Start, segment.End))
|
||||
Flush();
|
||||
|
||||
var box = entity.BoundingBox;
|
||||
var low = Point(new Vector(box.Left, box.Bottom), location, context);
|
||||
var high = Point(new Vector(box.Right, box.Top), location, context);
|
||||
left = System.Math.Min(left, low.X);
|
||||
bottom = System.Math.Min(bottom, low.Y);
|
||||
right = System.Math.Max(right, high.X);
|
||||
top = System.Math.Max(top, high.Y);
|
||||
}
|
||||
Flush();
|
||||
if (contours.Count == 0)
|
||||
throw Error(context, "missing drawable material geometry.");
|
||||
if (!double.IsFinite(right - left) || !double.IsFinite(top - bottom))
|
||||
throw Error(context, "geometry bounds are not finite.");
|
||||
return new ReportGeometry(contours.ToImmutable(), new ReportBounds(left, bottom, right, top));
|
||||
}
|
||||
|
||||
private static void ValidateProgram(Program? program, ref Vector current,
|
||||
HashSet<Program> active, string context)
|
||||
{
|
||||
if (program?.Codes == null)
|
||||
throw Error(context, "missing program or subprogram reference.");
|
||||
if (!active.Add(program))
|
||||
throw Error(context, "cyclic subprogram reference.");
|
||||
if (active.Count > 256)
|
||||
throw Error(context, "unsupported subprogram nesting depth (maximum 256).");
|
||||
if (!double.IsFinite(program.Rotation) || !Enum.IsDefined(program.Mode))
|
||||
throw Error(context, "invalid program transform or coordinate mode.");
|
||||
var origin = current;
|
||||
for (var index = 0; index < program.Codes.Count; index++)
|
||||
{
|
||||
var code = program.Codes[index];
|
||||
var codeContext = $"{context}, code {index + 1}";
|
||||
switch (code)
|
||||
{
|
||||
case SubProgramCall call when code.Type == CodeType.SubProgramCall:
|
||||
ValidatePoint(call.Offset, codeContext);
|
||||
if (!double.IsFinite(call.Rotation))
|
||||
throw Error(codeContext, "invalid subprogram rotation.");
|
||||
current = origin + call.Offset;
|
||||
ValidatePoint(current, codeContext);
|
||||
ValidateProgram(call.Program, ref current, active, $"{codeContext}, subprogram {call.Id}");
|
||||
break;
|
||||
case Motion motion when code is LinearMove && code.Type == CodeType.LinearMove
|
||||
|| code is RapidMove && code.Type == CodeType.RapidMove
|
||||
|| code is ArcMove && code.Type == CodeType.ArcMove:
|
||||
ValidatePoint(motion.EndPoint, codeContext);
|
||||
var end = program.Mode == Mode.Incremental ? current + motion.EndPoint : motion.EndPoint;
|
||||
ValidatePoint(end, codeContext);
|
||||
if (motion is LinearMove linear && !Enum.IsDefined(linear.Layer))
|
||||
throw Error(codeContext, "unsupported material layer.");
|
||||
if (motion is ArcMove arc)
|
||||
{
|
||||
ValidatePoint(arc.CenterPoint, codeContext);
|
||||
if (!Enum.IsDefined(arc.Rotation) || !Enum.IsDefined(arc.Layer))
|
||||
throw Error(codeContext, "invalid arc rotation or layer.");
|
||||
var center = program.Mode == Mode.Incremental ? current + arc.CenterPoint : arc.CenterPoint;
|
||||
ValidatePoint(center, codeContext);
|
||||
var startRadius = current.DistanceTo(center);
|
||||
var endRadius = end.DistanceTo(center);
|
||||
if (!double.IsFinite(startRadius) || !double.IsFinite(endRadius)
|
||||
|| startRadius <= 0 || endRadius <= 0
|
||||
|| System.Math.Abs(startRadius - endRadius) > Tolerance.Epsilon)
|
||||
throw Error(codeContext, "invalid arc/circle radius or inconsistent endpoints.");
|
||||
}
|
||||
current = end;
|
||||
break;
|
||||
case Comment when code.Type == CodeType.Comment:
|
||||
case Feedrate when code.Type == CodeType.SetFeedrate:
|
||||
case Kerf when code.Type == CodeType.SetKerf:
|
||||
break;
|
||||
default:
|
||||
throw Error(codeContext, code == null ? "missing code." : $"unsupported code '{code.GetType().Name}'.");
|
||||
}
|
||||
}
|
||||
active.Remove(program);
|
||||
}
|
||||
|
||||
private static ReportPoint Point(Vector point, Vector location, string context)
|
||||
{
|
||||
var translated = point + location;
|
||||
ValidatePoint(translated, context);
|
||||
return new ReportPoint(translated.X, translated.Y);
|
||||
}
|
||||
|
||||
private static void ValidatePoint(Vector point, string context)
|
||||
{
|
||||
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y))
|
||||
throw Error(context, "nonfinite geometry coordinate.");
|
||||
}
|
||||
|
||||
// Only absorb floating-point trig roundoff, never ShapeBuilder's welding tolerance.
|
||||
// A deliberate tab/rapid interruption is never joined by contour construction.
|
||||
private static bool SamePoint(ReportPoint first, ReportPoint second) =>
|
||||
System.Math.Abs(first.X - second.X) <= 1e-12 && System.Math.Abs(first.Y - second.Y) <= 1e-12;
|
||||
|
||||
private static InvalidOperationException Error(string context, string message) => new($"{context}: {message}");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
public sealed record ReportPoint(double X, double Y);
|
||||
|
||||
public sealed record ReportBounds(double Left, double Bottom, double Right, double Top)
|
||||
{
|
||||
public double Length => Right - Left;
|
||||
public double Width => Top - Bottom;
|
||||
}
|
||||
|
||||
/// <summary>Native line or arc; angles are radians and a null center denotes a line.</summary>
|
||||
public sealed record ReportSegment(ReportPoint Start, ReportPoint End, ReportPoint? Center,
|
||||
double Radius, double StartAngle, double SweepAngle);
|
||||
|
||||
public sealed record ReportContour(ImmutableArray<ReportSegment> Segments, bool Closed);
|
||||
|
||||
public sealed record ReportGeometry(ImmutableArray<ReportContour> Contours, ReportBounds Bounds)
|
||||
{
|
||||
public bool StrokeOnly => Contours.Any(contour => !contour.Closed);
|
||||
}
|
||||
|
||||
public sealed record ReportDrawing(string Id, string Name, long Required, long Nested,
|
||||
long Shortage, long Extra, ImmutableArray<int> Plates, ReportGeometry Geometry);
|
||||
|
||||
public sealed record ReportPlacement(string ReportId, ReportGeometry Geometry);
|
||||
|
||||
public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, double PartSpacing,
|
||||
double Utilization, ImmutableArray<ReportPlacement> Parts, ImmutableArray<ReportGeometry> Cutoffs);
|
||||
|
||||
/// <summary>A detached value snapshot. No domain objects or mutable geometry are retained.</summary>
|
||||
public sealed record NestReportSnapshot(string Name, string Customer, string Notes, string Material,
|
||||
string Grade, double Thickness, string Units, DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<ReportDrawing> Drawings, ImmutableArray<ReportPlate> Plates)
|
||||
{
|
||||
public long TotalSheets => Plates.Aggregate(0L, (total, plate) => checked(total + plate.Copies));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
<EmbeddedResource Include="Fonts\*.ttf" />
|
||||
<None Update="Fonts\LICENSE.txt;THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="ReportingNotices\%(Filename)%(Extension)" />
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,59 @@
|
||||
using PdfSharp.Fonts;
|
||||
|
||||
namespace OpenNest.Reporting;
|
||||
|
||||
/// <summary>Process-wide, embedded fonts; never consult platform font directories.</summary>
|
||||
internal static class ReportFonts
|
||||
{
|
||||
internal const string Family = "OpenNest Report Sans";
|
||||
private static readonly object Sync = new();
|
||||
private static readonly BundledResolver Resolver = new();
|
||||
|
||||
internal static void Initialize()
|
||||
{
|
||||
lock (Sync)
|
||||
{
|
||||
if (GlobalFontSettings.FontResolver == null)
|
||||
GlobalFontSettings.FontResolver = Resolver;
|
||||
else if (!ReferenceEquals(GlobalFontSettings.FontResolver, Resolver))
|
||||
throw new InvalidOperationException("Nest reports require their bundled font resolver before PDF fonts are created. Another resolver is already installed.");
|
||||
// MigraDoc otherwise asks the resolver for a platform "Courier New" error font.
|
||||
MigraDoc.PredefinedFontsAndChars.ErrorFontName = Family;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidateText(string text, string field)
|
||||
{
|
||||
// Slice 1 deliberately supports printable Latin-1, not general Unicode shaping.
|
||||
// Reject unavailable text explicitly instead of substituting .notdef boxes or hiding it.
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
var value = rune.Value;
|
||||
if (value is '\r' or '\n' or '\t' || value is >= 0x20 and <= 0x7e
|
||||
|| value is >= 0xa0 and <= 0xff && value != 0xad)
|
||||
continue;
|
||||
throw new InvalidOperationException($"{field}: unsupported report character U+{value:X4}. This report slice supports printable Latin-1 text only.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BundledResolver : IFontResolver
|
||||
{
|
||||
public FontResolverInfo? ResolveTypeface(string familyName, bool isBold, bool isItalic)
|
||||
{
|
||||
if (!string.Equals(familyName, Family, StringComparison.OrdinalIgnoreCase) || isItalic)
|
||||
return null;
|
||||
return new FontResolverInfo(isBold ? "DejaVuSans-Bold" : "DejaVuSans");
|
||||
}
|
||||
|
||||
public byte[] GetFont(string faceName)
|
||||
{
|
||||
if (faceName is not ("DejaVuSans" or "DejaVuSans-Bold"))
|
||||
throw new InvalidOperationException($"Unknown report font face: {faceName}.");
|
||||
using var source = typeof(ReportFonts).Assembly.GetManifestResourceStream($"OpenNest.Reporting.Fonts.{faceName}.ttf")
|
||||
?? throw new InvalidOperationException($"Bundled report font missing: {faceName}.");
|
||||
using var bytes = new MemoryStream();
|
||||
source.CopyTo(bytes);
|
||||
return bytes.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using OpenNest.Reporting;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public sealed class AtomicReportFileTests : IDisposable
|
||||
{
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-report-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public AtomicReportFileTests() => Directory.CreateDirectory(directory);
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulRenderReplacesExistingFileOnlyAfterRendering()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.pdf");
|
||||
File.WriteAllText(path, "original");
|
||||
AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
Assert.Equal("original", File.ReadAllText(path));
|
||||
stream.Write("replacement"u8);
|
||||
});
|
||||
Assert.Equal("replacement", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateRenderFailurePreservesExistingFileAndRemovesTemporarySibling()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.pdf");
|
||||
File.WriteAllText(path, "original");
|
||||
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
stream.Write("partial PDF"u8);
|
||||
Assert.Equal(2, Directory.GetFiles(directory).Length);
|
||||
throw new IOException("injected late render failure");
|
||||
}));
|
||||
Assert.Equal("original", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedNewExportLeavesNoOutput()
|
||||
{
|
||||
var path = Path.Combine(directory, "new.pdf");
|
||||
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
stream.Write("partial"u8);
|
||||
throw new IOException("injected write failure");
|
||||
}));
|
||||
Assert.Empty(Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedReplacementPreservesDirectoryTargetAndCleansTemporaryFile()
|
||||
{
|
||||
var path = Path.Combine(directory, "not-a-file.pdf");
|
||||
Directory.CreateDirectory(path);
|
||||
Assert.ThrowsAny<IOException>(() => AtomicReportFile.Write(path, stream => stream.Write("PDF"u8)));
|
||||
Assert.True(Directory.Exists(path));
|
||||
Assert.Empty(Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(directory, true);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Reporting;
|
||||
using PdfSharp.Pdf.IO;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public sealed class NestPdfWriterTests : IDisposable
|
||||
{
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public NestPdfWriterTests() => Directory.CreateDirectory(directory);
|
||||
|
||||
[Fact]
|
||||
public void Write_OnePlateJobProducesPortraitSummaryAndLandscapeVectorPlatePage()
|
||||
{
|
||||
var path = WriteReport(NestReportTestData.CreateNest());
|
||||
|
||||
using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import))
|
||||
{
|
||||
Assert.Equal(2, pdf.PageCount);
|
||||
Assert.Equal((612, 792), EffectiveSize(pdf.Pages[0]));
|
||||
Assert.Equal((792, 612), EffectiveSize(pdf.Pages[1]));
|
||||
}
|
||||
var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path));
|
||||
// Subset-embedded faces; PDF names escape the space as #20.
|
||||
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans(?![,\w])", bytes);
|
||||
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans,Bold\b", bytes);
|
||||
Assert.Contains("/FontFile2", bytes);
|
||||
// Thumbnails and diagrams are vectors, never raster screenshots.
|
||||
Assert.DoesNotMatch(@"/Subtype\s*/Image", bytes);
|
||||
Assert.Matches(@"/Subtype\s*/Form", bytes);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers()
|
||||
{
|
||||
var path = WriteReport(NestReportTestData.CreateNest());
|
||||
var summary = ExtractText(path, 1);
|
||||
var plate = ExtractText(path, 2);
|
||||
|
||||
Assert.Contains("Report test job", summary);
|
||||
Assert.Contains("Test customer", summary);
|
||||
Assert.Contains("Steel A36", summary);
|
||||
Assert.Contains("0.125 in", summary);
|
||||
Assert.Contains("2026-09-29 12:00 +00:00", summary);
|
||||
Assert.Matches(@"Distinct layouts:\s+1", summary);
|
||||
Assert.Matches(@"Total physical sheets:\s+2", summary);
|
||||
Assert.Matches(@"1\s+48 x 24 in\s+2\s+4\s+\d+\.\d%", summary);
|
||||
Assert.Matches(@"R001\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
|
||||
Assert.Matches(@"R002\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
|
||||
Assert.Matches(@"R003\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
|
||||
Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
|
||||
Assert.Contains("Page 1 of 2", summary);
|
||||
|
||||
Assert.Contains("Plate 1", plate);
|
||||
Assert.Contains("Report test job", plate);
|
||||
Assert.Matches(@"Copies:\s+2", plate);
|
||||
Assert.Matches(@"R001\s+Bracket\s+2\s+4", plate);
|
||||
Assert.Matches(@"R002\s+Rotated\s+1\s+2", plate);
|
||||
Assert.Matches(@"R003\s+Bracket\s+1\s+2", plate);
|
||||
Assert.DoesNotContain("R004", plate);
|
||||
Assert.DoesNotContain("Cutoff test", plate + summary);
|
||||
Assert.Contains("not a dimensioned cutting drawing", plate);
|
||||
Assert.Contains("not a geometry or CNC approval", plate);
|
||||
Assert.Contains("Page 2 of 2", plate);
|
||||
|
||||
// Repeated export of the same snapshot is textually identical.
|
||||
Assert.Equal(summary + plate, ExtractText(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
|
||||
+ ExtractText(Path.Combine(directory, "again.pdf"), 2));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
|
||||
{
|
||||
var nest = new Nest("Empty test") { Units = Units.Millimeters };
|
||||
var empty = ExtractText(WriteReport(nest, "empty.pdf"), 1);
|
||||
Assert.Contains("No plates in this job.", empty);
|
||||
Assert.Contains("No parts in this job.", empty);
|
||||
Assert.Contains("Page 1 of 1", empty);
|
||||
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2));
|
||||
var demand = ExtractText(WriteReport(nest, "demand.pdf"), 1);
|
||||
Assert.Contains("No plates in this job.", demand);
|
||||
Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
|
||||
Assert.Contains("Page 1 of 1", demand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
|
||||
{
|
||||
var nest = NestReportTestData.CreateTabbedNest();
|
||||
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
|
||||
Assert.True(geometry.StrokeOnly);
|
||||
var path = WriteReport(nest);
|
||||
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
|
||||
Assert.Equal(2, pdf.PageCount);
|
||||
// The vector diagram is the last content stream on the plate page. Each stroke-only
|
||||
// contour must begin its own subpath; a missing move-to draws a false connecting line.
|
||||
var diagram = ContentStreams(pdf.Pages[1]).Last();
|
||||
Assert.Equal(geometry.Contours.Length, Regex.Matches(diagram, @"(?m)^[-\d. ]+ m$").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_LateFailureLeavesExistingReportUnchangedAndNoTemporaryFile()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.report.pdf");
|
||||
var original = "%PDF previous report"u8.ToArray();
|
||||
File.WriteAllBytes(path, original);
|
||||
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
|
||||
|
||||
Assert.Throws<IOException>(() => NestPdfWriter.Write(snapshot, path, stream => new FailingStream(stream, 512)));
|
||||
|
||||
Assert.Equal(original, File.ReadAllBytes(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_SuccessfulExportReplacesExistingReport()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.report.pdf");
|
||||
File.WriteAllText(path, "old");
|
||||
WriteReport(NestReportTestData.CreateNest(), "existing.report.pdf");
|
||||
Assert.StartsWith("%PDF-", Encoding.ASCII.GetString(File.ReadAllBytes(path), 0, 5));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("second-plate")]
|
||||
[InlineData("summary-overflow")]
|
||||
[InlineData("plate-table-overflow")]
|
||||
[InlineData("illegible-label")]
|
||||
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var plate = nest.Plates[0];
|
||||
switch (scenario)
|
||||
{
|
||||
case "second-plate":
|
||||
nest.Plates.Add(new Plate(24, 48) { Quantity = 1 });
|
||||
nest.Plates[1].Parts.Add(new Part(plate.Parts[0].BaseDrawing));
|
||||
break;
|
||||
case "summary-overflow":
|
||||
for (var i = 0; i < 40; i++)
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle($"Demand {i:D2}", 1, 1));
|
||||
break;
|
||||
case "plate-table-overflow":
|
||||
for (var i = 0; i < 12; i++)
|
||||
// Large enough for readable labels, so only the table can overflow.
|
||||
plate.Parts.Add(new Part(NestReportTestData.Rectangle($"Small {i:D2}", 2, 2), new Vector(2 + i * 3, 16)));
|
||||
break;
|
||||
case "illegible-label":
|
||||
plate.Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.05, 0.05), new Vector(44, 20)));
|
||||
break;
|
||||
}
|
||||
var path = Path.Combine(directory, "keep.report.pdf");
|
||||
File.WriteAllText(path, "keep");
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
var error = Assert.Throws<NotSupportedException>(() => NestPdfWriter.Write(snapshot, path));
|
||||
|
||||
Assert.Contains("not supported", error.Message);
|
||||
Assert.Equal("keep", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_UnsupportedTextIsIdentifiedBeforeReplacingDestination()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
nest.Plates[0].Parts[2].BaseDrawing.Name = "Rotated 中";
|
||||
var path = Path.Combine(directory, "keep.report.pdf");
|
||||
File.WriteAllText(path, "keep");
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => NestPdfWriter.Write(snapshot, path));
|
||||
|
||||
Assert.Contains("R002", error.Message);
|
||||
Assert.Contains("U+4E2D", error.Message);
|
||||
Assert.Equal("keep", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(directory, true);
|
||||
|
||||
private string WriteReport(Nest nest, string name = "test.report.pdf")
|
||||
{
|
||||
var path = Path.Combine(directory, name);
|
||||
NestPdfWriter.Write(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt), path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static (int, int) EffectiveSize(PdfSharp.Pdf.PdfPage page)
|
||||
{
|
||||
var box = page.MediaBox;
|
||||
var width = (int)System.Math.Round(box.Width);
|
||||
var height = (int)System.Math.Round(box.Height);
|
||||
return page.Rotate % 180 == 0 ? (width, height) : (height, width);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ContentStreams(PdfSharp.Pdf.PdfPage page)
|
||||
{
|
||||
foreach (var item in page.Contents.Elements)
|
||||
{
|
||||
var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfSharp.Pdf.PdfDictionary
|
||||
?? item as PdfSharp.Pdf.PdfDictionary;
|
||||
if (dictionary?.Stream != null)
|
||||
yield return Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Poppler text extraction; skipped where poppler-utils is not installed.</summary>
|
||||
private static string ExtractText(string path, int page)
|
||||
{
|
||||
var info = new ProcessStartInfo("pdftotext")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (var argument in new[] { "-layout", "-f", page.ToString(), "-l", page.ToString(), path, "-" })
|
||||
info.ArgumentList.Add(argument);
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(info)!;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Skip.If(true, "pdftotext (poppler-utils) is not installed.");
|
||||
throw;
|
||||
}
|
||||
using (process)
|
||||
{
|
||||
var text = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
Assert.Equal(0, process.ExitCode);
|
||||
return Regex.Replace(text, "[ \t]+\n", "\n");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingStream(Stream inner, long limit) : Stream
|
||||
{
|
||||
private long written;
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position { get => inner.Position; set => inner.Position = value; }
|
||||
public override void Flush() => inner.Flush();
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => inner.SetLength(value);
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (written + count > limit)
|
||||
throw new IOException("injected late report write failure");
|
||||
written += count;
|
||||
inner.Write(buffer, offset, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Reporting;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public class NestReportBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Capture_ReconcilesOrderedReferenceUnionWithoutCountingCutoffs()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
Assert.Equal("Report test job", snapshot.Name);
|
||||
Assert.Equal("Test customer", snapshot.Customer);
|
||||
Assert.Equal("Synthetic report test data", snapshot.Notes);
|
||||
Assert.Equal("Steel", snapshot.Material);
|
||||
Assert.Equal("A36", snapshot.Grade);
|
||||
Assert.Equal(0.125, snapshot.Thickness);
|
||||
Assert.Equal("in", snapshot.Units);
|
||||
Assert.Equal(NestReportTestData.GeneratedAt, snapshot.GeneratedAt);
|
||||
Assert.Equal(new[] { "R001", "R002", "R003", "R004" }, snapshot.Drawings.Select(d => d.Id));
|
||||
Assert.Equal(new[] { "Bracket", "Rotated", "Bracket", "Unplaced" }, snapshot.Drawings.Select(d => d.Name));
|
||||
Assert.Equal(new long[] { 5, 1, 7, 3 }, snapshot.Drawings.Select(d => d.Required));
|
||||
Assert.Equal(new long[] { 4, 2, 2, 0 }, snapshot.Drawings.Select(d => d.Nested));
|
||||
Assert.Equal(new long[] { 1, 0, 5, 3 }, snapshot.Drawings.Select(d => d.Shortage));
|
||||
Assert.Equal(new long[] { 0, 1, 0, 0 }, snapshot.Drawings.Select(d => d.Extra));
|
||||
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
|
||||
Assert.Empty(snapshot.Drawings[3].Plates);
|
||||
var plate = Assert.Single(snapshot.Plates);
|
||||
Assert.Equal(new[] { "R001", "R001", "R002", "R003" }, plate.Parts.Select(p => p.ReportId));
|
||||
Assert.Single(plate.Cutoffs);
|
||||
Assert.Equal(2, snapshot.TotalSheets);
|
||||
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
|
||||
Assert.Equal(0.125, plate.PartSpacing);
|
||||
Assert.Equal(nest.Plates[0].Utilization(), plate.Utilization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RecountsAcrossPlateCopiesWithWideIntegersAndUniquePlateNumbers()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var drawing = nest.Plates[0].Parts[0].BaseDrawing;
|
||||
// Change copies after adding: the legacy cached Nested count stays stale.
|
||||
nest.Plates[0].Quantity = int.MaxValue;
|
||||
var second = new Plate(24, 48) { Quantity = 3 };
|
||||
second.Parts.Add(new Part(drawing));
|
||||
second.Parts.Add(new Part(drawing));
|
||||
nest.Plates.Add(second);
|
||||
var cached = drawing.Quantity.Nested;
|
||||
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
Assert.Equal(checked(2L * int.MaxValue + 6), snapshot.Drawings[0].Nested);
|
||||
Assert.Equal(new[] { 1, 2 }, snapshot.Drawings[0].Plates);
|
||||
Assert.Equal(checked((long)int.MaxValue + 3), snapshot.TotalSheets);
|
||||
Assert.Equal(cached, drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_IsRepeatableDoesNotMutateSourcesAndRetainsNoMutableReferences()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var part = nest.Plates[0].Parts[0];
|
||||
var drawing = part.BaseDrawing;
|
||||
var source = Fingerprint(nest);
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
var serialized = JsonSerializer.Serialize(snapshot);
|
||||
|
||||
Assert.Equal(source, Fingerprint(nest));
|
||||
Assert.Equal(serialized, JsonSerializer.Serialize(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt)));
|
||||
Assert.NotEmpty(snapshot.Plates);
|
||||
nest.Name = "Edited";
|
||||
nest.Material.Name = "Edited material";
|
||||
nest.Plates[0].Size = new Size(100, 200);
|
||||
nest.Plates[0].Quantity = 42;
|
||||
part.Location = new Vector(500, 600);
|
||||
((LinearMove)part.Program.Codes[1]).EndPoint = new Vector(99, 99);
|
||||
((LinearMove)drawing.Program.Codes[1]).EndPoint = new Vector(88, 88);
|
||||
drawing.Name = "Edited drawing";
|
||||
drawing.Quantity.Required = 123;
|
||||
nest.Drawings.Clear();
|
||||
nest.Plates.Clear();
|
||||
Assert.Equal(serialized, JsonSerializer.Serialize(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_EmptyAndDemandOnlyJobsKeepExplicitEmptyCollectionsAndOrdinalOrder()
|
||||
{
|
||||
var nest = new Nest("Empty test");
|
||||
var empty = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal("Empty test", empty.Name);
|
||||
Assert.Empty(empty.Drawings);
|
||||
Assert.Empty(empty.Plates);
|
||||
Assert.Equal(0, empty.TotalSheets);
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle("zeta", 1, 1, 5));
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 1, 1, 2));
|
||||
var demand = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal(new[] { "Alpha", "zeta" }, demand.Drawings.Select(d => d.Name));
|
||||
Assert.All(demand.Drawings, d => { Assert.Equal(0, d.Nested); Assert.Equal(d.Required, d.Shortage); });
|
||||
Assert.Empty(demand.Plates);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 48, 1)]
|
||||
[InlineData(24, -1, 1)]
|
||||
[InlineData(double.NaN, 48, 1)]
|
||||
[InlineData(24, double.PositiveInfinity, 1)]
|
||||
[InlineData(24, 48, 0)]
|
||||
[InlineData(24, 48, -1)]
|
||||
public void Capture_RejectsInvalidPlateBeforeProducingSnapshot(double width, double length, int copies)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
nest.Plates[0].Size = new Size(width, length);
|
||||
nest.Plates[0].Quantity = copies;
|
||||
var before = Fingerprint(nest);
|
||||
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
|
||||
Assert.Contains("Plate 1", error.Message);
|
||||
Assert.Equal(before, Fingerprint(nest));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 0, 0, 48, 24)]
|
||||
[InlineData(2, -48, 0, 0, 24)]
|
||||
[InlineData(3, -48, -24, 0, 0)]
|
||||
[InlineData(4, 0, -24, 48, 0)]
|
||||
public void Capture_UsesQuadrantAwareStockBounds(int quadrant, double left, double bottom, double right, double top)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
nest.Plates[0].Quadrant = quadrant;
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal(new ReportBounds(left, bottom, right, top), Assert.Single(snapshot.Plates).Bounds);
|
||||
}
|
||||
|
||||
internal static string Fingerprint(Nest nest) => JsonSerializer.Serialize(new
|
||||
{
|
||||
nest.Name,
|
||||
nest.Customer,
|
||||
nest.Notes,
|
||||
nest.Material,
|
||||
nest.Thickness,
|
||||
nest.Units,
|
||||
nest.DateCreated,
|
||||
nest.DateLastModified,
|
||||
Drawings = nest.Drawings.Select(d => new { d.Id, d.Name, d.Quantity, Main = NestWriter.GetProgramText(d.Program), Subs = NestWriter.GetSubProgramsText(d.Program) }),
|
||||
Plates = nest.Plates.Select(p => new
|
||||
{
|
||||
p.Size,
|
||||
p.Quantity,
|
||||
p.PartSpacing,
|
||||
p.Quadrant,
|
||||
Parts = p.Parts.Select(part => new
|
||||
{
|
||||
part.BaseDrawing.Id,
|
||||
part.Location,
|
||||
part.Rotation,
|
||||
part.HasManualLeadIns,
|
||||
part.LeadInsLocked,
|
||||
Main = NestWriter.GetProgramText(part.Program),
|
||||
Subs = NestWriter.GetSubProgramsText(part.Program),
|
||||
}),
|
||||
}),
|
||||
}, new JsonSerializerOptions { IncludeFields = true, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals });
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Reporting;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public class NestReportGeometryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Capture_PreservesNativeCircleHoleAndAppliesBakedRotationAndLocationOnce()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
var holed = snapshot.Plates[0].Parts[0].Geometry;
|
||||
Assert.False(holed.StrokeOnly);
|
||||
Assert.Equal(2, holed.Contours.Length);
|
||||
Assert.All(holed.Contours, c => Assert.True(c.Closed));
|
||||
var circle = Assert.Single(holed.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
|
||||
Assert.Equal(new ReportPoint(7, 7), circle.Center);
|
||||
Assert.Equal(1, circle.Radius);
|
||||
Assert.Equal(-2 * System.Math.PI, circle.SweepAngle);
|
||||
Assert.Equal(new ReportBounds(2, 2, 12, 12), holed.Bounds);
|
||||
var turned = snapshot.Plates[0].Parts[2].Geometry;
|
||||
AssertBounds(new ReportBounds(28, 2, 30, 6), turned.Bounds);
|
||||
AssertBounds(new ReportBounds(0, 0, 4, 2), snapshot.Drawings[1].Geometry.Bounds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RealTabbedLeadInHoleRemainsOpenAndNeverMutatesPrograms()
|
||||
{
|
||||
var nest = NestReportTestData.CreateTabbedNest();
|
||||
var part = nest.Plates[0].Parts[0];
|
||||
var converted = ConvertProgram.ToGeometry(part.Program);
|
||||
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadin);
|
||||
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadout);
|
||||
Assert.NotEmpty(part.Program.SubPrograms);
|
||||
// Deliberately stale settings must not control closure detection.
|
||||
part.CuttingParameters.TabsEnabled = false;
|
||||
var before = NestReportBuilderTests.Fingerprint(nest);
|
||||
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
var geometry = Assert.Single(snapshot.Plates[0].Parts).Geometry;
|
||||
|
||||
Assert.True(geometry.StrokeOnly);
|
||||
var open = Assert.Single(geometry.Contours.Where(c => !c.Closed));
|
||||
Assert.NotEqual(open.Segments[0].Start, open.Segments[^1].End);
|
||||
var gapX = open.Segments[^1].End.X - open.Segments[0].Start.X;
|
||||
var gapY = open.Segments[^1].End.Y - open.Segments[0].Start.Y;
|
||||
Assert.Equal(0.15, System.Math.Sqrt(gapX * gapX + gapY * gapY), 6);
|
||||
Assert.Single(geometry.Contours.Where(c => c.Closed));
|
||||
AssertBounds(new ReportBounds(10, 5, 20, 15), geometry.Bounds);
|
||||
var hole = Assert.Single(geometry.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
|
||||
Assert.Equal(15, hole.Center!.X, 6);
|
||||
Assert.Equal(10, hole.Center.Y, 6);
|
||||
var outlineCount = converted.Count(e => SpecialLayers.IsMaterial(e.Layer) && e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout);
|
||||
Assert.Equal(outlineCount, geometry.Contours.Sum(c => c.Segments.Length));
|
||||
Assert.False(snapshot.Drawings[0].Geometry.StrokeOnly);
|
||||
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_PreservesNativeArcSweepAndTrueBounds()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var program = nest.Plates[0].Parts[0].Program;
|
||||
program.Codes.Clear();
|
||||
program.Codes.Add(new RapidMove(2, 0));
|
||||
program.Codes.Add(new ArcMove(0, 2, 0, 0, RotationType.CCW));
|
||||
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
|
||||
var arc = Assert.Single(Assert.Single(geometry.Contours).Segments);
|
||||
Assert.Equal(new ReportPoint(2, 2), arc.Center);
|
||||
Assert.Equal(2, arc.Radius);
|
||||
Assert.Equal(0, arc.StartAngle);
|
||||
Assert.Equal(System.Math.PI / 2, arc.SweepAngle);
|
||||
AssertBounds(new ReportBounds(2, 2, 4, 4), geometry.Bounds);
|
||||
Assert.True(geometry.StrokeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_ExcludedLayersCannotConnectSeparateMaterialPaths()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var codes = nest.Plates[0].Parts[0].Program.Codes;
|
||||
codes.Clear();
|
||||
codes.Add(new RapidMove(0, 0));
|
||||
codes.Add(new LinearMove(1, 0) { Layer = LayerType.Display });
|
||||
codes.Add(new LinearMove(1, 1) { Layer = LayerType.Scribe });
|
||||
codes.Add(new LinearMove(2, 1));
|
||||
codes.Add(new LinearMove(100, 100) { Layer = LayerType.Leadout });
|
||||
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
|
||||
Assert.Equal(2, geometry.Contours.Length);
|
||||
Assert.All(geometry.Contours, c => { Assert.False(c.Closed); Assert.Single(c.Segments); });
|
||||
Assert.Equal(new ReportBounds(2, 2, 4, 3), geometry.Bounds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("missing")]
|
||||
[InlineData("nonfinite")]
|
||||
[InlineData("arc-center")]
|
||||
[InlineData("zero-radius")]
|
||||
[InlineData("unequal-radii")]
|
||||
[InlineData("rotation")]
|
||||
[InlineData("unsupported")]
|
||||
[InlineData("null-code")]
|
||||
[InlineData("null-subprogram")]
|
||||
[InlineData("cyclic-subprogram")]
|
||||
[InlineData("nonfinite-offset")]
|
||||
public void Capture_RejectsMalformedPlacedGeometryWithIdentification(string damage)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var part = nest.Plates[0].Parts[0];
|
||||
var codes = part.Program.Codes;
|
||||
switch (damage)
|
||||
{
|
||||
case "missing": codes.Clear(); break;
|
||||
case "nonfinite": ((LinearMove)codes[1]).EndPoint = new Vector(double.NaN, 1); break;
|
||||
case "arc-center": codes.Add(new ArcMove(1, 0, double.PositiveInfinity, 0)); break;
|
||||
case "zero-radius": codes.Add(new ArcMove(6, 5, 6, 5)); break;
|
||||
case "unequal-radii": codes.Add(new ArcMove(10, 5, 5, 5)); break;
|
||||
case "rotation": codes.Add(new ArcMove(6, 5, 5, 5, (RotationType)42)); break;
|
||||
case "unsupported": codes.Add(new UnsupportedCode()); break;
|
||||
case "null-code": codes.Add(null!); break;
|
||||
case "null-subprogram": codes.Add(new SubProgramCall { Id = 42 }); break;
|
||||
case "cyclic-subprogram": codes.Add(new SubProgramCall { Id = 42, Program = part.Program }); break;
|
||||
case "nonfinite-offset": part.Location = new Vector(double.NaN, 0); break;
|
||||
}
|
||||
var count = codes.Count;
|
||||
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
|
||||
Assert.Contains("Plate 1", error.Message);
|
||||
Assert.Contains("part 1", error.Message);
|
||||
Assert.Contains("Bracket", error.Message);
|
||||
Assert.Equal(count, codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RejectsUnrenderableDemandOnlyDrawingByName()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var drawing = new Drawing("Missing demand geometry");
|
||||
drawing.Quantity.Required = 3;
|
||||
nest.Drawings.Add(drawing);
|
||||
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
|
||||
Assert.Contains("Missing demand geometry", error.Message);
|
||||
}
|
||||
|
||||
private static void AssertBounds(ReportBounds expected, ReportBounds actual)
|
||||
{
|
||||
Assert.Equal(expected.Left, actual.Left, 6);
|
||||
Assert.Equal(expected.Bottom, actual.Bottom, 6);
|
||||
Assert.Equal(expected.Right, actual.Right, 6);
|
||||
Assert.Equal(expected.Top, actual.Top, 6);
|
||||
}
|
||||
|
||||
private sealed class UnsupportedCode : ICode
|
||||
{
|
||||
public CodeType Type => (CodeType)999;
|
||||
public ICode Clone() => new UnsupportedCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
/// <summary>Small, synthetic report examples; never production job data.</summary>
|
||||
public static class NestReportTestData
|
||||
{
|
||||
public static DateTimeOffset GeneratedAt { get; } = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public static Nest CreateNest()
|
||||
{
|
||||
var nest = new Nest("Report test job")
|
||||
{
|
||||
Customer = "Test customer",
|
||||
Notes = "Synthetic report test data",
|
||||
Material = new Material("Steel", "A36"),
|
||||
Thickness = 0.125,
|
||||
Units = Units.Inches,
|
||||
DateCreated = new DateTime(2026, 9, 1),
|
||||
DateLastModified = new DateTime(2026, 9, 28),
|
||||
};
|
||||
var holed = CreateHoledDrawing("Bracket", 5);
|
||||
var rotated = Rectangle("Rotated", 4, 2, 1);
|
||||
var sameName = Rectangle("Bracket", 3, 2, 7);
|
||||
var unplaced = Rectangle("Unplaced", 2, 1, 3);
|
||||
nest.Drawings.Add(unplaced);
|
||||
nest.Drawings.Add(rotated);
|
||||
nest.Drawings.Add(holed);
|
||||
// DrawingCollection is name-keyed; this reference deliberately exists only in placements.
|
||||
var plate = new Plate(24, 48) { Quantity = 2, PartSpacing = 0.125 };
|
||||
plate.Parts.Add(new Part(holed, new Vector(2, 2)));
|
||||
plate.Parts.Add(new Part(holed, new Vector(14, 2)));
|
||||
var turned = new Part(rotated);
|
||||
turned.Rotate(System.Math.PI / 2);
|
||||
turned.Location = new Vector(30, 2);
|
||||
plate.Parts.Add(turned);
|
||||
plate.Parts.Add(new Part(sameName, new Vector(32, 2)));
|
||||
var cutoffProgram = new Program();
|
||||
cutoffProgram.Codes.Add(new RapidMove(40, 0));
|
||||
cutoffProgram.Codes.Add(new LinearMove(40, 24));
|
||||
var cutoff = new Drawing("Cutoff test", cutoffProgram) { IsCutOff = true };
|
||||
cutoff.Quantity.Required = 99;
|
||||
plate.Parts.Add(new Part(cutoff));
|
||||
nest.Drawings.Add(cutoff);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
public static Nest CreateTabbedNest()
|
||||
{
|
||||
var drawing = CreateHoledDrawing("Tabbed with hole", 1);
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(System.Math.PI / 2);
|
||||
part.Offset(20, 5);
|
||||
part.ApplyLeadIns(new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
|
||||
ExternalLeadOut = new LineLeadOut { Length = 0.25 },
|
||||
ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
|
||||
TabsEnabled = true,
|
||||
TabConfig = new NormalTab { Size = 0.15 },
|
||||
}, new Vector(-5, -5));
|
||||
part.LeadInsLocked = true;
|
||||
var nest = new Nest("Tabbed report test") { Units = Units.Inches };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(24, 48) { Quantity = 1 };
|
||||
plate.Parts.Add(part);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
public static Drawing CreateHoledDrawing(string name, int required)
|
||||
{
|
||||
var program = RectangleProgram(10, 10);
|
||||
program.Codes.Add(new RapidMove(6, 5));
|
||||
program.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
|
||||
var drawing = new Drawing(name, program);
|
||||
drawing.Quantity.Required = required;
|
||||
return drawing;
|
||||
}
|
||||
|
||||
public static Drawing Rectangle(string name, double length, double width, int required = 1)
|
||||
{
|
||||
var drawing = new Drawing(name, RectangleProgram(length, width));
|
||||
drawing.Quantity.Required = required;
|
||||
return drawing;
|
||||
}
|
||||
|
||||
private static Program RectangleProgram(double length, double width)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(0, width));
|
||||
program.Codes.Add(new LinearMove(length, width));
|
||||
program.Codes.Add(new LinearMove(length, 0));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using OpenNest.Reporting;
|
||||
using PdfSharp.Drawing;
|
||||
using PdfSharp.Fonts;
|
||||
using PdfSharp.Pdf;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public class ReportFontsTests
|
||||
{
|
||||
[Fact]
|
||||
public void BundledFontsEmbedWithoutSystemFontResolutionAcrossRepeatedExports()
|
||||
{
|
||||
ReportFonts.Initialize();
|
||||
var resolver = GlobalFontSettings.FontResolver;
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
ReportFonts.Initialize();
|
||||
Assert.Same(resolver, GlobalFontSettings.FontResolver);
|
||||
using var doc = new PdfDocument();
|
||||
var page = doc.AddPage();
|
||||
using (var gfx = XGraphics.FromPdfPage(page))
|
||||
{
|
||||
gfx.DrawString("Résumé Ø TEST DATA", new XFont(ReportFonts.Family, 10), XBrushes.Black, 20, 20);
|
||||
gfx.DrawString("Bold", new XFont(ReportFonts.Family, 10, XFontStyleEx.Bold), XBrushes.Black, 20, 40);
|
||||
}
|
||||
using var stream = new MemoryStream();
|
||||
doc.Save(stream, false);
|
||||
Assert.True(stream.Length > 1000);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ASCII, 0.25 in; R001\nRésumé Ø Æ")]
|
||||
[InlineData("")]
|
||||
public void SupportedTextPasses(string text) => ReportFonts.ValidateText(text, "part R001");
|
||||
|
||||
[Theory]
|
||||
[InlineData("中文")]
|
||||
[InlineData("bad\u0000text")]
|
||||
[InlineData("hidden\u00ADhyphen")]
|
||||
public void UnsupportedTextHasAnIdentifiedErrorInsteadOfMissingGlyphs(string text)
|
||||
{
|
||||
var error = Assert.Throws<InvalidOperationException>(() => ReportFonts.ValidateText(text, "part R001"));
|
||||
Assert.Contains("part R001", error.Message);
|
||||
Assert.Contains("U+", error.Message);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.WinForms.Tests", "
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.FrontEnd.Tests", "OpenNest.FrontEnd.Tests\OpenNest.FrontEnd.Tests.csproj", "{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenNest.Reporting\OpenNest.Reporting.csproj", "{5C3708F5-DD69-436D-B412-7FF360DDB3DC}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -284,6 +286,18 @@ Global
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Nest report PDF library
|
||||
|
||||
The first delivery is a bounded, cross-platform library slice, not yet a desktop
|
||||
menu command or a general report layout engine. Capture a stable `Nest` on its
|
||||
owning thread, then render only the detached snapshot:
|
||||
|
||||
```csharp
|
||||
var snapshot = NestReportBuilder.Capture(nest, DateTimeOffset.Now);
|
||||
NestPdfWriter.Write(snapshot, destinationPath);
|
||||
```
|
||||
|
||||
Reporting does not select a post, generate CNC, refresh drawing quantities, change
|
||||
the selected plate, or certify that a layout passed geometry/pre-post checks.
|
||||
|
||||
## Snapshot contract
|
||||
|
||||
- IDs (`R001`, ...) are local to the report. Visit non-cutoff placements in plate
|
||||
and part order, then append unseen demanded drawings ordered by ordinal name and
|
||||
source path. Identity is by reference: distinct same-named drawings stay distinct,
|
||||
including placed drawings absent from the name-keyed drawing collection.
|
||||
- Recount nested quantities from placements times sheet copies exactly once using
|
||||
checked wide integers. Include unplaced demand; report shortage and extra
|
||||
separately. Cached `Quantity.Nested` is not authoritative.
|
||||
- Distinguish distinct layouts from physical sheets. Per-sheet quantities do not
|
||||
include copies; total quantities do. Utilization uses net part area divided by
|
||||
full sheet area, excluding cutoffs.
|
||||
- Actual placed programs already contain rotations. Capture their material paths
|
||||
with the location applied once. Preserve holes and intentional tab gaps; exclude
|
||||
rapid, scribe, lead-in and lead-out paths. Keep Cut and Display contours.
|
||||
- If any material contour of a part is open, show all its contours without fill.
|
||||
Do not close the gap or substitute the drawing's nominal outline. Cutoffs are
|
||||
separate strokes with no product ID or quantity.
|
||||
- Snapshots retain values, not live drawings, parts, CNC programs or mutable
|
||||
geometry. Invalid/missing/nonfinite geometry fails with drawing/plate context.
|
||||
|
||||
## Initial layout and safety limits
|
||||
|
||||
Slice 1 supports an empty/demand-only job or one plate layout: a Letter portrait
|
||||
summary page and a Letter landscape plate page, each with "Page X of Y". The
|
||||
writer rejects, with `NotSupportedException` and before touching the destination:
|
||||
|
||||
- more than one distinct plate layout;
|
||||
- a summary (job fields, plate list, part rows with thumbnails) longer than one page;
|
||||
- a plate page (header, diagram, part table) longer than one page;
|
||||
- a part ID label that does not fit inside its part's fitted bounds at 7 pt, or
|
||||
that overlaps another label.
|
||||
|
||||
It never shrinks text, truncates rows or drops labels to make a layout fit.
|
||||
Multi-plate pagination, dense-label callouts and the desktop command belong to
|
||||
subsequent slices. Labels are centered on the part's bounds, which can place them
|
||||
inside a central hole; smarter placement is part of the dense-label work.
|
||||
|
||||
Summary thumbnails and the sheet diagram are vector paths, never raster images.
|
||||
Each thumbnail is a small PDFsharp page embedded by MigraDoc as a form XObject.
|
||||
The diagram is drawn into a fixed-height table row reserved in MigraDoc's flow,
|
||||
located after layout with `DocumentRenderer.GetRenderInfoFromPage`. Closed parts
|
||||
use one even-odd filled path, so holes stay unfilled. Parts with any open contour,
|
||||
and cutoffs (dashed), stroke each contour as its own path: PDFsharp's
|
||||
`StartFigure` does not start a new subpath after an open figure, and would
|
||||
otherwise draw a false segment across a tab gap.
|
||||
|
||||
A report is fully rendered to a unique temporary sibling before replacement of
|
||||
its destination. A failed render or write leaves an existing report untouched and
|
||||
removes temporary output. Applications should obtain overwrite consent before
|
||||
calling the library.
|
||||
|
||||
Advanced timing, cutting distances, pierce counts, weights, costs, gas use, and
|
||||
machine/NC identity are intentionally omitted until their semantics are verified.
|
||||
The PDF can be opened or printed through a normal PDF viewer; its fitted diagram
|
||||
is not a dimensioned cutting drawing.
|
||||
|
||||
## Backend, fonts and redistribution
|
||||
|
||||
- Official `PDFsharp-MigraDoc` Core **6.2.4**, with `PDFsharp` **6.2.4**, targeting
|
||||
net8.0. No GDI/WPF dependency or printer driver is used. MigraDoc owns flowing
|
||||
document layout; PDFsharp draws vectors into its measured reserved areas.
|
||||
- DejaVu Sans **2.37**, regular and bold, is bundled unmodified as assembly
|
||||
resources. Source: https://dejavu-fonts.github.io/ ; binary provenance:
|
||||
Ubuntu `fonts-dejavu-core` **2.37-8**. Both required faces are embedded in PDFs;
|
||||
neither generation nor viewing needs a platform font installation.
|
||||
- A custom resolver is installed once before fonts are created. Repeated exports
|
||||
reuse it. An already-installed foreign resolver is an explicit integration
|
||||
error, never silently replaced. A host that already uses PDFsharp needs resolver
|
||||
composition before adding report support. MigraDoc's predefined error font is
|
||||
pointed at the bundled family; its default (`Courier New`) is a platform font
|
||||
the bundled resolver deliberately cannot supply.
|
||||
- The initial text contract is printable ASCII/Latin-1 and line/tab separators.
|
||||
Other codepoints, controls and soft hyphens fail with the field and codepoint;
|
||||
there is no silent missing-glyph substitution. Broader Unicode/shaping support
|
||||
is deferred rather than implied by the font's larger character inventory.
|
||||
- PDFsharp/MigraDoc are MIT-licensed. The actual v6.2.4 license and the Microsoft
|
||||
transitive dependency notices are in `OpenNest.Reporting/THIRD-PARTY-NOTICES.txt`.
|
||||
Font permission/redistribution terms are in `OpenNest.Reporting/Fonts/LICENSE.txt`
|
||||
(Bitstream Vera permission with DejaVu changes in the public domain). Preserve
|
||||
both files in `ReportingNotices/` when publishing; embedded fonts do not replace
|
||||
the obligation to ship their notices. Package notices are deduplicated, not
|
||||
rewritten or shortened.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~Reporting
|
||||
```
|
||||
|
||||
The writer tests read page sizes and content streams with PDFsharp. Text
|
||||
assertions extract with Poppler's `pdftotext -layout` and are skipped, not
|
||||
passed, when poppler-utils is absent. For manual inspection also use `pdfinfo`,
|
||||
`pdffonts` and `pdftoppm -png` (Linux verification baseline: poppler-utils
|
||||
24.02.0). Confirm page sizes, quantity rows, page X of Y, embedded fonts, native
|
||||
vector curves, unfilled holes and visible tab gaps with no connecting stroke.
|
||||
Pixel inspection supplements geometry assertions; it is not a CNC-validation
|
||||
result.
|
||||
|
||||
Windows compilation is not runtime acceptance. The later desktop adapter must be
|
||||
tested on Windows for busy-operation guards, cancellation, overwrite handling,
|
||||
unchanged job state and PDF viewing/printing, including the packaged application.
|
||||
Reference in New Issue
Block a user