From dadca94b91c2f5669552fefed6556f4c3d599bb0 Mon Sep 17 00:00:00 2001 From: meld-cp <18450687+meld-cp@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:40:20 +1200 Subject: [PATCH] further performance improvements --- Source/Excel.Report.PDF/ExcelOverWriter.cs | 1027 +++++++++++------ .../ExcelPageLoopProcessor.cs | 153 +++ .../Excel.Report.PDF/IExcelSymbolConverter.cs | 11 + .../ObjectExcelSymbolConverter.cs | 110 +- Source/Test/Test/ExcelOverWriterTest.cs | 199 +++- Source/Test/Test/IOverWriteFunctionTest.cs | 29 + .../Test/ObjectExcelSymbolConverterTest.cs | 110 ++ 7 files changed, 1271 insertions(+), 368 deletions(-) create mode 100644 Source/Excel.Report.PDF/ExcelPageLoopProcessor.cs create mode 100644 Source/Test/Test/ObjectExcelSymbolConverterTest.cs diff --git a/Source/Excel.Report.PDF/ExcelOverWriter.cs b/Source/Excel.Report.PDF/ExcelOverWriter.cs index bcf3c9f..0c0454e 100644 --- a/Source/Excel.Report.PDF/ExcelOverWriter.cs +++ b/Source/Excel.Report.PDF/ExcelOverWriter.cs @@ -1,469 +1,802 @@ -using ClosedXML.Excel; using System.Collections; +using ClosedXML.Excel; namespace Excel.Report.PDF { + /// + /// Expands template loops and replaces symbols in Excel worksheets. + /// public static class ExcelOverWriter { - static List _overWriteFunctions = new() { new ImageOverWriteFunction(), new QRCodeOverWriteFunction() }; - public static void RegisterOverWriteFunction(IOverWriteFunction function) - => _overWriteFunctions.Add(function); - - class PageLoopRowsInfo - { - public List List { get; set; } = new(); - - public string FirstPageSheetName { get; set; } = string.Empty; - public int FirstPageBlockCount { get; set; } - public string SourceBodyPageSheetName { get; set; } = string.Empty; - public List BodyPageSheetNames { get; set; } = new(); - public int BodyPageBlockCount { get; set; } - public string LastPageSheetName { get; set; } = string.Empty; - public int LastPageBlockCount { get; set; } - - public List FirstPageList { get; set; } = new(); - public List> BodyPageLists { get; set; } = new(); - public List LastPageList { get; set; } = new(); - } + #region Public API + + static readonly List Functions = new() { new ImageOverWriteFunction(), new QRCodeOverWriteFunction() }; - enum PageType + /// + /// Adds a custom function that templates can use. + /// + public static void RegisterOverWriteFunction(IOverWriteFunction function) => RegisterFunction(function); + + /// + /// Expands every sheet in a workbook after preparing any paged sheets. + /// + public static async Task OverWrite(this XLWorkbook book, IExcelSymbolConverter converter) { - First, - Body, - Last + var pagePlans = await ExcelPageLoopProcessor.BuildPagePlansAsync(book, converter); + ExcelPageLoopProcessor.MaterializeBodyPageSheets(book, pagePlans); + + // Use a copy because writing a sheet creates a temporary snapshot sheet. + foreach (var sheet in book.Worksheets.ToList()) + await WriteWorksheetAsync(sheet, converter, pagePlans.Values.ToList()); } - public static async Task OverWrite(this XLWorkbook book, IExcelSymbolConverter converter) + /// + /// Expands one worksheet without preparing paged sheets. + /// + public static async Task OverWrite(this IXLWorksheet sheet, IExcelSymbolConverter converter) + => await WriteWorksheetAsync(sheet, converter, Array.Empty()); + + /// + /// Stores custom functions in one shared list so every worksheet uses the same functions. + /// + static void RegisterFunction(IOverWriteFunction function) => Functions.Add(function); + + #endregion + + #region Worksheet pipeline + + /// + /// Coordinates the snapshot, planning, and materialization phases for one worksheet. + /// Keeping these phases in order prevents row changes from invalidating source coordinates. + /// + static async Task WriteWorksheetAsync(IXLWorksheet sheet, IExcelSymbolConverter converter, IReadOnlyList pagePlans) { - var pagedLoopRowsInfos = await GetPagedLoopRowInfos(book, converter); + ExcelUtils.GetRowColCount(sheet, out var rowCount, out var colCount); + var template = SnapshotTemplate(sheet, rowCount); + var plan = await BuildExpansionPlanAsync(sheet, rowCount, converter, pagePlans, template); - AdjustBodySheets(book, pagedLoopRowsInfos); + await MaterializeWorksheetAsync(sheet, template, plan, rowCount, colCount); + } - foreach (var sheet in book.Worksheets) + /// + /// Writes the planned rows using batch writes or template range copies. + /// + static async Task MaterializeWorksheetAsync(IXLWorksheet sheet, TemplateSnapshot template, ExpansionPlan plan, int sourceRowCount, int colCount) + { + if (CanUsePlainValueBatch(sheet, template, plan)) { - await OverWrite(sheet, converter, pagedLoopRowsInfos.Values.ToList()); + ReserveWorksheetRows(sheet, plan.Rows.Count, sourceRowCount, colCount); + await WriteSymbolValuesAsBatchAsync(sheet, plan.Rows, colCount); + return; + } + + var workbook = sheet.Workbook; + var sourceName = $"__EOWT_{Guid.NewGuid():N}"[..15]; // Excel limits worksheet names to 31 characters. + var sourceSheet = sheet.CopyTo(sourceName); + try + { + ReserveWorksheetRows(sheet, plan.Rows.Count, sourceRowCount, colCount); + if (template.CanUseStyledValueBatch) + { + // Copy styles in grouped ranges and values in one matrix so formatting does not force one CopyTo per row. + ApplyStyleGroups(sheet, sourceSheet, plan.Rows, sourceRowCount, colCount); + await WriteSymbolValuesAsBatchAsync(sheet, plan.Rows, colCount); + RestoreMergedRanges(sheet, plan.Rows, plan.Merges); + } + else + { + // Copy template cells to preserve formulas, literal values, and function calls. + CopyTemplateRanges(sheet, sourceSheet, plan.Rows, sourceRowCount, colCount); + RestoreMergedRanges(sheet, plan.Rows, plan.Merges); + await ApplyCellOperationsAsync(sheet, plan.Rows); + } + } + finally + { + workbook.Worksheets.Delete(sourceName); } } - public static async Task OverWrite(this IXLWorksheet sheet, IExcelSymbolConverter converter) - => await OverWrite(sheet, converter, new()); + #endregion - static void AdjustBodySheets(XLWorkbook book, Dictionary pagedLoopRowsInfos) + #region Planning + + /// + /// Reads template operations once so generated rows can reuse them. + /// + static TemplateSnapshot SnapshotTemplate(IXLWorksheet sheet, int rowCount) { - foreach (var e in pagedLoopRowsInfos) + var rows = new IReadOnlyList[rowCount + 1]; + var leftText = new string[rowCount + 1]; + var canUsePlainValueBatch = true; + var canUseStyledValueBatch = true; + var defaultStyle = XLWorkbook.DefaultStyle; + + for (var rowNumber = 1; rowNumber <= rowCount; rowNumber++) { - if (string.IsNullOrEmpty(e.Value.SourceBodyPageSheetName)) continue; - var bodySheet = book.Worksheet(e.Value.SourceBodyPageSheetName); - for (int i = 0; i < e.Value.BodyPageLists.Count; i++) + leftText[rowNumber] = sheet.GetText(rowNumber, 1).Trim(); + var operations = new List(); + foreach (var cell in sheet.Row(rowNumber).CellsUsed(XLCellsUsedOptions.All)) { - bodySheet.CopyTo($"{e.Value.SourceBodyPageSheetName}_{i}", bodySheet.Position + i); + var text = cell.GetString().Trim(); + var symbol = text.StartsWith("$") ? text.Substring(1) : null; + var operationKind = GetOperationKind(text, symbol); + var isValueOperation = operationKind == TemplateCellKind.Symbol || operationKind == TemplateCellKind.Directive; + + // Inspect every used cell, including literals and styled blanks, before filtering operations. + if (cell.HasFormula || !cell.Style.Equals(defaultStyle) || !isValueOperation) + canUsePlainValueBatch = false; + if (cell.HasFormula || (text.Length > 0 && !isValueOperation)) + canUseStyledValueBatch = false; + + if (operationKind != TemplateCellKind.Literal) + operations.Add(new TemplateCellOperation(cell.Address.ColumnNumber, text, symbol, operationKind)); } - book.Worksheets.Delete(e.Value.SourceBodyPageSheetName); + rows[rowNumber] = operations.ToArray(); } + + return new TemplateSnapshot(rows, leftText, canUsePlainValueBatch, canUseStyledValueBatch); } - static async Task> GetPagedLoopRowInfos(XLWorkbook book, IExcelSymbolConverter converter) + /// + /// Resolves all loops before changing the worksheet. + /// This keeps source row numbers stable while the plan is built. + /// + static async Task BuildExpansionPlanAsync(IXLWorksheet sheet, int rowCount, IExcelSymbolConverter converter, IReadOnlyList pagePlans, TemplateSnapshot template) { - Dictionary pagedLoopRowsInfos = new(); + var rows = new List(rowCount); + var planner = new ExpansionPlanner(sheet.Name, pagePlans, template, rows); + await planner.PlanRangeAsync(1, rowCount, converter, new[] { converter }, false, RowFormattingMode.CopyTemplate); + + var merges = sheet.MergedRanges.Select(range => new MergeOperation( + range.RangeAddress.FirstAddress.RowNumber, + range.RangeAddress.LastAddress.RowNumber, + range.RangeAddress.FirstAddress.ColumnNumber, + range.RangeAddress.LastAddress.ColumnNumber)).ToArray(); + return new ExpansionPlan(rows, merges); + } - foreach (var sheet in book.Worksheets) + /// + /// Holds worksheet-wide planning state shared by every recursive range. + /// + sealed class ExpansionPlanner + { + readonly string sheetName; + readonly IReadOnlyList pagePlans; + readonly TemplateSnapshot template; + readonly List rows; + + public ExpansionPlanner(string sheetName, IReadOnlyList pagePlans, TemplateSnapshot template, List rows) { - ExcelUtils.GetRowColCount(sheet, out var rowCount, out var colCount); + this.sheetName = sheetName; + this.pagePlans = pagePlans; + this.template = template; + this.rows = rows; + } - // get left cells and check #PagedLoopRows - List leftCells = new(); - int pagedLoopCount = 0; - for (int i = 0; i <= rowCount; i++) - { - var rowIndex = i + 1; - var text = sheet.GetText(rowIndex, 1).Trim(); - if (text.StartsWith("#PagedLoopRows")) pagedLoopCount++; - if (1 < pagedLoopCount) throw new Exception($"One sheet can have only one #PagedLoopRows. SheetName:{sheet.Name}"); - leftCells.Add(text); - } - - foreach(var leftCell in leftCells) + /// + /// Adds output rows for nested loops. + /// Data-only loops also scan their preallocated rows to preserve their existing behavior. + /// + public async Task PlanRangeAsync(int startRow, int endRow, IExcelSymbolConverter converter, IReadOnlyList converterScopes, bool clearFirstDirective, RowFormattingMode formattingMode) + { + var sourceRow = startRow; + var effectiveEndRow = endRow; + while (sourceRow <= effectiveEndRow) { - //#PagedLoopRows(pageType, rowsPerPage, $items, items, blockRowCount) - if (leftCell.StartsWith("#PagedLoopRows")) + var leftText = template.GetLeftText(sourceRow); + if (clearFirstDirective && sourceRow == startRow) { - var args = leftCell.Replace("#PagedLoopRows", "").Replace("(", "").Replace(")", "").Split(',').Select(e => e.Trim()).ToArray(); - if (args.Length != 5) break; - var items = args[2]; - if (!items.StartsWith("$")) break; - items = items.Substring(1); - if (!pagedLoopRowsInfos.TryGetValue(items, out var info)) - { - info = new PageLoopRowsInfo(); - var enumerable = (await converter.GetData(items))?.Value as IEnumerable; - if (enumerable == null) break; - foreach (var e in enumerable) - { - info.List.Add(e); - } - pagedLoopRowsInfos[items] = info; - } - if (!Enum.TryParse(args[0], out var pageType)) break; - if (!int.TryParse(args[1], out var rowsPerPage)) break; - if (!int.TryParse(args[4], out var blockRowCount)) break; - switch (pageType) - { - case PageType.First: - info.FirstPageSheetName = sheet.Name; - info.FirstPageBlockCount = rowsPerPage; - break; - case PageType.Body: - info.SourceBodyPageSheetName = sheet.Name; - info.BodyPageBlockCount = rowsPerPage; - break; - case PageType.Last: - info.LastPageSheetName = sheet.Name; - info.LastPageBlockCount = rowsPerPage; - break; - } - break; + // The parent owns the marker, but the other cells still run for this item. + AddOutputRow(sourceRow, converterScopes, RowCleanup.Directive, formattingMode); + sourceRow++; + continue; } - } - } - //Distributing Lists per page - foreach (var e in pagedLoopRowsInfos) - { - if (!e.Value.List.Any()) - { - continue; - } - int bodyCount = e.Value.List.Count - e.Value.FirstPageBlockCount - e.Value.LastPageBlockCount; - if (bodyCount == 0) - { - var firstPageCount = e.Value.FirstPageBlockCount; - var lastPageCount = e.Value.List.Count - e.Value.FirstPageBlockCount; - if (lastPageCount <= 0) + if (!leftText.StartsWith("#LoopRow") && !leftText.StartsWith("#PagedLoopRows")) { - lastPageCount = 1; - firstPageCount = e.Value.List.Count - 1; + // Only valid loop directives change the row layout; other text is copied normally. + AddOutputRow(sourceRow, converterScopes, RowCleanup.None, formattingMode); + sourceRow++; + continue; } - e.Value.FirstPageList = e.Value.List.Take(firstPageCount).ToList(); - e.Value.LastPageList = e.Value.List.Skip(firstPageCount).Take(lastPageCount).ToList(); - } - else - { - var first = e.Value.List.Take(e.Value.FirstPageBlockCount).ToList(); - var body = new List>(); - var rest = e.Value.List.Skip(e.Value.FirstPageBlockCount).ToList(); - while (e.Value.LastPageBlockCount < rest.Count) + + var loop = new LoopPlan(); + if (!await ParseLoopAsync(leftText, converter, loop, sheetName, pagePlans)) { - body.Add(rest.Take(e.Value.BodyPageBlockCount).ToList()); - rest = rest.Skip(e.Value.BodyPageBlockCount).ToList(); + // Invalid directives stay as text for compatibility. + AddOutputRow(sourceRow, converterScopes, RowCleanup.None, formattingMode); + sourceRow++; + continue; + } + + var blockEnd = sourceRow + loop.RowCopyCount - 1; + if (loop.Items.Count == 0) + { + if (loop.Mode == LoopMode.InsertRows) + { + // Omit the whole block without deleting rows one at a time. + sourceRow = blockEnd + 1; + continue; + } + + // Data-only loops keep their row, but clear its old symbols and marker. + AddOutputRow(sourceRow, converterScopes, RowCleanup.Directive | RowCleanup.Symbols, GetChildFormattingMode(loop.Mode, formattingMode)); + sourceRow++; + continue; } - e.Value.FirstPageList = first; - e.Value.BodyPageLists = body; - e.Value.LastPageList = rest; + var emittedStart = rows.Count; + foreach (var item in loop.Items) + { + var child = converter.CreateChildExcelSymbolConverter(item, loop.Name); + var childScopes = converterScopes.Concat(new[] { child }).ToArray(); + await PlanRangeAsync(sourceRow, blockEnd, child, childScopes, true, GetChildFormattingMode(loop.Mode, formattingMode)); + } - for(int i = 0; i < body.Count; i++) + if (loop.Mode == LoopMode.InsertRows) { - e.Value.BodyPageSheetNames.Add($"{e.Value.SourceBodyPageSheetName}_{i}"); + // Only the original block is consumed; generated copies exist in the plan. + sourceRow = blockEnd + 1; + } + else + { + var emittedRows = rows.Count - emittedStart; + // Data-only loops consume the rows they fill, including new rows beyond the template. + sourceRow += emittedRows; + effectiveEndRow += emittedRows - loop.RowCopyCount; } } } - return pagedLoopRowsInfos; + + /// + /// Adds a planned row with its source operations and converter scopes. + /// The output row number is assigned later during materialization. + /// + void AddOutputRow(int sourceRow, IReadOnlyList converterScopes, RowCleanup cleanup, RowFormattingMode formattingMode) + => rows.Add(new OutputRowPlan(sourceRow, converterScopes, cleanup, formattingMode, template.GetRow(sourceRow))); } - static async Task OverWrite(IXLWorksheet sheet, IExcelSymbolConverter converter, List pageLoopRowsInfoList) + /// + /// Parses a loop and resolves its collection so the planner knows how many rows to create. + /// + static async Task ParseLoopAsync(string text, IExcelSymbolConverter converter, LoopPlan loop, string sheetName, IReadOnlyList pagePlans) { - // Get all rows and columns of the sheet - ExcelUtils.GetRowColCount(sheet, out var rowCount, out var colCount); - await OverWrite(sheet, 1, rowCount, colCount, converter, pageLoopRowsInfoList); + if (text.StartsWith("#LoopRow")) + return await ParseOrdinaryLoopAsync(text, converter, loop); + + return ParsePagedLoop(text, loop, sheetName, pagePlans); } - static async Task OverWrite(IXLWorksheet sheet, int startRow, int endRow, int colCount, IExcelSymbolConverter converter, List pageLoopRowsInfoList) + static async Task ParseOrdinaryLoopAsync(string text, IExcelSymbolConverter converter, LoopPlan loop) { - for (int i = startRow; i <= endRow;) + var dataOnly = text.StartsWith("#LoopRowData"); + var prefix = dataOnly ? "#LoopRowData" : "#LoopRow"; + var args = text.Replace(prefix, "").Replace("(", "").Replace(")", "").Split(',').Select(value => value.Trim()).ToArray(); + var rowCopyCount = 1; + if (args.Length == 3 && !int.TryParse(args[2], out rowCopyCount)) return false; + if (args.Length < 2 || !args[0].StartsWith("$")) return false; + + loop.Mode = dataOnly ? LoopMode.DataOnly : LoopMode.InsertRows; + loop.RowCopyCount = rowCopyCount; + loop.Name = args[1]; + var enumerable = (await converter.GetData(args[0].Substring(1)))?.Value as IEnumerable; + if (enumerable == null) return false; + loop.Items = enumerable.OfType().ToList(); + return true; + } + + static bool ParsePagedLoop(string text, LoopPlan loop, string sheetName, IReadOnlyList pagePlans) + { + if (!text.StartsWith("#PagedLoopRows")) return false; + var pageArgs = text.Replace("#PagedLoopRows", "").Replace("(", "").Replace(")", "").Split(',').Select(value => value.Trim()).ToArray(); + if (pageArgs.Length < 5 || !int.TryParse(pageArgs[4], out var blockRowCount)) return false; + if (!Enum.TryParse(pageArgs[0], out var pageType)) return false; + + var pagePlan = pagePlans.FirstOrDefault(item => + pageType == ExcelPageLoopProcessor.PageType.First && item.FirstPageSheetName == sheetName || + pageType == ExcelPageLoopProcessor.PageType.Body && item.BodyPageSheetNames.Contains(sheetName) || + pageType == ExcelPageLoopProcessor.PageType.Last && item.LastPageSheetName == sheetName); + if (pagePlan == null) return false; + + loop.Mode = LoopMode.Paged; + loop.Name = pageArgs[3]; + loop.RowCopyCount = blockRowCount; + loop.Items = pageType switch { - var leftText = sheet.GetText(i, 1).Trim(); - - // On a loop directive row, suppress custom function invocations in this pre-pass: - // $item.* references resolve to null with the outer converter, so a naive function - // would write garbage into the template cell and CopyRows would propagate it. - // The recursive pass runs OverWriteCell again with the per-element converter and - // invokes functions there with resolved args. - var isLoopRow = leftText.StartsWith("#LoopRow") || leftText.StartsWith("#PagedLoopRows"); - await OverWriteCell(sheet, i, colCount, async t => await converter.GetData(t), isLoopRow); - - // Only directive rows can contain loops. Avoid parsing every - // ordinary output row as a possible loop. - if (!isLoopRow) - { - i++; - continue; - } + ExcelPageLoopProcessor.PageType.First => pagePlan.FirstPageItems, + ExcelPageLoopProcessor.PageType.Body => pagePlan.BodyPageItems[pagePlan.BodyPageSheetNames.IndexOf(sheetName)], + _ => pagePlan.LastPageItems + }; + return true; + } - LoopInfo loopInfo = new(); - if (!await TryParseLoop(leftText, converter, loopInfo, sheet.Name, pageLoopRowsInfoList)) - { - i++; - continue; - } + /// + /// Classifies a used cell once so later phases can dispatch by meaning instead of reparsing its text. + /// + static TemplateCellKind GetOperationKind(string text, string? symbol) + { + if (symbol != null) return TemplateCellKind.Symbol; + if (!text.StartsWith("#")) return TemplateCellKind.Literal; + return IsLoopDirective(text) ? TemplateCellKind.Directive : TemplateCellKind.Function; + } - // delete #LoopRow - var cell = sheet.Cell(i, 1); - cell.SetValue(XLCellValue.FromObject(null)); + /// + /// Selects the formatting inherited by a nested loop. + /// Data-only loops keep destination formatting, while insert loops copy the template. + /// + static RowFormattingMode GetChildFormattingMode(LoopMode loopMode, RowFormattingMode parentMode) + => loopMode switch + { + LoopMode.DataOnly => RowFormattingMode.PreserveDestination, + LoopMode.InsertRows => RowFormattingMode.CopyTemplate, + _ => parentMode + }; + + #endregion + + #region Materialization + + /// + /// Selects the fast matrix strategy only when all cells use the default style. + /// InsertData cannot preserve arbitrary styles or cell types. + /// + static bool CanUsePlainValueBatch(IXLWorksheet sheet, TemplateSnapshot template, ExpansionPlan plan) + => template.CanUsePlainValueBatch && plan.Merges.Count == 0 && sheet.RowHeight == XLWorkbook.DefaultRowHeight && sheet.Rows(1, template.RowCount).All(row => row.Height == sheet.RowHeight); + + /// + /// Clears old content and reserves the final row count in one operation. + /// Repeated insertions make ClosedXML repeatedly update the rows below them. + /// + static void ReserveWorksheetRows(IXLWorksheet sheet, int outputRowCount, int sourceRowCount, int colCount) + { + foreach (var merge in sheet.MergedRanges.ToList()) merge.Unmerge(); + if (outputRowCount > sourceRowCount) sheet.Row(1).InsertRowsAbove(outputRowCount - sourceRowCount); + + var rowsToClear = Math.Max(sourceRowCount, outputRowCount); + if (rowsToClear > 0 && colCount > 0) sheet.Range(1, 1, rowsToClear, colCount).Clear(XLClearOptions.Contents); + if (outputRowCount < sourceRowCount && colCount > 0) sheet.Range(outputRowCount + 1, 1, sourceRowCount, colCount).Clear(XLClearOptions.All); + } - if (!loopInfo.LoopList.Any()) + /// + /// Resolves symbols into one rectangular matrix because a single InsertData call is much cheaper for large reports. + /// + static async Task WriteSymbolValuesAsBatchAsync(IXLWorksheet sheet, IReadOnlyList rows, int colCount) + { + var values = new object?[rows.Count][]; + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + var rowValues = new object?[colCount]; + foreach (var operation in row.Operations) { - if (loopInfo.IsInsertMode) + if (operation.Kind != TemplateCellKind.Symbol) continue; + if (row.ShouldClearSymbols) { - for (int j = 0; j < loopInfo.RowCopyCount; j++) - { - sheet.Row(i).Delete(); - } + // Empty data-only rows must not retain the original symbol text. + rowValues[operation.ColumnNumber - 1] = null; + continue; } - else + var symbol = operation.Symbol!; + if (row.ScopesAreSynchronous) { - //$Empty cells of strings beginning with $ - for (int j = 1; j <= colCount; j++) - { - var x = sheet.Cell(i, j); - if (x.GetString().Trim().StartsWith("$")) - { - x.SetValue(XLCellValue.FromObject(null)); - } - } - i++; + // Keep the built-in converter synchronous; this loop can process hundreds of thousands of cells. + rowValues[operation.ColumnNumber - 1] = TryResolveSymbolSynchronously(row, symbol, out var value) + ? value + : operation.TemplateText; + } + else + { + var result = await ResolveSymbolAsync(row.ConverterScopes, symbol); + rowValues[operation.ColumnNumber - 1] = result.Found ? result.Value : operation.TemplateText; } - continue; } + values[rowIndex] = rowValues; + } - // copy rows - CopyRows(sheet, i, loopInfo.RowCopyCount, loopInfo.LoopList.Count, loopInfo.IsInsertMode, loopInfo.IsFormatCopy, colCount); + // InsertData writes the ordinary cells in one ClosedXML operation. + if (rows.Count > 0) sheet.Cell(1, 1).InsertData(values); + } - // over write - bool isFirstLoop = true; - foreach (var e in loopInfo.LoopList) + /// + /// Copies formatted rows in groups and data-only rows as content. + /// Data-only loops must preserve the styles already assigned to their destination rows. + /// + static void CopyTemplateRanges(IXLWorksheet sheet, IXLWorksheet source, IReadOnlyList rows, int sourceRowCount, int colCount) + { + var startIndex = 0; + while (startIndex < rows.Count) + { + if (rows[startIndex].FormattingMode != RowFormattingMode.CopyTemplate) { - var elementConverter = converter.CreateChildExcelSymbolConverter(e, loopInfo.LoopName); - - // Recursive Processing - var processedRows = await OverWrite(sheet, i, i + loopInfo.RowCopyCount - 1, colCount, elementConverter, pageLoopRowsInfoList); - i += processedRows; + CopyValuesOnly(sheet, source, startIndex + 1, rows[startIndex].TemplateRow, sourceRowCount, colCount); + startIndex++; + continue; + } - // Increment endRow - endRow = IncrementEndRow(ref isFirstLoop, endRow, processedRows, loopInfo.RowCopyCount); + var endExclusive = startIndex + 1; + while (endExclusive < rows.Count + && rows[endExclusive].FormattingMode == RowFormattingMode.CopyTemplate + && rows[endExclusive].TemplateRow == rows[endExclusive - 1].TemplateRow + 1 + && rows[endExclusive].TemplateRow <= sourceRowCount) + endExclusive++; + var sourceStart = rows[startIndex].TemplateRow; + var sourceEnd = rows[endExclusive - 1].TemplateRow; + if (sourceStart > 0 && sourceEnd <= sourceRowCount) + { + var destinationStart = startIndex + 1; + source.Range(sourceStart, 1, sourceEnd, colCount).CopyTo(sheet.Range(destinationStart, 1, destinationStart + sourceEnd - sourceStart, colCount)); + for (var offset = 0; offset <= sourceEnd - sourceStart; offset++) CopyRowMetadata(sheet.Row(destinationStart + offset), source.Row(sourceStart + offset)); } + startIndex = endExclusive; } - // Processed Rows - return endRow - startRow + 1; } - class LoopInfo + /// + /// Applies cached source styles to grouped destination ranges because style-only templates do not need full cell copies. + /// + static void ApplyStyleGroups(IXLWorksheet sheet, IXLWorksheet source, IReadOnlyList rows, int sourceRowCount, int colCount) { - internal int RowCopyCount { get; set; } - internal List LoopList { get; set; } = new(); - internal string LoopName { get; set; } = string.Empty; - internal bool IsInsertMode { get; set; } - internal bool IsFormatCopy { get; set; } = true; - } + var styleRunsBySourceRow = new Dictionary>(); + var startIndex = 0; + while (startIndex < rows.Count) + { + if (rows[startIndex].FormattingMode != RowFormattingMode.CopyTemplate) + { + startIndex++; + continue; + } - static async Task TryParseLoop(string leftCell, IExcelSymbolConverter converter, LoopInfo loopInfo, string sheetName, List pageLoopRowsInfoList) - { - if (await TryParseLoopNormal(leftCell, converter, loopInfo)) return true; - return TryParsePageLoop(leftCell, converter, loopInfo, sheetName, pageLoopRowsInfoList); - } + var endExclusive = startIndex + 1; + while (endExclusive < rows.Count + && rows[endExclusive].FormattingMode == RowFormattingMode.CopyTemplate + && rows[endExclusive].TemplateRow == rows[endExclusive - 1].TemplateRow) + endExclusive++; + var sourceRow = rows[startIndex].TemplateRow; + if (sourceRow > 0 && sourceRow <= sourceRowCount) + { + if (!styleRunsBySourceRow.TryGetValue(sourceRow, out var styleRuns)) + { + styleRuns = GetStyleRuns(source, sourceRow, colCount); + styleRunsBySourceRow[sourceRow] = styleRuns; + } - static async Task TryParseLoopNormal(string leftCell, IExcelSymbolConverter converter, LoopInfo loopInfo) - { - if (!leftCell.StartsWith("#LoopRow")) return false; - var isLoopRowData = leftCell.StartsWith("#LoopRowData"); + foreach (var styleRun in styleRuns) + sheet.Range(startIndex + 1, styleRun.FirstColumn, endExclusive, styleRun.LastColumn).Style = styleRun.Style; - // #LoopRow($list, i, rowCopyCount) - var args = isLoopRowData - ? leftCell.Replace("#LoopRowData", "").Replace("(", "").Replace(")", "").Split(',').Select(e => e.Trim()).ToArray() - : leftCell.Replace("#LoopRow", "").Replace("(", "").Replace(")", "").Split(',').Select(e => e.Trim()).ToArray(); + var sourceMetadata = source.Row(sourceRow); + if (sourceMetadata.Height != XLWorkbook.DefaultRowHeight || sourceMetadata.IsHidden || sourceMetadata.OutlineLevel != 0) + { + for (var destinationRow = startIndex + 1; destinationRow <= endExclusive; destinationRow++) + CopyRowMetadata(sheet.Row(destinationRow), sourceMetadata); + } + } - // rowCopyCount is optional - var rowCopyCount = 1; - if (args.Length == 3) - { - if (!int.TryParse(args[2], out rowCopyCount)) return false; + startIndex = endExclusive; } - loopInfo.IsInsertMode = !isLoopRowData; - loopInfo.IsFormatCopy = !isLoopRowData; - loopInfo.RowCopyCount = rowCopyCount; - - // #list and i(enumerable name) are must - if (args.Length < 2) return false; - - if (!args[0].StartsWith("$")) return false; - var enumerableName = args[0].Substring(1); - loopInfo.LoopName = args[1]; + } - var enumerable = (await converter.GetData(enumerableName))?.Value as IEnumerable; - if (enumerable == null) return false; + /// + /// Groups adjacent columns with the same style so they can be formatted together. + /// + static IReadOnlyList GetStyleRuns(IXLWorksheet source, int sourceRow, int colCount) + { + var segments = new List(); + if (colCount == 0) return segments; - loopInfo.LoopList = enumerable.OfType().ToList(); + var firstColumn = 1; + var currentStyle = source.Cell(sourceRow, firstColumn).Style; + for (var column = 2; column <= colCount; column++) + { + var style = source.Cell(sourceRow, column).Style; + if (style.Equals(currentStyle)) continue; + segments.Add(new StyleRun(firstColumn, column - 1, currentStyle)); + firstColumn = column; + currentStyle = style; + } - return true; + segments.Add(new StyleRun(firstColumn, colCount, currentStyle)); + return segments; } - static bool TryParsePageLoop(string leftCell, IExcelSymbolConverter converter, LoopInfo loopInfo, string sheetName, List pageLoopRowsInfoList) + /// + /// Copies values and formulas without styles for data-only rows. + /// Their preallocated destination cells own the styles. + /// + static void CopyValuesOnly(IXLWorksheet destination, IXLWorksheet source, int destinationRow, int sourceRow, int sourceRowCount, int colCount) { - if (!leftCell.StartsWith("#PagedLoopRows")) return false; - var args = leftCell.Replace("#PagedLoopRows", "").Replace("(", "").Replace(")", "").Split(',').Select(e => e.Trim()).ToArray(); - if (!int.TryParse(args[4], out var blockRowCount)) return false; - loopInfo.IsInsertMode = false; - loopInfo.IsFormatCopy = true; - - var first = pageLoopRowsInfoList.FirstOrDefault(e => e.FirstPageSheetName == sheetName); - if (first != null) - { - loopInfo.LoopList = first.FirstPageList; - loopInfo.LoopName = args[3]; - loopInfo.RowCopyCount = blockRowCount; - return true; - } - var body = pageLoopRowsInfoList.FirstOrDefault(e => e.BodyPageSheetNames.Contains(sheetName)); - if (body != null) + if (sourceRow <= 0 || sourceRow > sourceRowCount) return; + for (var column = 1; column <= colCount; column++) { - var index = body.BodyPageSheetNames.IndexOf(sheetName); - loopInfo.LoopList = body.BodyPageLists[index]; - loopInfo.LoopName = args[3]; - loopInfo.RowCopyCount = blockRowCount; - return true; + var sourceCell = source.Cell(sourceRow, column); + var destinationCell = destination.Cell(destinationRow, column); + if (sourceCell.HasFormula) destinationCell.FormulaA1 = sourceCell.FormulaA1; + else destinationCell.Value = sourceCell.Value; } - var last = pageLoopRowsInfoList.FirstOrDefault(e => e.LastPageSheetName == sheetName); - if (last != null) + } + + /// + /// Copies row height, visibility, and outline level because range copying does not reliably copy them. + /// + static void CopyRowMetadata(IXLRow destination, IXLRow source) + { + destination.Height = source.Height; + if (source.IsHidden) destination.Hide(); else destination.Unhide(); + destination.OutlineLevel = source.OutlineLevel; + } + + /// + /// Recreates each planned merge because row expansion does not translate merge addresses. + /// + static void RestoreMergedRanges(IXLWorksheet sheet, IReadOnlyList rows, IReadOnlyList merges) + { + var restored = new HashSet(); + foreach (var merge in merges) { - loopInfo.LoopList = last.LastPageList; - loopInfo.LoopName = args[3]; - loopInfo.RowCopyCount = blockRowCount; - return true; + var length = merge.LastRow - merge.FirstRow; + for (var start = 0; start + length < rows.Count; start++) + { + if (Enumerable.Range(0, length + 1).Any(offset => rows[start + offset].TemplateRow != merge.FirstRow + offset)) continue; + var destination = sheet.Range(start + 1, merge.FirstColumn, start + length + 1, merge.LastColumn); + if (restored.Add(destination.RangeAddress.ToString()!)) destination.Merge(); + } } - return false; } - static async Task OverWriteCell(IXLWorksheet sheet, int rowIndex, int colCount, Func> converter, bool skipFunctions = false) + #endregion + + #region Cell operations + + /// + /// Resolves symbols and runs functions after layout is final. + /// Custom functions need the final row coordinates. + /// + static async Task ApplyCellOperationsAsync(IXLWorksheet sheet, IReadOnlyList rows) { - for (var i = 0; i < colCount; i++) + for (var index = 0; index < rows.Count; index++) { - var cellIndex = i + 1; - var text = sheet.GetText(rowIndex, cellIndex).Trim(); - - if (text.StartsWith("#")) + var row = rows[index]; + foreach (var operation in row.Operations) { - if (skipFunctions) continue; - foreach(var function in _overWriteFunctions) + if (row.ShouldClearSymbols && operation.Kind == TemplateCellKind.Symbol) + SetValue(sheet, index + 1, operation.ColumnNumber, null); + + if (row.ShouldClearDirective && operation.Kind == TemplateCellKind.Directive && operation.ColumnNumber == 1) { - if (text.StartsWith($"#{function.Name}(")) + // Range copying also copies the marker, so clear it explicitly. + SetValue(sheet, index + 1, operation.ColumnNumber, null); + continue; + } + + switch (operation.Kind) + { + case TemplateCellKind.Function: { - var argsText = text.Replace($"#{function.Name}", "").Replace("(", "").Replace(")", ""); - var args = new List(); - foreach (var e in argsText.Split(',').Select(e => e.Trim())) - { - if (e.StartsWith("$")) - { - var x = await converter(e.Substring(1)); - args.Add(x?.Value); - } - else - { - args.Add(e); - } + foreach (var function in Functions) + { + if (!operation.TemplateText.StartsWith($"#{function.Name}(", StringComparison.Ordinal)) continue; + var argsText = operation.TemplateText.Replace($"#{function.Name}", "").Replace("(", "").Replace(")", ""); + var args = new List(); + foreach (var argument in argsText.Split(',').Select(value => value.Trim())) + args.Add(argument.StartsWith("$") ? (await ResolveSymbolAsync(row.ConverterScopes, argument.Substring(1))).Value : argument); + await function.InvokeAsync(sheet, index + 1, operation.ColumnNumber, args.ToArray()); + break; } - - await function.InvokeAsync(sheet, rowIndex, cellIndex, args.ToArray()); + break; + } + case TemplateCellKind.Symbol: + { + var result = await ResolveSymbolAsync(row.ConverterScopes, operation.Symbol!); + if (result.Found) SetValue(sheet, index + 1, operation.ColumnNumber, result.Value); break; } } } - else if (text.StartsWith("$")) - { - var x = await converter(text.Substring(1)); - SetCellData(sheet, rowIndex, cellIndex, x); - } } } - static void SetCellData(IXLWorksheet sheet, int rowIndex, int cellIndex, ExcelOverWriteCell? cellData) - { - if (cellData == null) return; - var cell = sheet.Cell(rowIndex, cellIndex); - cell.SetValue(XLCellValue.FromObject(cellData.Value)); - } - - static void CopyRows(IXLWorksheet sheet, int rowIndex, int rowCopyCount, int loopCount, bool isInsertMode, bool formatCopy, int colCount) + /// + /// Searches inner scopes first, then outer scopes. + /// This lets nested values override root values while keeping outer references available. + /// + static async Task<(bool Found, object? Value)> ResolveSymbolAsync(IReadOnlyList converterScopes, string symbol) { - var rangeToCopy = sheet.Range(rowIndex, 1, rowIndex + rowCopyCount - 1, colCount); - - double[] srcHeights = new double[0]; - if (formatCopy) + for (var index = converterScopes.Count - 1; index >= 0; index--) { - srcHeights = new double[rowCopyCount]; - for (int i = 0; i < rowCopyCount; i++) + if (converterScopes[index] is ISynchronousExcelSymbolConverter synchronous) + { + // Avoid creating a task for synchronous lookups. + if (synchronous.TryGetData(symbol, out var value)) return (true, value); + } + else { - srcHeights[i] = sheet.Row(rowIndex + i).Height; + var value = await converterScopes[index].GetData(symbol); + if (value != null) return (true, value.Value); } } - int srcFirstRow = rowIndex; - (int RowOffset, int ColumnNumber, XLCellValue Value)[] srcCellsCache = null!; - if (!formatCopy) + return (false, null); + } + + /// + /// Resolves synchronous scopes without tasks to keep the hot path fast. + /// + static bool TryResolveSymbolSynchronously(OutputRowPlan row, string symbol, out object? value) + { + if (row.InnermostSynchronousConverter != null && row.InnermostSynchronousConverter.TryGetData(symbol, out value)) return true; + for (var index = row.ConverterScopes.Count - 2; index >= 0; index--) { - srcCellsCache = rangeToCopy - .CellsUsed() - .Select(c => - ( - RowOffset: c.Address.RowNumber - srcFirstRow, - ColumnNumber: c.Address.ColumnNumber, - Value: c.Value - )) - .ToArray(); + if (((ISynchronousExcelSymbolConverter)row.ConverterScopes[index]).TryGetData(symbol, out value)) return true; } - if (isInsertMode && loopCount > 1) + value = null; + return false; + } + + /// + /// Writes a CLR value through ClosedXML; symbols may produce strings, numbers, dates, or null. + /// + static void SetValue(IXLWorksheet sheet, int row, int column, object? value) + => sheet.Cell(row, column).SetValue(XLCellValue.FromObject(value)); + + /// + /// Checks whether the text starts with a supported loop directive. + /// + static bool IsLoopDirective(string text) + => text.StartsWith("#LoopRow(") || text.StartsWith("#LoopRowData(") || text.StartsWith("#PagedLoopRows("); + + #endregion + + #region Plan models + + /// + /// Controls whether a planned row removes the directive or its unresolved symbols. + /// A flag keeps the two independent cleanup actions explicit without adding per-row objects. + /// + [Flags] + enum RowCleanup + { + None = 0, + Directive = 1, + Symbols = 2 + } + + /// + /// Describes whether a row receives template formatting or keeps its destination formatting. + /// This is the key difference between normal loops and data-only loops. + /// + enum RowFormattingMode + { + CopyTemplate, + PreserveDestination + } + + /// + /// Identifies the structural behavior of a loop directive. + /// + enum LoopMode + { + InsertRows, + DataOnly, + Paged + } + + /// + /// Identifies the small set of cell operations the writer must revisit after copying the layout. + /// Literal and formula cells stay in the copied template and need no per-cell operation here. + /// + enum TemplateCellKind + { + Literal, + Symbol, + Directive, + Function + } + + /// + /// Stores source operations and row markers so they remain available after the worksheet changes. + /// + sealed class TemplateSnapshot + { + readonly IReadOnlyList[] _rows; + readonly string[] _leftText; + + public TemplateSnapshot(IReadOnlyList[] rows, string[] leftText, bool canUsePlainValueBatch, bool canUseStyledValueBatch) { - sheet.Row(rowIndex + rowCopyCount).InsertRowsAbove(rowCopyCount * (loopCount - 1)); + _rows = rows; + _leftText = leftText; + CanUsePlainValueBatch = canUsePlainValueBatch; + CanUseStyledValueBatch = canUseStyledValueBatch; } - for (int i = 1; i < loopCount; i++) - { - var insertRowIndex = rowIndex + rowCopyCount * i; - var insertRow = sheet.Row(insertRowIndex); + public bool CanUsePlainValueBatch { get; } + public bool CanUseStyledValueBatch { get; } + public int RowCount => _rows.Length - 1; + public IReadOnlyList GetRow(int row) => row > 0 && row < _rows.Length ? _rows[row] : Array.Empty(); + public string GetLeftText(int row) => row > 0 && row < _leftText.Length ? _leftText[row] : string.Empty; + } - if (formatCopy) - { - rangeToCopy.CopyTo(insertRow); - for (int j = 0; j < rowCopyCount; j++) - { - sheet.Row(insertRowIndex + j).Height = srcHeights[j]; - } - } - else - { - foreach (var src in srcCellsCache) - { - var destRow = insertRowIndex + src.RowOffset; - var destCell = sheet.Cell(destRow, src.ColumnNumber); - destCell.Value = src.Value; - } - } + /// + /// Stores one parsed source cell so it does not need to be inspected for every generated row. + /// + sealed class TemplateCellOperation + { + public TemplateCellOperation(int columnNumber, string templateText, string? symbol, TemplateCellKind kind) + { + ColumnNumber = columnNumber; + TemplateText = templateText; + Symbol = symbol; + Kind = kind; } + + public int ColumnNumber { get; } + public string TemplateText { get; } + public string? Symbol { get; } + public TemplateCellKind Kind { get; } } - static int IncrementEndRow(ref bool isFirstLoop, int endRow, int processedRows, int rowCopyCount) + /// + /// Stores one output row, its converter scopes, and the cleanup rules inherited from its loop. + /// + sealed class OutputRowPlan { - if (isFirstLoop) + public OutputRowPlan(int templateRow, IReadOnlyList converterScopes, RowCleanup cleanup, RowFormattingMode formattingMode, IReadOnlyList operations) { - isFirstLoop = false; - - // Subtract duplicate rows from the processed rows - endRow += (processedRows - rowCopyCount); + TemplateRow = templateRow; + ConverterScopes = converterScopes; + ScopesAreSynchronous = converterScopes.All(scope => scope is ISynchronousExcelSymbolConverter); + InnermostSynchronousConverter = converterScopes[^1] as ISynchronousExcelSymbolConverter; + Cleanup = cleanup; + FormattingMode = formattingMode; + Operations = operations; } - else + + public int TemplateRow { get; } + public IReadOnlyList ConverterScopes { get; } + public bool ScopesAreSynchronous { get; } + public ISynchronousExcelSymbolConverter? InnermostSynchronousConverter { get; } + public RowCleanup Cleanup { get; } + public RowFormattingMode FormattingMode { get; } + public IReadOnlyList Operations { get; } + public bool ShouldClearDirective => (Cleanup & RowCleanup.Directive) != 0; + public bool ShouldClearSymbols => (Cleanup & RowCleanup.Symbols) != 0; + } + + /// + /// Stores one source merge so each repeated occurrence can get translated coordinates. + /// + readonly record struct MergeOperation(int FirstRow, int LastRow, int FirstColumn, int LastColumn); + + /// + /// Describes a contiguous style run because repeated destination rows can receive it as one range. + /// + readonly record struct StyleRun(int FirstColumn, int LastColumn, IXLStyle Style); + + /// + /// Stores the final row layout so materialization does not rediscover loop structure. + /// + sealed class ExpansionPlan + { + public ExpansionPlan(IReadOnlyList rows, IReadOnlyList merges) { - endRow += processedRows; + Rows = rows; + Merges = merges; } - return endRow; + public IReadOnlyList Rows { get; } + public IReadOnlyList Merges { get; } + } + + /// + /// Stores the settings shared by normal, data-only, and paged loops. + /// + sealed class LoopPlan + { + public int RowCopyCount { get; set; } + public List Items { get; set; } = new(); + public string Name { get; set; } = string.Empty; + public LoopMode Mode { get; set; } } + + #endregion } } diff --git a/Source/Excel.Report.PDF/ExcelPageLoopProcessor.cs b/Source/Excel.Report.PDF/ExcelPageLoopProcessor.cs new file mode 100644 index 0000000..0f701aa --- /dev/null +++ b/Source/Excel.Report.PDF/ExcelPageLoopProcessor.cs @@ -0,0 +1,153 @@ +using System.Collections; +using ClosedXML.Excel; + +namespace Excel.Report.PDF +{ + /// + /// Builds page plans and creates body-page worksheets before OverWrite runs. + /// Paging stays separate because it can add and remove worksheets. + /// + internal static class ExcelPageLoopProcessor + { + /// + /// Identifies a worksheet's role in a paged report. + /// + internal enum PageType + { + First, + Body, + Last + } + + /// + /// Stores the items and sheet names needed to process one paged loop. + /// + internal sealed class PageLoopPlan + { + public List AllItems { get; } = new(); + public string FirstPageSheetName { get; set; } = string.Empty; + public int FirstPageItemCount { get; set; } + public string BodyTemplateSheetName { get; set; } = string.Empty; + public List BodyPageSheetNames { get; } = new(); + public int BodyPageItemCount { get; set; } + public string LastPageSheetName { get; set; } = string.Empty; + public int LastPageItemCount { get; set; } + public List FirstPageItems { get; set; } = new(); + public List> BodyPageItems { get; } = new(); + public List LastPageItems { get; set; } = new(); + } + + /// + /// Copies the body template for each page and removes the original source sheet. + /// + public static void MaterializeBodyPageSheets(XLWorkbook book, Dictionary pagePlans) + { + foreach (var entry in pagePlans) + { + if (string.IsNullOrEmpty(entry.Value.BodyTemplateSheetName)) continue; + var bodySheet = book.Worksheet(entry.Value.BodyTemplateSheetName); + for (var i = 0; i < entry.Value.BodyPageItems.Count; i++) + bodySheet.CopyTo($"{entry.Value.BodyTemplateSheetName}_{i}", bodySheet.Position + i); + + book.Worksheets.Delete(entry.Value.BodyTemplateSheetName); + } + } + + /// + /// Reads and splits paged-loop data once so converter work is not repeated for every page. + /// + public static async Task> BuildPagePlansAsync(XLWorkbook book, IExcelSymbolConverter converter) + { + var pagePlans = new Dictionary(); + foreach (var sheet in book.Worksheets) + { + ExcelUtils.GetRowColCount(sheet, out var rowCount, out _); + var leftCells = new List(); + var pagedLoopCount = 0; + for (var i = 0; i <= rowCount; i++) + { + var text = sheet.GetText(i + 1, 1).Trim(); + if (text.StartsWith("#PagedLoopRows")) pagedLoopCount++; + if (pagedLoopCount > 1) throw new Exception($"One sheet can have only one #PagedLoopRows. SheetName:{sheet.Name}"); + leftCells.Add(text); + } + + foreach (var leftCell in leftCells) + { + if (!leftCell.StartsWith("#PagedLoopRows")) continue; + var args = leftCell.Replace("#PagedLoopRows", "").Replace("(", "").Replace(")", "").Split(',').Select(e => e.Trim()).ToArray(); + if (args.Length != 5 || !args[2].StartsWith("$")) break; + + var items = args[2].Substring(1); + if (!pagePlans.TryGetValue(items, out var plan)) + { + plan = new PageLoopPlan(); + var enumerable = (await converter.GetData(items))?.Value as IEnumerable; + if (enumerable == null) break; + plan.AllItems.AddRange(enumerable.Cast()); + pagePlans[items] = plan; + } + + if (!Enum.TryParse(args[0], out var pageType) || + !int.TryParse(args[1], out var rowsPerPage) || + !int.TryParse(args[4], out _)) break; + + switch (pageType) + { + case PageType.First: + plan.FirstPageSheetName = sheet.Name; + plan.FirstPageItemCount = rowsPerPage; + break; + case PageType.Body: + plan.BodyTemplateSheetName = sheet.Name; + plan.BodyPageItemCount = rowsPerPage; + break; + case PageType.Last: + plan.LastPageSheetName = sheet.Name; + plan.LastPageItemCount = rowsPerPage; + break; + } + + break; + } + } + + // Split each source list once because all page sheets use the same boundaries. + foreach (var entry in pagePlans) + { + var plan = entry.Value; + if (!plan.AllItems.Any()) continue; + + var bodyCount = plan.AllItems.Count - plan.FirstPageItemCount - plan.LastPageItemCount; + if (bodyCount == 0) + { + var firstPageCount = plan.FirstPageItemCount; + var lastPageCount = plan.AllItems.Count - plan.FirstPageItemCount; + if (lastPageCount <= 0) + { + lastPageCount = 1; + firstPageCount = plan.AllItems.Count - 1; + } + + plan.FirstPageItems = plan.AllItems.Take(firstPageCount).ToList(); + plan.LastPageItems = plan.AllItems.Skip(firstPageCount).Take(lastPageCount).ToList(); + } + else + { + var rest = plan.AllItems.Skip(plan.FirstPageItemCount).ToList(); + plan.FirstPageItems = plan.AllItems.Take(plan.FirstPageItemCount).ToList(); + while (plan.LastPageItemCount < rest.Count) + { + plan.BodyPageItems.Add(rest.Take(plan.BodyPageItemCount).ToList()); + rest = rest.Skip(plan.BodyPageItemCount).ToList(); + } + + plan.LastPageItems = rest; + for (var i = 0; i < plan.BodyPageItems.Count; i++) plan.BodyPageSheetNames.Add($"{plan.BodyTemplateSheetName}_{i}"); + } + } + + return pagePlans; + } + } +} diff --git a/Source/Excel.Report.PDF/IExcelSymbolConverter.cs b/Source/Excel.Report.PDF/IExcelSymbolConverter.cs index 92c7104..3f3b461 100644 --- a/Source/Excel.Report.PDF/IExcelSymbolConverter.cs +++ b/Source/Excel.Report.PDF/IExcelSymbolConverter.cs @@ -6,3 +6,14 @@ public interface IExcelSymbolConverter Task GetData(string symbol); } } + +namespace Excel.Report.PDF +{ + // Internal fast path for the built-in converter. + // It avoids creating an async wrapper for every cell; custom converters still use GetData above. + internal interface ISynchronousExcelSymbolConverter + { + // Lets the writer resolve a value without creating a Task. + bool TryGetData(string symbol, out object? value); + } +} diff --git a/Source/Excel.Report.PDF/ObjectExcelSymbolConverter.cs b/Source/Excel.Report.PDF/ObjectExcelSymbolConverter.cs index defb9c8..407af43 100644 --- a/Source/Excel.Report.PDF/ObjectExcelSymbolConverter.cs +++ b/Source/Excel.Report.PDF/ObjectExcelSymbolConverter.cs @@ -1,9 +1,26 @@ namespace Excel.Report.PDF { - public class ObjectExcelSymbolConverter : IExcelSymbolConverter + public class ObjectExcelSymbolConverter : IExcelSymbolConverter, ISynchronousExcelSymbolConverter { - object? _obj; - string _name = string.Empty; + // Caches property getters because the same symbols are often used many times. + sealed class PropertyAccessor + { + public PropertyAccessor(bool exists, Func getter) + { + Exists = exists; + Getter = getter; + } + + public bool Exists { get; } + public Func Getter { get; } + } + + static readonly PropertyAccessor MissingProperty = new(false, _ => null); // Cache missing properties too. + static readonly System.Collections.Concurrent.ConcurrentDictionary<(Type Type, string Name), PropertyAccessor> PropertyAccessors = new(); // Reuse getters across rows. + static readonly Task MissingData = Task.FromResult(null); // Reuse the common missing result. + + readonly object? _obj; + readonly string _name = string.Empty; public ObjectExcelSymbolConverter(object? obj) => _obj = obj; ObjectExcelSymbolConverter(object? obj, string name) @@ -15,29 +32,82 @@ public class ObjectExcelSymbolConverter : IExcelSymbolConverter public IExcelSymbolConverter CreateChildExcelSymbolConverter(object? obj, string name) => new ObjectExcelSymbolConverter(obj, name); - public async Task GetData(string symbol) + public Task GetData(string symbol) + { + // Keep the public async API while using the faster synchronous lookup internally. + if (!TryGetData(symbol, out var value)) return MissingData; + return Task.FromResult(new ExcelOverWriteCell { Value = value }); + } + + public bool TryGetData(string symbol, out object? value) + { + value = null; + + if (_obj == null) return false; + + var propertyName = symbol; + if (!string.IsNullOrEmpty(_name)) + { + var prefix = _name + "."; + if (!symbol.StartsWith(prefix, StringComparison.Ordinal)) return false; + propertyName = symbol.Substring(prefix.Length); + } + + return TryGetPropertyValue(_obj, propertyName, out value); + } + + public Task GetData(object? element, string elementName, string symbol) + { + if (_obj == null) return MissingData; + + var prefix = elementName + "."; + if (!symbol.StartsWith(prefix, StringComparison.Ordinal)) return MissingData; + if (element == null) + return Task.FromResult(new ExcelOverWriteCell()); + + return TryGetPropertyValue(element, symbol.Substring(prefix.Length), out var value) + ? Task.FromResult(new ExcelOverWriteCell { Value = value }) + : MissingData; + } + + static bool TryGetPropertyValue(object target, string propertyName, out object? value) { - await Task.CompletedTask; + // Create each type/property lookup only once. + var accessor = PropertyAccessors.GetOrAdd( + (target.GetType(), propertyName), + static key => CreatePropertyAccessor(key.Type, key.Name)); - if (_obj == null) - return null; + if (!accessor.Exists) + { + value = null; + return false; + } - if(!string.IsNullOrEmpty(_name)) - return await GetData(_obj, _name, symbol); - var prop = _obj.GetType().GetProperty(symbol); - return prop == null ? null : new ExcelOverWriteCell { Value = prop.GetValue(_obj) }; + value = accessor.Getter(target); + return true; } - public async Task GetData(object? element, string elementName, string symbol) + static PropertyAccessor CreatePropertyAccessor(Type type, string propertyName) { - await Task.CompletedTask; - - if (_obj == null) - return null; - if (!symbol.StartsWith(elementName + ".")) return null; - if (element == null) return new ExcelOverWriteCell(); - var prop = element.GetType().GetProperty(symbol.Substring((elementName + ".").Length)); - return prop == null ? null : new ExcelOverWriteCell { Value = prop.GetValue(element) }; + var property = type.GetProperty(propertyName); + if (property == null || property.GetMethod == null) + return MissingProperty; + + try + { + // Compiled getters avoid reflection on every output cell. + var target = System.Linq.Expressions.Expression.Parameter(typeof(object), "target"); + var typedTarget = System.Linq.Expressions.Expression.Convert(target, type); + var propertyValue = System.Linq.Expressions.Expression.Property(typedTarget, property); + var boxedValue = System.Linq.Expressions.Expression.Convert(propertyValue, typeof(object)); + var getter = System.Linq.Expressions.Expression.Lambda>(boxedValue, target).Compile(); + return new PropertyAccessor(true, getter); + } + catch (ArgumentException) + { + // Fall back to reflection for properties that cannot be compiled. + return new PropertyAccessor(true, property.GetValue); + } } } diff --git a/Source/Test/Test/ExcelOverWriterTest.cs b/Source/Test/Test/ExcelOverWriterTest.cs index c0673b0..c465bd9 100644 --- a/Source/Test/Test/ExcelOverWriterTest.cs +++ b/Source/Test/Test/ExcelOverWriterTest.cs @@ -46,9 +46,57 @@ class Loop2 public int Id { get; set; } } + class DataManyProps + { + public string Prop1 { get; set; } = "Prop1"; + public string Prop2 { get; set; } = "Prop2"; + public string Prop3 { get; set; } = "Prop3"; + public string Prop4 { get; set; } = "Prop4"; + public string Prop5 { get; set; } = "Prop5"; + public string Prop6 { get; set; } = "Prop6"; + public string Prop7 { get; set; } = "Prop7"; + public string Prop8 { get; set; } = "Prop8"; + public string Prop9 { get; set; } = "Prop9"; + public string Prop10 { get; set; } = "Prop10"; + + public string Prop11 { get; set; } = "Prop11"; + public string Prop12 { get; set; } = "Prop12"; + public string Prop13 { get; set; } = "Prop13"; + public string Prop14 { get; set; } = "Prop14"; + public string Prop15 { get; set; } = "Prop15"; + public string Prop16 { get; set; } = "Prop16"; + public string Prop17 { get; set; } = "Prop17"; + public string Prop18 { get; set; } = "Prop18"; + public string Prop19 { get; set; } = "Prop19"; + public string Prop20 { get; set; } = "Prop20"; + + public string Prop21 { get; set; } = "Prop21"; + public string Prop22 { get; set; } = "Prop22"; + public string Prop23 { get; set; } = "Prop23"; + public string Prop24 { get; set; } = "Prop24"; + public string Prop25 { get; set; } = "Prop25"; + public string Prop26 { get; set; } = "Prop26"; + public string Prop27 { get; set; } = "Prop27"; + public string Prop28 { get; set; } = "Prop28"; + public string Prop29 { get; set; } = "Prop29"; + public string Prop30 { get; set; } = "Prop30"; + + public string Prop31 { get; set; } = "Prop31"; + public string Prop32 { get; set; } = "Prop32"; + public string Prop33 { get; set; } = "Prop33"; + public string Prop34 { get; set; } = "Prop34"; + public string Prop35 { get; set; } = "Prop35"; + public string Prop36 { get; set; } = "Prop36"; + public string Prop37 { get; set; } = "Prop37"; + public string Prop38 { get; set; } = "Prop38"; + public string Prop39 { get; set; } = "Prop39"; + public string Prop40 { get; set; } = "Prop40"; + } + const string EmptySheetInputFileName = "EmptySheetTest.xlsx"; const string RecursiveLoop2TestInputFileName = "ExcelOverWriterTest_RecursiveLoop2Test.xlsx"; const string RecursiveLoop2TestMergedInputFileName = "ExcelOverWriterTest_RecursiveLoop2Test(Merged).xlsx"; + const string RecursiveLoop2TestManyRowsAndPropsInputFileName = "ExcelOverWriterTest_RecursiveLoop2Test(ManyRowsAndProps).xlsx"; [OneTimeSetUp] public void OneTimeSetUp() @@ -83,6 +131,12 @@ public void OneTimeSetUp() RecursiveLoop2TestMergedInputWorkbook( recursiveLoop2TestMergedInputPath ); } + var recursiveLoop2TestManyRowsAndPropsInputPath = Path.Combine( TestEnvironment.PdfSrcPath, RecursiveLoop2TestManyRowsAndPropsInputFileName ); + if (!File.Exists( recursiveLoop2TestManyRowsAndPropsInputPath )) { + Directory.CreateDirectory( TestEnvironment.PdfSrcPath ); + RecursiveLoop2TestManyRowsAndPropsInputWorkbook( recursiveLoop2TestManyRowsAndPropsInputPath ); + } + } private void RecursiveLoop2TestInputWorkbook(string path) @@ -127,6 +181,38 @@ private void RecursiveLoop2TestMergedInputWorkbook(string path) book.SaveAs( path ); } + private void RecursiveLoop2TestManyRowsAndPropsInputWorkbook(string path) { + using var book = new XLWorkbook(); + var sheet = book.AddWorksheet( "Sheet1" ); + + sheet.Column( 1 ).Width = 30; + sheet.Cell( 1, 1 ).SetValue( "#LoopRow($Loop1Data, x, 3)" ); + sheet.Range( 1, 2, 1, 43 ).Merge().Style.Fill.BackgroundColor = XLColor.Yellow; + sheet.Cell( 1, 2 ).SetValue( "$x.Header1" ); + + sheet.Cell( 2, 1 ).SetValue( "#LoopRow($x.Loop2Data, y, 2)" ); + sheet.Cell( 2, 3 ).SetValue( "$y.Header2" ); + + sheet.Cell( 3, 1 ).SetValue( "#LoopRow($y.Loop3Data, z, 1)" ); + + System.Reflection.PropertyInfo[] props = typeof( DataManyProps ).GetProperties(); + for (int i = 0; i < props.Length; i++) + { + System.Reflection.PropertyInfo pi = props[i]; + sheet.Cell( 3, 4+i ).SetValue( $"$z.{pi.Name}" ) + .Style.Fill.SetBackgroundColor( + (i % 3) switch + { + 0 => XLColor.LightBlue, + 1 => XLColor.LightGreen, + _ => XLColor.LightPink + } + ); + } + + book.SaveAs( path ); + } + static void CreateEmptySheetInputWorkbook(string path) { using var book = new XLWorkbook(); @@ -370,6 +456,10 @@ public async Task RecursiveLoop1Test() var sheet = book.Worksheets.First(); + // A1:The Loop directives should not be output as they are, and the cell should be empty. + sheet.Cell( 1, 1 ).Value.IsBlank.IsTrue(); + sheet.Cell( 2, 1 ).Value.IsBlank.IsTrue(); + // B1:the part unrelated to the loop, verify if the data is output as it is. var noLoopData = sheet.Cell(1, 2).Value.GetText(); noLoopData.Is("NameA"); @@ -470,7 +560,8 @@ public async Task RecursiveLoop2Test() } [Test] - public async Task RecursiveLoop2MergedTest() { + public async Task RecursiveLoop2MergedTest() + { var data = new Data { Name = "NameA" }; @@ -538,6 +629,112 @@ public async Task RecursiveLoop2MergedTest() { File.WriteAllBytes( Path.Combine( TestEnvironment.TestResultsPath, Path.ChangeExtension( RecursiveLoop2TestMergedInputFileName, "pdf" ) ), outStream.ToArray() ); } + [Test] + [Explicit( "This is a test for large row count with many properties, it may take a long time to run" )] + public async Task RecursiveLoop2LargeRowCountWithManyPropsTest() + { + + var sourceData = Enumerable + .Range( 0, 50000 ) + .Select( r => + { + var g1Header = string.Concat( "[G1.", r % 100, "] Prop1" ); + var g2Header = string.Concat( "[G2.", r % 10, "] Prop2" ); + return new DataManyProps() + { + Prop1 = g1Header, + Prop2 = g2Header, + Prop3 = Guid.NewGuid().ToString("N"), + } + ; + } + ) + .ToArray() + ; + + var data = new + { + Loop1Data = sourceData + .GroupBy( x => x.Prop1 ) + .Select( g => new + { + Header1 = g.Key, + Loop2Data = g + .GroupBy( x => x.Prop2 ) + .Select( g2 => new + { + Header2 = g2.Key, + Loop3Data = g2 + } + ) + .ToArray() + } + ) + .ToArray() + }; + + var converter = new ObjectExcelSymbolConverter( data ); + + byte[] templateBytes; + using (var templateStream = new FileStream( Path.Combine( TestEnvironment.PdfSrcPath, RecursiveLoop2TestManyRowsAndPropsInputFileName ), FileMode.Open, FileAccess.Read, FileShare.ReadWrite )) + using (var memoryStream = new MemoryStream()) + { + templateStream.CopyTo( memoryStream ); + templateBytes = memoryStream.ToArray(); + } + + // Warm up JIT, getter compilation, and ClosedXML before collecting timings. + using (var warmupBook = new XLWorkbook( new MemoryStream( templateBytes, writable: false ) )) + { + await warmupBook.Worksheets.First().OverWrite( converter ); + } + + const int measuredRuns = 3; // Median reduces noise from occasional GC or OS activity. + var timings = new long[measuredRuns]; + XLWorkbook? lastBook = null; + var isBookSaved = false; + try + { + for (int i = 0; i < measuredRuns; i++) + { + var book = new XLWorkbook( new MemoryStream( templateBytes, writable: false ) ); + lastBook?.Dispose(); + lastBook = book; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await book.Worksheets.First().OverWrite( converter ); + sw.Stop(); + timings[i] = sw.ElapsedMilliseconds; + + if (!isBookSaved) { + book.SaveAs( Path.Combine( TestEnvironment.TestResultsPath, RecursiveLoop2TestManyRowsAndPropsInputFileName ) ); + isBookSaved = true; + } + } + + Array.Sort( timings ); + Console.WriteLine( $"OverWrite timings: {string.Join( ", ", timings )} ms; median: {timings[measuredRuns / 2]} ms" ); + + var sheet = lastBook!.Worksheets.First(); + sheet.Cell( 1, 2 ).GetText().Is( "[G1.0] Prop1" ); + sheet.Cell( 2, 3 ).GetText().Is( "[G2.0] Prop2" ); + sheet.Cell( 3, 4 ).GetText().Is( "[G1.0] Prop1" ); + sheet.Cell( 3, 43 ).GetText().Is( "Prop40" ); + + // Verify the styled path kept both the repeated header style and alternating leaf styles. + sheet.Cell( 1, 2 ).Style.Fill.BackgroundColor.Color.Is( XLColor.Yellow.Color, "The repeated header should stay yellow" ); + sheet.Cell( 3, 4 ).Style.Fill.BackgroundColor.Color.Is( XLColor.LightBlue.Color, "The first leaf should stay light blue" ); + sheet.Cell( 3, 5 ).Style.Fill.BackgroundColor.Color.Is( XLColor.LightGreen.Color, "The second leaf should stay light green" ); + sheet.Cell( 1, 2 ).MergedRange().IsNotNull( "The repeated header merge should be restored" ); + + } + finally + { + lastBook?.Dispose(); + } + + } + [Test] public void TestCopyPage() diff --git a/Source/Test/Test/IOverWriteFunctionTest.cs b/Source/Test/Test/IOverWriteFunctionTest.cs index c81fc5b..5ecad0a 100644 --- a/Source/Test/Test/IOverWriteFunctionTest.cs +++ b/Source/Test/Test/IOverWriteFunctionTest.cs @@ -452,6 +452,35 @@ public async Task CustomFunctionInsideLoopRowData_ExpandsPerIteration() sheet.Cell(4, 2).Value.GetText().Is("C/3"); } + [Test] + public async Task NestedLoopRowDataUsesEachNestedConverterScope() + { + using var book = new XLWorkbook(); + var sheet = book.AddWorksheet("Sheet1"); + sheet.Cell(1, 1).SetValue("Header"); + sheet.Cell(2, 1).SetValue("#LoopRowData($Groups, group, 2)"); + sheet.Cell(2, 2).SetValue("$group.Name"); + sheet.Cell(3, 1).SetValue("#LoopRowData($group.Items, item)"); + sheet.Cell(3, 2).SetValue("$item.Label"); + + var data = new + { + Groups = new[] + { + new { Name = "A", Items = new[] { new { Label = "A1" }, new { Label = "A2" } } }, + new { Name = "B", Items = new[] { new { Label = "B1" } } } + } + }; + + await sheet.OverWrite(new ObjectExcelSymbolConverter(data)); + + sheet.Cell(2, 2).GetText().Is("A"); + sheet.Cell(3, 2).GetText().Is("A1"); + sheet.Cell(4, 2).GetText().Is("A2"); + sheet.Cell(5, 2).GetText().Is("B"); + sheet.Cell(6, 2).GetText().Is("B1"); + } + // For a multi-row loop block (rowCopyCount > 1), only the directive row gets function // suppression. Functions on the *other* rows of the block must still be invoked // normally during the recursive pass. This pins down the boundary of the fix. diff --git a/Source/Test/Test/ObjectExcelSymbolConverterTest.cs b/Source/Test/Test/ObjectExcelSymbolConverterTest.cs new file mode 100644 index 0000000..9dc590d --- /dev/null +++ b/Source/Test/Test/ObjectExcelSymbolConverterTest.cs @@ -0,0 +1,110 @@ +using ClosedXML.Excel; +using Excel.Report.PDF; + +namespace Test +{ + // Covers cached object-symbol resolution and the custom async path to prevent performance changes from altering behavior. + public class ObjectExcelSymbolConverterTest + { + class Model + { + public string Text { get; set; } = string.Empty; + public int Number { get; set; } + public string? NullText { get; set; } + public string Computed => $"{Text}:{Number}"; + } + + class Owner + { + public Model Child { get; set; } = new(); + } + + class AsyncConverter : IExcelSymbolConverter + { + public int CallCount { get; private set; } + + public IExcelSymbolConverter CreateChildExcelSymbolConverter(object? obj, string name) => this; + + public Task GetData(string symbol) + { + CallCount++; + return Task.FromResult( + symbol == "Value" ? new ExcelOverWriteCell { Value = "async" } : null); + } + } + + [Test] + public async Task RepeatedPropertyLookupReturnsTheSameValue() + { + var converter = new ObjectExcelSymbolConverter(new Model { Text = "hello" }); + + for (var i = 0; i < 100; i++) + { + var result = await converter.GetData("Text"); + result.IsNotNull(); + result!.Value.Is("hello"); + } + } + + [Test] + public async Task MissingPropertyReturnsNull() + { + var converter = new ObjectExcelSymbolConverter(new Model()); + + var result = await converter.GetData("DoesNotExist"); + + result.IsNull(); + } + + [Test] + public async Task NullPropertyReturnsAWriteCellWithNullValue() + { + var converter = new ObjectExcelSymbolConverter(new Model { NullText = null }); + + var result = await converter.GetData("NullText"); + + result.IsNotNull(); + result!.Value.IsNull(); + } + + [Test] + public async Task ComputedPropertyIsResolved() + { + var converter = new ObjectExcelSymbolConverter(new Model { Text = "hello", Number = 42 }); + + var result = await converter.GetData("Computed"); + + result.IsNotNull(); + result!.Value.Is("hello:42"); + } + + [Test] + public async Task ChildConverterResolvesNestedProperty() + { + var owner = new Owner { Child = new Model { Text = "nested" } }; + var converter = new ObjectExcelSymbolConverter(owner); + var child = converter.CreateChildExcelSymbolConverter(owner.Child, "item"); + + var result = await child.GetData("item.Text"); + var unrelated = await child.GetData("other.Text"); + + result.IsNotNull(); + result!.Value.Is("nested"); + unrelated.IsNull(); + } + + [Test] + public async Task CustomAsyncConverterUsesTheAsyncPath() + { + using var book = new XLWorkbook(); + var sheet = book.AddWorksheet("Sheet1"); + sheet.Cell(1, 1).SetValue("$Value"); + var converter = new AsyncConverter(); + + await sheet.OverWrite(converter); + + sheet.Cell(1, 1).GetText().Is("async"); + converter.CallCount.Is(1); + } + } +}