sw/CppunitTest_sw_filter_md.mk | 1 sw/inc/ndnotxt.hxx | 2 - sw/qa/filter/md/md.cxx | 62 ++++++++++++++++++++++++++------ sw/source/filter/md/wrtmd.cxx | 79 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 132 insertions(+), 12 deletions(-)
New commits: commit d92109c8c26794194c4d27f76e20f3002f2d8274 Author: Miklos Vajna <[email protected]> AuthorDate: Mon Sep 1 12:01:48 2025 +0200 Commit: Caolán McNamara <[email protected]> CommitDate: Mon Sep 1 13:26:31 2025 +0200 tdf#168172 sw markdown export: handle images Load the bugdoc, save back to markdown, the image is lost. Markdown doesn't seem to have a way to embed images, also these images are not anchored. So focus on the image type that is created by the markdown import: linked, inline images. This also required extending OutMarkdown_SwTextNode() a bit, because it makes sense to not emit e.g. empty bold or italic hints, but e.g. inline images are hints with no end, but we still want to produce output for them. Change-Id: I6529606ff4dd677815da4668f31c079184e18fa9 Reviewed-on: https://gerrit.libreoffice.org/c/core/+/190457 Tested-by: Jenkins CollaboraOffice <[email protected]> Reviewed-by: Caolán McNamara <[email protected]> diff --git a/sw/CppunitTest_sw_filter_md.mk b/sw/CppunitTest_sw_filter_md.mk index 6ec0da4b5289..4fc01af86145 100644 --- a/sw/CppunitTest_sw_filter_md.mk +++ b/sw/CppunitTest_sw_filter_md.mk @@ -27,6 +27,7 @@ $(eval $(call gb_CppunitTest_use_libraries,sw_filter_md, \ unotest \ utl \ svl \ + vcl \ )) $(eval $(call gb_CppunitTest_use_externals,sw_filter_md,\ diff --git a/sw/inc/ndnotxt.hxx b/sw/inc/ndnotxt.hxx index 9829ce7d2375..b07d904d49ff 100644 --- a/sw/inc/ndnotxt.hxx +++ b/sw/inc/ndnotxt.hxx @@ -62,7 +62,7 @@ public: virtual bool RestorePersistentData(); SW_DLLPUBLIC OUString GetTitle() const; - void SetTitle( const OUString& rTitle ); + SW_DLLPUBLIC void SetTitle( const OUString& rTitle ); SW_DLLPUBLIC OUString GetDescription() const; void SetDescription( const OUString& rDescription ); diff --git a/sw/qa/filter/md/md.cxx b/sw/qa/filter/md/md.cxx index 50e0dbd048ec..bae5a5fdd211 100644 --- a/sw/qa/filter/md/md.cxx +++ b/sw/qa/filter/md/md.cxx @@ -19,6 +19,10 @@ #include <IDocumentStylePoolAccess.hxx> #include <poolfmt.hxx> #include <charatr.hxx> +#include <fmtanchr.hxx> +#include <IDocumentContentOperations.hxx> +#include <fmtcntnt.hxx> +#include <ndgrf.hxx> namespace { @@ -39,6 +43,14 @@ public: : SwModelTestBase(u"/sw/qa/filter/md/data/"_ustr, u"Markdown"_ustr) { } + + std::string TempFileToString() + { + SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); + std::vector<char> aBuffer(aStream.remainingSize()); + aStream.ReadBytes(aBuffer.data(), aBuffer.size()); + return std::string(aBuffer.data(), aBuffer.size()); + } }; } @@ -206,15 +218,12 @@ CPPUNIT_TEST_FIXTURE(Test, testExportingCodeSpan) save(mpFilter); // Then make sure the format of B is exported: - SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); - std::vector<char> aBuffer(aStream.remainingSize()); - aStream.ReadBytes(aBuffer.data(), aBuffer.size()); - std::string_view aActual(aBuffer.data(), aBuffer.size()); + std::string aActual = TempFileToString(); // Without the accompanying fix in place, this test would have failed with: // - Expected: A `B` C // - Actual : A B C // i.e. the code formatting was lost. - std::string_view aExpected("A `B` C" SAL_NEWLINE_STRING); + std::string aExpected("A `B` C" SAL_NEWLINE_STRING); CPPUNIT_ASSERT_EQUAL(aExpected, aActual); } @@ -247,11 +256,8 @@ CPPUNIT_TEST_FIXTURE(Test, testExportingList) save(mpFilter); // Then make sure list type and level is exported: - SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); - std::vector<char> aBuffer(aStream.remainingSize()); - aStream.ReadBytes(aBuffer.data(), aBuffer.size()); - std::string_view aActual(aBuffer.data(), aBuffer.size()); - std::string_view aExpected( + std::string aActual = TempFileToString(); + std::string aExpected( // clang-format off "A" SAL_NEWLINE_STRING SAL_NEWLINE_STRING "- B" SAL_NEWLINE_STRING SAL_NEWLINE_STRING @@ -269,6 +275,42 @@ CPPUNIT_TEST_FIXTURE(Test, testExportingList) CPPUNIT_ASSERT_EQUAL(aExpected, aActual); } +CPPUNIT_TEST_FIXTURE(Test, testExportingImage) +{ + // Given a document with an inline, linked image: + createSwDoc(); + SwDocShell* pDocShell = getSwDocShell(); + SwDoc* pDoc = pDocShell->GetDoc(); + SwWrtShell* pWrtShell = pDocShell->GetWrtShell(); + pWrtShell->Insert(u"A "_ustr); + SfxItemSet aFrameSet(pDoc->GetAttrPool(), svl::Items<RES_FRMATR_BEGIN, RES_FRMATR_END - 1>); + SwFormatAnchor aAnchor(RndStdIds::FLY_AS_CHAR); + aFrameSet.Put(aAnchor); + Graphic aGraphic; + OUString aGraphicURL(u"./test.png"_ustr); + IDocumentContentOperations& rIDCO = pDoc->getIDocumentContentOperations(); + SwCursor* pCursor = pWrtShell->GetCursor(); + SwFlyFrameFormat* pFlyFormat + = rIDCO.InsertGraphic(*pCursor, aGraphicURL, OUString(), &aGraphic, &aFrameSet, + /*pGrfAttrSet=*/nullptr, /*SwFrameFormat=*/nullptr); + SwNodeOffset nContentOffset = pFlyFormat->GetContent().GetContentIdx()->GetIndex(); + SwGrfNode* pGrfNode = pDoc->GetNodes()[nContentOffset + 1]->GetGrfNode(); + pGrfNode->SetTitle(u"mytitle"_ustr); + pWrtShell->Insert(u" B"_ustr); + + // When saving that to markdown: + save(mpFilter); + + // Then make sure the image is exported: + std::string aActual = TempFileToString(); + std::string aExpected("A  B" SAL_NEWLINE_STRING); + // Without the accompanying fix in place, this test would have failed with: + // - Expected: A  B + // - Actual : A B + // i.e. the image was lost. + 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/wrtmd.cxx b/sw/source/filter/md/wrtmd.cxx index ec5acca16aa0..63af0dc247c1 100644 --- a/sw/source/filter/md/wrtmd.cxx +++ b/sw/source/filter/md/wrtmd.cxx @@ -30,6 +30,7 @@ #include <svl/itemiter.hxx> #include <editeng/fontitem.hxx> #include <comphelper/string.hxx> +#include <svl/urihelper.hxx> #include <officecfg/Office/Writer.hxx> @@ -44,6 +45,8 @@ #include <strings.hrc> #include <txatbase.hxx> #include <charatr.hxx> +#include <fmtcntnt.hxx> +#include <ndgrf.hxx> #include "wrtmd.hxx" #include <algorithm> @@ -52,6 +55,28 @@ namespace { +/// Stores information about a to-be-exported linked image. +struct SwMDImageInfo +{ + OUString aURL; + OUString aTitle; + + SwMDImageInfo(const OUString& rURL, const OUString& rTitle) + : aURL(rURL) + , aTitle(rTitle) + { + } + + bool operator<(const SwMDImageInfo& rOther) const + { + if (aURL < rOther.aURL) + return true; + if (rOther.aURL < aURL) + return false; + return aTitle < rOther.aTitle; + } +}; + struct FormattingStatus { int nCrossedOutChange = 0; @@ -61,6 +86,7 @@ struct FormattingStatus int nCodeChange = 0; std::unordered_map<OUString, int> aHyperlinkChanges; std::unordered_map<const SwRangeRedline*, int> aRedlineChanges; + std::set<SwMDImageInfo> aImages; }; template <typename T> struct PosData @@ -159,6 +185,33 @@ void ApplyItem(SwMDWriter& rWrt, FormattingStatus& rChange, const SfxPoolItem& r } break; } + case RES_TXTATR_FLYCNT: + { + // Inline image. + const SwFormatFlyCnt& rFormatFlyCnt = rItem.StaticWhichCast(RES_TXTATR_FLYCNT); + const SwFrameFormat& rFrameFormat = *rFormatFlyCnt.GetFrameFormat(); + const SwFormatContent& rFlyContent = rFrameFormat.GetContent(); + SwNodeOffset nStart = rFlyContent.GetContentIdx()->GetIndex() + 1; + SwGrfNode* pGrfNode = rWrt.m_pDoc->GetNodes()[nStart]->GetGrfNode(); + Graphic aGraphic = pGrfNode->GetGraphic(); + if (!pGrfNode->IsLinkedFile()) + { + // Not linked, ignore for now. + break; + } + + // Try to extract a relative URL and a title. + OUString aGraphicURL; + pGrfNode->GetFileFilterNms(&aGraphicURL, /*pFilterNm=*/nullptr); + const OUString& rBaseURL = rWrt.GetBaseURL(); + if (!rBaseURL.isEmpty()) + { + aGraphicURL = URIHelper::simpleNormalizedMakeRelative(rBaseURL, aGraphicURL); + } + OUString aTitle = pGrfNode->GetTitle(); + rChange.aImages.emplace(aGraphicURL, aTitle); + break; + } } } @@ -199,6 +252,7 @@ FormattingStatus CalculateFormattingChange(SwMDWriter& rWrt, NodePositions& posi bool ShouldCloseIt(int prev, int curr) { return prev != curr && prev >= 0 && curr <= 0; } bool ShouldOpenIt(int prev, int curr) { return prev != curr && prev <= 0 && curr > 0; } +void OutEscapedChars(SwMDWriter& rWrt, std::u16string_view chars); void OutFormattingChange(SwMDWriter& rWrt, NodePositions& positions, sal_Int32 pos, FormattingStatus& current) { @@ -305,6 +359,23 @@ void OutFormattingChange(SwMDWriter& rWrt, NodePositions& positions, sal_Int32 p rWrt.Strm().WriteUnicodeOrByteText(u"`"); } + // Images: write the complete markup on start. + for (const auto& rImageInfo : result.aImages) + { + // 'current' is the old state and 'result' is the new state, so only write images which are + // new in 'result'. + if (current.aImages.contains(rImageInfo)) + { + continue; + } + + rWrt.Strm().WriteUnicodeOrByteText(u"; + rWrt.Strm().WriteUnicodeOrByteText(rImageInfo.aURL); + rWrt.Strm().WriteUnicodeOrByteText(u")"); + } + current = result; } @@ -454,7 +525,13 @@ void OutMarkdown_SwTextNode(SwMDWriter& rWrt, const SwTextNode& rNode, bool bFir if (nHintStart >= nEnd) break; const sal_Int32 nHintEnd = pHint->GetAnyEnd(); - if (nHintEnd == nHintStart || nHintEnd <= nStrPos) + if (nHintStart >= nStrPos && pHint->Which() == RES_TXTATR_FLYCNT) + { + // SwTextFlyCnt is a hint that has no end, but still output it. + positions.hintStarts.add(std::max(nHintStart, nStrPos), &pHint->GetAttr()); + continue; + } + else if (nHintEnd == nHintStart || nHintEnd <= nStrPos) continue; // no output of zero-length hints and hints ended before output started yet positions.hintStarts.add(std::max(nHintStart, nStrPos), &pHint->GetAttr()); positions.hintEnds.add(std::min(nHintEnd, nEnd), &pHint->GetAttr());
