sw/qa/filter/md/md.cxx | 33 ++++++++++++++ sw/source/filter/md/wrtmd.cxx | 93 ++++++++++++++++++++++++++++++++++++++++++ sw/source/filter/md/wrtmd.hxx | 14 ++++++ 3 files changed, 140 insertions(+)
New commits: commit 8bcdc8f326752176efbe2dfe6cfabfed6f9280b2 Author: Miklos Vajna <[email protected]> AuthorDate: Fri Oct 3 09:01:21 2025 +0200 Commit: Caolán McNamara <[email protected]> CommitDate: Fri Oct 3 15:12:48 2025 +0200 tdf#168662 sw markdown export: handle anchored images The bugdoc has a to-char anchored image, exporting that to markdown the image is lost. This happens because anchored images are not represented in the doc model as a SwFormatFlyCnt on a dummy character. Fix the problem similar to what the HTML export does with its SwHTMLWriter::OutFlyFrame() code: 1) Collect the position of all fly formats at the start 2) In OutMarkdown_SwTextNode(), take the flys anchored to a given text node out of that container 3) In OutFormattingChange(), emit the anchored images using the markdown image markup, which will turn them into inline images Draw shapes are ignored for now, since the current fly export code takes the object title / description directly from the fly frame format. Change-Id: I8061a4ea51b2c24a7d48daca0b3c62d79d3e7a3e Reviewed-on: https://gerrit.libreoffice.org/c/core/+/191811 Tested-by: Jenkins CollaboraOffice <[email protected]> Reviewed-by: Caolán McNamara <[email protected]> diff --git a/sw/qa/filter/md/md.cxx b/sw/qa/filter/md/md.cxx index 32bcefc99c28..ac3be4b87c29 100644 --- a/sw/qa/filter/md/md.cxx +++ b/sw/qa/filter/md/md.cxx @@ -865,6 +865,39 @@ CPPUNIT_TEST_FIXTURE(Test, testEmbeddedImageMdExport) CPPUNIT_ASSERT(aActual.ends_with(") B" SAL_NEWLINE_STRING)); } +CPPUNIT_TEST_FIXTURE(Test, testEmbeddedAnchoredImageMdExport) +{ + // Given a document with an embedded anchored 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_AT_CHAR); + aFrameSet.Put(aAnchor); + OUString aImageURL = createFileURL(u"test.png"); + SvFileStream aImageStream(aImageURL, StreamMode::READ); + GraphicFilter& rFilter = GraphicFilter::GetGraphicFilter(); + Graphic aGraphic = rFilter.ImportUnloadedGraphic(aImageStream); + IDocumentContentOperations& rIDCO = pDoc->getIDocumentContentOperations(); + SwCursor* pCursor = pWrtShell->GetCursor(); + SwFlyFrameFormat* pFlyFormat + = rIDCO.InsertGraphic(*pCursor, /*rGrfName=*/OUString(), OUString(), &aGraphic, &aFrameSet, + /*pGrfAttrSet=*/nullptr, /*SwFrameFormat=*/nullptr); + pFlyFormat->SetObjDescription(u"mydesc"_ustr); + pWrtShell->Insert(u" B"_ustr); + + // When saving that to markdown: + save(mpFilter); + + // Then make sure that the embedded image is exported: + std::string aActual = TempFileToString(); + // Without the accompanying fix in place, this test would have failed, aActual was 'A B '. + CPPUNIT_ASSERT(aActual.starts_with("A ); + CPPUNIT_ASSERT(aActual.ends_with(") B" SAL_NEWLINE_STRING)); +} + 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 81ee40177a4b..5707a1e0db0d 100644 --- a/sw/source/filter/md/wrtmd.cxx +++ b/sw/source/filter/md/wrtmd.cxx @@ -50,6 +50,7 @@ #include <ndgrf.hxx> #include <ndole.hxx> #include <fmturl.hxx> +#include <fmtanchr.hxx> #include "wrtmd.hxx" #include <algorithm> @@ -105,6 +106,7 @@ struct FormattingStatus std::set<SwMDImageInfo> aImages; // Checked/unchecked -> increment (1 or -1) map. std::unordered_map<bool, int> aTaskListItemChanges; + std::set<const SwFrameFormat*> aFlys; }; template <typename T> struct PosData @@ -126,6 +128,7 @@ struct NodePositions PosData<SfxPoolItem> hintEnds; PosData<SwRangeRedline> redlineStarts; PosData<SwRangeRedline> redlineEnds; + PosData<SwFrameFormat> flys; sal_Int32 getEndOfCurrent(sal_Int32 end) { @@ -136,6 +139,7 @@ struct NodePositions pos_of(hintStarts.current()), pos_of(redlineEnds.current()), pos_of(redlineStarts.current()), + pos_of(flys.current()), }); } }; @@ -281,6 +285,11 @@ void ApplyItem(FormattingStatus& rChange, const SwRangeRedline* pItem, int incre rChange.aRedlineChanges[pItem] += increment; } +void ApplyItem(FormattingStatus& rChange, const SwFrameFormat* pItem) +{ + rChange.aFlys.insert(pItem); +} + FormattingStatus CalculateFormattingChange(SwMDWriter& rWrt, NodePositions& positions, sal_Int32 pos, const FormattingStatus& currentFormatting) { @@ -305,6 +314,12 @@ FormattingStatus CalculateFormattingChange(SwMDWriter& rWrt, NodePositions& posi p = positions.redlineStarts.next()) ApplyItem(result, p->second, +1); + // Output anchored flys. + for (auto it = positions.flys.current(); it && it->first == pos; it = positions.flys.next()) + { + ApplyItem(result, it->second); + } + return result; } @@ -400,6 +415,29 @@ void OutFormattingChange(SwMDWriter& rWrt, NodePositions& positions, sal_Int32 p } } + // Write flys anchored at this position. + FormattingStatus aChange; + for (const SwFrameFormat* pFrameFormat : result.aFlys) + { + if (current.aFlys.contains(pFrameFormat)) + { + // 'current' is the old state and 'result' is the new state, so don't write images which were written already. + continue; + } + + if (pFrameFormat->Which() != RES_FLYFRMFMT) + { + continue; + } + + const auto& rFlyFrameFormat = static_cast<const SwFlyFrameFormat&>(*pFrameFormat); + ApplyFlyFrameFormat(rFlyFrameFormat, rWrt, aChange); + } + for (const SwMDImageInfo& rImage : aChange.aImages) + { + OutMarkdown_SwMDImageInfo(rImage, rWrt); + } + // Not in CommonMark if (ShouldOpenIt(current.nCrossedOutChange, result.nCrossedOutChange)) rWrt.Strm().WriteUnicodeOrByteText(u"~~"); @@ -733,6 +771,24 @@ void OutMarkdown_SwTextNode(SwMDWriter& rWrt, const SwTextNode& rNode, bool bFir positions.redlineEnds.sort(); + // Collect flys anchored to this text node. + for (size_t nFly = 0; nFly < rWrt.GetFlys().size(); ++nFly) + { + const SwMDFly& rFly = rWrt.GetFlys()[nFly]; + if (rFly.m_nAnchorNodeOffset < rNode.GetIndex()) + { + continue; + } + if (rFly.m_nAnchorNodeOffset > rNode.GetIndex()) + { + break; + } + + SwMDFly aFly = rWrt.GetFlys().erase_extract(nFly); + --nFly; + positions.flys.add(aFly.m_nAnchorContentOffset, aFly.m_pFrameFormat); + } + FormattingStatus currentStatus; while (nStrPos < nEnd) { @@ -874,8 +930,45 @@ void OutMarkdown_SwTableNode(SwMDWriter& rWrt, const SwTableNode& rTableNode) SwMDWriter::SwMDWriter(const OUString& rBaseURL) { SetBaseURL(rBaseURL); } +bool SwMDFly::operator<(const SwMDFly& rFly) const +{ + if (m_nAnchorNodeOffset != rFly.m_nAnchorNodeOffset) + { + return m_nAnchorNodeOffset < rFly.m_nAnchorNodeOffset; + } + + if (m_nAnchorContentOffset != rFly.m_nAnchorContentOffset) + { + return m_nAnchorContentOffset < rFly.m_nAnchorContentOffset; + } + + return m_pFrameFormat->GetAnchor().GetOrder() < rFly.m_pFrameFormat->GetAnchor().GetOrder(); +} + +void SwMDWriter::CollectFlys() +{ + // ApplyItem() already handles as-char flys. + SwPosFlyFrames aFrames(m_pDoc->GetAllFlyFormats(m_bWriteAll ? nullptr : m_pCurrentPam.get(), + /*bDrawAlso=*/false)); + for (const SwPosFlyFrame& rFrame : aFrames) + { + const SwFrameFormat& rFrameFormat = rFrame.GetFormat(); + const SwFormatAnchor& rAnchor = rFrameFormat.GetAnchor(); + SwContentNode* pAnchorNode = rAnchor.GetAnchorContentNode(); + if (!pAnchorNode) + { + continue; + } + + m_aFlys.insert( + { pAnchorNode->GetIndex(), rAnchor.GetAnchorContentOffset(), &rFrameFormat }); + } +} + ErrCode SwMDWriter::WriteStream() { + CollectFlys(); + Strm().SetStreamCharSet(RTL_TEXTENCODING_UTF8); if (m_bShowProgress) ::StartProgress(STR_STATSTR_W4WWRITE, 0, sal_Int32(m_pDoc->GetNodes().Count()), diff --git a/sw/source/filter/md/wrtmd.hxx b/sw/source/filter/md/wrtmd.hxx index 78a83af4c0bb..fbc984e191f8 100644 --- a/sw/source/filter/md/wrtmd.hxx +++ b/sw/source/filter/md/wrtmd.hxx @@ -51,6 +51,16 @@ struct SwMDTableInfo const SwEndNode* pEndNode = nullptr; }; +/// Tracks an anchored object to be exported at a specific document model position. +struct SwMDFly +{ + SwNodeOffset m_nAnchorNodeOffset = {}; + sal_Int32 m_nAnchorContentOffset = {}; + const SwFrameFormat* m_pFrameFormat = nullptr; + + bool operator<(const SwMDFly&) const; +}; + class SwMDWriter : public Writer { public: @@ -64,12 +74,14 @@ public: void SetTaskListItems(int nTaskListItems) { m_nTaskListItems = nTaskListItems; } int GetTaskListItems() const { return m_nTaskListItems; } + o3tl::sorted_vector<SwMDFly>& GetFlys() { return m_aFlys; } protected: ErrCode WriteStream() override; private: void Out_SwDoc(SwPaM* pPam); + void CollectFlys(); bool m_bOutTable = false; SwNodeOffset m_nStartNodeIndex{ 0 }; @@ -78,6 +90,8 @@ private: std::stack<SwMDTableInfo> m_aTableInfos; /// Number of currently open task list items. int m_nTaskListItems = 0; + /// Anchored fly frames, e.g. images. + o3tl::sorted_vector<SwMDFly> m_aFlys; }; /* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s cinkeys+=0=break: */
