tdf#166401: support custom placeholder prompt text

A slide layout is imported as a master page, and the shown text was only set
off a master, so an authored prompt was stored but never displayed or written
back; the export emitted Impress's own localized resource instead. Set it on
a master too, and accept the graphic kind in RestoreDefaultText, which keeps
the object an empty presentation object. Now that the two are
distinguishable, a placeholder that authored no prompt writes no text - its
text body keeps the properties a reader needs, and holds an empty paragraph.

A slide's own placeholder asks its master for that prompt before falling back
to the resource string, so the two no longer disagree about what is really
one placeholder.

This is what degraded in commit 73f1e76b1760d4e936aa22627623c922dcda964b
(cool#15754: Fix PPTX round-trip of empty picture placeholders, 2026-05-21):
mapping a layout <p:ph type="pic"/> to a real presentation service is what
made it reach that text reset, where the previous OutlinerShape mapping had
kept the authored prompt.

Implemented a TODO from commit 67ba6ccd5a
(tdf#140912, tdf#159219: fix import of graphic placeholder with custom
prompt, 2024-01-17). That commit deferred this because marking the object
empty after setting the text crashed the PPTX export; going through
RestoreDefaultText, which does exactly that, no longer does. Its other
objection stands: ODF stores no placeholder prompt, so a save to ODP still
drops it.

Signed-off-by: Mike Kaganski <mike.kaganski@collabora.com>
Change-Id: I1b6f3e38cb24ac737d3de6258559090596817bad
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/9046
Tested-by: Jenkins CPCI <releng@collaboraoffice.com>
Reviewed-by: Caolán McNamara <caolan.mcnamara@collabora.com>
(cherry picked from commit b41a780d139ec6ca5a5514db972ccf0e50058dfc)
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/210008
Tested-by: Jenkins
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
This commit is contained in:
Mike Kaganski
2026-08-26 12:20:37 +02:00
committed by Xisco Fauli
parent 280d307535
commit 47aa325014
7 changed files with 94 additions and 18 deletions
+3 -1
View File
@@ -178,7 +178,9 @@ public:
* @return <tt>*this</tt>
*/
ShapeExport& WriteShape( const css::uno::Reference< css::drawing::XShape >& xShape );
ShapeExport& WriteTextBox( const css::uno::Reference< css::uno::XInterface >& xIface, sal_Int32 nXmlNamespace, bool bWritePropertiesAsLstStyles = false );
/** @param bText false writes the body properties and an empty paragraph, for a shape whose
text belongs to the application rather than to the file - a placeholder's prompt. */
ShapeExport& WriteTextBox( const css::uno::Reference< css::uno::XInterface >& xIface, sal_Int32 nXmlNamespace, bool bWritePropertiesAsLstStyles = false, bool bText = true );
virtual ShapeExport&
WriteTextShape( const css::uno::Reference< css::drawing::XShape >& xShape );
ShapeExport&
+2 -2
View File
@@ -2225,8 +2225,8 @@ Reference< XShape > const & Shape::createAndInsert(
// Make sure to not set text to placeholders. Doing it here would eventually call
// SvxTextEditSourceImpl::UpdateData, SdrObject::SetEmptyPresObj(false), and that
// would make the object behave like a standard outline object.
// TODO/FIXME: support custom prompt text in placeholders.
// would make the object behave like a standard outline object. An authored prompt
// reaches the object through the CustomPromptText property instead.
if (rServiceName == "com.sun.star.presentation.GraphicObjectShape")
mpTextBody.reset();
+5 -2
View File
@@ -2333,7 +2333,7 @@ static bool lcl_isTextBox(const Reference<XInterface>& xIface)
return aTextBox.get<bool>();
}
ShapeExport& ShapeExport::WriteTextBox( const Reference< XInterface >& xIface, sal_Int32 nXmlNamespace, bool bWritePropertiesAsLstStyles )
ShapeExport& ShapeExport::WriteTextBox( const Reference< XInterface >& xIface, sal_Int32 nXmlNamespace, bool bWritePropertiesAsLstStyles, bool bText )
{
// In case this shape has an associated textbox, then export that, and we're done.
if (GetDocumentType() == DOCUMENT_DOCX && !mbUserShapes && GetTextExport())
@@ -2354,8 +2354,11 @@ ShapeExport& ShapeExport::WriteTextBox( const Reference< XInterface >& xIface, s
pFS->startElementNS(nXmlNamespace,
(GetDocumentType() != DOCUMENT_DOCX || mbUserShapes ? XML_txBody : XML_txbx));
WriteText(xIface, /*bBodyPr=*/(GetDocumentType() != DOCUMENT_DOCX || mbUserShapes), /*bText=*/true,
WriteText(xIface, /*bBodyPr=*/(GetDocumentType() != DOCUMENT_DOCX || mbUserShapes), bText,
/*nXmlNamespace=*/0, /*bWritePropertiesAsLstStyles=*/bWritePropertiesAsLstStyles);
if (!bText)
// A text body holds at least one paragraph, and an empty one says the shape has no text
pFS->singleElementNS(XML_a, XML_p);
pFS->endElementNS( nXmlNamespace, (GetDocumentType() != DOCUMENT_DOCX || mbUserShapes ? XML_txBody : XML_txbx) );
if (GetDocumentType() == DOCUMENT_DOCX && !mbUserShapes)
WriteText( xIface, /*bBodyPr=*/true, /*bText=*/false, /*nXmlNamespace=*/nXmlNamespace );
+51 -2
View File
@@ -24,6 +24,7 @@
#include <svx/svdograf.hxx>
#include <svx/svdoole2.hxx>
#include <svx/svdotable.hxx>
#include <svx/unoapi.hxx>
#include <xmloff/autolayout.hxx>
#include <com/sun/star/awt/FontUnderline.hpp>
@@ -932,8 +933,8 @@ CPPUNIT_TEST_FIXTURE(SdOOXMLExportTest4, testTdf140912_PicturePlaceholder)
>>= isEmptyPresentationObject;
CPPUNIT_ASSERT(isEmptyPresentationObject);
// If we supported custom prompt text, here we would also test "String" property,
// which would be equal to "Insert Image". See first tests: testCustomPromptTexts
// The slide-side shape does not carry the layout's prompt; only the layout placeholder
// does. See testPicturePlaceholderCustomPromptText for the prompt itself.
auto xShape = xShapeProps.queryThrow<drawing::XShape>();
aSizeBefore = xShape->getSize();
@@ -1003,6 +1004,54 @@ CPPUNIT_TEST_FIXTURE(SdOOXMLExportTest4, testPicPlaceholderMovedToLayout)
assertXPath(pSlide, "/p:sld/p:cSld/p:spTree/p:sp/p:nvSpPr/p:nvPr/p:ph[@type='pic']", 1);
}
CPPUNIT_TEST_FIXTURE(SdOOXMLExportTest4, testSlideShowsMasterCustomPrompt)
{
// Given a layout whose picture placeholder authored a prompt:
createSdImpressDoc("pptx/picture-placeholder-custom-prompt.pptx");
// The slide's own placeholder shows it too. PlaceholderText is what the user reads on the
// slide; without the accompanying fix the slide ignored the layout and showed Impress's own
// resource string instead, so the layout and the slide disagreed about the same placeholder.
// The placeholder is looked up by what it is, not by where it sits: which shape comes first on
// the slide is not what this test is about.
SdPage* pPage = getSdDocShell()->GetDoc()->GetSdPage(0, PageKind::Standard);
SdrObject* pPlaceholder = pPage->GetPresObj(PresObjKind::Graphic);
CPPUNIT_ASSERT_MESSAGE("no picture placeholder on the slide", pPlaceholder);
auto xShapeProps = GetXShapeForSdrObject(pPlaceholder).queryThrow<beans::XPropertySet>();
OUString aPlaceholderText;
CPPUNIT_ASSERT(xShapeProps->getPropertyValue(u"PlaceholderText"_ustr) >>= aPlaceholderText);
CPPUNIT_ASSERT_EQUAL(u"Custom prompt to insert an image"_ustr, aPlaceholderText);
}
CPPUNIT_TEST_FIXTURE(SdOOXMLExportTest4, testPicturePlaceholderCustomPromptText)
{
// Given one layout whose picture placeholder authored a prompt, and another whose
// picture placeholder authored none:
createSdImpressDoc("pptx/picture-placeholder-custom-prompt.pptx");
save(TestFilter::PPTX);
static constexpr OString aPicPlaceholder(
"/p:sldLayout/p:cSld/p:spTree/p:sp[p:nvSpPr/p:nvPr/p:ph/@type='pic']"_ostr);
// The authored prompt survives. Without the accompanying fix it was replaced by Impress's
// own localized resource, since a slide layout arrives as a master page and the shown text
// was only ever set off a master.
xmlDocUniquePtr pAuthored = parseExportedLayoutNamed(u"Picture placeholder, custom prompt");
assertXPath(pAuthored, aPicPlaceholder + "/p:nvSpPr/p:nvPr/p:ph", "hasCustomPrompt", u"1");
assertXPathContent(pAuthored, aPicPlaceholder + "/p:txBody/a:p/a:r/a:t",
u"Custom prompt to insert an image");
// The one with no authored prompt writes no text, rather than that same resource string
// written out as if the document had contained it. Its body properties stay: a reader needs
// the insets and the anchor whether or not there is anything in the box yet.
xmlDocUniquePtr pDefault = parseExportedLayoutNamed(u"Picture with Caption");
assertXPath(pDefault, aPicPlaceholder, 1);
assertXPath(pDefault, aPicPlaceholder + "/p:txBody/a:bodyPr", 1);
assertXPath(pDefault, aPicPlaceholder + "/p:txBody/a:p", 1);
assertXPath(pDefault, aPicPlaceholder + "/p:txBody/a:p/a:r", 0);
}
CPPUNIT_TEST_FIXTURE(SdOOXMLExportTest4, testEnhancedPathViewBox)
{
createSdImpressDoc("odp/tdf147978_enhancedPath_viewBox.odp");
+14 -1
View File
@@ -2727,6 +2727,18 @@ Orientation SdPage::GetOrientation() const
OUString SdPage::GetPresObjText(PresObjKind eObjKind) const
{
// A layout can author its own prompt, and it belongs to the slides using that layout, so ask
// the master for one before falling back to our own resource string.
if (!mbMaster && TRG_HasMasterPage())
{
SdPage& rMasterPage = static_cast<SdPage&>(TRG_GetMasterPage());
if (SdrObject* pMasterObj = rMasterPage.GetPresObj(eObjKind))
{
if (!pMasterObj->GetCustomPromptText().isEmpty())
return pMasterObj->GetCustomPromptText();
}
}
OUString aString;
#if defined(IOS) || defined(ANDROID)
@@ -3018,7 +3030,8 @@ bool SdPage::RestoreDefaultText( SdrObject* pObj, const OUString& rStr )
if (ePresObjKind == PresObjKind::Title ||
ePresObjKind == PresObjKind::Outline ||
ePresObjKind == PresObjKind::Notes ||
ePresObjKind == PresObjKind::Text)
ePresObjKind == PresObjKind::Text ||
ePresObjKind == PresObjKind::Graphic)
{
sd::ModifyGuard aGuard(static_cast<SdDrawDocument*>(&getSdrModelFromSdrPage()));
+15 -2
View File
@@ -2743,9 +2743,22 @@ ShapeExport& PowerPointShapeExport::WritePlaceholderShape(const Reference< XShap
= (mePageType == PageType::MASTER)
&& (ePlaceholder == Title || ePlaceholder == Subtitle || ePlaceholder == Outliner);
// A slide-side empty picture placeholder inherits the prompt from the layout.
// An untouched placeholder shows Impress's own localized prompt resource unless the document
// authored one, and that must not reach the file - a reader supplies its own prompt.
bool bIsEmptyPresObj(false);
if (xProps
&& xProps->getPropertySetInfo()->hasPropertyByName(u"IsEmptyPresentationObject"_ustr))
xProps->getPropertyValue(u"IsEmptyPresentationObject"_ustr) >>= bIsEmptyPresObj;
// Field placeholders carry their field content, and a master's text box carries list styles.
const bool bTextIsDefaultPrompt = bIsEmptyPresObj && !bUseCustomPrompt && !bUsePlaceholderIndex
&& !bWritePropertiesAsLstStyles;
// A slide-side empty picture placeholder inherits the prompt from the layout. The body
// properties belong in the file even where the text does not - they carry the insets, the
// anchor, the writing direction and the autofit.
if (ePlaceholder != Picture || mbMaster)
WriteTextBox(xShape, XML_p, bUsePlaceholderIndex || bWritePropertiesAsLstStyles);
WriteTextBox(xShape, XML_p, bUsePlaceholderIndex || bWritePropertiesAsLstStyles,
/*bText=*/!bTextIsDefaultPrompt);
mpFS->endElementNS(XML_p, XML_sp);
+4 -8
View File
@@ -983,15 +983,11 @@ void SdXShape::SetCustomPromptText(const OUString& aVal)
if (pObj == nullptr)
return;
if (!pObj->getSdrPageFromSdrObject()->IsMasterPage())
{
if (pObj->getSdrPageFromSdrObject()->RestoreDefaultText(pObj, aVal))
pObj->SetCustomPromptText(aVal);
}
else
{
// A slide layout is imported as a master page, and a layout is where an authored prompt lives,
// so the shown text has to follow the property there too - not just on a slide.
const bool bTextSet = pObj->getSdrPageFromSdrObject()->RestoreDefaultText(pObj, aVal);
if (bTextSet || pObj->getSdrPageFromSdrObject()->IsMasterPage())
pObj->SetCustomPromptText(aVal);
}
}
bool SdXShape::IsMasterDepend() const noexcept