From 6e785d7b0b1fb1b069d6ca6c23a95ecd018d8fc3 Mon Sep 17 00:00:00 2001 From: Mike Kaganski Date: Mon, 7 Sep 2026 04:12:43 +0500 Subject: [PATCH] tdf#163240 sw: subdivide a Link by Spans The commit "tdf#163240 vcl,sw: keep one Link tag for a wrapped link" made language, baseline shift and decoration come from the link's first portion for the whole link, and noted subdividing by Spans as the fix: those three are attributes of the Link tag, and there is now one tag per hyperlink, so a change inside it had nowhere to go. Open a Span in the Link instead. A span waits inside its link, so two tags can be open at once, and an annotation goes to the link's own tag - vcl takes it on a Link only, and a styled run crossing a line break would otherwise leave that line's annotation without a StructParent. Signed-off-by: Mike Kaganski Change-Id: I302d32f66fcb4e3b1eaf63b136bdcf96aaea17b7 Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/11543 Tested-by: Jenkins CPCI Reviewed-by: Miklos Vajna (cherry picked from commit 8f52d63f897aaf710dfe4cd9771f39689471abfd) Reviewed-on: https://gerrit.libreoffice.org/c/core/+/211101 Tested-by: Jenkins Reviewed-by: Xisco Fauli --- sw/inc/EnhancedPDFExportHelper.hxx | 11 +- .../core/text/EnhancedPDFExportHelper.cxx | 172 ++++++++------ .../pdfexport/data/LinkSpanWithFly.fodt | 32 +++ .../cppunit/pdfexport/data/LinkSpanWraps.fodt | 32 +++ .../cppunit/pdfexport/data/LinkWithSpan.fodt | 32 +++ vcl/qa/cppunit/pdfexport/pdfexport.cxx | 222 ++++++++++++++++++ 6 files changed, 434 insertions(+), 67 deletions(-) create mode 100644 vcl/qa/cppunit/pdfexport/data/LinkSpanWithFly.fodt create mode 100644 vcl/qa/cppunit/pdfexport/data/LinkSpanWraps.fodt create mode 100644 vcl/qa/cppunit/pdfexport/data/LinkWithSpan.fodt diff --git a/sw/inc/EnhancedPDFExportHelper.hxx b/sw/inc/EnhancedPDFExportHelper.hxx index cd02d0d5284f..a71cea0c8ec6 100644 --- a/sw/inc/EnhancedPDFExportHelper.hxx +++ b/sw/inc/EnhancedPDFExportHelper.hxx @@ -167,10 +167,19 @@ class SwTaggedPDFHelper void BeginInlineStructureElements(); void EndStructureElements(); + // what an open link or span leaves for this portion to do + enum class Continuation + { + None, // nothing is open that covers it + Whole, // the open tag covers it, so no new tag + SpanInLink // the link goes on, but its properties do not + }; + void EndCurrentAll(); void EndCurrentSpan(); void CreateCurrentSpan(SwTextPaintInfo const& rInf, OUString const& rStyleName); - bool CheckContinueSpan(SwTextPaintInfo const& rInf, std::u16string_view rStyleName, SwTextAttr const* pInetFormatAttr); + Continuation CheckContinuation(SwTextPaintInfo const& rInf, OUString const& rStyleName, + SwTextAttr const* pInetFormatAttr); bool CheckReopenTag(); void CheckRestoreTag() const; diff --git a/sw/source/core/text/EnhancedPDFExportHelper.cxx b/sw/source/core/text/EnhancedPDFExportHelper.cxx index 97dfad02e1f2..ffe98c7b350a 100644 --- a/sw/source/core/text/EnhancedPDFExportHelper.cxx +++ b/sw/source/core/text/EnhancedPDFExportHelper.cxx @@ -164,6 +164,8 @@ struct SwEnhancedPDFState SwFontScript nScript; LanguageType nLang; OUString StyleName; + + bool operator==(const Span&) const = default; }; struct Link @@ -172,6 +174,8 @@ struct SwEnhancedPDFState sal_Int32 nStructElement; // the annotations already nested in it std::vector AnnotIds; + // the properties the Link's own tag carries + Span aSpan; }; ::std::optional m_oCurrentSpan; @@ -732,7 +736,7 @@ void SwTaggedPDFHelper::EndTag() void SwTaggedPDFHelper::DeferTag() { assert(!m_aOpenedTags.empty()); - assert(mpPDFExtOutDevData->GetSwPDFState()->m_DeferredTags.empty()); + assert(mpPDFExtOutDevData->GetSwPDFState()->m_DeferredTags.size() <= 1); // a span in a link assert(mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan || mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink); mpPDFExtOutDevData->GetSwPDFState()->m_DeferredTags.push_back(m_aOpenedTags.back()); @@ -750,9 +754,18 @@ void SwTaggedPDFHelper::EndDeferredTag() { assert(IsDeferredTagCurrent()); auto& rState(*mpPDFExtOutDevData->GetSwPDFState()); - assert(!rState.m_oCurrentSpan || !rState.m_oCurrentLink); // one reset covers both - rState.m_oCurrentSpan.reset(); - rState.m_oCurrentLink.reset(); + // one deferred tag per open span or link + assert(rState.m_DeferredTags.size() + == (rState.m_oCurrentSpan ? 1u : 0u) + (rState.m_oCurrentLink ? 1u : 0u)); + // a span is inside the link, so it is the innermost of the two + if (rState.m_oCurrentSpan) + { + rState.m_oCurrentSpan.reset(); + } + else + { + rState.m_oCurrentLink.reset(); + } rState.m_DeferredTags.pop_back(); EndTag(); } @@ -787,7 +800,11 @@ namespace { return; sal_Int32 nLinkId = (*aIter).second; - if (roCurrentLink && roCurrentLink->nStructElement == nCurrentSE) // not a nested SE + // a Link of its own, for a footnote or a fly, is not the link being subdivided + const auto& rDeferredTags(rPDFExtOutDevData.GetSwPDFState()->m_DeferredTags); + const bool bInSpanOfLink(roCurrentLink && rPDFExtOutDevData.GetSwPDFState()->m_oCurrentSpan + && !rDeferredTags.empty() && rDeferredTags.back() == nCurrentSE); + if (roCurrentLink && (roCurrentLink->nStructElement == nCurrentSE || bInSpanOfLink)) { // every portion of a line finds that line's annotation again if (std::find(roCurrentLink->AnnotIds.begin(), roCurrentLink->AnnotIds.end(), nLinkId) @@ -796,6 +813,15 @@ namespace { return; } roCurrentLink->AnnotIds.push_back(nLinkId); + if (bInSpanOfLink) + { + // vcl accepts the attribute on a Link, so set it on the link + rPDFExtOutDevData.SetCurrentStructureElement(roCurrentLink->nStructElement); + rPDFExtOutDevData.SetStructureAttributeNumerical( + vcl::pdf::PDFWriter::LinkAnnotation, nLinkId); + rPDFExtOutDevData.SetCurrentStructureElement(nCurrentSE); + return; + } } rPDFExtOutDevData.SetStructureAttributeNumerical(vcl::pdf::PDFWriter::LinkAnnotation, nLinkId); @@ -1975,73 +2001,78 @@ void SwTaggedPDFHelper::EndCurrentSpan() EndDeferredTag(); } +namespace +{ +// the properties a Span tag would carry for this portion +SwEnhancedPDFState::Span MakeSpan(const SwTextPaintInfo& rInf, const OUString& rStyleName) +{ + return { .eUnderline = rInf.GetFont()->GetUnderline(), + .eOverline = rInf.GetFont()->GetOverline(), + .eStrikeout = rInf.GetFont()->GetStrikeout(), + .eFontEmphasis = rInf.GetFont()->GetEmphasisMark(), + .nEscapement = rInf.GetFont()->GetEscapement(), + .nScript = rInf.GetFont()->GetActual(), + .nLang = rInf.GetFont()->GetLanguage(), + .StyleName = rStyleName }; +} +} + void SwTaggedPDFHelper::CreateCurrentSpan( SwTextPaintInfo const& rInf, OUString const& rStyleName) { assert(!mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan); - mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan.emplace( - SwEnhancedPDFState::Span{ - rInf.GetFont()->GetUnderline(), - rInf.GetFont()->GetOverline(), - rInf.GetFont()->GetStrikeout(), - rInf.GetFont()->GetEmphasisMark(), - rInf.GetFont()->GetEscapement(), - rInf.GetFont()->GetActual(), - rInf.GetFont()->GetLanguage(), - rStyleName}); + mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan.emplace(MakeSpan(rInf, rStyleName)); } -bool SwTaggedPDFHelper::CheckContinueSpan( - SwTextPaintInfo const& rInf, std::u16string_view const rStyleName, - SwTextAttr const*const pInetFormatAttr) +SwTaggedPDFHelper::Continuation +SwTaggedPDFHelper::CheckContinuation(SwTextPaintInfo const& rInf, OUString const& rStyleName, + SwTextAttr const* const pInetFormatAttr) { - // for now, don't create span inside of link - this should be very rare - // situation and it looks complicated to implement. - assert(!mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan - || !mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink); - if (mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink) + auto& rState(*mpPDFExtOutDevData->GetSwPDFState()); + if (rState.m_oCurrentLink) { - if (pInetFormatAttr - && pInetFormatAttr == mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink->pAttr) + if (!pInetFormatAttr || pInetFormatAttr != rState.m_oCurrentLink->pAttr) { - return true; - } - else - { - if (IsDeferredTagCurrent()) // a ruby leaves it to the paragraph + // the span is inside the link, so it closes first + if (rState.m_oCurrentSpan && IsDeferredTagCurrent()) + { + EndCurrentSpan(); + } + if (IsDeferredTagCurrent()) { EndDeferredTag(); } - return false; + return Continuation::None; } - } - if (mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan && pInetFormatAttr) - { - if (IsDeferredTagCurrent()) // a fly leaves it to the paragraph + // the link goes on; a property change subdivides it instead of splitting it + const SwEnhancedPDFState::Span aSpan(MakeSpan(rInf, rStyleName)); + if (rState.m_oCurrentSpan) { - EndCurrentSpan(); + if (aSpan == *rState.m_oCurrentSpan) + { + return Continuation::Whole; + } + if (IsDeferredTagCurrent()) + { + EndCurrentSpan(); + } } - return false; + return aSpan == rState.m_oCurrentLink->aSpan ? Continuation::Whole + : Continuation::SpanInLink; } - - if (!mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan) - return false; - - SwEnhancedPDFState::Span const& rCurrent(*mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentSpan); - - bool const ret(rCurrent.eUnderline == rInf.GetFont()->GetUnderline() - && rCurrent.eOverline == rInf.GetFont()->GetOverline() - && rCurrent.eStrikeout == rInf.GetFont()->GetStrikeout() - && rCurrent.eFontEmphasis == rInf.GetFont()->GetEmphasisMark() - && rCurrent.nEscapement == rInf.GetFont()->GetEscapement() - && rCurrent.nScript == rInf.GetFont()->GetActual() - && rCurrent.nLang == rInf.GetFont()->GetLanguage() - && rCurrent.StyleName == rStyleName); - if (!ret && IsDeferredTagCurrent()) // a fly leaves it to the paragraph + if (!rState.m_oCurrentSpan) + { + return Continuation::None; + } + if (!pInetFormatAttr && MakeSpan(rInf, rStyleName) == *rState.m_oCurrentSpan) + { + return Continuation::Whole; + } + if (IsDeferredTagCurrent()) // a fly in the span leaves it to the paragraph { EndCurrentSpan(); } - return ret; + return Continuation::None; } void SwTaggedPDFHelper::BeginInlineStructureElements() @@ -2072,12 +2103,14 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() SwStyleNameMapper::FillProgName( pCharFormat->GetName(), sStyleName, SwGetPoolIdFromName::TxtColl ); } + const OUString aStyleName(sStyleName.toString()); // note: ILSE may be nested, so only end the span if needed to start new one - bool const isContinueSpan(CheckContinueSpan(rInf, sStyleName.toString(), pInetFormatAttr)); + const Continuation eContinuation(CheckContinuation(rInf, aStyleName, pInetFormatAttr)); + const sal_Int32 nParentSE(mpPDFExtOutDevData->GetCurrentStructureElement()); const auto& rDeferredTags(mpPDFExtOutDevData->GetSwPDFState()->m_DeferredTags); - // only one tag waits for the next portion; a ruby closes the rest with itself - const bool bCanDefer(rDeferredTags.empty()); + // a span may be deferred inside its link, but not inside a ruby + const bool bCanDefer(rDeferredTags.empty() || rDeferredTags.back() == nParentSE); sal_uInt16 nPDFType = USHRT_MAX; OUString aPDFType; bool bDeferTag(false); @@ -2113,7 +2146,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() // Check for Link: if( pInetFormatAttr ) { - if (!isContinueSpan) + if (eContinuation == Continuation::None) { nPDFType = sal_uInt16(vcl::pdf::StructElement::Link); aPDFType = aLinkString; @@ -2121,7 +2154,8 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() { assert(!mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink); mpPDFExtOutDevData->GetSwPDFState()->m_oCurrentLink.emplace( - SwEnhancedPDFState::Link{ pInetFormatAttr, -1, {} }); + SwEnhancedPDFState::Link{ + pInetFormatAttr, -1, {}, MakeSpan(rInf, aStyleName) }); bDeferTag = true; } } @@ -2131,12 +2165,18 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() SwRect aPorRect; rInf.CalcRect(*pPor, &aPorRect); LinkLinkLink(*mpPDFExtOutDevData, aPorRect); + if (eContinuation == Continuation::SpanInLink) + { + nPDFType = sal_uInt16(vcl::pdf::StructElement::Span); + aPDFType = aSpanString; + bDeferSpan = bCanDefer; + } } } // Emphasis else if (sStyleName == constEmphasisStyleName) { - if (!isContinueSpan) + if (eContinuation == Continuation::None) { nPDFType = sal_uInt16(vcl::pdf::StructElement::Emphasis); aPDFType = constEmphasisStyleName; @@ -2146,7 +2186,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() // Strong else if (sStyleName == constStrongEmphasisStyleName) { - if (!isContinueSpan) + if (eContinuation == Continuation::None) { nPDFType = sal_uInt16(vcl::pdf::StructElement::Strong); aPDFType = constStrongEmphasisStyleName; @@ -2156,7 +2196,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() // Check for Quote/Code character style: else if (sStyleName == aQuotation) { - if (!isContinueSpan) + if (eContinuation == Continuation::None) { nPDFType = sal_uInt16(vcl::pdf::StructElement::Quote); aPDFType = aQuoteString; @@ -2165,14 +2205,14 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() } else if (sStyleName == aSourceText) { - if (!isContinueSpan) + if (eContinuation == Continuation::None) { nPDFType = sal_uInt16(vcl::pdf::StructElement::Code); aPDFType = aCodeString; bDeferSpan = bCanDefer; } } - else if (!isContinueSpan) + else if (eContinuation == Continuation::None) { const LanguageType nCurrentLanguage = rInf.GetFont()->GetLanguage(); const SwFontScript nFont = rInf.GetFont()->GetActual(); @@ -2274,7 +2314,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() // for FootnoteNum, is called twice: outer generates Lbl, inner Link case PortionType::FootnoteNum: - assert(!isContinueSpan); // is at start + assert(eContinuation == Continuation::None); // is at start if (mpPorInfo->m_Mode == 0) { // tdf#152218 link both directions nPDFType = sal_uInt16(vcl::pdf::StructElement::Link); @@ -2285,7 +2325,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() case PortionType::Number: case PortionType::Bullet: case PortionType::GrfNum: - assert(!isContinueSpan); // is at start + assert(eContinuation == Continuation::None); // is at start if (mpPorInfo->m_Mode == 1) { // only works for multiple lines via wrapper from PaintSwFrame nPDFType = sal_uInt16(vcl::pdf::StructElement::LILabel); @@ -2307,7 +2347,7 @@ void SwTaggedPDFHelper::BeginInlineStructureElements() BeginTag( static_cast(nPDFType), aPDFType ); if (bDeferSpan) { - CreateCurrentSpan(rInf, sStyleName.toString()); + CreateCurrentSpan(rInf, aStyleName); bDeferTag = true; } if (bDeferTag) diff --git a/vcl/qa/cppunit/pdfexport/data/LinkSpanWithFly.fodt b/vcl/qa/cppunit/pdfexport/data/LinkSpanWithFly.fodt new file mode 100644 index 000000000000..2c1141bb564e --- /dev/null +++ b/vcl/qa/cppunit/pdfexport/data/LinkSpanWithFly.fodt @@ -0,0 +1,32 @@ + + + Link with a span and an as-character frame + + + + + + + + + + + + + + + + + + + + + + + + + + plain before deutsch inner weiter after + + + diff --git a/vcl/qa/cppunit/pdfexport/data/LinkSpanWraps.fodt b/vcl/qa/cppunit/pdfexport/data/LinkSpanWraps.fodt new file mode 100644 index 000000000000..2b86022e724e --- /dev/null +++ b/vcl/qa/cppunit/pdfexport/data/LinkSpanWraps.fodt @@ -0,0 +1,32 @@ + + + Link with a wrapped language change + + + + + + + + + + + + + + + + + + + + + + + + + + This hyperlink starts in the document language and then continues in German for long enough that the run itself has to break onto a second line, and the link ends with it + + + diff --git a/vcl/qa/cppunit/pdfexport/data/LinkWithSpan.fodt b/vcl/qa/cppunit/pdfexport/data/LinkWithSpan.fodt new file mode 100644 index 000000000000..113d8837e252 --- /dev/null +++ b/vcl/qa/cppunit/pdfexport/data/LinkWithSpan.fodt @@ -0,0 +1,32 @@ + + + Link with a language change + + + + + + + + + + + + + + + + + + + + + + + + + + before deutsch after + + + diff --git a/vcl/qa/cppunit/pdfexport/pdfexport.cxx b/vcl/qa/cppunit/pdfexport/pdfexport.cxx index 2828f98d523a..2306cffbad57 100644 --- a/vcl/qa/cppunit/pdfexport/pdfexport.cxx +++ b/vcl/qa/cppunit/pdfexport/pdfexport.cxx @@ -3183,6 +3183,139 @@ CPPUNIT_TEST_FIXTURE(PdfExportTest, testLinkWithRuby) CPPUNIT_ASSERT(std::find(aRubyKids.begin(), aRubyKids.end(), "RT"_ostr) != aRubyKids.end()); } +CPPUNIT_TEST_FIXTURE(PdfExportTest, testLinkWithSpan) +{ + uno::Sequence aFilterData{ comphelper::makePropertyValue(u"PDFUACompliance"_ustr, true) }; + + vcl::filter::PDFDocument aDocument; + loadFromFile(u"LinkWithSpan.fodt"); + save(TestFilter::PDF_WRITER, + { comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) }); + + SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); + CPPUNIT_ASSERT(aDocument.Read(aStream)); + + vcl::filter::PDFObjectElement* pLinkSE(nullptr); + for (const auto& rDocElement : aDocument.GetElements()) + { + auto pObject = dynamic_cast(rDocElement.get()); + if (!pObject) + continue; + auto pType = dynamic_cast(pObject->Lookup("Type"_ostr)); + auto pS = dynamic_cast(pObject->Lookup("S"_ostr)); + if (pType && pType->GetValue() == "StructElem" && pS && pS->GetValue() == "Link") + { + CPPUNIT_ASSERT_MESSAGE("a language change must not split the link", !pLinkSE); + pLinkSE = pObject; + } + } + CPPUNIT_ASSERT(pLinkSE); + + // the link keeps the language of its first portion, so the German must not be on it + auto pLinkLang + = dynamic_cast(pLinkSE->Lookup("Lang"_ostr)); + if (pLinkLang) + { + CPPUNIT_ASSERT(pLinkLang->GetValue() != "de-DE"_ostr); + } + + // the run that differs is a Span inside it, not a second Link + auto pKids = dynamic_cast(pLinkSE->Lookup("K"_ostr)); + CPPUNIT_ASSERT(pKids); + size_t nMCID(0); + size_t nMCIDBeforeSpan(0); + vcl::filter::PDFObjectElement* pSpanSE(nullptr); + for (const auto pElement : pKids->GetElements()) + { + if (dynamic_cast(pElement)) + { + ++nMCID; + continue; + } + auto pRef = dynamic_cast(pElement); + if (!pRef) + continue; // the OBJR for the annotation + auto pKid = pRef->LookupObject(); + CPPUNIT_ASSERT(pKid); + auto pKidS = dynamic_cast(pKid->Lookup("S"_ostr)); + CPPUNIT_ASSERT(pKidS); + CPPUNIT_ASSERT_EQUAL("Span"_ostr, pKidS->GetValue()); + CPPUNIT_ASSERT(!pSpanSE); + pSpanSE = pKid; + nMCIDBeforeSpan = nMCID; + } + CPPUNIT_ASSERT(pSpanSE); + CPPUNIT_ASSERT_EQUAL(size_t(2), nMCID); + // it reads between the text before it and the text after it + CPPUNIT_ASSERT_EQUAL(size_t(1), nMCIDBeforeSpan); + + auto pSpanLang + = dynamic_cast(pSpanSE->Lookup("Lang"_ostr)); + CPPUNIT_ASSERT(pSpanLang); + CPPUNIT_ASSERT_EQUAL("de-DE"_ostr, pSpanLang->GetValue()); +} + +CPPUNIT_TEST_FIXTURE(PdfExportTest, testLinkSpanWithFly) +{ + uno::Sequence aFilterData{ comphelper::makePropertyValue(u"PDFUACompliance"_ustr, true) }; + + vcl::filter::PDFDocument aDocument; + loadFromFile(u"LinkSpanWithFly.fodt"); + save(TestFilter::PDF_WRITER, + { comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) }); + + SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); + CPPUNIT_ASSERT(aDocument.Read(aStream)); + + // the frame's own paragraph is current when the link ends, so neither the span nor + // the link closes there - one link, one span, and the frame nested in the span + vcl::filter::PDFObjectElement* pLinkSE(nullptr); + vcl::filter::PDFObjectElement* pSpanSE(nullptr); + for (const auto& rDocElement : aDocument.GetElements()) + { + auto pObject = dynamic_cast(rDocElement.get()); + if (!pObject) + continue; + auto pType = dynamic_cast(pObject->Lookup("Type"_ostr)); + auto pS = dynamic_cast(pObject->Lookup("S"_ostr)); + if (!pType || pType->GetValue() != "StructElem" || !pS) + continue; + if (pS->GetValue() == "Link") + { + CPPUNIT_ASSERT_MESSAGE("a frame must not split the link", !pLinkSE); + pLinkSE = pObject; + } + else if (pS->GetValue() == "Span") + { + CPPUNIT_ASSERT_MESSAGE("a frame must not split the span", !pSpanSE); + pSpanSE = pObject; + } + } + CPPUNIT_ASSERT(pLinkSE); + CPPUNIT_ASSERT(pSpanSE); + + // the span is a kid of the link, and the frame a kid of the span + auto pSpanParent = dynamic_cast(pSpanSE->Lookup("P"_ostr)); + CPPUNIT_ASSERT(pSpanParent); + CPPUNIT_ASSERT_EQUAL(pLinkSE, pSpanParent->LookupObject()); + auto pSpanKids = dynamic_cast(pSpanSE->Lookup("K"_ostr)); + CPPUNIT_ASSERT(pSpanKids); + size_t nDiv(0); + for (const auto pElement : pSpanKids->GetElements()) + { + auto pRef = dynamic_cast(pElement); + if (!pRef) + continue; + auto pKid = pRef->LookupObject(); + CPPUNIT_ASSERT(pKid); + auto pKidS = dynamic_cast(pKid->Lookup("S"_ostr)); + CPPUNIT_ASSERT(pKidS); + CPPUNIT_ASSERT_EQUAL("Div"_ostr, pKidS->GetValue()); + ++nDiv; + } + CPPUNIT_ASSERT_EQUAL(size_t(1), nDiv); +} + CPPUNIT_TEST_FIXTURE(PdfExportTest, testSpanWithFly) { uno::Sequence aFilterData{ comphelper::makePropertyValue(u"PDFUACompliance"_ustr, true) }; @@ -3238,6 +3371,95 @@ CPPUNIT_TEST_FIXTURE(PdfExportTest, testSpanWithFly) CPPUNIT_ASSERT_GREATER(size_t(1), nMCID); } +CPPUNIT_TEST_FIXTURE(PdfExportTest, testLinkSpanWraps) +{ + uno::Sequence aFilterData{ comphelper::makePropertyValue(u"PDFUACompliance"_ustr, true) }; + + vcl::filter::PDFDocument aDocument; + loadFromFile(u"LinkSpanWraps.fodt"); + save(TestFilter::PDF_WRITER, + { comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) }); + + SvFileStream aStream(maTempFile.GetURL(), StreamMode::READ); + CPPUNIT_ASSERT(aDocument.Read(aStream)); + + std::vector aPages = aDocument.GetPages(); + CPPUNIT_ASSERT_EQUAL(size_t(1), aPages.size()); + auto pAnnots = dynamic_cast(aPages[0]->Lookup("Annots"_ostr)); + CPPUNIT_ASSERT(pAnnots); + // how many lines the link takes does not matter, only that the styled run breaks + CPPUNIT_ASSERT_GREATER(size_t(1), pAnnots->GetElements().size()); + + vcl::filter::PDFObjectElement* pLinkSE(nullptr); + for (const auto& rDocElement : aDocument.GetElements()) + { + auto pObject = dynamic_cast(rDocElement.get()); + if (!pObject) + continue; + auto pType = dynamic_cast(pObject->Lookup("Type"_ostr)); + auto pS = dynamic_cast(pObject->Lookup("S"_ostr)); + if (pType && pType->GetValue() == "StructElem" && pS && pS->GetValue() == "Link") + { + CPPUNIT_ASSERT_MESSAGE("a wrapped span must not split the link", !pLinkSE); + pLinkSE = pObject; + } + } + CPPUNIT_ASSERT(pLinkSE); + + // both lines' annotations reach the Link, although the span is what is open + // across the break + auto pKids = dynamic_cast(pLinkSE->Lookup("K"_ostr)); + CPPUNIT_ASSERT(pKids); + std::vector aKidAnnots; + for (const auto pElement : pKids->GetElements()) + { + auto pObjR = dynamic_cast(pElement); + if (!pObjR) + continue; + auto pOType = dynamic_cast(pObjR->LookupElement("Type"_ostr)); + CPPUNIT_ASSERT(pOType); + CPPUNIT_ASSERT_EQUAL("OBJR"_ostr, pOType->GetValue()); + auto pRef + = dynamic_cast(pObjR->LookupElement("Obj"_ostr)); + CPPUNIT_ASSERT(pRef); + aKidAnnots.push_back(pRef->LookupObject()); + } + CPPUNIT_ASSERT_EQUAL(pAnnots->GetElements().size(), aKidAnnots.size()); + CPPUNIT_ASSERT_EQUAL(aKidAnnots.size(), std::set(aKidAnnots.begin(), aKidAnnots.end()).size()); + + // the span is what straddles the break, so it holds a run of text on each line + vcl::filter::PDFObjectElement* pSpanSE(nullptr); + for (const auto pElement : pKids->GetElements()) + { + auto pRef = dynamic_cast(pElement); + if (!pRef) + continue; + auto pKid = pRef->LookupObject(); + CPPUNIT_ASSERT(pKid); + auto pKidS = dynamic_cast(pKid->Lookup("S"_ostr)); + CPPUNIT_ASSERT(pKidS); + CPPUNIT_ASSERT_EQUAL("Span"_ostr, pKidS->GetValue()); + CPPUNIT_ASSERT(!pSpanSE); + pSpanSE = pKid; + } + CPPUNIT_ASSERT(pSpanSE); + auto pSpanKids = dynamic_cast(pSpanSE->Lookup("K"_ostr)); + CPPUNIT_ASSERT(pSpanKids); + CPPUNIT_ASSERT_GREATER(size_t(1), pSpanKids->GetElements().size()); + + // and every annotation carries a StructParent and is one of the Link's OBJR kids + for (const auto pElement : pAnnots->GetElements()) + { + auto pAnnotRef = dynamic_cast(pElement); + CPPUNIT_ASSERT(pAnnotRef); + auto pAnnot = pAnnotRef->LookupObject(); + CPPUNIT_ASSERT(pAnnot); + CPPUNIT_ASSERT_MESSAGE("every annotation needs a StructParent", + pAnnot->Lookup("StructParent"_ostr)); + CPPUNIT_ASSERT(std::find(aKidAnnots.begin(), aKidAnnots.end(), pAnnot) != aKidAnnots.end()); + } +} + CPPUNIT_TEST_FIXTURE(PdfExportTest, testRubyWithSpan) { uno::Sequence aFilterData{ comphelper::makePropertyValue(u"PDFUACompliance"_ustr, true) };