feat(reporting): add multi-plate pagination and dense-label fallback

Slice 2 of the nest report plan: pagination, overflow and dense-label
coverage on top of the Slice 1 one-plate library.

- NestPdfWriter: general multi-plate pagination. Summary/plate tables
  continue across pages with repeated (HeadingFormat) header rows; notes
  flow as an ordinary paragraph instead of a bounded cell; page headers
  are wrapped and sized into the top margin; drawing areas are located
  per page via DocumentRenderer.GetRenderInfoFromPage.
- ReportText: lossless pre-wrapping (MigraDoc clips an over-tall row and
  lets an unbroken token overflow a narrow cell without warning), capped
  table-cell line counts, plate-range compression ("1-2, 4"), and
  A/B/.../AA map-grid row names.
- NestReportDiagram: label placement centers each part ID on its
  PolyLabel pole (matching PlateView's LayoutPart), computed on a
  placement-independent quantized copy so identical parts share one
  label position. Parts whose ID cannot fit legibly at overview scale
  get a lettered/numbered map grid and a zoomed, framed detail page per
  crowded cell; the writer fails with plate/part/ID when even that
  cannot place a label, or a plate would need more than 24 detail views.
- Tests: NestPdfLayoutTests (multi-plate totals/ranges/same-named
  references, table continuation with no lost rows, long name/notes
  wrapping without column overflow, mm units in all four quadrants,
  dense-label detail views with hole avoidance, save/reload of the
  tabbed/lead-in fixture with stale tab flags, invalid later-plate data,
  late write failures against an existing destination, source
  unchanged on success/failure), ReportPdf test helper (page/word/
  content-stream extraction).
- docs/nest-reports.md: replace the Slice 1 one-plate limits section
  with the general pagination/dense-label contract and the process-wide
  PDFsharp/MigraDoc render lock.

Verification: Reporting filter 70/70; full OpenNest.Tests 2524
passed/21 skipped/0 failed; Engine 351/351; IO 77/77;
EnableWindowsTargeting=true full-solution build 0 errors; scoped
dotnet format --verify-no-changes exit 0. Preview PDFs rendered and
visually inspected (evidence: /home/aj/extracted/2026-09-29/opennest-report-slice2/).
Windows runtime, the desktop adapter and packaged-app font deployment
remain unverified (Slice 3).
This commit is contained in:
aj
2026-09-29 15:43:00 -04:00
parent e4d64a88eb
commit b15c4cf6c6
9 changed files with 1328 additions and 228 deletions
+219 -130
View File
@@ -9,19 +9,23 @@ namespace OpenNest.Reporting;
/// <summary> /// <summary>
/// Renders a detached <see cref="NestReportSnapshot"/> to PDF: MigraDoc owns text, tables and /// 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. /// pagination; PDFsharp draws vector thumbnails and sheet diagrams into reserved layout areas.
/// </summary> /// </summary>
public static class NestPdfWriter 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 Margin = 36;
private const double BottomMargin = 50; 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 ThumbnailWidth = 54;
private const double ThumbnailHeight = 36; private const double ThumbnailHeight = 36;
private const double DiagramHeight = 320; private const double PortraitWidth = 540;
private const double DiagramInset = 5; private const double LandscapeWidth = 720;
private const double LabelFontSize = 7; private const double OverviewHeight = 320;
private const double LabelPadding = 1; private const double DetailHeight = 400;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222); private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
@@ -46,39 +50,37 @@ public static class NestPdfWriter
private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput) private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput)
{ {
ValidateText(snapshot); 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(); 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(); var document = new Document();
document.Info.Title = snapshot.Name; document.Info.Title = snapshot.Name;
var normal = document.Styles[StyleNames.Normal] var normal = document.Styles[StyleNames.Normal]
?? throw new InvalidOperationException("MigraDoc normal style is unavailable."); ?? throw new InvalidOperationException("MigraDoc normal style is unavailable.");
normal.Font.Name = ReportFonts.Family; normal.Font.Name = ReportFonts.Family;
normal.Font.Size = 9; normal.Font.Size = BodyFontSize;
var diagrams = new List<Table>(); var areas = new List<(Table Table, Action<XGraphics, XRect> Draw)>();
AddSummary(document, snapshot); AddSummary(document, snapshot);
foreach (var plate in snapshot.Plates) for (var index = 0; index < snapshot.Plates.Length; index++)
diagrams.Add(AddPlate(document, snapshot, plate)); AddPlate(document, snapshot, snapshot.Plates[index], plans[index], areas);
var renderer = new PdfDocumentRenderer { Document = document }; var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument(); renderer.RenderDocument();
using var pdf = renderer.PdfDocument; using var pdf = renderer.PdfDocument;
// PDFsharp 6.2.4 disallows reading the page count after Save. // PDFsharp 6.2.4 disallows reading the page count after Save.
var pageCount = pdf.PageCount; var pageCount = pdf.PageCount;
var areas = LocateDiagrams(renderer, pageCount, diagrams); foreach (var (page, area, draw) in LocateAreas(renderer, pageCount, areas))
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]); 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.Title = snapshot.Name;
pdf.Info.Creator = "OpenNest"; pdf.Info.Creator = "OpenNest";
@@ -99,7 +101,7 @@ public static class NestPdfWriter
private static void AddSummary(Document document, NestReportSnapshot snapshot) 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"); var title = section.AddParagraph("Nest Report");
title.Format.Font.Size = 14; title.Format.Font.Size = 14;
title.Format.Font.Bold = true; title.Format.Font.Bold = true;
@@ -110,24 +112,28 @@ public static class NestPdfWriter
AddInfoRow(info, "Nest:", snapshot.Name); AddInfoRow(info, "Nest:", snapshot.Name);
if (snapshot.Customer.Length > 0) if (snapshot.Customer.Length > 0)
AddInfoRow(info, "Customer:", snapshot.Customer); AddInfoRow(info, "Customer:", snapshot.Customer);
if (snapshot.Notes.Length > 0)
AddInfoRow(info, "Notes:", snapshot.Notes);
AddInfoRow(info, "Material:", Material(snapshot)); AddInfoRow(info, "Material:", Material(snapshot));
AddInfoRow(info, "Thickness:", Thickness(snapshot)); AddInfoRow(info, "Thickness:", Thickness(snapshot));
AddInfoRow(info, "Units:", snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)"); 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, "Generated:", snapshot.GeneratedAt.ToString("yyyy-MM-dd HH:mm zzz", Invariant));
AddInfoRow(info, "Distinct layouts:", snapshot.Plates.Length.ToString(Invariant)); AddInfoRow(info, "Distinct layouts:", snapshot.Plates.Length.ToString(Invariant));
AddInfoRow(info, "Total physical sheets:", snapshot.TotalSheets.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"); AddHeading(section, "Plates");
if (snapshot.Plates.Length == 0) if (snapshot.Plates.Length == 0)
section.AddParagraph("No plates in this job."); section.AddParagraph("No plates in this job.");
else 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"); AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization");
foreach (var plate in snapshot.Plates) 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)); 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."); section.AddParagraph("No parts in this job.");
return; 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"); AddHeader(parts, "ID", "Part", "Drawing", "Required", "Nested", "Shortage", "Extra", "Plates");
foreach (var drawing in snapshot.Drawings) foreach (var drawing in snapshot.Drawings)
{ {
var row = AddRow(parts, drawing.Id, null, drawing.Name, drawing.Required.ToString(Invariant), var row = AddRow(parts, $"Drawing {drawing.Id}", false, drawing.Id, null, drawing.Name,
drawing.Nested.ToString(Invariant), drawing.Shortage.ToString(Invariant), drawing.Required.ToString(Invariant), drawing.Nested.ToString(Invariant),
drawing.Extra.ToString(Invariant), drawing.Plates.Length == 0 ? "-" : string.Join(", ", drawing.Plates)); drawing.Shortage.ToString(Invariant), drawing.Extra.ToString(Invariant), ReportText.Ranges(drawing.Plates));
var image = row.Cells[1].AddParagraph().AddImage(Thumbnail(drawing.Geometry)); var image = row.Cells[1].AddParagraph().AddImage(Thumbnail(drawing.Geometry));
image.Width = Unit.FromPoint(ThumbnailWidth); image.Width = Unit.FromPoint(ThumbnailWidth);
image.Height = Unit.FromPoint(ThumbnailHeight); 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<XGraphics, XRect>)> areas)
{ {
var section = AddSection(document, Orientation.Landscape); var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}";
var title = section.AddParagraph($"Plate {plate.Number} of {snapshot.Plates.Length}"); var header = new List<string> { $"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.Size = 13;
title.Format.Font.Bold = true; title.Format.Font.Bold = true;
@@ -166,96 +181,165 @@ public static class NestPdfWriter
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:", AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)"); Percent(plate.Utilization) + " (net part area / full sheet)");
// An empty fixed-height row reserves the vector diagram area in MigraDoc's flow. var overview = AddReservedArea(section, null, plan.Overview.Size.Height);
var diagram = AddTable(section, 720); areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan.Overview)));
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."); 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.Font.Size = 7.5;
legend.Format.SpaceBefore = Unit.FromPoint(4); 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."); var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5; approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4); 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) var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), 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)"); AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)");
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id))) foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
{ {
var count = perSheet[drawing.Id]; var count = perSheet[drawing.Id];
AddRow(parts, drawing.Id, names[drawing.Id], count.ToString(Invariant), AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id],
checked(count * plate.Copies).ToString(Invariant)); 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) foreach (var detail in plan.Details)
{ {
var found = new (int Page, XRect Area)?[diagrams.Count]; 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)));
}
}
/// <summary>A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow.</summary>
private static Table AddReservedArea(Section section, string? caption, double height)
{
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<XGraphics, XRect> Draw)> LocateAreas(
PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action<XGraphics, XRect> Draw)> areas)
{
var found = new (int Page, XRect Area)?[areas.Count];
for (var page = 1; page <= pageCount; page++) for (var page = 1; page <= pageCount; page++)
{ {
var renderInfos = renderer.DocumentRenderer?.GetRenderInfoFromPage(page) var renderInfos = renderer.DocumentRenderer?.GetRenderInfoFromPage(page)
?? throw new InvalidOperationException("MigraDoc render information is unavailable."); ?? throw new InvalidOperationException("MigraDoc render information is unavailable.");
foreach (var info in renderInfos) 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) if (index < 0)
continue; 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) if (found[index] != null)
throw new NotSupportedException($"A diagram for plate {index + 1} split across pages is not supported by this report slice."); throw new InvalidOperationException($"Report drawing area {index + 1} was laid out twice.");
var area = info.LayoutInfo.ContentArea; var content = info.LayoutInfo.ContentArea;
found[index] = (page, new XRect(area.X.Point, area.Y.Point, area.Width.Point, area.Height.Point)); 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 for (var index = 0; index < areas.Count; index++)
?? throw new NotSupportedException($"Plate {index + 1}: a missing diagram area is not supported by this report slice.")).ToList(); {
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, var fit = view.Fit;
area.Width - 2 * DiagramInset, DiagramHeight - 2 * DiagramInset); // Detail views clip exactly at their frame line so cropped neighbours stop there.
var fit = Fit.Create(plate.Bounds, inner); var frame = new XRect(0.5, 0.5, view.Size.Width - 1, view.Size.Height - 1);
var font = new XFont(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold); var state = gfx.Save();
if (view.CellBounds != null)
// Validate every label before drawing: dense-label callouts are a later slice. gfx.IntersectClip(frame);
var labels = new List<(string Id, XRect Box)>(); // The map grid lies beneath the sheet and parts so it never hides an outline.
for (var index = 0; index < plate.Parts.Length; index++) if (view.Grid is { } grid)
{ DrawGrid(gfx, grid, fit);
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)); gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
var outline = new XPen(XColors.Black, 0.6); var outline = new XPen(XColors.Black, 0.6);
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
DrawGeometry(gfx, part.Geometry, fit, outline); 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) foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff); 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); foreach (var (row, column) in overviewGrid.DetailCells)
gfx.DrawString(id, font, XBrushes.Black, box, XStringFormats.Center); 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 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 section = document.AddSection();
var setup = section.PageSetup; var setup = section.PageSetup;
setup.PageFormat = PageFormat.Letter; setup.PageFormat = PageFormat.Letter;
setup.Orientation = orientation; 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.BottomMargin = Unit.FromPoint(BottomMargin);
setup.FooterDistance = Unit.FromPoint(20); 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 "); var footer = section.Footers.Primary.AddParagraph("Page ");
footer.AddPageField(); footer.AddPageField();
footer.AddText(" of "); footer.AddText(" of ");
@@ -354,6 +455,7 @@ public static class NestPdfWriter
heading.Format.Font.Size = 11; heading.Format.Font.Size = 11;
heading.Format.SpaceBefore = Unit.FromPoint(10); heading.Format.SpaceBefore = Unit.FromPoint(10);
heading.Format.SpaceAfter = Unit.FromPoint(4); heading.Format.SpaceAfter = Unit.FromPoint(4);
heading.Format.KeepWithNext = true;
} }
private static Table AddTable(Section section, params double[] widths) private static Table AddTable(Section section, params double[] widths)
@@ -361,16 +463,17 @@ public static class NestPdfWriter
var table = section.AddTable(); var table = section.AddTable();
table.Borders.Width = 0.5; table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray; 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); table.TopPadding = table.BottomPadding = Unit.FromPoint(1.5);
foreach (var width in widths) foreach (var width in widths)
table.AddColumn(Unit.FromPoint(width)); table.AddColumn(Unit.FromPoint(width));
return table; return table;
} }
/// <summary>Heading rows repeat at the top of every continuation page.</summary>
private static void AddHeader(Table table, params string[] cells) 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.HeadingFormat = true;
row.Format.Font.Bold = true; row.Format.Font.Bold = true;
row.Shading.Color = Colors.LightGray; row.Shading.Color = Colors.LightGray;
@@ -378,31 +481,45 @@ public static class NestPdfWriter
private static void AddInfoRow(Table table, params string[] cells) private static void AddInfoRow(Table table, params string[] cells)
{ {
var row = AddRow(table, cells); var row = table.AddRow();
for (var index = 0; index < cells.Length; index += 2) 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; row.Cells[index].Format.Font.Bold = true;
} }
}
/// <summary>Adds text cells; a null cell is left empty for non-text content.</summary> /// <summary>Adds wrapped text cells; a null cell is left empty for non-text content.</summary>
private static Row AddRow(Table table, params string?[] cells) private static Row AddRow(Table table, string context, bool bold, params string?[] cells)
{ {
var row = table.AddRow(); var row = table.AddRow();
row.VerticalAlignment = VerticalAlignment.Center; row.VerticalAlignment = VerticalAlignment.Center;
for (var index = 0; index < cells.Length; index++) for (var index = 0; index < cells.Length; index++)
{ {
if (cells[index] == null) if (cells[index] != null)
continue; FillCell(table, row, index, cells[index]!, bold, context);
var paragraph = row.Cells[index].AddParagraph(); }
var lines = cells[index]!.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\t', ' ').Split('\n'); return row;
for (var line = 0; line < lines.Length; line++) }
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<string> lines)
{
for (var line = 0; line < lines.Count; line++)
{ {
if (line > 0) if (line > 0)
paragraph.AddLineBreak(); paragraph.AddLineBreak();
paragraph.AddText(lines[line]); paragraph.AddText(lines[line]);
} }
} }
return row;
}
private static string Material(NestReportSnapshot snapshot) private static string Material(NestReportSnapshot snapshot)
{ {
@@ -420,32 +537,4 @@ public static class NestPdfWriter
private static string Number(double value) => value.ToString("0.####", Invariant); private static string Number(double value) => value.ToString("0.####", Invariant);
private static string Percent(double value) => (value * 100).ToString("0.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);
}
}
} }
+333
View File
@@ -0,0 +1,333 @@
using OpenNest.Geometry;
using PdfSharp.Drawing;
namespace OpenNest.Reporting;
/// <summary>A part ID label in canvas-local page coordinates (Y down).</summary>
internal sealed record DiagramLabel(int Part, string Id, XRect Box);
/// <summary>Map grid over the overview; rows are lettered from the top, columns numbered from the left.</summary>
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);
}
}
/// <summary>One drawn view of a plate: the fitted overview or a zoomed grid-cell detail.</summary>
internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels,
DiagramGrid? Grid, string? Cell, ReportBounds? CellBounds);
internal sealed record PlateDiagramPlan(DiagramView Overview, IReadOnlyList<DiagramView> Details);
/// <summary>
/// Plans labels before any PDF output. IDs are never shrunk below <see cref="LabelFontSize"/> 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.
/// </summary>
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);
}
/// <summary>Chord tolerance for placement-only flattening, relative to the part size.</summary>
internal static double ChordTolerance(ReportBounds bounds) =>
System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.0005, 1e-9);
/// <summary>PolyLabel search precision, relative to the part size.</summary>
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<DiagramView>();
var unplaced = new List<int>();
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.");
}
/// <summary>Grid cell holding a part's bounds center; rows count down from the top.</summary>
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));
}
/// <summary>Grid spacing on the overview must exceed the gutter names it carries.</summary>
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;
}
/// <summary>
/// Centers each ID on its part's pole of inaccessibility (<see cref="PolyLabel"/>, 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.
/// </summary>
private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles,
List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed)
{
failed = [];
var placed = new List<DiagramLabel>();
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;
}
/// <summary>Scale at which the label would fit the part's bounds, with room to avoid holes and strokes.</summary>
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<Shape> 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);
}
/// <summary>
/// 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.
/// </summary>
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<Vector>(FindPole);
}
private readonly Lazy<Vector> lazyPole;
public ReportBounds Bounds { get; }
/// <summary>Pole of inaccessibility: the material point farthest from every edge.</summary>
public Vector Pole => lazyPole.Value;
private Box Box { get; }
private List<Polygon> Rings { get; }
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>Whether any contour edge lies in or crosses the model-space rectangle.</summary>
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);
}
}
/// <summary>One aspect-preserving model-to-canvas transform, centered, with the Y axis flipped.</summary>
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<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) => 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);
}
}
+122
View File
@@ -0,0 +1,122 @@
using System.Globalization;
using System.Text;
using PdfSharp.Drawing;
namespace OpenNest.Reporting;
/// <summary>
/// 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.
/// </summary>
internal static class ReportText
{
/// <summary>Line cap for one table cell; taller rows would be clipped by MigraDoc.</summary>
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);
}
/// <summary>
/// Splits explicit line breaks, then wraps at spaces, and breaks tokens wider than
/// <paramref name="width"/> between characters. Only the space at a wrap point is consumed.
/// </summary>
internal static List<string> 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<string>();
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;
}
/// <summary>Wraps table-cell text and rejects a row MigraDoc could not show completely.</summary>
internal static List<string> 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;
}
/// <summary>Compresses ascending plate numbers losslessly, e.g. "1-3, 5".</summary>
internal static string Ranges(IReadOnlyList<int> numbers)
{
if (numbers.Count == 0)
return "-";
var parts = new List<string>();
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);
}
/// <summary>Map-style row names: A..Z, AA, AB, ...</summary>
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;
}
}
@@ -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;
/// <summary>Slice 2: pagination, overflow, dense labels and late failures on real PDFs.</summary>
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<InvalidOperationException>(() =>
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<IOException>(() => 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);
}
/// <summary>Text of the R002 row inside one table column, read from word boxes in reading order.</summary>
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;
}
}
@@ -13,6 +13,30 @@ public sealed class NestPdfWriterConcurrencyTests : IDisposable
public void Dispose() => Directory.Delete(directory, true); 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<int>();
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] [Fact]
public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport() public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport()
{ {
+30 -81
View File
@@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -21,8 +20,8 @@ public sealed class NestPdfWriterTests : IDisposable
using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import)) using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import))
{ {
Assert.Equal(2, pdf.PageCount); Assert.Equal(2, pdf.PageCount);
Assert.Equal((612, 792), EffectiveSize(pdf.Pages[0])); Assert.Equal((612, 792), ReportPdf.EffectiveSize(pdf.Pages[0]));
Assert.Equal((792, 612), EffectiveSize(pdf.Pages[1])); Assert.Equal((792, 612), ReportPdf.EffectiveSize(pdf.Pages[1]));
} }
var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path)); var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path));
// Subset-embedded faces; PDF names escape the space as #20. // Subset-embedded faces; PDF names escape the space as #20.
@@ -38,8 +37,8 @@ public sealed class NestPdfWriterTests : IDisposable
public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers() public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers()
{ {
var path = WriteReport(NestReportTestData.CreateNest()); var path = WriteReport(NestReportTestData.CreateNest());
var summary = ExtractText(path, 1); var summary = ReportPdf.Text(path, 1);
var plate = ExtractText(path, 2); var plate = ReportPdf.Text(path, 2);
Assert.Contains("Report test job", summary); Assert.Contains("Report test job", summary);
Assert.Contains("Test customer", summary); Assert.Contains("Test customer", summary);
@@ -68,21 +67,21 @@ public sealed class NestPdfWriterTests : IDisposable
Assert.Contains("Page 2 of 2", plate); Assert.Contains("Page 2 of 2", plate);
// Repeated export of the same snapshot is textually identical. // Repeated export of the same snapshot is textually identical.
Assert.Equal(summary + plate, ExtractText(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1) Assert.Equal(summary + plate, ReportPdf.Text(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
+ ExtractText(Path.Combine(directory, "again.pdf"), 2)); + ReportPdf.Text(Path.Combine(directory, "again.pdf"), 2));
} }
[SkippableFact] [SkippableFact]
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports() public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
{ {
var nest = new Nest("Empty test") { Units = Units.Millimeters }; 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 plates in this job.", empty);
Assert.Contains("No parts in this job.", empty); Assert.Contains("No parts in this job.", empty);
Assert.Contains("Page 1 of 1", empty); Assert.Contains("Page 1 of 1", empty);
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2)); 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.Contains("No plates in this job.", demand);
Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand); Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
Assert.Contains("Page 1 of 1", demand); Assert.Contains("Page 1 of 1", demand);
@@ -99,7 +98,9 @@ public sealed class NestPdfWriterTests : IDisposable
Assert.Equal(2, pdf.PageCount); Assert.Equal(2, pdf.PageCount);
// The vector diagram is the last content stream on the plate page. Each stroke-only // 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. // 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); Assert.Equal(geometry.Contours.Length, Regex.Matches(diagram, @"(?m)^[-\d. ]+ m$").Count);
} }
@@ -128,42 +129,38 @@ public sealed class NestPdfWriterTests : IDisposable
} }
[Theory] [Theory]
[InlineData("second-plate")] [InlineData("illegible-label", "Plate 4, part 2 (R004)")]
[InlineData("summary-overflow")] [InlineData("cell-overflow", "Drawing R002")]
[InlineData("plate-table-overflow")] [InlineData("header-overflow", "Page header")]
[InlineData("illegible-label")] public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario)
{ {
var nest = NestReportTestData.CreateNest(); var nest = NestReportTestData.CreateMultiPlateNest();
var plate = nest.Plates[0];
switch (scenario) 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": 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; break;
} }
var path = Path.Combine(directory, "keep.report.pdf"); var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep"); File.WriteAllText(path, "keep");
var before = NestReportBuilderTests.Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt); var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws<NotSupportedException>(() => NestPdfWriter.Write(snapshot, path)); var error = Assert.Throws<NotSupportedException>(() => 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("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory)); Assert.Equal(new[] { path }, Directory.GetFiles(directory));
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
} }
[Fact] [Fact]
@@ -192,55 +189,7 @@ public sealed class NestPdfWriterTests : IDisposable
return path; return path;
} }
private static (int, int) EffectiveSize(PdfSharp.Pdf.PdfPage page) internal sealed class FailingStream(Stream inner, long limit) : Stream
{
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; private long written;
public override bool CanRead => false; public override bool CanRead => false;
@@ -48,6 +48,51 @@ public static class NestReportTestData
return nest; return nest;
} }
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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() public static Nest CreateTabbedNest()
{ {
var drawing = CreateHoledDrawing("Tabbed with hole", 1); var drawing = CreateHoledDrawing("Tabbed with hole", 1);
@@ -81,6 +126,15 @@ public static class NestReportTestData
return drawing; 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) public static Drawing Rectangle(string name, double length, double width, int required = 1)
{ {
var drawing = new Drawing(name, RectangleProgram(length, width)); var drawing = new Drawing(name, RectangleProgram(length, width));
+84
View File
@@ -0,0 +1,84 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using PdfSharp.Pdf;
namespace OpenNest.Tests.Reporting;
/// <summary>PDF inspection for report tests: PDFsharp for structure, Poppler for text.</summary>
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<string> 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);
}
}
/// <summary>Layout text of one page; skipped where poppler-utils is not installed.</summary>
public static string Text(string path, int page) =>
Regex.Replace(Run("pdftotext", "-layout", "-f", Page(page), "-l", Page(page), path, "-"), "[ \t]+\n", "\n");
/// <summary>Every page's layout text, in order.</summary>
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();
}
/// <summary>Words with page-space boxes (points, Y down).</summary>
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, "<word xMin=\"([\\d.]+)\" yMin=\"([\\d.]+)\" xMax=\"([\\d.]+)\" yMax=\"([\\d.]+)\">([^<]*)</word>")
.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;
}
}
}
+32 -14
View File
@@ -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 - Snapshots retain values, not live drawings, parts, CNC programs or mutable
geometry. Invalid/missing/nonfinite geometry fails with drawing/plate context. 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 An empty/demand-only job, a single plate, or many plates and drawings all
summary page and a Letter landscape plate page, each with "Page X of Y". The produce one document: a Letter portrait summary followed by one Letter
writer rejects, with `NotSupportedException` and before touching the destination: 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; Each part ID is centered on its part's pole of inaccessibility (`PolyLabel`,
- a summary (job fields, plate list, part rows with thumbnails) longer than one page; the same method `PlateView`'s `LayoutPart` uses), computed on a
- a plate page (header, diagram, part table) longer than one page; placement-independent quantized copy so identical parts always get the
- a part ID label that does not fit inside its part's fitted bounds at 7 pt, or identical label position and the label naturally clears a central hole. When
that overlaps another label. 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. The writer still rejects, with `NotSupportedException` and before touching
Multi-plate pagination, dense-label callouts and the desktop command belong to the destination:
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. - 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. 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. 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 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 its destination. A failed render or write leaves an existing report untouched and
removes temporary output. Applications should obtain overwrite consent before 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 Advanced timing, cutting distances, pierce counts, weights, costs, gas use, and
machine/NC identity are intentionally omitted until their semantics are verified. machine/NC identity are intentionally omitted until their semantics are verified.