From 66a83972d8135c925d1558e5f06dde54fb4c41a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AdrianParn=C3=A9us?= Date: Wed, 6 May 2026 16:38:14 +0200 Subject: [PATCH 1/3] Fixed issue where drawings set to absolute positioning would crash --- src/EPPlus/Drawing/ExcelDrawing.cs | 49 ++++++++++++++++++++-- src/EPPlusTest/Drawing/CopyDrawingTests.cs | 13 ++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 1778afb660..7498d56e19 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -16,6 +16,7 @@ Date Author Change using OfficeOpenXml.Drawing.OleObject; using OfficeOpenXml.Drawing.Slicer; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; using OfficeOpenXml.Packaging; using OfficeOpenXml.Utils.EnumUtils; using OfficeOpenXml.Utils.FileUtils; @@ -731,8 +732,8 @@ internal void GetFromBounds(out int fromRow, out int fromRowOff, out int fromCol { if (CellAnchor == eEditAs.Absolute) { - GetToRowFromPixels(Position.Y, out fromRow, out fromRowOff); - GetToColumnFromPixels(Position.X, out fromCol, out fromColOff); + GetToRowFromPixels(Position.Y / (double)EMU_PER_PIXEL, out fromRow, out fromRowOff); + GetToColumnFromPixels(Position.X / (double)EMU_PER_PIXEL, out fromCol, out fromColOff); } else { @@ -747,7 +748,7 @@ internal void GetToBounds(out int toRow, out int toRowOff, out int toCol, out in if (CellAnchor == eEditAs.Absolute) { GetToRowFromPixels((Position.Y + Size.Height) / EMU_PER_PIXEL, out toRow, out toRowOff); - GetToColumnFromPixels(Position.X + Size.Width / EMU_PER_PIXEL, out toCol, out toColOff); + GetToColumnFromPixels((Position.X + Size.Width) / EMU_PER_PIXEL, out toCol, out toColOff); } else { @@ -1033,6 +1034,26 @@ internal void SetPixelHeight(double pixels) internal void GetToRowFromPixels(double pixels, out int toRow, out int rowOff, int fromRow = -1, int fromRowOff = -1) { + if (From == null && this is not ExcelControl) + { + // Absolute anchor path + double remaining = pixels; + int currentRow = 1; + + while (true) + { + double rowPix = _drawings.Worksheet.GetRowHeight(currentRow) / 0.75; + if (remaining < rowPix) + break; + + remaining -= rowPix; + currentRow++; + } + + toRow = currentRow - 1; + rowOff = (int)(remaining); + return; + } if (fromRow < 0) { fromRow = From.Row; @@ -1087,7 +1108,27 @@ internal void GetToColumnFromPixels(double pixels, out int col, out int colOff, { ExcelWorksheet ws = _drawings.Worksheet; double mdw = ws.Workbook.MaxFontWidth; - if (fromColumn < 0) + if (From == null && this is not ExcelControl) + { + // Absolute anchor path + double remaining = pixels; + int currentCol = 1; + + while (true) + { + double colPix = (ws.GetColumnWidth(fromColumn) * mdw + 0.75d); + if (remaining < colPix) + break; + + remaining -= colPix; + currentCol++; + } + + col = currentCol-1; + colOff = (int)(remaining); + return; + } + if (From != null && fromColumn < 0) { fromColumn = From.Column; fromColumnOff = From.ColumnOff; diff --git a/src/EPPlusTest/Drawing/CopyDrawingTests.cs b/src/EPPlusTest/Drawing/CopyDrawingTests.cs index 171142befd..05cc5004de 100644 --- a/src/EPPlusTest/Drawing/CopyDrawingTests.cs +++ b/src/EPPlusTest/Drawing/CopyDrawingTests.cs @@ -799,5 +799,18 @@ public void s814CopySameImageTwiceToEmptyNamedRanges() SaveAndCleanup(targetPackage); } } + + + [TestMethod] + public void CopyDrawingWithAbsolutePosition () + { + using var p = OpenTemplatePackage("Test-file.xlsx"); + var sourceSheet = p.Workbook.Worksheets[1]; + using var destPackage = new ExcelPackage(); + var destSheet = destPackage.Workbook.Worksheets.Add("Dest"); + sourceSheet.Cells[1, 1, sourceSheet.Dimension.Rows, sourceSheet.Dimension.Columns].Copy(destSheet.Cells[1, 1], ExcelRangeCopyOptionFlags.ExcludeFormulas); + //Assert.AreEqual(1, destSheet.Drawings.Count); + destPackage.SaveAs($"C:\\epplusTest\\Testoutput\\AbsoluteDrawingCopy.xlsx"); + } } } From 99940b2eef06d8dff30a77f4d702d4d791f8575a Mon Sep 17 00:00:00 2001 From: AdrianEPPlus <162118292+AdrianEPPlus@users.noreply.github.com> Date: Thu, 7 May 2026 14:58:38 +0200 Subject: [PATCH 2/3] Update CopyDrawingTests.cs --- src/EPPlusTest/Drawing/CopyDrawingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EPPlusTest/Drawing/CopyDrawingTests.cs b/src/EPPlusTest/Drawing/CopyDrawingTests.cs index 05cc5004de..e71858de2e 100644 --- a/src/EPPlusTest/Drawing/CopyDrawingTests.cs +++ b/src/EPPlusTest/Drawing/CopyDrawingTests.cs @@ -810,7 +810,7 @@ public void CopyDrawingWithAbsolutePosition () var destSheet = destPackage.Workbook.Worksheets.Add("Dest"); sourceSheet.Cells[1, 1, sourceSheet.Dimension.Rows, sourceSheet.Dimension.Columns].Copy(destSheet.Cells[1, 1], ExcelRangeCopyOptionFlags.ExcludeFormulas); //Assert.AreEqual(1, destSheet.Drawings.Count); - destPackage.SaveAs($"C:\\epplusTest\\Testoutput\\AbsoluteDrawingCopy.xlsx"); + SaveAndCleanup(destPackage); } } } From 887e0ad995ab67fead8fc26ab561069c4fd35610 Mon Sep 17 00:00:00 2001 From: swmal <{ID}+username}@users.noreply.github.com> Date: Fri, 8 May 2026 16:12:51 +0200 Subject: [PATCH 3/3] #2346 Fix NRE and incorrect column resolution for absolute-anchored drawings.Extract duplicated pixel calculations to PixelHelper. Add tests. --- src/EPPlus/Drawing/ExcelDrawing.cs | 41 +++--- src/EPPlus/Utils/Drawing/PixelHelper.cs | 47 +++++++ src/EPPlusTest/Drawing/CopyDrawingTests.cs | 139 +++++++++++++++++++++ 3 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 src/EPPlus/Utils/Drawing/PixelHelper.cs diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 7498d56e19..1147ea1e9a 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -18,6 +18,8 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; using OfficeOpenXml.Packaging; +using OfficeOpenXml.Utils; +using OfficeOpenXml.Utils.Drawings; using OfficeOpenXml.Utils.EnumUtils; using OfficeOpenXml.Utils.FileUtils; using OfficeOpenXml.Utils.XML; @@ -26,6 +28,7 @@ Date Author Change using System.Globalization; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Xml; @@ -856,15 +859,14 @@ internal double GetPixelWidth() if (CellAnchor == eEditAs.TwoCell) { ExcelWorksheet ws = _drawings.Worksheet; - double mdw = ws.Workbook.MaxFontWidth; pix = -From.ColumnOff / (double)EMU_PER_PIXEL; for (int col = From.Column + 1; col <= To.Column; col++) { - pix += MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(col) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + pix += PixelHelper.GetColumnWidth(ws, col); } - var w = MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(To.Column + 1) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + var w = PixelHelper.GetColumnWidth(ws, To.Column + 1); pix += Math.Min(w, Convert.ToDouble(To.ColumnOff) / EMU_PER_PIXEL); } else @@ -899,9 +901,9 @@ internal double GetPixelHeight() pix = -(From.RowOff / (double)EMU_PER_PIXEL); for (int row = From.Row + 1; row <= To.Row; row++) { - pix += ws.GetRowHeight(row) / 0.75; + pix += PixelHelper.GetRowHeight(ws, row); } - var h = ws.GetRowHeight(To.Row + 1) / 0.75; + var h = PixelHelper.GetRowHeight(ws, To.Row + 1); pix += Math.Min(h, Convert.ToDouble(To.RowOff) / EMU_PER_PIXEL); } else @@ -940,12 +942,12 @@ internal void CalcRowFromPixelTop(double pixels, out int row, out int rowOff) ExcelWorksheet ws = _drawings.Worksheet; double mdw = ws.Workbook.MaxFontWidth; double prevPix = 0; - double pix = ws.GetRowHeight(1) / 0.75; + double pix = PixelHelper.GetRowHeight(ws, 1); int r = 2; while (pix < pixels) { prevPix = pix; - pix += (int)(ws.GetRowHeight(r++) / 0.75); + pix += (int)PixelHelper.GetRowHeight(ws, r++); } if (pix == pixels) @@ -988,15 +990,14 @@ internal void CalcColFromPixelLeft(double pixels, out int column, out int column { ExcelWorksheet ws = _drawings.Worksheet; - double mdw = ws.Workbook.MaxFontWidth; double prevPix = 0; - double pix = (int)MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(1) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + double pix = (int)PixelHelper.GetColumnWidth(ws, 1); int col = 2; while (pix < pixels) { prevPix = pix; - pix += (int)MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(col++) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + pix += (int)PixelHelper.GetColumnWidth(ws, col++); } if (pix == pixels) { @@ -1042,7 +1043,7 @@ internal void GetToRowFromPixels(double pixels, out int toRow, out int rowOff, i while (true) { - double rowPix = _drawings.Worksheet.GetRowHeight(currentRow) / 0.75; + double rowPix = PixelHelper.GetRowHeight(_drawings.Worksheet, currentRow); if (remaining < rowPix) break; @@ -1060,14 +1061,14 @@ internal void GetToRowFromPixels(double pixels, out int toRow, out int rowOff, i fromRowOff = From.RowOff; } ExcelWorksheet ws = _drawings.Worksheet; - var pixOff = pixels - ((ws.GetRowHeight(fromRow + 1) / 0.75) - (fromRowOff / (double)EMU_PER_PIXEL)); + var pixOff = pixels - (PixelHelper.GetRowHeight(ws, fromRow + 1) - (fromRowOff / (double)EMU_PER_PIXEL)); double prevPixOff = pixels; int row = fromRow + 1; while (pixOff >= 0) { prevPixOff = pixOff; - pixOff -= (ws.GetRowHeight(++row) / 0.75); + pixOff -= PixelHelper.GetRowHeight(ws, ++row); } toRow = row - 1; if (fromRow == toRow) @@ -1107,21 +1108,17 @@ internal void SetPixelWidth(double pixels) internal void GetToColumnFromPixels(double pixels, out int col, out int colOff, int fromColumn = -1, int fromColumnOff = -1) { ExcelWorksheet ws = _drawings.Worksheet; - double mdw = ws.Workbook.MaxFontWidth; if (From == null && this is not ExcelControl) { // Absolute anchor path double remaining = pixels; int currentCol = 1; - - while (true) + double colPix = PixelHelper.GetColumnWidth(ws, currentCol); + while (remaining >= colPix) { - double colPix = (ws.GetColumnWidth(fromColumn) * mdw + 0.75d); - if (remaining < colPix) - break; - remaining -= colPix; currentCol++; + colPix = PixelHelper.GetColumnWidth(ws, currentCol); } col = currentCol-1; @@ -1133,13 +1130,13 @@ internal void GetToColumnFromPixels(double pixels, out int col, out int colOff, fromColumn = From.Column; fromColumnOff = From.ColumnOff; } - double pixOff = pixels - (MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(fromColumn + 1) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw) - fromColumnOff / EMU_PER_PIXEL); + double pixOff = pixels - (PixelHelper.GetColumnWidth(ws, fromColumn + 1) - fromColumnOff / EMU_PER_PIXEL); double offset = (double)fromColumnOff / EMU_PER_PIXEL + pixels; col = fromColumn + 2; while (pixOff >= 0) { offset = pixOff; - pixOff -= MathHelper.TruncateDouble(((256 * ws.GetColumnWidth(col++) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + pixOff -= PixelHelper.GetColumnWidth(ws, col++); } colOff = (int)offset; } diff --git a/src/EPPlus/Utils/Drawing/PixelHelper.cs b/src/EPPlus/Utils/Drawing/PixelHelper.cs new file mode 100644 index 0000000000..3057de3c87 --- /dev/null +++ b/src/EPPlus/Utils/Drawing/PixelHelper.cs @@ -0,0 +1,47 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 05/08/2026 EPPlus Software AB Initial release + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; + +namespace OfficeOpenXml.Utils.Drawings +{ + /// + /// Helper methods for converting between worksheet coordinates and pixels. + /// + internal static class PixelHelper + { + /// + /// Returns the width of a column in pixels, using the same formula + /// Excel uses internally. + /// + /// The worksheet. + /// The 1-based column index. + /// The column width in pixels. + internal static double GetColumnWidth(ExcelWorksheet ws, int column) + { + double mdw = ws.Workbook.MaxFontWidth; + return MathHelper.TruncateDouble( + ((256 * ws.GetColumnWidth(column) + MathHelper.TruncateDouble(128 / mdw)) / 256) * mdw); + } + + /// + /// Returns the height of a row in pixels. + /// + /// The worksheet. + /// The 1-based row index. + /// The row height in pixels. + internal static double GetRowHeight(ExcelWorksheet ws, int row) + { + return ws.GetRowHeight(row) / 0.75; + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/Drawing/CopyDrawingTests.cs b/src/EPPlusTest/Drawing/CopyDrawingTests.cs index e71858de2e..1ab47a2d51 100644 --- a/src/EPPlusTest/Drawing/CopyDrawingTests.cs +++ b/src/EPPlusTest/Drawing/CopyDrawingTests.cs @@ -812,5 +812,144 @@ public void CopyDrawingWithAbsolutePosition () //Assert.AreEqual(1, destSheet.Drawings.Count); SaveAndCleanup(destPackage); } + + [TestMethod] + public void GetFromAndToBounds_AbsoluteAnchor_FromCornerAndToRow_ResolveCorrectly() + { + // Reproduces the customer ticket where calling GetFromBounds / + // GetToBounds on an absolute-anchored drawing throws NRE because + // From is null after the workbook has been read from disk. + // + // This test verifies the From corner and the To row resolve to the + // correct cell coordinates. The To column is verified separately + // in GetFromAndToBounds_AbsoluteAnchor_ToColumn_ResolvesCorrectly. + // + // The drawing is positioned at (6 px, 136 px) with size (1375 px, + // 20 px). Column widths are explicit; row heights use the workbook + // default (15 pt = 20 px per row). MaxFontWidth is the default 7. + const string fileName = "AbsoluteAnchorBounds.xlsx"; + + // Build and save the workbook. Saving and reloading is required to + // produce the From == null state that triggers the customer's NRE. + using (var p = OpenPackage(fileName, delete: true)) + { + var ws = p.Workbook.Worksheets.Add("AbsoluteAnchorTest"); + + ws.Column(1).Width = 10.6640625; + ws.Column(2).Width = 5.5546875; + ws.Column(3).Width = 5; + ws.Column(4).Width = 7; + ws.Column(5).Width = 8.109375; + ws.Column(6).Width = 9; + ws.Column(7).Width = 28; + ws.Column(8).Width = 8.88671875; + ws.Column(9).Width = 8.88671875; + ws.Column(10).Width = 8.33203125; + ws.Column(11).Width = 8.33203125; + ws.Column(12).Width = 27; + ws.Column(13).Width = 7.5546875; + ws.Column(14).Width = 8.33203125; + ws.Column(15).Width = 19.5546875; + ws.Column(16).Width = 9; + ws.Column(17).Width = 7.6640625; + ws.Column(18).Width = 6.109375; + // Column 19 keeps the default width. + + var pic = ws.Drawings.AddPicture("AbsBar", GetResourceFile("EPPlus.png")); + pic.ChangeCellAnchor(eEditAs.Absolute, PixelTop: 136, PixelLeft: 6, + width: 1375, height: 20); + p.Save(); + } + + using (var p = OpenPackage(fileName)) + { + var ws = p.Workbook.Worksheets["AbsoluteAnchorTest"]; + var pic = ws.Drawings[0]; + + Assert.AreEqual(eEditAs.Absolute, pic.CellAnchor); + Assert.IsNull(pic.From, "Reloaded absolute-anchored drawings should have null From."); + Assert.AreEqual(7, p.Workbook.MaxFontWidth, "Test assumes default MaxFontWidth = 7."); + + pic.GetFromBounds(out int fromRow, out int fromRowOff, + out int fromCol, out int fromColOff); + pic.GetToBounds(out int toRow, out int toRowOff, + out int toCol, out int toColOff); + + // From corner at pixel (6, 136). + // Column: pixel 6 falls inside column 1 (0-indexed: 0), 6 px in. + // Row: pixel 136 with row height 20 px; six full rows consume + // 120 px, leaving 16 px offset in row 7. + Assert.AreEqual(0, fromCol, "From column should be A (0-indexed)."); + Assert.AreEqual(6, fromColOff, "From column offset should be 6 px."); + Assert.AreEqual(6, fromRow, "From row should be 7 (0-indexed)."); + Assert.AreEqual(16, fromRowOff, "From row offset should be 16 px."); + + // To corner at pixel (1381, 156). + // Row: pixel 156 falls inside row 8 (0-indexed: 7), 16 px in. + Assert.AreEqual(7, toRow, "To row should be 8 (0-indexed)."); + Assert.AreEqual(16, toRowOff, "To row offset should be 16 px."); + } + } + + [TestMethod] + public void GetFromAndToBounds_AbsoluteAnchor_ToColumn_ResolvesCorrectly() + { + // Companion test to GetFromAndToBounds_AbsoluteAnchor_FromCornerAndToRow_ResolveCorrectly. + // + // Verifies that the To column resolves correctly for an absolute- + // anchored drawing. This test currently FAILS due to a known bug + // in the absolute-anchor branch of GetToColumnFromPixels: the loop + // measures column width using the unset 'fromColumn' parameter + // (-1) instead of the iterating 'currentCol', so every iteration + // uses the workbook default column width and the result is wrong + // whenever the worksheet has columns of varying widths. + // + // Expected values are derived from walking the actual column + // widths set up in the workbook below: pixel 1381 lands in + // column 19 (0-indexed: 18) with a 30 px offset. + // + // Once the bug is fixed this test should pass. + const string fileName = "AbsoluteAnchorBounds.xlsx"; + + using (var p = OpenPackage(fileName, delete: true)) + { + var ws = p.Workbook.Worksheets.Add("AbsoluteAnchorTest"); + + ws.Column(1).Width = 10.6640625; + ws.Column(2).Width = 5.5546875; + ws.Column(3).Width = 5; + ws.Column(4).Width = 7; + ws.Column(5).Width = 8.109375; + ws.Column(6).Width = 9; + ws.Column(7).Width = 28; + ws.Column(8).Width = 8.88671875; + ws.Column(9).Width = 8.88671875; + ws.Column(10).Width = 8.33203125; + ws.Column(11).Width = 8.33203125; + ws.Column(12).Width = 27; + ws.Column(13).Width = 7.5546875; + ws.Column(14).Width = 8.33203125; + ws.Column(15).Width = 19.5546875; + ws.Column(16).Width = 9; + ws.Column(17).Width = 7.6640625; + ws.Column(18).Width = 6.109375; + + var pic = ws.Drawings.AddPicture("AbsBar", GetResourceFile("EPPlus.png")); + pic.ChangeCellAnchor(eEditAs.Absolute, PixelTop: 136, PixelLeft: 6, + width: 1375, height: 20); + p.Save(); + } + + using (var p = OpenPackage(fileName)) + { + var ws = p.Workbook.Worksheets["AbsoluteAnchorTest"]; + var pic = ws.Drawings[0]; + + pic.GetToBounds(out _, out _, out int toCol, out int toColOff); + + Assert.AreEqual(18, toCol, "To column should be S (0-indexed)."); + Assert.AreEqual(30, toColOff, "To column offset should be ~30 px."); + } + } } }