View Issue Details

IDProjectCategoryView StatusLast Update
0010107ScribusText Frames / Story Editorpublic2026-09-03 05:18
Reporterale Assigned To 
PrioritynormalSeverityfeatureReproducibilityN/A
Status newResolutionopen 
Product Version1.5.0svn 
Summary0010107: Allow style matching when importing ODT files
Descriptionwe should be able to manually match the styles from an ODT file to the styles already in the .sla.
Tags#patch_to_be_reviewed, #please_test, mockup, ODF, styles
Attached Files
scribus_import_odt_styles.png (26,779 bytes)   
scribus_import_odt_styles.png (26,779 bytes)   
Patch

Relationships

related to 0011157 closedfschmid Import of ODT should use "display name" of style 
related to 0009464 new Text formatting changes itself 

Activities

ale

2011-07-07 17:57

manager   ~0026551

i've attached a mockup showing how it could be done with colors...

qirat

2026-09-03 03:12

reporter   ~0054402

This is very interesting and useful. Here is my attempt at the same. It is 3 patches in sequence (osm = odt style matching).

## `osm1-gettext2-cancel-safety-v1.0.patch`

- Makes the generic GetText2 import path cancel-safe in replace mode.
- Removes the premature story clear from `gtgettext.cpp`; importers now replace text only when the import actually commits.
- Prevents importer-side dialogues/cancellation from leaving the target frame empty.
- Minimal prerequisite for interactive ODT import workflows; no API/ABI changes.

## `osm2-style-mapping-engine-v1.1.patch`

- Adds the ODT style-mapping backend to `odt2im`.
- Splits import into style discovery → mapping → style materialisation → text import.
- Supports paragraph and character style mappings, including mapped ancestors and inherited child-style deltas.
- Makes mapped Scribus styles authoritative: mapped ODT style definitions are not recreated or reapplied as local overrides.
- Preserves lower-level ODT formatting differences where appropriate.
- Adds collision-safe import of unmapped ODT styles, including unique renaming and corrected parent references.
- Supports many-to-one mappings, prefixing, and ODT/FODT parity without changing the generic GetText2 ABI.

## `osm3-style-matching-dialog-v1.25.patch`

- Adds the Match ODT Styles UI before ODT style materialisation.
- Provides per-style mapping for paragraph and character styles, with Import as New Style, Match by Name, optional case-insensitive matching, and bulk mapping of remaining styles.
- Adds explicit mapping-state handling so manual, name-matched, and bulk assignments interact predictably.
- Adds per-tab filtering, Mapped only / Unmapped only, and reset support for larger style sets.
- Surfaces style-name collisions and ambiguous case-insensitive matches without silently guessing.
- Adds keyboard/accessibility handling, controlled TAB order, mnemonics, and protection against accidental mouse-wheel changes in mapping combos.
- Keeps the dialogue compact and tab-scoped while leaving the mapping engine itself in `osm2`.
osm1-gettext2-cancel-safety-v1.0.patch (476 bytes)   
Index: scribus/gtgettext.cpp
===================================================================
--- scribus/gtgettext.cpp	(revision 27785)
+++ scribus/gtgettext.cpp	(working copy)
@@ -255,8 +255,6 @@
 	fp_GetText2 = (gt2ptr) PluginManager::resolveSym(gtplugin,"GetText2");
 	if (fp_GetText2)
 	{
-		if (!append)
-			importItem->itemText.clear();
 		// Execute the importer's "GetText2" method.
 		(*fp_GetText2)(filePath, encoding, textOnly, prefix, append, importItem);
 	}
osm2-style-mapping-engine-v1.1.patch (23,594 bytes)   
Index: scribus/plugins/gettext/odt2im/importodt.h
===================================================================
--- scribus/plugins/gettext/odt2im/importodt.h	(revision 27810)
+++ scribus/plugins/gettext/odt2im/importodt.h	(working copy)
@@ -22,8 +22,10 @@
 #include <QDomDocument>
 #include <QDomElement>
 #include <QHash>
+#include <QList>
 #include <QStack>
 #include <QString>
+#include <QStringList>
 
 class ScZipHandler;
 
@@ -69,12 +71,22 @@
 class ODTIm
 {
 public:
+	struct StyleInfo
+	{
+		QString sourceName;
+		QString displayName;
+		QString family;
+	};
+
 	ODTIm(PageItem *textItem, bool prefix, bool append);
 	~ODTIm() = default;
 
 	ODTIm(ODTIm&) = delete;
 	ODTIm& operator=(const ODTIm&) = delete;
 
+	bool collectStyles(const QString& fileName);
+	QList<StyleInfo> namedStyles() const;
+	void setStyleMappings(const QHash<QString, QString>& paragraphMappings, const QHash<QString, QString>& characterMappings);
 	bool importFile(const QString& fileName, bool textOnly);
 
 private:
@@ -127,6 +139,7 @@
 	bool parseStyleSheets(const QString& designMap);
 	bool parseStyleSheetsXML(const QDomDocument &designMapDom);
 	void parseStyles(const QDomElement &sp, const QString& type);
+	void materializeNamedStyles();
 	bool parseDocReference(const QString& designMap);
 	bool parseDocReferenceXML(const QDomDocument &designMapDom);
 	void parseTextSpan(const QDomElement &elem, PageItem* item, const ParagraphStyle &tmpStyle, const CharStyle &tmpCStyle, const ObjStyleODT& tmpOStyle, int &posC);
@@ -138,7 +151,11 @@
 	void applyCharacterStyle(CharStyle &tmpCStyle, const ObjStyleODT &oStyle);
 	void applyParagraphStyle(ParagraphStyle &tmpStyle, const ObjStyleODT &oStyle);
 	void resolveStyle(ObjStyleODT &tmpOStyle, const QString& pAttrs);
-	bool findNamedParagraphStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName) const;
+	bool findNamedParagraphStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName);
+	bool findNamedCharacterStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName);
+	QString importedStyleName(const QString& sourceStyleName, const QString& family);
+	bool hasMappedAncestor(const QString& styleName, const QHash<QString, QString>& mappings) const;
+	bool applyMappedCharacterStyle(const QString& styleName, CharStyle& style, const ObjStyleODT& resolvedStyle);
 	double parseUnit(const QString &unit) const;
 	QString parseColor( const QString &s );
 	QString constructFontName(const QString& fontBaseName, const QString& fontStyle);
@@ -152,6 +169,11 @@
 	QHash<QString, QString> map_ID_to_Name;
 	QHash<QString, QString> m_fontMap;
 	QHash<QString, DrawStyle> m_Styles;
+	QStringList m_namedStyleOrder;
+	QHash<QString, QString> m_parStyleMappings;
+	QHash<QString, QString> m_charStyleMappings;
+	QHash<QString, QString> m_parImportedStyleNames;
+	QHash<QString, QString> m_charImportedStyleNames;
 	QStack<QString> m_textStylesStack;
 	DrawStyle parDefaultStyle;
 	DrawStyle txtDefaultStyle;
Index: scribus/plugins/gettext/odt2im/importodt.cpp
===================================================================
--- scribus/plugins/gettext/odt2im/importodt.cpp	(revision 27810)
+++ scribus/plugins/gettext/odt2im/importodt.cpp	(working copy)
@@ -15,6 +15,7 @@
 #include <QApplication>
 #include <QByteArray>
 #include <QDebug>
+#include <QRegularExpression>
 #include <QScopedPointer>
 
 #include "scribusdoc.h"
@@ -55,8 +56,86 @@
 	// Nothing else to do
 }
 
+bool ODTIm::collectStyles(const QString& fileName)
+{
+	m_fontMap.clear();
+	m_Styles.clear();
+	m_namedStyleOrder.clear();
+	m_parImportedStyleNames.clear();
+	m_charImportedStyleNames.clear();
+	parDefaultStyle = DrawStyle();
+	txtDefaultStyle = DrawStyle();
+
+	QFileInfo fi(fileName);
+	if (fi.suffix().compare("fodt", Qt::CaseInsensitive) == 0)
+	{
+		QByteArray xmlData;
+		QDomDocument designMapDom;
+		if (!loadRawText(fileName, xmlData))
+			return false;
+#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
+		if (!designMapDom.setContent(xmlData, QDomDocument::ParseOption::PreserveSpacingOnlyNodes))
+			return false;
+#else
+		if (!designMapDom.setContent(xmlData))
+			return false;
+#endif
+		return parseStyleSheetsXML(designMapDom);
+	}
+
+	m_zip = std::make_unique<ScZipHandler>();
+	if (!m_zip || !m_zip->open(fileName))
+		return false;
+	bool result = true;
+	if (m_zip->contains("styles.xml"))
+		result = parseStyleSheets("styles.xml");
+	if (result && m_zip->contains("content.xml"))
+		result = parseStyleSheets("content.xml");
+	m_zip->close();
+	m_zip.reset();
+	return result;
+}
+
+QList<ODTIm::StyleInfo> ODTIm::namedStyles() const
+{
+	QList<StyleInfo> result;
+	for (const QString& sourceName : m_namedStyleOrder)
+	{
+		auto it = m_Styles.constFind(sourceName);
+		if (it == m_Styles.constEnd())
+			continue;
+		const DrawStyle& style = it.value();
+		if (!style.styleOrigin.valid || style.styleOrigin.value != "styles" || !style.styleType.valid)
+			continue;
+		if (style.styleType.value != "paragraph" && style.styleType.value != "text")
+			continue;
+		StyleInfo info;
+		info.sourceName = sourceName;
+		info.displayName = style.displayName.valid ? style.displayName.value : sourceName;
+		info.family = style.styleType.value;
+		result.append(info);
+	}
+	return result;
+}
+
+void ODTIm::setStyleMappings(const QHash<QString, QString>& paragraphMappings, const QHash<QString, QString>& characterMappings)
+{
+	m_parStyleMappings = paragraphMappings;
+	m_charStyleMappings = characterMappings;
+}
+
 bool ODTIm::importFile(const QString& fileName, bool textOnly)
 {
+	if (!textOnly)
+	{
+		m_fontMap.clear();
+		m_Styles.clear();
+		m_namedStyleOrder.clear();
+		m_parImportedStyleNames.clear();
+		m_charImportedStyleNames.clear();
+		parDefaultStyle = DrawStyle();
+		txtDefaultStyle = DrawStyle();
+	}
 	QFileInfo fi(fileName);
 	QString ext = fi.suffix().toLower();
 	if (ext == "fodt")
@@ -584,62 +663,163 @@
 			}
 			currStyle.displayName = AttributeValue(spd.attribute("style:display-name", ""));
 			m_Styles.insert(spd.attribute("style:name"), currStyle);
-			if (type == "styles")
+			const QString sourceName = spd.attribute("style:name");
+			if (type == "styles" && !m_namedStyleOrder.contains(sourceName))
+				m_namedStyleOrder.append(sourceName);
+		}
+	}
+}
+
+
+QString ODTIm::importedStyleName(const QString& sourceStyleName, const QString& family)
+{
+	const bool paragraphFamily = (family == "paragraph");
+	QHash<QString, QString>& importedNames = paragraphFamily ? m_parImportedStyleNames : m_charImportedStyleNames;
+	auto nameIt = importedNames.constFind(sourceStyleName);
+	if (nameIt != importedNames.constEnd())
+		return nameIt.value();
+
+	auto styleIt = m_Styles.constFind(sourceStyleName);
+	QString styleName = sourceStyleName;
+	if (styleIt != m_Styles.constEnd() && styleIt->displayName.valid)
+		styleName = styleIt->displayName.value;
+	if (m_prefixName)
+		styleName.prepend(m_item->itemName() + "_");
+
+	auto nameExists = [this, paragraphFamily, &importedNames](const QString& name)
+	{
+		if (importedNames.values().contains(name))
+			return true;
+		return paragraphFamily ? m_Doc->paragraphStyles().contains(name) : m_Doc->charStyles().contains(name);
+	};
+
+	if (nameExists(styleName))
+	{
+		QString prefix = styleName;
+		int suffixNum = 1;
+		static const QRegularExpression suffixRx("^(.*)\\s+\\((\\d+)\\)$");
+		const QRegularExpressionMatch match = suffixRx.match(styleName);
+		if (match.hasMatch())
+		{
+			prefix = match.captured(1);
+			suffixNum = match.captured(2).toInt();
+		}
+		do
+		{
+			++suffixNum;
+			styleName = prefix + " (" + QString::number(suffixNum) + ")";
+		}
+		while (nameExists(styleName));
+	}
+
+	importedNames.insert(sourceStyleName, styleName);
+	return styleName;
+}
+
+bool ODTIm::hasMappedAncestor(const QString& styleName, const QHash<QString, QString>& mappings) const
+{
+	QString currentName = styleName;
+	for (auto remaining = m_Styles.size(); remaining > 0 && !currentName.isEmpty(); --remaining)
+	{
+		if (mappings.contains(currentName))
+			return true;
+		auto it = m_Styles.constFind(currentName);
+		if (it == m_Styles.constEnd() || !it->parentStyle.valid)
+			break;
+		currentName = it->parentStyle.value;
+	}
+	return false;
+}
+
+void ODTIm::materializeNamedStyles()
+{
+	for (const QString& sourceName : m_namedStyleOrder)
+	{
+		auto it = m_Styles.constFind(sourceName);
+		if (it == m_Styles.constEnd())
+			continue;
+		const DrawStyle& currStyle = it.value();
+		if (!currStyle.styleOrigin.valid || currStyle.styleOrigin.value != "styles" || !currStyle.styleType.valid)
+			continue;
+
+		if (currStyle.styleType.value == "paragraph")
+		{
+			if (m_parStyleMappings.contains(sourceName))
+				continue;
+
+			ObjStyleODT tmpOStyle;
+			resolveStyle(tmpOStyle, sourceName);
+			ParagraphStyle newStyle;
+			newStyle.erase();
+			newStyle.setDefaultStyle(false);
+			newStyle.setLineSpacingMode(ParagraphStyle::AutomaticLineSpacing);
+			newStyle.setName(importedStyleName(sourceName, "paragraph"));
+
+			QString parentName = CommonStrings::DefaultParagraphStyle;
+			if (currStyle.parentStyle.valid && m_Styles.contains(currStyle.parentStyle.value))
 			{
-				ObjStyleODT tmpOStyle;
-				resolveStyle(tmpOStyle, spd.attribute("style:name"));
-				if (spd.attribute("style:family") == "paragraph")
-				{
-					ParagraphStyle newStyle;
-					newStyle.erase();
-					newStyle.setDefaultStyle(false);
-					newStyle.setLineSpacingMode(ParagraphStyle::AutomaticLineSpacing);
-					QString styleName = spd.attribute("style:name");
-					if (currStyle.displayName.valid)
-						styleName = currStyle.displayName.value;
-					if (m_prefixName)
-						newStyle.setName(m_item->itemName() + "_" + styleName);
-					else
-						newStyle.setName(styleName);
-					QString parentName = CommonStrings::DefaultParagraphStyle;
-					if (currStyle.parentStyle.valid)
-					{
-						if (m_Styles.contains(currStyle.parentStyle.value))
-						{
-							DrawStyle pStyle = m_Styles[currStyle.parentStyle.value];
-							parentName = currStyle.parentStyle.value;
-							if (pStyle.displayName.valid)
-								parentName = pStyle.displayName.value;
-						}
-					}
-					if (m_prefixName && (parentName != CommonStrings::DefaultParagraphStyle))
-						newStyle.setParent(m_item->itemName() + "_" + parentName);
-					else
-						newStyle.setParent(parentName);
-					applyParagraphStyle(newStyle, tmpOStyle);
-					applyCharacterStyle(newStyle.charStyle(), tmpOStyle);
-					StyleSet<ParagraphStyle>tmp;
-					tmp.create(newStyle);
-					m_Doc->redefineStyles(tmp, false);
-				}
-				else if (spd.attribute("style:family") == "text")
-				{
-					CharStyle newStyle;
-					newStyle.setDefaultStyle(false);
-					QString styleName = spd.attribute("style:name");
-					if (currStyle.displayName.valid)
-						styleName = currStyle.displayName.value;
-					if (m_prefixName)
-						newStyle.setName(m_item->itemName() + "_" + styleName);
-					else
-						newStyle.setName(styleName);
-					newStyle.setParent(CommonStrings::DefaultCharacterStyle);
-					applyCharacterStyle(newStyle, tmpOStyle);
-					StyleSet<CharStyle> temp;
-					temp.create(newStyle);
-					m_Doc->redefineCharStyles(temp, false);
-				}
+				const QString parentSourceName = currStyle.parentStyle.value;
+				auto mappingIt = m_parStyleMappings.constFind(parentSourceName);
+				parentName = (mappingIt != m_parStyleMappings.constEnd()) ? mappingIt.value() : importedStyleName(parentSourceName, "paragraph");
+			}
+			newStyle.setParent(parentName);
+			applyParagraphStyle(newStyle, tmpOStyle);
+			applyCharacterStyle(newStyle.charStyle(), tmpOStyle);
+
+			// If the direct parent is mapped, keep only this style's delta above it.
+			if (currStyle.parentStyle.valid && hasMappedAncestor(currStyle.parentStyle.value, m_parStyleMappings))
+			{
+				ObjStyleODT parentOStyle;
+				resolveStyle(parentOStyle, currStyle.parentStyle.value);
+				ParagraphStyle parentStyle;
+				parentStyle.erase();
+				applyParagraphStyle(parentStyle, parentOStyle);
+				applyCharacterStyle(parentStyle.charStyle(), parentOStyle);
+				newStyle.eraseStyle(parentStyle);
+				newStyle.setParent(parentName);
+			}
+
+			StyleSet<ParagraphStyle> temp;
+			temp.create(newStyle);
+			m_Doc->redefineStyles(temp, false);
+		}
+		else if (currStyle.styleType.value == "text")
+		{
+			if (m_charStyleMappings.contains(sourceName))
+				continue;
+
+			ObjStyleODT tmpOStyle;
+			resolveStyle(tmpOStyle, sourceName);
+			CharStyle newStyle;
+			newStyle.erase();
+			newStyle.setDefaultStyle(false);
+			newStyle.setName(importedStyleName(sourceName, "text"));
+
+			QString parentName = CommonStrings::DefaultCharacterStyle;
+			if (currStyle.parentStyle.valid && m_Styles.contains(currStyle.parentStyle.value)
+				&& hasMappedAncestor(currStyle.parentStyle.value, m_charStyleMappings))
+			{
+				const QString parentSourceName = currStyle.parentStyle.value;
+				auto mappingIt = m_charStyleMappings.constFind(parentSourceName);
+				parentName = (mappingIt != m_charStyleMappings.constEnd()) ? mappingIt.value() : importedStyleName(parentSourceName, "text");
+			}
+			newStyle.setParent(parentName);
+			applyCharacterStyle(newStyle, tmpOStyle);
+
+			if (currStyle.parentStyle.valid && hasMappedAncestor(currStyle.parentStyle.value, m_charStyleMappings))
+			{
+				ObjStyleODT parentOStyle;
+				resolveStyle(parentOStyle, currStyle.parentStyle.value);
+				CharStyle parentStyle;
+				parentStyle.erase();
+				applyCharacterStyle(parentStyle, parentOStyle);
+				newStyle.eraseCharStyle(parentStyle);
+				newStyle.setParent(parentName);
 			}
+
+			StyleSet<CharStyle> temp;
+			temp.create(newStyle);
+			m_Doc->redefineCharStyles(temp, false);
 		}
 	}
 }
@@ -675,47 +855,52 @@
 bool ODTIm::parseDocReferenceXML(const QDomDocument &designMapDom)
 {
 	QDomElement docElem = designMapDom.documentElement();
+
+	// Collect every style definition before creating Scribus styles or importing
+	// body text. This allows style mappings to replace an ODT named style without
+	// first redefining a same-named style already present in the document.
 	for (QDomElement drawPag = docElem.firstChildElement(); !drawPag.isNull(); drawPag = drawPag.nextSiblingElement())
 	{
 		if (drawPag.tagName() == "office:font-face-decls")
 		{
-			for (QDomElement spf = drawPag.firstChildElement(); !spf.isNull(); spf = spf.nextSiblingElement() )
+			for (QDomElement spf = drawPag.firstChildElement(); !spf.isNull(); spf = spf.nextSiblingElement())
 			{
-				if (spf.tagName() == "style:font-face")
-				{
-					if (!spf.attribute("style:name").isEmpty())
-					{
-						QString fontFamily = spf.attribute("svg:font-family");
-						if (fontFamily.startsWith(QChar('\'')))
-							fontFamily = fontFamily.mid(1);
-						if (fontFamily.endsWith(QChar('\'')))
-							fontFamily.chop(1);
-						m_fontMap.insert(spf.attribute("style:name"), fontFamily);
-					}
-				}
+				if (spf.tagName() != "style:font-face" || spf.attribute("style:name").isEmpty())
+					continue;
+				QString fontFamily = spf.attribute("svg:font-family");
+				if (fontFamily.startsWith(QChar('\'')))
+					fontFamily = fontFamily.mid(1);
+				if (fontFamily.endsWith(QChar('\'')))
+					fontFamily.chop(1);
+				m_fontMap.insert(spf.attribute("style:name"), fontFamily);
 			}
 		}
 		else if (drawPag.tagName() == "office:styles")
 			parseStyles(drawPag, "styles");
 		else if (drawPag.tagName() == "office:automatic-styles")
 			parseStyles(drawPag, "auto");
-		else if (drawPag.tagName() == "office:body")
+	}
+
+	materializeNamedStyles();
+
+	for (QDomElement drawPag = docElem.firstChildElement(); !drawPag.isNull(); drawPag = drawPag.nextSiblingElement())
+	{
+		if (drawPag.tagName() != "office:body")
+			continue;
+		for (QDomElement sp = drawPag.firstChildElement(); !sp.isNull(); sp = sp.nextSiblingElement())
 		{
-			for (QDomElement sp = drawPag.firstChildElement(); !sp.isNull(); sp = sp.nextSiblingElement() )
+			if (sp.tagName() == "office:text")
 			{
-				if (sp.tagName() == "office:text")
-				{
-					ObjStyleODT tmpOStyle;
-					resolveStyle(tmpOStyle, "standard");
-					parseText(sp, m_item, tmpOStyle);
-				}
+				ObjStyleODT tmpOStyle;
+				resolveStyle(tmpOStyle, "standard");
+				parseText(sp, m_item, tmpOStyle);
 			}
 		}
 	}
 	return true;
 }
 
-bool ODTIm::findNamedParagraphStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName) const
+bool ODTIm::findNamedParagraphStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName)
 {
 	sourceStyleName.clear();
 	importedStyleName.clear();
@@ -733,9 +918,8 @@
 			&& style.styleType.value == "paragraph")
 		{
 			sourceStyleName = currentName;
-			importedStyleName = style.displayName.valid ? style.displayName.value : currentName;
-			if (m_prefixName)
-				importedStyleName.prepend(m_item->itemName() + "_");
+			auto mappingIt = m_parStyleMappings.constFind(currentName);
+			importedStyleName = (mappingIt != m_parStyleMappings.constEnd()) ? mappingIt.value() : this->importedStyleName(currentName, "paragraph");
 			return true;
 		}
 
@@ -747,6 +931,62 @@
 	return false;
 }
 
+bool ODTIm::findNamedCharacterStyle(const QString& styleName, QString& sourceStyleName, QString& importedStyleName)
+{
+	sourceStyleName.clear();
+	importedStyleName.clear();
+
+	QString currentName = styleName;
+	for (auto remaining = m_Styles.size(); remaining > 0 && !currentName.isEmpty(); --remaining)
+	{
+		auto styleIt = m_Styles.constFind(currentName);
+		if (styleIt == m_Styles.constEnd())
+			break;
+
+		const DrawStyle& style = styleIt.value();
+		if (style.styleOrigin.valid && style.styleType.valid
+			&& style.styleOrigin.value == "styles"
+			&& style.styleType.value == "text")
+		{
+			sourceStyleName = currentName;
+			auto mappingIt = m_charStyleMappings.constFind(currentName);
+			importedStyleName = (mappingIt != m_charStyleMappings.constEnd()) ? mappingIt.value() : this->importedStyleName(currentName, "text");
+			return true;
+		}
+
+		if (!style.parentStyle.valid)
+			break;
+		currentName = style.parentStyle.value;
+	}
+	return false;
+}
+
+bool ODTIm::applyMappedCharacterStyle(const QString& styleName, CharStyle& style, const ObjStyleODT& resolvedStyle)
+{
+	QString sourceStyleName;
+	QString targetStyleName;
+	if (!findNamedCharacterStyle(styleName, sourceStyleName, targetStyleName)
+		|| !hasMappedAncestor(sourceStyleName, m_charStyleMappings))
+		return false;
+
+	ObjStyleODT inheritedOdtStyle;
+	resolveStyle(inheritedOdtStyle, sourceStyleName);
+	CharStyle inheritedStyle;
+	inheritedStyle.erase();
+	applyCharacterStyle(inheritedStyle, inheritedOdtStyle);
+
+	CharStyle deltaStyle;
+	deltaStyle.erase();
+	applyCharacterStyle(deltaStyle, resolvedStyle);
+	deltaStyle.eraseCharStyle(inheritedStyle);
+
+	style.eraseDirectFormatting();
+	style.setParent(targetStyleName);
+	style.applyStyle(deltaStyle);
+	return true;
+}
+
+
 void ODTIm::parseTextSpan(const QDomElement &elem, PageItem* item, const ParagraphStyle &tmpStyle, const CharStyle &tmpCStyle, const ObjStyleODT &tmpOStyle, int &posC)
 {
 	if (!elem.hasChildNodes())
@@ -756,34 +996,22 @@
 	CharStyle cStyle = tmpCStyle;
 
 	QString textStyleName = elem.attribute("text:style-name");
+	bool mappedCharacterStyle = false;
 	if (!textStyleName.isEmpty())
 	{
 		resolveStyle(odtStyle, textStyleName);
-		if (m_Styles.contains(textStyleName))
+		mappedCharacterStyle = applyMappedCharacterStyle(textStyleName, cStyle, odtStyle);
+		if (!mappedCharacterStyle && m_Styles.contains(textStyleName))
 		{
-			DrawStyle currStyle = m_Styles[textStyleName];
-			if (currStyle.styleOrigin.value == "styles")
-			{
-				QString charStyleName;
-				if (m_prefixName)
-				{
-					charStyleName = m_item->itemName() + "_" + textStyleName;
-					if (currStyle.displayName.valid)
-						charStyleName = m_item->itemName() + "_" + currStyle.displayName.value;
-				}
-				else
-				{
-					charStyleName = textStyleName;
-					if (currStyle.displayName.valid)
-						charStyleName = currStyle.displayName.value;
-				}
-				cStyle.setParent(charStyleName);
-			}
+			const DrawStyle& currStyle = m_Styles[textStyleName];
+			if (currStyle.styleOrigin.valid && currStyle.styleOrigin.value == "styles")
+				cStyle.setParent(importedStyleName(textStyleName, "text"));
 		}
 		m_textStylesStack.push(textStyleName);
 	}
-	
-	applyCharacterStyle(cStyle, odtStyle);
+
+	if (!mappedCharacterStyle)
+		applyCharacterStyle(cStyle, odtStyle);
 
 	for (QDomNode spn = elem.firstChild(); !spn.isNull(); spn = spn.nextSibling())
 	{
@@ -832,13 +1060,16 @@
 	CharStyle cStyle = tmpCStyle;
 
 	QString textStyleName = elem.attribute("text:style-name");
+	bool mappedCharacterStyle = false;
 	if (!textStyleName.isEmpty())
 	{
 		resolveStyle(odtStyle, textStyleName);
+		mappedCharacterStyle = applyMappedCharacterStyle(textStyleName, cStyle, odtStyle);
 		m_textStylesStack.push(textStyleName);
 	}
-	
-	applyCharacterStyle(cStyle, odtStyle);
+
+	if (!mappedCharacterStyle)
+		applyCharacterStyle(cStyle, odtStyle);
 
 	for (QDomNode spn = elem.firstChild(); !spn.isNull(); spn = spn.nextSibling())
 	{
@@ -926,32 +1157,56 @@
 		insertChars(item, txt, tmpStyle, tmpCStyle, posC);
 	}
 
-	if (hasNamedParagraphStyle)
+	const bool mappedParagraphStyle = hasNamedParagraphStyle && hasMappedAncestor(namedOdtStyleName, m_parStyleMappings);
+	if (mappedParagraphStyle)
 	{
-		// A paragraph assigned to a named style should not retain the importer's
-		// default paragraph values as local overrides.
+		// The mapped Scribus style is authoritative. Apply the resolved ODT style
+		// only long enough to calculate formatting introduced below the mapped
+		// named style, then erase that named ODT baseline.
 		tmpStyle.erase();
 		tmpStyle.setParent(parStyleName);
-	}
-	applyParagraphStyle(tmpStyle, pStyle);
+		applyParagraphStyle(tmpStyle, pStyle);
+		applyCharacterStyle(tmpStyle.charStyle(), pStyle);
 
-	if (hasNamedParagraphStyle)
-	{
 		ObjStyleODT inheritedStyle = tmpOStyle;
 		resolveStyle(inheritedStyle, namedOdtStyleName);
-
 		ParagraphStyle inheritedParagraphStyle;
 		inheritedParagraphStyle.erase();
 		applyParagraphStyle(inheritedParagraphStyle, inheritedStyle);
-
-		// Keep the named Scribus style as the paragraph base and retain only
-		// paragraph properties introduced by an ODT automatic style.
+		applyCharacterStyle(inheritedParagraphStyle.charStyle(), inheritedStyle);
 		tmpStyle.eraseStyle(inheritedParagraphStyle);
 		tmpStyle.setParent(parStyleName);
 	}
+	else
+	{
+		if (hasNamedParagraphStyle)
+		{
+			// A paragraph assigned to a named style should not retain the importer's
+			// default paragraph values as local overrides.
+			tmpStyle.erase();
+			tmpStyle.setParent(parStyleName);
+		}
+		applyParagraphStyle(tmpStyle, pStyle);
+
+		if (hasNamedParagraphStyle)
+		{
+			ObjStyleODT inheritedStyle = tmpOStyle;
+			resolveStyle(inheritedStyle, namedOdtStyleName);
+
+			ParagraphStyle inheritedParagraphStyle;
+			inheritedParagraphStyle.erase();
+			applyParagraphStyle(inheritedParagraphStyle, inheritedStyle);
+
+			// Keep the named Scribus style as the paragraph base and retain only
+			// paragraph properties introduced by an ODT automatic style.
+			tmpStyle.eraseStyle(inheritedParagraphStyle);
+			tmpStyle.setParent(parStyleName);
+		}
+	}
 
 	tmpCStyle = tmpStyle.charStyle();
-	applyCharacterStyle(tmpCStyle, pStyle);
+	if (!mappedParagraphStyle)
+		applyCharacterStyle(tmpCStyle, pStyle);
 
 	for (QDomNode spn = elem.firstChild(); !spn.isNull(); spn = spn.nextSibling())
 	{
osm3-style-matching-dialog-v1.25.patch (24,521 bytes)   
Index: scribus/plugins/gettext/odt2im/CMakeLists.txt
===================================================================
--- scribus/plugins/gettext/odt2im/CMakeLists.txt	(revision 27810)
+++ scribus/plugins/gettext/odt2im/CMakeLists.txt	(working copy)
@@ -5,6 +5,7 @@
 
 set(ODT_IM_PLUGIN_SOURCES
 	importodt.cpp
+	odtstylemappingdialog.cpp
 )
 
 set(SCRIBUS_ODT_IM_PLUGIN "odtimplugin")
Index: scribus/plugins/gettext/odt2im/importodt.cpp
===================================================================
--- scribus/plugins/gettext/odt2im/importodt.cpp	(revision 27810)
+++ scribus/plugins/gettext/odt2im/importodt.cpp	(working copy)
@@ -11,6 +11,7 @@
 	email                : Franz.Schmid@altmuehlnet.de
  ***************************************************************************/
 #include "importodt.h"
+#include "odtstylemappingdialog.h"
 
 #include <QApplication>
 #include <QByteArray>
@@ -43,8 +44,20 @@
 
 void GetText2(const QString& filename, const QString& /*encoding*/, bool textOnly, bool prefix, bool append, PageItem *textItem)
 {
-	auto docxim = std::make_unique<ODTIm>(textItem, prefix, append);
-	docxim->importFile(filename, textOnly);
+	auto odtim = std::make_unique<ODTIm>(textItem, prefix, append);
+	if (!textOnly && odtim->collectStyles(filename))
+	{
+		const QList<ODTIm::StyleInfo> styles = odtim->namedStyles();
+		if (!styles.isEmpty())
+		{
+			const QString importPrefix = prefix ? textItem->itemName() + "_" : QString();
+			OdtStyleMappingDialog dialog(textItem->doc(), styles, importPrefix, QApplication::activeWindow());
+			if (dialog.exec() != QDialog::Accepted)
+				return;
+			odtim->setStyleMappings(dialog.paragraphMappings(), dialog.characterMappings());
+		}
+	}
+	odtim->importFile(filename, textOnly);
 }
 
 ODTIm::ODTIm(PageItem *textItem, bool prefix, bool append)
Index: scribus/plugins/gettext/odt2im/odtstylemappingdialog.h
===================================================================
--- scribus/plugins/gettext/odt2im/odtstylemappingdialog.h	(nonexistent)
+++ scribus/plugins/gettext/odt2im/odtstylemappingdialog.h	(working copy)
@@ -0,0 +1,79 @@
+/*
+For general Scribus (>=1.3.2) copyright and licensing information please refer
+to the COPYING file provided with the program. Following this notice may exist
+a copyright and/or license notice that predates the release of Scribus 1.3.2
+for which a new license (GPL+exception) is in place.
+*/
+#ifndef ODTSTYLEMAPPINGDIALOG_H
+#define ODTSTYLEMAPPINGDIALOG_H
+
+#include <QDialog>
+#include <QHash>
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+#include "importodt.h"
+
+class QComboBox;
+class QLabel;
+class QTableWidget;
+class QTableWidgetItem;
+class ScribusDoc;
+
+class OdtStyleMappingDialog : public QDialog
+{
+	Q_OBJECT
+
+public:
+	OdtStyleMappingDialog(ScribusDoc* doc, const QList<ODTIm::StyleInfo>& styles, const QString& importPrefix, QWidget* parent = nullptr);
+
+	QHash<QString, QString> paragraphMappings() const;
+	QHash<QString, QString> characterMappings() const;
+
+private:
+	enum class MappingOrigin
+	{
+		Untouched,
+		Bulk,
+		NameMatch,
+		Manual
+	};
+
+	struct MappingRow
+	{
+		QString sourceName;
+		QString displayName;
+		QString idToolTip;
+		QString importName;
+		QComboBox* targetCombo { nullptr };
+		QTableWidgetItem* sourceItem { nullptr };
+		MappingOrigin origin { MappingOrigin::Untouched };
+		bool hasNameCollision { false };
+		bool ambiguousName { false };
+		bool wasMatchedByName { false };
+	};
+
+	QWidget* createTabPage(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+	                       const QStringList& targetStyles, QList<MappingRow>& rows);
+	QTableWidget* createTable(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+	                          const QStringList& targetStyles, QList<MappingRow>& rows);
+	void matchIdenticalNames(bool ignoreCase);
+	void mapRemaining(QList<MappingRow>& rows, const QString& targetStyle);
+	void resetMappings(QList<MappingRow>& rows);
+	void clearAmbiguities();
+	void updateRowWarning(MappingRow& row);
+	void updateAmbiguityWarning();
+	QHash<QString, QString> mappings(const QList<MappingRow>& rows) const;
+	QStringList paragraphStyleNames() const;
+	QStringList characterStyleNames() const;
+	void addTargetStyles(QComboBox* combo, const QStringList& targetStyles, const QString& family, bool includeImport = true) const;
+
+	ScribusDoc* m_doc { nullptr };
+	QString m_importPrefix;
+	QLabel* m_ambiguityWarningLabel { nullptr };
+	QList<MappingRow> m_paragraphRows;
+	QList<MappingRow> m_characterRows;
+};
+
+#endif
Index: scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp
===================================================================
--- scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp	(nonexistent)
+++ scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp	(working copy)
@@ -0,0 +1,569 @@
+/*
+For general Scribus (>=1.3.2) copyright and licensing information please refer
+to the COPYING file provided with the program. Following this notice may exist
+a copyright and/or license notice that predates the release of Scribus 1.3.2
+for which a new license (GPL+exception) is in place.
+*/
+#include "odtstylemappingdialog.h"
+
+#include <QAbstractItemView>
+#include <QApplication>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QHeaderView>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QStyle>
+#include <QTabWidget>
+#include <QTableWidget>
+#include <QTableWidgetItem>
+#include <QVBoxLayout>
+#include <QWheelEvent>
+
+#include "commonstrings.h"
+#include "iconmanager.h"
+#include "scribusdoc.h"
+#include "styles/charstyle.h"
+#include "styles/paragraphstyle.h"
+
+namespace
+{
+
+class OdtMappingComboBox : public QComboBox
+{
+public:
+	using QComboBox::QComboBox;
+
+protected:
+	void wheelEvent(QWheelEvent* event) override
+	{
+		event->ignore();
+	}
+};
+
+class OdtFilterLineEdit : public QLineEdit
+{
+public:
+	using QLineEdit::QLineEdit;
+
+protected:
+	void keyPressEvent(QKeyEvent* event) override
+	{
+		if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
+		{
+			event->accept();
+			return;
+		}
+		QLineEdit::keyPressEvent(event);
+	}
+};
+
+QIcon filterIcon()
+{
+	return IconManager::instance().loadIcon("action-filter");
+}
+
+int compactFieldWidth(const QWidget* widget, const QString& text)
+{
+	return widget->fontMetrics().horizontalAdvance(text) + 48;
+}
+
+}
+
+OdtStyleMappingDialog::OdtStyleMappingDialog(ScribusDoc* doc, const QList<ODTIm::StyleInfo>& styles, const QString& importPrefix, QWidget* parent)
+	: QDialog(parent),
+	  m_doc(doc),
+	  m_importPrefix(importPrefix)
+{
+	setModal(true);
+	setWindowTitle(tr("Match ODT Styles"));
+	setWindowIcon(QIcon(IconManager::instance().loadIcon("app-icon")));
+	resize(640, 420);
+
+	auto* mainLayout = new QVBoxLayout(this);
+	mainLayout->setSpacing(10);
+
+	auto* description = new QLabel(
+		tr("Map ODT styles to existing Scribus styles, or import them as new styles."), this);
+	description->setWordWrap(true);
+	mainLayout->addWidget(description);
+
+	auto* tabs = new QTabWidget(this);
+	const QStringList paragraphTargets = paragraphStyleNames();
+	const QStringList characterTargets = characterStyleNames();
+
+	QWidget* paragraphPage = createTabPage(styles, "paragraph", paragraphTargets, m_paragraphRows);
+	if (paragraphPage)
+		tabs->addTab(paragraphPage, tr("&Paragraph styles (%1)").arg(m_paragraphRows.size()));
+
+	QWidget* characterPage = createTabPage(styles, "text", characterTargets, m_characterRows);
+	if (characterPage)
+		tabs->addTab(characterPage, tr("Character styles (%1)").arg(m_characterRows.size()));
+
+	mainLayout->addWidget(tabs, 1);
+
+	auto* footerLayout = new QHBoxLayout;
+	footerLayout->setSpacing(8);
+	auto* matchButton = new QPushButton(tr("Match by &Name"), this);
+	matchButton->setToolTip(tr("Match ODT styles to Scribus styles with the same name."));
+	footerLayout->addWidget(matchButton);
+	auto* ignoreCaseCheck = new QCheckBox(tr("&Ignore case"), this);
+	ignoreCaseCheck->setToolTip(tr("Ignore letter case when matching style names."));
+	footerLayout->addWidget(ignoreCaseCheck);
+	m_ambiguityWarningLabel = new QLabel(this);
+	m_ambiguityWarningLabel->setPixmap(QApplication::style()->standardIcon(QStyle::SP_MessageBoxWarning).pixmap(16, 16));
+	m_ambiguityWarningLabel->setVisible(false);
+	footerLayout->addWidget(m_ambiguityWarningLabel);
+	footerLayout->addStretch();
+	auto* buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+	footerLayout->addWidget(buttonBox);
+	mainLayout->addLayout(footerLayout);
+
+	auto* cancelButton = buttonBox->button(QDialogButtonBox::Cancel);
+	auto* okButton = buttonBox->button(QDialogButtonBox::Ok);
+	QWidget::setTabOrder(matchButton, ignoreCaseCheck);
+	if (cancelButton && okButton)
+	{
+		QWidget::setTabOrder(ignoreCaseCheck, cancelButton);
+		QWidget::setTabOrder(cancelButton, okButton);
+	}
+
+	connect(matchButton, &QPushButton::clicked, this, [this, ignoreCaseCheck]()
+	{
+		matchIdenticalNames(ignoreCaseCheck->isChecked());
+	});
+	connect(ignoreCaseCheck, &QCheckBox::toggled, this, [this](bool checked)
+	{
+		if (!checked)
+			clearAmbiguities();
+	});
+	connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+	connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::paragraphMappings() const
+{
+	return mappings(m_paragraphRows);
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::characterMappings() const
+{
+	return mappings(m_characterRows);
+}
+
+QWidget* OdtStyleMappingDialog::createTabPage(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+                                                const QStringList& targetStyles, QList<MappingRow>& rows)
+{
+	auto* page = new QWidget(this);
+	auto* pageLayout = new QVBoxLayout(page);
+	pageLayout->setContentsMargins(8, 8, 8, 8);
+	pageLayout->setSpacing(8);
+
+	QTableWidget* table = createTable(styles, family, targetStyles, rows);
+	if (table->rowCount() == 0)
+	{
+		delete table;
+		delete page;
+		return nullptr;
+	}
+
+	auto* filterLayout = new QHBoxLayout;
+	filterLayout->setSpacing(8);
+	auto* filterEdit = new OdtFilterLineEdit(page);
+	filterEdit->setPlaceholderText(tr("Filter styles…"));
+	filterEdit->addAction(filterIcon(), QLineEdit::LeadingPosition);
+	filterEdit->setClearButtonEnabled(true);
+	filterEdit->setFixedWidth(compactFieldWidth(filterEdit, tr("Import as New Style")));
+	filterLayout->addWidget(filterEdit);
+	auto* mappedOnlyCheck = new QCheckBox(tr("&Mapped only"), page);
+	mappedOnlyCheck->setToolTip(tr("Show only styles mapped to an existing Scribus style."));
+	filterLayout->addWidget(mappedOnlyCheck);
+	auto* unmappedOnlyCheck = new QCheckBox(tr("&Unmapped only"), page);
+	unmappedOnlyCheck->setToolTip(tr("Show only styles not mapped to an existing Scribus style."));
+	filterLayout->addWidget(unmappedOnlyCheck);
+	filterLayout->addStretch();
+	pageLayout->addLayout(filterLayout);
+	pageLayout->addWidget(table, 1);
+
+	auto* bulkLayout = new QHBoxLayout;
+	bulkLayout->setSpacing(0);
+	bulkLayout->setContentsMargins(0, 8, 0, 0);
+	auto* bulkLabel = new QLabel(tr("Map &remaining styles to:"), page);
+	bulkLayout->addWidget(bulkLabel);
+	bulkLayout->addSpacing(8);
+	auto* bulkCombo = new OdtMappingComboBox(page);
+	addTargetStyles(bulkCombo, targetStyles, family);
+	bulkCombo->setToolTip(tr("Choose a target for unmapped styles."));
+	bulkCombo->setFixedWidth(compactFieldWidth(bulkCombo, tr("Import as New Style")) + 5);
+	bulkLabel->setBuddy(bulkCombo);
+	bulkLayout->addWidget(bulkCombo);
+	bulkLayout->addSpacing(8);
+	auto* applyButton = new QPushButton(tr("&Apply"), page);
+	applyButton->setToolTip(tr("Apply this target to all remaining unmapped styles."));
+	applyButton->setFixedWidth(applyButton->sizeHint().width() - 10);
+	bulkLayout->addWidget(applyButton);
+	bulkLayout->addStretch();
+	auto* resetButton = new QPushButton(IconManager::instance().loadIcon("reset"), tr("Reset"), page);
+	resetButton->setToolTip(tr("Reset all mappings in this tab."));
+	bulkLayout->addWidget(resetButton);
+	pageLayout->addLayout(bulkLayout);
+
+	QWidget::setTabOrder(filterEdit, mappedOnlyCheck);
+	QWidget::setTabOrder(mappedOnlyCheck, unmappedOnlyCheck);
+	QWidget* previousWidget = unmappedOnlyCheck;
+	for (const MappingRow& row : rows)
+	{
+		QWidget::setTabOrder(previousWidget, row.targetCombo);
+		previousWidget = row.targetCombo;
+	}
+	QWidget::setTabOrder(previousWidget, bulkCombo);
+	QWidget::setTabOrder(bulkCombo, applyButton);
+	QWidget::setTabOrder(applyButton, resetButton);
+
+	QList<MappingRow>* rowsPtr = &rows;
+	auto updateFilter = [table, filterEdit, unmappedOnlyCheck, mappedOnlyCheck, rowsPtr]()
+	{
+		const QString filter = filterEdit->text();
+		for (int row = 0; row < table->rowCount(); ++row)
+		{
+			const QTableWidgetItem* item = table->item(row, 0);
+			if (!item)
+				continue;
+
+			const bool textMatches = filter.isEmpty()
+				|| item->text().contains(filter, Qt::CaseInsensitive)
+				|| item->data(Qt::UserRole).toString().contains(filter, Qt::CaseInsensitive);
+			const bool isUnmapped = row < rowsPtr->size()
+				&& rowsPtr->at(row).targetCombo->currentData().toString().isEmpty();
+			const bool mappingMatches = (!unmappedOnlyCheck->isChecked() && !mappedOnlyCheck->isChecked())
+				|| (unmappedOnlyCheck->isChecked() && isUnmapped)
+				|| (mappedOnlyCheck->isChecked() && !isUnmapped);
+			table->setRowHidden(row, !(textMatches && mappingMatches));
+		}
+	};
+
+	connect(filterEdit, &QLineEdit::textChanged, this, [updateFilter](const QString&)
+	{
+		updateFilter();
+	});
+	connect(unmappedOnlyCheck, &QCheckBox::toggled, this, [mappedOnlyCheck, updateFilter](bool checked)
+	{
+		if (checked)
+			mappedOnlyCheck->setChecked(false);
+		updateFilter();
+	});
+	connect(mappedOnlyCheck, &QCheckBox::toggled, this, [unmappedOnlyCheck, updateFilter](bool checked)
+	{
+		if (checked)
+			unmappedOnlyCheck->setChecked(false);
+		updateFilter();
+	});
+	for (const MappingRow& row : rows)
+	{
+		connect(row.targetCombo, &QComboBox::currentIndexChanged, this, [updateFilter](int)
+		{
+			updateFilter();
+		});
+	}
+
+	connect(applyButton, &QPushButton::clicked, this, [this, rowsPtr, bulkCombo]()
+	{
+		mapRemaining(*rowsPtr, bulkCombo->currentData().toString());
+	});
+	connect(resetButton, &QPushButton::clicked, this, [this, rowsPtr]()
+	{
+		resetMappings(*rowsPtr);
+	});
+
+	return page;
+}
+
+QTableWidget* OdtStyleMappingDialog::createTable(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+                                                 const QStringList& targetStyles, QList<MappingRow>& rows)
+{
+	auto* table = new QTableWidget(this);
+	table->setColumnCount(2);
+	table->setHorizontalHeaderLabels(QStringList() << tr("ODT Style") << tr("Scribus Style"));
+	table->verticalHeader()->setVisible(false);
+	auto* header = table->horizontalHeader();
+	header->setMinimumHeight(header->sizeHint().height() + 5);
+	header->setSectionResizeMode(0, QHeaderView::Stretch);
+	header->setSectionResizeMode(1, QHeaderView::Stretch);
+	table->setSelectionMode(QAbstractItemView::NoSelection);
+	table->setEditTriggers(QAbstractItemView::NoEditTriggers);
+	table->setAlternatingRowColors(true);
+	table->setFocusPolicy(Qt::NoFocus);
+	table->setTabKeyNavigation(false);
+
+	for (const ODTIm::StyleInfo& style : styles)
+	{
+		if (style.family != family)
+			continue;
+
+		const int row = table->rowCount();
+		table->insertRow(row);
+
+		auto* sourceItem = new QTableWidgetItem(style.displayName);
+		sourceItem->setData(Qt::UserRole, style.sourceName);
+		table->setItem(row, 0, sourceItem);
+
+		auto* combo = new OdtMappingComboBox(table);
+		addTargetStyles(combo, targetStyles, family);
+		table->setCellWidget(row, 1, combo);
+
+		MappingRow mappingRow;
+		mappingRow.sourceName = style.sourceName;
+		mappingRow.displayName = style.displayName;
+		mappingRow.idToolTip = (style.sourceName != style.displayName)
+			? tr("ODT style ID: %1").arg(style.sourceName) : QString();
+		mappingRow.importName = m_importPrefix + style.displayName;
+		mappingRow.targetCombo = combo;
+		mappingRow.sourceItem = sourceItem;
+		mappingRow.hasNameCollision = targetStyles.contains(mappingRow.importName);
+		rows.append(mappingRow);
+
+		QList<MappingRow>* rowsPtr = &rows;
+		connect(combo, &QComboBox::currentIndexChanged, this, [this, rowsPtr, combo](int)
+		{
+			for (MappingRow& mappedRow : *rowsPtr)
+			{
+				if (mappedRow.targetCombo != combo)
+					continue;
+				updateRowWarning(mappedRow);
+				break;
+			}
+		});
+		connect(combo, &QComboBox::activated, this, [this, rowsPtr, combo](int)
+		{
+			for (MappingRow& mappedRow : *rowsPtr)
+			{
+				if (mappedRow.targetCombo != combo)
+					continue;
+				mappedRow.origin = MappingOrigin::Manual;
+				mappedRow.ambiguousName = false;
+				updateRowWarning(mappedRow);
+				updateAmbiguityWarning();
+				break;
+			}
+		});
+		updateRowWarning(rows.last());
+	}
+
+	return table;
+}
+
+void OdtStyleMappingDialog::matchIdenticalNames(bool ignoreCase)
+{
+	clearAmbiguities();
+
+	auto matchRows = [this, ignoreCase](QList<MappingRow>& rows)
+	{
+		for (MappingRow& row : rows)
+		{
+			if (row.origin == MappingOrigin::Manual && !row.wasMatchedByName)
+				continue;
+
+			auto findMatchingTarget = [ignoreCase, &row](const QString& sourceName)
+			{
+				if (!ignoreCase)
+					return row.targetCombo->findData(sourceName);
+
+				int matchIndex = -1;
+				for (int i = 0; i < row.targetCombo->count(); ++i)
+				{
+					const QString targetName = row.targetCombo->itemData(i).toString();
+					if (targetName.isEmpty() || QString::compare(targetName, sourceName, Qt::CaseInsensitive) != 0)
+						continue;
+					if (matchIndex >= 0)
+						return -2;
+					matchIndex = i;
+				}
+				return matchIndex;
+			};
+
+			int index = findMatchingTarget(row.displayName);
+			if (index == -1 && row.sourceName != row.displayName)
+				index = findMatchingTarget(row.sourceName);
+
+			if (index == -2)
+			{
+				row.ambiguousName = true;
+				row.origin = MappingOrigin::Untouched;
+				const int importIndex = row.targetCombo->findData(QString());
+				if (importIndex >= 0)
+					row.targetCombo->setCurrentIndex(importIndex);
+				updateRowWarning(row);
+				continue;
+			}
+
+			if (index >= 0)
+			{
+				row.targetCombo->setCurrentIndex(index);
+				row.origin = MappingOrigin::NameMatch;
+				row.wasMatchedByName = true;
+			}
+			updateRowWarning(row);
+		}
+	};
+
+	matchRows(m_paragraphRows);
+	matchRows(m_characterRows);
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::mapRemaining(QList<MappingRow>& rows, const QString& targetStyle)
+{
+	for (MappingRow& row : rows)
+	{
+		if (row.origin == MappingOrigin::Manual || row.origin == MappingOrigin::NameMatch)
+			continue;
+
+		const int index = row.targetCombo->findData(targetStyle);
+		if (index >= 0)
+		{
+			row.targetCombo->setCurrentIndex(index);
+			row.origin = MappingOrigin::Bulk;
+			row.ambiguousName = false;
+			updateRowWarning(row);
+		}
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::resetMappings(QList<MappingRow>& rows)
+{
+	for (MappingRow& row : rows)
+	{
+		const int importIndex = row.targetCombo->findData(QString());
+		if (importIndex >= 0)
+			row.targetCombo->setCurrentIndex(importIndex);
+		row.origin = MappingOrigin::Untouched;
+		row.ambiguousName = false;
+		row.wasMatchedByName = false;
+		updateRowWarning(row);
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::clearAmbiguities()
+{
+	for (MappingRow& row : m_paragraphRows)
+	{
+		row.ambiguousName = false;
+		updateRowWarning(row);
+	}
+	for (MappingRow& row : m_characterRows)
+	{
+		row.ambiguousName = false;
+		updateRowWarning(row);
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::updateRowWarning(MappingRow& row)
+{
+	if (!row.sourceItem || !row.targetCombo)
+		return;
+
+	QStringList toolTips;
+	if (!row.idToolTip.isEmpty())
+		toolTips.append(row.idToolTip);
+
+	const bool collisionWarning = row.hasNameCollision && row.targetCombo->currentData().toString().isEmpty();
+	if (collisionWarning)
+		toolTips.append(tr("A Scribus style named \"%1\" already exists.\nThe imported ODT style will be renamed.").arg(row.importName));
+	if (row.ambiguousName)
+		toolTips.append(tr("Multiple Scribus styles match this name when case is ignored."));
+
+	if (collisionWarning || row.ambiguousName)
+		row.sourceItem->setIcon(QApplication::style()->standardIcon(QStyle::SP_MessageBoxWarning));
+	else
+		row.sourceItem->setIcon(QIcon());
+	row.sourceItem->setToolTip(toolTips.join('\n'));
+}
+
+void OdtStyleMappingDialog::updateAmbiguityWarning()
+{
+	if (!m_ambiguityWarningLabel)
+		return;
+
+	int ambiguousCount = 0;
+	for (const MappingRow& row : m_paragraphRows)
+		ambiguousCount += row.ambiguousName ? 1 : 0;
+	for (const MappingRow& row : m_characterRows)
+		ambiguousCount += row.ambiguousName ? 1 : 0;
+
+	m_ambiguityWarningLabel->setVisible(ambiguousCount > 0);
+	if (ambiguousCount == 1)
+		m_ambiguityWarningLabel->setToolTip(tr("1 style name has multiple matches when case is ignored and was left unmapped."));
+	else if (ambiguousCount > 1)
+		m_ambiguityWarningLabel->setToolTip(tr("%1 style names have multiple matches when case is ignored and were left unmapped.").arg(ambiguousCount));
+	else
+		m_ambiguityWarningLabel->setToolTip(QString());
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::mappings(const QList<MappingRow>& rows) const
+{
+	QHash<QString, QString> result;
+	for (const MappingRow& row : rows)
+	{
+		const QString targetStyle = row.targetCombo->currentData().toString();
+		if (!targetStyle.isEmpty())
+			result.insert(row.sourceName, targetStyle);
+	}
+	return result;
+}
+
+QStringList OdtStyleMappingDialog::paragraphStyleNames() const
+{
+	QStringList result;
+	if (!m_doc)
+		return result;
+
+	for (int i = 0; i < m_doc->paragraphStyles().count(); ++i)
+	{
+		const ParagraphStyle& style = m_doc->paragraphStyles()[i];
+		if (!style.name().isEmpty() && !style.isDefaultStyle())
+			result.append(style.name());
+	}
+	result.sort(Qt::CaseInsensitive);
+	result.prepend(CommonStrings::DefaultParagraphStyle);
+	return result;
+}
+
+QStringList OdtStyleMappingDialog::characterStyleNames() const
+{
+	QStringList result;
+	if (!m_doc)
+		return result;
+
+	for (int i = 0; i < m_doc->charStyles().count(); ++i)
+	{
+		const CharStyle& style = m_doc->charStyles()[i];
+		if (!style.name().isEmpty() && !style.isDefaultStyle())
+			result.append(style.name());
+	}
+	result.sort(Qt::CaseInsensitive);
+	result.prepend(CommonStrings::DefaultCharacterStyle);
+	return result;
+}
+
+void OdtStyleMappingDialog::addTargetStyles(QComboBox* combo, const QStringList& targetStyles, const QString& family, bool includeImport) const
+{
+	if (includeImport)
+		combo->addItem(tr("Import as New Style"), QString());
+	for (const QString& targetStyle : targetStyles)
+	{
+		QString displayName = targetStyle;
+		if (family == "paragraph" && targetStyle == CommonStrings::DefaultParagraphStyle)
+			displayName = CommonStrings::trDefaultParagraphStyle;
+		else if (family == "text" && targetStyle == CommonStrings::DefaultCharacterStyle)
+			displayName = CommonStrings::trDefaultCharacterStyle;
+		combo->addItem(displayName, targetStyle);
+	}
+}
Index: resources/iconsets/1_7_0/1_7_0.xml
===================================================================
--- resources/iconsets/1_7_0/1_7_0.xml	(revision 27810)
+++ resources/iconsets/1_7_0/1_7_0.xml	(working copy)
@@ -41,6 +41,7 @@
     		
 		<!-- Action -->	
 		<icon id="add" file="16/action-add.svg" />
+		<icon id="action-filter" file="16/action-filter.svg" />
 		<icon id="chain-closed" file="16/action-link.svg" />
 		<icon id="chain-open" file="16/action-unlink.svg" />
 		<icon id="clear-right" file="16/action-backspace-reverse.svg" />
Index: resources/iconsets/1_7_0/16/action-filter.svg
===================================================================
--- resources/iconsets/1_7_0/16/action-filter.svg	(nonexistent)
+++ resources/iconsets/1_7_0/16/action-filter.svg	(working copy)
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><style>@import '../colors.css';</style>
+<path d="M1 2H15L9.5 8V13.5L6.5 15V8L1 2Z"/>
+</svg>

qirat

2026-09-03 03:19

reporter   ~0054403

#Files

## Changed existing files

- `scribus/gtgettext.cpp`
- `scribus/plugins/gettext/odt2im/importodt.h`
- `scribus/plugins/gettext/odt2im/importodt.cpp`
- `scribus/plugins/gettext/odt2im/CMakeLists.txt`
- `resources/iconsets/1_7_0/1_7_0.xml`

## New files added

- `scribus/plugins/gettext/odt2im/odtstylemappingdialog.h`
- `scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp`
- `resources/iconsets/1_7_0/16/action-filter.svg`

## Existing Scribus resources reused

- Existing `reset` icon — referenced through `IconManager`; its resource file is not modified or duplicated.

qirat

2026-09-03 05:18

reporter   ~0054404

Character Style tab added. A small change to only osm3.
osm3-style-matching-dialog-v1.26.patch (24,784 bytes)   
Index: scribus/plugins/gettext/odt2im/CMakeLists.txt
===================================================================
--- scribus/plugins/gettext/odt2im/CMakeLists.txt	(revision 27810)
+++ scribus/plugins/gettext/odt2im/CMakeLists.txt	(working copy)
@@ -5,6 +5,7 @@
 
 set(ODT_IM_PLUGIN_SOURCES
 	importodt.cpp
+	odtstylemappingdialog.cpp
 )
 
 set(SCRIBUS_ODT_IM_PLUGIN "odtimplugin")
Index: scribus/plugins/gettext/odt2im/importodt.cpp
===================================================================
--- scribus/plugins/gettext/odt2im/importodt.cpp	(revision 27810)
+++ scribus/plugins/gettext/odt2im/importodt.cpp	(working copy)
@@ -11,6 +11,7 @@
 	email                : Franz.Schmid@altmuehlnet.de
  ***************************************************************************/
 #include "importodt.h"
+#include "odtstylemappingdialog.h"
 
 #include <QApplication>
 #include <QByteArray>
@@ -43,8 +44,20 @@
 
 void GetText2(const QString& filename, const QString& /*encoding*/, bool textOnly, bool prefix, bool append, PageItem *textItem)
 {
-	auto docxim = std::make_unique<ODTIm>(textItem, prefix, append);
-	docxim->importFile(filename, textOnly);
+	auto odtim = std::make_unique<ODTIm>(textItem, prefix, append);
+	if (!textOnly && odtim->collectStyles(filename))
+	{
+		const QList<ODTIm::StyleInfo> styles = odtim->namedStyles();
+		if (!styles.isEmpty())
+		{
+			const QString importPrefix = prefix ? textItem->itemName() + "_" : QString();
+			OdtStyleMappingDialog dialog(textItem->doc(), styles, importPrefix, QApplication::activeWindow());
+			if (dialog.exec() != QDialog::Accepted)
+				return;
+			odtim->setStyleMappings(dialog.paragraphMappings(), dialog.characterMappings());
+		}
+	}
+	odtim->importFile(filename, textOnly);
 }
 
 ODTIm::ODTIm(PageItem *textItem, bool prefix, bool append)
Index: scribus/plugins/gettext/odt2im/odtstylemappingdialog.h
===================================================================
--- scribus/plugins/gettext/odt2im/odtstylemappingdialog.h	(nonexistent)
+++ scribus/plugins/gettext/odt2im/odtstylemappingdialog.h	(working copy)
@@ -0,0 +1,79 @@
+/*
+For general Scribus (>=1.3.2) copyright and licensing information please refer
+to the COPYING file provided with the program. Following this notice may exist
+a copyright and/or license notice that predates the release of Scribus 1.3.2
+for which a new license (GPL+exception) is in place.
+*/
+#ifndef ODTSTYLEMAPPINGDIALOG_H
+#define ODTSTYLEMAPPINGDIALOG_H
+
+#include <QDialog>
+#include <QHash>
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+#include "importodt.h"
+
+class QComboBox;
+class QLabel;
+class QTableWidget;
+class QTableWidgetItem;
+class ScribusDoc;
+
+class OdtStyleMappingDialog : public QDialog
+{
+	Q_OBJECT
+
+public:
+	OdtStyleMappingDialog(ScribusDoc* doc, const QList<ODTIm::StyleInfo>& styles, const QString& importPrefix, QWidget* parent = nullptr);
+
+	QHash<QString, QString> paragraphMappings() const;
+	QHash<QString, QString> characterMappings() const;
+
+private:
+	enum class MappingOrigin
+	{
+		Untouched,
+		Bulk,
+		NameMatch,
+		Manual
+	};
+
+	struct MappingRow
+	{
+		QString sourceName;
+		QString displayName;
+		QString idToolTip;
+		QString importName;
+		QComboBox* targetCombo { nullptr };
+		QTableWidgetItem* sourceItem { nullptr };
+		MappingOrigin origin { MappingOrigin::Untouched };
+		bool hasNameCollision { false };
+		bool ambiguousName { false };
+		bool wasMatchedByName { false };
+	};
+
+	QWidget* createTabPage(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+	                       const QStringList& targetStyles, QList<MappingRow>& rows);
+	QTableWidget* createTable(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+	                          const QStringList& targetStyles, QList<MappingRow>& rows);
+	void matchIdenticalNames(bool ignoreCase);
+	void mapRemaining(QList<MappingRow>& rows, const QString& targetStyle);
+	void resetMappings(QList<MappingRow>& rows);
+	void clearAmbiguities();
+	void updateRowWarning(MappingRow& row);
+	void updateAmbiguityWarning();
+	QHash<QString, QString> mappings(const QList<MappingRow>& rows) const;
+	QStringList paragraphStyleNames() const;
+	QStringList characterStyleNames() const;
+	void addTargetStyles(QComboBox* combo, const QStringList& targetStyles, const QString& family, bool includeImport = true) const;
+
+	ScribusDoc* m_doc { nullptr };
+	QString m_importPrefix;
+	QLabel* m_ambiguityWarningLabel { nullptr };
+	QList<MappingRow> m_paragraphRows;
+	QList<MappingRow> m_characterRows;
+};
+
+#endif
Index: scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp
===================================================================
--- scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp	(nonexistent)
+++ scribus/plugins/gettext/odt2im/odtstylemappingdialog.cpp	(working copy)
@@ -0,0 +1,572 @@
+/*
+For general Scribus (>=1.3.2) copyright and licensing information please refer
+to the COPYING file provided with the program. Following this notice may exist
+a copyright and/or license notice that predates the release of Scribus 1.3.2
+for which a new license (GPL+exception) is in place.
+*/
+#include "odtstylemappingdialog.h"
+
+#include <QAbstractItemView>
+#include <QApplication>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QHeaderView>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QStyle>
+#include <QTabWidget>
+#include <QTableWidget>
+#include <QTableWidgetItem>
+#include <QVBoxLayout>
+#include <QWheelEvent>
+
+#include "commonstrings.h"
+#include "iconmanager.h"
+#include "scribusdoc.h"
+#include "styles/charstyle.h"
+#include "styles/paragraphstyle.h"
+
+namespace
+{
+
+class OdtMappingComboBox : public QComboBox
+{
+public:
+	using QComboBox::QComboBox;
+
+protected:
+	void wheelEvent(QWheelEvent* event) override
+	{
+		event->ignore();
+	}
+};
+
+class OdtFilterLineEdit : public QLineEdit
+{
+public:
+	using QLineEdit::QLineEdit;
+
+protected:
+	void keyPressEvent(QKeyEvent* event) override
+	{
+		if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
+		{
+			event->accept();
+			return;
+		}
+		QLineEdit::keyPressEvent(event);
+	}
+};
+
+QIcon filterIcon()
+{
+	return IconManager::instance().loadIcon("action-filter");
+}
+
+int compactFieldWidth(const QWidget* widget, const QString& text)
+{
+	return widget->fontMetrics().horizontalAdvance(text) + 48;
+}
+
+}
+
+OdtStyleMappingDialog::OdtStyleMappingDialog(ScribusDoc* doc, const QList<ODTIm::StyleInfo>& styles, const QString& importPrefix, QWidget* parent)
+	: QDialog(parent),
+	  m_doc(doc),
+	  m_importPrefix(importPrefix)
+{
+	setModal(true);
+	setWindowTitle(tr("Match ODT Styles"));
+	setWindowIcon(QIcon(IconManager::instance().loadIcon("app-icon")));
+	resize(640, 420);
+
+	auto* mainLayout = new QVBoxLayout(this);
+	mainLayout->setSpacing(10);
+
+	auto* description = new QLabel(
+		tr("Map ODT styles to existing Scribus styles, or import them as new styles."), this);
+	description->setWordWrap(true);
+	mainLayout->addWidget(description);
+
+	auto* tabs = new QTabWidget(this);
+	const QStringList paragraphTargets = paragraphStyleNames();
+	const QStringList characterTargets = characterStyleNames();
+
+	QWidget* paragraphPage = createTabPage(styles, "paragraph", paragraphTargets, m_paragraphRows);
+	tabs->addTab(paragraphPage, tr("&Paragraph styles (%1)").arg(m_paragraphRows.size()));
+
+	QWidget* characterPage = createTabPage(styles, "text", characterTargets, m_characterRows);
+	tabs->addTab(characterPage, tr("&Character styles (%1)").arg(m_characterRows.size()));
+	if (m_paragraphRows.isEmpty() && !m_characterRows.isEmpty())
+		tabs->setCurrentWidget(characterPage);
+
+	mainLayout->addWidget(tabs, 1);
+
+	auto* footerLayout = new QHBoxLayout;
+	footerLayout->setSpacing(8);
+	auto* matchButton = new QPushButton(tr("Match by &Name"), this);
+	matchButton->setToolTip(tr("Match ODT styles to Scribus styles with the same name."));
+	footerLayout->addWidget(matchButton);
+	auto* ignoreCaseCheck = new QCheckBox(tr("&Ignore case"), this);
+	ignoreCaseCheck->setToolTip(tr("Ignore letter case when matching style names."));
+	footerLayout->addWidget(ignoreCaseCheck);
+	m_ambiguityWarningLabel = new QLabel(this);
+	m_ambiguityWarningLabel->setPixmap(QApplication::style()->standardIcon(QStyle::SP_MessageBoxWarning).pixmap(16, 16));
+	m_ambiguityWarningLabel->setVisible(false);
+	footerLayout->addWidget(m_ambiguityWarningLabel);
+	footerLayout->addStretch();
+	auto* buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+	footerLayout->addWidget(buttonBox);
+	mainLayout->addLayout(footerLayout);
+
+	auto* cancelButton = buttonBox->button(QDialogButtonBox::Cancel);
+	auto* okButton = buttonBox->button(QDialogButtonBox::Ok);
+	QWidget::setTabOrder(matchButton, ignoreCaseCheck);
+	if (cancelButton && okButton)
+	{
+		QWidget::setTabOrder(ignoreCaseCheck, cancelButton);
+		QWidget::setTabOrder(cancelButton, okButton);
+	}
+
+	connect(matchButton, &QPushButton::clicked, this, [this, ignoreCaseCheck]()
+	{
+		matchIdenticalNames(ignoreCaseCheck->isChecked());
+	});
+	connect(ignoreCaseCheck, &QCheckBox::toggled, this, [this](bool checked)
+	{
+		if (!checked)
+			clearAmbiguities();
+	});
+	connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+	connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::paragraphMappings() const
+{
+	return mappings(m_paragraphRows);
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::characterMappings() const
+{
+	return mappings(m_characterRows);
+}
+
+QWidget* OdtStyleMappingDialog::createTabPage(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+                                                const QStringList& targetStyles, QList<MappingRow>& rows)
+{
+	auto* page = new QWidget(this);
+	auto* pageLayout = new QVBoxLayout(page);
+	pageLayout->setContentsMargins(8, 8, 8, 8);
+	pageLayout->setSpacing(8);
+
+	QTableWidget* table = createTable(styles, family, targetStyles, rows);
+	if (table->rowCount() == 0)
+	{
+		delete table;
+		auto* emptyLabel = new QLabel(
+			family == "text" ? tr("No character styles found.") : tr("No paragraph styles found."), page);
+		emptyLabel->setAlignment(Qt::AlignCenter);
+		pageLayout->addWidget(emptyLabel, 1);
+		return page;
+	}
+
+	auto* filterLayout = new QHBoxLayout;
+	filterLayout->setSpacing(8);
+	auto* filterEdit = new OdtFilterLineEdit(page);
+	filterEdit->setPlaceholderText(tr("Filter styles…"));
+	filterEdit->addAction(filterIcon(), QLineEdit::LeadingPosition);
+	filterEdit->setClearButtonEnabled(true);
+	filterEdit->setFixedWidth(compactFieldWidth(filterEdit, tr("Import as New Style")));
+	filterLayout->addWidget(filterEdit);
+	auto* mappedOnlyCheck = new QCheckBox(tr("&Mapped only"), page);
+	mappedOnlyCheck->setToolTip(tr("Show only styles mapped to an existing Scribus style."));
+	filterLayout->addWidget(mappedOnlyCheck);
+	auto* unmappedOnlyCheck = new QCheckBox(tr("&Unmapped only"), page);
+	unmappedOnlyCheck->setToolTip(tr("Show only styles not mapped to an existing Scribus style."));
+	filterLayout->addWidget(unmappedOnlyCheck);
+	filterLayout->addStretch();
+	pageLayout->addLayout(filterLayout);
+	pageLayout->addWidget(table, 1);
+
+	auto* bulkLayout = new QHBoxLayout;
+	bulkLayout->setSpacing(0);
+	bulkLayout->setContentsMargins(0, 8, 0, 0);
+	auto* bulkLabel = new QLabel(tr("Map &remaining styles to:"), page);
+	bulkLayout->addWidget(bulkLabel);
+	bulkLayout->addSpacing(8);
+	auto* bulkCombo = new OdtMappingComboBox(page);
+	addTargetStyles(bulkCombo, targetStyles, family);
+	bulkCombo->setToolTip(tr("Choose a target for unmapped styles."));
+	bulkCombo->setFixedWidth(compactFieldWidth(bulkCombo, tr("Import as New Style")) + 5);
+	bulkLabel->setBuddy(bulkCombo);
+	bulkLayout->addWidget(bulkCombo);
+	bulkLayout->addSpacing(8);
+	auto* applyButton = new QPushButton(tr("&Apply"), page);
+	applyButton->setToolTip(tr("Apply this target to all remaining unmapped styles."));
+	applyButton->setFixedWidth(applyButton->sizeHint().width() - 10);
+	bulkLayout->addWidget(applyButton);
+	bulkLayout->addStretch();
+	auto* resetButton = new QPushButton(IconManager::instance().loadIcon("reset"), tr("Reset"), page);
+	resetButton->setToolTip(tr("Reset all mappings in this tab."));
+	bulkLayout->addWidget(resetButton);
+	pageLayout->addLayout(bulkLayout);
+
+	QWidget::setTabOrder(filterEdit, mappedOnlyCheck);
+	QWidget::setTabOrder(mappedOnlyCheck, unmappedOnlyCheck);
+	QWidget* previousWidget = unmappedOnlyCheck;
+	for (const MappingRow& row : rows)
+	{
+		QWidget::setTabOrder(previousWidget, row.targetCombo);
+		previousWidget = row.targetCombo;
+	}
+	QWidget::setTabOrder(previousWidget, bulkCombo);
+	QWidget::setTabOrder(bulkCombo, applyButton);
+	QWidget::setTabOrder(applyButton, resetButton);
+
+	QList<MappingRow>* rowsPtr = &rows;
+	auto updateFilter = [table, filterEdit, unmappedOnlyCheck, mappedOnlyCheck, rowsPtr]()
+	{
+		const QString filter = filterEdit->text();
+		for (int row = 0; row < table->rowCount(); ++row)
+		{
+			const QTableWidgetItem* item = table->item(row, 0);
+			if (!item)
+				continue;
+
+			const bool textMatches = filter.isEmpty()
+				|| item->text().contains(filter, Qt::CaseInsensitive)
+				|| item->data(Qt::UserRole).toString().contains(filter, Qt::CaseInsensitive);
+			const bool isUnmapped = row < rowsPtr->size()
+				&& rowsPtr->at(row).targetCombo->currentData().toString().isEmpty();
+			const bool mappingMatches = (!unmappedOnlyCheck->isChecked() && !mappedOnlyCheck->isChecked())
+				|| (unmappedOnlyCheck->isChecked() && isUnmapped)
+				|| (mappedOnlyCheck->isChecked() && !isUnmapped);
+			table->setRowHidden(row, !(textMatches && mappingMatches));
+		}
+	};
+
+	connect(filterEdit, &QLineEdit::textChanged, this, [updateFilter](const QString&)
+	{
+		updateFilter();
+	});
+	connect(unmappedOnlyCheck, &QCheckBox::toggled, this, [mappedOnlyCheck, updateFilter](bool checked)
+	{
+		if (checked)
+			mappedOnlyCheck->setChecked(false);
+		updateFilter();
+	});
+	connect(mappedOnlyCheck, &QCheckBox::toggled, this, [unmappedOnlyCheck, updateFilter](bool checked)
+	{
+		if (checked)
+			unmappedOnlyCheck->setChecked(false);
+		updateFilter();
+	});
+	for (const MappingRow& row : rows)
+	{
+		connect(row.targetCombo, &QComboBox::currentIndexChanged, this, [updateFilter](int)
+		{
+			updateFilter();
+		});
+	}
+
+	connect(applyButton, &QPushButton::clicked, this, [this, rowsPtr, bulkCombo]()
+	{
+		mapRemaining(*rowsPtr, bulkCombo->currentData().toString());
+	});
+	connect(resetButton, &QPushButton::clicked, this, [this, rowsPtr]()
+	{
+		resetMappings(*rowsPtr);
+	});
+
+	return page;
+}
+
+QTableWidget* OdtStyleMappingDialog::createTable(const QList<ODTIm::StyleInfo>& styles, const QString& family,
+                                                 const QStringList& targetStyles, QList<MappingRow>& rows)
+{
+	auto* table = new QTableWidget(this);
+	table->setColumnCount(2);
+	table->setHorizontalHeaderLabels(QStringList() << tr("ODT Style") << tr("Scribus Style"));
+	table->verticalHeader()->setVisible(false);
+	auto* header = table->horizontalHeader();
+	header->setMinimumHeight(header->sizeHint().height() + 5);
+	header->setSectionResizeMode(0, QHeaderView::Stretch);
+	header->setSectionResizeMode(1, QHeaderView::Stretch);
+	table->setSelectionMode(QAbstractItemView::NoSelection);
+	table->setEditTriggers(QAbstractItemView::NoEditTriggers);
+	table->setAlternatingRowColors(true);
+	table->setFocusPolicy(Qt::NoFocus);
+	table->setTabKeyNavigation(false);
+
+	for (const ODTIm::StyleInfo& style : styles)
+	{
+		if (style.family != family)
+			continue;
+
+		const int row = table->rowCount();
+		table->insertRow(row);
+
+		auto* sourceItem = new QTableWidgetItem(style.displayName);
+		sourceItem->setData(Qt::UserRole, style.sourceName);
+		table->setItem(row, 0, sourceItem);
+
+		auto* combo = new OdtMappingComboBox(table);
+		addTargetStyles(combo, targetStyles, family);
+		table->setCellWidget(row, 1, combo);
+
+		MappingRow mappingRow;
+		mappingRow.sourceName = style.sourceName;
+		mappingRow.displayName = style.displayName;
+		mappingRow.idToolTip = (style.sourceName != style.displayName)
+			? tr("ODT style ID: %1").arg(style.sourceName) : QString();
+		mappingRow.importName = m_importPrefix + style.displayName;
+		mappingRow.targetCombo = combo;
+		mappingRow.sourceItem = sourceItem;
+		mappingRow.hasNameCollision = targetStyles.contains(mappingRow.importName);
+		rows.append(mappingRow);
+
+		QList<MappingRow>* rowsPtr = &rows;
+		connect(combo, &QComboBox::currentIndexChanged, this, [this, rowsPtr, combo](int)
+		{
+			for (MappingRow& mappedRow : *rowsPtr)
+			{
+				if (mappedRow.targetCombo != combo)
+					continue;
+				updateRowWarning(mappedRow);
+				break;
+			}
+		});
+		connect(combo, &QComboBox::activated, this, [this, rowsPtr, combo](int)
+		{
+			for (MappingRow& mappedRow : *rowsPtr)
+			{
+				if (mappedRow.targetCombo != combo)
+					continue;
+				mappedRow.origin = MappingOrigin::Manual;
+				mappedRow.ambiguousName = false;
+				updateRowWarning(mappedRow);
+				updateAmbiguityWarning();
+				break;
+			}
+		});
+		updateRowWarning(rows.last());
+	}
+
+	return table;
+}
+
+void OdtStyleMappingDialog::matchIdenticalNames(bool ignoreCase)
+{
+	clearAmbiguities();
+
+	auto matchRows = [this, ignoreCase](QList<MappingRow>& rows)
+	{
+		for (MappingRow& row : rows)
+		{
+			if (row.origin == MappingOrigin::Manual && !row.wasMatchedByName)
+				continue;
+
+			auto findMatchingTarget = [ignoreCase, &row](const QString& sourceName)
+			{
+				if (!ignoreCase)
+					return row.targetCombo->findData(sourceName);
+
+				int matchIndex = -1;
+				for (int i = 0; i < row.targetCombo->count(); ++i)
+				{
+					const QString targetName = row.targetCombo->itemData(i).toString();
+					if (targetName.isEmpty() || QString::compare(targetName, sourceName, Qt::CaseInsensitive) != 0)
+						continue;
+					if (matchIndex >= 0)
+						return -2;
+					matchIndex = i;
+				}
+				return matchIndex;
+			};
+
+			int index = findMatchingTarget(row.displayName);
+			if (index == -1 && row.sourceName != row.displayName)
+				index = findMatchingTarget(row.sourceName);
+
+			if (index == -2)
+			{
+				row.ambiguousName = true;
+				row.origin = MappingOrigin::Untouched;
+				const int importIndex = row.targetCombo->findData(QString());
+				if (importIndex >= 0)
+					row.targetCombo->setCurrentIndex(importIndex);
+				updateRowWarning(row);
+				continue;
+			}
+
+			if (index >= 0)
+			{
+				row.targetCombo->setCurrentIndex(index);
+				row.origin = MappingOrigin::NameMatch;
+				row.wasMatchedByName = true;
+			}
+			updateRowWarning(row);
+		}
+	};
+
+	matchRows(m_paragraphRows);
+	matchRows(m_characterRows);
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::mapRemaining(QList<MappingRow>& rows, const QString& targetStyle)
+{
+	for (MappingRow& row : rows)
+	{
+		if (row.origin == MappingOrigin::Manual || row.origin == MappingOrigin::NameMatch)
+			continue;
+
+		const int index = row.targetCombo->findData(targetStyle);
+		if (index >= 0)
+		{
+			row.targetCombo->setCurrentIndex(index);
+			row.origin = MappingOrigin::Bulk;
+			row.ambiguousName = false;
+			updateRowWarning(row);
+		}
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::resetMappings(QList<MappingRow>& rows)
+{
+	for (MappingRow& row : rows)
+	{
+		const int importIndex = row.targetCombo->findData(QString());
+		if (importIndex >= 0)
+			row.targetCombo->setCurrentIndex(importIndex);
+		row.origin = MappingOrigin::Untouched;
+		row.ambiguousName = false;
+		row.wasMatchedByName = false;
+		updateRowWarning(row);
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::clearAmbiguities()
+{
+	for (MappingRow& row : m_paragraphRows)
+	{
+		row.ambiguousName = false;
+		updateRowWarning(row);
+	}
+	for (MappingRow& row : m_characterRows)
+	{
+		row.ambiguousName = false;
+		updateRowWarning(row);
+	}
+	updateAmbiguityWarning();
+}
+
+void OdtStyleMappingDialog::updateRowWarning(MappingRow& row)
+{
+	if (!row.sourceItem || !row.targetCombo)
+		return;
+
+	QStringList toolTips;
+	if (!row.idToolTip.isEmpty())
+		toolTips.append(row.idToolTip);
+
+	const bool collisionWarning = row.hasNameCollision && row.targetCombo->currentData().toString().isEmpty();
+	if (collisionWarning)
+		toolTips.append(tr("A Scribus style named \"%1\" already exists.\nThe imported ODT style will be renamed.").arg(row.importName));
+	if (row.ambiguousName)
+		toolTips.append(tr("Multiple Scribus styles match this name when case is ignored."));
+
+	if (collisionWarning || row.ambiguousName)
+		row.sourceItem->setIcon(QApplication::style()->standardIcon(QStyle::SP_MessageBoxWarning));
+	else
+		row.sourceItem->setIcon(QIcon());
+	row.sourceItem->setToolTip(toolTips.join('\n'));
+}
+
+void OdtStyleMappingDialog::updateAmbiguityWarning()
+{
+	if (!m_ambiguityWarningLabel)
+		return;
+
+	int ambiguousCount = 0;
+	for (const MappingRow& row : m_paragraphRows)
+		ambiguousCount += row.ambiguousName ? 1 : 0;
+	for (const MappingRow& row : m_characterRows)
+		ambiguousCount += row.ambiguousName ? 1 : 0;
+
+	m_ambiguityWarningLabel->setVisible(ambiguousCount > 0);
+	if (ambiguousCount == 1)
+		m_ambiguityWarningLabel->setToolTip(tr("1 style name has multiple matches when case is ignored and was left unmapped."));
+	else if (ambiguousCount > 1)
+		m_ambiguityWarningLabel->setToolTip(tr("%1 style names have multiple matches when case is ignored and were left unmapped.").arg(ambiguousCount));
+	else
+		m_ambiguityWarningLabel->setToolTip(QString());
+}
+
+QHash<QString, QString> OdtStyleMappingDialog::mappings(const QList<MappingRow>& rows) const
+{
+	QHash<QString, QString> result;
+	for (const MappingRow& row : rows)
+	{
+		const QString targetStyle = row.targetCombo->currentData().toString();
+		if (!targetStyle.isEmpty())
+			result.insert(row.sourceName, targetStyle);
+	}
+	return result;
+}
+
+QStringList OdtStyleMappingDialog::paragraphStyleNames() const
+{
+	QStringList result;
+	if (!m_doc)
+		return result;
+
+	for (int i = 0; i < m_doc->paragraphStyles().count(); ++i)
+	{
+		const ParagraphStyle& style = m_doc->paragraphStyles()[i];
+		if (!style.name().isEmpty() && !style.isDefaultStyle())
+			result.append(style.name());
+	}
+	result.sort(Qt::CaseInsensitive);
+	result.prepend(CommonStrings::DefaultParagraphStyle);
+	return result;
+}
+
+QStringList OdtStyleMappingDialog::characterStyleNames() const
+{
+	QStringList result;
+	if (!m_doc)
+		return result;
+
+	for (int i = 0; i < m_doc->charStyles().count(); ++i)
+	{
+		const CharStyle& style = m_doc->charStyles()[i];
+		if (!style.name().isEmpty() && !style.isDefaultStyle())
+			result.append(style.name());
+	}
+	result.sort(Qt::CaseInsensitive);
+	result.prepend(CommonStrings::DefaultCharacterStyle);
+	return result;
+}
+
+void OdtStyleMappingDialog::addTargetStyles(QComboBox* combo, const QStringList& targetStyles, const QString& family, bool includeImport) const
+{
+	if (includeImport)
+		combo->addItem(tr("Import as New Style"), QString());
+	for (const QString& targetStyle : targetStyles)
+	{
+		QString displayName = targetStyle;
+		if (family == "paragraph" && targetStyle == CommonStrings::DefaultParagraphStyle)
+			displayName = CommonStrings::trDefaultParagraphStyle;
+		else if (family == "text" && targetStyle == CommonStrings::DefaultCharacterStyle)
+			displayName = CommonStrings::trDefaultCharacterStyle;
+		combo->addItem(displayName, targetStyle);
+	}
+}
Index: resources/iconsets/1_7_0/1_7_0.xml
===================================================================
--- resources/iconsets/1_7_0/1_7_0.xml	(revision 27810)
+++ resources/iconsets/1_7_0/1_7_0.xml	(working copy)
@@ -41,6 +41,7 @@
     		
 		<!-- Action -->	
 		<icon id="add" file="16/action-add.svg" />
+		<icon id="action-filter" file="16/action-filter.svg" />
 		<icon id="chain-closed" file="16/action-link.svg" />
 		<icon id="chain-open" file="16/action-unlink.svg" />
 		<icon id="clear-right" file="16/action-backspace-reverse.svg" />
Index: resources/iconsets/1_7_0/16/action-filter.svg
===================================================================
--- resources/iconsets/1_7_0/16/action-filter.svg	(nonexistent)
+++ resources/iconsets/1_7_0/16/action-filter.svg	(working copy)
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><style>@import '../colors.css';</style>
+<path d="M1 2H15L9.5 8V13.5L6.5 15V8L1 2Z"/>
+</svg>

Issue History

Date Modified Username Field Change
2011-07-07 17:56 ale New Issue
2011-07-07 17:57 ale File Added: scribus_import_odt_styles.png
2011-07-07 17:57 ale Note Added: 0026551
2015-09-17 20:08 Kunda Category Story Editor / Text Frames => Story Ed/Txt Frames
2015-09-17 20:12 Kunda Category Story Ed/Txt Frames => Story Editor / Text Frames
2015-11-25 05:08 Kunda Relationship added related to 0011157
2016-05-09 15:17 Kunda Relationship added related to 0009464
2016-05-09 15:17 Kunda Tag Attached: styles
2016-05-09 15:18 Kunda Tag Attached: mockup
2016-05-09 15:33 Kunda Tag Attached: ODF
2025-04-27 19:16 cbradney Category Story Editor / Text Frames => Text Frames / Story Editor
2026-09-03 03:12 qirat Note Added: 0054402
2026-09-03 03:12 qirat File Added: osm1-gettext2-cancel-safety-v1.0.patch
2026-09-03 03:12 qirat File Added: osm2-style-mapping-engine-v1.1.patch
2026-09-03 03:12 qirat File Added: osm3-style-matching-dialog-v1.25.patch
2026-09-03 03:12 qirat File Added: osm-odt-style-matching-PROPOSED.png
2026-09-03 03:13 qirat Tag Attached: #please_test
2026-09-03 03:19 qirat Note Added: 0054403
2026-09-03 05:18 qirat Note Added: 0054404
2026-09-03 05:18 qirat File Added: osm3-style-matching-dialog-v1.26.patch
2026-09-03 05:18 qirat File Added: osm-odt-style-matching-PROPOSED-v1.26.png
2026-09-03 05:18 qirat Tag Attached: #patch_to_be_reviewed