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
+222 -133
View File
@@ -9,19 +9,23 @@ namespace OpenNest.Reporting;
/// <summary>
/// Renders a detached <see cref="NestReportSnapshot"/> to PDF: MigraDoc owns text, tables and
/// pagination; PDFsharp draws vector thumbnails and the sheet diagram into reserved layout areas.
/// pagination; PDFsharp draws vector thumbnails and sheet diagrams into reserved layout areas.
/// </summary>
public static class NestPdfWriter
{
// Slice 1 layout limits. Anything beyond them fails before the destination is touched.
private const double Margin = 36;
private const double BottomMargin = 50;
private const double 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<Stream, Stream>? 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<Table>();
normal.Font.Size = BodyFontSize;
var areas = new List<(Table Table, Action<XGraphics, XRect> 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<XGraphics, XRect>)> 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<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.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<Table> diagrams)
/// <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 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<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++)
{
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;
}
/// <summary>Heading rows repeat at the top of every continuation page.</summary>
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;
}
}
/// <summary>Adds text cells; a null cell is left empty for non-text content.</summary>
private static Row AddRow(Table table, params string?[] cells)
/// <summary>Adds wrapped text cells; a null cell is left empty for non-text content.</summary>
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<string> 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) + "%";
/// <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;
}
}