diff --git a/README_V2.md b/README_V2.md index 8402a915..c6a6e13b 100644 --- a/README_V2.md +++ b/README_V2.md @@ -745,6 +745,38 @@ exporter.Export("Book1.xlsx", dt, configuration: config); ![image](docs/images/freeze-pane-1.png) +#### 14. Chart sheets + +A chart sheet holds only a chart. Add an `OpenXmlChartSheet` to the sheets of a workbook, next to the sheets its series read from: + +```csharp +var chart = new OpenXmlChart { Title = "Speed", LegendPosition = OpenXmlChartLegendPosition.Bottom }; +chart.XAxis.Title = "Distance (m)"; +chart.YAxis.Title = "Speed (m/s)"; +chart.Plots.Add(new OpenXmlChartPlot(OpenXmlChartType.ScatterLines) +{ + Series = + { + new OpenXmlChartSeries(OpenXmlChartData.FromRange("Readings", "B2:B500"), OpenXmlChartData.FromRange("Readings", "A2:A500")) + { + Name = "Speed", + Color = Color.Black + } + } +}); + +var sheets = new Dictionary +{ + ["Readings"] = readings, + ["Speed Graph"] = new OpenXmlChartSheet(chart) +}; + +var exporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); +exporter.Export("speed.xlsx", sheets); +``` + +Series read from a cell range or from literal values (`OpenXmlChartData.FromValues`). A chart can combine several plots, such as stacked columns on the secondary axes behind a line on the primary ones, and each axis can set its title, bounds, number format, label rotation, tick marks and gridlines. Colors with an alpha channel are drawn transparent, and `ShowInLegend = false` leaves a series out of the legend. + ### Fill Data To Excel Template diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs index 80dca3d8..19cff51c 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs @@ -1,3 +1,4 @@ +using MiniExcelLib.OpenXml.Charts; using MiniExcelLib.OpenXml.Writer; // ReSharper disable once CheckNamespace @@ -63,6 +64,9 @@ public async Task InsertSheetAsync(Stream stream, object value, string shee bool printHeader = true, bool overwriteSheet = false, OpenXmlConfiguration? configuration = null, IProgress? progress = null, CancellationToken cancellationToken = default) { + if (value is OpenXmlChartSheet) + throw new NotSupportedException("Chart sheets can't be inserted into an existing workbook"); + stream.Seek(0, SeekOrigin.End); configuration ??= new OpenXmlConfiguration { FastMode = true }; @@ -94,6 +98,9 @@ public async Task CopyAndAddSheetAsync(Stream inputStream, Stream outputStr bool printHeader = true, bool overwriteSheet = false, OpenXmlConfiguration? configuration = null, IProgress? progress = null, CancellationToken cancellationToken = default) { + if (value is OpenXmlChartSheet) + throw new NotSupportedException("Chart sheets can't be added to a copy of an existing workbook"); + var writer = await OpenXmlWriter .CreateForCopyAsync(inputStream, outputStream, value, sheetName, printHeader, configuration, cancellationToken) .ConfigureAwait(false); diff --git a/src/MiniExcel.OpenXml/Charts/ChartXml.cs b/src/MiniExcel.OpenXml/Charts/ChartXml.cs new file mode 100644 index 00000000..8efcb118 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/ChartXml.cs @@ -0,0 +1,423 @@ +using System.Drawing; + +namespace MiniExcelLib.OpenXml.Charts; + +internal static class ChartXml +{ + private const int PrimaryXAxisId = 500000001; + private const int PrimaryYAxisId = 500000002; + private const int SecondaryXAxisId = 500000003; + private const int SecondaryYAxisId = 500000004; + + // The size Excel gives a new chart sheet; it is rescaled to the window when opened + private const long ChartSheetWidthEmu = 9309100; + private const long ChartSheetHeightEmu = 6072188; + + private enum AxisKind + { + Scatter, + Vertical, + Horizontal + } + + internal static string ChartSheet(int sheetIndex) => XmlHelper.MinifyXml( + $""" + + + + + + """); + + internal static string ChartRelationship(int chartIndex) + => $""""""; + + internal static string ChartSheetAnchor(int chartIndex) + => $""" + + + + + + + + + + + + + + + + + + """; + + internal static void Validate(OpenXmlChart chart) + { + if (chart.Plots.Count == 0) + throw new InvalidOperationException("A chart needs at least one plot."); + if (chart.Plots.Any(p => p.Series.Count == 0)) + throw new InvalidOperationException("Every plot of a chart needs at least one series."); + if (chart.Plots.All(p => p.UseSecondaryAxes)) + throw new InvalidOperationException("At least one plot of a chart must use the primary axes."); + if (chart.Plots.SelectMany(p => p.Series).Any(s => s.Values.Strings is not null)) + throw new InvalidOperationException("Series values must be numbers or a cell range."); + if (chart.Plots.Any(p => p.GapWidth is < 0 or > 500)) + throw new InvalidOperationException("The gap width of a plot must be from 0 to 500."); + if (new[] { chart.XAxis, chart.YAxis, chart.SecondaryXAxis, chart.SecondaryYAxis }.Any(a => a.LabelRotation is < -90 or > 90)) + throw new InvalidOperationException("The label rotation of an axis must be from -90 to 90 degrees."); + if (new[] { chart.XAxis, chart.YAxis, chart.SecondaryXAxis, chart.SecondaryYAxis }.SelectMany(a => new[] { a.Minimum, a.Maximum, a.MajorUnit }).Any(v => v is { } d && (double.IsNaN(d) || double.IsInfinity(d)))) + throw new InvalidOperationException("The minimum, maximum and major unit of an axis must be finite numbers."); + + foreach (var group in chart.Plots.GroupBy(p => p.UseSecondaryAxes)) + { + var axes = group.Key ? "secondary" : "primary"; + if (group.Select(GetAxisKind).Distinct().Count() > 1) + throw new InvalidOperationException($"The plots on the {axes} axes can't share them: scatter, column and line, and horizontal bar plots each need their own axes."); + if (group.Count(p => p.IsBar) > 1 || group.Count(p => p.IsScatter) > 1 || group.Count(p => p.Type == OpenXmlChartType.Line) > 1) + throw new InvalidOperationException($"The {axes} axes have more than one plot of the same kind; add the series to a single plot instead."); + } + } + + internal static string Chart(OpenXmlChart chart) + { + var sb = new StringBuilder(); + sb.Append(""""""); + sb.Append(""""""); + sb.Append(""""""); + sb.Append(""); + + if (chart.Title is null) + { + sb.Append(""""""); + } + else + { + AppendTitle(sb, chart.Title, chart.TitleFontSize, chart.TitleBold, vertical: false); + sb.Append(""""""); + } + + sb.Append(""); + + // Excel lists the plots by chart type, columns and bars before lines before scatter, primary axes first + var plots = chart.Plots.OrderBy(p => p.IsBar ? 0 : p.IsScatter ? 2 : 1).ThenBy(p => p.UseSecondaryAxes).ToList(); + + var seriesIndex = 0; + foreach (var plot in plots) + { + AppendPlot(sb, plot, ref seriesIndex); + } + + var primary = chart.Plots.First(p => !p.UseSecondaryAxes); + AppendAxes(sb, GetAxisKind(primary), chart.XAxis, chart.YAxis, PrimaryXAxisId, PrimaryYAxisId, secondary: false); + + if (chart.Plots.FirstOrDefault(p => p.UseSecondaryAxes) is { } secondary) + { + AppendAxes(sb, GetAxisKind(secondary), chart.SecondaryXAxis, chart.SecondaryYAxis, SecondaryXAxisId, SecondaryYAxisId, secondary: true); + } + + sb.Append(""); + + if (chart.LegendPosition is { } legendPosition) + { + sb.Append($""""""); + var entries = GetLegendOrder(plots, legendPosition); + for (var i = 0; i < entries.Count; i++) + { + if (!entries[i].ShowInLegend) + sb.Append($""""""); + } + sb.Append(""""""); + } + + sb.Append(""""""); + sb.Append(""); + + if (chart.Border is { } border) + { + AppendLineProperties(sb, border); + } + + sb.Append(""); + return sb.ToString(); + } + + private static AxisKind GetAxisKind(OpenXmlChartPlot plot) => plot switch + { + { IsScatter: true } => AxisKind.Scatter, + { Type: OpenXmlChartType.Bar or OpenXmlChartType.StackedBar } => AxisKind.Horizontal, + _ => AxisKind.Vertical + }; + + // A legendEntry index is the position of the entry in the legend as Excel draws it, not a series index. Excel + // lists horizontal bars bottom up, and stacked columns top down beside a vertical legend, so those are reversed + private static List GetLegendOrder(List plots, OpenXmlChartLegendPosition position) + { + var verticalLegend = position is OpenXmlChartLegendPosition.Left or OpenXmlChartLegendPosition.Right or OpenXmlChartLegendPosition.TopRight; + var entries = new List(); + foreach (var plot in plots) + { + var reversed = plot.Type == OpenXmlChartType.Bar || (plot.Type == OpenXmlChartType.StackedColumn && verticalLegend); + entries.AddRange(reversed ? Enumerable.Reverse(plot.Series) : plot.Series); + } + + return entries; + } + + private static void AppendPlot(StringBuilder sb, OpenXmlChartPlot plot, ref int seriesIndex) + { + var (xAxisId, yAxisId) = plot.UseSecondaryAxes ? (SecondaryXAxisId, SecondaryYAxisId) : (PrimaryXAxisId, PrimaryYAxisId); + + switch (plot.Type) + { + case OpenXmlChartType.Scatter or OpenXmlChartType.ScatterLines: + sb.Append(""""""); + break; + case OpenXmlChartType.Line: + sb.Append(""""""); + break; + default: + var direction = plot.Type is OpenXmlChartType.Bar or OpenXmlChartType.StackedBar ? "bar" : "col"; + var grouping = plot.Type is OpenXmlChartType.StackedColumn or OpenXmlChartType.StackedBar ? "stacked" : "clustered"; + sb.Append($""""""); + break; + } + + foreach (var series in plot.Series) + { + AppendSeries(sb, plot, series, seriesIndex++); + } + + switch (plot.Type) + { + case OpenXmlChartType.Scatter or OpenXmlChartType.ScatterLines: + sb.Append($""""""); + break; + case OpenXmlChartType.Line: + sb.Append($""""""); + break; + default: + sb.Append($""""""); + if (plot.Type is OpenXmlChartType.StackedColumn or OpenXmlChartType.StackedBar) + sb.Append(""""""); + sb.Append($""""""); + break; + } + } + + private static void AppendSeries(StringBuilder sb, OpenXmlChartPlot plot, OpenXmlChartSeries series, int index) + { + sb.Append($""""""); + if (series.Name is not null) + sb.Append($"{XmlHelper.EncodeXml(series.Name)}"); + + if (plot.IsBar) + { + if (!series.Color.IsEmpty) + sb.Append($"{Color(series.Color)}"); + sb.Append(""""""); + } + else if (plot.Type is OpenXmlChartType.Scatter) + { + // Markers only: Excel stores this as a line marker series whose line isn't drawn + sb.Append(""""""); + sb.Append(""""""); + if (!series.Color.IsEmpty) + sb.Append($"{Color(series.Color)}{Color(series.Color)}"); + sb.Append(""); + } + else + { + AppendLineProperties(sb, new OpenXmlChartLine { Color = series.Color, Width = series.LineWidth }); + sb.Append(""""""); + } + + if (plot.IsScatter) + { + if (series.Categories is { } xValues) + AppendData(sb, "c:xVal", xValues, textRange: false); + AppendData(sb, "c:yVal", series.Values, textRange: false); + sb.Append(""""""); + } + else + { + if (series.Categories is { } categories) + AppendData(sb, "c:cat", categories, textRange: true); + AppendData(sb, "c:val", series.Values, textRange: false); + if (plot.Type is OpenXmlChartType.Line) + sb.Append(""""""); + } + + sb.Append(""); + } + + private static void AppendData(StringBuilder sb, string element, OpenXmlChartData data, bool textRange) + { + sb.Append($"<{element}>"); + if (data.Formula is { } formula) + { + // Excel refills the cache from the cells on load, but it numbers legend entries before that and skips + // series without one, so an empty cache keeps the hidden entries pointing at the right series + var (reference, cache) = textRange + ? ("c:strRef", """""") + : ("c:numRef", """General"""); + sb.Append($"<{reference}>{XmlHelper.EncodeXml(formula)}{cache}"); + } + else if (data.Numbers is { } numbers) + { + sb.Append($"""General"""); + for (var i = 0; i < numbers.Length; i++) + { + sb.Append($"""{Number(numbers[i])}"""); + } + sb.Append(""); + } + else + { + var strings = data.Strings!; + sb.Append($""""""); + for (var i = 0; i < strings.Length; i++) + { + sb.Append($"""{XmlHelper.EncodeXml(strings[i])}"""); + } + sb.Append(""); + } + sb.Append($""); + } + + private static void AppendAxes(StringBuilder sb, AxisKind kind, OpenXmlChartAxis xAxis, OpenXmlChartAxis yAxis, int xAxisId, int yAxisId, bool secondary) + { + var (xPosition, yPosition) = (kind, secondary) switch + { + (AxisKind.Horizontal, false) => ("l", "b"), + (AxisKind.Horizontal, true) => ("r", "t"), + (_, false) => ("b", "l"), + (_, true) => ("t", "r") + }; + + // The value axis crosses its partner at the far end on the secondary axes, so it sits opposite the primary one + var yCrosses = secondary ? "max" : "autoZero"; + var crossBetween = kind == AxisKind.Scatter ? "midCat" : "between"; + + if (kind == AxisKind.Scatter) + { + AppendValueAxis(sb, xAxis, xAxisId, yAxisId, xPosition, "autoZero", crossBetween); + } + else + { + AppendCategoryAxis(sb, xAxis, xAxisId, yAxisId, xPosition); + } + + AppendValueAxis(sb, yAxis, yAxisId, xAxisId, yPosition, yCrosses, crossBetween); + } + + private static void AppendValueAxis(StringBuilder sb, OpenXmlChartAxis axis, int axisId, int crossAxisId, string position, string crosses, string crossBetween) + { + sb.Append($""""""); + if (axis.Maximum is { } max) + sb.Append($""""""); + if (axis.Minimum is { } min) + sb.Append($""""""); + sb.Append(""); + + AppendAxisBody(sb, axis, crossAxisId, position); + + sb.Append($""""""); + if (axis.MajorUnit is { } majorUnit) + sb.Append($""""""); + sb.Append(""); + } + + private static void AppendCategoryAxis(StringBuilder sb, OpenXmlChartAxis axis, int axisId, int crossAxisId, string position) + { + sb.Append($""""""); + AppendAxisBody(sb, axis, crossAxisId, position); + sb.Append(""""""); + } + + // The elements every axis shares, from delete to crossAx + private static void AppendAxisBody(StringBuilder sb, OpenXmlChartAxis axis, int crossAxisId, string position) + { + sb.Append($""""""); + + if (axis.MajorGridlines is { } gridlines) + { + sb.Append(""); + AppendLineProperties(sb, gridlines); + sb.Append(""); + } + + if (axis.Title is { } title) + AppendTitle(sb, title, null, false, vertical: position is "l" or "r"); + + sb.Append(axis.NumberFormat is { } format + ? $"""""" + : """"""); + + sb.Append($""""""); + + if (axis.Line is { } line) + AppendLineProperties(sb, line); + + if (axis.LabelRotation is { } rotation) + { + // DrawingML turns text clockwise, while Excel's dialog turns it counterclockwise + sb.Append($""""""); + } + + sb.Append($""""""); + } + + private static void AppendTitle(StringBuilder sb, string text, double? fontSize, bool bold, bool vertical) + { + var size = fontSize is { } points ? $" sz=\"{(int)Math.Round(points * 100)}\"" : ""; + var weight = bold ? " b=\"1\"" : " b=\"0\""; + var rotation = vertical ? """ rot="-5400000" vert="horz" """ : " "; + + sb.Append($""); + sb.Append($"{XmlHelper.EncodeXml(text)}"); + sb.Append(""""""); + } + + private static void AppendLineProperties(StringBuilder sb, OpenXmlChartLine line) + { + if (line.Color.IsEmpty && line.Width is null) + return; + + var width = line.Width is { } points ? $" w=\"{(long)Math.Round(points * 12700)}\"" : ""; + sb.Append($""); + if (!line.Color.IsEmpty) + sb.Append($"{Color(line.Color)}"); + sb.Append(""); + } + + private static string Color(Color color) + { + var rgb = $"{color.R:X2}{color.G:X2}{color.B:X2}"; + return color.A == 255 + ? $"""""" + : $""""""; + } + + private static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture); + + private static string GetTickMark(OpenXmlChartTickMark tickMark) => tickMark switch + { + OpenXmlChartTickMark.None => "none", + OpenXmlChartTickMark.Inside => "in", + OpenXmlChartTickMark.Outside => "out", + OpenXmlChartTickMark.Cross => "cross", + _ => throw new InvalidEnumArgumentException(nameof(tickMark), (int)tickMark, typeof(OpenXmlChartTickMark)) + }; + + private static string GetLegendPosition(OpenXmlChartLegendPosition position) => position switch + { + OpenXmlChartLegendPosition.Right => "r", + OpenXmlChartLegendPosition.Left => "l", + OpenXmlChartLegendPosition.Top => "t", + OpenXmlChartLegendPosition.Bottom => "b", + OpenXmlChartLegendPosition.TopRight => "tr", + _ => throw new InvalidEnumArgumentException(nameof(position), (int)position, typeof(OpenXmlChartLegendPosition)) + }; +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChart.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChart.cs new file mode 100644 index 00000000..ba5bed53 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChart.cs @@ -0,0 +1,48 @@ +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// A chart made of one or more plots that share primary and, optionally, secondary axes. +/// +public class OpenXmlChart +{ + public string? Title { get; set; } + + /// + /// The title font size in points. When null, Excel picks it. + /// + public double? TitleFontSize { get; set; } + + public bool TitleBold { get; set; } + + /// + /// Where the legend is placed. When null, the chart has no legend. + /// + public OpenXmlChartLegendPosition? LegendPosition { get; set; } = OpenXmlChartLegendPosition.Right; + + /// + /// The style of the chart border. When null, Excel picks it. + /// + public OpenXmlChartLine? Border { get; set; } + + /// + /// The horizontal axis of the primary plots, or the vertical one for bar plots. + /// + public OpenXmlChartAxis XAxis { get; } = new(); + + /// + /// The value axis of the primary plots. + /// + public OpenXmlChartAxis YAxis { get; } = new() { MajorGridlines = new OpenXmlChartLine() }; + + /// + /// The category or X axis of the plots on the secondary axes. Hidden unless made visible. + /// + public OpenXmlChartAxis SecondaryXAxis { get; } = new() { Visible = false }; + + /// + /// The value axis of the plots on the secondary axes, drawn on the opposite side of the primary one. + /// + public OpenXmlChartAxis SecondaryYAxis { get; } = new(); + + public List Plots { get; } = []; +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartAxis.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartAxis.cs new file mode 100644 index 00000000..9cba5830 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartAxis.cs @@ -0,0 +1,50 @@ +namespace MiniExcelLib.OpenXml.Charts; + +public class OpenXmlChartAxis +{ + /// + /// Whether the axis line, tick marks and labels are drawn. A hidden axis still scales its plots. + /// + public bool Visible { get; set; } = true; + + public string? Title { get; set; } + + /// + /// The lowest value on the axis. When null, Excel picks it. Ignored by category axes. + /// + public double? Minimum { get; set; } + + /// + /// The highest value on the axis. When null, Excel picks it. Ignored by category axes. + /// + public double? Maximum { get; set; } + + /// + /// The distance between major tick marks. When null, Excel picks it. Ignored by category axes. + /// + public double? MajorUnit { get; set; } + + /// + /// The number format of the labels, such as #,##0. When null, the labels follow the source data. + /// + public string? NumberFormat { get; set; } + + /// + /// The rotation of the labels in degrees from -90 to 90, counterclockwise like in Excel. When null, Excel picks it. + /// + public int? LabelRotation { get; set; } + + public OpenXmlChartTickMark MajorTickMark { get; set; } = OpenXmlChartTickMark.Outside; + + public OpenXmlChartTickMark MinorTickMark { get; set; } = OpenXmlChartTickMark.None; + + /// + /// The style of the axis line. When null, Excel picks it. + /// + public OpenXmlChartLine? Line { get; set; } + + /// + /// The style of the major gridlines. When null, no gridlines are drawn. + /// + public OpenXmlChartLine? MajorGridlines { get; set; } +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartData.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartData.cs new file mode 100644 index 00000000..47df2790 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartData.cs @@ -0,0 +1,81 @@ +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// The values or categories of a chart series: either a range of cells or literal values stored in the chart itself. +/// +public sealed class OpenXmlChartData +{ + private OpenXmlChartData(string? formula, double[]? numbers, string[]? strings) + { + Formula = formula; + Numbers = numbers; + Strings = strings; + } + + internal string? Formula { get; } + internal double[]? Numbers { get; } + internal string[]? Strings { get; } + + /// + /// Data read from a range of cells, such as B2:B100, on a sheet of the same workbook. + /// + public static OpenXmlChartData FromRange(string sheetName, string range) + { + if (string.IsNullOrEmpty(range)) + throw new ArgumentException("The range must not be empty.", nameof(range)); + + var cells = range.Split(':'); + if (cells.Length > 2) + throw new ArgumentException($"Value {range} is not a valid cell range.", nameof(range)); + + if (!CellReferenceConverter.TryParseCellReference(cells[0], out var firstColumn, out var firstRow) || + !CellReferenceConverter.TryParseCellReference(cells[^1], out var lastColumn, out var lastRow)) + throw new ArgumentException($"Value {range} is not a valid cell range.", nameof(range)); + + return FromRange(sheetName, firstRow, firstColumn, lastRow, lastColumn); + } + + /// + /// Data read from a range of cells on a sheet of the same workbook. Rows and columns are 1-based. + /// + public static OpenXmlChartData FromRange(string sheetName, int firstRow, int firstColumn, int lastRow, int lastColumn) + { + if (string.IsNullOrEmpty(sheetName)) + throw new ArgumentException("The sheet name must not be empty.", nameof(sheetName)); + if (firstRow < 1 || firstColumn < 1 || lastRow < firstRow || lastColumn < firstColumn) + throw new ArgumentOutOfRangeException(nameof(lastRow), "The range must start at row and column 1 or later and must not end before it starts."); + + var first = AbsoluteReference(firstColumn, firstRow); + var last = AbsoluteReference(lastColumn, lastRow); + var range = first == last ? first : $"{first}:{last}"; + + return new OpenXmlChartData($"'{sheetName.Replace("'", "''")}'!{range}", null, null); + } + + /// + /// Numbers stored in the chart itself. + /// + public static OpenXmlChartData FromValues(params double[] values) + { + if (values is null or []) + throw new ArgumentException("At least one value is required.", nameof(values)); + if (values.Any(v => double.IsNaN(v) || double.IsInfinity(v))) + throw new ArgumentException("Chart values must be finite numbers.", nameof(values)); + + return new OpenXmlChartData(null, values, null); + } + + /// + /// Text stored in the chart itself, typically category labels. + /// + public static OpenXmlChartData FromValues(params string[] values) + { + if (values is null or []) + throw new ArgumentException("At least one value is required.", nameof(values)); + + return new OpenXmlChartData(null, null, values); + } + + private static string AbsoluteReference(int column, int row) + => $"${CellReferenceConverter.GetAlphabeticalIndex(column - 1)}${row}"; +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartEnums.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartEnums.cs new file mode 100644 index 00000000..c2061e53 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartEnums.cs @@ -0,0 +1,36 @@ +namespace MiniExcelLib.OpenXml.Charts; + +public enum OpenXmlChartType +{ + /// Points at X and Y values, drawn as markers. + Scatter, + /// Points at X and Y values, joined by straight lines without markers. + ScatterLines, + /// Values at evenly spaced categories, joined by straight lines without markers. + Line, + /// Vertical bars side by side. + Column, + /// Vertical bars stacked on top of each other. + StackedColumn, + /// Horizontal bars side by side. + Bar, + /// Horizontal bars stacked end to end. + StackedBar +} + +public enum OpenXmlChartLegendPosition +{ + Right, + Left, + Top, + Bottom, + TopRight +} + +public enum OpenXmlChartTickMark +{ + None, + Inside, + Outside, + Cross +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartLine.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartLine.cs new file mode 100644 index 00000000..11b848d2 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartLine.cs @@ -0,0 +1,19 @@ +using System.Drawing; + +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// The style of a line in a chart: an axis, gridlines or the chart border. +/// +public class OpenXmlChartLine +{ + /// + /// The line color. The alpha channel sets the transparency. When empty, Excel picks the color. + /// + public Color Color { get; set; } + + /// + /// The line width in points. When null, Excel picks the width. + /// + public double? Width { get; set; } +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartPlot.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartPlot.cs new file mode 100644 index 00000000..858c7262 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartPlot.cs @@ -0,0 +1,26 @@ +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// A group of series drawn the same way on the same axes. A chart with several plots combines them, for example columns +/// on the secondary axes behind a line on the primary axes. +/// +public class OpenXmlChartPlot(OpenXmlChartType type) +{ + public OpenXmlChartType Type { get; } = type; + + /// + /// Whether the plot uses the secondary axes of the chart instead of the primary ones. + /// + public bool UseSecondaryAxes { get; set; } + + /// + /// The gap between column or bar clusters as a percentage of the bar width, from 0 to 500. Ignored by other plots. + /// + public int GapWidth { get; set; } = 150; + + public List Series { get; } = []; + + internal bool IsScatter => Type is OpenXmlChartType.Scatter or OpenXmlChartType.ScatterLines; + + internal bool IsBar => Type is OpenXmlChartType.Column or OpenXmlChartType.StackedColumn or OpenXmlChartType.Bar or OpenXmlChartType.StackedBar; +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartSeries.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartSeries.cs new file mode 100644 index 00000000..d17edb07 --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartSeries.cs @@ -0,0 +1,36 @@ +using System.Drawing; + +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// One series of a chart plot. +/// +/// The values of the series: the Y values of a scatter plot, or the bar heights and line points of the other plots. +/// The X values of a scatter plot, or the category labels of the other plots. When omitted, points are numbered from 1. +public class OpenXmlChartSeries(OpenXmlChartData values, OpenXmlChartData? categories = null) +{ + public OpenXmlChartData Values { get; } = values ?? throw new ArgumentNullException(nameof(values)); + + public OpenXmlChartData? Categories { get; } = categories; + + /// + /// The name shown in the legend. + /// + public string? Name { get; set; } + + /// + /// The line color of scatter and line series, or the fill color of column and bar series. The alpha channel sets + /// the transparency. When empty, Excel picks the color. + /// + public Color Color { get; set; } + + /// + /// The line width in points, for scatter and line series. When null, Excel picks the width. + /// + public double? LineWidth { get; set; } + + /// + /// Whether the series has an entry in the legend. + /// + public bool ShowInLegend { get; set; } = true; +} diff --git a/src/MiniExcel.OpenXml/Charts/OpenXmlChartSheet.cs b/src/MiniExcel.OpenXml/Charts/OpenXmlChartSheet.cs new file mode 100644 index 00000000..ce31be1e --- /dev/null +++ b/src/MiniExcel.OpenXml/Charts/OpenXmlChartSheet.cs @@ -0,0 +1,9 @@ +namespace MiniExcelLib.OpenXml.Charts; + +/// +/// A sheet that holds only a chart. Export it as one of the sheets of a workbook, next to the sheets with its data. +/// +public class OpenXmlChartSheet(OpenXmlChart chart) +{ + public OpenXmlChart Chart { get; } = chart ?? throw new ArgumentNullException(nameof(chart)); +} diff --git a/src/MiniExcel.OpenXml/Constants/ExcelContentTypes.cs b/src/MiniExcel.OpenXml/Constants/ExcelContentTypes.cs index 9f207e4d..7ed1e95d 100644 --- a/src/MiniExcel.OpenXml/Constants/ExcelContentTypes.cs +++ b/src/MiniExcel.OpenXml/Constants/ExcelContentTypes.cs @@ -5,6 +5,8 @@ internal static class ExcelContentTypes internal const string Relationships = "application/vnd.openxmlformats-package.relationships+xml"; internal const string Workbook = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; internal const string Worksheet = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; + internal const string ChartSheet = "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"; + internal const string Chart = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; internal const string SharedStrings = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"; internal const string Styles = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; internal const string Drawing = "application/vnd.openxmlformats-officedocument.drawing+xml"; diff --git a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs index 9ee84b1c..4916563a 100644 --- a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs +++ b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs @@ -16,6 +16,9 @@ internal static class ExcelFileNames internal static string Worksheet(int sheetId) => $"xl/worksheets/sheet{sheetId}.xml"; internal static string SheetRels(int sheetId) => $"xl/worksheets/_rels/sheet{sheetId}.xml.rels"; + internal static string ChartSheet(int sheetId) => $"xl/chartsheets/sheet{sheetId}.xml"; + internal static string ChartSheetRels(int sheetId) => $"xl/chartsheets/_rels/sheet{sheetId}.xml.rels"; + internal static string Chart(int chartIndex) => $"xl/charts/chart{chartIndex}.xml"; internal static string Drawing(int sheetIndex) => $"xl/drawings/drawing{sheetIndex}.xml"; internal static string DrawingRels(int sheetIndex) => $"xl/drawings/_rels/drawing{sheetIndex}.xml.rels"; internal static string DrawingRels(string drawingFileName) => $"xl/drawings/_rels/{drawingFileName}.rels"; diff --git a/src/MiniExcel.OpenXml/Constants/ExcelXml.cs b/src/MiniExcel.OpenXml/Constants/ExcelXml.cs index 7d6a500e..1be22b83 100644 --- a/src/MiniExcel.OpenXml/Constants/ExcelXml.cs +++ b/src/MiniExcel.OpenXml/Constants/ExcelXml.cs @@ -109,7 +109,7 @@ internal static string ContentTypes(Dictionary contentTypes) } internal static string WorksheetRelationship(SheetDto sheetDto) - => $""""""; + => $""""""; internal static string ImageRelationship(FileDto image) => $""""""; diff --git a/src/MiniExcel.OpenXml/Models/SheetDto.cs b/src/MiniExcel.OpenXml/Models/SheetDto.cs index 967b4763..2d486c32 100644 --- a/src/MiniExcel.OpenXml/Models/SheetDto.cs +++ b/src/MiniExcel.OpenXml/Models/SheetDto.cs @@ -5,6 +5,8 @@ internal class SheetDto internal int SheetIdx { get; set; } internal string Id => $"rSheetId{SheetIdx}"; internal string? Name { get; set; } - internal string Path => ExcelFileNames.Worksheet(SheetIdx); + internal bool IsChartSheet { get; set; } + internal string Path => IsChartSheet ? ExcelFileNames.ChartSheet(SheetIdx) : ExcelFileNames.Worksheet(SheetIdx); + internal string RelsPath => IsChartSheet ? ExcelFileNames.ChartSheetRels(SheetIdx) : ExcelFileNames.SheetRels(SheetIdx); internal string State { get; set; } } diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs index 29e3aa00..e54d0bea 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.XmlGeneration.cs @@ -1,3 +1,4 @@ +using MiniExcelLib.OpenXml.Charts; using static MiniExcelLib.Core.Helpers.ImageHelper; namespace MiniExcelLib.OpenXml.Writer; @@ -15,6 +16,7 @@ internal partial class OpenXmlWriter private readonly Dictionary _zipContentsMap = []; private readonly Dictionary _sharedStrings = []; + private readonly Dictionary _charts = []; private IEnumerable<(SheetDto Sheet, object? Data)> GetSheets() { @@ -385,6 +387,11 @@ private string GetDrawingRelationshipXml(int sheetIndex) drawing.AppendLine(ExcelXml.ImageRelationship(image)); } + if (_charts.ContainsKey(sheetIndex)) + { + drawing.AppendLine(ChartXml.ChartRelationship(sheetIndex)); + } + return drawing.ToString(); } @@ -401,14 +408,19 @@ private string GetDrawingXml(int sheetIndex) } } + if (_charts.ContainsKey(sheetIndex)) + { + drawing.Append(ChartXml.ChartSheetAnchor(sheetIndex)); + } + return drawing.ToString(); } - private (string WorkbookContents, string WorkbookRelsContents, Dictionary SheetRelsContents) GenerateWorkbookItems() + private (string WorkbookContents, string WorkbookRelsContents, Dictionary SheetRelsContents) GenerateWorkbookItems() { var workbookContents = new StringBuilder(); var workbookRelsContents = new StringBuilder(); - var sheetsRelsDict = new Dictionary(); + var sheetsRelsDict = new Dictionary(); var sheetId = 0; foreach (var sheetDto in _sheets) @@ -419,7 +431,7 @@ private string GetDrawingXml(int sheetIndex) workbookRelsContents.AppendLine(ExcelXml.WorksheetRelationship(sheetDto)); //TODO: support multiple drawing - sheetsRelsDict.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); + sheetsRelsDict.Add(sheetDto.RelsPath, ExcelXml.DrawingRelationship(sheetId)); } return (workbookContents.ToString(), workbookRelsContents.ToString(), sheetsRelsDict); diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs index 2c7aeaff..7c2dbe70 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs @@ -1,4 +1,5 @@ using MiniExcelLib.Core.WriteAdapters; +using MiniExcelLib.OpenXml.Charts; using MiniExcelLib.OpenXml.Reader; using MiniExcelLib.OpenXml.Styles.Builder; @@ -63,6 +64,11 @@ public async Task SaveAsAsync(IProgress? progress = null, Cancellati #else using var disposableArchive = _archive; #endif + foreach (var chartSheet in GetSheets().Select(s => s.Data).OfType()) + { + ChartXml.Validate(chartSheet.Chart); + } + await CreateZipEntryAsync(ExcelFileNames.Rels, ExcelContentTypes.Relationships, ExcelXml.DefaultRels, cancellationToken).ConfigureAwait(false); await using var sbc = _sheetStyleBuilderContext.ConfigureAwait(false); @@ -76,6 +82,13 @@ public async Task SaveAsAsync(IProgress? progress = null, Cancellati _sheets.Add(sheet.Sheet); //TODO:remove _currentSheetIndex = sheet.Sheet.SheetIdx; + if (sheet.Data is OpenXmlChartSheet chartSheet) + { + await CreateChartSheetXmlAsync(sheet.Sheet, chartSheet.Chart, cancellationToken).ConfigureAwait(false); + rowsWritten.Add(0); + continue; + } + var rows = await CreateSheetXmlAsync(sheet.Data, sheet.Sheet.Path, progress, cancellationToken).ConfigureAwait(false); rowsWritten.Add(rows); } @@ -116,7 +129,8 @@ public async Task InsertAsync(bool overwriteSheet = false, IProgress? { Name = sheet.Name, SheetIdx = (int)sheet.Id, - State = sheet.State + State = sheet.State, + IsChartSheet = sheet.Path?.Contains("chartsheets/") is true }) ); @@ -194,6 +208,16 @@ private async Task CreateSheetXmlAsync(object? values, string sheetPath, IP return rowsWritten; } + [CreateSyncVersion] + private async Task CreateChartSheetXmlAsync(SheetDto sheet, OpenXmlChart chart, CancellationToken cancellationToken) + { + sheet.IsChartSheet = true; + _charts.Add(sheet.SheetIdx, chart); + + await CreateZipEntryAsync(sheet.Path, ExcelContentTypes.ChartSheet, ChartXml.ChartSheet(sheet.SheetIdx), cancellationToken).ConfigureAwait(false); + await CreateZipEntryAsync(ExcelFileNames.Chart(sheet.SheetIdx), ExcelContentTypes.Chart, ChartXml.Chart(chart), cancellationToken).ConfigureAwait(false); + } + [CreateSyncVersion] private static async Task WriteEmptySheetAsync(MiniExcelStreamWriter writer) { @@ -568,9 +592,8 @@ await CreateZipEntryAsync( private async Task GenerateWorkbookAsync(bool removeOriginalEntry = false, CancellationToken cancellationToken = default) { var (workbookXml, workbookRelsXml, sheetsRelsXml) = GenerateWorkbookItems(); - foreach (var (index, contents) in sheetsRelsXml) + foreach (var (sheetRelsXmlPath, contents) in sheetsRelsXml) { - var sheetRelsXmlPath = ExcelFileNames.SheetRels(index); if (removeOriginalEntry) _archive.Entries.SingleOrDefault(s => s.FullName == sheetRelsXmlPath)?.Delete(); diff --git a/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetAsyncTests.cs new file mode 100644 index 00000000..e16aa958 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetAsyncTests.cs @@ -0,0 +1,251 @@ +using System.Drawing; +using MiniExcelLib.OpenXml.Charts; +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; + +namespace MiniExcelLib.OpenXml.Tests.Charts; + +public class MiniExcelOpenXmlChartSheetAsyncTests +{ + private readonly OpenXmlExporter _exporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); + private readonly OpenXmlImporter _importer = MiniExcelV2.Importers.GetOpenXmlImporter(); + + public MiniExcelOpenXmlChartSheetAsyncTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + private static readonly object[] Readings = + [ + new { Odometer = 0.0, Speed = 1.5 }, + new { Odometer = 10.0, Speed = 4.5 }, + new { Odometer = 20.0, Speed = 6.5 } + ]; + + private static OpenXmlChart SpeedChart() + { + var chart = new OpenXmlChart + { + Title = "Speed", + TitleFontSize = 18, + TitleBold = true, + LegendPosition = OpenXmlChartLegendPosition.Bottom, + Border = new OpenXmlChartLine { Width = 0.75 } + }; + chart.XAxis.Title = "Odometer (m)"; + chart.XAxis.Minimum = 0; + chart.XAxis.Maximum = 20; + chart.XAxis.NumberFormat = "#,##0"; + chart.XAxis.LabelRotation = -75; + chart.YAxis.Title = "Speed (m/s)"; + chart.YAxis.Minimum = 0; + chart.YAxis.Maximum = 8; + chart.YAxis.MajorGridlines = new OpenXmlChartLine { Color = Color.Gray, Width = 0.75 }; + chart.SecondaryYAxis.Visible = false; + chart.SecondaryYAxis.Minimum = 0; + chart.SecondaryYAxis.Maximum = 8; + + chart.Plots.Add(new OpenXmlChartPlot(OpenXmlChartType.ScatterLines) + { + Series = + { + new OpenXmlChartSeries(OpenXmlChartData.FromRange("Raw", "B2:B4"), OpenXmlChartData.FromRange("Raw", "A2:A4")) + { + Name = "Speed", + Color = Color.Black, + LineWidth = 2, + ShowInLegend = false + } + } + }); + + // Bands behind the line: two full-width columns stacked to the band limits + var bands = new OpenXmlChartPlot(OpenXmlChartType.StackedColumn) { UseSecondaryAxes = true, GapWidth = 0 }; + var categories = OpenXmlChartData.FromValues(0, 20); + bands.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(5, 5), categories) { Name = "Standard", Color = Color.FromArgb(64, Color.Green) }); + bands.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(1, 1), categories) { Name = "Modified", Color = Color.FromArgb(64, Color.Orange) }); + bands.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(2, 2), categories) { Name = "Best efforts", Color = Color.FromArgb(64, Color.Red) }); + chart.Plots.Add(bands); + + return chart; + } + + private async Task ExportAsync(OpenXmlChart chart) + { + var sheets = new Dictionary + { + ["Raw"] = Readings, + ["Speed Graph"] = new OpenXmlChartSheet(chart) + }; + + var stream = new MemoryStream(); + await _exporter.ExportAsync(stream, sheets); + stream.Position = 0; + return stream; + } + + [Fact] + public async Task ChartSheetPlotsTheDataSheetAsync() + { + using var stream = await ExportAsync(SpeedChart()); + + using var package = new ExcelPackage(stream); + var chartSheet = Assert.IsType(package.Workbook.Worksheets["Speed Graph"]); + var chart = chartSheet.Chart; + var line = Assert.IsType(chart.PlotArea.ChartTypes.Single(c => c is ExcelScatterChart)); + + Assert.Equal("Speed", chart.Title.Text); + Assert.Equal("Odometer (m)", line.XAxis.Title.Text); + Assert.Equal("Speed (m/s)", line.YAxis.Title.Text); + Assert.Equal(0, line.YAxis.MinValue); + Assert.Equal(8, line.YAxis.MaxValue); + Assert.Equal("#,##0", line.XAxis.Format); + + var series = line.Series.Single(); + Assert.Equal("'Raw'!$B$2:$B$4", series.Series); + Assert.Equal("'Raw'!$A$2:$A$4", series.XSeries); + } + + [Fact] + public async Task SecondaryPlotIsCombinedWithThePrimaryOneAsync() + { + using var stream = await ExportAsync(SpeedChart()); + + using var package = new ExcelPackage(stream); + var chart = ((ExcelChartsheet)package.Workbook.Worksheets["Speed Graph"]).Chart; + + Assert.Equal(2, chart.PlotArea.ChartTypes.Count); + var bands = Assert.IsType(chart.PlotArea.ChartTypes.Single(c => c is ExcelBarChart)); + Assert.Equal(3, bands.Series.Count); + Assert.Equal(0, bands.GapWidth); + } + + [Fact] + public async Task ChartXmlKeepsStylesLegendAndLiteralsAsync() + { + using var stream = await ExportAsync(SpeedChart()); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + using var reader = new StreamReader(archive.GetEntry("xl/charts/chart2.xml")!.Open()); + var chartXml = await reader.ReadToEndAsync(); + + // Excel lists columns before lines, so the line added first is series and legend entry 3 + Assert.Contains("""""", chartXml); + Assert.Contains("""""", chartXml); + Assert.Contains("""""", chartXml); + Assert.Contains("""General5""", chartXml); + Assert.Contains("""""", chartXml); + Assert.Contains("""""", chartXml); + Assert.Contains("""""", chartXml); + } + + [Theory] + [InlineData(OpenXmlChartLegendPosition.Right, 2)] + [InlineData(OpenXmlChartLegendPosition.Bottom, 0)] + public async Task HiddenLegendEntryFollowsExcelsLegendOrderAsync(OpenXmlChartLegendPosition position, int expectedEntry) + { + // Beside a vertical legend Excel lists stacked columns top down, so the first series is the last entry + var chart = new OpenXmlChart { LegendPosition = position }; + var plot = new OpenXmlChartPlot(OpenXmlChartType.StackedColumn); + plot.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(1, 2)) { ShowInLegend = false }); + plot.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(3, 4))); + plot.Series.Add(new OpenXmlChartSeries(OpenXmlChartData.FromValues(5, 6))); + chart.Plots.Add(plot); + + using var stream = await ExportAsync(chart); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + using var reader = new StreamReader(archive.GetEntry("xl/charts/chart2.xml")!.Open()); + var chartXml = await reader.ReadToEndAsync(); + + Assert.Contains($"""""", chartXml); + } + + [Fact] + public async Task DataSheetIsStillReadableAsync() + { + using var stream = await ExportAsync(SpeedChart()); + + var names = await _importer.GetSheetNamesAsync(stream, leaveOpen: true); + Assert.Equal(["Raw", "Speed Graph"], names); + + stream.Position = 0; + var rows = await _importer.QueryAsync(stream, hasHeaderRow: true, sheetName: "Raw").ToListAsync(); + Assert.Equal(3, rows.Count); + Assert.Equal(6.5, (double)rows[2].Speed); + } + + [Fact] + public async Task InsertingASheetKeepsTheChartSheetAsync() + { + using var stream = await ExportAsync(SpeedChart()); + + await _exporter.InsertSheetAsync(stream, new[] { new { Note = "added" } }, sheetName: "Notes"); + + stream.Position = 0; + using var package = new ExcelPackage(stream); + Assert.IsType(package.Workbook.Worksheets["Speed Graph"]); + Assert.Equal("added", package.Workbook.Worksheets["Notes"].Cells["A2"].Value); + } + + [Fact] + public async Task ChartSheetCanNotBeInsertedAsync() + { + using var stream = await ExportAsync(SpeedChart()); + + await Assert.ThrowsAsync(() => _exporter.InsertSheetAsync(stream, new OpenXmlChartSheet(SpeedChart()), sheetName: "Another")); + } + + [Fact] + public async Task ChartSheetCanNotBeAddedToACopyAsync() + { + using var stream = await ExportAsync(SpeedChart()); + using var output = new MemoryStream(); + + await Assert.ThrowsAsync(() => _exporter.CopyAndAddSheetAsync(stream, output, new OpenXmlChartSheet(SpeedChart()), sheetName: "Another")); + } + + [Theory] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NaN)] + public async Task NonFiniteAxisValueIsRejectedAsync(double value) + { + var chart = SpeedChart(); + chart.YAxis.Maximum = value; + + await Assert.ThrowsAsync(() => ExportAsync(chart)); + } + + [Fact] + public async Task ScatterAndColumnPlotsCanNotShareAxesAsync() + { + var chart = SpeedChart(); + chart.Plots[1].UseSecondaryAxes = false; + + var exception = await Assert.ThrowsAsync(() => ExportAsync(chart)); + Assert.Contains("primary axes", exception.Message); + } + + [Fact] + public async Task ChartWithoutPlotsIsRejectedAsync() + { + await Assert.ThrowsAsync(() => ExportAsync(new OpenXmlChart())); + } + + [Theory] + [InlineData("Raw", "A2:A10", "'Raw'!$A$2:$A$10")] + [InlineData("O'Brien", "c3", "'O''Brien'!$C$3")] + public void RangeIsAbsoluteAndQuoted(string sheetName, string range, string expected) + { + var data = OpenXmlChartData.FromRange(sheetName, range); + Assert.Equal(expected, data.Formula); + } + + [Theory] + [InlineData("A")] + [InlineData("A1:B2:C3")] + [InlineData("B5:A1")] + public void InvalidRangeIsRejected(string range) + { + Assert.ThrowsAny(() => OpenXmlChartData.FromRange("Raw", range)); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetTests.cs b/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetTests.cs new file mode 100644 index 00000000..51f30e69 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Charts/MiniExcelOpenXmlChartSheetTests.cs @@ -0,0 +1,42 @@ +using MiniExcelLib.OpenXml.Charts; +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; + +namespace MiniExcelLib.OpenXml.Tests.Charts; + +public class MiniExcelOpenXmlChartSheetTests +{ + private readonly OpenXmlExporter _exporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); + + public MiniExcelOpenXmlChartSheetTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + [Fact] + public void ColumnChartSheetIsWritten() + { + var chart = new OpenXmlChart { Title = "Sales" }; + chart.Plots.Add(new OpenXmlChartPlot(OpenXmlChartType.Column) + { + Series = { new OpenXmlChartSeries(OpenXmlChartData.FromRange("Data", "B2:B3"), OpenXmlChartData.FromRange("Data", "A2:A3")) { Name = "Sales" } } + }); + + var sheets = new Dictionary + { + ["Chart"] = new OpenXmlChartSheet(chart), + ["Data"] = new[] { new { Month = "Jan", Sales = 10 }, new { Month = "Feb", Sales = 12 } } + }; + + using var stream = new MemoryStream(); + _exporter.Export(stream, sheets); + stream.Position = 0; + + using var package = new ExcelPackage(stream); + var chartSheet = Assert.IsType(package.Workbook.Worksheets["Chart"]); + var barChart = Assert.IsType(chartSheet.Chart); + Assert.Equal(eChartType.ColumnClustered, barChart.ChartType); + Assert.Equal("'Data'!$A$2:$A$3", barChart.Series.Single().XSeries); + Assert.Equal("Jan", package.Workbook.Worksheets["Data"].Cells["A2"].Value); + } +}