From 3243668641b188e37c3d5a1d152eaf853075ea15 Mon Sep 17 00:00:00 2001 From: bin yang Date: Wed, 11 Aug 2021 21:18:36 +0800 Subject: [PATCH 1/2] Enable excel exporting with table spanning multiple rows Signed-off-by: bin yang --- .../TemplateExport/TemplateExportHelper.cs | 59 ++++++++++---- .../ExcelTemplateExporter2_Tests.cs | 77 ++++++++++++++++++ .../ExportTemplates/template-multirows.xlsx | Bin 0 -> 11638 bytes 3 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs create mode 100644 src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-multirows.xlsx diff --git a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs index c8da927..348a746 100644 --- a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs +++ b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs @@ -174,7 +174,7 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport Data = data ?? throw new ArgumentException("数据不能为空!", nameof(data)); - using (Stream stream = new FileStream(TemplateFilePath, FileMode.Open)) + using (Stream stream = new FileStream(TemplateFilePath, FileMode.Open, FileAccess.Read)) { using (var excelPackage = new ExcelPackage(stream)) { @@ -277,6 +277,23 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport var isFirst = true; + //calculate stride + var minRowIndex = -1; + var maxRowIndex = -1; + foreach (var writer in tableGroup) + { + var address = new ExcelAddressBase(writer.TplAddress); + if (minRowIndex == -1 || minRowIndex > address.Start.Row) + { + minRowIndex = address.Start.Row; + } + if (maxRowIndex < address.End.Row) + { + maxRowIndex = address.End.Row; + } + } + var stride = maxRowIndex - minRowIndex + 1;//default to 1 + foreach (var col in tableGroup) { var address = new ExcelAddressBase(col.TplAddress); @@ -292,21 +309,25 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport startRow = address.Start.Row; //插入行 //插入的目标行号 - var targetRow = address.Start.Row + 1 + insertRows; + var targetRow = address.Start.Row + 1*stride + insertRows; //插入 - var numRowsToInsert = rowCount - 1; + var numRowsToInsert = (rowCount - 1); var refRow = address.Start.Row + insertRows; //sheet.InsertRow(targetRow, numRowsToInsert, refRow); - sheet.InsertRow(targetRow, numRowsToInsert); + sheet.InsertRow(targetRow, numRowsToInsert * stride); //EPPlus的问题。修复如果存在合并的单元格,但是在新插入的行无法生效的问题,具体见 https://stackoverflow.com/questions/31853046/epplus-copy-style-to-a-range/34299694#34299694 for (var i = 0; i < numRowsToInsert; i++) { - sheet.Cells[String.Format("{0}:{0}", refRow)].Copy(sheet.Cells[String.Format("{0}:{0}", targetRow + i)]); - //sheet.Row(refRow).StyleID = sheet.Row(targetRow + i).StyleID; + for(var j=0; j < stride; j++) + { + sheet.Cells[String.Format("{0}:{0}", refRow+j)].Copy( + sheet.Cells[String.Format("{0}:{0}", targetRow + i*stride+j)]); + //sheet.Row(refRow).StyleID = sheet.Row(targetRow + i).StyleID; + } } } - RenderTableCells(target, tbParameters, sheet, insertRows, tableKey, rowCount, col, address); + RenderTableCells(target, tbParameters, sheet, insertRows, tableKey, rowCount, col, address, stride); if (isFirst) { @@ -319,13 +340,13 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport var updateCellWriters = SheetWriters[sheetName].Where(p => p.WriterType == WriterTypes.Cell).Where(p => p.RowIndex > startRow); foreach (var item in updateCellWriters) { - item.RowIndex += rowCount - 1; + item.RowIndex += (rowCount - 1)*stride; } #endregion 更新单元格 //表格渲染完成后更新插入的行数 - insertRows += rowCount - 1; + insertRows += (rowCount - 1)*stride; } } @@ -365,7 +386,10 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport /// /// /// - private void RenderTableCells(Interpreter target, Parameter[] tbParameters, ExcelWorksheet sheet, int insertRows, string tableKey, int rowCount, IWriter writer, ExcelAddressBase address) + /// + private void RenderTableCells( + Interpreter target, Parameter[] tbParameters, ExcelWorksheet sheet, int insertRows, + string tableKey, int rowCount, IWriter writer, ExcelAddressBase address, int stride=1) { var cellString = writer.CellString; if (cellString.Contains("{{Table>>")) @@ -375,7 +399,8 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport //{{Remark|>>Table}} cellString = cellString.Split('|')[0].Trim() + "}}"; - RenderTableCells(target, tbParameters, sheet, insertRows, tableKey, rowCount, cellString, address); + RenderTableCells(target, tbParameters, sheet, insertRows, tableKey, + rowCount, cellString, address, stride); } /// @@ -456,7 +481,10 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport /// /// /// - private void RenderTableCells(Interpreter target, Parameter[] parameters, ExcelWorksheet sheet, int insertRows, string tableKey, int rowCount, string cellString, ExcelAddressBase address) + /// + private void RenderTableCells(Interpreter target, Parameter[] parameters, + ExcelWorksheet sheet, int insertRows, string tableKey, int rowCount, + string cellString, ExcelAddressBase address, int stride=1) { //var dataVar = !IsDynamicSupportTypes ? ("\" + data." + tableKey + "[index].") : ("\" + data[\"" + tableKey + "\"][index]"); string dataVar; @@ -476,7 +504,7 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport //渲染一列单元格 for (var i = 0; i < rowCount; i++) { - var rowIndex = address.Start.Row + i + insertRows; + var rowIndex = address.Start.Row + i*stride + insertRows; var targetAddress = new ExcelAddress(rowIndex, address.Start.Column, rowIndex, address.Start.Column); //https://github.com/dotnetcore/Magicodes.IE/issues/155 sheet.Row(rowIndex).Height = sheet.Row(address.Start.Row).Height; @@ -669,10 +697,11 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport var rows = q.GroupBy(p => p.Rows); + //move isStartTable out of outer loop to cope with multiple line table + var isStartTable = false; + string tableKey = null; foreach (var rowGroups in rows) { - var isStartTable = false; - string tableKey = null; foreach (var cell in rowGroups) { var cellString = cell.Value.ToString(); diff --git a/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs new file mode 100644 index 0000000..08280d1 --- /dev/null +++ b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs @@ -0,0 +1,77 @@ +using Magicodes.ExporterAndImporter.Core; +using Magicodes.ExporterAndImporter.Excel; +using Magicodes.ExporterAndImporter.Tests.Models.Export; +using Magicodes.ExporterAndImporter.Tests.Models.Export.ExportByTemplate_Test1; +using Newtonsoft.Json.Linq; +using OfficeOpenXml; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Magicodes.ExporterAndImporter.Tests +{ + public class ExcelTemplateExporter2_Tests : TestBase + { + #region 模板导出 + + [Fact(DisplayName = "Excel跨行模板导出")] + public async Task ExportByTemplateMultiRows_Test() + { + //模板路径 + var tplPath = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "ExportTemplates", + "template-multirows.xlsx"); + //创建Excel导出对象 + IExportFileByTemplate exporter = new ExcelExporter(); + //导出路径 + var filePath = Path.Combine(Directory.GetCurrentDirectory(), nameof(ExportByTemplateMultiRows_Test) + ".xlsx"); + if (File.Exists(filePath)) File.Delete(filePath); + //根据模板导出 + await exporter.ExportByTemplate(filePath, + new TextbookOrderInfo("湖南心莱信息科技有限公司", "湖南长沙岳麓区", "雪雁", "1367197xxxx", null, + DateTime.Now.ToLongDateString(), "https://docs.microsoft.com/en-us/media/microsoft-logo-dark.png", + new List() + { + new BookInfo(1, "0000000001", "《XX从入门到放弃》", null, "机械工业出版社", "3.14", 100, "备注") + { + Cover = Path.Combine("TestFiles", "ExporterTest.png") + }, + new BookInfo(2, "0000000001", "《XX从入门到放弃》", null, "机械工业出版社", "3.14", 100, "备注") + { + Cover = "https://docs.microsoft.com/en-us/media/microsoft-logo-dark.png" + }, + new BookInfo(3, "0000000002", "《XX从入门到放弃》", "张三", "机械工业出版社", "3.14", 100, null), + new BookInfo(4, null, "《XX从入门到放弃》", "张三", "机械工业出版社", "3.14", 100, "备注") + { + Cover = Path.Combine("TestFiles", "issue131.png") + } + }), + tplPath); + + using (var pck = new ExcelPackage(new FileInfo(filePath))) + { + //检查转换结果 + var sheet = pck.Workbook.Worksheets.First(); + //确保所有的转换均已完成 + sheet.Cells[sheet.Dimension.Address].Any(p => p.Text.Contains("{{")).ShouldBeFalse(); + //检查图片 + sheet.Drawings.Count.ShouldBe(4); + + sheet.Cells[sheet.Dimension.Address].Any(p => p.Text.Contains("图")).ShouldBeTrue(); + //检查合计是否正确 + + sheet.Cells["H15"].Formula.ShouldBe("=SUM(G4,G6,G8,G10)"); + sheet.Cells["H16"].Formula.ShouldBe("=AVERAGE(G4,G6,G8,G10)"); + } + } + + + #endregion 模板导出 + + } +} diff --git a/src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-multirows.xlsx b/src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-multirows.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e22dc639a028d2de8fb3e8b25b1ad8beb6300fd1 GIT binary patch literal 11638 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 Dr(>yh literal 0 HcmV?d00001 -- Gitee From 2e911555ee0a679e6bdca619c6b9315c7af3af43 Mon Sep 17 00:00:00 2001 From: bin yang Date: Wed, 11 Aug 2021 21:22:13 +0800 Subject: [PATCH 2/2] Enable excel exporting with auto-merging adjacent cells Introduce Merge directive to auto-merge adjacent cells with identical value Signed-off-by: bin yang --- .../Utility/TemplateExport/IWriter.cs | 5 + .../TemplateExport/TemplateExportHelper.cs | 149 +++++++++++++++++- .../Utility/TemplateExport/Writer.cs | 5 + .../ExcelTemplateExporter2_Tests.cs | 52 +++++- ...Magicodes.ExporterAndImporter.Tests.csproj | 6 + .../ExportTemplates/template-mergecells.xlsx | Bin 0 -> 11572 bytes 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-mergecells.xlsx diff --git a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/IWriter.cs b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/IWriter.cs index 030dd45..107a313 100644 --- a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/IWriter.cs +++ b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/IWriter.cs @@ -47,5 +47,10 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport /// 表格数据对象Key /// string TableKey { get; set; } + + /// + /// 单元格合并键 + /// + string MergeCellKey { get; set; } } } \ No newline at end of file diff --git a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs index 348a746..8dda776 100644 --- a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs +++ b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/TemplateExportHelper.cs @@ -293,7 +293,7 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport } } var stride = maxRowIndex - minRowIndex + 1;//default to 1 - + var mergeCellKeys = new Dictionary>(); foreach (var col in tableGroup) { var address = new ExcelAddressBase(col.TplAddress); @@ -302,6 +302,16 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport sheet.Cells[address.Start.Row, address.Start.Column].Value = string.Empty; continue; } + if (!col.MergeCellKey.IsNullOrWhiteSpace()) + { + if (!mergeCellKeys.ContainsKey(col.MergeCellKey)) + { + mergeCellKeys.Add(col.MergeCellKey, new List()); + } + var mergecelllist = mergeCellKeys[col.MergeCellKey]; + //mergecelllist.Add(address); + mergecelllist.Add(new ExcelAddressBase(col.TplAddress)); //avoid side effect of below codes + } //TODO:支持同一行多个表格 //行数大于1时需要插入行 if (isFirst && rowCount > 1) @@ -335,6 +345,59 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport } } + #region 合并单元格 + { + foreach (var mergeCellKey in mergeCellKeys.Keys) + { + foreach (var address in mergeCellKeys[mergeCellKey]) + { + object currentValue; + object prevValue = null; + var rangeStartRowIndex = -1; + var rangeEndRowIndex = -1; + //check the list of data for this column + for (var i = 0; i < rowCount; i++) + { + var cellFunc = CreateOrGetCellFuncByTableKey(target, tbParameters, tableKey, mergeCellKey); + var result = cellFunc.Invoke(i); + + var rowIndex = address.Start.Row + i * stride + insertRows; + currentValue = result; + if (currentValue.Equals(prevValue)) + { + //merge cell + if (rangeStartRowIndex == -1) + { + rangeStartRowIndex = rowIndex - 1; + } + rangeEndRowIndex = rowIndex; + } + else + { + //change of value, hence perform range merge + if (rangeStartRowIndex != -1) + { + var rangeAddress = new ExcelAddress( + rangeStartRowIndex, address.Start.Column, rangeEndRowIndex, address.Start.Column); + sheet.Cells[rangeAddress.Address].Merge = true; + rangeStartRowIndex = -1; + } + } + prevValue = currentValue; + } + if (rangeStartRowIndex != -1) + { + var rangeAddress = new ExcelAddress( + rangeStartRowIndex, address.Start.Column, rangeEndRowIndex, address.Start.Column); + sheet.Cells[rangeAddress.Address].Merge = true; + rangeStartRowIndex = -1; + } + } + } + } + + #endregion 合并单元格 + #region 更新单元格 var updateCellWriters = SheetWriters[sheetName].Where(p => p.WriterType == WriterTypes.Cell).Where(p => p.RowIndex > startRow); @@ -349,7 +412,51 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport insertRows += (rowCount - 1)*stride; } } + private Lambda CreateOrGetCellFuncByTableKey(Interpreter target, Parameter[] tbParameters, string tableKey, string cellKey) + { + //get expression + var expresson = "{{" + cellKey + "}}"; + string dataVar; + if (IsDictionaryType || IsExpandoObjectType) + { + dataVar = ($"\" + {tableKey}.Skip(index).First()"); + } + else if (IsJObjectType) + { + dataVar = $"\" + data[\"{tableKey}\"][index]"; + } + else + { + dataVar = $"\" + {tableKey}.Skip(index).First()."; + } + { + if (IsDynamicSupportTypes) + { + dataVar = dataVar.TrimEnd('.'); + expresson = expresson + .Replace("{{", dataVar + "[\"") + .Replace("}}", "\"] + \""); + } + else + { + expresson = expresson + .Replace("{{", dataVar) + .Replace("}}", " + \""); + } + + expresson = expresson.StartsWith("\"") + ? expresson.TrimStart('\"').TrimStart().TrimStart('+') + : "\"" + expresson; + expresson = expresson.EndsWith("\"") + ? expresson.TrimEnd('\"').TrimEnd().TrimEnd('+') + : expresson + "\""; + } + var cellFunc = CreateOrGetCellFunc(target, null, expresson, tbParameters); + + return cellFunc; + + } /// /// 重新设置行宽(适应图片) /// @@ -392,12 +499,13 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport string tableKey, int rowCount, IWriter writer, ExcelAddressBase address, int stride=1) { var cellString = writer.CellString; + var tokens = cellString.Split('|'); if (cellString.Contains("{{Table>>")) //{{ Table >> BookInfo | RowNo}} - cellString = "{{" + cellString.Split('|')[1].Trim(); + cellString = "{{" + tokens[1].Trim(); else if (cellString.Contains(">>Table}}")) //{{Remark|>>Table}} - cellString = cellString.Split('|')[0].Trim() + "}}"; + cellString = tokens[0].Trim() + "}}"; RenderTableCells(target, tbParameters, sheet, insertRows, tableKey, rowCount, cellString, address, stride); @@ -430,7 +538,7 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport /// /// /// - private void RenderCell(Interpreter target, ExcelWorksheet sheet, string expresson, string cellAddress, string dataVar = "\" + data.", Lambda cellFunc = null, Parameter[] parameters = null, params object[] invokeParams) + private object RenderCell(Interpreter target, ExcelWorksheet sheet, string expresson, string cellAddress, string dataVar = "\" + data.", Lambda cellFunc = null, Parameter[] parameters = null, params object[] invokeParams) { //处理单元格渲染管道 RenderCellPipeline(target, sheet, ref expresson, cellAddress, cellFunc, parameters, dataVar, invokeParams); @@ -468,6 +576,7 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport { sheet.Cells[cellAddress].Value = expresson; } + return sheet.Cells[cellAddress].Value; } /// @@ -502,13 +611,26 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport } //渲染一列单元格 + //var isFirstCell = true; + //object previousValue = null; for (var i = 0; i < rowCount; i++) { var rowIndex = address.Start.Row + i*stride + insertRows; var targetAddress = new ExcelAddress(rowIndex, address.Start.Column, rowIndex, address.Start.Column); //https://github.com/dotnetcore/Magicodes.IE/issues/155 sheet.Row(rowIndex).Height = sheet.Row(address.Start.Row).Height; - RenderCell(target, sheet, cellString, targetAddress.Address, dataVar, null, parameters, i); + var value = RenderCell(target, sheet, cellString, targetAddress.Address, dataVar, null, parameters, i); + //if (!mergeCellKey.IsNullOrWhiteSpace()) + //{ + // if (!isFirstCell) + // { + // //check if need merge with the cell in previous row (in case of stride > 1, + // //make sure the adjacent cells are merged in template) + + // } + // isFirstCell = false; + // previousValue = value; + //} } } @@ -711,6 +833,20 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport //{{ Table >> BookInfo | RowNo}} tableKey = Regex.Split(cellString, "{{Table>>")[1].Split('|')[0].Trim(); } + var mergeCellKey = string.Empty; + if (cellString.ToLower().Contains("|merge")) + { + var tokens = Regex.Split(cellString, + "Merge", RegexOptions.IgnoreCase); + var tokens1 = tokens[1].Split('}'); + var tokens2 = tokens1[0].Trim('?', '&').Split('='); + mergeCellKey = tokens2[1].Split('|')[0].Trim('}',' '); + + //remove merge + cellString = Regex.Replace(cellString, "\\|Merge.*\\|", e => { return "|"; }, RegexOptions.IgnoreCase); + cellString = Regex.Replace(cellString, "\\|Merge.*}}", e => { return "}}"; }, RegexOptions.IgnoreCase); + + } writers.Add(new Writer { @@ -719,7 +855,8 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport CellString = cellString, WriterType = isStartTable ? WriterTypes.Table : WriterTypes.Cell, RowIndex = cell.Start.Row, - ColIndex = cell.Start.Column + ColIndex = cell.Start.Column, + MergeCellKey = mergeCellKey }); if (isStartTable && cellString.Contains(">>Table}}")) diff --git a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/Writer.cs b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/Writer.cs index 118d55c..ac7cfc3 100644 --- a/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/Writer.cs +++ b/src/Magicodes.ExporterAndImporter.Excel/Utility/TemplateExport/Writer.cs @@ -52,5 +52,10 @@ namespace Magicodes.ExporterAndImporter.Excel.Utility.TemplateExport /// 列号 /// public int ColIndex { get; set; } + + /// + /// 单元格合并索引属性:相邻行此属性同值时,当前单元格与上一行同列单元格合并 + /// + public string MergeCellKey { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs index 08280d1..2dbac12 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs +++ b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter2_Tests.cs @@ -70,7 +70,57 @@ namespace Magicodes.ExporterAndImporter.Tests } } - + + [Fact(DisplayName = "Excel合并单元格模板导出")] + public async Task ExportByTemplateMergeCells_Test() + { + //模板路径 + var tplPath = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "ExportTemplates", + "template-mergecells.xlsx"); + //创建Excel导出对象 + IExportFileByTemplate exporter = new ExcelExporter(); + //导出路径 + var filePath = Path.Combine(Directory.GetCurrentDirectory(), nameof(ExportByTemplateMergeCells_Test) + ".xlsx"); + if (File.Exists(filePath)) File.Delete(filePath); + //根据模板导出 + await exporter.ExportByTemplate(filePath, + new TextbookOrderInfo("湖南心莱信息科技有限公司", "湖南长沙岳麓区", "雪雁", "1367197xxxx", null, + DateTime.Now.ToLongDateString(), "https://docs.microsoft.com/en-us/media/microsoft-logo-dark.png", + new List() + { + new BookInfo(1, "0000000001", "《XX从入门到放弃》", null, "机械工业出版社", "3.14", 100, "备注") + { + Cover = Path.Combine("TestFiles", "ExporterTest.png") + }, + new BookInfo(2, "0000000001", "《XX从入门到放弃》", null, "机械工业出版社", "3.14", 100, "备注") + { + Cover = "https://docs.microsoft.com/en-us/media/microsoft-logo-dark.png" + }, + new BookInfo(3, "0000000002", "《XX从入门到放弃》", "张三", "机械工业出版社", "3.14", 100, null), + new BookInfo(4, null, "《XX从入门到放弃》", "张三", "机械工业出版社", "3.14", 100, "备注") + { + Cover = Path.Combine("TestFiles", "issue131.png") + } + }), + tplPath); + + using (var pck = new ExcelPackage(new FileInfo(filePath))) + { + //检查转换结果 + var sheet = pck.Workbook.Worksheets.First(); + //确保所有的转换均已完成 + sheet.Cells[sheet.Dimension.Address].Any(p => p.Text.Contains("{{")).ShouldBeFalse(); + //检查图片 + sheet.Drawings.Count.ShouldBe(4); + + sheet.Cells[sheet.Dimension.Address].Any(p => p.Text.Contains("图")).ShouldBeTrue(); + //检查合计是否正确 + + sheet.Cells["H11"].Formula.ShouldBe("=SUM(G4:G6,G4)"); + sheet.Cells["H12"].Formula.ShouldBe("=AVERAGE(G4:G6)"); + } + } + #endregion 模板导出 } diff --git a/src/Magicodes.ExporterAndImporter.Tests/Magicodes.ExporterAndImporter.Tests.csproj b/src/Magicodes.ExporterAndImporter.Tests/Magicodes.ExporterAndImporter.Tests.csproj index 632cf4a..831c9b2 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/Magicodes.ExporterAndImporter.Tests.csproj +++ b/src/Magicodes.ExporterAndImporter.Tests/Magicodes.ExporterAndImporter.Tests.csproj @@ -84,6 +84,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-mergecells.xlsx b/src/Magicodes.ExporterAndImporter.Tests/TestFiles/ExportTemplates/template-mergecells.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..58e73308ee950e890e6801f510c800aa31be0a4a GIT binary patch literal 11572 zcmeHt1y@@OvvzQIC|X>LyQjEAai>Ue2@)(&DDEv3Efgs36eteGp}4zyad#_Uj@;XG z`u6(;_uXqHJ6YNDBzrRRjH)Wa!Q%rE0muLVfC}Ip+e76A0|59V004LZWLN`9N06%p z$kkXA;$#6fV)Jycr_6$fWy}D;La+bt@qbtYCGq{Bb`EUmtAtyu_q7C^u4-BY{?Ziv zPf%`Q=+kMRggZbGQQs_wLkfyK83UBFX41V6l`KWb%03F|iHoh<(<9m#)0%+u-?DO_ zh~90BM#9VMmD*ZS;gO1Qk}ob}Sdn6VaH{H3(v|2h2otum8RLeUr5{P7xQAXY8Sz_F3lX5xE(7wx&`ycF zv+=!fz-Ww;X2y$2r$ZN`ehR`T;H@V+koTW+!|4=AE%ukLhf84S<1jv#akb;`#_IDqQETo&(Tvjr^F zWtX8Ii3W99V;2j1Fgx2H&;NDR|HJGn-J>6oo2+(MfM~s;jeu@~3z7c00#*^7 zVZ!FqkYy6pL7uK5Ib>llTo&;9>&;g_e&sToelN$~TpH$>JgK1=z3Z4+iQO~aEbYu( z+O~4vzMZhXJL*?($=(!Ss93m{Z|&5;JdA-H)l?y9h z7sTG1b;VgB`f5mLm>+j&>B}ut3;*dd&9<<1Lr`}yM+5+{pjUX>vwJwY*qJ#x+WisX za<;7XQ!bDwfYE!cgt>@1PNngk;C zKRAim;^%f8xY=e_$&2T(-Qiv(3j7IQ;lt>dKg>>M^sL(O7^4;T zeDN!zw$kaI?=9FlQ(CsWaLKYn`8ICjI-$D>=~@qYRdI zo+i2NFTS8+>8|3S-xi&mh4t1ikVBl)v?fe6%9tA zcK01`A<)40LZXFVZIv4fW4Q?Uuu!OplkFVOk~~9~8*3*m5c*k3Jq=HbKDC7SoN2Px zt#NlCKU{mK`0Y+jJSuEG?rbW5{}Ahjgu#rtMAD=X&cwN}uUV~CjA|MW`=a_w31j~U zh3$>1;vbT`wAIO%l#R-WDHnH2lGK_LeGt=lxV@VVhWPUk5pjQUnc3#YEmiRaAMC=2 zwWs zU$xF#c8ctkw}KV|7ZKeF8D?VenQ z{?q@-mVxMmpwI9CfCrQ+iJ({f&6Z$m3kz2;`=2MyKWH;m#~?bJ58tS7KtMtYc3@Lp_=yq(r_$aA zx1SRuMS36GDnIvTi{fKrvJF1<0H$d>R|C(owW+WE!~OVL@CkcYvbw35gf`1yC3d~z zytHecjjk?BKJgM6@e4`+Pxt18g88fBoVno#LPMP@9Rl2nD8LpOL}BvvFktz1?3hx# zgn+UH%v~fnm9V;sr$NS$p3Te#6X#AKyz3*bEs4zrQhwBl)2G%W9WGRw^@R1| zt`?nQHCJT~64_LOeMT0!8pV%pS6i10nAtM1hmTrjJw4yg1ruTHrRyoU!=p&`K+FSo z8RhacN+7~3_7hW^AA6dggiEA=sh4Ukcf*FPn4HusO3(rS`WCA2mI5FBzjkncGY+_jky|K;P4~g%n1>sD{wi)q5Cvaq_ee>vqh^V$6 z)$~sYNDgEyuDnZqv(@kUL1f#UUun$-V_Vh(1=t7c^~Qaz*o8?x4Z+sDWufCSL!n)< z$*EuwH8;F2no5c#6-ye(do>=$7Hnce7cUn{!WSf8VOlzCXvg>U8uEi@I>&}R4iMGl+w|=irp&oq==qV8H6_ZY@&u-= zr9g39r8y?0%Fd-buDfq1v<-K#Z*yFb?dU8v3@g)maJoXP*UpyE0*&(CD)fY^1{EV% zu_Nju)im}<&W43!>2ZxOgipd}_5#g)XivV-(h|V{ssJ|->D1tdDE#PW0}bs;ntk%0 zk=~vbsPU0l6sx(D&~2;R3ru>3OptqhS7y1^O$C?{M=TT5q`(BjbDk`~S!wF{511viqRBniw2&ZF6xEoU++xt+|y`)xc#%MpFA$JX$ zmKE6zQ=_OD#aRcM71PlBbgIb4_*3~~Ci6*n>Ct+7Yk;!(eV%5i`ffL$bGZ)GhFRclNH_^qzP})BnAcvcDv-7Mmh8Mv9Bpq^Tc3Fu78Jx7 zhK9?JiCi$DVAz+h#~46;!F;@*JTOBV>d>$Kte}@}{ibPa>ni=t@Nm8B%}a?UngIk; zn_)-8f;(~*xF@kZyEnDpva#51(_@1hJ zB~<)lpCmqGBU_PrsgNu8$$uQwu58lSod!J9aqbu@7qw5icusQB7Ww35pv&w2)wm&<7MoRG#_n33 zde5de^#cyM zfUthB>a_B^z0x81jDGhAGz#>oo_DknxhaSarsiLVLt!hpaxBlKTIK1zf5;LCJXN#t zIXMp{d_m#Oo(>eMf85ykG0cEPp1SbJG;M)$n90M1@afIfjq&p9wTGMQ`7Nv#*Y)7= z$Aj({Zyq+$_^~h5EJiEK$RB=eKu^!_*6%_wuQ6ru@PPCvPnLLJ?=PC8lqd^-t08-F zT`V8G|)yU4Z<~{5V1`gNw3-1aqq*aEG*P2@gaDmP;9o35@MbiRoV5mPEqZ z9--L%Wk|FuO67&;huDsUIQfd-%+2W3>8fOL!}{ATD1+us2iqJRlhme*kgj`V+C#)9 zOz(*_`rqn|7-n_mxxWF6WJbzhed;=V z#R$XSZp0243SHs6qIYSvd?2Dr9=DABMt>>E8Hg{vgh6j~3}<CyY@x`4k2# zFxTJ;aJJzW44Iy_F2Lkw!16ceed(C6KEl~Uws@$Srdjy%D)0TFXA&agtPW@UhXFpb zaZ@QPL1eXS2AcD}p{|I}9TAZV4_uo4jESEC<9!9KD7}GTtzk#rGZ6V4y_9poT0H+{ z|8~(zs$I^sU|b5ZYN+B{YPcdfkAzaYqEn4huV$x&Qen0f}>+|wu(2CX2moHspe|FA!4^1*clR9Z#r;e z27r)=U*rqKSkAn~@p+mJ(|RnZBd~0HGL6J6&ll=M@olw&KNIYWCmvBPgt6HggIu>} zv$2GZvY?PCrMSIZ$XaVSjNPMDOVe75D!Ri( zXyYhN8Q@aZ`>N0pQ5mT=Uj-JyCQ7$1J2B9sNlo`$?89=&uhb?8+pJf2gnQ@?zQKa~ zxPWj<)M|8?s_=e!X(`ZvOyGR8wvEogdi0E&1inH&Ja53}F?ICsd*uf7+SHxlT#XX*Xuq+q)-2XUuBG6r0;6In>R*;q z-$hZ#F}Vu*QjfiMZ~2%s%Yhe6P{~DTGGvjI+wxi|qmpD;>df^2hd5Emjr&;wae=P#`Q#iHyrU~TsHb1PHk8$4ukCs{0Z#6)UbWf zmlqIuULG0~?k;0)z3$zhAlcXliFewpNEFdtm)PW34W_zEXsQ9Jy!v~U(AM9 z+Ch-_1S7zFZqUmbrat7fJ*|0x*H6o4tUi@HQ~yA!U(@p}d&?jTcF`&c0Vp#X$^1~2 zvESbIppr)&IHTez*?n*Z279JS0;k1w-p+G*4U8^eWi2w`N5x8I5iO_aNgzzAP0F6a z4|8nL%E@y*5b0*-;yAQb`zxCp1oYre;~fk(}i zc5w)BYA9aL6(FDlFsz5)v(MoB$|@l<(%&uyWpMW6Bv7+ie&Eh*4bU&LibR=^h%AXv zXe-LuRX8QMD1AkMh+w*>>Ib2Z*GFPNEH7H&#%aNPNJrV!{xX+4?d`fbSFUQNv=ivM zvpl|Oi_%(QRj3xyE+dJ^ruV*2Cro^@_$6_}=#d10;1GAJ$I#fI z`SMfQDc-ITiR9BBBQJaJkvdyg zFU6UvYtG@+K1*z#E_g2j;Wip6fRj+IL)I|=6c`tn`+qCm3q^!k9D?eGW2*8s(DND7Btl1;1;3oGa z2vc9lDMH~Hc@l-U`@;`36}e=X0^5nQjc^p{EUW3GPaDKJWuLuF3~mbj0wu2<6H)=! zcWsTAiw_)5`b!y`K^SC$2i8toQmY3mhoG5D*rUs0XAQ4T(ZYtAU>e-#`}p`E4G5h* z!jH_uy;&K_4xL-L8Tw22k#nF_^At^bp#_}M8<&>+(-zL^EwYt`#Z~%Z1biRoHH@RAy zx>%TNxVqSYtp4QK94&(v6Jq$j{3p+Od{!I|zI?Wxa^SC9eL|y$a}L?YoJToON=ufm zh*@!?LR=M>8dd&E&2ET$3|sPp#FG8RJwiUKqTdDXPTsux(ChrIk4LH8aYGAk_7>>s zA;?pcmtbct{~20H5L$x}&&<=toM%yRsYrZnsyf|N8r$e*Cbf1WRTqeNs{^_9~7y2xX~mIXY{Gbk4DJ+%8Neagw=nh7t+iP8$F;NASa39 zk(5axHM3F~@KLl|kh5#1t2%UGe_SeJH1n-Nc<`y!hB%KsQxL9B-kIzE#ENI9=-tRl zMVhPbV^`f!KFT;)?fIHTi(f}YUzA-ZCl7(>>r40#G(e!>Y{X6^|hSuk2;TTsW^65|~e+=A$>)d3U{ z+v*_mWlB1PFT*D-I2+5aUTNFv+O@r?@YZt>AEtotg za7UlTR~%V%SB*=Dg`Pp%@K`nB@6ALxerUaFfrt&!v)e5gn5zQE&d znZE?%C%QlI7JmtnH zf%WM=zwd8z8DR%5#Wtv$NkBiP`ODq@Xzc#otNW|d`?F*BXP-AHx-Fue17G?I?uVqO zcidDkx}*j_b)D80oZ0Oh;(Suf75;;FC7DI*=BCr|Cf~<9kwQ{S%=au44Y0u*uL!Nn zkGV7$&Fx{ZDhNRDo(0;mWcW)1PEe+CwQ$j}aCQBIa@c=) z&k1e&f%fAD6*|Q5gJ0mXMxgolv30t2#j8xR*pbqW@tDT$3enNPE2xj*x2;9iU7Fs) zj|CrPFzWLq+MEdI>6g&@OU7ncN#UN?V!xLW zu%Odpw*4|U7ZtH%6UUm$v71hu=E}u96pO!=?h5C~*vOaj^!(NbeVkTjJoer?>y)xy zV|(7zTG11dHAej`mL>S8SJgcgd(l316ZE8*KR|QbUc{ga+aRz02aj8dPS%$8Viz-1 zwf~t)Yco3xQD?>vmU}Xq8e)B9&Vw8^6r?D(~sAGCF}l#L$MAp5xH|5&a<5 z4mXN*+24%s6!W0V!2uuK%tHqvdNiS1`4A(hz4F22-6|##9}Uja_W{v8-^VpY#hlOy z7)OT8NYw7v-Av*iDl>KHRp=HoPq?41tIP&qt!*zE2$Ia#yq(jd=-A4?b~`(sh$c~3 zJcxkn(`ds3`PI*D)Jn*+mU0^)bbpvl7d!V#8u#ncdIo8i36;4KpP^QxXz{l2mzdhUVDz zEJnHnYc>#XS2iwMJcadFeiHUDUBN&R)iZojxeWk!4YF=nN7V3hu!C^>vMsF`5xeyiA9-1`53Y|P^$tY3#r=6FLnLbk zyGT|Vow{o7l@%)qWb6dvtgX!qvIc3)f}qKE5-c7266STB_TsL4SitFXikxpEd7P!&B!VP7ulMa!cUFdv|^YvVAJPvs=$n}ymplts# z)K={yJY9EFzdOIG2bsZXTCbKvJ8B>M;F0vz*l>r()Oe-5Z|oV(J4*%MH--qaslYIkyKv(bn!JTekS4W5cTJ8VXEC7%?@&d}9M2N?YD;~)8`SLWAxj9d37TL)0 zQyY_b7GVb|VylEYk3b;}i=xdU?9bD$HutbZ{iSa+N(xQVagcLx+*QA;w$&~^DWycN z;Fo-^N5kwH3-ftzp7h6BX&mqwu^6>d!3w90ju}~}+Ah$XiI0i6blOKL`?-nmJB6O1 z2t7qSPPz^9NI|LM_l-`+-ONeX#xCV+3vBxy3`x6F_k_>=)ns`!k1LtyJ~PYtZ--Pf zGC%@no_dR!OCvj2H(Er0WJg|rBukihrK!B}@=@96h@2U)CAl19@Cx7DSdu*Fr9+U0 zq~}stM&W)t;a$HjlNda?0AKJ!weVNmg@I*-R#d-V-2ADkzh3=jjZ;