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