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; } }