sc: parenthesize a reference union on OOXML save

OOXML writes the union operator as the same comma that separates
function arguments, so a union with no parentheses of its own fell
apart on save: SUM(A1~B2) became SUM(A1,B2), which reads as two
arguments, and INDEX(A1:B3~D1:E3;1;1;2) changed its meaning even on
Calc's own reload. A union already inside a parenthesised group was
safe, since the group's tokens are kept.

The walk now records where the union-level expression of each
parenthesis scope starts. The first union operator opens a list
there, and the list takes one pair of parentheses when the
expression ends: at a looser operator, an argument separator, the
parenthesis that closes the scope, or the end of the formula.

A list that fills a grouping parenthesis exactly keeps that pair, so
a formula holds its shape over repeated round trips. Two kinds of
parenthesis cannot be reused that way: the one behind AND, OR or the
call operator, which opens an argument list rather than a group, and
one that an @ wrapper beginning at the same place has already taken.

The group check behind the @ wrapper refuses a group with a comma
directly inside it, passing over the commas an inline matrix or a
bracketed reference carries. Text that no parse could read takes no
parentheses at all, so a list reaching such text drops its pair.

Unit tests pin the written string for each union shape, and the
positions the inserted parenthesis shifts. A round-trip test checks
the saved XML and the reloaded formulas and values.

Signed-off-by: Tomaž Vajngerl <tomaz.vajngerl@collabora.co.uk>
Change-Id: I970f3b4ecea680f6e70d146a2f1608c4adff9c1f
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/9308
Tested-by: Tomaž Vajngerl <tomaz.vajngerl@collabora.com>
Reviewed-by: Mike Kaganski <mike.kaganski@collabora.com>
(cherry picked from commit dd8104342d7147f07006bebb1dbeec60b48250f6)
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/209411
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
Tested-by: Jenkins
This commit is contained in:
Tomaž Vajngerl
2026-08-21 00:02:28 +02:00
committed by Xisco Fauli
parent 1a18114fce
commit cccd6f9f1a
4 changed files with 294 additions and 10 deletions
+118 -9
View File
@@ -2884,6 +2884,9 @@ bool lclIsOneParenthesizedGroup(const OUStringBuffer& rBuffer, sal_Int32 nStart)
if (nEnd - nStart < 2 || rBuffer[nStart] != '(' || rBuffer[nEnd - 1] != ')')
return false;
sal_Int32 nGroupDepth = 0;
// The braces of an inline matrix and the brackets of a table or external
// reference, which carry separators of their own.
sal_Int32 nBracketDepth = 0;
sal_Unicode cQuote = 0;
for (sal_Int32 nIndex = nStart; nIndex < nEnd; ++nIndex)
{
@@ -2895,6 +2898,10 @@ bool lclIsOneParenthesizedGroup(const OUStringBuffer& rBuffer, sal_Int32 nStart)
}
else if (cChar == '"' || cChar == '\'')
cQuote = cChar;
else if (cChar == '{' || cChar == '[')
++nBracketDepth;
else if (cChar == '}' || cChar == ']')
--nBracketDepth;
else if (cChar == '(')
++nGroupDepth;
else if (cChar == ')')
@@ -2903,6 +2910,13 @@ bool lclIsOneParenthesizedGroup(const OUStringBuffer& rBuffer, sal_Int32 nStart)
if (nGroupDepth == 0)
return nIndex == nEnd - 1;
}
else if (cChar == ',' && nGroupDepth == 1 && nBracketDepth == 0)
{
// A comma directly inside the group makes it a union list, whose
// parentheses keep it one argument, so they cannot double as the
// wrapper's own.
return false;
}
}
return false;
}
@@ -2935,6 +2949,59 @@ void lclCancelSingleValues(std::vector<PendingSingleValue>& rPending, sal_Int32
while (!rPending.empty() && rPending.back().mnScope >= nScope)
rPending.pop_back();
}
// Buffer positions for one open parenthesis scope. Parentheses are spliced in
// once the text they enclose stands in the buffer, so the walk records where
// each of them would go.
struct UnionScope
{
// Where the scope's own text begins.
sal_Int32 mnContentStart;
// Where the union-level expression being written began.
sal_Int32 mnExpressionStart;
// Where an open union list began. -1 while the scope has none.
sal_Int32 mnListStart;
// The scope is a grouping parenthesis rather than a call's argument list.
bool mbGroupParenthesis;
};
// Whether a wrapper still to be spliced in begins where the scope's text does, so
// the scope's own parentheses are already spoken for.
bool lclHasWrapperAtStart(const std::vector<PendingSingleValue>& rPending, sal_Int32 nScope,
sal_Int32 nContentStart)
{
for (const PendingSingleValue& rSingle : std::views::reverse(rPending))
{
if (rSingle.mnScope < nScope)
break;
if (rSingle.mnWrapStart == nContentStart)
return true;
}
return false;
}
// Wrap the scope's open union list in parentheses. A list that exactly fills a
// grouping parenthesis keeps that pair when bMayTakeScopeParenthesis allows it.
void lclCloseUnionList(OUStringBuffer& rBuffer, UnionScope& rScope,
std::vector<PendingSingleValue>& rPending, bool bMayTakeScopeParenthesis)
{
if (rScope.mnListStart < 0)
return;
if (!(bMayTakeScopeParenthesis && rScope.mbGroupParenthesis
&& rScope.mnListStart == rScope.mnContentStart))
{
rBuffer.insert(rScope.mnListStart, u"(");
rBuffer.append(u")");
// The opening parenthesis moves the text behind it along, so a wrapper
// waiting inside the list follows its own operand.
for (PendingSingleValue& rSingle : rPending)
{
if (rSingle.mnWrapStart > rScope.mnListStart)
++rSingle.mnWrapStart;
}
}
rScope.mnListStart = -1;
}
} // namespace
bool FormulaCompiler::AppendTokenOrError(OUStringBuffer& rBuffer, const FormulaToken*& rpToken)
@@ -3021,6 +3088,9 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
aFactorStarts.push_back(rBuffer.getLength());
// The @ markers whose _xlfn.SINGLE wrapper is still to be spliced in.
std::vector<PendingSingleValue> aPendingSingles;
// One entry per parenthesis scope, tracking the scope's union list.
std::vector<UnionScope> aUnionScopes;
aUnionScopes.push_back({ rBuffer.getLength(), rBuffer.getLength(), -1, false });
// Whether the next token stands where an operand starts, so a plus or a
// minus there is the unary form.
bool bOperandPosition = true;
@@ -3051,6 +3121,8 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
// The consumed parenthesis opens a scope like a written one.
lclMarkPendingOperand(aPendingSingles);
aFactorStarts.push_back(rBuffer.getLength());
aUnionScopes.push_back(
{ rBuffer.getLength(), rBuffer.getLength(), -1, false });
rBuffer.append(GetNativeSymbol(ocErrRef));
bOperandPosition = false;
bBadFactor = false;
@@ -3102,7 +3174,12 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
// behind it.
const bool bPrefix = bOperandPosition && lclIsPrefixOperator(eOpHere);
const bool bCallName = !bPrefix && lclIsCallBeforeArguments(eOpHere, maArrIterator);
const bool bSeparator = !bPrefix && lclIsOoxmlFactorSeparator(eOpHere);
const bool bSeparator
= !bPrefix && !bCallName && lclIsOoxmlFactorSeparator(eOpHere);
// The union list and the @ operand cover the same stretch of the
// grammar, so the same tokens end both of them.
const bool bEndsOperand
= !bPrefix && !bCallName && lclEndsSingleValueOperand(eOpHere);
// A structured table reference walks through here token by token,
// so its brackets scope like parentheses and the separators inside
@@ -3118,6 +3195,10 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
}
lclMarkPendingOperand(aPendingSingles);
aFactorStarts.push_back(rBuffer.getLength());
// A grouping parenthesis stands where an operand starts; a call's
// follows its function name.
aUnionScopes.push_back({ rBuffer.getLength(), rBuffer.getLength(), -1,
eOpHere == ocOpen && bOperandPosition });
bOperandPosition = true;
bBadFactor = false;
continue;
@@ -3126,9 +3207,14 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
{
if (aFactorStarts.size() > 1)
{
// A wrapper whose operand lives in this scope closes inside
// the parenthesis that ends the scope.
lclCloseSingleValues(rBuffer, aPendingSingles, sal_Int32(aFactorStarts.size()));
// The union list closes before the @ wrappers, so its
// parentheses sit inside theirs.
const sal_Int32 nScope = sal_Int32(aFactorStarts.size());
const bool bMayTakeScopeParenthesis = !lclHasWrapperAtStart(
aPendingSingles, nScope, aUnionScopes.back().mnContentStart);
lclCloseUnionList(rBuffer, aUnionScopes.back(), aPendingSingles,
bMayTakeScopeParenthesis);
lclCloseSingleValues(rBuffer, aPendingSingles, nScope);
}
if (!AppendTokenOrError(rBuffer, t))
{
@@ -3137,21 +3223,27 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
}
if (aFactorStarts.size() > 1)
aFactorStarts.pop_back();
if (aUnionScopes.size() > 1)
aUnionScopes.pop_back();
lclMarkPendingOperand(aPendingSingles);
bOperandPosition = false;
bBadFactor = false;
continue;
}
// An operator the grammar puts above the @ ends its operand, so the
// wrapper closes in front of it.
if (!bPrefix && !bCallName && lclEndsSingleValueOperand(eOpHere))
// An operator the grammar puts above them ends the union list and the
// @ operand, so their wrappers close in front of it.
if (bEndsOperand)
{
lclCloseUnionList(rBuffer, aUnionScopes.back(), aPendingSingles, false);
lclCloseSingleValues(rBuffer, aPendingSingles, sal_Int32(aFactorStarts.size()));
}
if (eOpHere == ocBad)
{
// Raw text that no parse could read cannot take parentheses, so
// the @ markers waiting on it are left out.
// the wrappers waiting on it are left out.
aUnionScopes.back().mnListStart = -1;
lclCancelSingleValues(aPendingSingles, sal_Int32(aFactorStarts.size()));
bBadFactor = true;
}
@@ -3170,6 +3262,18 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
// outside the # wrapper of the operand it applies to.
aFactorStarts.back() = rBuffer.getLength();
}
if (eOpHere == ocUnion && aUnionScopes.back().mnListStart < 0)
{
// The first union operator of the expression opens the list; the
// ones after it keep extending the same list.
aUnionScopes.back().mnListStart = aUnionScopes.back().mnExpressionStart;
}
if (bPrefix || (bEndsOperand && bSeparator))
{
// The next expression begins behind a prefix sign or an infix
// operator. A postfix one follows its operand instead.
aUnionScopes.back().mnExpressionStart = rBuffer.getLength();
}
if (bSeparator)
bBadFactor = false;
if (!bWhitespace)
@@ -3182,9 +3286,14 @@ void FormulaCompiler::CreateStringFromTokenArray( OUStringBuffer& rBuffer )
}
// Whatever is still pending wraps up to the end of the formula, unless the
// formula came out as a single error string.
// formula came out as a single error string. The union lists close first, so
// their parentheses sit inside the @ wrappers.
if (FormulaGrammar::isOOXML(meGrammar) && !bUnwritable)
{
for (UnionScope& rScope : std::views::reverse(aUnionScopes))
lclCloseUnionList(rBuffer, rScope, aPendingSingles, false);
lclCloseSingleValues(rBuffer, aPendingSingles, 1);
}
if (pSaveArr != mpArr)
{
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body>
<office:spreadsheet>
<table:table table:name="Sheet1">
<table:table-column table:number-columns-repeated="6"/>
<table:table-row>
<table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell>
<table:table-cell/>
<table:table-cell office:value-type="float" office:value="10"><text:p>10</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="20"><text:p>20</text:p></table:table-cell>
<table:table-cell table:formula="of:=SUM([.A1]~[.B2])" office:value-type="float" office:value="5"/>
</table:table-row>
<table:table-row>
<table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="4"><text:p>4</text:p></table:table-cell>
<table:table-cell/>
<table:table-cell office:value-type="float" office:value="11"><text:p>11</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="21"><text:p>21</text:p></table:table-cell>
<table:table-cell table:formula="of:=AREAS([.A1:.A2]~[.B1:.B2])" office:value-type="float" office:value="2"/>
</table:table-row>
<table:table-row>
<table:table-cell table:number-columns-repeated="3"/>
<table:table-cell office:value-type="float" office:value="12"><text:p>12</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="22"><text:p>22</text:p></table:table-cell>
<table:table-cell table:formula="of:=INDEX([.A1:.B3]~[.D1:.E3];1;1;2)" office:value-type="float" office:value="10"/>
</table:table-row>
</table:table>
</office:spreadsheet>
</office:body>
</office:document>
+37
View File
@@ -1418,6 +1418,43 @@ CPPUNIT_TEST_FIXTURE(ScExportTest4, testSingleOnSpilledRangeXlsxExport)
u"_xlfn.SINGLE(_xlfn.ANCHORARRAY(A1))");
}
CPPUNIT_TEST_FIXTURE(ScExportTest4, testReferenceUnionXlsxRoundTrip)
{
// OOXML spells the union operator with the same comma that separates
// arguments, so a union saves as a parenthesized list that stays one
// argument and survives the round trip.
// F1:F3 hold =SUM(A1~B2), =AREAS(A1:A2~B1:B2) and
// =INDEX(A1:B3~D1:E3;1;1;2).
createScDoc("fods/reference-union.fods");
ScDocument* pDoc = getScDoc();
CPPUNIT_ASSERT_EQUAL(5.0, pDoc->GetValue(ScAddress(5, 0, 0)));
CPPUNIT_ASSERT_EQUAL(2.0, pDoc->GetValue(ScAddress(5, 1, 0)));
CPPUNIT_ASSERT_EQUAL(10.0, pDoc->GetValue(ScAddress(5, 2, 0)));
save(TestFilter::XLSX);
xmlDocUniquePtr pSheet = parseExport(u"xl/worksheets/sheet1.xml"_ustr);
CPPUNIT_ASSERT(pSheet);
assertXPathContent(pSheet, "/x:worksheet/x:sheetData/x:row[1]/x:c[@r='F1']/x:f",
u"SUM((A1,B2))");
assertXPathContent(pSheet, "/x:worksheet/x:sheetData/x:row[2]/x:c[@r='F2']/x:f",
u"AREAS((A1:A2,B1:B2))");
assertXPathContent(pSheet, "/x:worksheet/x:sheetData/x:row[3]/x:c[@r='F3']/x:f",
u"INDEX((A1:B3,D1:E3),1,1,2)");
// On reload each union is still one argument, now inside the parentheses
// the save gave it, and the values are unchanged.
saveAndReload(TestFilter::XLSX);
pDoc = getScDoc();
CPPUNIT_ASSERT_EQUAL(u"=SUM((A1~B2))"_ustr, pDoc->GetFormula(5, 0, 0));
CPPUNIT_ASSERT_EQUAL(u"=AREAS((A1:A2~B1:B2))"_ustr, pDoc->GetFormula(5, 1, 0));
CPPUNIT_ASSERT_EQUAL(u"=INDEX((A1:B3~D1:E3),1,1,2)"_ustr, pDoc->GetFormula(5, 2, 0));
CPPUNIT_ASSERT_EQUAL(5.0, pDoc->GetValue(ScAddress(5, 0, 0)));
CPPUNIT_ASSERT_EQUAL(2.0, pDoc->GetValue(ScAddress(5, 1, 0)));
CPPUNIT_ASSERT_EQUAL(10.0, pDoc->GetValue(ScAddress(5, 2, 0)));
}
CPPUNIT_PLUGIN_IMPLEMENT();
/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
+103 -1
View File
@@ -6972,9 +6972,13 @@ CPPUNIT_TEST_FIXTURE(TestFormula2, testImplicitIntersectionTakesOperandWhole)
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE(-myrange)"_ustr,
resolveAndPrintAsOoxml(*m_pDoc, u"-myrange"_ustr));
// An inline matrix reaches the writer as one push, brackets and all.
// An inline matrix reaches the writer as one push, brackets and all. Its column
// separator is a comma, which does not turn a parenthesized matrix into a union
// list, so the group's own pair still serves as the wrapper's.
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE({1,2})"_ustr,
resolveAndPrintAsOoxml(*m_pDoc, u"{1;2}"_ustr));
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE({1,2})"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"@({1;2})"_ustr));
// Whitespace between the @ and its operand goes inside the wrapper.
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE( A1:A3)"_ustr,
@@ -7053,6 +7057,94 @@ CPPUNIT_TEST_FIXTURE(TestFormula2, testSpilledRangeBindsTighterThanSignAndMarker
m_pDoc->DeleteTab(0);
}
CPPUNIT_TEST_FIXTURE(TestFormula2, testUnionSavesAsParenthesizedListInOoxml)
{
// OOXML spells the union operator with the same comma that separates arguments,
// so a union expression takes parentheses of its own to stay one argument.
m_pDoc->InsertTab(0, u"Sheet1"_ustr);
CPPUNIT_ASSERT_EQUAL(u"(A1,B2)"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"A1~B2"_ustr));
// One pair encloses the whole list, however many parts it has.
CPPUNIT_ASSERT_EQUAL(u"(A1,B2,C3)"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"A1~B2~C3"_ustr));
CPPUNIT_ASSERT_EQUAL(u"SUM((A1,B2))"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"SUM(A1~B2)"_ustr));
CPPUNIT_ASSERT_EQUAL(u"INDEX((A1:B3,D1:E3),1,1,2)"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"INDEX(A1:B3~D1:E3;1;1;2)"_ustr));
// AND and OR open an argument list too, although their opcodes are operators.
CPPUNIT_ASSERT_EQUAL(u"AND((A1,B2))"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"AND(A1~B2)"_ustr));
CPPUNIT_ASSERT_EQUAL(u"OR((A1,B2))"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"OR(A1~B2)"_ustr));
// A sign applies to the whole union, so the list's parentheses sit inside it.
CPPUNIT_ASSERT_EQUAL(u"-(A1,B2)"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"-A1~B2"_ustr));
// The list ends where an operator of looser binding follows.
CPPUNIT_ASSERT_EQUAL(u"(1+(A1,B2))"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"(1+A1~B2)"_ustr));
// Not doubling the pair is what holds a formula's shape over round trips.
CPPUNIT_ASSERT_EQUAL(u"(A1,B2)"_ustr, compileAndPrintAsOoxml(*m_pDoc, u"(A1~B2)"_ustr));
CPPUNIT_ASSERT_EQUAL(u"SUM((A1,B2))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"SUM((A1~B2))"_ustr));
m_pDoc->DeleteTab(0);
}
CPPUNIT_TEST_FIXTURE(TestFormula2, testUnionStaysInsideImplicitIntersectionWrapper)
{
// The union binds tighter than the @, so the parenthesized list sits inside the
// _xlfn.SINGLE wrapper and keeps the wrapper to one argument.
m_pDoc->InsertTab(0, u"Sheet1"_ustr);
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE((A1,B2))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"@A1~B2"_ustr));
CPPUNIT_ASSERT_EQUAL(u"_xlfn.SINGLE((A1,B2))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"@(A1~B2)"_ustr));
CPPUNIT_ASSERT_EQUAL(u"SUM(_xlfn.SINGLE((A1,B2)))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"SUM(@A1~B2)"_ustr));
// With the @ inside the group, one pair of parentheses cannot serve both, so
// the list takes a pair of its own.
CPPUNIT_ASSERT_EQUAL(u"(_xlfn.SINGLE((A1,B2)))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"(@A1~B2)"_ustr));
CPPUNIT_ASSERT_EQUAL(u"SUM((_xlfn.SINGLE((A1,B2))))"_ustr,
compileAndPrintAsOoxml(*m_pDoc, u"SUM((@A1~B2))"_ustr));
m_pDoc->DeleteTab(0);
}
CPPUNIT_TEST_FIXTURE(TestFormula2, testUnionParenthesesEncloseExactlyTheirList)
{
// The list's opening parenthesis moves the text behind it along, and a wrapper
// spliced afterwards still lands where its own operand begins.
m_pDoc->InsertTab(0, u"Sheet1"_ustr);
ScSingleRefData aFirst;
aFirst.InitAddress(ScAddress(0, 0, 0));
ScSingleRefData aSecond;
aSecond.InitAddress(ScAddress(1, 1, 0));
// An @ behind the union operator, a shape no parse of a formula produces.
ScTokenArray aMarkerInList(*m_pDoc);
aMarkerInList.AddSingleReference(aFirst);
aMarkerInList.AddOpCode(ocUnion);
aMarkerInList.AddOpCode(ocSingleValue);
aMarkerInList.AddSingleReference(aSecond);
CPPUNIT_ASSERT_EQUAL(u"($A$1,_xlfn.SINGLE($B$2))"_ustr,
printAsOoxml(*m_pDoc, aMarkerInList, ScAddress(1, 0, 0)));
// A percent sign in front of the union operator, likewise. The sign follows the
// first part of the list, so the list still begins in front of that part.
ScTokenArray aPercentInList(*m_pDoc);
aPercentInList.AddSingleReference(aFirst);
aPercentInList.AddOpCode(ocPercentSign);
aPercentInList.AddOpCode(ocUnion);
aPercentInList.AddSingleReference(aSecond);
CPPUNIT_ASSERT_EQUAL(u"($A$1%,$B$2)"_ustr,
printAsOoxml(*m_pDoc, aPercentInList, ScAddress(1, 0, 0)));
m_pDoc->DeleteTab(0);
}
CPPUNIT_TEST_FIXTURE(TestFormula2, testWrappersDroppedFromTextThatDidNotParse)
{
// Text that no parse could read cannot take parentheses, so every wrapper around
@@ -7082,6 +7174,16 @@ CPPUNIT_TEST_FIXTURE(TestFormula2, testWrappersDroppedFromTextThatDidNotParse)
CPPUNIT_ASSERT_EQUAL(u"PW value"_ustr, printAsOoxml(*m_pDoc, aSpilled, ScAddress(1, 0, 0)));
// A union list that reaches such text goes without its parentheses too.
ScTokenArray aUnion(*m_pDoc);
ScSingleRefData aReference;
aReference.InitAddress(ScAddress(0, 0, 0));
aUnion.AddSingleReference(aReference);
aUnion.AddOpCode(ocUnion);
aUnion.AddBad(u"PW value"_ustr);
CPPUNIT_ASSERT_EQUAL(u"$A$1,PW value"_ustr, printAsOoxml(*m_pDoc, aUnion, ScAddress(1, 0, 0)));
m_pDoc->DeleteTab(0);
}