sw/qa/extras/layout/data/nbsp-moved-to-new-line.fodt |   28 +++++++++++++
 sw/qa/extras/layout/layout6.cxx                      |   39 +++++++++++++++++++
 sw/source/core/text/guess.cxx                        |    3 -
 sw/source/core/text/porexp.cxx                       |    4 -
 sw/source/core/text/porexp.hxx                       |    2 
 5 files changed, 72 insertions(+), 4 deletions(-)

New commits:
commit e3c068cb190e6cf04a49f9984f984d5ef72f9d9a
Author:     Mike Kaganski <[email protected]>
AuthorDate: Sat Feb 7 14:57:33 2026 +0500
Commit:     Mike Kaganski <[email protected]>
CommitDate: Sat Feb 7 12:15:09 2026 +0100

    tdf#167946: reimplement the fix for tdf#120677
    
    Commit 4bb28ad217ea9d6511b6921dcd3d28328edcb4d6 (tdf#120677: restore
    treatment of blanks in SwTextGuess::Guess, 2018-11-12) made NBSPs
    behave like ordinary spaces for line breaking purposes. But that is
    wrong; NBSPs are explicitly defined in UAX #14 to behave differently.
    
    It turns out, that the problem in tdf#120677 was caused by specifics
    of handling of SwBlankPortion, which was defined as descendant of
    SwExpandPortion (since initial import). SwExpandPortion::Format uses
    special logic to change the passed SwTextFormatInfo using SwTextSlot,
    to allow formatting text that is different from what was initially
    passed to SwTextFormatInfo. This logic isn't needed for blanks, and
    happens to confuse the normal line-breaking algorithm to return zero
    for blank portions with NBSP. SwExpandPortion::Paint method, also
    previously used by SwBlankPortion, doesn't seem to include any logic
    specifically useful for blanks as well, and respective methods of
    SwTextPortion can be used instead.
    
    This change reverts the functional change of the previous fix for
    tdf#120677 (the unit test is kept), and makes SwBlankPortion inherit
    from SwTextPortion, which fixes both tdf#120677 and tdf#167946.
    
    Change-Id: I9109069093a7f58a997a93723f81137d1a6bd7f8
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/198882
    Reviewed-by: Mike Kaganski <[email protected]>
    Tested-by: Jenkins

diff --git a/sw/qa/extras/layout/data/nbsp-moved-to-new-line.fodt 
b/sw/qa/extras/layout/data/nbsp-moved-to-new-line.fodt
new file mode 100644
index 000000000000..63ad3180eca5
--- /dev/null
+++ b/sw/qa/extras/layout/data/nbsp-moved-to-new-line.fodt
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<office:document 
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" 
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" 
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" 
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" 
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" 
office:version="1.4" office:mimetype="application/vnd.oasis.opendocument.text">
+ <office:font-face-decls>
+  <style:font-face style:name="Liberation Serif" 
svg:font-family="&apos;Liberation Serif&apos;" 
style:font-family-generic="roman" style:font-pitch="variable"/>
+ </office:font-face-decls>
+ <office:styles>
+  <style:default-style style:family="paragraph">
+   <style:paragraph-properties style:text-autospace="ideograph-alpha" 
style:punctuation-wrap="hanging" style:line-break="strict" 
style:writing-mode="page"/>
+   <style:text-properties style:font-name="Liberation Serif" 
fo:font-size="12pt" fo:hyphenate="false"/>
+  </style:default-style>
+  <style:style style:name="Standard" style:family="paragraph" 
style:class="text"/>
+ </office:styles>
+ <office:automatic-styles>
+  <style:page-layout style:name="pm1">
+   <style:page-layout-properties fo:page-width="210mm" fo:page-height="297mm" 
style:print-orientation="portrait" fo:margin-top="20mm" fo:margin-bottom="20mm" 
fo:margin-left="20mm" fo:margin-right="20mm" style:writing-mode="lr-tb"/>
+  </style:page-layout>
+ </office:automatic-styles>
+ <office:master-styles>
+  <style:master-page style:name="Standard" style:page-layout-name="pm1"/>
+ </office:master-styles>
+ <office:body>
+  <office:text>
+   <!-- The first space after "elit." is a normal space; all the following up 
to comma are NBSPs -->
+   <text:p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.            
                                                                     ,</text:p>
+  </office:text>
+ </office:body>
+</office:document>
\ No newline at end of file
diff --git a/sw/qa/extras/layout/layout6.cxx b/sw/qa/extras/layout/layout6.cxx
index 4f88d7392943..859515ff48f3 100644
--- a/sw/qa/extras/layout/layout6.cxx
+++ b/sw/qa/extras/layout/layout6.cxx
@@ -2113,6 +2113,45 @@ CPPUNIT_TEST_FIXTURE(SwLayoutWriter6, testTdf170630)
     assertXPath(pXmlDoc, "//page[1]/body/txt[3]/anchored/fly", 2);
 }
 
+CPPUNIT_TEST_FIXTURE(SwLayoutWriter6, testTdf167946)
+{
+    // Given a document with a sequence of non-breaking spaces after a text 
and a space, which
+    // sequence doesn't fit the rest of the line. Before the fix, only the 
comma was moved to the
+    // second line, and all NBSPs were elided as a hole portion at the end of 
the first line:
+    createSwDoc("nbsp-moved-to-new-line.fodt");
+
+    xmlDocUniquePtr pXmlDoc = parseLayoutDump();
+    CPPUNIT_ASSERT(pXmlDoc);
+
+    // One page:
+    assertXPath(pXmlDoc, "//page", 1);
+
+    // One paragraph:
+    assertXPath(pXmlDoc, "//txt", 1);
+
+    // Two lines in the paragraph:
+    assertXPath(pXmlDoc, "//txt/SwParaPortion/SwLineLayout", 2);
+    assertXPath(pXmlDoc, "//SwLineLayout", 2);
+
+    // First line consists of a text portion with, and a hole portion for the 
following space:
+    assertXPath(pXmlDoc, "//SwLineLayout[1]/child::*[1]", "type", 
u"PortionType::Text");
+    assertXPath(pXmlDoc, "//SwLineLayout[1]/child::*[1]", "portion",
+                u"Lorem ipsum dolor sit amet, consectetur adipiscing elit.");
+    assertXPath(pXmlDoc, "//SwLineLayout[1]/child::*[2]", "type", 
u"PortionType::Hole");
+    assertXPath(pXmlDoc, "//SwLineLayout[1]/child::*[2]", "portion", u" ");
+
+    // Second line has blank portions for the leading NBSPs, followed by a 
text portion with comma:
+    assertXPathChildren(pXmlDoc, "//SwLineLayout[2]", 81);
+    for (int i = 1; i <= 80; ++i)
+    {
+        OString aXPath = "//SwLineLayout[2]/child::*[" + OString::number(i) + 
"]";
+        assertXPath(pXmlDoc, aXPath, "type", u"PortionType::Blank");
+        assertXPath(pXmlDoc, aXPath, "portion", u"\xA0");
+    }
+    assertXPath(pXmlDoc, "//SwLineLayout[2]/child::*[81]", "type", 
u"PortionType::Text");
+    assertXPath(pXmlDoc, "//SwLineLayout[2]/child::*[81]", "portion", u",");
+}
+
 } // end of anonymous namespace
 
 CPPUNIT_PLUGIN_IMPLEMENT();
diff --git a/sw/source/core/text/guess.cxx b/sw/source/core/text/guess.cxx
index 7f38f06c578f..3480d54d0ea6 100644
--- a/sw/source/core/text/guess.cxx
+++ b/sw/source/core/text/guess.cxx
@@ -43,7 +43,8 @@ using namespace ::com::sun::star::linguistic2;
 
 namespace{
 
-bool IsBlank(sal_Unicode ch) { return ch == CH_BLANK || ch == CH_FULL_BLANK || 
ch == CH_NB_SPACE || ch == CH_SIX_PER_EM; }
+// UAX #14: spaces from SP and BA classes (elided in the end of a line)
+bool IsBlank(sal_Unicode ch) { return ch == CH_BLANK || ch == CH_FULL_BLANK || 
ch == CH_SIX_PER_EM; }
 
 // Used when spaces should not be counted in layout
 // Returns adjusted cut position
diff --git a/sw/source/core/text/porexp.cxx b/sw/source/core/text/porexp.cxx
index afdf71da21c0..a9bb3add21b0 100644
--- a/sw/source/core/text/porexp.cxx
+++ b/sw/source/core/text/porexp.cxx
@@ -195,7 +195,7 @@ void SwBlankPortion::FormatEOL( SwTextFormatInfo &rInf )
  */
 bool SwBlankPortion::Format( SwTextFormatInfo &rInf )
 {
-    const bool bFull = rInf.IsUnderflow() || SwExpandPortion::Format( rInf );
+    const bool bFull = rInf.IsUnderflow() || SwTextPortion::Format(rInf);
     if( bFull && MayUnderflow( rInf, rInf.GetIdx(), rInf.IsUnderflow() ) )
     {
         Truncate();
@@ -211,7 +211,7 @@ void SwBlankPortion::Paint( const SwTextPaintInfo &rInf ) 
const
     // Draw field shade (can be disabled individually)
     if (!m_bMulti) // No gray background for multiportion brackets
         rInf.DrawViewOpt(*this, PortionType::Blank);
-    SwExpandPortion::Paint(rInf);
+    SwTextPortion::Paint(rInf);
 
     if (rInf.GetOpt().IsViewMetaChars() && rInf.GetOpt().IsHardBlank())
     {
diff --git a/sw/source/core/text/porexp.hxx b/sw/source/core/text/porexp.hxx
index baf45c7cf2dd..22b1347f78c3 100644
--- a/sw/source/core/text/porexp.hxx
+++ b/sw/source/core/text/porexp.hxx
@@ -39,7 +39,7 @@ public:
 };
 
 /// Non-breaking space or non-breaking hyphen.
-class SwBlankPortion : public SwExpandPortion
+class SwBlankPortion final : public SwTextPortion
 {
     sal_Unicode m_cChar;
     bool m_bMulti;        // For multiportion brackets

Reply via email to