cui/source/dialogs/SpellDialog.cxx                   |   23 ++++++++++++++++---
 i18npool/qa/cppunit/test_breakiterator.cxx           |   18 ++++++++++++++
 lingucomponent/source/spellcheck/spell/sspellimp.cxx |   23 +++++++++++++++----
 linguistic/source/dicimp.cxx                         |   13 +++++++++-
 sw/qa/extras/layout/layout2.cxx                      |   20 ++++++++++++++++
 sw/source/uibase/lingu/olmenu.cxx                    |   18 ++++++++++++++
 sw/source/uibase/shells/textsh1.cxx                  |   21 ++++++++++++++---
 7 files changed, 122 insertions(+), 14 deletions(-)

New commits:
commit 54b14742892638e1602bcbe5712a3d48163d4c9e
Author:     László Németh <[email protected]>
AuthorDate: Sun Dec 28 05:04:17 2025 +0100
Commit:     Xisco Fauli <[email protected]>
CommitDate: Mon Dec 29 17:24:53 2025 +0100

    tdf#130695 sw linguistic: fix custom abbreviations
    
    linguistic: Abbreviations in the custom dictionary
    were accepted without the terminating dot, too
    (because bSimilarOnly comparison used by getEntry()
    removes all terminating dots during the dictionary
    look up.)
    
    Because "Add to [custom] dictionary" always removed
    the terminating dot, and direct editing of the custom
    dictionaries is still not so comfortable, the following
    user-friendly options were implemented for the
    abbreviations:
    
    – spell checking dialog: words with multiple dots
      – e.g. F.A.C.S. –, are always added to the custom dictionary
      with the terminating dot to reject the bad abbreviations:
      "e.g", "F.A.C.S" etc.;
    
    – context menu: add optional menu items to the context menu
      of the abbreviations to put the word with the terminating
      dot into the custom dictionaries, e.g. for the word "Corp.":
    
      * standard.dic (Corp)
      * standard.dic (Corp.)
    
      as menu items of the the Add to Dictionary submenu.
    
    Change-Id: I54532c0a63af8effe9f103e75e853703d7d57563
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/196263
    Reviewed-by: László Németh <[email protected]>
    Tested-by: Jenkins
    Signed-off-by: Xisco Fauli <[email protected]>
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/196284

diff --git a/cui/source/dialogs/SpellDialog.cxx 
b/cui/source/dialogs/SpellDialog.cxx
index 381612ebf2e6..e446e9164694 100644
--- a/cui/source/dialogs/SpellDialog.cxx
+++ b/cui/source/dialogs/SpellDialog.cxx
@@ -603,6 +603,23 @@ IMPL_LINK_NOARG(SpellDialog, ChangeHdl, weld::Button&, 
void)
         m_xIgnorePB->grab_focus();
 }
 
+// words with multiple dots are added to the custom
+// dictionary with the terminating dot
+static bool lcl_IsStripDot( const OUString & rWord )
+{
+    // strip the terminating dot, if there is no
+    // an extra dot inside the word, e.g.
+    // put "dots." as "dots" into the dictionary,
+    // but put "F.A.C.S." as "F.A.C.S.", not "F.A.C.S"
+    if ( rWord.endsWith(".") ) {
+        sal_Int32 nDotPos = rWord.indexOf(".");
+        if ( nDotPos == rWord.getLength() - 1 )
+            return true;
+    }
+
+    return false;
+}
+
 IMPL_LINK_NOARG(SpellDialog, ChangeAllHdl, weld::Button&, void)
 {
     auto xGuard(std::make_unique<UndoChangeGroupGuard>(*m_xSentenceED));
@@ -615,7 +632,7 @@ IMPL_LINK_NOARG(SpellDialog, ChangeAllHdl, weld::Button&, 
void)
     Reference<XDictionary> aXDictionary = LinguMgr::GetChangeAllList();
     DictionaryError nAdded = AddEntryToDic( aXDictionary,
             aOldWord, true,
-            aString );
+            aString, lcl_IsStripDot(aOldWord) );
 
     if(nAdded == DictionaryError::NONE)
     {
@@ -661,7 +678,7 @@ IMPL_LINK( SpellDialog, IgnoreAllHdl, weld::Button&, 
rButton, void )
         OUString sErrorText(m_xSentenceED->GetErrorText());
         DictionaryError nAdded = AddEntryToDic( aXDictionary,
             sErrorText, false,
-            OUString() );
+            OUString(), lcl_IsStripDot(sErrorText) );
         if (nAdded == DictionaryError::NONE)
         {
             std::unique_ptr<SpellUndoAction_Impl> pAction(new 
SpellUndoAction_Impl(
@@ -914,7 +931,7 @@ void SpellDialog::AddToDictionaryExecute(const OUString& 
rItemId)
     DictionaryError nAddRes = DictionaryError::UNKNOWN;
     if (xDic.is())
     {
-        nAddRes = AddEntryToDic( xDic, aNewWord, false, OUString() );
+        nAddRes = AddEntryToDic( xDic, aNewWord, false, OUString(), 
lcl_IsStripDot(aNewWord) );
         // save modified user-dictionary if it is persistent
         uno::Reference< frame::XStorable >  xSavDic( xDic, uno::UNO_QUERY );
         if (xSavDic.is())
diff --git a/linguistic/source/dicimp.cxx b/linguistic/source/dicimp.cxx
index 9c243c794933..24f6384cab3e 100644
--- a/linguistic/source/dicimp.cxx
+++ b/linguistic/source/dicimp.cxx
@@ -834,8 +834,19 @@ uno::Reference< XDictionaryEntry > SAL_CALL 
DictionaryNeo::getEntry(
     if (bNeedEntries)
         loadEntries( aMainURL );
 
+    // first search: ignore the dots at the end of the
+    // dictionary words and at the end of the query word
     sal_Int32 nPos;
-    bool bFound = seekEntry( aWord, &nPos, true );
+    bool bFound = seekEntry( aWord, &nPos,/*bSimilarOnly=*/true );
+    // don't accept an abbreviation without the dot, i.e. when the
+    // searched word is without dot, but the dictionary word is there
+    // in the dictionary only with dot
+    if ( bFound && !aWord.endsWith(".") && 
aEntries[nPos]->getDictionaryWord().endsWith(".") )
+    {
+        sal_Int32 nPos2;
+        if ( !seekEntry( aWord, &nPos2, /*bSimilarOnly=*/false ) )
+            bFound = false;
+    }
     DBG_ASSERT(!bFound || nPos < static_cast<sal_Int32>(aEntries.size()), "lng 
: index out of range");
 
     return bFound ? aEntries[ nPos ]
diff --git a/sw/source/uibase/lingu/olmenu.cxx 
b/sw/source/uibase/lingu/olmenu.cxx
index 1bb4dbdd07a3..6ae1056e5289 100644
--- a/sw/source/uibase/lingu/olmenu.cxx
+++ b/sw/source/uibase/lingu/olmenu.cxx
@@ -324,6 +324,13 @@ SwSpellPopup::SwSpellPopup(
 
         m_aDics = xDicList->getDictionaries();
 
+        OUString sWord;
+        if ( m_xSpellAlt.is() )
+            sWord = m_xSpellAlt->getWord();
+        // allow to put the word with terminating dot to the custom 
dictionaries
+        // using optional menu items
+        bool bPossibleAbbreviation = sWord.endsWith(".");
+
         for (const uno::Reference<linguistic2::XDictionary>& rDic : m_aDics)
         {
             uno::Reference< linguistic2::XDictionary >  xDicTmp = rDic;
@@ -339,7 +346,9 @@ SwSpellPopup::SwSpellPopup(
             {
                 // the extra 1 is because of the (possible) external
                 // linguistic entry above
-                pMenu->InsertItem( nItemId, xDicTmp->getName() );
+                pMenu->InsertItem( nItemId, bPossibleAbbreviation
+                        ? xDicTmp->getName() + " (" + sWord.subView(0, 
sWord.getLength()-1) + ")"
+                        : xDicTmp->getName() );
                 m_aDicNameSingle = xDicTmp->getName();
 
                 if (bUseImagesInMenus)
@@ -358,6 +367,13 @@ SwSpellPopup::SwSpellPopup(
                 }
 
                 ++nItemId;
+
+                // add an optional menu item for adding the word with dot to 
this dictionary
+                if ( bPossibleAbbreviation )
+                {
+                    pMenu->InsertItem( nItemId, xDicTmp->getName() + " (" + 
sWord + ")");
+                    ++nItemId;
+                }
             }
         }
     }
diff --git a/sw/source/uibase/shells/textsh1.cxx 
b/sw/source/uibase/shells/textsh1.cxx
index cd26bb553ead..6388271b990b 100644
--- a/sw/source/uibase/shells/textsh1.cxx
+++ b/sw/source/uibase/shells/textsh1.cxx
@@ -927,7 +927,7 @@ bool lcl_DeleteChartColumns(const 
uno::Reference<chart2::XChartDocument>& xChart
 }
 }
 
-static bool AddWordToWordbook(const uno::Reference<linguistic2::XDictionary>& 
xDictionary, SwWrtShell &rWrtSh)
+static bool AddWordToWordbook(const uno::Reference<linguistic2::XDictionary>& 
xDictionary, SwWrtShell &rWrtSh, bool bAddWithDot = false)
 {
     if (!xDictionary)
         return false;
@@ -938,7 +938,7 @@ static bool AddWordToWordbook(const 
uno::Reference<linguistic2::XDictionary>& xD
         return false;
 
     OUString sWord = xSpellAlt->getWord();
-    linguistic::DictionaryError nAddRes = 
linguistic::AddEntryToDic(xDictionary, sWord, false, OUString());
+    linguistic::DictionaryError nAddRes = 
linguistic::AddEntryToDic(xDictionary, sWord, false, OUString(), !bAddWithDot);
     if (linguistic::DictionaryError::NONE != nAddRes && xDictionary.is() && 
!xDictionary->getEntry(sWord).is())
     {
         SvxDicError(rWrtSh.GetView().GetFrameWeld(), nAddRes);
@@ -2377,7 +2377,7 @@ void SwTextShell::Execute(SfxRequest &rReq)
         }
         else if (sApplyText == "Spelling")
         {
-            AddWordToWordbook(LinguMgr::GetIgnoreAllList(), rWrtSh);
+            AddWordToWordbook(LinguMgr::GetIgnoreAllList(), rWrtSh );
         }
     }
     break;
@@ -2387,9 +2387,22 @@ void SwTextShell::Execute(SfxRequest &rReq)
         if (const SfxStringItem* pItem1 = 
rReq.GetArg<SfxStringItem>(FN_PARAM_1))
             aDicName = pItem1->GetValue();
 
+        // strip dot, if the extended dictionary name ends with ")", but not 
".)", e.g.
+        // "standard.dic (F.A.C.S)"  -> strip dot
+        // "standard.dic (F.A.C.S.)" -> with dot
+        bool bAddWithDot = false;
+        if ( aDicName.endsWith(")") )
+        {
+            bAddWithDot = aDicName.endsWith(".)");
+            // restore the dictionary name by stripping the parenthesized 
extension
+            sal_Int32 nHintPos = aDicName.indexOf(" (");
+            if ( nHintPos > 0 )
+                aDicName = aDicName.copy(0, nHintPos);
+        }
+
         uno::Reference<linguistic2::XSearchableDictionaryList> 
xDicList(LinguMgr::GetDictionaryList());
         uno::Reference<linguistic2::XDictionary> xDic = xDicList.is() ? 
xDicList->getDictionaryByName(aDicName) : nullptr;
-        if (AddWordToWordbook(xDic, rWrtSh))
+        if (AddWordToWordbook(xDic, rWrtSh, bAddWithDot))
         {
             // save modified user-dictionary if it is persistent
             uno::Reference<frame::XStorable> xSavDic(xDic, uno::UNO_QUERY);
commit 5fad461dd7d85e0fe41de621e49933b251a629ce
Author:     László Németh <[email protected]>
AuthorDate: Sat Dec 27 13:40:51 2025 +0100
Commit:     Xisco Fauli <[email protected]>
CommitDate: Mon Dec 29 17:24:42 2025 +0100

    tdf#170140 lingucomponent: check words with non-ASCII apostrophe
    
    If the spelling dictionary contains them with non-ASCII apostrophe.
    
    Previously the words were checked only with apostrophes converted
    to their ASCII version, resulting continuous false alarms, despite
    the correct orthography in the document and in the spelling
    dictionary.
    
    Now the words are checked both with ASCII conversion and with
    the original non-ASCII apostrophes (because still there are
    UTF-8 dictionaries with ASCII apostrophes, moreover, dictionaries
    containing mixed apostrophes in the same dic file).
    
    Example words for the Hungarian dictionary: d’Arc, d’Alembert,
    McDonald’s (and their several recognized suffixed forms: d’Arcért,
    d’Arckal, d’Arcként, d’Arcnak, d’Arcot etc. etc.)
    
    Add unit tests for 1) break iterator and 2) spell checking with
    non-ASCII apostrophes.
    
    Change-Id: I24a570df41fa5aba2e7b67dde0db33377717dc2a
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/196248
    Reviewed-by: László Németh <[email protected]>
    Tested-by: Jenkins
    Signed-off-by: Xisco Fauli <[email protected]>
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/196283

diff --git a/i18npool/qa/cppunit/test_breakiterator.cxx 
b/i18npool/qa/cppunit/test_breakiterator.cxx
index ae2e5af4e5e9..c91532d5ff3a 100644
--- a/i18npool/qa/cppunit/test_breakiterator.cxx
+++ b/i18npool/qa/cppunit/test_breakiterator.cxx
@@ -1844,7 +1844,18 @@ void TestBreakIterator::testDictWordAbbreviationHU()
 
     for (const auto& rLocale : aLocale)
     {
-        auto aTest = u"Pl. stb. dr.-ral Mo.-gal 50-et 50.-et"_ustr;
+
+        auto aTest =
+                // abbreviations
+                u"Pl. stb. "
+                // abbreviations with suffixes
+                "dr.-ral Mo.-gal "
+                // number with a suffix
+                "50-et "
+                // ordinal number with a suffix
+                "50.-et "
+                // word with a non-ASCII apostrophe
+                "d’Arc"_ustr;
 
         i18n::Boundary aBounds
             = m_xBreak->getWordBoundary(aTest, 1, rLocale, 
i18n::WordType::DICTIONARY_WORD, false);
@@ -1875,6 +1886,11 @@ void TestBreakIterator::testDictWordAbbreviationHU()
             = m_xBreak->getWordBoundary(aTest, 31, rLocale, 
i18n::WordType::DICTIONARY_WORD, false);
         CPPUNIT_ASSERT_EQUAL(sal_Int32(31), aBounds.startPos);
         CPPUNIT_ASSERT_EQUAL(sal_Int32(37), aBounds.endPos);
+
+        aBounds
+            = m_xBreak->getWordBoundary(aTest, 38, rLocale, 
i18n::WordType::DICTIONARY_WORD, false);
+        CPPUNIT_ASSERT_EQUAL(sal_Int32(38), aBounds.startPos);
+        CPPUNIT_ASSERT_EQUAL(sal_Int32(43), aBounds.endPos);
     }
 }
 
diff --git a/lingucomponent/source/spellcheck/spell/sspellimp.cxx 
b/lingucomponent/source/spellcheck/spell/sspellimp.cxx
index fe676cde5312..153ce18ec2be 100644
--- a/lingucomponent/source/spellcheck/spell/sspellimp.cxx
+++ b/lingucomponent/source/spellcheck/spell/sspellimp.cxx
@@ -243,6 +243,7 @@ sal_Bool SAL_CALL SpellChecker::hasLocale(const Locale& 
rLocale)
     return bRes;
 }
 
+#define SPELL_NON_ASCII_APOSTROPHE 1 << 10
 sal_Int16 SpellChecker::GetSpellFailure(const OUString &rWord, const Locale 
&rLocale, int& rInfo)
 {
     if (rWord.getLength() > MAXWORDLEN)
@@ -261,15 +262,19 @@ sal_Int16 SpellChecker::GetSpellFailure(const OUString 
&rWord, const Locale &rLo
     sal_Int32 n = rBuf.getLength();
     sal_Unicode c;
     sal_Int32 extrachar = 0;
-
+    const bool bDoNotConvertApostrophe = bool(rInfo & 
SPELL_NON_ASCII_APOSTROPHE);
+    bool bHasNonASCIIApostrophe = false;
+    rInfo = 0;
     for (sal_Int32 ix=0; ix < n; ix++)
     {
         c = rBuf[ix];
         if ((c == 0x201C) || (c == 0x201D))
             rBuf[ix] = u'"';
-        else if ((c == 0x2018) || (c == 0x2019))
+        else if (!bDoNotConvertApostrophe && ((c == 0x2018) || (c == 0x2019)))
+        {
             rBuf[ix] = u'\'';
-
+            bHasNonASCIIApostrophe = true;
+        }
         // recognize words with Unicode ligatures and ZWNJ/ZWJ characters (only
         // with 8-bit encoded dictionaries. For UTF-8 encoded dictionaries
         // set ICONV and IGNORE aff file options, if needed.)
@@ -370,6 +375,10 @@ sal_Int16 SpellChecker::GetSpellFailure(const OUString 
&rWord, const Locale &rLo
         }
     }
 
+    // checked with apostrophe conversion
+    if ( bHasNonASCIIApostrophe )
+        rInfo |= SPELL_NON_ASCII_APOSTROPHE;
+
     return nRes;
 }
 
@@ -396,8 +405,14 @@ sal_Bool SAL_CALL SpellChecker::isValid( const OUString& 
rWord, const Locale& rL
     PropertyHelper_Spelling& rHelper = GetPropHelper();
     rHelper.SetTmpPropVals( rProperties );
 
-    int nInfo = 0;
+    int nInfo = 0; // return compound information, disable apostrophe 
conversion
     sal_Int16 nFailure = GetSpellFailure( rWord, rLocale, nInfo );
+    // it contains non-ASCII apostrophe, and it was bad with ASCII conversion:
+    // check the word with the original apostrophe character(s), too
+    if ( nFailure != -1 && nInfo & SPELL_NON_ASCII_APOSTROPHE ) {
+        nInfo = SPELL_NON_ASCII_APOSTROPHE; // disable apostrophe conversion
+        nFailure = GetSpellFailure( rWord, rLocale, nInfo );
+    }
     if (nFailure != -1 && !rWord.match(SPELL_XML, 0))
     {
         LanguageType nLang = LinguLocaleToLanguage( rLocale );
diff --git a/sw/qa/extras/layout/layout2.cxx b/sw/qa/extras/layout/layout2.cxx
index f332a5226806..b288a036359d 100644
--- a/sw/qa/extras/layout/layout2.cxx
+++ b/sw/qa/extras/layout/layout2.cxx
@@ -1275,6 +1275,26 @@ CPPUNIT_TEST_FIXTURE(SwLayoutWriter2, 
testTdf158885_not_compound_remain)
                 u"lenes emberellenes emberellenes emberellenes emberellenes 
emberellenes ");
 }
 
+// TODO: move this test to the lingucomponent project
+CPPUNIT_TEST_FIXTURE(SwLayoutWriter2, testTdf170140)
+{
+    uno::Reference<linguistic2::XSpellChecker1> xSpell = 
LinguMgr::GetSpellChecker();
+    auto aLocale = lang::Locale(u"hu"_ustr, u"HU"_ustr, OUString());
+    LanguageType eLang = LanguageTag::convertToLanguageType(aLocale);
+    if (!xSpell.is() || !xSpell->hasLanguage(static_cast<sal_uInt16>(eLang)))
+        return;
+
+    uno::Sequence<beans::PropertyValue> aProperties;
+
+    // correct non-ASCII apostrophe
+    OUString sWord(u"d’Arc"_ustr);
+    CPPUNIT_ASSERT(xSpell->isValid(sWord, static_cast<sal_uInt16>(eLang), 
aProperties));
+
+    // bad ASCII apostrophe
+    OUString sWord2(u"d'Arc"_ustr);
+    CPPUNIT_ASSERT(!xSpell->isValid(sWord2, static_cast<sal_uInt16>(eLang), 
aProperties));
+}
+
 CPPUNIT_TEST_FIXTURE(SwLayoutWriter2, testRedlineNumberInFootnote)
 {
     createSwDoc("tdf85610.fodt");

Reply via email to