Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/EPPlus/Core/Worksheet/ExcelPivotTableCopyEventArgs.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class ExcelPivotTableCopyEventArgs
{
/// <summary>
/// The name of the pivot table on the source worksheet.
/// </summary>
public string SourceTableName { get; internal set; }

/// <summary>
/// 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 <see cref="SourceTableName"/>;
/// if a pivot table with that name already exists in the target workbook, a generated name
/// is used instead.
/// </summary>
public string DefaultName { get; internal set; }

/// <summary>
/// The name to assign to the copied pivot table. Leave as null to keep <see cref="DefaultName"/>.
/// Setting this to an existing pivot table name will cause the same validation exception
/// as a normal pivot table name assignment.
/// </summary>
public string NewName { get; set; }
}
}
11 changes: 11 additions & 0 deletions src/EPPlus/Core/Worksheet/ExcelWorksheetCopyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,16 @@ public class ExcelWorksheetCopyOptions
/// formula references are updated and name uniqueness is validated.
/// </summary>
public Action<ExcelTableCopyEventArgs> TableCopyHandler { get; set; }

/// <summary>
/// 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 <see cref="ExcelPivotTableCopyEventArgs.NewName"/>
/// on the argument to rename the copied pivot table. The rename is applied through the same
/// path as a normal <see cref="OfficeOpenXml.Table.PivotTable.ExcelPivotTable.Name"/>
/// assignment, so name uniqueness is validated.
/// </summary>
public Action<ExcelPivotTableCopyEventArgs> PivotTableCopyHandler { get; set; }
}
}
43 changes: 40 additions & 3 deletions src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ internal static ExcelWorksheet Copy(ExcelWorksheets targetWorksheets, string nam
copiedTableNames = CopyTable(sourceWorksheet, targetWorksheet);
}

Dictionary<string, string> copiedPivotTableNames = null;
if (sourceWorksheet.PivotTables.Count > 0)
{
CopyPivotTable(sourceWorksheet, targetWorksheet);
copiedPivotTableNames = CopyPivotTable(sourceWorksheet, targetWorksheet);
}

CopyDefinedNames(sourceWorksheet, targetWorksheet);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -227,6 +228,41 @@ private static void ApplyTableCopyOptions(ExcelWorksheet added, ExcelWorksheetCo
}
}

private static void ApplyPivotTableCopyOptions(ExcelWorksheet added, ExcelWorksheetCopyOptions options, Dictionary<string, string> 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)
Expand Down Expand Up @@ -1185,7 +1221,7 @@ private static List<KeyValuePair<string, string>> CopyTable(ExcelWorksheet sourc

return copiedTableNames;
}
private static void CopyPivotTable(ExcelWorksheet sourceWs, ExcelWorksheet destWs)
private static Dictionary<string, string> CopyPivotTable(ExcelWorksheet sourceWs, ExcelWorksheet destWs)
{
sourceWs._package.Workbook.ReadAllPivotTables();
string prevName = "";
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 7 additions & 6 deletions src/EPPlus/Table/PivotTable/ExcelPivotTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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));
Expand Down
138 changes: 138 additions & 0 deletions src/EPPlusTest/Core/Worksheet/CopyWorksheetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<ArgumentException>(() =>
{
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
}
}
Loading