tdf#171959 sw: keep trailing blank decorations inside the text area

Commit b36f181bb7 (tdf#168777: Trailing
space must show all decorations, also for ODF, 2025-10-11) made the
trailing blank of a line show its underline by default. In a justified
paragraph that underline is drawn past the right margin.

A line's trailing blank lives in a hole portion, which deliberately takes
no part in the layout (see 853e13f914,
tdf#57187: make sure to put trailing blanks to hole portion in narrow
lines, 2023-12-30), so it hangs outside the line. When the blank is
formatted, its decorated part is limited to the room then left on the
line - but adjustment happens afterwards, and justification hands exactly
that room to the glue between the words. The blank ends up beyond the
text area with its decorations still on. Right-aligned text has the same
problem, and centred text has it on lines that nearly fill the width.

Reconsider the decorated part once the line is adjusted. The room still
available is the width of the trailing margin portion(s), which is where
adjustment parks whatever is left of the line, so no coordinates are
needed. Lines that need no adjustment at all - left-aligned ones - get no
margin portion, and keep the room they were formatted against.

Where the room no longer fits the whole blank, the hole is split: the
first part is decorated and as wide as the room, a plain hole carries the
rest. A hole holds nothing but blanks, so the characters divide between
the two in proportion to the width, and the cursor still travels over the
whole blank.

The blank also took a share of the justification stretch while being
painted, which made the overshoot about twice the width of a space. It
is not part of the line's content, so exclude it.

Signed-off-by: Mike Kaganski <mike.kaganski@collabora.com>
Change-Id: I82dfe55a10f7b15969bc4561f8f2f04eb7eeee52
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/7834
Reviewed-by: Caolán McNamara <caolan.mcnamara@collabora.com>
Tested-by: Jenkins CPCI <releng@collaboraoffice.com>
(cherry picked from commit 6ebdd28de79e412501152305b48a64d25b3609c5)
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/208741
Tested-by: Xisco Fauli <xiscofauli@libreoffice.org>
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
This commit is contained in:
Mike Kaganski
2026-07-28 20:02:15 +02:00
committed by Xisco Fauli
parent 0aeb164f0f
commit 9e11a7494b
6 changed files with 159 additions and 1 deletions
Binary file not shown.
Binary file not shown.
+78
View File
@@ -11,7 +11,9 @@
#include <comphelper/propertysequence.hxx>
#include <com/sun/star/linguistic2/XHyphenator.hpp>
#include <com/sun/star/text/XTextSectionsSupplier.hpp>
#include <vcl/gdimtf.hxx>
#include <vcl/scheduler.hxx>
#include <editeng/adjustitem.hxx>
#include <editeng/unolingu.hxx>
#include <editeng/editobj.hxx>
#include <comphelper/propertyvalue.hxx>
@@ -1987,6 +1989,82 @@ CPPUNIT_TEST_FIXTURE(SwLayoutWriter4, TestTdf164098)
pWrtShell->Insert(u"ـ"_ustr);
}
CPPUNIT_TEST_FIXTURE(SwLayoutWriter4, testTdf171959)
{
// Every line of this document ends in a blank, and the text is justified and underlined.
createSwDoc("tdf171959.odt");
// Whether the trailing blank is decorated can only be settled once the line has been adjusted,
// which happens on the way to painting.
std::shared_ptr<GDIMetaFile> xMetaFile = getSwDocShell()->GetPreviewMetaFile();
// Justification stretches each line to the right margin, leaving no room for the trailing
// blank, which hangs outside the text area. Without the fix the blank kept its underline, so
// the underline was drawn past the margin.
xmlDocUniquePtr pLayout = parseLayoutDump();
assertXPath(pLayout, "//SwHolePortion", 7);
assertXPath(pLayout, "//SwHolePortion[@show-underline='true']", 0);
// The blank is still painted (tdf#43244), only without decorations.
MetafileXmlDump dumper;
xmlDocUniquePtr pMetaXml = dumpAndParse(dumper, *xMetaFile);
assertXPath(pMetaXml, "//textarray[@length='1']", 7);
assertXPath(pMetaXml, "//textarray[@length='1'][preceding-sibling::font[1]/@underline!='0']",
0);
}
CPPUNIT_TEST_FIXTURE(SwLayoutWriter4, testTdf171959Centered)
{
// Same document, but centred: here the trailing blank has the right half of the free space to
// live in, so its decorations may show as far as that reaches.
createSwDoc("tdf171959-center.odt");
getSwDocShell()->GetPreviewMetaFile();
xmlDocUniquePtr pLayout = parseLayoutDump();
// A line with 365 twips to spare decorates the whole 60 twip blank.
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[2]/SwHolePortion", "width", u"60");
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[2]/SwHolePortion", "show-underline",
u"true");
// A line with only 49 twips left decorates that much, and an undecorated hole carries the rest
// of the blank, so the cursor still travels over all of it.
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[5]/SwHolePortion[1]", "width", u"49");
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[5]/SwHolePortion[1]",
"show-underline", u"true");
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[5]/SwHolePortion[2]", "width", u"11");
assertXPath(pLayout, "//txt[1]/SwParaPortion/SwLineLayout[5]/SwHolePortion[2]",
"show-underline", u"false");
}
CPPUNIT_TEST_FIXTURE(SwLayoutWriter4, testTdf171959ManyBlanks)
{
// A centred line ending in many blanks: only the right half of the free space is inside the
// text area, so roughly half of the blanks may keep the decoration.
createSwDoc();
SwWrtShell* pWrtShell = getSwDocShell()->GetWrtShell();
pWrtShell->SetAttrItem(SvxAdjustItem(SvxAdjust::Center, RES_PARATR_ADJUST));
pWrtShell->Insert("foo" + RepeatedUChar(' ', 200));
getSwDocShell()->GetPreviewMetaFile();
xmlDocUniquePtr pLayout = parseLayoutDump();
// The blank was already too long for the line, so it had a plain hole after it: what no longer
// fits the text area joins that one instead of making a third.
assertXPath(pLayout, "//SwLineLayout/SwHolePortion", 2);
assertXPath(pLayout, "//SwLineLayout/SwHolePortion[1]", "show-underline", u"true");
assertXPath(pLayout, "//SwLineLayout/SwHolePortion[2]", "show-underline", u"false");
// The blanks are shared out, not squeezed into the decorated part, and none go missing.
const sal_Int32 nLen1
= getXPath(pLayout, "//SwLineLayout/SwHolePortion[1]", "length").toInt32();
const sal_Int32 nLen2
= getXPath(pLayout, "//SwLineLayout/SwHolePortion[2]", "length").toInt32();
CPPUNIT_ASSERT_GREATER(sal_Int32(1), nLen1);
CPPUNIT_ASSERT_GREATER(sal_Int32(0), nLen2);
CPPUNIT_ASSERT_EQUAL(sal_Int32(200), nLen1 + nLen2);
}
} // end of anonymous namespace
CPPUNIT_PLUGIN_IMPLEMENT();
+4 -1
View File
@@ -701,8 +701,11 @@ void SwTextPaintInfo::DrawText_( const OUString &rText, const SwLinePortion &rPo
aDrawInf.SetUnderFnt( m_pUnderFnt );
// tdf#171959: a hole portion carries the trailing blank that hangs outside the line, so it
// takes no part in the justification of the line's content
const tools::Long nSpaceAdd = ( rPor.IsBlankPortion() || rPor.IsDropPortion() ||
rPor.InNumberGrp() ) ? 0 : GetSpaceAdd(/*bShrink=*/true);
rPor.InNumberGrp() || rPor.IsHolePortion() )
? 0 : GetSpaceAdd(/*bShrink=*/true);
if ( nSpaceAdd )
{
TextFrameIndex nCharCnt(0);
+76
View File
@@ -17,6 +17,11 @@
* the License at http://www.apache.org/licenses/LICENSE-2.0 .
*/
#include <algorithm>
#include <optional>
#include <o3tl/safeint.hxx>
#include <ndtxt.hxx>
#include <doc.hxx>
#include <paratr.hxx>
@@ -40,6 +45,7 @@
#include "pordrop.hxx"
#include <crstate.hxx>
#include "pormulti.hxx"
#include "portxt.hxx"
#include <numrule.hxx>
#include <com/sun/star/i18n/ScriptType.hpp>
@@ -437,6 +443,75 @@ static bool isTrailingDecoration(SwLinePortion* p)
return true; // no more portions
}
// How much of the text area is still free after this portion: an adjusted line parks whatever is
// left of it in the trailing margin portion(s). Lines that need no adjustment - left-aligned ones -
// get no margin portion at all, and keep the room they were formatted against; those answer none.
static std::optional<SwTwips> calcTrailingRoom(const SwLinePortion* p)
{
std::optional<SwTwips> oRoom;
for (; p; p = p->GetNextPortion())
if (p->IsMarginPortion())
oRoom = oRoom.value_or(0) + p->Width();
return oRoom;
}
// tdf#171959: The trailing blank deliberately hangs outside the line (tdf#57187), so once the line
// is adjusted it can sit past the text area - and its decorations would then be drawn out there.
// Keep them inside by decorating only as much as still fits, and letting a plain hole carry the
// rest of the blank, so the cursor can still be moved over all of it.
static void limitTrailingDecoration(SwLinePortion& rPor, const SwLinePortion* pNext)
{
if (!rPor.IsHolePortion())
return;
auto& rHole = static_cast<SwHolePortion&>(rPor);
if (!rHole.ShowUnderline())
return;
const std::optional<SwTwips> oRoom = calcTrailingRoom(pNext);
if (!oRoom || *oRoom >= rHole.Width())
return;
if (*oRoom <= 0)
{
rHole.SetShowUnderline(false);
return;
}
// A hole holds nothing but blanks, so its characters divide up in proportion to its width.
// Keeping one is the least that makes sense: the decorated part exists to be decorated.
const SwTwips nWidth = rHole.Width();
const sal_Int32 nCount = sal_Int32(rHole.GetLen());
// The room is short of the width, so the share of the blanks that fits cannot exceed them all
const sal_Int32 nFits
= o3tl::narrowing<sal_Int32>((nCount * *oRoom + nWidth / 2) / nWidth);
const TextFrameIndex nKeep(std::max<sal_Int32>(1, nFits));
const TextFrameIndex nMoved(rHole.GetLen() - nKeep);
const SwTwips nExcess(nWidth - *oRoom);
rHole.SetLen(nKeep);
rHole.Width(*oRoom);
// A blank too long for the line already has a plain hole after it, holding what did not fit.
// The part that no longer fits the text area belongs there too; only make a hole of its own
// when there is none. Note the follower has not been given its width yet, so hand it over as
// extra blank width, the way formatting left it.
SwLinePortion* pFollow = rHole.GetNextPortion();
if (pFollow && pFollow->IsHolePortion()
&& !static_cast<SwHolePortion*>(pFollow)->ShowUnderline())
{
pFollow->SetLen(pFollow->GetLen() + nMoved);
pFollow->ExtraBlankWidth(pFollow->ExtraBlankWidth() + nExcess);
return;
}
SwHolePortion* pRest = new SwHolePortion(rHole);
pRest->SetShowUnderline(false);
pRest->SetLen(nMoved);
pRest->Width(nExcess);
rHole.Insert(pRest);
}
// tdf#120715 tdf#43100: Make width for some HolePortions, so cursor will be able to move into it.
// It should not change the layout, so this should be called after the layout is calculated.
void SwTextCursor::AddExtraBlankWidth()
@@ -451,6 +526,7 @@ void SwTextCursor::AddExtraBlankWidth()
{
pPos->Width(pPos->Width() + pPos->ExtraBlankWidth());
pPos->ExtraBlankWidth(0);
limitTrailingDecoration(*pPos, pNextPos);
}
pPos = pNextPos;
}
+1
View File
@@ -79,6 +79,7 @@ public:
virtual void Paint( const SwTextPaintInfo &rInf ) const override;
bool ShowUnderline() const { return m_bShowUnderline; }
void SetShowUnderline(bool bNew) { m_bShowUnderline = bNew; }
// Accessibility: pass information about this portion to the PortionHandler
virtual void HandlePortion( SwPortionHandler& rPH ) const override;