Spaces:
Runtime error
Runtime error
Download TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs from Yuruyuruu/task-tracking-system: direct link, hf CLI and curl.
- Browser
- Download file 27.1 kB
-
https://huggingface.co/spaces/Yuruyuruu/task-tracking-system/resolve/main/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs
- Command line
-
hf download hf://spaces/Yuruyuruu/task-tracking-system/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs
-
curl -L -o SimplePdfReportBuilder.cs https://huggingface.co/spaces/Yuruyuruu/task-tracking-system/resolve/main/TaskTrackingSystem.Shared/SimplePdfReportBuilder.cs
27.1 kB
| using System.Globalization; | |
| using SkiaSharp; | |
| using SkiaSharp.HarfBuzz; | |
| namespace TaskTrackingSystem.Shared; | |
| public static class SimplePdfReportBuilder | |
| { | |
| private const float LetterWidth = 612; | |
| private const float LetterHeight = 792; | |
| private const float PageMargin = 32; | |
| private const float FooterHeight = 30; | |
| private const float TableCellPadding = 7; | |
| private const float TableLineHeight = 11.5f; | |
| private const float TableHeaderLineHeight = 11.5f; | |
| private const int MaximumCellLines = 5; | |
| private const string FontPathEnvironmentVariable = "TASKTRACKING_PDF_FONT_PATH"; | |
| private static readonly string[] RequiredGlyphSamples = ["ABC0123|:-", "\u1019\u103C\u1014\u103A\u1019\u102C"]; | |
| private static readonly SKColor BrandDark = SKColor.Parse("#12312D"); | |
| private static readonly SKColor BrandPrimary = SKColor.Parse("#0F766E"); | |
| private static readonly SKColor BrandAccent = SKColor.Parse("#B45309"); | |
| private static readonly SKColor BrandLight = SKColor.Parse("#E6F4F1"); | |
| private static readonly SKColor TextMuted = SKColor.Parse("#64706D"); | |
| private static readonly SKColor BorderColor = SKColor.Parse("#D6E2DF"); | |
| private static readonly SKColor StripeColor = SKColor.Parse("#F7FAF9"); | |
| public static byte[] BuildTableReport( | |
| string title, | |
| IEnumerable<string>? summaryLines, | |
| IReadOnlyList<string> headers, | |
| IEnumerable<IReadOnlyList<string>> rows) | |
| { | |
| if (headers.Count == 0) | |
| { | |
| return BuildTextReport(title, rows.SelectMany(row => row)); | |
| } | |
| var normalizedHeaders = headers.Select(NormalizeCell).ToList(); | |
| var normalizedRows = rows | |
| .Select(row => NormalizeRow(row, normalizedHeaders.Count)) | |
| .ToList(); | |
| var summaries = ParseSummaryItems(summaryLines).ToList(); | |
| var landscape = normalizedHeaders.Count >= 6; | |
| var pageWidth = landscape ? LetterHeight : LetterWidth; | |
| var pageHeight = landscape ? LetterWidth : LetterHeight; | |
| var tableWidth = pageWidth - (PageMargin * 2); | |
| using var typeface = LoadReportTypeface(); | |
| using var shaper = new SKShaper(typeface); | |
| using var tableFont = new SKFont(typeface, landscape ? 8.2f : 8.6f); | |
| using var tableHeaderFont = new SKFont(typeface, landscape ? 8.4f : 8.8f); | |
| var columnWidths = CalculateColumnWidths( | |
| normalizedHeaders, | |
| normalizedRows, | |
| tableWidth, | |
| shaper, | |
| tableFont, | |
| tableHeaderFont); | |
| var headerCells = normalizedHeaders | |
| .Select((header, index) => WrapText(header, columnWidths[index] - (TableCellPadding * 2), shaper, tableHeaderFont, 3)) | |
| .ToList(); | |
| var tableHeaderHeight = Math.Max( | |
| 34, | |
| headerCells.Max(cell => cell.Count) * TableHeaderLineHeight + (TableCellPadding * 2)); | |
| var rowLayouts = normalizedRows | |
| .Select(row => CreateRowLayout(row, columnWidths, shaper, tableFont)) | |
| .ToList(); | |
| var firstTableTop = CalculateFirstTableTop(summaries.Count, landscape); | |
| var continuedTableTop = 86f; | |
| var pages = PaginateRows( | |
| rowLayouts, | |
| pageHeight, | |
| firstTableTop, | |
| continuedTableTop, | |
| tableHeaderHeight); | |
| using var output = new MemoryStream(); | |
| using var document = SKDocument.CreatePdf(output) | |
| ?? throw new InvalidOperationException("The PDF renderer could not be initialized."); | |
| for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++) | |
| { | |
| using var canvas = document.BeginPage(pageWidth, pageHeight); | |
| using var pageShaper = new SKShaper(typeface); | |
| DrawTablePage( | |
| canvas, | |
| pageShaper, | |
| typeface, | |
| title, | |
| summaries, | |
| headerCells, | |
| columnWidths, | |
| pages[pageIndex], | |
| pageIndex, | |
| pages.Count, | |
| pageWidth, | |
| pageHeight, | |
| firstTableTop, | |
| continuedTableTop, | |
| tableHeaderHeight, | |
| landscape); | |
| document.EndPage(); | |
| } | |
| document.Close(); | |
| return output.ToArray(); | |
| } | |
| public static byte[] BuildTextReport(string title, IEnumerable<string> bodyLines) | |
| { | |
| using var typeface = LoadReportTypeface(); | |
| using var shaper = new SKShaper(typeface); | |
| using var bodyFont = new SKFont(typeface, 10); | |
| var availableWidth = LetterWidth - (PageMargin * 2); | |
| var normalizedLines = bodyLines | |
| .SelectMany(line => WrapText(line, availableWidth, shaper, bodyFont, int.MaxValue)) | |
| .ToList(); | |
| var pages = normalizedLines.Chunk(43).Select(chunk => chunk.ToList()).ToList(); | |
| if (pages.Count == 0) | |
| { | |
| pages.Add(["No data available."]); | |
| } | |
| using var output = new MemoryStream(); | |
| using var document = SKDocument.CreatePdf(output) | |
| ?? throw new InvalidOperationException("The PDF renderer could not be initialized."); | |
| for (var pageIndex = 0; pageIndex < pages.Count; pageIndex++) | |
| { | |
| using var canvas = document.BeginPage(LetterWidth, LetterHeight); | |
| using var pageShaper = new SKShaper(typeface); | |
| canvas.Clear(SKColors.White); | |
| DrawReportHeader(canvas, pageShaper, typeface, title, pageIndex > 0, LetterWidth); | |
| using var paint = CreatePaint(BrandDark); | |
| var baseline = 104f; | |
| foreach (var line in pages[pageIndex]) | |
| { | |
| DrawShapedText(canvas, pageShaper, line, PageMargin, baseline, SKTextAlign.Left, bodyFont, paint); | |
| baseline += 15; | |
| } | |
| DrawFooter(canvas, pageShaper, typeface, pageIndex + 1, pages.Count, LetterWidth, LetterHeight); | |
| document.EndPage(); | |
| } | |
| document.Close(); | |
| return output.ToArray(); | |
| } | |
| private static void DrawTablePage( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| string title, | |
| IReadOnlyList<SummaryItem> summaries, | |
| IReadOnlyList<IReadOnlyList<string>> headerCells, | |
| IReadOnlyList<float> columnWidths, | |
| TablePage page, | |
| int pageIndex, | |
| int totalPages, | |
| float pageWidth, | |
| float pageHeight, | |
| float firstTableTop, | |
| float continuedTableTop, | |
| float tableHeaderHeight, | |
| bool landscape) | |
| { | |
| canvas.Clear(SKColors.White); | |
| DrawReportHeader(canvas, shaper, typeface, title, pageIndex > 0, pageWidth); | |
| if (pageIndex == 0 && summaries.Count > 0) | |
| { | |
| DrawSummaryCards(canvas, shaper, typeface, summaries, pageWidth, landscape); | |
| } | |
| var tableTop = pageIndex == 0 ? firstTableTop : continuedTableTop; | |
| DrawTableHeader(canvas, shaper, typeface, headerCells, columnWidths, tableTop, tableHeaderHeight); | |
| var rowTop = tableTop + tableHeaderHeight; | |
| if (page.Rows.Count == 0) | |
| { | |
| DrawEmptyTableRow(canvas, shaper, typeface, rowTop, columnWidths.Sum()); | |
| } | |
| else | |
| { | |
| for (var rowIndex = 0; rowIndex < page.Rows.Count; rowIndex++) | |
| { | |
| DrawTableRow( | |
| canvas, | |
| shaper, | |
| typeface, | |
| page.Rows[rowIndex], | |
| columnWidths, | |
| rowTop, | |
| page.StartRowIndex + rowIndex); | |
| rowTop += page.Rows[rowIndex].Height; | |
| } | |
| } | |
| DrawFooter(canvas, shaper, typeface, pageIndex + 1, totalPages, pageWidth, pageHeight); | |
| } | |
| private static void DrawReportHeader( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| string title, | |
| bool continued, | |
| float pageWidth) | |
| { | |
| using var kickerFont = new SKFont(typeface, 7.5f); | |
| using var titleFont = new SKFont(typeface, continued ? 15 : 19); | |
| using var kickerPaint = CreatePaint(BrandPrimary); | |
| using var titlePaint = CreatePaint(BrandDark); | |
| using var accentPaint = CreatePaint(BrandAccent); | |
| canvas.DrawRoundRect(new SKRect(PageMargin, 30, PageMargin + 4, 68), 2, 2, accentPaint); | |
| DrawShapedText(canvas, shaper, "TASKIFY / REPORT", PageMargin + 14, 39, SKTextAlign.Left, kickerFont, kickerPaint, emphasized: true); | |
| DrawShapedText(canvas, shaper, title, PageMargin + 14, 62, SKTextAlign.Left, titleFont, titlePaint, emphasized: true); | |
| if (continued) | |
| { | |
| using var continuedPaint = CreatePaint(TextMuted); | |
| DrawShapedText(canvas, shaper, "CONTINUED", pageWidth - PageMargin, 42, SKTextAlign.Right, kickerFont, continuedPaint); | |
| } | |
| } | |
| private static void DrawSummaryCards( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| IReadOnlyList<SummaryItem> summaries, | |
| float pageWidth, | |
| bool landscape) | |
| { | |
| var columns = Math.Min(landscape ? 4 : 2, summaries.Count); | |
| var gap = 8f; | |
| var cardWidth = (pageWidth - (PageMargin * 2) - (gap * (columns - 1))) / columns; | |
| const float cardHeight = 42; | |
| const float top = 82; | |
| using var labelFont = new SKFont(typeface, 7.1f); | |
| using var valueFont = new SKFont(typeface, 8.6f); | |
| using var backgroundPaint = CreatePaint(BrandLight); | |
| using var labelPaint = CreatePaint(BrandPrimary); | |
| using var valuePaint = CreatePaint(BrandDark); | |
| for (var index = 0; index < summaries.Count; index++) | |
| { | |
| var column = index % columns; | |
| var row = index / columns; | |
| var left = PageMargin + column * (cardWidth + gap); | |
| var cardTop = top + row * (cardHeight + gap); | |
| var rect = new SKRect(left, cardTop, left + cardWidth, cardTop + cardHeight); | |
| canvas.DrawRoundRect(rect, 5, 5, backgroundPaint); | |
| DrawShapedText(canvas, shaper, summaries[index].Label, left + 9, cardTop + 13, SKTextAlign.Left, labelFont, labelPaint, emphasized: true); | |
| var valueLines = WrapText(summaries[index].Value, cardWidth - 18, shaper, valueFont, 2); | |
| for (var lineIndex = 0; lineIndex < valueLines.Count; lineIndex++) | |
| { | |
| DrawShapedText( | |
| canvas, | |
| shaper, | |
| valueLines[lineIndex], | |
| left + 9, | |
| cardTop + 27 + lineIndex * 9.5f, | |
| SKTextAlign.Left, | |
| valueFont, | |
| valuePaint, | |
| emphasized: lineIndex == 0); | |
| } | |
| } | |
| } | |
| private static void DrawTableHeader( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| IReadOnlyList<IReadOnlyList<string>> headerCells, | |
| IReadOnlyList<float> columnWidths, | |
| float top, | |
| float height) | |
| { | |
| using var backgroundPaint = CreatePaint(BrandPrimary); | |
| using var textPaint = CreatePaint(SKColors.White); | |
| using var font = new SKFont(typeface, 8.4f); | |
| var tableWidth = columnWidths.Sum(); | |
| canvas.DrawRoundRect(new SKRect(PageMargin, top, PageMargin + tableWidth, top + height), 5, 5, backgroundPaint); | |
| var left = PageMargin; | |
| for (var column = 0; column < headerCells.Count; column++) | |
| { | |
| DrawCellLines( | |
| canvas, | |
| shaper, | |
| headerCells[column], | |
| new SKRect(left, top, left + columnWidths[column], top + height), | |
| font, | |
| textPaint, | |
| TableHeaderLineHeight, | |
| emphasized: true); | |
| left += columnWidths[column]; | |
| } | |
| } | |
| private static void DrawTableRow( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| TableRowLayout row, | |
| IReadOnlyList<float> columnWidths, | |
| float top, | |
| int absoluteRowIndex) | |
| { | |
| var tableWidth = columnWidths.Sum(); | |
| if (absoluteRowIndex % 2 == 1) | |
| { | |
| using var stripePaint = CreatePaint(StripeColor); | |
| canvas.DrawRect(new SKRect(PageMargin, top, PageMargin + tableWidth, top + row.Height), stripePaint); | |
| } | |
| using var borderPaint = CreateStrokePaint(BorderColor, 0.6f); | |
| using var textPaint = CreatePaint(BrandDark); | |
| using var font = new SKFont(typeface, 8.2f); | |
| canvas.DrawLine(PageMargin, top + row.Height, PageMargin + tableWidth, top + row.Height, borderPaint); | |
| var left = PageMargin; | |
| for (var column = 0; column < row.Cells.Count; column++) | |
| { | |
| if (column > 0) | |
| { | |
| canvas.DrawLine(left, top, left, top + row.Height, borderPaint); | |
| } | |
| DrawCellLines( | |
| canvas, | |
| shaper, | |
| row.Cells[column], | |
| new SKRect(left, top, left + columnWidths[column], top + row.Height), | |
| font, | |
| textPaint, | |
| TableLineHeight); | |
| left += columnWidths[column]; | |
| } | |
| } | |
| private static void DrawCellLines( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| IReadOnlyList<string> lines, | |
| SKRect cell, | |
| SKFont font, | |
| SKPaint paint, | |
| float lineHeight, | |
| bool emphasized = false) | |
| { | |
| canvas.Save(); | |
| canvas.ClipRect(cell); | |
| var baseline = cell.Top + TableCellPadding + font.Size; | |
| foreach (var line in lines) | |
| { | |
| DrawShapedText( | |
| canvas, | |
| shaper, | |
| line, | |
| cell.Left + TableCellPadding, | |
| baseline, | |
| SKTextAlign.Left, | |
| font, | |
| paint, | |
| emphasized); | |
| baseline += lineHeight; | |
| } | |
| canvas.Restore(); | |
| } | |
| private static void DrawEmptyTableRow( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| float top, | |
| float tableWidth) | |
| { | |
| using var backgroundPaint = CreatePaint(StripeColor); | |
| using var textPaint = CreatePaint(TextMuted); | |
| using var font = new SKFont(typeface, 9); | |
| var rect = new SKRect(PageMargin, top, PageMargin + tableWidth, top + 42); | |
| canvas.DrawRect(rect, backgroundPaint); | |
| DrawShapedText(canvas, shaper, "No data available.", rect.MidX, top + 25, SKTextAlign.Center, font, textPaint); | |
| } | |
| private static void DrawFooter( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| SKTypeface typeface, | |
| int pageNumber, | |
| int totalPages, | |
| float pageWidth, | |
| float pageHeight) | |
| { | |
| using var linePaint = CreateStrokePaint(BorderColor, 0.7f); | |
| using var textPaint = CreatePaint(TextMuted); | |
| using var brandPaint = CreatePaint(BrandPrimary); | |
| using var font = new SKFont(typeface, 7.5f); | |
| var lineY = pageHeight - FooterHeight; | |
| canvas.DrawLine(PageMargin, lineY, pageWidth - PageMargin, lineY, linePaint); | |
| DrawShapedText(canvas, shaper, "TASKIFY", PageMargin, pageHeight - 13, SKTextAlign.Left, font, brandPaint, emphasized: true); | |
| DrawShapedText( | |
| canvas, | |
| shaper, | |
| $"Page {pageNumber} of {totalPages}", | |
| pageWidth - PageMargin, | |
| pageHeight - 13, | |
| SKTextAlign.Right, | |
| font, | |
| textPaint); | |
| } | |
| private static TableRowLayout CreateRowLayout( | |
| IReadOnlyList<string> row, | |
| IReadOnlyList<float> columnWidths, | |
| SKShaper shaper, | |
| SKFont font) | |
| { | |
| var cells = row | |
| .Select((cell, index) => (IReadOnlyList<string>)WrapText( | |
| cell, | |
| columnWidths[index] - (TableCellPadding * 2), | |
| shaper, | |
| font, | |
| MaximumCellLines)) | |
| .ToList(); | |
| var lineCount = Math.Max(1, cells.Max(cell => cell.Count)); | |
| var height = Math.Max(28, lineCount * TableLineHeight + (TableCellPadding * 2)); | |
| return new TableRowLayout(cells, height); | |
| } | |
| private static List<TablePage> PaginateRows( | |
| IReadOnlyList<TableRowLayout> rows, | |
| float pageHeight, | |
| float firstTableTop, | |
| float continuedTableTop, | |
| float tableHeaderHeight) | |
| { | |
| var pages = new List<TablePage>(); | |
| var currentRows = new List<TableRowLayout>(); | |
| var startRowIndex = 0; | |
| var usedHeight = firstTableTop + tableHeaderHeight; | |
| var pageBottom = pageHeight - FooterHeight - 10; | |
| foreach (var row in rows) | |
| { | |
| if (currentRows.Count > 0 && usedHeight + row.Height > pageBottom) | |
| { | |
| pages.Add(new TablePage(currentRows, startRowIndex)); | |
| startRowIndex += currentRows.Count; | |
| currentRows = new List<TableRowLayout>(); | |
| usedHeight = continuedTableTop + tableHeaderHeight; | |
| } | |
| currentRows.Add(row); | |
| usedHeight += row.Height; | |
| } | |
| if (currentRows.Count > 0 || pages.Count == 0) | |
| { | |
| pages.Add(new TablePage(currentRows, startRowIndex)); | |
| } | |
| return pages; | |
| } | |
| private static float CalculateFirstTableTop(int summaryCount, bool landscape) | |
| { | |
| if (summaryCount == 0) | |
| { | |
| return 84; | |
| } | |
| var columns = Math.Min(landscape ? 4 : 2, summaryCount); | |
| var rows = (int)Math.Ceiling(summaryCount / (double)columns); | |
| return 82 + rows * 42 + Math.Max(0, rows - 1) * 8 + 16; | |
| } | |
| private static IReadOnlyList<float> CalculateColumnWidths( | |
| IReadOnlyList<string> headers, | |
| IReadOnlyList<IReadOnlyList<string>> rows, | |
| float tableWidth, | |
| SKShaper shaper, | |
| SKFont bodyFont, | |
| SKFont headerFont) | |
| { | |
| var count = headers.Count; | |
| var minimumWidth = Math.Min(52, tableWidth / count * 0.72f); | |
| var desired = new float[count]; | |
| for (var column = 0; column < count; column++) | |
| { | |
| var measured = rows | |
| .Take(250) | |
| .Select(row => MeasureText(row[column], shaper, bodyFont) + (TableCellPadding * 2)) | |
| .OrderBy(width => width) | |
| .ToList(); | |
| var percentileWidth = measured.Count == 0 | |
| ? 0 | |
| : measured[(int)Math.Floor((measured.Count - 1) * 0.72)]; | |
| desired[column] = Math.Clamp( | |
| Math.Max(MeasureText(headers[column], shaper, headerFont) + (TableCellPadding * 2), percentileWidth), | |
| minimumWidth, | |
| tableWidth * 0.30f); | |
| } | |
| var remainingWidth = Math.Max(0, tableWidth - minimumWidth * count); | |
| var flexibleWeights = desired.Select(width => Math.Max(1, width - minimumWidth)).ToArray(); | |
| var totalWeight = flexibleWeights.Sum(); | |
| var result = flexibleWeights | |
| .Select(weight => minimumWidth + remainingWidth * weight / totalWeight) | |
| .ToArray(); | |
| result[^1] += tableWidth - result.Sum(); | |
| return result; | |
| } | |
| private static List<string> WrapText( | |
| string? value, | |
| float maxWidth, | |
| SKShaper shaper, | |
| SKFont font, | |
| int maxLines) | |
| { | |
| var text = NormalizeCell(value); | |
| var lines = new List<string>(); | |
| var remaining = text; | |
| while (remaining.Length > 0 && lines.Count < maxLines) | |
| { | |
| if (MeasureText(remaining, shaper, font) <= maxWidth) | |
| { | |
| lines.Add(remaining); | |
| remaining = string.Empty; | |
| break; | |
| } | |
| var breakIndex = FindBreakIndex(remaining, maxWidth, shaper, font); | |
| if (breakIndex <= 0) | |
| { | |
| breakIndex = Math.Min(1, remaining.Length); | |
| } | |
| lines.Add(remaining[..breakIndex].TrimEnd()); | |
| remaining = remaining[breakIndex..].TrimStart(); | |
| } | |
| if (remaining.Length > 0 && lines.Count > 0) | |
| { | |
| lines[^1] = Ellipsize(lines[^1] + " " + remaining, maxWidth, shaper, font); | |
| } | |
| return lines.Count == 0 ? ["-"] : lines; | |
| } | |
| private static int FindBreakIndex(string text, float maxWidth, SKShaper shaper, SKFont font) | |
| { | |
| var textElementIndexes = StringInfo.ParseCombiningCharacters(text); | |
| var low = 1; | |
| var high = textElementIndexes.Length; | |
| var fittingElementCount = 1; | |
| while (low <= high) | |
| { | |
| var middle = low + ((high - low) / 2); | |
| var endIndex = middle < textElementIndexes.Length ? textElementIndexes[middle] : text.Length; | |
| if (MeasureText(text[..endIndex], shaper, font) <= maxWidth) | |
| { | |
| fittingElementCount = middle; | |
| low = middle + 1; | |
| } | |
| else | |
| { | |
| high = middle - 1; | |
| } | |
| } | |
| var fittingEndIndex = fittingElementCount < textElementIndexes.Length | |
| ? textElementIndexes[fittingElementCount] | |
| : text.Length; | |
| var whitespaceIndex = text.LastIndexOf(' ', Math.Max(0, fittingEndIndex - 1), fittingEndIndex); | |
| return whitespaceIndex > 0 ? whitespaceIndex : fittingEndIndex; | |
| } | |
| private static string Ellipsize(string text, float maxWidth, SKShaper shaper, SKFont font) | |
| { | |
| const string suffix = "..."; | |
| var indexes = StringInfo.ParseCombiningCharacters(text); | |
| var low = 0; | |
| var high = indexes.Length; | |
| var fittingCount = 0; | |
| while (low <= high) | |
| { | |
| var middle = low + ((high - low) / 2); | |
| var end = middle < indexes.Length ? indexes[middle] : text.Length; | |
| if (MeasureText(text[..end].TrimEnd() + suffix, shaper, font) <= maxWidth) | |
| { | |
| fittingCount = middle; | |
| low = middle + 1; | |
| } | |
| else | |
| { | |
| high = middle - 1; | |
| } | |
| } | |
| var fittingEnd = fittingCount < indexes.Length ? indexes[fittingCount] : text.Length; | |
| return text[..fittingEnd].TrimEnd() + suffix; | |
| } | |
| private static float MeasureText(string text, SKShaper shaper, SKFont font) | |
| => string.IsNullOrEmpty(text) ? 0 : shaper.Shape(text, font).Width; | |
| private static IEnumerable<SummaryItem> ParseSummaryItems(IEnumerable<string>? summaryLines) | |
| { | |
| if (summaryLines == null) | |
| { | |
| yield break; | |
| } | |
| foreach (var rawLine in summaryLines.Where(line => !string.IsNullOrWhiteSpace(line))) | |
| { | |
| var line = rawLine.Trim(); | |
| var separator = line.IndexOf(':'); | |
| if (separator > 0) | |
| { | |
| yield return new SummaryItem(line[..separator].Trim(), NormalizeCell(line[(separator + 1)..])); | |
| } | |
| else | |
| { | |
| yield return new SummaryItem("DETAIL", NormalizeCell(line)); | |
| } | |
| } | |
| } | |
| private static IReadOnlyList<string> NormalizeRow(IReadOnlyList<string> row, int columnCount) | |
| { | |
| var normalized = new string[columnCount]; | |
| for (var index = 0; index < columnCount; index++) | |
| { | |
| normalized[index] = index < row.Count ? NormalizeCell(row[index]) : "-"; | |
| } | |
| return normalized; | |
| } | |
| private static SKTypeface LoadReportTypeface() | |
| { | |
| foreach (var path in GetFontCandidates()) | |
| { | |
| if (!File.Exists(path)) | |
| { | |
| continue; | |
| } | |
| var typeface = SKTypeface.FromFile(path); | |
| if (typeface != null && SupportsReportGlyphs(typeface)) | |
| { | |
| return typeface; | |
| } | |
| typeface?.Dispose(); | |
| } | |
| foreach (var family in new[] { "Noto Sans Myanmar", "Myanmar Text", "Nirmala UI", "Padauk" }) | |
| { | |
| var typeface = SKTypeface.FromFamilyName(family); | |
| if (typeface != null && SupportsReportGlyphs(typeface)) | |
| { | |
| return typeface; | |
| } | |
| typeface?.Dispose(); | |
| } | |
| throw new InvalidOperationException( | |
| "No Unicode font capable of rendering Burmese was found. " + | |
| $"Install Noto Sans Myanmar or set {FontPathEnvironmentVariable} to a compatible .ttf file."); | |
| } | |
| private static IEnumerable<string> GetFontCandidates() | |
| { | |
| var configuredPath = Environment.GetEnvironmentVariable(FontPathEnvironmentVariable); | |
| if (!string.IsNullOrWhiteSpace(configuredPath)) | |
| { | |
| yield return configuredPath; | |
| } | |
| var windowsDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Windows); | |
| if (!string.IsNullOrWhiteSpace(windowsDirectory)) | |
| { | |
| yield return Path.Combine(windowsDirectory, "Fonts", "mmrtext.ttf"); | |
| yield return Path.Combine(windowsDirectory, "Fonts", "Nirmala.ttf"); | |
| } | |
| yield return "/usr/share/fonts/truetype/noto/NotoSansMyanmar-Regular.ttf"; | |
| yield return "/usr/share/fonts/truetype/padauk/Padauk-Regular.ttf"; | |
| yield return "/usr/share/fonts/opentype/noto/NotoSansMyanmar-Regular.ttf"; | |
| } | |
| private static bool SupportsReportGlyphs(SKTypeface typeface) | |
| => RequiredGlyphSamples.All(sample => typeface.GetGlyphs(sample).All(glyph => glyph != 0)); | |
| private static string NormalizeCell(string? value) | |
| { | |
| if (string.IsNullOrWhiteSpace(value)) | |
| { | |
| return "-"; | |
| } | |
| return value.Replace("\r", " ").Replace("\n", " ").Trim(); | |
| } | |
| private static SKPaint CreatePaint(SKColor color) => new() | |
| { | |
| Color = color, | |
| IsAntialias = true, | |
| Style = SKPaintStyle.Fill | |
| }; | |
| private static SKPaint CreateStrokePaint(SKColor color, float width) => new() | |
| { | |
| Color = color, | |
| IsAntialias = true, | |
| Style = SKPaintStyle.Stroke, | |
| StrokeWidth = width | |
| }; | |
| private static void DrawShapedText( | |
| SKCanvas canvas, | |
| SKShaper shaper, | |
| string text, | |
| float x, | |
| float y, | |
| SKTextAlign alignment, | |
| SKFont font, | |
| SKPaint paint, | |
| bool emphasized = false) | |
| { | |
| canvas.DrawShapedText(shaper, text, x, y, alignment, font, paint); | |
| if (emphasized) | |
| { | |
| canvas.DrawShapedText(shaper, text, x + 0.28f, y, alignment, font, paint); | |
| } | |
| } | |
| private sealed record SummaryItem(string Label, string Value); | |
| private sealed record TableRowLayout(IReadOnlyList<IReadOnlyList<string>> Cells, float Height); | |
| private sealed record TablePage(IReadOnlyList<TableRowLayout> Rows, int StartRowIndex); | |
| } | |