using System.Globalization; using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel.Tables; using MigraDoc.Rendering; using PdfSharp.Drawing; using PdfSharp.Pdf; namespace OpenNest.Reporting; /// /// Renders a detached to PDF: MigraDoc owns text, tables and /// pagination; PDFsharp draws vector thumbnails and sheet diagrams into reserved layout areas. /// public static class NestPdfWriter { 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 int CardsPerRow = 4; private const double CardGap = 8; private const double CardWidth = (PortraitWidth - (CardsPerRow - 1) * CardGap) / CardsPerRow; private const double CardThumbnailHeight = 66; private const double CardFontSize = 8; private const double PortraitWidth = 540; private const double LandscapeWidth = 720; private const double OverviewHeight = 320; private static readonly XColor Fill = XColor.FromArgb(222, 222, 222); private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; // PDFsharp/MigraDoc layout and font state is process-wide and not safe for concurrent // documents: parallel exports laid out text differently. Reports are rare, so serialize them. // Shared with PartPrintoutWriter for the same reason. internal static readonly object RenderLock = new(); /// Thread-safe; concurrent calls are serialized. public static void Write(NestReportSnapshot snapshot, string destination) => Write(snapshot, destination, null); /// Test seam for injecting late write failures. internal static void Write(NestReportSnapshot snapshot, string destination, Func? wrapOutput) { ArgumentNullException.ThrowIfNull(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(destination); lock (RenderLock) WriteSerialized(snapshot, destination, wrapOutput); } private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func? wrapOutput) { ValidateText(snapshot); 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)).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 = BodyFontSize; var areas = new List<(Table Table, Action[] Layers)>(); AddSummary(document, snapshot); 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; foreach (var (page, area, layers) in LocateAreas(renderer, pageCount, areas)) { // Each layer is its own content stream, so the diagram's stream holds only the sheet // and what is on it, never the rulers around it. foreach (var draw in layers) { using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]); 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); } } pdf.Info.Title = snapshot.Name; pdf.Info.Creator = "OpenNest"; pdf.Info.CreationDate = snapshot.GeneratedAt.UtcDateTime; AtomicReportFile.Write(destination, stream => pdf.Save(wrapOutput?.Invoke(stream) ?? stream, false)); } private static void ValidateText(NestReportSnapshot snapshot) { ReportFonts.ValidateText(snapshot.Name, "Nest name"); ReportFonts.ValidateText(snapshot.Customer, "Customer"); ReportFonts.ValidateText(snapshot.Notes, "Notes"); ReportFonts.ValidateText(snapshot.Material, "Material"); ReportFonts.ValidateText(snapshot.Grade, "Material grade"); ReportFonts.ValidateText(snapshot.ThicknessName, "Thickness name"); foreach (var drawing in snapshot.Drawings) ReportFonts.ValidateText(drawing.Name, $"Drawing {drawing.Id} name"); } private static void AddSummary(Document document, NestReportSnapshot snapshot) { var section = AddSection(document, snapshot, Orientation.Portrait, PortraitWidth, $"Nest report: {snapshot.Name}"); var title = section.AddParagraph("Nest Report"); title.Format.Font.Size = 14; title.Format.Font.Bold = true; title.Format.SpaceAfter = Unit.FromPoint(6); // Two key/value columns keep the header short so more part cards fit on the first page: // the job and material on the left, the report's totals on the right. var left = new List<(string Key, string Value)> { ("Nest:", snapshot.Name) }; if (snapshot.Customer.Length > 0) left.Add(("Customer:", snapshot.Customer)); left.Add(("Material:", Material(snapshot))); // No units row: every size, thickness and weight carries its own unit. left.Add(("Thickness:", Thickness(snapshot))); var right = new List<(string Key, string Value)> { ("Distinct layouts:", snapshot.Plates.Length.ToString(Invariant)), ("Total physical sheets:", snapshot.TotalSheets.ToString(Invariant)), }; if (snapshot.TotalCutTime is { } total) right.Add(("Estimated cut time:", Duration(total) + " (all sheets)")); var info = AddTable(section, 72, 182, 136, 150); info.Borders.Visible = false; // The left column always has at least as many rows as the right. for (var index = 0; index < left.Count; index++) { string[] cells = index < right.Count ? [left[index].Key, left[index].Value, right[index].Key, right[index].Value] : [left[index].Key, left[index].Value]; AddInfoRow(info, cells); } 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 AddPlatesTable(section, snapshot); AddHeading(section, "Parts"); if (snapshot.Drawings.Length == 0) { section.AddParagraph("No parts in this job."); return; } if (snapshot.Drawings.Any(drawing => drawing.Weight == null)) { var note = section.AddParagraph(WeightNote(snapshot)); note.Format.Font.Size = 7.5; note.Format.SpaceAfter = Unit.FromPoint(4); } AddPartCards(section, snapshot); } /// /// Sheet weight and cut time columns appear only when the report has them; the stock size /// column takes whatever width they leave. /// private static void AddPlatesTable(Section section, NestReportSnapshot snapshot) { var hasWeights = snapshot.Plates.Any(plate => plate.Weight != null); var widths = new List { 40, 0, 56, 92, 68 }; var headings = new List { "Plate", "Stock size", "Quantity", "Parts per plate", "Utilization" }; if (hasWeights) { widths.Add(92); headings.Add("Sheet weight"); } if (snapshot.HasCutTimes) { widths.Add(92); headings.Add("Est. cut time"); } widths[1] = PortraitWidth - widths.Sum(); var plates = AddTable(section, widths.ToArray()); AddHeader(plates, headings.ToArray()); foreach (var plate in snapshot.Plates) { var cells = new List { plate.Number.ToString(Invariant), SheetSize(snapshot, plate), plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization), }; if (hasWeights) cells.Add(SheetWeight(snapshot, plate)); if (snapshot.HasCutTimes) cells.Add(PlateCutTime(plate)); AddRow(plates, $"Plate {plate.Number}", false, cells.ToArray()); } } /// One sheet, then all its copies on a second line. private static string SheetWeight(NestReportSnapshot snapshot, ReportPlate plate) => plate.Weight is not { } each ? "-" : plate.Copies == 1 ? $"{Weight(each)} {snapshot.WeightUnits}" : $"{Weight(each)} {snapshot.WeightUnits} each\n{Weight(each * plate.Copies)} {snapshot.WeightUnits} total"; /// /// One card per drawing, to a row. Cards are table cells with /// unbordered spacer columns between them; MigraDoc never splits a row across pages. /// private static void AddPartCards(Section section, NestReportSnapshot snapshot) { var columns = new List(); for (var index = 0; index < CardsPerRow; index++) { if (index > 0) columns.Add(CardGap); columns.Add(CardWidth); } var table = AddTable(section, columns.ToArray()); table.Borders.Visible = false; table.LeftPadding = table.RightPadding = Unit.FromPoint(CellPadding); table.TopPadding = table.BottomPadding = Unit.FromPoint(CellPadding); table.Format.Font.Size = CardFontSize; Row? row = null; for (var index = 0; index < snapshot.Drawings.Length; index++) { if (index % CardsPerRow == 0) { if (row != null) { var gap = table.AddRow(); gap.HeightRule = RowHeightRule.Exactly; gap.Height = Unit.FromPoint(CardGap); gap.TopPadding = gap.BottomPadding = Unit.Zero; } row = table.AddRow(); row.VerticalAlignment = VerticalAlignment.Top; } AddPartCard(row!.Cells[index % CardsPerRow * 2], snapshot, snapshot.Drawings[index]); } } private static void AddPartCard(Cell cell, NestReportSnapshot snapshot, ReportDrawing drawing) { var context = $"Drawing {drawing.Id}"; var inner = CardWidth - 2 * CellPadding; cell.Borders.Visible = true; cell.Borders.Width = 0.5; cell.Borders.Color = Colors.Gray; // ID on the left; a shortage or extra on the right. Complete parts show no status. var title = cell.AddParagraph(); title.Format.Font.Bold = true; title.Format.Font.Size = BodyFontSize; title.Format.TabStops.AddTabStop(Unit.FromPoint(inner - 1), TabAlignment.Right); title.AddText(drawing.Id); if (drawing.Shortage > 0 || drawing.Extra > 0) { title.AddTab(); var status = title.AddFormattedText(drawing.Shortage > 0 ? $"SHORT {drawing.Shortage.ToString(Invariant)}" : $"EXTRA {drawing.Extra.ToString(Invariant)}"); status.Color = drawing.Shortage > 0 ? Colors.DarkRed : Colors.Black; status.Font.Size = CardFontSize; } var picture = cell.AddParagraph(); picture.Format.Alignment = ParagraphAlignment.Center; picture.Format.SpaceBefore = picture.Format.SpaceAfter = Unit.FromPoint(2); var image = picture.AddImage(Thumbnail(drawing.Geometry, inner, CardThumbnailHeight)); image.Width = Unit.FromPoint(inner); image.Height = Unit.FromPoint(CardThumbnailHeight); var name = cell.AddParagraph(); name.Format.Font.Bold = true; AddLines(name, ReportText.Cell(drawing.Name, CardFont(true), inner, context)); var details = cell.AddParagraph(); details.Format.SpaceBefore = Unit.FromPoint(1); string[] lines = [ $"Size: {PartSize(snapshot, drawing.Geometry.Bounds)}", $"Weight: {PartWeight(snapshot, drawing)}", .. TotalWeight(snapshot, drawing), $"Required {drawing.Required.ToString(Invariant)}, nested {drawing.Nested.ToString(Invariant)}", $"Plates: {ReportText.Ranges(drawing.Plates)}", ]; AddLines(details, lines.SelectMany(line => ReportText.Cell(line, CardFont(false), inner, context)).ToList()); } private static string PartSize(NestReportSnapshot snapshot, ReportBounds bounds) => $"{Dimension(bounds.Width)} x {Dimension(bounds.Length)} {snapshot.Units}"; /// Bounding-box sizes to shop precision; sheet sizes keep . private static string Dimension(double value) => value.ToString("0.###", Invariant); private static string PartWeight(NestReportSnapshot snapshot, ReportDrawing drawing) => drawing.Weight is { } each ? $"{Weight(each)} {snapshot.WeightUnits} ea" : "-"; /// All nested copies; omitted when it would repeat the single-part weight. private static string[] TotalWeight(NestReportSnapshot snapshot, ReportDrawing drawing) => drawing.Weight is { } each && drawing.Nested > 1 ? [$"Total weight: {Weight(each * drawing.Nested)} {snapshot.WeightUnits}"] : []; private static string WeightNote(NestReportSnapshot snapshot) => snapshot.Thickness > 0 ? "Weights need a material density (lb/in\u00B3); parts without one show \"-\"." : "Weights need the nest thickness and a material density (lb/in\u00B3); parts without them show \"-\"."; /// Three significant figures for light parts, whole units from 1000 up. private static string Weight(double value) => value switch { < 10 => value.ToString("0.00", Invariant), < 1000 => value.ToString("0.0", Invariant), _ => value.ToString("#,0", Invariant), }; private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate, DiagramView plan, List<(Table, Action[])> areas) { 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, snapshot, Orientation.Landscape, LandscapeWidth, string.Join(" | ", header)); var title = section.AddParagraph(heading); title.Format.Font.Size = 13; title.Format.Font.Bold = true; var info = AddTable(section, 100, 260, 90, 270); info.Borders.Visible = false; AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate)); AddInfoRow(info, "Material:", Material(snapshot), "Quantity:", plate.Copies.ToString(Invariant)); AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:", $"{Number(plate.PartSpacing)} {snapshot.Units}"); AddInfoRow(info, "Parts per plate:", plate.Parts.Length.ToString(Invariant), "Utilization:", Percent(plate.Utilization) + " (net part area / full sheet)"); if (plate.CutTime is { } cutTime) AddInfoRow(info, "Est. cut time:", Duration(cutTime) + " per plate", "Total cut time:", Duration(cutTime * plate.Copies) + (plate.Copies == 1 ? " (1 plate)" : $" ({plate.Copies} plates)")); var overview = AddReservedArea(section, plan.Size.Height); areas.Add((overview, [(gfx, _) => DrawRulers(gfx, plate, plan, snapshot.Units), (gfx, _) => DrawView(gfx, plate, plan)])); var notes = new List(); if (plate.Clamps.Length > 0) notes.Add(plate.Clamps.Any(clamp => clamp.Removed) ? "Hatched rectangles are machine clamps. A dashed rectangle crossed out is a clamp to remove before cutting." : "Hatched rectangles are machine clamps."); notes.Add($"Rulers show sheet coordinates in {UnitsName(snapshot)}."); for (var i = 0; i < notes.Count; i++) { var note = section.AddParagraph(notes[i]); note.Format.Font.Size = 7.5; if (i == 0) note.Format.SpaceBefore = Unit.FromPoint(4); if (i == notes.Count - 1) note.Format.SpaceAfter = Unit.FromPoint(4); } 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, 60, 400, 110, 150); AddHeader(parts, "ID", "Drawing", "Parts per plate", $"Total ({plate.Copies} plates)"); foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id))) { var count = perSheet[drawing.Id]; AddRow(parts, $"Plate {plate.Number}, drawing {drawing.Id}", false, drawing.Id, names2[drawing.Id], count.ToString(Invariant), checked(count * plate.Copies).ToString(Invariant)); } } /// A fixed-height, never-split table row reserves a vector drawing area in MigraDoc's flow. private static Table AddReservedArea(Section section, double height) { var table = AddTable(section, LandscapeWidth); table.Borders.Visible = false; table.LeftPadding = table.RightPadding = Unit.Zero; table.TopPadding = table.BottomPadding = Unit.Zero; table.Format.SpaceBefore = Unit.FromPoint(4); var reserved = table.AddRow(); reserved.HeightRule = RowHeightRule.Exactly; reserved.Height = Unit.FromPoint(height); return table; } private static IEnumerable<(int Page, XRect Area, Action[] Layers)> LocateAreas( PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action[] Layers)> 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 = 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 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)); } } 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].Layers); } } private static void DrawView(XGraphics gfx, ReportPlate plate, DiagramView view) { var fit = view.Fit; gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds)); // Active clamps lie under the parts; a removed clamp's area may hold parts, so its // cross goes on top where the operator sees it. foreach (var clamp in plate.Clamps.Where(clamp => !clamp.Removed)) DrawClamp(gfx, fit.Rect(clamp.Bounds)); var outline = new XPen(XColors.Black, 0.6); foreach (var part in plate.Parts) DrawGeometry(gfx, part.Geometry, fit, outline); var removed = new XPen(XColors.Black, 0.6) { DashPattern = [5, 3] }; var cross = new XPen(XColors.Black, 0.4); foreach (var clamp in plate.Clamps.Where(clamp => clamp.Removed)) { var box = fit.Rect(clamp.Bounds); gfx.DrawRectangle(removed, box); gfx.DrawLine(cross, box.TopLeft, box.BottomRight); gfx.DrawLine(cross, box.TopRight, box.BottomLeft); } // 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); var groupPen = new XPen(XColors.Black, 0.6) { DashPattern = [1, 3] }; foreach (var group in view.Groups) gfx.DrawRectangle(groupPen, group.Boundary); var font = NestReportDiagram.LabelFont(); foreach (var label in view.Labels.Concat(view.Groups.Select(group => group.Label))) { gfx.DrawRectangle(XBrushes.White, label.Box); gfx.DrawString(label.Id, font, XBrushes.Black, label.Box, XStringFormats.Center); } } /// A machine clamp: white, hatched with diagonal lines, outlined. private static void DrawClamp(XGraphics gfx, XRect box) { const double spacing = 2.5; gfx.DrawRectangle(XBrushes.White, box); var state = gfx.Save(); gfx.IntersectClip(box); var hatch = new XPen(XColors.Black, 0.35); for (var offset = -box.Height; offset < box.Width; offset += spacing) gfx.DrawLine(hatch, box.X + offset, box.Bottom, box.X + offset + box.Height, box.Y); gfx.Restore(state); gfx.DrawRectangle(new XPen(XColors.Black, 0.75), box); } /// /// A horizontal ruler under the drawing and a vertical one left of it, each spanning the /// sheet and reading its plate coordinates, with the units in the corner between them. /// private static void DrawRulers(XGraphics gfx, ReportPlate plate, DiagramView view, string units) { var fit = view.Fit; var rulers = view.Rulers; var sheet = plate.Bounds; var pen = new XPen(XColors.Black, 0.5); var font = NestReportDiagram.RulerFont(); var textHeight = font.GetHeight(); var low = fit.Point(sheet.Left, sheet.Top); var high = fit.Point(sheet.Right, sheet.Bottom); const double textBox = 100; var baseY = rulers.Horizontal.Top; var textY = baseY + NestReportDiagram.MajorTick + NestReportDiagram.RulerTextGap; gfx.DrawLine(pen, low.X, baseY, high.X, baseY); foreach (var value in rulers.Minors(sheet.Left, sheet.Right)) { var x = fit.Point(value, 0).X; gfx.DrawLine(pen, x, baseY, x, baseY + NestReportDiagram.MinorTick); } foreach (var value in rulers.Majors(sheet.Left, sheet.Right)) { var x = fit.Point(value, 0).X; gfx.DrawLine(pen, x, baseY, x, baseY + NestReportDiagram.MajorTick); gfx.DrawString(rulers.Format(value), font, XBrushes.Black, new XRect(x - textBox / 2, textY, textBox, textHeight), XStringFormats.TopCenter); } var baseX = rulers.Vertical.Right; var textRight = baseX - NestReportDiagram.MajorTick - NestReportDiagram.RulerTextGap; gfx.DrawLine(pen, baseX, low.Y, baseX, high.Y); foreach (var value in rulers.Minors(sheet.Bottom, sheet.Top)) { var y = fit.Point(0, value).Y; gfx.DrawLine(pen, baseX, y, baseX - NestReportDiagram.MinorTick, y); } foreach (var value in rulers.Majors(sheet.Bottom, sheet.Top)) { var y = fit.Point(0, value).Y; gfx.DrawLine(pen, baseX, y, baseX - NestReportDiagram.MajorTick, y); gfx.DrawString(rulers.Format(value), font, XBrushes.Black, new XRect(textRight - textBox, y - textHeight / 2, textBox, textHeight), XStringFormats.CenterRight); } gfx.DrawString(units, font, XBrushes.Black, new XRect(textRight - textBox, textY, textBox, textHeight), XStringFormats.TopRight); } private static string Thumbnail(ReportGeometry geometry, double width, double height) { using var document = new PdfDocument(); var page = document.AddPage(); page.Width = XUnit.FromPoint(width); page.Height = XUnit.FromPoint(height); using (var gfx = XGraphics.FromPdfPage(page)) { var fit = Fit.Create(geometry.Bounds, new XRect(2, 2, width - 4, height - 4)); DrawGeometry(gfx, geometry, fit, new XPen(XColors.Black, 0.5)); } using var stream = new MemoryStream(); document.Save(stream, false); // MigraDoc embeds a PDF image source as a vector form XObject, not a raster image. return "base64:" + Convert.ToBase64String(stream.ToArray()); } private static void DrawGeometry(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen) { if (geometry.StrokeOnly) { StrokeContours(gfx, geometry, fit, pen); return; } // Every contour is closed here, so CloseFigure separates the subpaths. var path = new XGraphicsPath(); foreach (var contour in geometry.Contours) { AddContour(path, contour, fit); path.CloseFigure(); } path.FillMode = XFillMode.Alternate; // Even-odd: holes stay unfilled. gfx.DrawPath(pen, new XSolidBrush(Fill), path); } /// /// PDFsharp's StartFigure does not emit a move-to after an open figure, which would draw a /// false segment across a tab gap to the next contour. Stroke each contour as its own path. /// private static void StrokeContours(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen) { foreach (var contour in geometry.Contours) { var path = new XGraphicsPath(); AddContour(path, contour, fit); if (contour.Closed) path.CloseFigure(); gfx.DrawPath(pen, path); } } internal static void AddContour(XGraphicsPath path, ReportContour contour, Fit fit) { foreach (var segment in contour.Segments) { if (segment.Center == null) { path.AddLine(fit.Point(segment.Start), fit.Point(segment.End)); continue; } var center = fit.Point(segment.Center); var radius = segment.Radius * fit.Scale; var box = new XRect(center.X - radius, center.Y - radius, 2 * radius, 2 * radius); // Page Y points down, so model angles and sweeps change sign. path.AddArc(box, -Degrees(segment.StartAngle), -Degrees(segment.SweepAngle)); } } private static double Degrees(double radians) => radians * 180 / System.Math.PI; private static XFont BodyFont(bool bold) => new(ReportFonts.Family, BodyFontSize, bold ? XFontStyleEx.Bold : XFontStyleEx.Regular); private static XFont CardFont(bool bold) => new(ReportFonts.Family, CardFontSize, bold ? XFontStyleEx.Bold : XFontStyleEx.Regular); private static Section AddSection(Document document, NestReportSnapshot snapshot, Orientation orientation, double width, string header) { var section = document.AddSection(); var setup = section.PageSetup; setup.PageFormat = PageFormat.Letter; setup.Orientation = orientation; 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); // When it was generated on the left, in this computer's time zone; the page on the right. var footer = section.Footers.Primary.AddParagraph(); footer.Format.TabStops.AddTabStop(Unit.FromPoint(width), TabAlignment.Right); footer.AddText("Generated " + snapshot.GeneratedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", Invariant)); footer.AddTab(); footer.AddText("Page "); footer.AddPageField(); footer.AddText(" of "); footer.AddNumPagesField(); footer.Format.Font.Size = 7.5; return section; } private static void AddHeading(Section section, string text) { var heading = section.AddParagraph(text); heading.Format.Font.Bold = true; heading.Format.Font.Size = 11; heading.Format.SpaceBefore = Unit.FromPoint(10); heading.Format.SpaceAfter = Unit.FromPoint(4); heading.Format.KeepWithNext = true; } private static Table AddTable(Section section, params double[] widths) { var table = section.AddTable(); table.Borders.Width = 0.5; table.Borders.Color = Colors.Gray; table.LeftPadding = table.RightPadding = Unit.FromPoint(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, "Table heading", true, cells); row.HeadingFormat = true; row.Format.Font.Bold = true; row.Shading.Color = Colors.LightGray; } private static void AddInfoRow(Table table, params string[] cells) { var row = 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 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) 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 UnitsName(NestReportSnapshot snapshot) => snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)"; private static string Material(NestReportSnapshot snapshot) { var material = $"{snapshot.Material} {snapshot.Grade}".Trim(); return material.Length == 0 ? "Not specified" : material; } // Zero thickness means "unset" in a new nest; never present it as a genuine value. // The machine config's name for the thickness leads: "16 ga - 0.0598 in". private static string Thickness(NestReportSnapshot snapshot) => snapshot.Thickness <= 0 ? "Not specified" : snapshot.ThicknessName.Length > 0 ? $"{snapshot.ThicknessName} - {Number(snapshot.Thickness)} {snapshot.Units}" : $"{Number(snapshot.Thickness)} {snapshot.Units}"; private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) => $"{Number(plate.Bounds.Width)} x {Number(plate.Bounds.Length)} {snapshot.Units}"; private static string Number(double value) => value.ToString("0.####", Invariant); private static string PlateCutTime(ReportPlate plate) => plate.CutTime is not { } time ? "" : plate.Copies == 1 ? Duration(time) : $"{Duration(time)} each\n{Duration(time * plate.Copies)} total"; /// Whole seconds as "1h 02m 03s", "4m 05s" or "45s". private static string Duration(TimeSpan time) { var seconds = (long)System.Math.Round(time.TotalSeconds); var hours = seconds / 3600; var minutes = seconds / 60 % 60; var rest = seconds % 60; return hours > 0 ? $"{hours}h {minutes:00}m {rest:00}s" : minutes > 0 ? $"{minutes}m {rest:00}s" : $"{rest}s"; } private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%"; }