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 7608d96302652eb48863da441e121e0c61350412 Author: Miklos Vajna <[email protected]> AuthorDate: Tue Sep 30 08:23:34 2025 +0200 Commit: Miklos Vajna <[email protected]> CommitDate: Tue Sep 30 13:27:42 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/+/191657 Reviewed-by: Miklos Vajna <[email protected]> Tested-by: Jenkins 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 157c03235316..217d212a90f8 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 { @@ -741,6 +742,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 73a1ebc714b3..22d494d54e92 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 931db9bfeedb..3aebbabf0896 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 7d43006eca26..6bc5dc8444c2 100644 --- a/sw/source/filter/md/wrtmd.cxx +++ b/sw/source/filter/md/wrtmd.cxx @@ -107,6 +107,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; }; // This is a vector of positions in the node text, where objects of class T start or end. @@ -255,6 +257,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; + } } } @@ -334,6 +351,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)) @@ -406,6 +431,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"`"); @@ -706,7 +750,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 ebe08ca85f29..126feeec8c54 100644 --- a/sw/source/uibase/wrtsh/wrtsh1.cxx +++ b/sw/source/uibase/wrtsh/wrtsh1.cxx @@ -1175,10 +1175,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; }
