diff --git a/OpenNest.Reporting/NestPdfWriter.cs b/OpenNest.Reporting/NestPdfWriter.cs
index 120c9b6..bb9c2b3 100644
--- a/OpenNest.Reporting/NestPdfWriter.cs
+++ b/OpenNest.Reporting/NestPdfWriter.cs
@@ -9,19 +9,23 @@ namespace OpenNest.Reporting;
///
/// Renders a detached to PDF: MigraDoc owns text, tables and
-/// pagination; PDFsharp draws vector thumbnails and the sheet diagram into reserved layout areas.
+/// pagination; PDFsharp draws vector thumbnails and sheet diagrams into reserved layout areas.
///
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 HeaderDistance = 18;
+ private const double HeaderFontSize = 7.5;
+ private const int MaxHeaderLines = 3;
+ private const double BodyFontSize = 9;
+ private const double CellPadding = 3;
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 const double PortraitWidth = 540;
+ private const double LandscapeWidth = 720;
+ private const double OverviewHeight = 320;
+ private const double DetailHeight = 400;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
@@ -46,39 +50,37 @@ public static class NestPdfWriter
private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func? wrapOutput)
{
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();
+
+ // Plan every diagram before layout; nothing below touches the destination until Save.
+ var plans = snapshot.Plates.Select(plate =>
+ NestReportDiagram.Plan(plate, LandscapeWidth, OverviewHeight, DetailHeight)).ToList();
+
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();
+ normal.Font.Size = BodyFontSize;
+ var areas = new List<(Table Table, Action Draw)>();
AddSummary(document, snapshot);
- foreach (var plate in snapshot.Plates)
- diagrams.Add(AddPlate(document, snapshot, plate));
+ for (var index = 0; index < snapshot.Plates.Length; index++)
+ AddPlate(document, snapshot, snapshot.Plates[index], plans[index], areas);
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++)
+ foreach (var (page, area, draw) in LocateAreas(renderer, pageCount, areas))
{
- 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);
+ var state = gfx.Save();
+ gfx.TranslateTransform(area.X, area.Y);
+ gfx.IntersectClip(new XRect(0, 0, area.Width, area.Height));
+ draw(gfx, area);
+ gfx.Restore(state);
}
- 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";
@@ -99,7 +101,7 @@ public static class NestPdfWriter
private static void AddSummary(Document document, NestReportSnapshot snapshot)
{
- var section = AddSection(document, Orientation.Portrait);
+ var section = AddSection(document, Orientation.Portrait, PortraitWidth, $"Nest report: {snapshot.Name}");
var title = section.AddParagraph("Nest Report");
title.Format.Font.Size = 14;
title.Format.Font.Bold = true;
@@ -110,24 +112,28 @@ public static class NestPdfWriter
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));
+ if (snapshot.Notes.Length > 0)
+ {
+ // Free paragraphs split across pages; a table row taller than a page would be clipped.
+ AddHeading(section, "Notes");
+ AddLines(section.AddParagraph(), ReportText.Wrap(snapshot.Notes, BodyFont(false), PortraitWidth));
+ }
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);
+ var plates = AddTable(section, 60, 200, 80, 100, 100);
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization");
foreach (var plate in snapshot.Plates)
- AddRow(plates, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
+ AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
}
@@ -137,23 +143,32 @@ public static class NestPdfWriter
section.AddParagraph("No parts in this job.");
return;
}
- var parts = AddTable(section, 36, 62, 170, 50, 50, 52, 40, 80);
+ // Column widths fit the bold headings on one line (measured, plus padding).
+ var parts = AddTable(section, 36, 62, 138, 56, 50, 56, 42, 100);
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 row = AddRow(parts, $"Drawing {drawing.Id}", false, drawing.Id, null, drawing.Name,
+ drawing.Required.ToString(Invariant), drawing.Nested.ToString(Invariant),
+ drawing.Shortage.ToString(Invariant), drawing.Extra.ToString(Invariant), ReportText.Ranges(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)
+ private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate,
+ PlateDiagramPlan plan, List<(Table, Action)> areas)
{
- var section = AddSection(document, Orientation.Landscape);
- var title = section.AddParagraph($"Plate {plate.Number} of {snapshot.Plates.Length}");
+ var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}";
+ var header = new List { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) };
+ // Unset material/thickness are stated in the page body, not repeated in every header.
+ if (snapshot.Material.Length + snapshot.Grade.Length > 0)
+ header.Add(Material(snapshot));
+ if (snapshot.Thickness > 0)
+ header.Add(Thickness(snapshot));
+ var section = AddSection(document, Orientation.Landscape, LandscapeWidth, string.Join(" | ", header));
+ var title = section.AddParagraph(heading);
title.Format.Font.Size = 13;
title.Format.Font.Bold = true;
@@ -166,96 +181,165 @@ public static class NestPdfWriter
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 overview = AddReservedArea(section, null, plan.Overview.Size.Height);
+ areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan.Overview)));
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);
+ if (plan.Overview.Grid is { } grid)
+ {
+ var names = string.Join(", ", plan.Details.Select(detail => detail.Cell));
+ var fallback = section.AddParagraph();
+ fallback.Format.Font.Size = 7.5;
+ AddLines(fallback, ReportText.Wrap(
+ $"Some part IDs are too small to label at this scale. Grid rows are lettered from the top and columns numbered from the left; cells outlined dash-dot have detail views on the following pages: {names}.",
+ new XFont(ReportFonts.Family, 7.5), LandscapeWidth));
+ }
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 names2 = 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);
+ var parts = AddTable(section, 60, 400, 110, 150);
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));
+ AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id],
+ count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant));
+ }
+
+ foreach (var detail in plan.Details)
+ {
+ var bounds = detail.CellBounds!;
+ var caption = $"Plate {plate.Number} detail {detail.Cell}: X {Number(bounds.Left)} to {Number(bounds.Right)}, Y {Number(bounds.Bottom)} to {Number(bounds.Top)} {snapshot.Units}. Parts centered in the dash-dot cell are labeled.";
+ var table = AddReservedArea(section, caption, detail.Size.Height);
+ areas.Add((table, (gfx, _) => DrawView(gfx, plate, detail)));
}
- return diagram;
}
- private static List<(int Page, XRect Area)> LocateDiagrams(PdfDocumentRenderer renderer, int pageCount, List diagrams)
+ /// A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.
+ private static Table AddReservedArea(Section section, string? caption, double height)
{
- var found = new (int Page, XRect Area)?[diagrams.Count];
+ var table = AddTable(section, LandscapeWidth);
+ table.Borders.Visible = false;
+ table.LeftPadding = table.RightPadding = Unit.Zero;
+ table.TopPadding = table.BottomPadding = Unit.Zero;
+ table.Format.SpaceBefore = Unit.FromPoint(4);
+ if (caption != null)
+ {
+ var row = AddRow(table, "Detail caption", true, caption);
+ row.KeepWith = 1;
+ }
+ var reserved = table.AddRow();
+ reserved.HeightRule = RowHeightRule.Exactly;
+ reserved.Height = Unit.FromPoint(height);
+ return table;
+ }
+
+ private static IEnumerable<(int Page, XRect Area, Action Draw)> LocateAreas(
+ PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action Draw)> areas)
+ {
+ var found = new (int Page, XRect Area)?[areas.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));
+ var index = areas.FindIndex(area => ReferenceEquals(area.Table, info.DocumentObject));
if (index < 0)
continue;
+ var table = areas[index].Table;
+ // Only the page holding the reserved (last) row receives the drawing.
+ if (info.FormatInfo is not TableFormatInfo format || format.EndRow != table.Rows.Count - 1)
+ 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));
+ throw new InvalidOperationException($"Report drawing area {index + 1} was laid out twice.");
+ var content = info.LayoutInfo.ContentArea;
+ var height = table.Rows[table.Rows.Count - 1].Height.Point;
+ found[index] = (page, new XRect(content.X.Point, content.Y.Point + content.Height.Point - height,
+ content.Width.Point, height));
}
}
- return found.Select((item, index) => item
- ?? throw new NotSupportedException($"Plate {index + 1}: a missing diagram area is not supported by this report slice.")).ToList();
+ for (var index = 0; index < areas.Count; index++)
+ {
+ var (page, area) = found[index]
+ ?? throw new InvalidOperationException($"Report drawing area {index + 1} was not laid out.");
+ yield return (page, area, areas[index].Draw);
+ }
}
- private static void DrawPlate(XGraphics gfx, NestReportSnapshot snapshot, ReportPlate plate, XRect area)
+ private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view)
{
- 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));
- }
-
+ var fit = view.Fit;
+ // Detail views clip exactly at their frame line so cropped neighbours stop there.
+ var frame = new XRect(0.5, 0.5, view.Size.Width - 1, view.Size.Height - 1);
+ var state = gfx.Save();
+ if (view.CellBounds != null)
+ gfx.IntersectClip(frame);
+ // The map grid lies beneath the sheet and parts so it never hides an outline.
+ if (view.Grid is { } grid)
+ DrawGrid(gfx, grid, fit);
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 };
+ // PDFsharp multiplies dash patterns by the pen width; the preset styles are so short at
+ // these thin widths that they print as solid lines.
+ var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] };
foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff);
- foreach (var (id, box) in labels)
+ // Long dash-dot, so detail cells are never confused with dashed scrap cutoffs.
+ var detail = new XPen(XColors.Black, 1.6) { DashPattern = [9, 3, 1, 3] };
+ if (view.Grid is { } overviewGrid)
{
- gfx.DrawRectangle(XBrushes.White, box);
- gfx.DrawString(id, font, XBrushes.Black, box, XStringFormats.Center);
+ foreach (var (row, column) in overviewGrid.DetailCells)
+ gfx.DrawRectangle(detail, fit.Rect(overviewGrid.Cell(row, column)));
+ }
+ if (view.CellBounds is { } cell)
+ gfx.DrawRectangle(detail, fit.Rect(cell));
+ var font = NestReportDiagram.LabelFont();
+ foreach (var label in view.Labels)
+ {
+ gfx.DrawRectangle(XBrushes.White, label.Box);
+ gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center);
+ }
+ gfx.Restore(state);
+ // Frame the viewport so cropped neighbouring parts read as intentional.
+ if (view.CellBounds != null)
+ gfx.DrawRectangle(new XPen(XColors.Gray, 0.75), frame);
+ }
+
+ private static void DrawGrid(XGraphics gfx, DiagramGrid grid, Fit fit)
+ {
+ var line = new XPen(XColors.Gray, 0.3) { DashPattern = [3, 6] };
+ var area = fit.Rect(grid.Union);
+ for (var column = 0; column <= grid.Columns; column++)
+ {
+ var x = area.X + column * grid.CellLength * fit.Scale;
+ gfx.DrawLine(line, x, area.Y, x, area.Y + grid.Rows * grid.CellWidth * fit.Scale);
+ }
+ for (var row = 0; row <= grid.Rows; row++)
+ {
+ var y = area.Y + row * grid.CellWidth * fit.Scale;
+ gfx.DrawLine(line, area.X, y, area.X + grid.Columns * grid.CellLength * fit.Scale, y);
+ }
+ var font = NestReportDiagram.LabelFont();
+ for (var column = 0; column < grid.Columns; column++)
+ {
+ var x = area.X + (column + 0.5) * grid.CellLength * fit.Scale;
+ gfx.DrawString((column + 1).ToString(Invariant), font, XBrushes.Black,
+ new XRect(x - 20, area.Y - 11, 40, 10), XStringFormats.BottomCenter);
+ }
+ for (var row = 0; row < grid.Rows; row++)
+ {
+ var y = area.Y + (row + 0.5) * grid.CellWidth * fit.Scale;
+ gfx.DrawString(ReportText.RowName(row), font, XBrushes.Black,
+ new XRect(area.X - 16, y - 5, 14, 10), XStringFormats.CenterRight);
}
}
@@ -329,15 +413,32 @@ public static class NestPdfWriter
private static double Degrees(double radians) => radians * 180 / System.Math.PI;
- private static Section AddSection(Document document, Orientation orientation)
+ private static XFont BodyFont(bool bold) =>
+ new(ReportFonts.Family, BodyFontSize, bold ? XFontStyleEx.Bold : XFontStyleEx.Regular);
+
+ private static Section AddSection(Document document, Orientation orientation, double width, string header)
{
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.LeftMargin = setup.RightMargin = Unit.FromPoint(Margin);
setup.BottomMargin = Unit.FromPoint(BottomMargin);
setup.FooterDistance = Unit.FromPoint(20);
+
+ // Every page, including continuations, identifies its job and plate. MigraDoc does not
+ // push the body below a tall header, so size the top margin from the wrapped header.
+ var headerFont = new XFont(ReportFonts.Family, HeaderFontSize);
+ var lines = ReportText.Wrap(header, headerFont, width);
+ if (lines.Count > MaxHeaderLines)
+ throw new NotSupportedException($"Page header (nest name): text needs {lines.Count} lines; this report supports at most {MaxHeaderLines}.");
+ setup.HeaderDistance = Unit.FromPoint(HeaderDistance);
+ setup.TopMargin = Unit.FromPoint(HeaderDistance + lines.Count * headerFont.GetHeight() + 8);
+ var top = section.Headers.Primary.AddParagraph();
+ top.Format.Font.Size = HeaderFontSize;
+ top.Format.Font.Color = Colors.DimGray;
+ AddLines(top, lines);
+
var footer = section.Footers.Primary.AddParagraph("Page ");
footer.AddPageField();
footer.AddText(" of ");
@@ -354,6 +455,7 @@ public static class NestPdfWriter
heading.Format.Font.Size = 11;
heading.Format.SpaceBefore = Unit.FromPoint(10);
heading.Format.SpaceAfter = Unit.FromPoint(4);
+ heading.Format.KeepWithNext = true;
}
private static Table AddTable(Section section, params double[] widths)
@@ -361,16 +463,17 @@ public static class NestPdfWriter
var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray;
- table.LeftPadding = table.RightPadding = Unit.FromPoint(3);
+ table.LeftPadding = table.RightPadding = Unit.FromPoint(CellPadding);
table.TopPadding = table.BottomPadding = Unit.FromPoint(1.5);
foreach (var width in widths)
table.AddColumn(Unit.FromPoint(width));
return table;
}
+ /// Heading rows repeat at the top of every continuation page.
private static void AddHeader(Table table, params string[] cells)
{
- var row = AddRow(table, cells);
+ var row = AddRow(table, "Table heading", true, cells);
row.HeadingFormat = true;
row.Format.Font.Bold = true;
row.Shading.Color = Colors.LightGray;
@@ -378,32 +481,46 @@ public static class NestPdfWriter
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;
+ var row = table.AddRow();
+ row.VerticalAlignment = VerticalAlignment.Top;
+ for (var index = 0; index < cells.Length; index++)
+ {
+ var bold = index % 2 == 0;
+ FillCell(table, row, index, cells[index], bold, $"{cells[index - index % 2].TrimEnd(':')} field");
+ if (bold)
+ row.Cells[index].Format.Font.Bold = true;
+ }
}
- /// Adds text cells; a null cell is left empty for non-text content.
- private static Row AddRow(Table table, params string?[] cells)
+ /// Adds wrapped text cells; a null cell is left empty for non-text content.
+ private static Row AddRow(Table table, string context, bool bold, 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]);
- }
+ if (cells[index] != null)
+ FillCell(table, row, index, cells[index]!, bold, context);
}
return row;
}
+ private static void FillCell(Table table, Row row, int index, string text, bool bold, string context)
+ {
+ var width = table.Columns[index].Width.Point - table.LeftPadding.Point - table.RightPadding.Point;
+ AddLines(row.Cells[index].AddParagraph(), ReportText.Cell(text, BodyFont(bold), width, context));
+ }
+
+ private static void AddLines(Paragraph paragraph, IReadOnlyList lines)
+ {
+ for (var line = 0; line < lines.Count; line++)
+ {
+ if (line > 0)
+ paragraph.AddLineBreak();
+ paragraph.AddText(lines[line]);
+ }
+ }
+
private static string Material(NestReportSnapshot snapshot)
{
var material = $"{snapshot.Material} {snapshot.Grade}".Trim();
@@ -420,32 +537,4 @@ public static class NestPdfWriter
private static string Number(double value) => value.ToString("0.####", Invariant);
private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%";
-
- /// One aspect-preserving model-to-page transform, centered, with the Y axis flipped.
- 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(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);
- }
- }
}
diff --git a/OpenNest.Reporting/NestReportDiagram.cs b/OpenNest.Reporting/NestReportDiagram.cs
new file mode 100644
index 0000000..9bfbc32
--- /dev/null
+++ b/OpenNest.Reporting/NestReportDiagram.cs
@@ -0,0 +1,333 @@
+using OpenNest.Geometry;
+using PdfSharp.Drawing;
+
+namespace OpenNest.Reporting;
+
+/// A part ID label in canvas-local page coordinates (Y down).
+internal sealed record DiagramLabel(int Part, string Id, XRect Box);
+
+/// Map grid over the overview; rows are lettered from the top, columns numbered from the left.
+internal sealed record DiagramGrid(ReportBounds Union, double CellLength, double CellWidth, int Columns, int Rows,
+ IReadOnlyList<(int Row, int Column)> DetailCells)
+{
+ public string Name(int row, int column) => ReportText.RowName(row) + (column + 1).ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ public ReportBounds Cell(int row, int column)
+ {
+ var left = Union.Left + column * CellLength;
+ var top = Union.Top - row * CellWidth;
+ return new ReportBounds(left, top - CellWidth, left + CellLength, top);
+ }
+}
+
+/// One drawn view of a plate: the fitted overview or a zoomed grid-cell detail.
+internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList Labels,
+ DiagramGrid? Grid, string? Cell, ReportBounds? CellBounds);
+
+internal sealed record PlateDiagramPlan(DiagramView Overview, IReadOnlyList Details);
+
+///
+/// Plans labels before any PDF output. IDs are never shrunk below or
+/// dropped: a label that cannot sit legibly inside its part's material at overview scale is placed
+/// in a zoomed map-grid detail view instead, or the report fails with the part identified.
+///
+internal static class NestReportDiagram
+{
+ internal const double LabelFontSize = 7;
+ internal const int MaxDetailViews = 24;
+ private const double LabelPadding = 1;
+ // Minimum white space between a label and any edge, hole, cutoff or other label.
+ private const double Clearance = 1.5;
+ private const double Inset = 5;
+ private const double GutterLeft = 16;
+ private const double GutterTop = 12;
+ private const double DetailMargin = 0.08;
+ private static readonly double[] Zooms = [1, 2, 4];
+
+ internal static XFont LabelFont()
+ {
+ ReportFonts.Initialize();
+ return new(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold);
+ }
+
+ /// Chord tolerance for placement-only flattening, relative to the part size.
+ internal static double ChordTolerance(ReportBounds bounds) =>
+ System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.0005, 1e-9);
+
+ /// PolyLabel search precision, relative to the part size.
+ internal static double PolePrecision(ReportBounds bounds) =>
+ System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.005, 1e-9);
+
+ internal static PlateDiagramPlan Plan(ReportPlate plate, double width, double overviewHeight, double detailHeight)
+ {
+ var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList();
+ var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList();
+ var union = Union(plate, obstacles);
+ var font = LabelFont();
+ var all = Enumerable.Range(0, plate.Parts.Length).ToList();
+ var canvas = new XSize(width, overviewHeight);
+ var overviewView = new XRect(0, 0, width, overviewHeight);
+
+ var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, overviewHeight - 2 * Inset));
+ var labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out var failed);
+ if (failed.Count == 0)
+ return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, null, null, null), []);
+
+ // Reserve map-grid gutters, then re-place what still fits at overview scale.
+ fit = Fit.Create(union, new XRect(GutterLeft, GutterTop, width - GutterLeft - Inset, overviewHeight - GutterTop - Inset));
+ labels = PlaceLabels(plate, shapes, obstacles, all, fit, overviewView, font, out failed);
+ var failedSet = failed.ToHashSet();
+ var required = failed.Max(index => RequiredScale(plate, index, font));
+ var inner = new XRect(Inset, Inset, width - 2 * Inset, detailHeight - 2 * Inset);
+ var legibleGrid = false;
+ foreach (var zoom in Zooms)
+ {
+ var scale = required * zoom;
+ // Cells evenly divide the sheet (no overrun); each is at most the size a detail view
+ // shows at the required scale, so the zoom only increases.
+ var columns = System.Math.Max(1, (int)System.Math.Ceiling(union.Length * scale * (1 + 2 * DetailMargin) / inner.Width));
+ var rows = System.Math.Max(1, (int)System.Math.Ceiling(union.Width * scale * (1 + 2 * DetailMargin) / inner.Height));
+ var cellLength = union.Length / columns;
+ var cellWidth = union.Width / rows;
+ var grid = new DiagramGrid(union, cellLength, cellWidth, columns, rows, []);
+ // Every grid line and gutter name must stay readable on the overview.
+ if (!GridIsLegible(grid, fit, font))
+ continue;
+ legibleGrid = true;
+ (int Row, int Column) CellOf(int index) => CellIndex(plate, index, union, columns, rows);
+
+ var cells = failed.Select(CellOf).Distinct().OrderBy(cell => cell.Row).ThenBy(cell => cell.Column).ToList();
+ if (cells.Count > MaxDetailViews)
+ throw new NotSupportedException($"Plate {plate.Number}: legible part ID labels would need {cells.Count} detail views; this report supports at most {MaxDetailViews} per plate.");
+ grid = grid with { DetailCells = cells };
+ var details = new List();
+ var unplaced = new List();
+ foreach (var (row, column) in cells)
+ {
+ var cell = grid.Cell(row, column);
+ var marginX = cellLength * DetailMargin;
+ var marginY = cellWidth * DetailMargin;
+ var view = new ReportBounds(cell.Left - marginX, cell.Bottom - marginY, cell.Right + marginX, cell.Top + marginY);
+ var detailFit = Fit.Create(view, inner);
+ // Parts missing from the overview claim label space first.
+ var members = all.Where(index => CellOf(index) == (row, column))
+ .OrderBy(index => failedSet.Contains(index) ? 0 : 1).ToList();
+ var detailLabels = PlaceLabels(plate, shapes, obstacles, members, detailFit,
+ new XRect(0, 0, width, detailHeight), font, out var missed);
+ unplaced.AddRange(missed.Where(failedSet.Contains));
+ details.Add(new DiagramView(new XSize(width, detailHeight), detailFit, detailLabels, null,
+ grid.Name(row, column), cell));
+ }
+ if (unplaced.Count == 0)
+ return new PlateDiagramPlan(new DiagramView(canvas, fit, labels, grid, null, null), details);
+ }
+
+ var worst = failed.First();
+ if (!legibleGrid)
+ throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): the part is too small relative to the sheet for a legible detail-view grid; this is not supported by the report.");
+ throw new NotSupportedException($"Plate {plate.Number}, part {worst + 1} ({plate.Parts[worst].ReportId}): its ID label cannot be placed legibly inside the part's material, even in a zoomed detail view; this is not supported by the report.");
+ }
+
+ /// Grid cell holding a part's bounds center; rows count down from the top.
+ private static (int Row, int Column) CellIndex(ReportPlate plate, int index, ReportBounds union, int columns, int rows)
+ {
+ var bounds = plate.Parts[index].Geometry.Bounds;
+ var x = (bounds.Left + bounds.Right) / 2;
+ var y = (bounds.Bottom + bounds.Top) / 2;
+ return (System.Math.Clamp((int)((union.Top - y) / (union.Width / rows)), 0, rows - 1),
+ System.Math.Clamp((int)((x - union.Left) / (union.Length / columns)), 0, columns - 1));
+ }
+
+ /// Grid spacing on the overview must exceed the gutter names it carries.
+ internal static bool GridIsLegible(DiagramGrid grid, Fit fit, XFont font)
+ {
+ var column = ReportText.Size(grid.Columns.ToString(System.Globalization.CultureInfo.InvariantCulture), font);
+ var row = ReportText.Size(ReportText.RowName(grid.Rows - 1), font);
+ return grid.CellLength * fit.Scale >= column.Width + 2 && grid.CellWidth * fit.Scale >= row.Height + 1
+ && row.Width + 2 <= GutterLeft && column.Height + 2 <= GutterTop;
+ }
+
+ ///
+ /// Centers each ID on its part's pole of inaccessibility (, as PlateView
+ /// does), so it sits deepest inside the material and clear of holes. A label box that would
+ /// touch an edge, hole, cutoff or another label is not moved elsewhere: the part fails here
+ /// and gets a zoomed detail view instead.
+ ///
+ private static List PlaceLabels(ReportPlate plate, List shapes, List obstacles,
+ List targets, Fit fit, XRect view, XFont font, out List failed)
+ {
+ failed = [];
+ var placed = new List();
+ foreach (var index in targets)
+ {
+ var id = plate.Parts[index].ReportId;
+ var size = ReportText.Size(id, font);
+ var width = size.Width + 2 * LabelPadding;
+ var height = size.Height + 2 * LabelPadding;
+ var pole = fit.Point(shapes[index].Pole.X, shapes[index].Pole.Y);
+ var box = new XRect(pole.X - width / 2, pole.Y - height / 2, width, height);
+ var topLeft = fit.Model(new XPoint(box.X - Clearance, box.Y - Clearance));
+ var bottomRight = fit.Model(new XPoint(box.Right + Clearance, box.Bottom + Clearance));
+ var test = new Box(topLeft.X, bottomRight.Y, bottomRight.X - topLeft.X, topLeft.Y - bottomRight.Y);
+ if (view.Contains(box) && !placed.Any(label => label.Box.IntersectsWith(box))
+ && !obstacles.Any(shape => shape.Crosses(test)))
+ placed.Add(new DiagramLabel(index, id, box));
+ else
+ failed.Add(index);
+ }
+ return placed;
+ }
+
+ /// Scale at which the label would fit the part's bounds, with room to avoid holes and strokes.
+ private static double RequiredScale(ReportPlate plate, int index, XFont font)
+ {
+ var bounds = plate.Parts[index].Geometry.Bounds;
+ if (!(bounds.Length > 0) || !(bounds.Width > 0))
+ throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part has no area to hold its ID label; this is not supported by the report.");
+ var size = ReportText.Size(plate.Parts[index].ReportId, font);
+ var scale = System.Math.Max((size.Width + 2 * (LabelPadding + Clearance)) / bounds.Length,
+ (size.Height + 2 * (LabelPadding + Clearance)) / bounds.Width) * 1.5;
+ if (!double.IsFinite(scale))
+ throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({plate.Parts[index].ReportId}): the part is too small to hold a legible ID label.");
+ return scale;
+ }
+
+ private static ReportBounds Union(ReportPlate plate, IEnumerable shapes)
+ {
+ var left = plate.Bounds.Left;
+ var bottom = plate.Bounds.Bottom;
+ var right = plate.Bounds.Right;
+ var top = plate.Bounds.Top;
+ foreach (var shape in shapes)
+ {
+ left = System.Math.Min(left, shape.Bounds.Left);
+ bottom = System.Math.Min(bottom, shape.Bounds.Bottom);
+ right = System.Math.Max(right, shape.Bounds.Right);
+ top = System.Math.Max(top, shape.Bounds.Top);
+ }
+ return new ReportBounds(left, bottom, right, top);
+ }
+
+ ///
+ /// Contours flattened with the same chord-tolerance routine as overlap/validation region
+ /// preparation, used only for label placement and never for drawing. Open contours are
+ /// closed here so a label cannot sit in a tab gap; rendering never closes them.
+ ///
+ private sealed class Shape
+ {
+ public Shape(ReportGeometry geometry)
+ {
+ Bounds = geometry.Bounds;
+ Box = new Box(Bounds.Left, Bounds.Bottom, Bounds.Length, Bounds.Width);
+ Rings = geometry.Contours.Select(contour => ClipperBridge.Flatten(ToShape(contour),
+ ChordTolerance(Bounds), circumscribe: false)).ToList();
+ lazyPole = new Lazy(FindPole);
+ }
+
+ private readonly Lazy lazyPole;
+
+ public ReportBounds Bounds { get; }
+
+ /// Pole of inaccessibility: the material point farthest from every edge.
+ public Vector Pole => lazyPole.Value;
+
+ private Box Box { get; }
+
+ private List Rings { get; }
+
+ ///
+ /// Like PlateView, which finds the pole once in the drawing's own frame, search a
+ /// placement-independent copy: symmetric shapes (a square with a central hole) have
+ /// several equally deep poles, and sheet-position roundoff must not pick different ones
+ /// for identical parts. Largest ring is the perimeter, the rest holes (ShapeProfile's rule).
+ ///
+ private Vector FindPole()
+ {
+ if (Rings.Count == 0)
+ return new Vector();
+ var grain = System.Math.Max(Bounds.Length, Bounds.Width) * 1e-9;
+ Polygon Local(Polygon ring)
+ {
+ var local = new Polygon();
+ local.Vertices.AddRange(ring.Vertices.Select(vertex => new Vector(
+ System.Math.Round((vertex.X - Bounds.Left) / grain) * grain,
+ System.Math.Round((vertex.Y - Bounds.Bottom) / grain) * grain)));
+ return local;
+ }
+ var ordered = Rings.OrderByDescending(ring => ring.Area()).Select(Local).ToList();
+ var pole = PolyLabel.Find(ordered[0], ordered.Skip(1).ToList(), PolePrecision(Bounds));
+ return new Vector(pole.X + Bounds.Left, pole.Y + Bounds.Bottom);
+ }
+
+ /// Whether any contour edge lies in or crosses the model-space rectangle.
+ public bool Crosses(Box box)
+ {
+ if (!Box.Intersects(box))
+ return false;
+ var outline = new Polygon();
+ outline.Vertices.AddRange([new Vector(box.Left, box.Bottom), new Vector(box.Right, box.Bottom),
+ new Vector(box.Right, box.Top), new Vector(box.Left, box.Top)]);
+ outline.Close();
+ return Rings.Any(ring => ring.Vertices.Any(box.Contains) || ring.Intersects(outline));
+ }
+
+ private static OpenNest.Geometry.Shape ToShape(ReportContour contour)
+ {
+ var shape = new OpenNest.Geometry.Shape();
+ foreach (var segment in contour.Segments)
+ {
+ if (segment.Center == null)
+ {
+ shape.Entities.Add(new Line(ToVector(segment.Start), ToVector(segment.End)));
+ continue;
+ }
+ var center = ToVector(segment.Center);
+ if (System.Math.Abs(segment.SweepAngle) >= 2 * System.Math.PI)
+ {
+ shape.Entities.Add(new Circle(center, segment.Radius)
+ {
+ Rotation = segment.SweepAngle < 0 ? RotationType.CW : RotationType.CCW,
+ });
+ continue;
+ }
+ // A reversed arc runs from StartAngle toward the (smaller) EndAngle.
+ shape.Entities.Add(new Arc(center, segment.Radius, segment.StartAngle,
+ segment.StartAngle + segment.SweepAngle, reversed: segment.SweepAngle < 0));
+ }
+ return shape;
+ }
+
+ private static Vector ToVector(ReportPoint point) => new(point.X, point.Y);
+ }
+}
+
+/// One aspect-preserving model-to-canvas transform, centered, with the Y axis flipped.
+internal readonly record struct Fit(ReportBounds Bounds, double Scale, double X, double Y)
+{
+ public static Fit Create(ReportBounds bounds, XRect area)
+ {
+ var scales = new List(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) => Point(point.X, point.Y);
+
+ public XPoint Point(double x, double y) => new(X + (x - Bounds.Left) * Scale, Y + (Bounds.Top - y) * Scale);
+
+ public XPoint Model(XPoint local) => new(Bounds.Left + (local.X - X) / Scale, Bounds.Top - (local.Y - Y) / Scale);
+
+ public XRect Rect(ReportBounds box)
+ {
+ var low = Point(box.Left, box.Top);
+ return new XRect(low.X, low.Y, box.Length * Scale, box.Width * Scale);
+ }
+}
diff --git a/OpenNest.Reporting/ReportText.cs b/OpenNest.Reporting/ReportText.cs
new file mode 100644
index 0000000..11ba4e2
--- /dev/null
+++ b/OpenNest.Reporting/ReportText.cs
@@ -0,0 +1,122 @@
+using System.Globalization;
+using System.Text;
+using PdfSharp.Drawing;
+
+namespace OpenNest.Reporting;
+
+///
+/// Lossless pre-wrapping. MigraDoc lets an unbroken token overflow a table cell and silently
+/// clips a row taller than the page, so cell text is wrapped here and bounded before layout.
+///
+internal static class ReportText
+{
+ /// Line cap for one table cell; taller rows would be clipped by MigraDoc.
+ internal const int MaxCellLines = 20;
+
+ // Headless measurement only; PDF output uses the same bundled font metrics.
+ private static readonly XGraphics Measure = XGraphics.CreateMeasureContext(
+ new XSize(2000, 2000), XGraphicsUnit.Point, XPageDirection.Downwards);
+ private static readonly object Sync = new();
+
+ internal static XSize Size(string text, XFont font)
+ {
+ lock (Sync)
+ return Measure.MeasureString(text, font);
+ }
+
+ ///
+ /// Splits explicit line breaks, then wraps at spaces, and breaks tokens wider than
+ /// between characters. Only the space at a wrap point is consumed.
+ ///
+ internal static List Wrap(string text, XFont font, double width)
+ {
+ // Leave slack so MigraDoc does not re-break an already fitted line.
+ var limit = width - 1;
+ var lines = new List();
+ foreach (var hard in text.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\t', ' ').Split('\n'))
+ {
+ var line = new StringBuilder();
+ foreach (var word in hard.Split(' '))
+ {
+ var candidate = line.Length == 0 ? word : line + " " + word;
+ if (Size(candidate, font).Width <= limit)
+ {
+ line.Clear().Append(candidate);
+ continue;
+ }
+ if (line.Length > 0)
+ {
+ lines.Add(line.ToString());
+ line.Clear();
+ }
+ var rest = word;
+ while (Size(rest, font).Width > limit)
+ {
+ var take = FittingPrefix(rest, font, limit);
+ lines.Add(rest[..take]);
+ rest = rest[take..];
+ }
+ line.Append(rest);
+ }
+ lines.Add(line.ToString());
+ }
+ return lines;
+ }
+
+ /// Wraps table-cell text and rejects a row MigraDoc could not show completely.
+ internal static List Cell(string text, XFont font, double width, string field)
+ {
+ var lines = Wrap(text, font, width);
+ if (lines.Count > MaxCellLines)
+ throw new NotSupportedException($"{field}: text needs {lines.Count} lines in its table cell; this report supports at most {MaxCellLines}.");
+ return lines;
+ }
+
+ /// Compresses ascending plate numbers losslessly, e.g. "1-3, 5".
+ internal static string Ranges(IReadOnlyList numbers)
+ {
+ if (numbers.Count == 0)
+ return "-";
+ var parts = new List();
+ var start = numbers[0];
+ var previous = start;
+ for (var index = 1; index <= numbers.Count; index++)
+ {
+ if (index < numbers.Count && numbers[index] == previous + 1)
+ {
+ previous = numbers[index];
+ continue;
+ }
+ parts.Add(start == previous
+ ? start.ToString(CultureInfo.InvariantCulture)
+ : $"{start.ToString(CultureInfo.InvariantCulture)}-{previous.ToString(CultureInfo.InvariantCulture)}");
+ if (index < numbers.Count)
+ start = previous = numbers[index];
+ }
+ return string.Join(", ", parts);
+ }
+
+ /// Map-style row names: A..Z, AA, AB, ...
+ internal static string RowName(int index)
+ {
+ var name = "";
+ for (var value = index + 1; value > 0; value = (value - 1) / 26)
+ name = (char)('A' + (value - 1) % 26) + name;
+ return name;
+ }
+
+ private static int FittingPrefix(string text, XFont font, double limit)
+ {
+ // At least one character always advances, even if a single glyph is wider than the cell.
+ int low = 1, high = text.Length - 1;
+ while (low < high)
+ {
+ var middle = (low + high + 1) / 2;
+ if (Size(text[..middle], font).Width <= limit)
+ low = middle;
+ else
+ high = middle - 1;
+ }
+ return low;
+ }
+}
diff --git a/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs b/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs
new file mode 100644
index 0000000..fd428cf
--- /dev/null
+++ b/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs
@@ -0,0 +1,427 @@
+using System.Text.RegularExpressions;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.IO;
+using OpenNest.Reporting;
+using PdfSharp.Pdf.IO;
+
+namespace OpenNest.Tests.Reporting;
+
+/// Slice 2: pagination, overflow, dense labels and late failures on real PDFs.
+public sealed class NestPdfLayoutTests : IDisposable
+{
+ private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-layout-" + Guid.NewGuid().ToString("N"));
+
+ public NestPdfLayoutTests() => Directory.CreateDirectory(directory);
+
+ public void Dispose() => Directory.Delete(directory, true);
+
+ [SkippableFact]
+ public void Write_MultiPlateJobHasOnePagePerPlateWithTotalsAcrossPlatesAndSameNamedReferences()
+ {
+ var path = Write(NestReportTestData.CreateMultiPlateNest());
+ var pages = ReportPdf.Pages(path);
+
+ Assert.Equal(5, pages.Length);
+ using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import))
+ {
+ Assert.Equal((612, 792), ReportPdf.EffectiveSize(pdf.Pages[0]));
+ for (var page = 1; page < 5; page++)
+ Assert.Equal((792, 612), ReportPdf.EffectiveSize(pdf.Pages[page]));
+ }
+ var summary = pages[0];
+ Assert.Matches(@"Distinct layouts:\s+4", summary);
+ Assert.Matches(@"Total physical sheets:\s+7", summary);
+ // Bracket R001: 2/sheet x 2 + 1 x 3 + 1 x 1 = 8. Same-named R003: 1 x 2 + 1 x 1 = 3.
+ Assert.Matches(@"R001\s+Bracket\s+5\s+8\s+0\s+3\s+1-2, 4", summary);
+ Assert.Matches(@"R002\s+Rotated\s+1\s+3\s+0\s+2\s+1, 3", summary);
+ Assert.Matches(@"R003\s+Bracket\s+7\s+3\s+4\s+0\s+1, 3", summary);
+ Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
+ Assert.Matches(@"3\s+60 x 30 in\s+1\s+2", summary);
+ for (var plate = 1; plate <= 4; plate++)
+ {
+ var page = pages[plate];
+ Assert.Contains($"Plate {plate} of 4", page);
+ Assert.Contains($"Page {plate + 1} of 5", page);
+ Assert.Contains("Nest report: Report test job", page);
+ }
+ Assert.Matches(@"R001\s+Bracket\s+1\s+3", pages[2]);
+ Assert.Matches(@"R002\s+Rotated\s+1\s+1", pages[3]);
+ Assert.Matches(@"R003\s+Bracket\s+1\s+1", pages[3]);
+ Assert.DoesNotContain("R001", pages[3]);
+ }
+
+ [SkippableFact]
+ public void Write_ManyDrawingsAndPartsContinueTablesWithRepeatedHeadersAndNoLostRows()
+ {
+ var nest = NestReportTestData.CreateMultiPlateNest();
+ var plate = nest.Plates[0];
+ for (var i = 0; i < 70; i++)
+ {
+ var drawing = NestReportTestData.Rectangle($"Demand {i:D2}", 1, 1, i + 1);
+ nest.Drawings.Add(drawing);
+ if (i < 40)
+ plate.Parts.Add(new Part(drawing, new Vector(1 + i % 20 * 2.2, 14 + i / 20 * 2.2)));
+ }
+ var path = Write(nest);
+ var pages = ReportPdf.Pages(path);
+ var total = pages.Length;
+
+ var summaryPages = pages.TakeWhile(page => !page.Contains("Plate 1 of 4")).ToList();
+ Assert.True(summaryPages.Count >= 2, $"summary pages: {summaryPages.Count}");
+ var summaryText = string.Join("\n", summaryPages);
+ // Every row exactly once; the Parts heading row repeats on every continuation page.
+ for (var id = 1; id <= 74; id++)
+ Assert.Single(Regex.Matches(summaryText, $@"(?m)^\s*R{id:D3}\s"));
+ Assert.All(summaryPages.Skip(1), page => Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage", page));
+ // Placements first (R004-R043 on plate 1), then unplaced demand by ordinal name.
+ Assert.Matches(@"R043\s+Demand 39\s+40\s+2\s+38\s+0\s+1", summaryText);
+ Assert.Matches(@"R073\s+Demand 69\s+70\s+0\s+70\s+0\s+-", summaryText);
+ Assert.Matches(@"R074\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summaryText);
+
+ var platePages = pages.Where(page => page.Contains("Plate 1 of 4")).ToList();
+ Assert.True(platePages.Count >= 2, $"plate 1 pages: {platePages.Count}");
+ Assert.All(platePages, page => Assert.Contains("Nest report: Report test job", page));
+ // Every page carrying plate-table rows repeats the table heading.
+ var tablePages = platePages.Where(page => Regex.IsMatch(page, @"(?m)^R\d{3}\s+Demand \d{2}\s+1\s+2$")).ToList();
+ Assert.True(tablePages.Count >= 2, $"plate table pages: {tablePages.Count}");
+ Assert.All(tablePages, page => Assert.Matches(@"ID\s+Drawing\s+Qty/sheet\s+Total \(2 copies\)", page));
+ var plateText = string.Join("\n", platePages);
+ for (var i = 0; i < 40; i++)
+ Assert.Matches($@"R{i + 4:D3}\s+Demand {i:D2}\s+1\s+2", plateText);
+ Assert.DoesNotContain("Demand 40", plateText);
+ for (var page = 1; page <= total; page++)
+ Assert.Contains($"Page {page} of {total}", pages[page - 1]);
+ }
+
+ [SkippableFact]
+ public void Write_LongNamesAndNotesWrapWithoutLosingTextOrOverflowingColumns()
+ {
+ var nest = NestReportTestData.CreateMultiPlateNest();
+ var longName = "PN-" + string.Concat(Enumerable.Repeat("ABCDEFGHIJ", 7)) + " revision C with long description";
+ nest.Plates[0].Parts[2].BaseDrawing.Name = longName;
+ nest.Notes = string.Join(" ", Enumerable.Range(0, 900).Select(i => $"note{i}"));
+ nest.Customer = "Customer " + string.Concat(Enumerable.Repeat("X", 120));
+ var path = Write(nest);
+ var pages = ReportPdf.Pages(path);
+
+ var all = string.Join("\n", pages);
+ var notes = Regex.Matches(all, @"note(\d+)").Select(match => int.Parse(match.Groups[1].Value)).ToList();
+ Assert.Equal(Enumerable.Range(0, 900), notes);
+ Assert.Contains(string.Concat(Enumerable.Repeat("X", 120)), Regex.Replace(all, @"\s+", ""));
+ // The long drawing name is wrapped inside its column on the summary and plate tables.
+ var summaryPage = Array.FindIndex(pages, page => page.Contains("Required") && page.Contains("R002"));
+ var platePage = Array.FindIndex(pages, page => page.Contains("Plate 1 of 4"));
+ Assert.True(summaryPage >= 0 && platePage > summaryPage);
+ Assert.Equal(longName.Replace(" ", ""), ColumnText(path, summaryPage + 1, "Drawing", "Required"));
+ Assert.Equal(longName.Replace(" ", ""), ColumnText(path, platePage + 1, "Drawing", "Qty/sheet"));
+ // Column headings stay on one line.
+ Assert.Matches(@"ID\s+Part\s+Drawing\s+Required\s+Nested\s+Shortage\s+Extra\s+Plates", pages[summaryPage]);
+ }
+
+ [SkippableTheory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void Write_MillimeterJobInEveryQuadrantDrawsInsideTheSheetOutline(int quadrant)
+ {
+ var nest = NestReportTestData.CreateMultiPlateNest();
+ nest.Units = Units.Millimeters;
+ nest.Thickness = 3;
+ foreach (var plate in nest.Plates)
+ {
+ plate.Quadrant = quadrant;
+ // Move every placement (and cutoff) into the sheet's own quadrant.
+ var bounds = plate.BoundingBox(false);
+ foreach (var part in plate.Parts)
+ part.Offset(bounds.Left, bounds.Bottom);
+ }
+ var path = Write(nest);
+ var pages = ReportPdf.Pages(path);
+
+ Assert.Equal(5, pages.Length);
+ Assert.Contains("millimeters (mm)", pages[0]);
+ Assert.Contains("3 mm", pages[0]);
+ Assert.Matches(@"48 x 24 mm", pages[2]);
+ using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
+ var stream = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
+ stream = stream[(stream.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
+ var sheet = Regex.Match(stream, @"([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) re\s+S");
+ Assert.True(sheet.Success);
+ double Value(int group) => double.Parse(sheet.Groups[group].Value, System.Globalization.CultureInfo.InvariantCulture);
+ var (left, top, width, height) = (Value(1), Value(2), Value(3), Value(4));
+ var points = Regex.Matches(stream, @"(?m)^([-\d.]+) ([-\d.]+) [ml]$")
+ .Select(match => (X: double.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture),
+ Y: double.Parse(match.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture)))
+ .ToList();
+ Assert.NotEmpty(points);
+ Assert.All(points, point =>
+ {
+ Assert.InRange(point.X, left - 1e-3, left + width + 1e-3);
+ Assert.InRange(point.Y, top - 1e-3, top + height + 1e-3);
+ });
+ }
+
+ [SkippableFact]
+ public void Write_TinyRepeatedPartsGetMapGridDetailViewsWithEveryIdLegible()
+ {
+ var nest = NestReportTestData.CreateDenseNest();
+ var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
+ var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
+ Assert.NotNull(plan.Overview.Grid);
+ Assert.NotEmpty(plan.Details);
+ // Every placement is labeled at full size somewhere; none are dropped.
+ var labeled = plan.Overview.Labels.Select(label => label.Part)
+ .Concat(plan.Details.SelectMany(detail => detail.Labels.Select(label => label.Part))).ToHashSet();
+ Assert.Equal(Enumerable.Range(0, snapshot.Plates[0].Parts.Length), labeled.Order());
+ var font = NestReportDiagram.LabelFont();
+ Assert.Equal(NestReportDiagram.LabelFontSize, font.Size);
+ foreach (var view in plan.Details.Prepend(plan.Overview))
+ {
+ foreach (var label in view.Labels)
+ {
+ Assert.True(label.Box.Width >= ReportText.Size(label.Id, font).Width);
+ Assert.True(label.Box.X >= 0 && label.Box.Right <= view.Size.Width && label.Box.Y >= 0 && label.Box.Bottom <= view.Size.Height);
+ Assert.DoesNotContain(view.Labels, other => other != label && other.Box.IntersectsWith(label.Box));
+ // Labels sit on material, not in the washer hole.
+ var center = view.Fit.Model(new PdfSharp.Drawing.XPoint(label.Box.X + label.Box.Width / 2, label.Box.Y + label.Box.Height / 2));
+ var part = snapshot.Plates[0].Parts[label.Part].Geometry;
+ var hole = part.Contours.SelectMany(contour => contour.Segments).Single(segment => segment.Center != null);
+ var dx = center.X - hole.Center!.X;
+ var dy = center.Y - hole.Center.Y;
+ Assert.True(dx * dx + dy * dy > hole.Radius * hole.Radius, $"{label.Id} label is inside its hole");
+ }
+ }
+
+ var path = Write(nest);
+ var pages = ReportPdf.Pages(path);
+ var plateText = string.Join("\n", pages.Skip(1));
+ Assert.Contains("cells outlined dash-dot have detail views", plateText);
+ foreach (var detail in plan.Details)
+ Assert.Contains($"Plate 1 detail {detail.Cell}:", plateText);
+ var washerLabels = pages.Skip(1).Sum(page => Regex.Matches(page, @"\bR002\b").Count);
+ // 24 diagram labels plus one table row.
+ Assert.True(washerLabels >= 25, $"R002 occurrences: {washerLabels}");
+ }
+
+ [SkippableFact]
+ public void Write_LabelOnHoledPartAvoidsTheHole()
+ {
+ var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
+ var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
+ Assert.Empty(plan.Details);
+ var label = plan.Overview.Labels.First(label => label.Part == 0);
+ var hole = snapshot.Plates[0].Parts[0].Geometry.Contours.SelectMany(c => c.Segments).Single(s => s.Center != null);
+ var holeBox = plan.Overview.Fit.Rect(new ReportBounds(hole.Center!.X - hole.Radius, hole.Center.Y - hole.Radius,
+ hole.Center.X + hole.Radius, hole.Center.Y + hole.Radius));
+ Assert.False(label.Box.IntersectsWith(holeBox));
+ }
+
+ [Fact]
+ public void Plan_LabelsSitAtThePolyLabelPoleLikePlateView()
+ {
+ // L-shape: the bounding-box center lies in the notch, outside the material.
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ foreach (var (x, y) in new[] { (10.0, 0.0), (10, 2), (2, 2), (2, 10), (0, 10), (0, 0) })
+ program.Codes.Add(new LinearMove(x, y));
+ var nest = new Nest("L test") { Units = Units.Inches };
+ var drawing = new Drawing("L bracket", program);
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(24, 48) { Quantity = 1 };
+ plate.Parts.Add(new Part(drawing, new Vector(4, 4)));
+ plate.Parts.Add(new Part(NestReportTestData.CreateHoledDrawing("Holed", 1), new Vector(20, 4)));
+ nest.Plates.Add(plate);
+ var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
+
+ var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
+
+ Assert.Empty(plan.Details);
+ foreach (var label in plan.Overview.Labels)
+ {
+ var geometry = snapshot.Plates[0].Parts[label.Part].Geometry;
+ var origin = new Vector(geometry.Bounds.Left, geometry.Bounds.Bottom);
+ var rings = geometry.Contours.Select(contour => ClipperBridge.Flatten(ToShape(contour),
+ NestReportDiagram.ChordTolerance(geometry.Bounds), circumscribe: false))
+ .OrderByDescending(ring => ring.Area()).ToList();
+ foreach (var ring in rings)
+ ring.Offset(-origin.X, -origin.Y);
+ var pole = PolyLabel.Find(rings[0], rings.Skip(1).ToList(), NestReportDiagram.PolePrecision(geometry.Bounds));
+ var expected = plan.Overview.Fit.Point(pole.X + origin.X, pole.Y + origin.Y);
+ // Pole of the placement-independent copy, to within the search precision.
+ var tolerance = NestReportDiagram.PolePrecision(geometry.Bounds) * plan.Overview.Fit.Scale;
+ Assert.InRange(label.Box.X + label.Box.Width / 2 - expected.X, -tolerance, tolerance);
+ Assert.InRange(label.Box.Y + label.Box.Height / 2 - expected.Y, -tolerance, tolerance);
+ }
+ var l = plan.Overview.Labels.Single(label => label.Part == 0);
+ var center = plan.Overview.Fit.Model(new PdfSharp.Drawing.XPoint(l.Box.X + l.Box.Width / 2, l.Box.Y + l.Box.Height / 2));
+ // The pole of this L is in its corner square, about 1.17 in from both outer edges.
+ Assert.InRange(center.X - 4, 0.9, 1.4);
+ Assert.InRange(center.Y - 4, 0.9, 1.4);
+ }
+
+ [Fact]
+ public void Plan_IdenticalRepeatedPartsGetTheSameLabelPositionOnEveryCopy()
+ {
+ // A square with a central hole has four equally deep poles; every copy must pick the same one.
+ var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateDenseNest(), NestReportTestData.GeneratedAt);
+ var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320, 400);
+ foreach (var view in plan.Details.Prepend(plan.Overview))
+ {
+ var offsets = view.Labels.GroupBy(label => label.Id).SelectMany(group =>
+ {
+ var relative = group.Select(label =>
+ {
+ var bounds = view.Fit.Rect(snapshot.Plates[0].Parts[label.Part].Geometry.Bounds);
+ return (X: label.Box.X - bounds.X, Y: label.Box.Y - bounds.Y);
+ }).ToList();
+ return relative.Select(offset => (offset.X - relative[0].X, offset.Y - relative[0].Y));
+ });
+ Assert.All(offsets, delta =>
+ {
+ Assert.InRange(delta.Item1, -0.01, 0.01);
+ Assert.InRange(delta.Item2, -0.01, 0.01);
+ });
+ }
+ Assert.Equal(24, plan.Details.Sum(detail => detail.Labels.Count(label => label.Id == "R002")));
+ }
+
+ private static OpenNest.Geometry.Shape ToShape(ReportContour contour)
+ {
+ var shape = new OpenNest.Geometry.Shape();
+ foreach (var segment in contour.Segments)
+ {
+ var start = new Vector(segment.Start.X, segment.Start.Y);
+ if (segment.Center == null)
+ shape.Entities.Add(new Line(start, new Vector(segment.End.X, segment.End.Y)));
+ else
+ shape.Entities.Add(new Circle(new Vector(segment.Center.X, segment.Center.Y), segment.Radius));
+ }
+ return shape;
+ }
+
+ [Fact]
+ public void Write_TabbedLeadInFixtureSurvivesSaveReloadWithStaleTabFlags()
+ {
+ var nest = NestReportTestData.CreateTabbedNest();
+ var original = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
+ foreach (var part in loaded.Plates[0].Parts)
+ {
+ if (part.CuttingParameters != null)
+ part.CuttingParameters.TabsEnabled = false;
+ }
+ if (loaded.Plates[0].CuttingParameters != null)
+ loaded.Plates[0].CuttingParameters.TabsEnabled = false;
+
+ var reloaded = NestReportBuilder.Capture(loaded, NestReportTestData.GeneratedAt);
+
+ var before = Assert.Single(original.Plates[0].Parts).Geometry;
+ var after = Assert.Single(reloaded.Plates[0].Parts).Geometry;
+ Assert.True(after.StrokeOnly);
+ Assert.Equal(before.Contours.Select(c => c.Closed), after.Contours.Select(c => c.Closed));
+ Assert.Equal(before.Contours.Select(c => c.Segments.Length), after.Contours.Select(c => c.Segments.Length));
+ Assert.Equal(before.Bounds.Left, after.Bounds.Left, 6);
+ Assert.Equal(before.Bounds.Bottom, after.Bounds.Bottom, 6);
+ Assert.Equal(before.Bounds.Right, after.Bounds.Right, 6);
+ Assert.Equal(before.Bounds.Top, after.Bounds.Top, 6);
+ Write(loaded, "reloaded.pdf");
+ }
+
+ [Fact]
+ public void Capture_InvalidLaterPlateFailsWithItsNumberAndNoOutput()
+ {
+ var nest = NestReportTestData.CreateMultiPlateNest();
+ ((LinearMove)nest.Plates[3].Parts[0].Program.Codes[1]).EndPoint = new Vector(double.NaN, 0);
+ var path = Path.Combine(directory, "keep.report.pdf");
+ File.WriteAllText(path, "keep");
+ var before = NestReportBuilderTests.Fingerprint(nest);
+
+ var error = Assert.Throws(() =>
+ NestPdfWriter.Write(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt), path));
+
+ Assert.Contains("Plate 4, part 1", error.Message);
+ Assert.Equal("keep", File.ReadAllText(path));
+ Assert.Equal(new[] { path }, Directory.GetFiles(directory));
+ Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
+ }
+
+ [Theory]
+ [InlineData(512)]
+ [InlineData(40_000)]
+ public void Write_LateMultiPageWriteFailureLeavesExistingReportAndSourceUnchanged(long failAfter)
+ {
+ var nest = NestReportTestData.CreateDenseNest();
+ var path = Path.Combine(directory, "existing.report.pdf");
+ var original = "%PDF previous report"u8.ToArray();
+ File.WriteAllBytes(path, original);
+ var before = NestReportBuilderTests.Fingerprint(nest);
+ var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
+
+ Assert.Throws(() => NestPdfWriter.Write(snapshot, path,
+ stream => new NestPdfWriterTests.FailingStream(stream, failAfter)));
+
+ Assert.Equal(original, File.ReadAllBytes(path));
+ Assert.Equal(new[] { path }, Directory.GetFiles(directory));
+ Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
+ }
+
+ [Fact]
+ public void Write_SuccessLeavesSourceQuantitiesProgramsAndTimestampsUnchanged()
+ {
+ var nest = NestReportTestData.CreateDenseNest();
+ var before = NestReportBuilderTests.Fingerprint(nest);
+ Write(nest);
+ Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
+ }
+
+ [Fact]
+ public void Ranges_CompressesConsecutivePlateNumbersLosslessly()
+ {
+ Assert.Equal("-", ReportText.Ranges([]));
+ Assert.Equal("1", ReportText.Ranges([1]));
+ Assert.Equal("1-3, 5, 7-8", ReportText.Ranges([1, 2, 3, 5, 7, 8]));
+ Assert.Equal("A", ReportText.RowName(0));
+ Assert.Equal("Z", ReportText.RowName(25));
+ Assert.Equal("AA", ReportText.RowName(26));
+ }
+
+ [Fact]
+ public void Wrap_BreaksUnbrokenTokensWithoutDroppingCharacters()
+ {
+ ReportFonts.Initialize();
+ var font = new PdfSharp.Drawing.XFont(ReportFonts.Family, 9);
+ var text = "short " + string.Concat(Enumerable.Repeat("0123456789", 8)) + "\nsecond line";
+ var lines = ReportText.Wrap(text, font, 60);
+ Assert.All(lines, line => Assert.True(ReportText.Size(line, font).Width <= 59));
+ Assert.Equal(text.Replace("\n", "").Replace(" ", ""), string.Concat(lines).Replace(" ", ""));
+ Assert.Contains("second line", lines);
+ }
+
+ /// Text of the R002 row inside one table column, read from word boxes in reading order.
+ private static string ColumnText(string path, int page, string column, string next)
+ {
+ var words = ReportPdf.Words(path, page);
+ var heading = words.First(word => word.Text == column);
+ var limit = words.First(word => word.Text == next && System.Math.Abs(word.Top - heading.Top) < 1);
+ var above = words.First(word => word.Text == "R001" && word.Top > heading.Top);
+ var below = words.First(word => word.Text == "R003" && word.Top > above.Top);
+ var cell = words.Where(word => word.Left >= heading.Left - 1 && word.Right <= limit.Left
+ && word.Top > above.Bottom && word.Bottom < below.Top)
+ .OrderBy(word => word.Top).ThenBy(word => word.Left);
+ // Nothing from this cell may extend into the next column.
+ Assert.DoesNotContain(words, word => word.Left < limit.Left && word.Right > limit.Left
+ && word.Top > above.Bottom && word.Bottom < below.Top);
+ return string.Concat(cell.Select(word => word.Text));
+ }
+
+ private string Write(Nest nest, string name = "layout.report.pdf")
+ {
+ var path = Path.Combine(directory, name);
+ NestPdfWriter.Write(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt), path);
+ return path;
+ }
+}
diff --git a/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs b/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs
index 4235447..4bdca03 100644
--- a/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs
+++ b/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs
@@ -13,6 +13,30 @@ public sealed class NestPdfWriterConcurrencyTests : IDisposable
public void Dispose() => Directory.Delete(directory, true);
+ [Fact]
+ public void Write_ConcurrentMultiPageAndDetailViewExportsMatchSequentialExports()
+ {
+ var snapshots = new[] { NestReportTestData.CreateMultiPlateNest(), NestReportTestData.CreateDenseNest() }
+ .Select(nest => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt)).ToArray();
+ var expected = snapshots.Select((snapshot, index) =>
+ {
+ var path = Path.Combine(directory, $"reference-{index}.pdf");
+ NestPdfWriter.Write(snapshot, path);
+ return PageContent(path);
+ }).ToArray();
+ var mismatches = new ConcurrentBag();
+
+ Parallel.For(0, 32, new ParallelOptions { MaxDegreeOfParallelism = System.Math.Max(8, Environment.ProcessorCount) }, index =>
+ {
+ var path = Path.Combine(directory, $"multi-{index}.pdf");
+ NestPdfWriter.Write(snapshots[index % 2], path);
+ if (PageContent(path) != expected[index % 2])
+ mismatches.Add(index);
+ });
+
+ Assert.Empty(mismatches);
+ }
+
[Fact]
public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport()
{
diff --git a/OpenNest.Tests/Reporting/NestPdfWriterTests.cs b/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
index 7477618..996e1d5 100644
--- a/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
+++ b/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using OpenNest.Geometry;
@@ -21,8 +20,8 @@ public sealed class NestPdfWriterTests : IDisposable
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]));
+ Assert.Equal((612, 792), ReportPdf.EffectiveSize(pdf.Pages[0]));
+ Assert.Equal((792, 612), ReportPdf.EffectiveSize(pdf.Pages[1]));
}
var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path));
// Subset-embedded faces; PDF names escape the space as #20.
@@ -38,8 +37,8 @@ public sealed class NestPdfWriterTests : IDisposable
public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers()
{
var path = WriteReport(NestReportTestData.CreateNest());
- var summary = ExtractText(path, 1);
- var plate = ExtractText(path, 2);
+ var summary = ReportPdf.Text(path, 1);
+ var plate = ReportPdf.Text(path, 2);
Assert.Contains("Report test job", summary);
Assert.Contains("Test customer", summary);
@@ -68,21 +67,21 @@ public sealed class NestPdfWriterTests : IDisposable
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));
+ Assert.Equal(summary + plate, ReportPdf.Text(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
+ + ReportPdf.Text(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);
+ var empty = ReportPdf.Text(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);
+ var demand = ReportPdf.Text(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);
@@ -99,7 +98,9 @@ public sealed class NestPdfWriterTests : IDisposable
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();
+ // The drawing area's clip path ("W* n") precedes the geometry.
+ var diagram = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
+ diagram = diagram[(diagram.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
Assert.Equal(geometry.Contours.Length, Regex.Matches(diagram, @"(?m)^[-\d. ]+ m$").Count);
}
@@ -128,42 +129,38 @@ public sealed class NestPdfWriterTests : IDisposable
}
[Theory]
- [InlineData("second-plate")]
- [InlineData("summary-overflow")]
- [InlineData("plate-table-overflow")]
- [InlineData("illegible-label")]
- public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario)
+ [InlineData("illegible-label", "Plate 4, part 2 (R004)")]
+ [InlineData("cell-overflow", "Drawing R002")]
+ [InlineData("header-overflow", "Page header")]
+ public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
{
- var nest = NestReportTestData.CreateNest();
- var plate = nest.Plates[0];
+ var nest = NestReportTestData.CreateMultiPlateNest();
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)));
+ // Too small for a legible label even in a detail view, on the LAST plate.
+ nest.Plates[3].Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.01, 0.01), new Vector(44, 20)));
+ break;
+ case "cell-overflow":
+ // A row taller than a page would be silently clipped by MigraDoc.
+ nest.Plates[0].Parts[2].BaseDrawing.Name = string.Concat(Enumerable.Repeat("0123456789", 60));
+ break;
+ case "header-overflow":
+ nest.Name = string.Join(" ", Enumerable.Repeat("Very long nest name", 40));
break;
}
var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep");
+ var before = NestReportBuilderTests.Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws(() => NestPdfWriter.Write(snapshot, path));
- Assert.Contains("not supported", error.Message);
+ Assert.Contains(identified, error.Message);
+ Assert.Contains("support", error.Message);
Assert.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
+ Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
}
[Fact]
@@ -192,55 +189,7 @@ public sealed class NestPdfWriterTests : IDisposable
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 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);
- }
- }
-
- /// Poppler text extraction; skipped where poppler-utils is not installed.
- 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
+ internal sealed class FailingStream(Stream inner, long limit) : Stream
{
private long written;
public override bool CanRead => false;
diff --git a/OpenNest.Tests/Reporting/NestReportTestData.cs b/OpenNest.Tests/Reporting/NestReportTestData.cs
index c64fb75..3e3806b 100644
--- a/OpenNest.Tests/Reporting/NestReportTestData.cs
+++ b/OpenNest.Tests/Reporting/NestReportTestData.cs
@@ -48,6 +48,51 @@ public static class NestReportTestData
return nest;
}
+ ///
+ /// Four layouts: "Bracket" R001 on plates 1, 2 and 4; R002 on plates 1 and 3; the distinct
+ /// same-named "Bracket" R003 on plates 1 and 3; R004 demanded but unplaced.
+ ///
+ public static Nest CreateMultiPlateNest()
+ {
+ var nest = CreateNest();
+ var first = nest.Plates[0];
+ var holed = first.Parts[0].BaseDrawing;
+ var rotated = first.Parts[2].BaseDrawing;
+ var second = new Plate(24, 48) { Quantity = 3, PartSpacing = 0.25 };
+ second.Parts.Add(new Part(holed, new Vector(2, 2)));
+ nest.Plates.Add(second);
+ var third = new Plate(30, 60) { Quantity = 1, PartSpacing = 0.125 };
+ third.Parts.Add(new Part(rotated, new Vector(10, 2)));
+ third.Parts.Add(new Part(first.Parts[3].BaseDrawing, new Vector(20, 2)));
+ nest.Plates.Add(third);
+ var fourth = new Plate(24, 48) { Quantity = 1 };
+ fourth.Parts.Add(new Part(holed, new Vector(20, 8)));
+ nest.Plates.Add(fourth);
+ return nest;
+ }
+
+ ///
+ /// One 120 x 60 sheet: two large parts and a cluster of tiny holed washers whose IDs cannot
+ /// be labeled legibly at overview scale, so they need detail views.
+ ///
+ public static Nest CreateDenseNest()
+ {
+ var nest = new Nest("Dense label test") { Units = Units.Inches };
+ var large = CreateHoledDrawing("Large bracket", 2);
+ var washer = new Drawing("Washer", HoledSquare(1, 0.25));
+ washer.Quantity.Required = 24;
+ nest.Drawings.Add(large);
+ nest.Drawings.Add(washer);
+ var plate = new Plate(60, 120) { Quantity = 2, PartSpacing = 0.25 };
+ plate.Parts.Add(new Part(large, new Vector(5, 5)));
+ plate.Parts.Add(new Part(large, new Vector(20, 5)));
+ for (var row = 0; row < 4; row++)
+ for (var column = 0; column < 6; column++)
+ plate.Parts.Add(new Part(washer, new Vector(62 + column * 1.5, 30 + row * 1.5)));
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
public static Nest CreateTabbedNest()
{
var drawing = CreateHoledDrawing("Tabbed with hole", 1);
@@ -81,6 +126,15 @@ public static class NestReportTestData
return drawing;
}
+ private static Program HoledSquare(double size, double radius)
+ {
+ var program = RectangleProgram(size, size);
+ var center = size / 2;
+ program.Codes.Add(new RapidMove(center + radius, center));
+ program.Codes.Add(new ArcMove(new Vector(center + radius, center), new Vector(center, center), RotationType.CW));
+ return program;
+ }
+
public static Drawing Rectangle(string name, double length, double width, int required = 1)
{
var drawing = new Drawing(name, RectangleProgram(length, width));
diff --git a/OpenNest.Tests/Reporting/ReportPdf.cs b/OpenNest.Tests/Reporting/ReportPdf.cs
new file mode 100644
index 0000000..b33b600
--- /dev/null
+++ b/OpenNest.Tests/Reporting/ReportPdf.cs
@@ -0,0 +1,84 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using PdfSharp.Pdf;
+
+namespace OpenNest.Tests.Reporting;
+
+/// PDF inspection for report tests: PDFsharp for structure, Poppler for text.
+public static class ReportPdf
+{
+ public static (int, int) EffectiveSize(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);
+ }
+
+ public static IEnumerable ContentStreams(PdfPage page)
+ {
+ foreach (var item in page.Contents.Elements)
+ {
+ var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfDictionary
+ ?? item as PdfDictionary;
+ if (dictionary?.Stream != null)
+ yield return Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue);
+ }
+ }
+
+ /// Layout text of one page; skipped where poppler-utils is not installed.
+ public static string Text(string path, int page) =>
+ Regex.Replace(Run("pdftotext", "-layout", "-f", Page(page), "-l", Page(page), path, "-"), "[ \t]+\n", "\n");
+
+ /// Every page's layout text, in order.
+ public static string[] Pages(string path)
+ {
+ var pages = Run("pdftotext", "-layout", path, "-").Split('\f');
+ // pdftotext terminates the last page with a form feed.
+ return pages.Take(pages.Length - 1).Select(page => Regex.Replace(page, "[ \t]+\n", "\n")).ToArray();
+ }
+
+ /// Words with page-space boxes (points, Y down).
+ public static List<(double Left, double Top, double Right, double Bottom, string Text)> Words(string path, int page)
+ {
+ var xml = Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-");
+ return Regex.Matches(xml, "([^<]*)")
+ .Select(match => (Number(match.Groups[1].Value), Number(match.Groups[2].Value), Number(match.Groups[3].Value),
+ Number(match.Groups[4].Value), System.Net.WebUtility.HtmlDecode(match.Groups[5].Value)))
+ .ToList();
+ }
+
+ private static string Page(int page) => page.ToString(CultureInfo.InvariantCulture);
+
+ private static double Number(string value) => double.Parse(value, CultureInfo.InvariantCulture);
+
+ private static string Run(string tool, params string[] arguments)
+ {
+ var info = new ProcessStartInfo(tool)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ foreach (var argument in arguments)
+ info.ArgumentList.Add(argument);
+ Process process;
+ try
+ {
+ process = Process.Start(info)!;
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ Skip.If(true, $"{tool} (poppler-utils) is not installed.");
+ throw;
+ }
+ using (process)
+ {
+ var text = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+ Assert.Equal(0, process.ExitCode);
+ return text;
+ }
+ }
+}
diff --git a/docs/nest-reports.md b/docs/nest-reports.md
index 238879a..83fbbbb 100644
--- a/docs/nest-reports.md
+++ b/docs/nest-reports.md
@@ -33,22 +33,38 @@ the selected plate, or certify that a layout passed geometry/pre-post checks.
- 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
+## Layout, pagination and dense-label fallback
-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:
+An empty/demand-only job, a single plate, or many plates and drawings all
+produce one document: a Letter portrait summary followed by one Letter
+landscape section per plate, every page carrying a repeated header and
+"Page X of Y". Long notes, long drawing names, many drawings and many parts
+per sheet paginate naturally: MigraDoc continues the Plates/Parts tables and
+each plate's part table across pages with the heading row repeated, and notes
+flow as an ordinary paragraph. No row, table or note text is ever dropped or
+truncated to fit a page.
-- 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.
+Each part ID is centered on its part's pole of inaccessibility (`PolyLabel`,
+the same method `PlateView`'s `LayoutPart` uses), computed on a
+placement-independent quantized copy so identical parts always get the
+identical label position and the label naturally clears a central hole. When
+an ID cannot sit legibly inside its own material at overview scale (for
+example, a cluster of tiny repeated parts), the plate gains a lettered
+(rows)/numbered (columns) map grid drawn beneath the sheet, and only the
+crowded cells get a zoomed, framed detail page listing that cell's real
+coordinates. Detail-cell outlines use a long dash-dot stroke, distinct from
+the shorter dashed scrap-cutoff stroke and the dotted grid lines. IDs are
+never shrunk below 7 pt or silently dropped.
-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.
+The writer still rejects, with `NotSupportedException` and before touching
+the destination:
+
+- a page header (nest name plus plate/material line) needing more than 3
+ wrapped lines;
+- a table cell needing more than 20 wrapped lines;
+- a part ID that cannot be placed legibly even in the most zoomed supported
+ detail view, or a plate that would need more than 24 detail views to label
+ every part — named with the plate, part index and ID.
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.
@@ -62,7 +78,9 @@ 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.
+calling the library. PDFsharp/MigraDoc layout and font state is process-wide;
+`NestPdfWriter.Write` serializes every export behind one static lock so
+concurrent calls cannot lay out text differently from a sequential export.
Advanced timing, cutting distances, pierce counts, weights, costs, gas use, and
machine/NC identity are intentionally omitted until their semantics are verified.