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