Skip to content

Commit 2ecd46f

Browse files
authored
Fill in missing row and cell references in templates (#863) (#1015)
The "r" attribute on rows and cells is optional (ECMA-376 18.3.1.73 and 18.3.1.4): without it, a row follows the previous row and a cell follows the previous cell, which is how OpenXmlReader already reads them. The template code keys everything on "r", so a template written that way crashed in UpdateDimensionAndGetRowsInfo with an ArgumentNullException. FillMissingReferences fills in the missing ones when the template sheet is loaded, in both the create and update modes, so FillTemplate and MergeSameCells both work. References that are already there are left as they are, so a malformed one still gets the existing NotSupportedException.
1 parent 357b9de commit 2ecd46f

4 files changed

Lines changed: 127 additions & 1 deletion

File tree

‎src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ private async Task GenerateSheetByUpdateModeAsync(ZipArchiveEntry sheetZipEntry,
4949
var sheetData = worksheet?.Element(SpreadsheetNs + "sheetData");
5050
var newSheetData = new XElement(sheetData);
5151
var rows = newSheetData.Elements(SpreadsheetNs + "row");
52+
FillMissingReferences(rows);
5253

5354
InjectSharedStrings(sharedStrings, rows);
5455
GetMergeCells(worksheet);
@@ -83,6 +84,7 @@ private async Task GenerateSheetByCreateModeAsync(ZipArchiveEntry templateSheetZ
8384
var newSheetData = new XElement(sheetData);
8485

8586
var rows = newSheetData.Elements(SpreadsheetNs + "row");
87+
FillMissingReferences(rows);
8688

8789
InjectSharedStrings(sharedStrings, rows);
8890
GetMergeCells(worksheet);
@@ -97,6 +99,39 @@ private async Task GenerateSheetByCreateModeAsync(ZipArchiveEntry templateSheetZ
9799
await WriteSheetXmlAsync(writer, worksheet, sheetData, mergeCells, cancellationToken).ConfigureAwait(false);
98100
}
99101

102+
// "r" is optional on rows and cells (ECMA-376 18.3.1.73, 18.3.1.4); without it they follow the previous one,
103+
// which is also how OpenXmlReader reads them. The rest of the template code keys on "r", so fill it in first.
104+
private static void FillMissingReferences(IEnumerable<XElement> rows)
105+
{
106+
var rowIndex = 0;
107+
foreach (var row in rows)
108+
{
109+
if (row.Attribute("r") is not { } rowReference)
110+
{
111+
rowIndex++;
112+
row.SetAttributeValue("r", rowIndex.ToString());
113+
}
114+
else if (int.TryParse(rowReference.Value, out var explicitRowIndex))
115+
{
116+
rowIndex = explicitRowIndex;
117+
}
118+
119+
var columnIndex = 0;
120+
foreach (var cell in row.Elements(SpreadsheetNs + "c"))
121+
{
122+
if (cell.Attribute("r") is not { } cellReference)
123+
{
124+
columnIndex++;
125+
cell.SetAttributeValue("r", CellReferenceConverter.GetCellFromCoordinates(columnIndex, rowIndex));
126+
}
127+
else if (CellReferenceConverter.TryParseCellReference(cellReference.Value, out var explicitColumnIndex, out _))
128+
{
129+
columnIndex = explicitColumnIndex;
130+
}
131+
}
132+
}
133+
}
134+
100135
private void GetMergeCells(XElement worksheet)
101136
{
102137
if (worksheet.Element(SpreadsheetNs + "mergeCells") is not { } mergeCells)
@@ -334,7 +369,6 @@ var s when s.StartsWith("@header") => SpecialCellType.Header,
334369
}
335370
}
336371

337-
//TODO: Fix parsing for documents that don't have the "r" attribute on rows
338372
if (row.Attribute("r")?.Value is not { } rVal || !int.TryParse(rVal, out var originRowIndex))
339373
throw new NotSupportedException("The format of the chosen template is not currently supported.");
340374

‎tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,4 +1313,30 @@ public async Task TestIssue980() // deterministic file generation
13131313
var bytes2 = await File.ReadAllBytesAsync(path2.FilePath);
13141314
Assert.True(bytes1.SequenceEqual(bytes2));
13151315
}
1316+
1317+
[Fact]
1318+
public async Task TestIssue863() // template rows and cells without the optional "r" reference
1319+
{
1320+
var original = PathHelper.GetFile("xlsx/TestTemplateComplex.xlsx");
1321+
using var withoutReferences = AutoDeletingPath.Create();
1322+
SheetHelper.CopyWithoutCellReferences(original, withoutReferences.FilePath);
1323+
1324+
var value = new Dictionary<string, object>
1325+
{
1326+
["title"] = "FooCompany",
1327+
["managers"] = new[] { new { name = "Jack", department = "HR" }, new { name = "Loan", department = "IT" } },
1328+
["employees"] = new[] { new { name = "Wade", department = "HR" }, new { name = "Keaton", department = "IT" } }
1329+
};
1330+
1331+
using var expected = AutoDeletingPath.Create();
1332+
using var actual = AutoDeletingPath.Create();
1333+
await _excelTemplater.FillTemplateAsync(expected.FilePath, original, value);
1334+
await _excelTemplater.FillTemplateAsync(actual.FilePath, withoutReferences.FilePath, value);
1335+
1336+
var expectedRows = _excelImporter.Query(expected.FilePath).Select(row => ((IDictionary<string, object?>)row).Values.ToArray()).ToList();
1337+
var actualRows = _excelImporter.Query(actual.FilePath).Select(row => ((IDictionary<string, object?>)row).Values.ToArray()).ToList();
1338+
Assert.Equal("A1:C7", SheetHelper.GetFirstSheetDimensionRefValue(actual.FilePath));
1339+
Assert.Equal(expectedRows, actualRows);
1340+
Assert.Equal("Keaton", actualRows[6][1]);
1341+
}
13161342
}

‎tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3029,4 +3029,49 @@ public void TestIssue980() // deterministic file generation
30293029
var bytes2 = File.ReadAllBytes(path2.FilePath);
30303030
Assert.True(bytes1.SequenceEqual(bytes2));
30313031
}
3032+
3033+
[Theory]
3034+
[InlineData(false)]
3035+
[InlineData(true)]
3036+
public void TestIssue863(bool keepRowReferences) // template rows and cells without the optional "r" reference
3037+
{
3038+
var original = PathHelper.GetFile("xlsx/TestTemplateComplex.xlsx");
3039+
using var withoutReferences = AutoDeletingPath.Create();
3040+
SheetHelper.CopyWithoutCellReferences(original, withoutReferences.FilePath, keepRowReferences);
3041+
3042+
var value = new Dictionary<string, object>
3043+
{
3044+
["title"] = "FooCompany",
3045+
["managers"] = new[] { new { name = "Jack", department = "HR" }, new { name = "Loan", department = "IT" } },
3046+
["employees"] = new[] { new { name = "Wade", department = "HR" }, new { name = "Keaton", department = "IT" } }
3047+
};
3048+
3049+
using var expected = AutoDeletingPath.Create();
3050+
using var actual = AutoDeletingPath.Create();
3051+
_excelTemplater.FillTemplate(expected.FilePath, original, value);
3052+
_excelTemplater.FillTemplate(actual.FilePath, withoutReferences.FilePath, value);
3053+
3054+
var actualRows = ReadValues(actual.FilePath);
3055+
Assert.Equal("A1:C7", SheetHelper.GetFirstSheetDimensionRefValue(actual.FilePath));
3056+
Assert.Equal(ReadValues(expected.FilePath), actualRows);
3057+
Assert.Equal("Keaton", actualRows[6][1]);
3058+
}
3059+
3060+
[Fact]
3061+
public void TestIssue863_MergeSameCells()
3062+
{
3063+
using var withoutReferences = AutoDeletingPath.Create();
3064+
SheetHelper.CopyWithoutCellReferences(PathHelper.GetFile("xlsx/TestMergeWithTag.xlsx"), withoutReferences.FilePath);
3065+
using var merged = AutoDeletingPath.Create();
3066+
3067+
_excelTemplater.MergeSameCells(merged.FilePath, withoutReferences.FilePath);
3068+
3069+
var mergedCells = SheetHelper.GetFirstSheetMergedCells(merged.FilePath);
3070+
Assert.Equal("A2:A4", mergedCells[0]);
3071+
Assert.Equal("C3:C4", mergedCells[1]);
3072+
Assert.Equal("A7:A8", mergedCells[2]);
3073+
}
3074+
3075+
private List<object?[]> ReadValues(string path) =>
3076+
_excelImporter.Query(path).Select(row => ((IDictionary<string, object?>)row).Values.ToArray()).ToList();
30323077
}

‎tests/MiniExcel.OpenXml.Tests/Utils/SheetHelper.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,25 @@ internal static MemoryStream CreateTestWorkbookStream()
133133
stream.Position = 0;
134134
return stream;
135135
}
136+
137+
// Copy of the workbook without "r" on its cells, and on its rows unless keepRowReferences (ECMA-376 allows both)
138+
internal static void CopyWithoutCellReferences(string source, string target, bool keepRowReferences = false)
139+
{
140+
File.Copy(source, target, overwrite: true);
141+
using var zip = ZipFile.Open(target, ZipArchiveMode.Update);
142+
foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith("xl/worksheets/sheet")).ToList())
143+
{
144+
XDocument doc;
145+
using (var stream = entry.Open())
146+
doc = XDocument.Load(stream);
147+
148+
foreach (var element in doc.Descendants().Where(e => e.Name.LocalName == "c" || (e.Name.LocalName == "row" && !keepRowReferences)))
149+
element.Attribute("r")?.Remove();
150+
151+
var name = entry.FullName;
152+
entry.Delete();
153+
using var output = zip.CreateEntry(name).Open();
154+
doc.Save(output);
155+
}
156+
}
136157
}

0 commit comments

Comments
 (0)