diff --git a/src/EPPlus/Core/Worksheet/ExcelPivotTableCopyEventArgs.cs b/src/EPPlus/Core/Worksheet/ExcelPivotTableCopyEventArgs.cs new file mode 100644 index 0000000000..cf29d2404f --- /dev/null +++ b/src/EPPlus/Core/Worksheet/ExcelPivotTableCopyEventArgs.cs @@ -0,0 +1,43 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com +************************************************************************************************* + Date Author Change +************************************************************************************************* + 08/05/2026 EPPlus Software AB Added +*************************************************************************************************/ +namespace OfficeOpenXml.Core.Worksheet +{ + /// + /// Provides context for a pivot table that is being copied to a new worksheet, and allows + /// a custom name to be assigned to the copied pivot table. + /// + public class ExcelPivotTableCopyEventArgs + { + /// + /// The name of the pivot table on the source worksheet. + /// + public string SourceTableName { get; internal set; } + + /// + /// The name that was assigned to the copied pivot table by default, before this handler + /// runs. When the worksheet is copied within the same workbook, this is a generated name + /// (PivotTable1, PivotTable2, ...). When copied to another workbook, the original name is + /// kept when it is still available, in which case this equals ; + /// if a pivot table with that name already exists in the target workbook, a generated name + /// is used instead. + /// + public string DefaultName { get; internal set; } + + /// + /// The name to assign to the copied pivot table. Leave as null to keep . + /// Setting this to an existing pivot table name will cause the same validation exception + /// as a normal pivot table name assignment. + /// + public string NewName { get; set; } + } +} \ No newline at end of file diff --git a/src/EPPlus/Core/Worksheet/ExcelWorksheetCopyOptions.cs b/src/EPPlus/Core/Worksheet/ExcelWorksheetCopyOptions.cs index 4848995483..7743905f16 100644 --- a/src/EPPlus/Core/Worksheet/ExcelWorksheetCopyOptions.cs +++ b/src/EPPlus/Core/Worksheet/ExcelWorksheetCopyOptions.cs @@ -31,5 +31,16 @@ public class ExcelWorksheetCopyOptions /// formula references are updated and name uniqueness is validated. /// public Action TableCopyHandler { get; set; } + + /// + /// A handler that is invoked for each pivot table that is copied to the new worksheet. + /// Use this to assign a custom name to the copied pivot table. When a worksheet is copied + /// within the same workbook, copied pivot tables are otherwise given a generated name + /// (PivotTable1, PivotTable2, ...). Set + /// on the argument to rename the copied pivot table. The rename is applied through the same + /// path as a normal + /// assignment, so name uniqueness is validated. + /// + public Action PivotTableCopyHandler { get; set; } } } diff --git a/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs b/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs index 05cdf6a85e..08ebc5aa80 100644 --- a/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs +++ b/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs @@ -117,9 +117,10 @@ internal static ExcelWorksheet Copy(ExcelWorksheets targetWorksheets, string nam copiedTableNames = CopyTable(sourceWorksheet, targetWorksheet); } + Dictionary copiedPivotTableNames = null; if (sourceWorksheet.PivotTables.Count > 0) { - CopyPivotTable(sourceWorksheet, targetWorksheet); + copiedPivotTableNames = CopyPivotTable(sourceWorksheet, targetWorksheet); } CopyDefinedNames(sourceWorksheet, targetWorksheet); @@ -188,7 +189,7 @@ internal static ExcelWorksheet Copy(ExcelWorksheets targetWorksheets, string nam //CopyDxfStyles and the slicer copy, which resolve the copied tables //by their default name. ApplyTableCopyOptions(targetWorksheet, options, copiedTableNames); - + ApplyPivotTableCopyOptions(targetWorksheet, options, copiedPivotTableNames); return targetWorksheet; } @@ -227,6 +228,41 @@ private static void ApplyTableCopyOptions(ExcelWorksheet added, ExcelWorksheetCo } } + private static void ApplyPivotTableCopyOptions(ExcelWorksheet added, ExcelWorksheetCopyOptions options, Dictionary copiedPivotTableNames) + { + if (options == null || options.PivotTableCopyHandler == null || copiedPivotTableNames == null) + { + return; + } + + foreach (var pair in copiedPivotTableNames) + { + var sourceTableName = pair.Key; + var defaultName = pair.Value; + var copiedPivotTable = added.PivotTables[defaultName]; + if (copiedPivotTable == null) + { + continue; + } + + var args = new ExcelPivotTableCopyEventArgs + { + SourceTableName = sourceTableName, + DefaultName = defaultName + }; + options.PivotTableCopyHandler.Invoke(args); + + if (!string.IsNullOrEmpty(args.NewName) && args.NewName != defaultName) + { + //Route through the ExcelPivotTable.Name setter so name uniqueness is + //validated, exactly as for a normal rename. Pivot table references in + //GETPIVOTDATA are address based, not name based, so no formula + //adjustment is required. + copiedPivotTable.Name = args.NewName; + } + } + } + private static void SetTableFunction(ExcelWorksheet added) { foreach (var t in added.Tables) @@ -1185,7 +1221,7 @@ private static List> CopyTable(ExcelWorksheet sourc return copiedTableNames; } - private static void CopyPivotTable(ExcelWorksheet sourceWs, ExcelWorksheet destWs) + private static Dictionary CopyPivotTable(ExcelWorksheet sourceWs, ExcelWorksheet destWs) { sourceWs._package.Workbook.ReadAllPivotTables(); string prevName = ""; @@ -1274,6 +1310,7 @@ private static void CopyPivotTable(ExcelWorksheet sourceWs, ExcelWorksheet destW } //Can't have a cell selected when "group editing" avoids pop-up by not selecting sheet. destWs.View.SetTabSelected(false); + return nameMap; } private static void CreateCacheInNewPackage(ExcelWorksheet sourceWs, ExcelPivotTable tbl, ZipPackagePart partTbl) diff --git a/src/EPPlus/Table/PivotTable/ExcelPivotTable.cs b/src/EPPlus/Table/PivotTable/ExcelPivotTable.cs index 955e57540d..c39a5726cc 100644 --- a/src/EPPlus/Table/PivotTable/ExcelPivotTable.cs +++ b/src/EPPlus/Table/PivotTable/ExcelPivotTable.cs @@ -266,7 +266,8 @@ private void CreatePivotTable(ExcelWorksheet sheet, ExcelAddressBase address, in { LoadXmlSafe(PivotTableXml, copy.PivotTableXml.OuterXml, Encoding.UTF8); TopNode = PivotTableXml.DocumentElement; - Name = name; + SetXmlNodeString(NAME_PATH, name); + SetXmlNodeString(DISPLAY_NAME_PATH, CleanDisplayName(name)); } PivotTableUri = GetNewUri(pck, "/xl/pivotTables/pivotTable{0}.xml", ref tblId); @@ -361,16 +362,16 @@ public string Name } set { - if (WorkSheet.Workbook.ExistsTableName(value)) + if (WorkSheet.Workbook.ExistsPivotTableName(value)) { throw (new ArgumentException("PivotTable name is not unique")); } string prevName = Name; - if (WorkSheet.Tables._tableNames.ContainsKey(prevName)) + if (WorkSheet.PivotTables._pivotTableNames.ContainsKey(prevName)) { - int ix = WorkSheet.Tables._tableNames[prevName]; - WorkSheet.Tables._tableNames.Remove(prevName); - WorkSheet.Tables._tableNames.Add(value, ix); + int ix = WorkSheet.PivotTables._pivotTableNames[prevName]; + WorkSheet.PivotTables._pivotTableNames.Remove(prevName); + WorkSheet.PivotTables._pivotTableNames.Add(value, ix); } SetXmlNodeString(NAME_PATH, value); SetXmlNodeString(DISPLAY_NAME_PATH, CleanDisplayName(value)); diff --git a/src/EPPlusTest/Core/Worksheet/CopyWorksheetTests.cs b/src/EPPlusTest/Core/Worksheet/CopyWorksheetTests.cs index 2780e91ced..9c0855d411 100644 --- a/src/EPPlusTest/Core/Worksheet/CopyWorksheetTests.cs +++ b/src/EPPlusTest/Core/Worksheet/CopyWorksheetTests.cs @@ -20,6 +20,19 @@ private static ExcelPackage CreatePackageWithTable(out ExcelWorksheet source) return package; } + private static ExcelPackage CreatePackageWithPivotTable(out ExcelWorksheet source, string pivotName) + { + var package = new ExcelPackage(); + source = package.Workbook.Worksheets.Add("Template"); + var range = LoadItemData(source); + var pt = source.PivotTables.Add(source.Cells["A1"], range, pivotName); + pt.RowFields.Add(pt.Fields[1]); + pt.DataFields.Add(pt.Fields[3]); + return package; + } + + #region Copy with Tables + [TestMethod] public void Copy_WithTableCopyHandler_RenamesCopiedTable() { @@ -373,5 +386,130 @@ public void Copy_TableCopyHandler_NewNameCollidesWithExistingTable_Throws() }); } } + + #endregion + + #region Copy with pivot tables + [TestMethod] + public void Copy_WithoutHandler_AssignsGeneratedPivotTableName() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + var copy = package.Workbook.Worksheets.Copy(source.Name, "Copy"); + + Assert.AreEqual(1, copy.PivotTables.Count); + //Same workbook copy renames the copied pivot table to a generated name. + Assert.AreNotEqual("SalesPivot", copy.PivotTables[0].Name); + } + } + + [TestMethod] + public void Copy_WithPivotTableCopyHandler_RenamesCopiedPivotTable() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + var copy = package.Workbook.Worksheets.Copy(source.Name, "BaltimoreMD", options => + { + options.PivotTableCopyHandler = args => + { + args.NewName = "BaltimoreMD_" + args.SourceTableName; + }; + }); + + Assert.AreEqual(1, copy.PivotTables.Count); + Assert.IsNotNull(copy.PivotTables["BaltimoreMD_SalesPivot"]); + } + } + + [TestMethod] + public void Copy_WithPivotTableCopyHandler_ProvidesSourceAndDefaultName() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + string capturedSourceName = null; + string capturedDefaultName = null; + + package.Workbook.Worksheets.Copy(source.Name, "Copy", options => + { + options.PivotTableCopyHandler = args => + { + capturedSourceName = args.SourceTableName; + capturedDefaultName = args.DefaultName; + }; + }); + + Assert.AreEqual("SalesPivot", capturedSourceName); + Assert.IsNotNull(capturedDefaultName); + } + } + + [TestMethod] + public void Copy_PivotTableCopyHandler_NullNewName_KeepsDefaultName() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + string defaultName = null; + + var copy = package.Workbook.Worksheets.Copy(source.Name, "Copy", options => + { + options.PivotTableCopyHandler = args => + { + defaultName = args.DefaultName; + // NewName left null. + }; + }); + + Assert.IsNotNull(copy.PivotTables[defaultName]); + } + } + + [TestMethod] + public void Copy_PivotTableCopyHandler_RenameToExistingName_Throws() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + //A second pivot table in the workbook whose name we will collide with. + var other = package.Workbook.Worksheets.Add("Other"); + var otherPt = other.PivotTables.Add(other.Cells["A1"], source.Cells["K1:N11"], "ExistingPivot"); + otherPt.RowFields.Add(otherPt.Fields[1]); + otherPt.DataFields.Add(otherPt.Fields[3]); + + Assert.ThrowsExactly(() => + { + package.Workbook.Worksheets.Copy(source.Name, "Copy", options => + { + options.PivotTableCopyHandler = args => + { + args.NewName = "ExistingPivot"; + }; + }); + }); + } + } + + [TestMethod] + public void Copy_PivotTableCopyHandler_GetPivotDataStillResolvesAfterRename() + { + using (var package = CreatePackageWithPivotTable(out var source, "SalesPivot")) + { + //GETPIVOTDATA references the pivot by cell address, not by name, so a rename + //must not break the copied formula's resolution. + source.Cells["H1"].Formula = "GETPIVOTDATA(\"Stock\",$A$1)"; + + var copy = package.Workbook.Worksheets.Copy(source.Name, "Copy", options => + { + options.PivotTableCopyHandler = args => + { + args.NewName = "RenamedPivot"; + }; + }); + + //The copied formula is unchanged (address based) and the pivot was renamed. + Assert.AreEqual("GETPIVOTDATA(\"Stock\",$A$1)", copy.Cells["H1"].Formula); + Assert.IsNotNull(copy.PivotTables["RenamedPivot"]); + } + } + + #endregion } } \ No newline at end of file