From e434164a6b2f895aa014c84f97d2d798b4cc7e14 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:17:39 +0200 Subject: [PATCH 1/2] #2479 - Fix header/footer section parsing when &L is not first --- src/EPPlus/ExcelHeaderFooter.cs | 6 +- .../HeaderFooterSectionOrderTests.cs | 347 ++++++++++++++++++ 2 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs diff --git a/src/EPPlus/ExcelHeaderFooter.cs b/src/EPPlus/ExcelHeaderFooter.cs index 8da37d5dae..401c9fe600 100644 --- a/src/EPPlus/ExcelHeaderFooter.cs +++ b/src/EPPlus/ExcelHeaderFooter.cs @@ -65,14 +65,14 @@ internal ExcelHeaderFooterText(XmlNode TextNode, ExcelWorksheet ws, string hf) string text = TextNode.InnerText; string code = text.Substring(0, 2); int startPos = 2; - for (int pos = startPos; pos < text.Length - 2; pos++) + for (int pos = startPos; pos < text.Length - 1; pos++) { string newCode = text.Substring(pos, 2); - if (newCode == "&C" || newCode == "&R") + if (newCode == "&L" || newCode == "&C" || newCode == "&R") { SetText(code, text.Substring(startPos, pos - startPos)); startPos = pos + 2; - pos = startPos; + pos = startPos - 1; code = newCode; } } diff --git a/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs b/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs new file mode 100644 index 0000000000..a3983193cc --- /dev/null +++ b/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs @@ -0,0 +1,347 @@ +/******************************************************************************* + * You may amend and distribute as you like, but don't remove this header! + * + * Required Notice: Copyright (C) EPPlus Software AB. + * https://epplussoftware.com + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Lesser General Public License for more details. + * + * The GNU Lesser General Public License can be viewed at http://www.opensource.org/licenses/lgpl-license.php + * If you unfamiliar with this license or have questions about it, here is an http://www.gnu.org/licenses/gpl-faq.html + * + * All code and executables are provided "" as is "" with no warranty either express or implied. + * The author accepts no liability for any damage or loss of business that this product may cause. + * + * Code change notes: + * + Date Author Change + ******************************************************************************* + 08/25/2026 EPPlus Software AB Regression tests: header/footer sections + must parse regardless of their order + *******************************************************************************/ +using System; +using System.IO; +using System.Xml; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.Core.Worksheet +{ + /// + /// Regression tests for header/footer sections being lost when they are not stored in + /// Left, Center, Right order. + /// + /// The ExcelHeaderFooterText constructor takes the first section code from the first two + /// characters of the raw string, then scans for further section codes. Before the fix its + /// scan only recognized "&C" and "&R" - never "&L" - so a "&L" appearing + /// anywhere other than at position 0 was not treated as the start of a new section. Its + /// content was swallowed into the preceding section, and then discarded when that section + /// was normalized by ReadHeaderFooterFormat/WriteHeaderFooterFormat, taking any "&G" + /// picture placeholder with it. The picture survived in the VML collection, so + /// HeaderFooter.Pictures.Count was unchanged, but nothing referenced it any more and it + /// stopped rendering in Excel. + /// + /// Excel writes the sections in the order the user created them, which is frequently not + /// Left, Center, Right - so this affected ordinary files produced by Excel itself. + /// + /// Reported case (oddfooter-left-corruption-template.xlsx), oddFooter raw value: + /// &C&"-,Bold"&12{FORMID}&"-,Regular"&11\n&L&G&R&G + /// which before the fix was persisted as: + /// &C&"-,Bold"&12{FORMID}&"-,Regular"&11\n&R&G + /// + /// The same file's oddHeader ("&L&G&R&G") was unaffected, both because it + /// starts with "&L" and because Save() only rewrites a node whose backing field is + /// non-null - and the reported repro only ever touched OddFooter. + /// + [TestClass] + public class HeaderFooterSectionOrderTests : TestBase + { + /// The customer's exact oddFooter value. Section order: Center, Left, Right. + private const string CustomerOddFooter = "&C&\"-,Bold\"&12{FORMID}&\"-,Regular\"&11\n&L&G&R&G"; + + /// The customer's exact oddHeader value. Section order: Left, Right. + private const string CustomerOddHeader = "&L&G&R&G"; + + [ClassInitialize] + public static void Init(TestContext testContext) + { + InitBase(); + } + + [TestMethod] + public void CustomerOddFooter_SurvivesRoundTrip_WhenOddFooterIsTouched() + { + // The exact scenario from the support ticket: a footer whose sections are stored + // in Center, Left, Right order, with a single read of a header/footer property as + // the only interaction before saving. + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", CustomerOddFooter); + SetRawHeaderFooterNode(ws, "oddHeader", CustomerOddHeader); + + // The single interaction from the customer's repro script. + var _ = ws.HeaderFooter.OddFooter.LeftAlignedText; + + var persistedFooter = SaveAndReadRawNode(pkg, "oddFooter"); + var persistedHeader = SaveAndReadRawNode(pkg, "oddHeader"); + + StringAssert.Contains(persistedFooter, "&L" + ExcelHeaderFooter.Image, + "The Left section's picture placeholder must survive the round trip. " + + $"Persisted footer was: \"{Escape(persistedFooter)}\"."); + + Assert.AreEqual(CustomerOddHeader, persistedHeader, + "The untouched oddHeader must be unchanged by the save."); + } + } + + [TestMethod] + public void LeftSection_IsParsed_RegardlessOfSectionOrder() + { + // Every one of these holds the same three logical sections, only reordered. The + // Left section always contains a picture placeholder, and must always come back. + var variants = new[] + { + new { Name = "L,C,R", Raw = "&L&G&CCenterText&R&G" }, + new { Name = "C,L,R", Raw = "&CCenterText&L&G&R&G" }, + new { Name = "R,L,C", Raw = "&R&G&L&G&CCenterText" }, + new { Name = "C,R,L", Raw = "&CCenterText&R&G&L&G" }, + new { Name = "R,C,L", Raw = "&R&G&CCenterText&L&G" }, + new { Name = "L,R", Raw = "&L&G&R&G" }, + new { Name = "C,L", Raw = "&CCenterText&L&G" }, + new { Name = "R,L", Raw = "&R&G&L&G" }, + }; + + foreach (var v in variants) + { + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", v.Raw); + + var oddFooter = ws.HeaderFooter.OddFooter; + + StringAssert.Contains(oddFooter.LeftAlignedText, ExcelHeaderFooter.Image, + $"[{v.Name}] The Left section's '&G' placeholder was not parsed from " + + $"\"{Escape(v.Raw)}\". Left parsed as \"{Escape(oddFooter.LeftAlignedText)}\"."); + + // Guard against the content merely being relocated into another section. + Assert.IsFalse(Contains(oddFooter.CenteredText, "&L"), + $"[{v.Name}] The Center section swallowed a stray '&L': " + + $"\"{Escape(oddFooter.CenteredText)}\"."); + Assert.IsFalse(Contains(oddFooter.RightAlignedText, "&L"), + $"[{v.Name}] The Right section swallowed a stray '&L': " + + $"\"{Escape(oddFooter.RightAlignedText)}\"."); + } + } + } + + [TestMethod] + public void AllSections_SurviveRoundTrip_RegardlessOfSectionOrder() + { + // As above, but verifying what actually reaches the file. Section order is not + // required to be preserved - only the content of each section. + var variants = new[] + { + new { Name = "L,C,R", Raw = "&L&G&CCenterText&R&G" }, + new { Name = "C,L,R", Raw = "&CCenterText&L&G&R&G" }, + new { Name = "R,L,C", Raw = "&R&G&L&G&CCenterText" }, + new { Name = "C,R,L", Raw = "&CCenterText&R&G&L&G" }, + new { Name = "R,C,L", Raw = "&R&G&CCenterText&L&G" }, + }; + + foreach (var v in variants) + { + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", v.Raw); + + // Touch the object so Save() rewrites the node. + var _ = ws.HeaderFooter.OddFooter.CenteredText; + + var persisted = SaveAndReadRawNode(pkg, "oddFooter"); + + StringAssert.Contains(persisted, "&L" + ExcelHeaderFooter.Image, + $"[{v.Name}] Left section lost. Raw was \"{Escape(v.Raw)}\", " + + $"persisted \"{Escape(persisted)}\"."); + StringAssert.Contains(persisted, "&CCenterText", + $"[{v.Name}] Center section lost. Persisted \"{Escape(persisted)}\"."); + StringAssert.Contains(persisted, "&R" + ExcelHeaderFooter.Image, + $"[{v.Name}] Right section lost. Persisted \"{Escape(persisted)}\"."); + } + } + } + + [TestMethod] + public void EmptySectionBetweenTwoSections_DoesNotConsumeFollowingSectionCode() + { + // Covers the "pos = startPos - 1" part of the fix. With the previous + // "pos = startPos" the loop's pos++ skipped the character at startPos, so a + // section code immediately following an empty section was missed. + const string raw = "&C&L&G&R&G"; + + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", raw); + + var oddFooter = ws.HeaderFooter.OddFooter; + + StringAssert.Contains(oddFooter.LeftAlignedText, ExcelHeaderFooter.Image, + $"Left section not parsed from \"{Escape(raw)}\" - the empty Center " + + $"section should not swallow the following '&L'. Left parsed as " + + $"\"{Escape(oddFooter.LeftAlignedText)}\"."); + StringAssert.Contains(oddFooter.RightAlignedText, ExcelHeaderFooter.Image, + "Right section not parsed."); + } + } + + [TestMethod] + public void SectionCodeAtEndOfString_IsRecognized() + { + // Covers the "text.Length - 1" part of the fix. The previous "text.Length - 2" + // bound meant a section code occupying the final two characters was never seen, + // so it was swallowed into the preceding section instead of starting an empty one. + const string raw = "&L&G&R"; + + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", raw); + + var oddFooter = ws.HeaderFooter.OddFooter; + + Assert.IsFalse(Contains(oddFooter.LeftAlignedText, "&R"), + $"The trailing '&R' was swallowed into the Left section: " + + $"\"{Escape(oddFooter.LeftAlignedText)}\"."); + StringAssert.Contains(oddFooter.LeftAlignedText, ExcelHeaderFooter.Image, + "The Left section's picture placeholder should still be parsed."); + } + } + + [TestMethod] + public void HeaderSections_AreParsed_RegardlessOfSectionOrder() + { + // The same parsing path backs OddHeader, so it needs the same coverage - the + // reported case simply never touched the header. + const string raw = "&CHeaderCenter&L&G&R&G"; + + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddHeader", raw); + + var oddHeader = ws.HeaderFooter.OddHeader; + + StringAssert.Contains(oddHeader.LeftAlignedText, ExcelHeaderFooter.Image, + $"Left header section not parsed from \"{Escape(raw)}\". " + + $"Left parsed as \"{Escape(oddHeader.LeftAlignedText)}\"."); + } + } + + [TestMethod] + public void PictureCount_IsUnchanged_ByHeaderFooterTextRoundTrip() + { + // The reported symptom that made this hard to spot: the picture object itself was + // never lost, only the text reference to it, so Pictures.Count kept reporting the + // original value. This pins that behavior down so a future change cannot start + // silently dropping the VML pictures instead. + using (var pkg = new ExcelPackage()) + { + var ws = pkg.Workbook.Worksheets.Add("Sheet1"); + SetRawHeaderFooterNode(ws, "oddFooter", CustomerOddFooter); + + var countBefore = ws.HeaderFooter.Pictures.Count; + var _ = ws.HeaderFooter.OddFooter.LeftAlignedText; + + using (var stream = new MemoryStream()) + { + pkg.SaveAs(stream); + using (var reloaded = new ExcelPackage(stream)) + { + Assert.AreEqual(countBefore, + reloaded.Workbook.Worksheets[0].HeaderFooter.Pictures.Count, + "HeaderFooter.Pictures.Count changed across the round trip."); + } + } + } + } + + #region Helpers + + private static string Escape(string s) + { + return s == null ? "" : s.Replace("\n", "\\n").Replace("\r", "\\r"); + } + + private static bool Contains(string haystack, string needle) + { + return haystack != null && haystack.Contains(needle); + } + + private static XmlNamespaceManager GetNsm(ExcelWorksheet ws) + { + var nsm = new XmlNamespaceManager(ws.WorksheetXml.NameTable); + nsm.AddNamespace("d", ExcelPackage.schemaMain); + return nsm; + } + + /// + /// Writes a raw string straight into d:headerFooter/d:{nodeName}, so section ordering + /// is under the test's control instead of EPPlus's own always-Left-Center-Right + /// authoring order - which is what makes these orderings reachable at all. + /// + private static void SetRawHeaderFooterNode(ExcelWorksheet ws, string nodeName, string rawText) + { + var nsm = GetNsm(ws); + var wsNode = ws.WorksheetXml.SelectSingleNode("d:worksheet", nsm); + + var hfNode = wsNode.SelectSingleNode("d:headerFooter", nsm); + if (hfNode == null) + { + hfNode = ws.WorksheetXml.CreateElement("headerFooter", ExcelPackage.schemaMain); + wsNode.AppendChild(hfNode); + } + + var node = hfNode.SelectSingleNode("d:" + nodeName, nsm); + if (node == null) + { + node = ws.WorksheetXml.CreateElement(nodeName, ExcelPackage.schemaMain); + hfNode.AppendChild(node); + } + node.InnerText = rawText; + } + + /// + /// Saves the package to a stream, reloads it, and returns the raw text of the + /// requested header/footer node as persisted - i.e. what Excel would read. + /// + private static string SaveAndReadRawNode(ExcelPackage pkg, string nodeName) + { + using (var stream = new MemoryStream()) + { + pkg.SaveAs(stream); + + using (var reloaded = new ExcelPackage(stream)) + { + var ws = reloaded.Workbook.Worksheets[0]; + var nsm = GetNsm(ws); + var node = ws.WorksheetXml.SelectSingleNode( + "d:worksheet/d:headerFooter/d:" + nodeName, nsm); + return node == null ? null : node.InnerText; + } + } + } + + #endregion + } +} \ No newline at end of file From 7f658278a5742ae29d240a37d6071317039a3483 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:57:11 +0200 Subject: [PATCH 2/2] #2479 - added test to replicate -2 index error --- .../FontScanning/NameTableSubfamilyTests.cs | 219 ++++++++++++++++++ .../HeaderFooterSectionOrderTests.cs | 32 ++- 2 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs new file mode 100644 index 0000000000..3f4d4eab8a --- /dev/null +++ b/src/EPPlus.Fonts.OpenType.Tests/FontScanning/NameTableSubfamilyTests.cs @@ -0,0 +1,219 @@ +/************************************************************************************************* + 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/25/2026 EPPlus Software AB Initial tests for NameTable subfamily mapping + *************************************************************************************************/ +using EPPlus.Fonts.OpenType.Tables.Name; +using OfficeOpenXml.Interfaces.Fonts; + +namespace EPPlus.Fonts.OpenType.Tests.FontScanning +{ + /// + /// Low-level unit tests for — bare table instances, + /// no font file loading. These tests exercise the string-to-enum heuristic directly, so they + /// stay fast and deterministic regardless of which fonts are installed on the test machine. + /// + /// Background: fonts whose subfamily name carries a weight beyond Bold (e.g. "Black", "Heavy", + /// "Demi" — as seen in real-world builds of "Arial Black") were previously mapped to + /// FontSubFamily.Bold. That made an exact match against a Regular request impossible, so a + /// query for "Arial Black" + Regular fell through to the fallback chain even though the font + /// was installed and found by FindBestMatch. See NameTable.GetSubfamilyEnum for the fix. + /// + [TestClass] + public class NameTableSubfamilyTests : FontTestBase + { + public override TestContext? TestContext { get; set; } + + #region Weight names beyond Bold — should map to Regular, not Bold + + [TestMethod] + public void GetSubfamilyEnum_Black_ReturnsRegular() + { + var nameTable = CreateNameTable(subfamilyName: "Black"); + + var result = nameTable.GetSubfamilyEnum(); + + Assert.AreEqual(FontSubFamily.Regular, result, + "'Black' is a separate typographic weight, not a Bold variant. The family " + + "name (e.g. 'Arial Black') already distinguishes it, so within the 4-value " + + "FontSubFamily enum it must be treated as Regular."); + } + + [TestMethod] + public void GetSubfamilyEnum_Heavy_ReturnsRegular() + { + var nameTable = CreateNameTable(subfamilyName: "Heavy"); + + var result = nameTable.GetSubfamilyEnum(); + + Assert.AreEqual(FontSubFamily.Regular, result); + } + + [TestMethod] + public void GetSubfamilyEnum_Demi_ReturnsRegular() + { + var nameTable = CreateNameTable(subfamilyName: "Demi"); + + var result = nameTable.GetSubfamilyEnum(); + + Assert.AreEqual(FontSubFamily.Regular, result); + } + + [TestMethod] + public void GetSubfamilyEnum_Light_ReturnsRegular() + { + var nameTable = CreateNameTable(subfamilyName: "Light"); + + var result = nameTable.GetSubfamilyEnum(); + + Assert.AreEqual(FontSubFamily.Regular, result); + } + + #endregion + + #region True RIBBI styles — must keep working (regression guard) + + [TestMethod] + public void GetSubfamilyEnum_Regular_ReturnsRegular() + { + var nameTable = CreateNameTable(subfamilyName: "Regular"); + + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_Bold_ReturnsBold() + { + var nameTable = CreateNameTable(subfamilyName: "Bold"); + + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_Italic_ReturnsItalic() + { + var nameTable = CreateNameTable(subfamilyName: "Italic"); + + Assert.AreEqual(FontSubFamily.Italic, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_BoldItalic_ReturnsBoldItalic() + { + var nameTable = CreateNameTable(subfamilyName: "Bold Italic"); + + Assert.AreEqual(FontSubFamily.BoldItalic, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_SemiBold_StillContainsBold_ReturnsBold() + { + // "Semibold" legitimately contains the substring "bold" and is a reasonable + // approximation of Bold within the 4-value enum — unlike "Black"/"Heavy"/"Demi", + // which don't contain "bold" at all. This must keep matching Bold. + var nameTable = CreateNameTable(subfamilyName: "SemiBold"); + + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_Oblique_ReturnsItalic() + { + var nameTable = CreateNameTable(subfamilyName: "Oblique"); + + Assert.AreEqual(FontSubFamily.Italic, nameTable.GetSubfamilyEnum()); + } + + #endregion + + #region Field priority — Typographic Subfamily (17) over legacy Subfamily (2) + + [TestMethod] + public void GetSubfamilyEnum_PrefersTypographicSubfamily17OverLegacySubfamily2() + { + // Real-world "Arial Black"-style layout: legacy subfamily (2) carries the extra + // weight name, while the typographic subfamily (17) correctly says "Regular". + // GetSubfamilyName() must prefer 17, so the enum should resolve to Regular even + // without the Black/Heavy/Demi fix above. + var nameTable = new NameTable + { + NameRecords = new[] + { + MakeRecord(NameRecordTypes.FontSubfamilyName, "Black"), + MakeRecord(NameRecordTypes.TypographicSubfamilyName, "Regular"), + } + }; + + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum()); + } + + #endregion + + #region Fallback to OS/2 fsSelection when name table has no usable subfamily + + [TestMethod] + public void GetSubfamilyEnum_NoNameRecords_FallsBackToFsSelectionBold() + { + const ushort fsSelectionBold = 0x0020; + var nameTable = new NameTable + { + NameRecords = new NameRecord[0], + Os2FsSelection = fsSelectionBold + }; + + Assert.AreEqual(FontSubFamily.Bold, nameTable.GetSubfamilyEnum()); + } + + [TestMethod] + public void GetSubfamilyEnum_NoNameRecords_FallsBackToFsSelectionRegular() + { + var nameTable = new NameTable + { + NameRecords = new NameRecord[0], + Os2FsSelection = 0 + }; + + Assert.AreEqual(FontSubFamily.Regular, nameTable.GetSubfamilyEnum()); + } + + #endregion + + #region Helpers + + /// + /// Builds a minimal bare NameTable with a single Font Subfamily Name (nameID 2) record — + /// enough to exercise GetSubfamilyEnum's string-matching heuristic in isolation. + /// + private static NameTable CreateNameTable(string subfamilyName) + { + return new NameTable + { + NameRecords = new[] + { + MakeRecord(NameRecordTypes.FontSubfamilyName, subfamilyName) + } + }; + } + + private static NameRecord MakeRecord(NameRecordTypes type, string name) + { + return new NameRecord + { + RecordType = type, + nameId = (ushort)type, + platformId = 3, + encodingId = 1, + Name = name + }; + } + + #endregion + } +} \ No newline at end of file diff --git a/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs b/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs index a3983193cc..58be5d0ae2 100644 --- a/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs +++ b/src/EPPlusTest/Core/Worksheet/HeaderFooterSectionOrderTests.cs @@ -186,8 +186,15 @@ public void EmptySectionBetweenTwoSections_DoesNotConsumeFollowingSectionCode() { // Covers the "pos = startPos - 1" part of the fix. With the previous // "pos = startPos" the loop's pos++ skipped the character at startPos, so a - // section code immediately following an empty section was missed. - const string raw = "&C&L&G&R&G"; + // section code starting immediately after a consumed code - i.e. an empty + // section - was missed, and the following section was swallowed by it. + // + // The empty section must sit in the MIDDLE for this to bite: the first section + // code is taken outside the loop, so no skip has happened yet at that point. + // "&C&L&G&R&G" therefore parses correctly even without the fix and would not + // catch a regression here - the empty Center has to follow a match made inside + // the loop, as below. + const string raw = "&L&G&C&R&G"; using (var pkg = new ExcelPackage()) { @@ -196,12 +203,25 @@ public void EmptySectionBetweenTwoSections_DoesNotConsumeFollowingSectionCode() var oddFooter = ws.HeaderFooter.OddFooter; + Console.WriteLine($"Raw : \"{Escape(raw)}\""); + Console.WriteLine($"Left : \"{Escape(oddFooter.LeftAlignedText)}\""); + Console.WriteLine($"Center : \"{Escape(oddFooter.CenteredText)}\""); + Console.WriteLine($"Right : \"{Escape(oddFooter.RightAlignedText)}\""); + StringAssert.Contains(oddFooter.LeftAlignedText, ExcelHeaderFooter.Image, - $"Left section not parsed from \"{Escape(raw)}\" - the empty Center " + - $"section should not swallow the following '&L'. Left parsed as " + - $"\"{Escape(oddFooter.LeftAlignedText)}\"."); + $"Left section not parsed from \"{Escape(raw)}\"."); + + // The empty Center must not swallow the Right section's code. + Assert.IsFalse(Contains(oddFooter.CenteredText, "&R"), + $"The empty Center section swallowed the following '&R': " + + $"\"{Escape(oddFooter.CenteredText)}\"."); + + // This is the assertion that should fail if "pos = startPos - 1" is reverted: + // Right is never set, so RightAlignedText comes back as just "&R". StringAssert.Contains(oddFooter.RightAlignedText, ExcelHeaderFooter.Image, - "Right section not parsed."); + $"Right section not parsed from \"{Escape(raw)}\" - it was most likely " + + $"consumed by the empty Center section. Right parsed as " + + $"\"{Escape(oddFooter.RightAlignedText)}\"."); } }