sw/inc/formatcontentcontrol.hxx         |    5 ++
 sw/qa/filter/md/data/task-list-items.md |    2 
 sw/qa/filter/md/md.cxx                  |   76 ++++++++++++++++++++++++++++++++
 sw/source/filter/md/swmd.cxx            |   30 +++++++++++-
 sw/source/filter/md/swmd.hxx            |    3 -
 sw/source/filter/md/wrtmd.cxx           |   51 +++++++++++++++++++++
 sw/source/filter/md/wrtmd.hxx           |    5 ++
 sw/source/uibase/wrtsh/wrtsh1.cxx       |    6 --
 8 files changed, 168 insertions(+), 10 deletions(-)

New commits:
commit dc16ee920847dd05e1273e912a408bee5c79d71a
Author:     Miklos Vajna <[email protected]>
AuthorDate: Tue Sep 30 08:23:34 2025 +0200
Commit:     Caolán McNamara <[email protected]>
CommitDate: Tue Sep 30 09:34:21 2025 +0200

    tdf#168617 sw markdown filter: map tasks to checkbox content controls and 
back
    
    Import the bugdoc, try to click on the unchecked checkbox to check it,
    nothing happens, while this works in some other markdown renderers.
    
    Writer has a checkbox content control type that can model a task list
    item described at
    <https://github.github.com/gfm/#task-list-items-extension->.
    
    Fix the problem by replacing the static checkboxes with a content
    control so the user can interact with the checkbox.
    
    And do the opposite during export.
    
    Change-Id: I7ed66481bfffbf79aa828e7b3dbe34c0d753a817
    Reviewed-on: https://gerrit.libreoffice.org/c/core/+/191648
    Tested-by: Jenkins CollaboraOffice <[email protected]>
    Tested-by: Caolán McNamara <[email protected]>
    Reviewed-by: Caolán McNamara <[email protected]>

diff --git a/sw/inc/formatcontentcontrol.hxx b/sw/inc/formatcontentcontrol.hxx
index cc0d4d8e0724..72383d76710d 100644
--- a/sw/inc/formatcontentcontrol.hxx
+++ b/sw/inc/formatcontentcontrol.hxx
@@ -398,6 +398,11 @@ public:
     const OUString& GetMultiLine() const { return m_aMultiLine; }
 
     SwContentControlType GetType() const;
+
+    // Ballot Box with X
+    static constexpr OUString CHECKED_STATE = u"\u2612"_ustr;
+    // Ballot Box
+    static constexpr OUString UNCHECKED_STATE = u"\u2610"_ustr;
 };
 
 /* vim:set shiftwidth=4 softtabstop=4 expandtab: */
diff --git a/sw/qa/filter/md/data/task-list-items.md 
b/sw/qa/filter/md/data/task-list-items.md
new file mode 100644
index 000000000000..2efcdf77ba62
--- /dev/null
+++ b/sw/qa/filter/md/data/task-list-items.md
@@ -0,0 +1,2 @@
+- [x] foo
+- [ ] bar
diff --git a/sw/qa/filter/md/md.cxx b/sw/qa/filter/md/md.cxx
index 05ee24f00841..3c384a2f9871 100644
--- a/sw/qa/filter/md/md.cxx
+++ b/sw/qa/filter/md/md.cxx
@@ -26,6 +26,7 @@
 #include <itabenum.hxx>
 #include <ndtxt.hxx>
 #include <fmturl.hxx>
+#include <textcontentcontrol.hxx>
 
 namespace
 {
@@ -731,6 +732,81 @@ CPPUNIT_TEST_FIXTURE(Test, testNestedTableMdExport)
     CPPUNIT_ASSERT_EQUAL(aExpected, aActual);
 }
 
+CPPUNIT_TEST_FIXTURE(Test, testTastListItemsMdImport)
+{
+    // Given a document with 2 task list items:
+    setImportFilterName("Markdown");
+
+    // When importing that document from markdown:
+    createSwDoc("task-list-items.md");
+
+    // Then make sure we have two checkbox content controls, first is checked, 
second is not:
+    SwDocShell* pDocShell = getSwDocShell();
+    SwWrtShell* pWrtShell = pDocShell->GetWrtShell();
+    pWrtShell->SttEndDoc(/*bStt=*/true);
+    pWrtShell->Right(SwCursorSkipMode::Chars, /*bSelect=*/false, 1, 
/*bBasicCall=*/false);
+    {
+        SwTextContentControl* pTextContentControl = 
pWrtShell->CursorInsideContentControl();
+        // Without the accompanying fix in place, this test would have failed, 
the task list item
+        // was imported as a static checkbox character.
+        CPPUNIT_ASSERT(pTextContentControl);
+        const SwFormatContentControl& rFormatContentControl
+            = pTextContentControl->GetContentControl();
+        const std::shared_ptr<SwContentControl>& pContentControl
+            = rFormatContentControl.GetContentControl();
+        CPPUNIT_ASSERT_EQUAL(SwContentControlType::CHECKBOX, 
pContentControl->GetType());
+        CPPUNIT_ASSERT(pContentControl->GetChecked());
+    }
+    pWrtShell->Down(/*bSelect=*/false, 1);
+    pWrtShell->Right(SwCursorSkipMode::Chars, /*bSelect=*/false, 1, 
/*bBasicCall=*/false);
+    {
+        SwTextContentControl* pTextContentControl = 
pWrtShell->CursorInsideContentControl();
+        CPPUNIT_ASSERT(pTextContentControl);
+        const SwFormatContentControl& rFormatContentControl
+            = pTextContentControl->GetContentControl();
+        const std::shared_ptr<SwContentControl>& pContentControl
+            = rFormatContentControl.GetContentControl();
+        CPPUNIT_ASSERT_EQUAL(SwContentControlType::CHECKBOX, 
pContentControl->GetType());
+        CPPUNIT_ASSERT(!pContentControl->GetChecked());
+    }
+}
+
+CPPUNIT_TEST_FIXTURE(Test, testTastListItemsMdExport)
+{
+    // Given a document with two content control checkboxes, first is checked, 
second is unchecked:
+    createSwDoc();
+    SwDocShell* pDocShell = getSwDocShell();
+    SwWrtShell* pWrtShell = pDocShell->GetWrtShell();
+    pWrtShell->InsertContentControl(SwContentControlType::CHECKBOX);
+    {
+        SwTextContentControl* pTextContentControl = 
pWrtShell->CursorInsideContentControl();
+        const SwFormatContentControl& rFormatContentControl
+            = pTextContentControl->GetContentControl();
+        const std::shared_ptr<SwContentControl>& pContentControl
+            = rFormatContentControl.GetContentControl();
+        pContentControl->SetChecked(true);
+    }
+    pWrtShell->SttEndDoc(/*bStt=*/false);
+    pWrtShell->Insert(u" foo"_ustr);
+    pWrtShell->SplitNode();
+    pWrtShell->InsertContentControl(SwContentControlType::CHECKBOX);
+    pWrtShell->SttEndDoc(/*bStt=*/false);
+    pWrtShell->Insert(u" bar"_ustr);
+
+    // When saving that to markdown:
+    save(mpFilter);
+
+    // Then make sure that the task list item markup is used:
+    std::string aActual = TempFileToString();
+    std::string aExpected("[x] foo" SAL_NEWLINE_STRING SAL_NEWLINE_STRING
+                          "[ ] bar" SAL_NEWLINE_STRING);
+    // Without the accompanying fix in place, this test would have failed with:
+    // - Expected: [x] foo

[ ] bar
+    // - Actual  : ☐ foo

☐ bar
+    // i.e. checkboxes were not written using the task list item markup.
+    CPPUNIT_ASSERT_EQUAL(aExpected, aActual);
+}
+
 CPPUNIT_PLUGIN_IMPLEMENT();
 
 /* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s 
cinkeys+=0=break: */
diff --git a/sw/source/filter/md/swmd.cxx b/sw/source/filter/md/swmd.cxx
index 331fb0e90698..fca4dbae890f 100644
--- a/sw/source/filter/md/swmd.cxx
+++ b/sw/source/filter/md/swmd.cxx
@@ -43,10 +43,12 @@
 #include <unotools/securityoptions.hxx>
 #include <vcl/graph.hxx>
 #include <vcl/graphicfilter.hxx>
+#include <comphelper/random.hxx>
 #include <ndgrf.hxx>
 #include <fmtcntnt.hxx>
 #include <swtypes.hxx>
 #include <fmturl.hxx>
+#include <formatcontentcontrol.hxx>
 
 #include "swmd.hxx"
 
@@ -468,9 +470,33 @@ void 
SwMarkdownParser::StartNumberedBulletListItem(MD_BLOCK_LI_DETAIL aDetail)
 
         if (aDetail.is_task)
         {
+            // Map the task list item to a Writer checkbox content control.
             bool bChecked = (aDetail.task_mark == 'x' || aDetail.task_mark == 
'X') ? true : false;
-            pTextNode->InsertText((bChecked ? Checkmark : Crossmark) + u" 
"_ustr,
-                                  SwContentIndex(pTextNode, 0));
+            auto pContentControl = std::make_shared<SwContentControl>(nullptr);
+            sal_Int32 nId = comphelper::rng::uniform_uint_distribution(
+                1, std::numeric_limits<sal_Int32>::max());
+            SwFormatContentControl aContentControl(pContentControl, 
RES_TXTATR_CONTENTCONTROL);
+            pContentControl->SetId(nId);
+            pContentControl->SetCheckbox(true);
+            pContentControl->SetCheckedState(SwContentControl::CHECKED_STATE);
+            
pContentControl->SetUncheckedState(SwContentControl::UNCHECKED_STATE);
+            pContentControl->SetChecked(bChecked);
+            OUString aPlaceholder;
+            if (bChecked)
+            {
+                aPlaceholder = SwContentControl::CHECKED_STATE;
+            }
+            else
+            {
+                aPlaceholder = SwContentControl::UNCHECKED_STATE;
+            }
+            pTextNode->InsertText(aPlaceholder, SwContentIndex(pTextNode, 
pTextNode->Len()));
+            SwPosition aStart(*m_pPam->GetPoint());
+            aStart.nContent -= aPlaceholder.getLength();
+            SwPosition aEnd(*m_pPam->GetPoint());
+            SwPaM aPaM(aStart, aEnd);
+            m_xDoc->getIDocumentContentOperations().InsertPoolItem(aPaM, 
aContentControl);
+            pTextNode->InsertText(u" "_ustr, SwContentIndex(pTextNode, 
pTextNode->Len()));
         }
     }
     else
diff --git a/sw/source/filter/md/swmd.hxx b/sw/source/filter/md/swmd.hxx
index a828130533ad..1f219357f609 100644
--- a/sw/source/filter/md/swmd.hxx
+++ b/sw/source/filter/md/swmd.hxx
@@ -55,9 +55,6 @@ constexpr tools::Long MD_MAX_IMAGE_HEIGHT_IN_TWIPS = 5000;
 constexpr tools::Long MD_MIN_IMAGE_WIDTH_IN_TWIPS = 500;
 constexpr tools::Long MD_MIN_IMAGE_HEIGHT_IN_TWIPS = 500;
 
-constexpr OUString Checkmark = u"\x2705"_ustr;
-constexpr OUString Crossmark = u"\x274C"_ustr;
-
 constexpr frozen::unordered_map<MD_ALIGN, SvxAdjust, 4> adjustMap
     = { { MD_ALIGN_DEFAULT, SvxAdjust::Left },
         { MD_ALIGN_LEFT, SvxAdjust::Left },
diff --git a/sw/source/filter/md/wrtmd.cxx b/sw/source/filter/md/wrtmd.cxx
index afa904598ec5..a475eb3597f7 100644
--- a/sw/source/filter/md/wrtmd.cxx
+++ b/sw/source/filter/md/wrtmd.cxx
@@ -102,6 +102,8 @@ struct FormattingStatus
     std::unordered_map<OUString, int> aHyperlinkChanges;
     std::unordered_map<const SwRangeRedline*, int> aRedlineChanges;
     std::set<SwMDImageInfo> aImages;
+    // Checked/unchecked -> increment (1 or -1) map.
+    std::unordered_map<bool, int> aTaskListItemChanges;
 };
 
 template <typename T> struct PosData
@@ -245,6 +247,21 @@ void ApplyItem(SwMDWriter& rWrt, FormattingStatus& 
rChange, const SfxPoolItem& r
             rChange.aImages.emplace(aGraphicURL, aTitle, aDescription, aLink);
             break;
         }
+        case RES_TXTATR_CONTENTCONTROL:
+        {
+            // Content control.
+            const SwFormatContentControl& rFormatContentControl
+                = rItem.StaticWhichCast(RES_TXTATR_CONTENTCONTROL);
+            const std::shared_ptr<SwContentControl>& pContentControl
+                = rFormatContentControl.GetContentControl();
+            if (pContentControl->GetType() != SwContentControlType::CHECKBOX)
+            {
+                break;
+            }
+
+            rChange.aTaskListItemChanges[pContentControl->GetChecked()] += 
increment;
+            break;
+        }
     }
 }
 
@@ -315,6 +332,14 @@ void OutFormattingChange(SwMDWriter& rWrt, NodePositions& 
positions, sal_Int32 p
     if (ShouldCloseIt(current.nWeightChange, result.nWeightChange))
         rWrt.Strm().WriteUnicodeOrByteText(u"**");
 
+    for (const auto & [ bChecked, nCurr ] : result.aTaskListItemChanges)
+    {
+        if (ShouldCloseIt(current.aTaskListItemChanges[bChecked], nCurr))
+        {
+            rWrt.SetTaskListItems(rWrt.GetTaskListItems() - 1);
+        }
+    }
+
     for (const auto & [ url, curr ] : result.aHyperlinkChanges)
     {
         if (ShouldCloseIt(current.aHyperlinkChanges[url], curr))
@@ -387,6 +412,25 @@ void OutFormattingChange(SwMDWriter& rWrt, NodePositions& 
positions, sal_Int32 p
         }
     }
 
+    // Task list items: write the complete markup on start.
+    for (const auto & [ bChecked, nCurr ] : result.aTaskListItemChanges)
+    {
+        if (ShouldOpenIt(current.aTaskListItemChanges[bChecked], nCurr))
+        {
+            rWrt.Strm().WriteUnicodeOrByteText(u"[");
+            if (bChecked)
+            {
+                rWrt.Strm().WriteUnicodeOrByteText(u"x");
+            }
+            else
+            {
+                rWrt.Strm().WriteUnicodeOrByteText(u" ");
+            }
+            rWrt.Strm().WriteUnicodeOrByteText(u"]");
+            rWrt.SetTaskListItems(rWrt.GetTaskListItems() + 1);
+        }
+    }
+
     if (ShouldOpenIt(current.nCodeChange, result.nCodeChange))
     {
         rWrt.Strm().WriteUnicodeOrByteText(u"`");
@@ -687,7 +731,12 @@ void OutMarkdown_SwTextNode(SwMDWriter& rWrt, const 
SwTextNode& rNode, bool bFir
             // 2. Escape and output the character. This relies on hints not 
appearing in the middle of
             // a surrogate pair.
             sal_Int32 nEndOfChunk = positions.getEndOfCurrent(nEnd);
-            OutEscapedChars(rWrt, rNodeText.subView(nStrPos, nEndOfChunk - 
nStrPos));
+            // If we're inside a task list item, then only write the markup, 
not the generated
+            // content.
+            if (!rWrt.GetTaskListItems())
+            {
+                OutEscapedChars(rWrt, rNodeText.subView(nStrPos, nEndOfChunk - 
nStrPos));
+            }
             nStrPos = nEndOfChunk;
         }
         assert(positions.hintStarts.current() == nullptr);
diff --git a/sw/source/filter/md/wrtmd.hxx b/sw/source/filter/md/wrtmd.hxx
index 77b6e965c792..78a83af4c0bb 100644
--- a/sw/source/filter/md/wrtmd.hxx
+++ b/sw/source/filter/md/wrtmd.hxx
@@ -62,6 +62,9 @@ public:
     const std::map<int, int>& GetListLevelPrefixSizes() const { return 
m_aListLevelPrefixSizes; }
     std::stack<SwMDTableInfo>& GetTableInfos() { return m_aTableInfos; }
 
+    void SetTaskListItems(int nTaskListItems) { m_nTaskListItems = 
nTaskListItems; }
+    int GetTaskListItems() const { return m_nTaskListItems; }
+
 protected:
     ErrCode WriteStream() override;
 
@@ -73,6 +76,8 @@ private:
     /// List level -> prefix size map, e.g. "1. " size is 3.
     std::map<int, int> m_aListLevelPrefixSizes;
     std::stack<SwMDTableInfo> m_aTableInfos;
+    /// Number of currently open task list items.
+    int m_nTaskListItems = 0;
 };
 
 /* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s 
cinkeys+=0=break: */
diff --git a/sw/source/uibase/wrtsh/wrtsh1.cxx 
b/sw/source/uibase/wrtsh/wrtsh1.cxx
index 78b29cad5803..c9fd6049fc1b 100644
--- a/sw/source/uibase/wrtsh/wrtsh1.cxx
+++ b/sw/source/uibase/wrtsh/wrtsh1.cxx
@@ -1173,10 +1173,8 @@ void 
SwWrtShell::InsertContentControl(SwContentControlType eType)
         case SwContentControlType::CHECKBOX:
         {
             pContentControl->SetCheckbox(true);
-            // Ballot Box with X
-            pContentControl->SetCheckedState(u"\u2612"_ustr);
-            // Ballot Box
-            pContentControl->SetUncheckedState(u"\u2610"_ustr);
+            pContentControl->SetCheckedState(SwContentControl::CHECKED_STATE);
+            
pContentControl->SetUncheckedState(SwContentControl::UNCHECKED_STATE);
             aPlaceholder = u"\u2610"_ustr;
             break;
         }

Reply via email to