314 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			314 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using Elwig.Models.Dtos;
 | |
| using System;
 | |
| using System.Collections.Generic;
 | |
| using System.Data.Entity.Core.Common.CommandTrees.ExpressionBuilder;
 | |
| using System.Globalization;
 | |
| using System.IO;
 | |
| using System.IO.Compression;
 | |
| using System.Linq;
 | |
| using System.Threading.Tasks;
 | |
| 
 | |
| namespace Elwig.Helpers.Export {
 | |
|     public class OdsFile : IDisposable {
 | |
| 
 | |
|         protected readonly string FileName;
 | |
|         protected readonly ZipArchive ZipArchive;
 | |
|         protected StreamWriter? Content;
 | |
|         private readonly List<string> _tables;
 | |
| 
 | |
|         public OdsFile(string filename) {
 | |
|             FileName = filename;
 | |
|             File.Delete(filename);
 | |
|             ZipArchive = ZipFile.Open(FileName, ZipArchiveMode.Create);
 | |
|             _tables = [];
 | |
|         }
 | |
| 
 | |
|         public void Dispose() {
 | |
|             AddTrailer().GetAwaiter().GetResult();
 | |
|             ZipArchive?.Dispose();
 | |
|             GC.SuppressFinalize(this);
 | |
|         }
 | |
| 
 | |
|         private async Task AddHeader() {
 | |
|             var mimetype = ZipArchive.CreateEntry("mimetype", CompressionLevel.NoCompression);
 | |
|             using (var writer = new StreamWriter(mimetype.Open(), Utils.UTF8)) {
 | |
|                 await writer.WriteAsync("application/vnd.oasis.opendocument.spreadsheet");
 | |
|             }
 | |
| 
 | |
|             var manifest = ZipArchive.CreateEntry("META-INF/manifest.xml");
 | |
|             using (var writer = new StreamWriter(manifest.Open(), Utils.UTF8)) {
 | |
|                 await writer.WriteAsync("""
 | |
|                     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | |
|                     <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
 | |
|                      <manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
 | |
|                      <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
 | |
|                      <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
 | |
|                      <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
 | |
|                      <manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>
 | |
|                     </manifest:manifest>
 | |
| 
 | |
|                     """);
 | |
|             }
 | |
| 
 | |
|             var styles = ZipArchive.CreateEntry("styles.xml");
 | |
|             using (var writer = new StreamWriter(styles.Open(), Utils.UTF8)) {
 | |
|                 await writer.WriteAsync("""
 | |
|                     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | |
|                     <office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" office:version="1.3">
 | |
|                     </office:document-styles>
 | |
| 
 | |
|                     """);
 | |
|             }
 | |
| 
 | |
|             var meta = ZipArchive.CreateEntry("meta.xml");
 | |
|             using (var writer = new StreamWriter(meta.Open(), Utils.UTF8)) {
 | |
|                 var now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
 | |
|                 await writer.WriteAsync($"""
 | |
|                     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | |
|                     <office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.3">
 | |
|                      <office:meta>
 | |
|                       <meta:generator>Elwig {App.Version}</meta:generator>
 | |
|                       <meta:initial-creator>Elwig</meta:initial-creator>
 | |
|                       <dc:creator>Elwig</dc:creator>
 | |
|                       <meta:creation-date>{now}</meta:creation-date>
 | |
|                       <dc:date>{now}</dc:date>
 | |
|                      </office:meta>
 | |
|                     </office:document-meta>
 | |
| 
 | |
|                     """);
 | |
|             }
 | |
| 
 | |
|             var content = ZipArchive.CreateEntry("content.xml");
 | |
|             Content = new StreamWriter(content.Open(), Utils.UTF8);
 | |
|             await Content.WriteAsync("""
 | |
|                 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | |
|                 <office:document-content xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" office:version="1.3">
 | |
|                  <office:automatic-styles>
 | |
|                   <style:default-style style:family="table-cell">
 | |
|                    <style:text-properties fo:language="de" fo:country="AT"/>
 | |
|                   </style:default-style>
 | |
|                   <style:style style:name="default" style:family="table-cell">
 | |
|                    <style:table-cell-properties style:vertical-align="top"/>
 | |
|                   </style:style>
 | |
| 
 | |
|                 """);
 | |
| 
 | |
|             for (int i = 1; i <= 100; i++) {
 | |
|                 await Content.WriteAsync($"  <style:style style:name=\"col{i}mm\" style:family=\"table-column\"><style:table-column-properties style:column-width=\"{i}mm\"/></style:style>\r\n");
 | |
|             }
 | |
| 
 | |
|             await Content.WriteAsync("""
 | |
|                   <style:style style:name="header" style:family="table-cell" style:parent-style-name="default">
 | |
|                    <style:table-cell-properties style:text-align-source="fix" style:repeat-content="false"/>
 | |
|                    <style:paragraph-properties fo:text-align="center"/>
 | |
|                    <style:text-properties fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold" fo:font-size="16pt"/>
 | |
|                   </style:style>
 | |
|                   <style:style style:name="subheader" style:family="table-cell" style:parent-style-name="default">
 | |
|                    <style:table-cell-properties style:text-align-source="fix" style:repeat-content="false"/>
 | |
|                    <style:paragraph-properties fo:text-align="center"/>
 | |
|                    <style:text-properties fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"/>
 | |
|                   </style:style>
 | |
|                   <style:style style:name="th" style:family="table-cell" style:parent-style-name="default">
 | |
|                    <style:table-cell-properties style:text-align-source="fix" style:repeat-content="false" style:vertical-align="middle"/>
 | |
|                    <style:paragraph-properties fo:text-align="center"/>
 | |
|                    <style:text-properties fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"/>
 | |
|                   </style:style>
 | |
|                   <number:number-style style:name="NN0"><number:number number:decimal-places="0" number:min-decimal-places="0" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N0" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN0"/>
 | |
|                   <number:number-style style:name="NN1"><number:number number:decimal-places="1" number:min-decimal-places="1" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N1" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN1"/>
 | |
|                   <number:number-style style:name="NN2"><number:number number:decimal-places="2" number:min-decimal-places="2" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N2" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN2"/>
 | |
|                   <number:number-style style:name="NN3"><number:number number:decimal-places="3" number:min-decimal-places="3" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N3" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN3"/>
 | |
|                   <number:number-style style:name="NN4"><number:number number:decimal-places="4" number:min-decimal-places="4" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N4" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN4"/>
 | |
|                   <number:number-style style:name="NN5"><number:number number:decimal-places="5" number:min-decimal-places="5" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N5" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN5"/>
 | |
|                   <number:number-style style:name="NN6"><number:number number:decimal-places="6" number:min-decimal-places="6" number:min-integer-digits="1" number:grouping="true"/></number:number-style>
 | |
|                   <style:style style:name="N6" style:family="table-cell" style:parent-style-name="default" style:data-style-name="NN6"/>
 | |
|                   <number:date-style style:name="_date"><number:day number:style="long"/><number:text>.</number:text><number:month number:style="long"/><number:text>.</number:text><number:year number:style="long"/></number:date-style>
 | |
|                   <style:style style:name="date" style:family="table-cell" style:parent-style-name="default" style:data-style-name="_date"/>
 | |
|                   <number:time-style style:name="_time"><number:hours number:style="long"/><number:text>:</number:text><number:minutes number:style="long"/><number:text>:</number:text><number:seconds number:style="long"/></number:time-style>
 | |
|                   <style:style style:name="time" style:family="table-cell" style:parent-style-name="default" style:data-style-name="_time"/>
 | |
|                   <number:date-style style:name="_datetime">
 | |
|                    <number:day number:style="long"/><number:text>.</number:text><number:month number:style="long"/><number:text>.</number:text><number:year number:style="long"/>
 | |
|                    <number:text> </number:text>
 | |
|                    <number:hours number:style="long"/><number:text>:</number:text><number:minutes number:style="long"/><number:text>:</number:text><number:seconds number:style="long"/>
 | |
|                   </number:date-style>
 | |
|                   <style:style style:name="datetime" style:family="table-cell" style:parent-style-name="default" style:data-style-name="_datetime"/>
 | |
|                  </office:automatic-styles>
 | |
|                  <office:body>
 | |
|                   <office:spreadsheet>
 | |
|                    <table:calculation-settings table:case-sensitive="false" table:search-criteria-must-apply-to-whole-cell="true" table:use-wildcards="true" table:use-regular-expressions="false" table:automatic-find-labels="false"/>
 | |
| 
 | |
|                 """);
 | |
|         }
 | |
| 
 | |
|         private async Task AddTrailer() {
 | |
|             if (Content == null) await AddHeader();
 | |
|             if (Content == null) return;
 | |
| 
 | |
|             await Content.WriteAsync("""
 | |
|                   </office:spreadsheet>
 | |
|                  </office:body>
 | |
|                 </office:document-content>
 | |
| 
 | |
|                 """);
 | |
|             Content.Close();
 | |
|             Content.Dispose();
 | |
|             Content = null;
 | |
| 
 | |
|             var settings = ZipArchive.CreateEntry("settings.xml");
 | |
|             using (var writer = new StreamWriter(settings.Open(), Utils.UTF8)) {
 | |
|                 await writer.WriteAsync("""
 | |
|                     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 | |
|                     <office:document-settings xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ooo="http://openoffice.org/2004/office" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" office:version="1.3">
 | |
|                      <office:settings>
 | |
|                       <config:config-item-set config:name="ooo:view-settings">
 | |
|                        <config:config-item-map-indexed config:name="Views">
 | |
|                         <config:config-item-map-entry>
 | |
|                          <config:config-item-map-named config:name="Tables">
 | |
| 
 | |
|                     """);
 | |
| 
 | |
|                 foreach (var tbl in _tables) {
 | |
|                     await writer.WriteAsync($"""
 | |
|                               <config:config-item-map-entry config:name="{tbl}">
 | |
|                                <config:config-item config:name="VerticalSplitMode" config:type="short">2</config:config-item>
 | |
|                                <config:config-item config:name="VerticalSplitPosition" config:type="int">5</config:config-item>
 | |
|                                <config:config-item config:name="PositionBottom" config:type="int">5</config:config-item>
 | |
|                               </config:config-item-map-entry>
 | |
| 
 | |
|                         """);
 | |
|                 }
 | |
| 
 | |
|                 await writer.WriteAsync("""
 | |
|                          </config:config-item-map-named>
 | |
|                         </config:config-item-map-entry>
 | |
|                        </config:config-item-map-indexed>
 | |
|                       </config:config-item-set>
 | |
|                      </office:settings>
 | |
|                     </office:document-settings>
 | |
| 
 | |
|                     """);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         public async Task AddTable<T>(DataTable<T> table, bool mergeSubRowCells = true) {
 | |
|             if (Content == null) await AddHeader();
 | |
|             if (Content == null) return;
 | |
|             var totalSpan = table.ColumnSpans.Sum();
 | |
| 
 | |
|             _tables.Add(table.Name);
 | |
|             await Content.WriteAsync($"    <table:table table:name=\"{table.Name}\" table:default-cell-style-name=\"default\">\r\n");
 | |
|             foreach (var (s, w) in table.ColumnSpans.Zip(table.ColumnWidths)) {
 | |
|                 for (int i = 0; i < s; i++) {
 | |
|                     await Content.WriteAsync("     <table:table-column" + (w != null ? $" table:style-name=\"col{(int)(w / s)}mm\"" : "") + "/>\r\n");
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             await Content.WriteAsync(
 | |
|                 $"     <table:table-row>\r\n" +
 | |
|                 FormatCell(table.FullName, colSpan: totalSpan, style: "header") +
 | |
|                 $"     </table:table-row>\r\n" +
 | |
|                 $"     <table:table-row>\r\n" +
 | |
|                 FormatCell(table.Subtitle, colSpan: totalSpan, style: "subheader") +
 | |
|                 $"     </table:table-row>\r\n" +
 | |
|                 $"     <table:table-row>\r\n" +
 | |
|                 $"      <table:table-cell table:number-columns-repeated=\"{totalSpan}\"/>\r\n" +
 | |
|                 $"     </table:table-row>\r\n" +
 | |
|                 $"     <table:table-row>\r\n");
 | |
|             foreach (var (name, span, units) in table.ColumnNames.Zip(table.ColumnSpans, table.ColumnUnits)) {
 | |
|                 var hasUnits = units.Length > 0;
 | |
|                 await Content.WriteAsync(FormatCell(name, colSpan: span, rowSpan: hasUnits ? 1 : 2, style: "th"));
 | |
|             }
 | |
|             await Content.WriteAsync("     </table:table-row>\r\n     <table:table-row>\r\n");
 | |
|             foreach (var (span, units) in table.ColumnSpans.Zip(table.ColumnUnits)) {
 | |
|                 if (units.Length == 0) {
 | |
|                     await Content.WriteAsync($"      <table:covered-table-cell table:number-columns-repeated=\"{span}\"/>\r\n");
 | |
|                     continue;
 | |
|                 }
 | |
|                 foreach (var u in units) {
 | |
|                     await Content.WriteAsync(FormatCell(u == null ? null : $"[{u}]", style: "th"));
 | |
|                 }
 | |
|             }
 | |
|             await Content.WriteAsync("     </table:table-row>\r\n");
 | |
| 
 | |
|             foreach (var row in table.GetData()) {
 | |
|                 await FormatRow(row, table.ColumnUnits, mergeSubRowCells);
 | |
|             }
 | |
| 
 | |
|             await Content.WriteAsync("    </table:table>\r\n");
 | |
|         }
 | |
| 
 | |
|         protected async Task FormatRow(IEnumerable<object?> row, IEnumerable<string?[]?> colUnits, bool mergeSubRowCells) {
 | |
|             if (Content == null) throw new InvalidOperationException();
 | |
|             var arrays = row.Where(c => c is Array).Cast<Array>().Select(c => c.Length).ToArray();
 | |
|             int rowNum = Math.Max(1, arrays.Length > 0 ? arrays.Max() : 0);
 | |
|             for (int i = 0; i < rowNum; i++) {
 | |
|                 await Content.WriteAsync("     <table:table-row>\r\n");
 | |
|                 foreach (var (data, units) in row.Zip(colUnits)) {
 | |
|                     if (data is Array a) {
 | |
|                         await Content.WriteAsync(i < a.Length ? FormatCell(a.GetValue(i), units: units) : $"      <table:table-cell table:number-columns-repeated=\"{GetSubCols(a.GetType().GetElementType())}\"/>\r\n");
 | |
|                     } else if (!mergeSubRowCells) {
 | |
|                         await Content.WriteAsync(FormatCell(data, units: units));
 | |
|                     } else {
 | |
|                         await Content.WriteAsync(FormatCell(data, rowSpan: i == 0 ? rowNum : 1, isCovered: i > 0, units: units));
 | |
|                     }
 | |
|                 }
 | |
|                 await Content.WriteAsync("     </table:table-row>\r\n");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         private static int GetSubCols(Type? type) {
 | |
|             if (type != null && type.IsValueType == true && type.Name.StartsWith("ValueTuple"))
 | |
|                 return type.GetFields().Length;
 | |
|             return 1;
 | |
|         }
 | |
| 
 | |
|         protected static string FormatCell(object? data, int rowSpan = 1, int colSpan = 1, string? style = "default", bool isCovered = false, string?[]? units = null) {
 | |
|             if (data?.GetType().IsValueType == true && data.GetType().Name.StartsWith("ValueTuple"))
 | |
|                 return string.Join("", data.GetType().GetFields().Zip(units ?? [])
 | |
|                     .Select(p => FormatCell(p.First.GetValue(data), rowSpan, colSpan, style, isCovered, [p.Second]))
 | |
|                 );
 | |
| 
 | |
|             var add = (style != null ? $" table:style-name=\"{style}\"" : "") + (rowSpan > 1 || colSpan > 1 ? $" table:number-rows-spanned=\"{rowSpan}\" table:number-columns-spanned=\"{colSpan}\"" : "");
 | |
|             string ct = isCovered ? "table:covered-table-cell" : "table:table-cell";
 | |
|             var isPercent = units != null && units.Length > 0 && units[0] == "%";
 | |
| 
 | |
|             string c;
 | |
|             if (data == null) {
 | |
|                 c = $"<{ct}{add}/>";
 | |
|             } else if (data is DateOnly date) {
 | |
|                 add = string.Join(' ', add.Split(' ').Select(p => p.StartsWith("table:style-name=") ? $"table:style-name=\"date\"" : p));
 | |
|                 c = $"<{ct} office:value-type=\"date\" calcext:value-type=\"date\" office:date-value=\"{date:yyyy-MM-dd}\"{add}><text:p>{date:dd.MM.yyyy}</text:p></{ct}>";
 | |
|             } else if (data is TimeOnly time) {
 | |
|                 add = string.Join(' ', add.Split(' ').Select(p => p.StartsWith("table:style-name=") ? $"table:style-name=\"time\"" : p));
 | |
|                 c = $"<{ct} office:value-type=\"time\" calcext:value-type=\"time\" office:time-value=\"{time:\\P\\THH\\Hmm\\Mss\\S}\"{add}><text:p>{time:HH:mm:ss}</text:p></{ct}>";
 | |
|             } else if (data is DateTime dt) {
 | |
|                 add = string.Join(' ', add.Split(' ').Select(p => p.StartsWith("table:style-name=") ? $"table:style-name=\"datetime\"" : p));
 | |
|                 c = $"<{ct} office:value-type=\"date\" calcext:value-type=\"date\" office:date-value=\"{dt:yyyy-MM-dd\\THH:mm:ss}\"{add}><text:p>{dt:dd.MM.yyyy HH:mm:ss}</text:p></{ct}>";
 | |
|             } else if (data is decimal || data is float || data is double || data is byte || data is char ||
 | |
|                        data is short || data is ushort || data is int || data is uint || data is long || data is ulong) {
 | |
|                 double v = double.Parse(data?.ToString() ?? "0");  // use default culture for ToString and Parse()!
 | |
|                 if (units != null && units.Length > 0) {
 | |
|                     int n = -1;
 | |
|                     switch (units[0]) {
 | |
|                         case "%": n = 1; data = $"{v:N1}"; break;
 | |
|                         case "€": n = 2; data = $"{v:N2}"; break;
 | |
|                         case "°KMW": n = 1; data = $"{v:N1}"; break;
 | |
|                         case "°Oe": n = 0; data = $"{v:N0}"; break;
 | |
|                     }
 | |
|                     if (n >= 0) add = string.Join(' ', add.Split(' ').Select(p => p.StartsWith("table:style-name=") ? $"table:style-name=\"N{n}\"" : p));
 | |
|                 }
 | |
|                 c = $"<{ct} office:value-type=\"float\" calcext:value-type=\"float\" office:value=\"{v.ToString(CultureInfo.InvariantCulture)}\"{add}><text:p>{data}</text:p></{ct}>";
 | |
|             } else {
 | |
|                 c = $"<{ct} office:value-type=\"string\" calcext:value-type=\"string\"{add}><text:p>{data}</text:p></{ct}>";
 | |
|             }
 | |
| 
 | |
|             return $"      {c}\r\n" + (colSpan > 1 ? $"      <table:covered-table-cell table:number-rows-repeated=\"{colSpan - 1}\"/>\r\n" : "");
 | |
|         }
 | |
|     }
 | |
| }
 |