tdf#173217 editeng,xmloff,oox: keep a hyperlink's name

A hyperlink in Impress or Draw can carry a name, the Hyperlink
dialog's Name field: the description a screen reader announces, and
what PDF export writes as the link annotation's Contents. Saving the
document and loading it again lost it, and a tooltip carried by a
PPTX never arrived at all, so the link fell back to describing itself
by its own visible text.

Commit 8bf8873f30 (tdf#161583
drawinglayer,editeng,svx,sd: add "Name" to SvxURLField ...,
2025-03-06) gave SvxURLField the name and wired the export path to
it, but no filter. It is not reachable over UNO either, and both
filters go through UNO, so neither had anywhere to put it.

Give the URL field a Name property, and read and write it in both
filters: office:name in ODF, and the tooltip of a:hlinkClick in
OOXML. On import that tooltip becomes the name rather than
Representation, which TextRun overwrites with the link's text; a
shape's own hyperlink goes through the same import context and is
unchanged. A Draw or Impress hyperlink now survives both round trips,
as a Writer one already did.

Inserting a field into shape text does not reach
SvxUnoTextField::CreateFieldData: SvxUnoTextBase builds the stored
field with SvxFieldData::Create, which needs the name too, or it is
dropped again on the way in.

Signed-off-by: Mike Kaganski <mike.kaganski@collabora.com>
Change-Id: I43d44d55ab460547bbed8acd04d8938a4ee9a615
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/12270
Tested-by: Jenkins CPCI <releng@collaboraoffice.com>
Reviewed-by: Miklos Vajna <vmiklos@collabora.com>
(cherry picked from commit f6218048f057a229eea7f15c198d850ceedd95dc)
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/211048
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
Tested-by: Jenkins
This commit is contained in:
Mike Kaganski
2026-09-17 08:58:43 +02:00
committed by Xisco Fauli
parent 8a18cad8ef
commit 7f398acee4
15 changed files with 200 additions and 10 deletions
+5
View File
@@ -112,8 +112,13 @@ SvxFieldData* SvxFieldData::Create(const uno::Reference<text::XTextContent>& xTe
xPropSet->getPropertyValue(UNO_TC_PROP_URL_TARGET) >>= aTarget;
xPropSet->getPropertyValue(UNO_TC_PROP_URL) >>= aURL;
xPropSet->getPropertyValue(UNO_TC_PROP_URL_FORMAT) >>= nFmt;
// Calc's own URL field has no name, and a miss here would drop the field
OUString aName;
if (xPropSet->getPropertySetInfo()->hasPropertyByName(UNO_TC_PROP_NAME))
xPropSet->getPropertyValue(UNO_TC_PROP_NAME) >>= aName;
SvxURLField* pData = new SvxURLField(aURL, aRep, aRep.isEmpty() ? SvxURLFormat::Url : SvxURLFormat::Repr);
pData->SetTargetFrame(aTarget);
pData->SetName(aName);
if (static_cast<SvxURLFormat>(nFmt) >= SvxURLFormat::AppDefault &&
static_cast<SvxURLFormat>(nFmt) <= SvxURLFormat::Repr)
pData->SetFormat(static_cast<SvxURLFormat>(nFmt));
+12
View File
@@ -57,6 +57,7 @@ using namespace ::com::sun::star;
#define WID_STRING1 5
#define WID_STRING2 6
#define WID_STRING3 7
#define WID_STRING4 8
class SvxUnoFieldData_Impl
{
@@ -68,6 +69,7 @@ public:
OUString msString1;
OUString msString2;
OUString msString3;
OUString msString4;
util::DateTime maDateTime;
OUString msPresentation;
@@ -96,6 +98,7 @@ static const SfxItemPropertySet* ImplGetFieldItemPropertySet( sal_Int32 mnId )
{ UNO_TC_PROP_URL_REPRESENTATION, WID_STRING1, ::cppu::UnoType<OUString>::get(), 0, 0 },
{ UNO_TC_PROP_URL_TARGET, WID_STRING2, ::cppu::UnoType<OUString>::get(), 0, 0 },
{ UNO_TC_PROP_URL, WID_STRING3, ::cppu::UnoType<OUString>::get(), 0, 0 },
{ UNO_TC_PROP_NAME, WID_STRING4, ::cppu::UnoType<OUString>::get(), 0, 0 },
};
static const SfxItemPropertySet aUrlFieldPropertySet_Impl(aUrlFieldPropertyMap_Impl);
@@ -340,6 +343,7 @@ SvxUnoTextField::SvxUnoTextField( uno::Reference< text::XTextRange > xAnchor, co
mpImpl->msString1 = static_cast<const SvxURLField*>(pData)->GetRepresentation();
mpImpl->msString2 = static_cast<const SvxURLField*>(pData)->GetTargetFrame();
mpImpl->msString3 = static_cast<const SvxURLField*>(pData)->GetURL();
mpImpl->msString4 = static_cast<const SvxURLField*>(pData)->GetName();
mpImpl->mnInt16 = sal::static_int_cast< sal_Int16 >(
static_cast<const SvxURLField*>(pData)->GetFormat());
break;
@@ -426,6 +430,7 @@ std::unique_ptr<SvxFieldData> SvxUnoTextField::CreateFieldData() const noexcept
case text::textfield::Type::URL:
pData.reset( new SvxURLField( mpImpl->msString3, mpImpl->msString1, !mpImpl->msString1.isEmpty() ? SvxURLFormat::Repr : SvxURLFormat::Url ) );
static_cast<SvxURLField*>(pData.get())->SetTargetFrame( mpImpl->msString2 );
static_cast<SvxURLField*>(pData.get())->SetName( mpImpl->msString4 );
if( static_cast<SvxURLFormat>(mpImpl->mnInt16) >= SvxURLFormat::AppDefault &&
static_cast<SvxURLFormat>(mpImpl->mnInt16) <= SvxURLFormat::Repr )
static_cast<SvxURLField*>(pData.get())->SetFormat( static_cast<SvxURLFormat>(mpImpl->mnInt16) );
@@ -722,6 +727,10 @@ void SAL_CALL SvxUnoTextField::setPropertyValue( const OUString& aPropertyName,
if(aValue >>= mpImpl->msString3)
return;
break;
case WID_STRING4:
if(aValue >>= mpImpl->msString4)
return;
break;
}
throw lang::IllegalArgumentException();
@@ -769,6 +778,9 @@ uno::Any SAL_CALL SvxUnoTextField::getPropertyValue( const OUString& PropertyNam
case WID_STRING3:
aValue <<= mpImpl->msString3;
break;
case WID_STRING4:
aValue <<= mpImpl->msString4;
break;
}
return aValue;
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body>
<office:presentation>
<draw:page draw:name="page1" draw:master-page-name="">
<draw:frame draw:name="box1" svg:width="10cm" svg:height="2cm"
svg:x="2cm" svg:y="2cm">
<draw:text-box>
<text:p><text:a xlink:href="https://www.libreoffice.org/"
xlink:type="simple"
office:name="LibreOffice home page">LibreOffice</text:a></text:p>
</draw:text-box>
</draw:frame>
</draw:page>
</office:presentation>
</office:body>
</office:document>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body>
<office:presentation>
<draw:page draw:name="page1" draw:master-page-name="">
<draw:frame draw:name="box1" svg:width="10cm" svg:height="2cm"
svg:x="2cm" svg:y="2cm">
<draw:text-box>
<text:p><text:a xlink:href="https://www.collaboraoffice.com/"
xlink:type="simple">Collabora</text:a></text:p>
</draw:text-box>
</draw:frame>
</draw:page>
</office:presentation>
</office:body>
</office:document>
+44
View File
@@ -9,8 +9,12 @@
#include <test/unoapi_test.hxx>
#include <com/sun/star/beans/XPropertySet.hpp>
#include <com/sun/star/container/XEnumerationAccess.hpp>
#include <com/sun/star/drawing/XDrawPagesSupplier.hpp>
#include <com/sun/star/text/ControlCharacter.hpp>
#include <com/sun/star/text/XTextDocument.hpp>
#include <com/sun/star/text/XTextField.hpp>
using namespace ::com::sun::star;
@@ -325,6 +329,46 @@ CPPUNIT_TEST_FIXTURE(Test, testCustomShapeArrowExport)
"fmla", u"val 66660");
}
CPPUNIT_TEST_FIXTURE(Test, testHyperlinkTooltip)
{
// Given a presentation whose hyperlink carries an accessible name:
loadFromFile(u"hyperlink-name.fodp");
// When saving it to PPTX:
saveAndReload(TestFilter::PPTX);
// Then the name goes out as the tooltip
xmlDocUniquePtr pXmlDoc = parseExport(u"ppt/slides/slide1.xml"_ustr);
assertXPath(pXmlDoc, "//a:hlinkClick", "tooltip", u"LibreOffice home page");
// and it survives reading that file back; the tooltip used to land in Representation,
// which TextRun overwrites with the link's visible text
auto xDoc = mxComponent.queryThrow<drawing::XDrawPagesSupplier>();
auto xPage = xDoc->getDrawPages()->getByIndex(0).queryThrow<drawing::XDrawPage>();
auto xShape = xPage->getByIndex(0).queryThrow<container::XEnumerationAccess>();
auto xParagraphs = xShape->createEnumeration();
auto xParagraph = xParagraphs->nextElement().queryThrow<container::XEnumerationAccess>();
auto xRuns = xParagraph->createEnumeration();
auto xRun = xRuns->nextElement().queryThrow<beans::XPropertySet>();
auto xField = xRun->getPropertyValue(u"TextField"_ustr).queryThrow<text::XTextField>();
OUString aName;
xField.queryThrow<beans::XPropertySet>()->getPropertyValue(u"Name"_ustr) >>= aName;
CPPUNIT_ASSERT_EQUAL(u"LibreOffice home page"_ustr, aName);
}
CPPUNIT_TEST_FIXTURE(Test, testHyperlinkNoTooltip)
{
// Given a presentation whose hyperlink has no name:
loadFromFile(u"hyperlink-no-name.fodp");
// When saving it to PPTX:
save(TestFilter::PPTX);
// Then no attribute is written, rather than an empty one
xmlDocUniquePtr pXmlDoc = parseExport(u"ppt/slides/slide1.xml"_ustr);
assertXPathNoAttribute(pXmlDoc, "//a:hlinkClick", "tooltip");
}
CPPUNIT_TEST_FIXTURE(Test, testCameraRevolutionGrabBag)
{
// Given a PPTX file that contains camera revolution (rotation around z axis) applied shapes
+6 -2
View File
@@ -37,7 +37,7 @@ using namespace ::oox::core;
namespace oox::drawingml {
HyperLinkContext::HyperLinkContext( ContextHandler2Helper const & rParent,
const AttributeList& rAttribs, PropertyMap& aProperties )
const AttributeList& rAttribs, PropertyMap& aProperties, bool bTextRun )
: ContextHandler2( rParent )
, maProperties(aProperties)
{
@@ -58,7 +58,11 @@ HyperLinkContext::HyperLinkContext( ContextHandler2Helper const & rParent,
}
OUString sTooltip = rAttribs.getStringDefaulted( XML_tooltip );
if ( !sTooltip.isEmpty() )
maProperties.setProperty(PROP_Representation, sTooltip);
{
// a shape's map is the shape's own, where Name is its name and Shape::finalizeXShape
// expects the tooltip in Representation
maProperties.setProperty(bTextRun ? PROP_Name : PROP_Representation, sTooltip);
}
OUString sFrame = rAttribs.getStringDefaulted( XML_tgtFrame );
if( !sFrame.isEmpty() )
+2 -1
View File
@@ -32,7 +32,8 @@ public:
HyperLinkContext(
::oox::core::ContextHandler2Helper const & rParent,
const ::oox::AttributeList& rAttribs,
PropertyMap& aProperties );
PropertyMap& aProperties,
bool bTextRun = false );
virtual ~HyperLinkContext() override;
virtual ::oox::core::ContextHandlerRef onCreateContext( ::sal_Int32 Element, const ::oox::AttributeList& rAttribs ) override;
+2 -1
View File
@@ -114,7 +114,8 @@ ContextHandlerRef TextParagraphContext::onCreateContext( sal_Int32 aElementToken
mrParagraph.addRun(pRun);
// parse hyperlink attributes: use HyperLinkContext for that
rtl::Reference<HyperLinkContext> pContext(new HyperLinkContext(
*this, rAttribs, pRun->getTextCharacterProperties().maHyperlinkPropertyMap));
*this, rAttribs, pRun->getTextCharacterProperties().maHyperlinkPropertyMap,
/*bTextRun=*/true));
// but create text run context because HyperLinkContext can't process internal w:r, w:t, etc
return new RegularTextRunContext(*this, std::move(pRun));
}
@@ -172,7 +172,9 @@ ContextHandlerRef TextCharacterPropertiesContext::onCreateContext( sal_Int32 aEl
case A_TOKEN( hlinkClick ): // CT_Hyperlink
case A_TOKEN( hlinkMouseOver ): // CT_Hyperlink
return new HyperLinkContext( *this, rAttribs, mrTextCharacterProperties.maHyperlinkPropertyMap );
return new HyperLinkContext( *this, rAttribs,
mrTextCharacterProperties.maHyperlinkPropertyMap,
/*bTextRun=*/true );
case W_TOKEN( rFonts ):
if( rAttribs.hasAttribute(W_TOKEN(ascii)) )
{
+12 -4
View File
@@ -2943,6 +2943,12 @@ void DrawingML::WriteRunProperties(const Reference<XPropertySet>& rRun, sal_Int3
mAny >>= sURL;
if (!sURL.isEmpty())
{
OUString sName;
if (GetProperty(rXPropSet, u"Name"_ustr))
mAny >>= sName;
// OOXML keeps the accessible name in the tooltip attribute
const auto oTooltip = sax_fastparser::UseIf(sName, !sName.isEmpty());
if (!sURL.match("#action?jump="))
{
bool bExtURL = URLTransformer().isExternalURL(sURL);
@@ -2960,15 +2966,16 @@ void DrawingML::WriteRunProperties(const Reference<XPropertySet>& rRun, sal_Int3
if (bExtURL)
{
if (isDiagaramExport())
mpFS->singleElementNS(XML_a, XML_hlinkClick, FSNS(XML_xmlns, XML_r), mpFB->getNamespaceURL(OOX_NS(officeRel)), FSNS(XML_r, XML_id), sRelId);
mpFS->singleElementNS(XML_a, XML_hlinkClick, FSNS(XML_xmlns, XML_r), mpFB->getNamespaceURL(OOX_NS(officeRel)), FSNS(XML_r, XML_id), sRelId, XML_tooltip, oTooltip);
else
mpFS->singleElementNS(XML_a, XML_hlinkClick, FSNS(XML_r, XML_id), sRelId);
mpFS->singleElementNS(XML_a, XML_hlinkClick, FSNS(XML_r, XML_id), sRelId, XML_tooltip, oTooltip);
}
else
{
mpFS->singleElementNS(
XML_a, XML_hlinkClick, FSNS(XML_r, XML_id), sRelId, XML_action,
sURL.isEmpty() ? "ppaction://noaction" : "ppaction://hlinksldjump");
sURL.isEmpty() ? "ppaction://noaction" : "ppaction://hlinksldjump",
XML_tooltip, oTooltip);
}
}
else
@@ -2976,7 +2983,8 @@ void DrawingML::WriteRunProperties(const Reference<XPropertySet>& rRun, sal_Int3
sal_Int32 nIndex = sURL.indexOf('=');
std::u16string_view aDestination(sURL.subView(nIndex + 1));
mpFS->singleElementNS(XML_a, XML_hlinkClick, FSNS(XML_r, XML_id), "", XML_action,
OUString::Concat("ppaction://hlinkshowjump?jump=") + aDestination);
OUString::Concat("ppaction://hlinkshowjump?jump=") + aDestination,
XML_tooltip, oTooltip);
}
}
}
+2
View File
@@ -909,7 +909,9 @@ class XMLUrlFieldImportContext final : public XMLTextFieldImportContext
{
OUString sURL;
OUString sFrame;
OUString sName;
bool bFrameOK;
bool bNameOK;
public:
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body>
<office:presentation>
<draw:page draw:name="page1" draw:master-page-name="">
<draw:frame draw:name="box1" svg:width="10cm" svg:height="2cm"
svg:x="2cm" svg:y="2cm">
<draw:text-box>
<text:p><text:a xlink:href="https://www.libreoffice.org/"
xlink:type="simple"
office:name="LibreOffice home page">LibreOffice</text:a></text:p>
</draw:text-box>
</draw:frame>
</draw:page>
</office:presentation>
</office:body>
</office:document>
+24
View File
@@ -28,6 +28,7 @@
#include <com/sun/star/table/XTableChart.hpp>
#include <com/sun/star/table/XTableCharts.hpp>
#include <com/sun/star/table/XTableChartsSupplier.hpp>
#include <com/sun/star/text/XTextField.hpp>
#include <com/sun/star/text/XTextRange.hpp>
#include <com/sun/star/text/XTextTable.hpp>
#include <com/sun/star/text/GraphicCrop.hpp>
@@ -99,6 +100,29 @@ uno::Reference<drawing::XShape> XmloffDrawTest::getShape(sal_uInt8 nShapeIndex)
return xShape;
}
CPPUNIT_TEST_FIXTURE(XmloffDrawTest, testHyperlinkName)
{
// the accessible name of a hyperlink
loadFromFile(u"hyperlink-name.fodp");
auto getHyperlinkName = [this]() -> OUString {
uno::Reference<beans::XPropertySet> xPortion(getShapeTextPortion(0, getShape(0)));
CPPUNIT_ASSERT(xPortion.is());
auto xField = xPortion->getPropertyValue(u"TextField"_ustr).queryThrow<text::XTextField>();
OUString aName;
xField.queryThrow<beans::XPropertySet>()->getPropertyValue(u"Name"_ustr) >>= aName;
return aName;
};
CPPUNIT_ASSERT_EQUAL(u"LibreOffice home page"_ustr, getHyperlinkName());
saveAndReload(TestFilter::ODP);
// office:name was dropped on import and never written on export, so the name a document
// carried did not survive a round trip
CPPUNIT_ASSERT_EQUAL(u"LibreOffice home page"_ustr, getHyperlinkName());
}
CPPUNIT_TEST_FIXTURE(XmloffDrawTest, testTextBoxLoss)
{
// Load a document that has a shape with a textbox in it. Save it to ODF and reload.
+2
View File
@@ -1744,6 +1744,8 @@ void XMLTextFieldExport::ExportFieldHelper(
ProcessString(XML_TARGET_FRAME_NAME,
GetStringProperty(gsPropertyTargetFrame,rPropSet),
true, XML_NAMESPACE_OFFICE);
ProcessString(XML_NAME, GetStringProperty(u"Name"_ustr, rPropSet),
true, XML_NAMESPACE_OFFICE);
GetExport().AddAttribute( XML_NAMESPACE_XLINK, XML_TYPE, XML_SIMPLE );
SvXMLElementExport aUrlField(rExport, XML_NAMESPACE_TEXT, XML_A,
false, false);
+12 -1
View File
@@ -2879,7 +2879,8 @@ XMLUrlFieldImportContext::XMLUrlFieldImportContext(
SvXMLImport& rImport,
XMLTextImportHelper& rHlp) :
XMLTextFieldImportContext(rImport, rHlp, sAPI_url),
bFrameOK(false)
bFrameOK(false),
bNameOK(false)
{
}
@@ -2897,6 +2898,10 @@ void XMLUrlFieldImportContext::ProcessAttribute(
sFrame = OUString::fromUtf8(sAttrValue);
bFrameOK = true;
break;
case XML_ELEMENT(OFFICE, XML_NAME):
sName = OUString::fromUtf8(sAttrValue);
bNameOK = true;
break;
default:
// ignore
XMLOFF_WARN_UNKNOWN_ATTR("xmloff", nAttrToken, sAttrValue);
@@ -2914,6 +2919,12 @@ void XMLUrlFieldImportContext::PrepareField(
xPropertySet->setPropertyValue(u"TargetFrame"_ustr, Any(sFrame));
}
// Calc's own URL field has no name, and this runs outside the try around InsertTextContent
if (bNameOK && xPropertySet->getPropertySetInfo()->hasPropertyByName(u"Name"_ustr))
{
xPropertySet->setPropertyValue(u"Name"_ustr, Any(sName));
}
xPropertySet->setPropertyValue(u"Representation"_ustr, Any(GetContent()));
}