task-tracking-system / TaskTrackingSystem.Shared /SpreadsheetReportBuilder.cs
User
fix: date time format, add pdf download, csv to xlsx, adjust dto responses
bec15e2
Raw
History Blame Contribute Delete
14.7 kB
using System.Globalization;
using System.IO.Compression;
using System.Security;
using System.Text;
using System.Xml;
namespace TaskTrackingSystem.Shared;
public static class SpreadsheetReportBuilder
{
private const string DateFormat = "dd-MM-yyyy";
private const string ContentTypeWorkbook = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
private const string ContentTypeWorksheet = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
private const string ContentTypeStyles = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml";
public static byte[] BuildTableWorkbook(
string worksheetName,
string title,
IReadOnlyList<string> headers,
IEnumerable<IReadOnlyList<object?>> rows,
IEnumerable<string>? summaryLines = null)
{
var rowData = rows.Select(row => row.ToArray()).ToList();
var summary = summaryLines?
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => line.Trim())
.ToList() ?? new List<string>();
var totalRows = 0;
if (!string.IsNullOrWhiteSpace(title))
{
totalRows++;
}
totalRows += summary.Count;
totalRows++; // blank row before header
totalRows++; // header row
totalRows += rowData.Count;
var columnCount = Math.Max(1, headers.Count);
var lastColumnName = ToColumnName(columnCount);
var lastRowNumber = Math.Max(1, totalRows);
var dimension = $"A1:{lastColumnName}{lastRowNumber}";
using var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
AddEntry(archive, "[Content_Types].xml", BuildContentTypesXml());
AddEntry(archive, "_rels/.rels", BuildRootRelsXml());
AddEntry(archive, "xl/workbook.xml", BuildWorkbookXml(worksheetName));
AddEntry(archive, "xl/_rels/workbook.xml.rels", BuildWorkbookRelsXml());
AddEntry(archive, "xl/styles.xml", BuildStylesXml());
AddEntry(archive, "xl/worksheets/sheet1.xml", BuildWorksheetXml(title, headers, rowData, summary, dimension));
}
return stream.ToArray();
}
private static void AddEntry(ZipArchive archive, string path, string content)
{
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
writer.Write(content);
}
private static string BuildContentTypesXml() =>
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="xml" ContentType="application/xml" />
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" />
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" />
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" />
</Types>
""";
private static string BuildRootRelsXml() =>
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml" />
</Relationships>
""";
private static string BuildWorkbookXml(string worksheetName)
{
var safeName = EscapeXml(SanitizeWorksheetName(worksheetName));
return $"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="{safeName}" sheetId="1" r:id="rId1" />
</sheets>
</workbook>
""";
}
private static string BuildWorkbookRelsXml() =>
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml" />
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml" />
</Relationships>
""";
private static string BuildStylesXml() =>
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="3">
<font>
<sz val="11" />
<color theme="1" />
<name val="Calibri" />
<family val="2" />
</font>
<font>
<b />
<sz val="12" />
<color rgb="FFFFFFFF" />
<name val="Calibri" />
<family val="2" />
</font>
<font>
<i />
<sz val="11" />
<color rgb="FF475569" />
<name val="Calibri" />
<family val="2" />
</font>
</fonts>
<fills count="3">
<fill>
<patternFill patternType="none" />
</fill>
<fill>
<patternFill patternType="gray125" />
</fill>
<fill>
<patternFill patternType="solid">
<fgColor rgb="FF5B21B6" />
<bgColor indexed="64" />
</patternFill>
</fill>
</fills>
<borders count="2">
<border>
<left />
<right />
<top />
<bottom />
<diagonal />
</border>
<border>
<left style="thin"><color rgb="FFE2E8F0" /></left>
<right style="thin"><color rgb="FFE2E8F0" /></right>
<top style="thin"><color rgb="FFE2E8F0" /></top>
<bottom style="thin"><color rgb="FFE2E8F0" /></bottom>
<diagonal />
</border>
</borders>
<cellStyleXfs count="1">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" />
</cellStyleXfs>
<cellXfs count="4">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" />
<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1">
<alignment horizontal="center" vertical="center" />
</xf>
<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1">
<alignment vertical="center" />
</xf>
<xf numFmtId="14" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1" applyAlignment="1">
<alignment vertical="center" />
</xf>
</cellXfs>
<cellStyles count="1">
<cellStyle name="Normal" xfId="0" builtinId="0" />
</cellStyles>
<dxfs count="0" />
<tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16" />
</styleSheet>
""";
private static string BuildWorksheetXml(
string title,
IReadOnlyList<string> headers,
IReadOnlyList<object?[]> rows,
IReadOnlyList<string> summaryLines,
string dimension)
{
var rowXml = new StringBuilder();
var rowIndex = 1;
var columnCount = Math.Max(1, headers.Count);
if (!string.IsNullOrWhiteSpace(title))
{
rowXml.Append(BuildSingleCellRow(rowIndex++, 0, title.Trim(), styleIndex: 1));
}
foreach (var line in summaryLines)
{
rowXml.Append(BuildSingleCellRow(rowIndex++, 0, line, styleIndex: 2));
}
rowIndex++; // blank row
rowXml.Append("<row r=\"");
rowXml.Append(rowIndex);
rowXml.Append("\">");
for (var col = 1; col <= columnCount; col++)
{
var header = col <= headers.Count ? headers[col - 1] : string.Empty;
rowXml.Append(BuildInlineCell(rowIndex, col, header, styleIndex: 1));
}
rowXml.AppendLine("</row>");
rowIndex++;
foreach (var row in rows)
{
rowXml.Append("<row r=\"");
rowXml.Append(rowIndex);
rowXml.Append("\">");
for (var col = 1; col <= columnCount; col++)
{
var value = col <= row.Length ? row[col - 1] : null;
rowXml.Append(BuildCell(rowIndex, col, value));
}
rowXml.AppendLine("</row>");
rowIndex++;
}
var autoFilterRow = 1 + (string.IsNullOrWhiteSpace(title) ? 0 : 1) + summaryLines.Count + 1;
var worksheetXml = new StringBuilder();
worksheetXml.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
worksheetXml.AppendLine("<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"");
worksheetXml.AppendLine(" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">");
worksheetXml.AppendLine($" <dimension ref=\"{dimension}\" />");
worksheetXml.AppendLine(" <sheetViews>");
worksheetXml.AppendLine(" <sheetView workbookViewId=\"0\">");
worksheetXml.AppendLine($" <pane ySplit=\"{autoFilterRow}\" topLeftCell=\"A{autoFilterRow + 1}\" activePane=\"bottomLeft\" state=\"frozen\" />");
worksheetXml.AppendLine($" <selection pane=\"bottomLeft\" activeCell=\"A{autoFilterRow + 1}\" sqref=\"A{autoFilterRow + 1}\" />");
worksheetXml.AppendLine(" </sheetView>");
worksheetXml.AppendLine(" </sheetViews>");
worksheetXml.AppendLine(" <sheetFormatPr defaultRowHeight=\"15\" />");
worksheetXml.AppendLine(" <cols>");
worksheetXml.Append(BuildColumnDefinitions(columnCount));
worksheetXml.AppendLine(" </cols>");
worksheetXml.AppendLine(" <sheetData>");
worksheetXml.Append(rowXml);
worksheetXml.AppendLine(" </sheetData>");
worksheetXml.AppendLine($" <autoFilter ref=\"A{autoFilterRow}:{ToColumnName(columnCount)}{rowIndex - 1}\" />");
worksheetXml.AppendLine("</worksheet>");
return worksheetXml.ToString();
}
private static string BuildSingleCellRow(int rowIndex, int colIndex, string value, int styleIndex)
{
return $"<row r=\"{rowIndex}\">{BuildInlineCell(rowIndex, colIndex + 1, value, styleIndex)}</row>\n";
}
private static string BuildColumnDefinitions(int columnCount)
{
var builder = new StringBuilder();
for (var col = 1; col <= columnCount; col++)
{
builder.Append($" <col min=\"{col}\" max=\"{col}\" width=\"18\" customWidth=\"1\" />\n");
}
return builder.ToString();
}
private static string BuildCell(int rowIndex, int columnIndex, object? value)
{
if (value == null)
{
return string.Empty;
}
return value switch
{
DateTime dateTime => BuildNumericCell(rowIndex, columnIndex, dateTime.ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3),
DateTimeOffset dateTimeOffset => BuildNumericCell(rowIndex, columnIndex, dateTimeOffset.DateTime.ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3),
DateOnly dateOnly => BuildNumericCell(rowIndex, columnIndex, dateOnly.ToDateTime(TimeOnly.MinValue).ToOADate().ToString(CultureInfo.InvariantCulture), styleIndex: 3),
bool boolean => BuildBooleanCell(rowIndex, columnIndex, boolean),
sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal
=> BuildNumericCell(rowIndex, columnIndex, Convert.ToString(value, CultureInfo.InvariantCulture) ?? "0"),
_ => BuildInlineCell(rowIndex, columnIndex, Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, styleIndex: 0)
};
}
private static string BuildInlineCell(int rowIndex, int columnIndex, string value, int styleIndex)
{
var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}";
return $"""
<c r="{cellRef}" t="inlineStr" s="{styleIndex}">
<is><t xml:space="preserve">{EscapeXml(value)}</t></is>
</c>
""";
}
private static string BuildNumericCell(int rowIndex, int columnIndex, string value, int styleIndex = 0)
{
var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}";
return $"""<c r="{cellRef}" s="{styleIndex}"><v>{value}</v></c>""";
}
private static string BuildBooleanCell(int rowIndex, int columnIndex, bool value)
{
var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}";
return $"""<c r="{cellRef}" t="b"><v>{(value ? "1" : "0")}</v></c>""";
}
private static string ToColumnName(int columnNumber)
{
var dividend = columnNumber;
var columnName = string.Empty;
while (dividend > 0)
{
var modulo = (dividend - 1) % 26;
columnName = Convert.ToChar('A' + modulo) + columnName;
dividend = (dividend - modulo) / 26;
}
return columnName;
}
private static string SanitizeWorksheetName(string name)
{
var invalid = new[] { '[', ']', ':', '*', '?', '/', '\\' };
var cleaned = new string(name.Select(ch => invalid.Contains(ch) ? ' ' : ch).ToArray()).Trim();
if (string.IsNullOrWhiteSpace(cleaned))
{
cleaned = "Report";
}
return cleaned.Length <= 31 ? cleaned : cleaned[..31];
}
private static string EscapeXml(string value) =>
SecurityElement.Escape(value) ?? string.Empty;
}