View Issue Details

IDProjectCategoryView StatusLast Update
0017985ScribusStylespublic2026-09-25 12:56
Reporterbuttar Assigned To 
PrioritynormalSeverityfeatureReproducibilityN/A
Status newResolutionopen 
PlatformLinuxOSFedora WorkstationOS Version44
Product Version1.7.4.svn 
Summary0017985: [patch] Create char and para styles from text selection
DescriptionScribus lacks a direct way to turn manually formatted text into reusable Character or Paragraph Styles. This patch series introduces Create Style from Selection for both style types. (through right-click menu entry)
- Users can format text first, then create a style from that formatting with optional `Based on` inheritance.
- The new style is applied back to the text, preserves local exceptions where appropriate, and supports Undo/Redo.
- This makes the work-flow faster and more natural by reducing the need to recreate formatting manually in Style Manager.

Here is a 3-patch series to achieve the same. Please test/review. (sfs = style from selection)

## sfs1-selection-style-extractor-v1.0.patch

- Adds reusable selection-to-style extraction for character formatting.
- Detects uniform/common formatting across a text range.
- Tracks common parent styles and builds deltas relative to a chosen base style.

## sfs2-create-char-style-from-selection-v1.05.patch

- Adds Create Style from Selection → Character Style….
- Creates a Character Style from selected text with optional Based on inheritance.
- Rejects mixed formatting and invalid/duplicate names.
- Applies the style to the selection with complete Undo/Redo.
- Keeps Style Manager synchronised while preserving unapplied edits.

## sfs3-create-para-from-selection-v1.03.patch

- Adds Create Style from Selection → Paragraph Style….
- Analyses the whole containing paragraph and restricts creation to one paragraph.
- Promotes only formatting common to the whole paragraph while preserving local exceptions.
- Preserves parent-style inheritance and removes only redundant direct formatting.
- Supports complete Undo/Redo and Style Manager synchronisation.
Tags#please_test
Attached Files
sfs1-selection-style-extractor-v1.0.patch (9,221 bytes)   
Index: scribus/text/CMakeLists.txt
===================================================================
--- scribus/text/CMakeLists.txt	(revision 27861)
+++ scribus/text/CMakeLists.txt	(working copy)
@@ -21,6 +21,7 @@
 	text/textlayout.cpp
 	text/textlayoutpainter.cpp
 	text/textshaper.cpp
+	text/textstyleextractor.cpp
 	text/textcontext.cpp
 )
 
Index: scribus/text/textstyleextractor.h
===================================================================
--- scribus/text/textstyleextractor.h	(nonexistent)
+++ scribus/text/textstyleextractor.h	(working copy)
@@ -0,0 +1,57 @@
+/*
+ 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.
+ */
+/***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************/
+
+#ifndef TEXTSTYLEEXTRACTOR_H
+#define TEXTSTYLEEXTRACTOR_H
+
+#include "scribusapi.h"
+#include "styles/charstyle.h"
+
+class StoryText;
+
+namespace TextStyleExtractor
+{
+
+struct SCRIBUS_API CharStyleSelection
+{
+	bool valid { false };
+	bool uniform { false };
+	bool hasCommonParent { false };
+	int characterCount { 0 };
+	QString commonParent;
+	CharStyle effectiveStyle;
+	CharStyle commonStyle;
+};
+
+/**
+ * Analyse effective character formatting in a text range.
+ *
+ * Paragraph separators are ignored. effectiveStyle is the fully materialised
+ * style of the first analysed character. commonStyle contains only properties
+ * whose effective values are common to every analysed character. uniform is
+ * true when every effective character-style property is common to the range.
+ */
+SCRIBUS_API CharStyleSelection extractCharStyle(const StoryText& story, int pos, int len);
+
+/**
+ * Return the explicit properties required to reproduce effectiveStyle against
+ * baseStyle. If baseStyle is null, all effective properties are materialised.
+ * Name, parent and shortcut are intentionally left unset for the caller.
+ */
+SCRIBUS_API CharStyle charStyleDelta(const CharStyle& effectiveStyle, const CharStyle* baseStyle = nullptr);
+
+} // namespace TextStyleExtractor
+
+#endif
Index: scribus/text/textstyleextractor.cpp
===================================================================
--- scribus/text/textstyleextractor.cpp	(nonexistent)
+++ scribus/text/textstyleextractor.cpp	(working copy)
@@ -0,0 +1,113 @@
+/*
+ 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.
+ */
+/***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************/
+
+#include "textstyleextractor.h"
+
+#include "specialchars.h"
+#include "storytext.h"
+#include "util_math.h"
+
+namespace
+{
+
+CharStyle materializedCharStyle(const CharStyle& source)
+{
+	CharStyle result;
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	result.set##attr_NAME(source.attr_GETTER());
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+	result.updateFeatures();
+	return result;
+}
+
+} // namespace
+
+namespace TextStyleExtractor
+{
+
+CharStyleSelection extractCharStyle(const StoryText& story, int pos, int len)
+{
+	CharStyleSelection result;
+	if (pos < 0 || len <= 0 || pos > story.length() || len > story.length() - pos)
+		return result;
+
+	bool first = true;
+	bool commonParent = true;
+	QString parentName;
+
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	bool common##attr_NAME = true;
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+
+	for (int i = pos; i < pos + len; ++i)
+	{
+		if (story.text(i) == SpecialChars::PARSEP)
+			continue;
+
+		const CharStyle& current = story.charStyle(i);
+		if (first)
+		{
+			result.effectiveStyle = materializedCharStyle(current);
+			parentName = current.parent();
+			first = false;
+		}
+		else
+		{
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+			if (common##attr_NAME && !isequiv(result.effectiveStyle.attr_GETTER(), current.attr_GETTER())) \
+				common##attr_NAME = false;
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+			if (commonParent && current.parent() != parentName)
+				commonParent = false;
+		}
+		++result.characterCount;
+	}
+
+	if (first)
+		return result;
+
+	result.valid = true;
+	result.uniform = true;
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	if (common##attr_NAME) \
+		result.commonStyle.set##attr_NAME(result.effectiveStyle.attr_GETTER()); \
+	else \
+		result.uniform = false;
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+	result.commonStyle.updateFeatures();
+
+	result.hasCommonParent = commonParent;
+	if (commonParent)
+		result.commonParent = parentName;
+	return result;
+}
+
+CharStyle charStyleDelta(const CharStyle& effectiveStyle, const CharStyle* baseStyle)
+{
+	CharStyle result;
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	if (!baseStyle || !isequiv(effectiveStyle.attr_GETTER(), baseStyle->attr_GETTER())) \
+		result.set##attr_NAME(effectiveStyle.attr_GETTER());
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+	result.updateFeatures();
+	return result;
+}
+
+} // namespace TextStyleExtractor
Index: scribus/tests/testStoryText.h
===================================================================
--- scribus/tests/testStoryText.h	(revision 27861)
+++ scribus/tests/testStoryText.h	(working copy)
@@ -27,4 +27,7 @@
 	void removePars();
 	void applyCharStyle();
 	void removeCharStyle();
+	void extractUniformCharStyle();
+	void extractMixedCharStyle();
+	void extractCharStyleDelta();
 };
Index: scribus/tests/testStoryText.cpp
===================================================================
--- scribus/tests/testStoryText.cpp	(revision 27861)
+++ scribus/tests/testStoryText.cpp	(working copy)
@@ -9,6 +9,7 @@
 
 #include <QDebug>
 #include "testStoryText.h"
+#include "text/textstyleextractor.h"
 
 void TestStoryText::initST()
 {
@@ -127,3 +128,62 @@
 	QCOMPARE(story.startOfRun(2), 5  + 26 + 1);
 	QCOMPARE(story.endOfRun(2), 11 + 26);
 }
+
+void TestStoryText::extractUniformCharStyle()
+{
+	StoryText story;
+	story.insertChars(0, QString("Style me"));
+
+	CharStyle direct;
+	direct.setFontSize(135.0);
+	direct.setFillColor("Red");
+	story.applyCharStyle(0, story.length(), direct);
+
+	auto extracted = TextStyleExtractor::extractCharStyle(story, 0, story.length());
+	QVERIFY(extracted.valid);
+	QVERIFY(extracted.uniform);
+	QCOMPARE(extracted.characterCount, story.length());
+	QCOMPARE(extracted.effectiveStyle.fontSize(), 135.0);
+	QCOMPARE(extracted.commonStyle.fontSize(), 135.0);
+	QCOMPARE(extracted.commonStyle.fillColor(), QString("Red"));
+}
+
+void TestStoryText::extractMixedCharStyle()
+{
+	StoryText story;
+	story.insertChars(0, QString("mixed"));
+
+	CharStyle common;
+	common.setFontSize(120.0);
+	story.applyCharStyle(0, story.length(), common);
+
+	CharStyle local;
+	local.setFillColor("Blue");
+	story.applyCharStyle(2, 1, local);
+
+	auto extracted = TextStyleExtractor::extractCharStyle(story, 0, story.length());
+	QVERIFY(extracted.valid);
+	QVERIFY(!extracted.uniform);
+	QVERIFY(!extracted.commonStyle.isInhFontSize());
+	QCOMPARE(extracted.commonStyle.fontSize(), 120.0);
+	QVERIFY(extracted.commonStyle.isInhFillColor());
+}
+
+void TestStoryText::extractCharStyleDelta()
+{
+	CharStyle base;
+	base.setFontSize(100.0);
+	base.setFillColor("Black");
+	base.setTracking(0.0);
+
+	CharStyle effective;
+	effective.setFontSize(110.0);
+	effective.setFillColor("Black");
+	effective.setTracking(0.0);
+
+	CharStyle delta = TextStyleExtractor::charStyleDelta(effective, &base);
+	QVERIFY(!delta.isInhFontSize());
+	QCOMPARE(delta.fontSize(), 110.0);
+	QVERIFY(delta.isInhFillColor());
+	QVERIFY(delta.isInhTracking());
+}
sfs2-create-char-style-from-selection-v1.05.patch (28,009 bytes)   
Index: scribus/CMakeLists_Sources.txt
===================================================================
--- scribus/CMakeLists_Sources.txt	(revision 27861)
+++ scribus/CMakeLists_Sources.txt	(working copy)
@@ -335,6 +335,7 @@
 	ui/contentpalette_default.cpp
 	ui/contentpalette_page.cpp
 	ui/contextmenu.cpp
+	ui/createstylefromselectiondialog.cpp
 	ui/cupsoptions.cpp
 	ui/curvewidget.cpp
 	ui/customfdialog.cpp
Index: scribus/ui/createstylefromselectiondialog.h
===================================================================
--- scribus/ui/createstylefromselectiondialog.h	(nonexistent)
+++ scribus/ui/createstylefromselectiondialog.h	(working copy)
@@ -0,0 +1,53 @@
+/*
+ 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.
+ */
+/***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************/
+
+#ifndef CREATESTYLEFROMSELECTIONDIALOG_H
+#define CREATESTYLEFROMSELECTIONDIALOG_H
+
+#include <QDialog>
+#include <QList>
+#include <QPair>
+#include <QString>
+#include <QStringList>
+
+class QComboBox;
+class QDialogButtonBox;
+class QLabel;
+class QLineEdit;
+
+class CreateStyleFromSelectionDialog : public QDialog
+{
+public:
+	CreateStyleFromSelectionDialog(const QString& windowTitle,
+	                               const QString& suggestedName,
+	                               const QStringList& existingNames,
+	                               const QList<QPair<QString, QString>>& parentStyles,
+	                               const QString& suggestedParent,
+	                               QWidget* parent = nullptr);
+
+	QString styleName() const;
+	QString parentStyle() const;
+
+private:
+	void updateCreateButton();
+
+	QStringList m_existingNames;
+	QLineEdit* m_nameEdit { nullptr };
+	QComboBox* m_parentCombo { nullptr };
+	QLabel* m_errorLabel { nullptr };
+	QDialogButtonBox* m_buttonBox { nullptr };
+};
+
+#endif
Index: scribus/ui/createstylefromselectiondialog.cpp
===================================================================
--- scribus/ui/createstylefromselectiondialog.cpp	(nonexistent)
+++ scribus/ui/createstylefromselectiondialog.cpp	(working copy)
@@ -0,0 +1,95 @@
+/*
+ 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.
+ */
+/***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************/
+
+#include "createstylefromselectiondialog.h"
+
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QFormLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+CreateStyleFromSelectionDialog::CreateStyleFromSelectionDialog(const QString& windowTitle,
+                                                               const QString& suggestedName,
+                                                               const QStringList& existingNames,
+                                                               const QList<QPair<QString, QString>>& parentStyles,
+                                                               const QString& suggestedParent,
+                                                               QWidget* parent)
+	: QDialog(parent),
+	  m_existingNames(existingNames)
+{
+	setWindowTitle(windowTitle);
+	setModal(true);
+	setMinimumWidth(420);
+
+	auto* layout = new QVBoxLayout(this);
+	auto* formLayout = new QFormLayout();
+	formLayout->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
+
+	m_nameEdit = new QLineEdit(this);
+	m_nameEdit->setText(suggestedName);
+	m_nameEdit->selectAll();
+	formLayout->addRow(tr("Name:"), m_nameEdit);
+
+	m_parentCombo = new QComboBox(this);
+	m_parentCombo->addItem(tr("(No Parent)"), QString());
+	for (const auto& parentStyle : parentStyles)
+		m_parentCombo->addItem(parentStyle.first, parentStyle.second);
+	const int parentIndex = m_parentCombo->findData(suggestedParent);
+	m_parentCombo->setCurrentIndex(parentIndex >= 0 ? parentIndex : 0);
+	formLayout->addRow(tr("Based on:"), m_parentCombo);
+
+	layout->addLayout(formLayout);
+
+	m_errorLabel = new QLabel(this);
+	m_errorLabel->setWordWrap(true);
+	layout->addWidget(m_errorLabel);
+
+	m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
+	m_buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create"));
+	layout->addWidget(m_buttonBox);
+
+	connect(m_buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+	connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+	connect(m_nameEdit, &QLineEdit::textChanged, this, [this]() { updateCreateButton(); });
+
+	updateCreateButton();
+	m_nameEdit->setFocus();
+}
+
+QString CreateStyleFromSelectionDialog::styleName() const
+{
+	return m_nameEdit->text().trimmed();
+}
+
+QString CreateStyleFromSelectionDialog::parentStyle() const
+{
+	return m_parentCombo->currentData().toString();
+}
+
+void CreateStyleFromSelectionDialog::updateCreateButton()
+{
+	const QString name = styleName();
+	QString error;
+	if (name.isEmpty())
+		error = tr("Enter a style name.");
+	else if (m_existingNames.contains(name))
+		error = tr("A style named \"%1\" already exists.").arg(name);
+
+	m_errorLabel->setText(error);
+	m_buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
+}
Index: scribus/ui/contextmenu.cpp
===================================================================
--- scribus/ui/contextmenu.cpp	(revision 27861)
+++ scribus/ui/contextmenu.cpp	(working copy)
@@ -26,6 +26,8 @@
 #include "appmodes.h"
 #include "canvas.h"
 #include "canvasmode.h"
+#include "iconmanager.h"
+#include "pageitem_table.h"
 #include "pageitem_textframe.h"
 #include "scmimedata.h"
 #include "scraction.h"
@@ -341,6 +343,23 @@
 		if (m_actionList.contains("itemAdjustFrameHeightToText"))
 			addAction(m_ScMW->scrActions["itemAdjustFrameHeightToText"]);
 
+		if ((m_doc->appMode == modeEdit || m_doc->appMode == modeEditTable) && selectedItemCount == 1)
+		{
+			PageItem* textItem = currItem;
+			if (currItem->isTable() && m_doc->appMode == modeEditTable)
+				textItem = currItem->asTable()->activeCell().textFrame();
+			if (textItem && textItem->itemText.hasSelection())
+			{
+				IconManager& iconManager = IconManager::instance();
+				QMenu* styleFromSelectionMenu = new QMenu(this);
+				QAction* styleFromSelectionAction = addMenu(styleFromSelectionMenu);
+				styleFromSelectionAction->setText(tr("Create Style from Selection"));
+				QAction* charStyleAction = styleFromSelectionMenu->addAction(
+					iconManager.loadIcon("character-style"), tr("Character Style..."));
+				connect(charStyleAction, SIGNAL(triggered()), m_ScMW, SLOT(createCharStyleFromSelection()));
+			}
+		}
+
 		if (!menuEditContent->actions().isEmpty() && currItem->isTable())
 		{
 			addSeparator();
Index: scribus/scribus.h
===================================================================
--- scribus/scribus.h	(revision 27861)
+++ scribus/scribus.h	(working copy)
@@ -521,6 +521,7 @@
 	void setNewDirection(int i);
 	void setNewParStyle(const QString& name);
 	void setNewCharStyle(const QString& name);
+	void createCharStyleFromSelection();
 	void setAlignmentValue(int i);
 	void setDirectionValue(int i);
 	void editItemsFromOutlines(PageItem *ite);
Index: scribus/scribus.cpp
===================================================================
--- scribus/scribus.cpp	(revision 27861)
+++ scribus/scribus.cpp	(working copy)
@@ -150,6 +150,7 @@
 #include "serializer.h"
 #include "storyloader.h"
 #include "stylesearch.h"
+#include "text/textstyleextractor.h"
 #include "textframespellchecker.h"
 #include "textnote.h"
 #include "tocgenerator.h"
@@ -168,6 +169,7 @@
 #include "ui/colorpicker/colorpicker.h"
 #include "ui/contentpalette.h"
 #include "ui/contextmenu.h"
+#include "ui/createstylefromselectiondialog.h"
 #include "ui/copypagetomasterpagedialog.h"
 #include "ui/customfdialog.h"
 #include "ui/delpages.h"
@@ -6348,6 +6350,93 @@
 	setTBvals(currItem);
 }
 
+void ScribusMainWindow::createCharStyleFromSelection()
+{
+	if (!HaveDoc || !doc || doc->m_Selection->count() != 1)
+		return;
+
+	PageItem* currItem = doc->m_Selection->itemAt(0);
+	if (currItem->isTable() && doc->appMode == modeEditTable)
+		currItem = currItem->asTable()->activeCell().textFrame();
+	if (!currItem || !currItem->itemText.hasSelection())
+		return;
+
+	const int start = currItem->itemText.startOfSelection();
+	const int length = currItem->itemText.endOfSelection() - start;
+	const auto extracted = TextStyleExtractor::extractCharStyle(currItem->itemText, start, length);
+	if (!extracted.valid)
+		return;
+	if (!extracted.uniform)
+	{
+		ScMessageBox::information(this, tr("Create Character Style"),
+		                         tr("The selection contains mixed character formatting.\n"
+		                            "Select text with consistent formatting and try again."));
+		return;
+	}
+
+	QStringList existingNames;
+	QList<QPair<QString, QString>> parentStyles;
+	const QList<int> sortedStyles = doc->getSortedCharStyleList();
+	for (int index : sortedStyles)
+	{
+		const CharStyle& charStyle = doc->charStyles()[index];
+		if (!charStyle.hasName())
+			continue;
+		existingNames.append(charStyle.name());
+		parentStyles.append(qMakePair(charStyle.displayName(), charStyle.name()));
+	}
+
+	if (m_styleManager)
+	{
+		const QStringList cachedNames = m_styleManager->cachedCharacterStyleNames();
+		for (const QString& name : cachedNames)
+		{
+			if (!existingNames.contains(name))
+				existingNames.append(name);
+		}
+	}
+
+	QString suggestedParent;
+	if (extracted.hasCommonParent && doc->charStyles().contains(extracted.commonParent))
+		suggestedParent = extracted.commonParent;
+	const QString suggestedName = m_styleManager
+		? m_styleManager->uniqueCharacterStyleName(tr("New Character Style"))
+		: doc->charStyles().getUniqueCopyName(tr("New Character Style"));
+
+	CreateStyleFromSelectionDialog dialog(tr("Create Character Style"),
+	                                     suggestedName, existingNames, parentStyles,
+	                                     suggestedParent, this);
+	if (dialog.exec() != QDialog::Accepted)
+		return;
+
+	const QString parentName = dialog.parentStyle();
+	const CharStyle* baseStyle = nullptr;
+	if (!parentName.isEmpty())
+	{
+		const int parentIndex = doc->charStyles().find(parentName);
+		if (parentIndex >= 0)
+			baseStyle = &doc->charStyles()[parentIndex];
+	}
+
+	CharStyle newStyle = TextStyleExtractor::charStyleDelta(extracted.effectiveStyle, baseStyle);
+	newStyle.setName(dialog.styleName());
+	newStyle.setDefaultStyle(false);
+	newStyle.setParent(parentName);
+
+	if (!doc->itemSelection_CreateNamedCharStyle(newStyle))
+	{
+		ScMessageBox::warning(this, CommonStrings::trWarning,
+		                      tr("The character style could not be created."));
+		return;
+	}
+
+	if (m_styleManager)
+		m_styleManager->syncCharacterStyle(newStyle.name(), true);
+	requestUpdate(reqCharStylesUpdate | reqStyleComboDocUpdate);
+	setTBvals(currItem);
+	view->DrawNew();
+}
+
 void ScribusMainWindow::setAlignmentValue(int i)
 {
 	contentPalette->updateTextAlignment(i);
Index: scribus/scribusdoc.h
===================================================================
--- scribus/scribusdoc.h	(revision 27861)
+++ scribus/scribusdoc.h	(working copy)
@@ -714,6 +714,7 @@
 
 		const CharStyle& charStyle(const QString& name) const { return m_docCharStyles.get(name); }
 		const StyleSet<CharStyle>& charStyles() const { return m_docCharStyles; }
+		bool addCharStyle(const CharStyle& style);
 		void redefineCharStyles(const StyleSet<CharStyle>& newStyles, bool removeUnused = false);
 		/**
 		 * @brief Remove any reference to old styles and replace with new name. This needs to be
@@ -860,6 +861,7 @@
 		 */
 		void restoreMarks(UndoState* ss, bool isUndo);
 		void restoreNoteStyle(SimpleState* ss, bool isUndo);
+		void restoreCharStyleCreation(SimpleState* ss, bool isUndo);
 		void restoreDeleteNote(UndoState* ss, bool isUndo);
 
 		/**
@@ -1180,6 +1182,7 @@
 		void itemSelection_EraseCharStyle(Selection* customSelection = nullptr);
 		void itemSelection_SetNamedParagraphStyle(const QString & name, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedCharStyle(const QString & name, Selection* customSelection = nullptr);
+		bool itemSelection_CreateNamedCharStyle(const CharStyle& style, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedLineStyle(const QString & name, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedCellStyle(const QString & name, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedTableStyle(const QString & name, Selection* customSelection = nullptr);
Index: scribus/scribusdoc.cpp
===================================================================
--- scribus/scribusdoc.cpp	(revision 27861)
+++ scribus/scribusdoc.cpp	(working copy)
@@ -104,6 +104,7 @@
 #include "ui/outlinepalette.h"
 #include "ui/pagepalette.h"
 #include "ui/storyeditor.h"
+#include "ui/stylemanager.h"
 #include "ui/tablecolumnwidthsdialog.h"
 #include "ui/tablerowheightsdialog.h"
 #include "undomanager.h"
@@ -1576,6 +1577,28 @@
 	m_docCharStyles.invalidate();
 }
 
+bool ScribusDoc::addCharStyle(const CharStyle& style)
+{
+	if (!style.hasName() || m_docCharStyles.contains(style.name()))
+		return false;
+
+	CharStyle newStyle(style);
+	newStyle.setDefaultStyle(false);
+	m_docCharStyles.create(newStyle);
+	m_docCharStyles.invalidate();
+
+	if (UndoManager::undoEnabled())
+	{
+		auto* state = new ScOldNewState<CharStyle>(tr("Create Character Style"), style.name());
+		state->set("CREATE_CHARSTYLE");
+		state->setStates(CharStyle(), newStyle);
+		m_undoManager->action(this, state);
+	}
+
+	changed();
+	return true;
+}
+
 void ScribusDoc::redefineTableStyles(const StyleSet<TableStyle>& newStyles, bool removeUnused)
 {
 	m_docTableStyles.redefine(newStyles, false);
@@ -1983,6 +2006,8 @@
 	}
 	else if (ss->contains("NSTYLE"))
 		restoreNoteStyle(ss, isUndo);
+	else if (ss->contains("CREATE_CHARSTYLE"))
+		restoreCharStyleCreation(ss, isUndo);
 	else if (ss->contains("DELETE_NOTE"))
 		restoreDeleteNote(state, isUndo);
 	else if (ss->contains("MARK"))
@@ -2513,6 +2538,36 @@
 		scMW()->nsEditor->updateNSList();
 }
 
+void ScribusDoc::restoreCharStyleCreation(SimpleState* ss, bool isUndo)
+{
+	const auto* state = dynamic_cast<ScOldNewState<CharStyle>*>(ss);
+	if (!state)
+		return;
+
+	const CharStyle& style = state->getNewState();
+	if (isUndo)
+	{
+		const int index = m_docCharStyles.find(style.name());
+		if (index >= 0)
+		{
+			CharStyle* oldStyle = &m_docCharStyles[index];
+			m_docCharStyles.remove(index);
+			delete oldStyle;
+		}
+	}
+	else if (!m_docCharStyles.contains(style.name()))
+		m_docCharStyles.create(style);
+
+	m_docCharStyles.invalidate();
+	changed();
+	if (ScCore->usingGUI())
+	{
+		if (scMW()->styleMgr())
+			scMW()->styleMgr()->syncCharacterStyle(style.name(), !isUndo);
+		scMW()->requestUpdate(reqCharStylesUpdate | reqStyleComboDocUpdate);
+	}
+}
+
 void ScribusDoc::restoreDeleteNote(UndoState* state, bool isUndo)
 {
 	const auto *is = dynamic_cast<ScItemsState*>(state);
@@ -8196,6 +8251,32 @@
 	itemSelection_ApplyCharStyle(newStyle, customSelection, "NAMED_STYLE");
 }
 
+bool ScribusDoc::itemSelection_CreateNamedCharStyle(const CharStyle& style, Selection* customSelection)
+{
+	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
+	if (!itemSelection || itemSelection->count() == 0 || !style.hasName())
+		return false;
+
+	UndoTransaction transaction;
+	if (UndoManager::undoEnabled())
+	{
+		const PageItem* currItem = itemSelection->itemAt(0);
+		const QString targetName = (itemSelection->count() > 1) ? Um::SelectionGroup : currItem->getUName();
+		QPixmap* targetPixmap = (itemSelection->count() > 1) ? Um::IGroup : currItem->getUPixmap();
+		transaction = m_undoManager->beginTransaction(targetName, targetPixmap,
+		                                              tr("Create Character Style"), style.name(), Um::IFont);
+	}
+
+	if (!addCharStyle(style))
+		return false;
+
+	itemSelection_SetNamedCharStyle(style.name(), itemSelection);
+
+	if (transaction)
+		transaction.commit();
+	return true;
+}
+
 void ScribusDoc::itemSelection_SetNamedParagraphStyle(const QString& name, Selection* customSelection)
 {
 	ParagraphStyle newStyle;
Index: scribus/ui/smtextstyles.h
===================================================================
--- scribus/ui/smtextstyles.h	(revision 27861)
+++ scribus/ui/smtextstyles.h	(working copy)
@@ -48,6 +48,8 @@
 	void languageChange() override;
 	void unitChange() override;
 	StyleSet<ParagraphStyle>& tmpStyles(); // butt ugly
+	bool mergeExternalStyle(const ParagraphStyle& style);
+	bool removeExternalStyle(const QString& documentName);
 
 signals:
 	void selectionDirty();
@@ -162,6 +164,8 @@
 	void languageChange() override;
 	void unitChange() override;
 	StyleSet<CharStyle>& tmpStyles();
+	bool mergeExternalStyle(const CharStyle& style);
+	bool removeExternalStyle(const QString& documentName);
 
 signals:
 	void selectionDirty();
Index: scribus/ui/smtextstyles.cpp
===================================================================
--- scribus/ui/smtextstyles.cpp	(revision 27861)
+++ scribus/ui/smtextstyles.cpp	(working copy)
@@ -90,6 +90,60 @@
 	return m_tmpStyles;
 }
 
+bool SMParagraphStyle::mergeExternalStyle(const ParagraphStyle& style)
+{
+	if (!m_doc || !style.hasName() || m_tmpStyles.contains(style.name()))
+		return false;
+	for (const RemoveItem& item : m_deleted)
+	{
+		if (item.first == style.name())
+			return false;
+	}
+
+	ParagraphStyle cachedStyle(style);
+	QString parentName = cachedStyle.parent();
+	for (const RemoveItem& item : m_deleted)
+	{
+		if (item.first == parentName)
+		{
+			parentName = item.second;
+			break;
+		}
+	}
+	if (parentName == CommonStrings::trDefaultParagraphStyle)
+		parentName = CommonStrings::DefaultParagraphStyle;
+	if (!parentName.isEmpty() && !m_tmpStyles.contains(parentName))
+		parentName.clear();
+	cachedStyle.setParent(parentName);
+	m_tmpStyles.create(cachedStyle);
+	m_tmpStyles.invalidate();
+	return true;
+}
+
+bool SMParagraphStyle::removeExternalStyle(const QString& documentName)
+{
+	const int index = m_tmpStyles.find(documentName);
+	if (index < 0 || m_tmpStyles.isDefault(m_tmpStyles[index]))
+		return false;
+
+	for (int i = m_selection.count() - 1; i >= 0; --i)
+	{
+		if (m_selection[i]->name() == documentName)
+			m_selection.removeAt(i);
+	}
+	ParagraphStyle* oldStyle = &m_tmpStyles[index];
+	m_tmpStyles.remove(index);
+	delete oldStyle;
+
+	for (int i = m_tmpStyles.count() - 1; i >= 0; --i)
+	{
+		if (m_tmpStyles[i].parent() == documentName)
+			m_tmpStyles[i].setParent(QString());
+	}
+	m_tmpStyles.invalidate();
+	return true;
+}
+
 QList<StyleName> SMParagraphStyle::styles(bool reloadFromDoc)
 {
 	QList<StyleName> tmpList;
@@ -1900,6 +1954,64 @@
 	return m_tmpStyles;
 }
 
+bool SMCharacterStyle::mergeExternalStyle(const CharStyle& style)
+{
+	if (!m_doc || !style.hasName() || m_tmpStyles.contains(style.name()))
+		return false;
+	for (const RemoveItem& item : m_deleted)
+	{
+		if (item.first == style.name())
+			return false;
+	}
+
+	CharStyle cachedStyle(style);
+	QString parentName = cachedStyle.parent();
+	for (const RemoveItem& item : m_deleted)
+	{
+		if (item.first == parentName)
+		{
+			parentName = item.second;
+			break;
+		}
+	}
+	if (parentName == CommonStrings::trDefaultCharacterStyle)
+		parentName = CommonStrings::DefaultCharacterStyle;
+	if (!parentName.isEmpty() && !m_tmpStyles.contains(parentName))
+		parentName.clear();
+	cachedStyle.setParent(parentName);
+	m_tmpStyles.create(cachedStyle);
+	m_tmpStyles.invalidate();
+	return true;
+}
+
+bool SMCharacterStyle::removeExternalStyle(const QString& documentName)
+{
+	const int index = m_tmpStyles.find(documentName);
+	if (index < 0 || m_tmpStyles.isDefault(m_tmpStyles[index]))
+		return false;
+
+	for (int i = m_selection.count() - 1; i >= 0; --i)
+	{
+		if (m_selection[i]->name() == documentName)
+			m_selection.removeAt(i);
+	}
+	CharStyle* oldStyle = &m_tmpStyles[index];
+	m_tmpStyles.remove(index);
+	delete oldStyle;
+
+	for (int i = m_tmpStyles.count() - 1; i >= 0; --i)
+	{
+		if (m_tmpStyles[i].parent() == documentName)
+			m_tmpStyles[i].setParent(QString());
+	}
+	m_tmpStyles.invalidate();
+
+	QList<RemoveItem> removedStyles;
+	removedStyles.append(RemoveItem(documentName, QString()));
+	emit charStylesDeleted(removedStyles);
+	return true;
+}
+
 QList<StyleName> SMCharacterStyle::styles(bool reloadFromDoc)
 {
 	QList<StyleName> tmpList;
Index: scribus/ui/stylemanager.h
===================================================================
--- scribus/ui/stylemanager.h	(revision 27861)
+++ scribus/ui/stylemanager.h	(working copy)
@@ -33,6 +33,12 @@
 
 	void addStyle(StyleItem *item);
 	void refreshLists();
+	QStringList cachedCharacterStyleNames();
+	QStringList cachedParagraphStyleNames();
+	QString uniqueCharacterStyleName(const QString& baseName);
+	QString uniqueParagraphStyleName(const QString& baseName);
+	void syncCharacterStyle(const QString& name, bool present);
+	void syncParagraphStyle(const QString& name, bool present);
 
 	void showAsNewParagraphStyle();
 	void showAsNewCharacterStyle();
@@ -164,6 +170,7 @@
 	void addNewType(StyleItem *item, bool loadFromDoc = true);
 	void createNewStyle(const QString &typeName, const QString &fromParent = QString());
 	void reloadStyleView(bool loadFromDoc = true); // are the styles loaded from doc or from tmp cache
+	void reloadStyleViewPreservingState();
 	bool shortcutExists(const QString &keys);
 	void updateActionName(const QString &oldName, const QString &newName);
 	/* QPair.first = type name and QPair.second = style name */
Index: scribus/ui/stylemanager.cpp
===================================================================
--- scribus/ui/stylemanager.cpp	(revision 27861)
+++ scribus/ui/stylemanager.cpp	(working copy)
@@ -295,6 +295,154 @@
 	}
 }
 
+QStringList StyleManager::cachedCharacterStyleNames()
+{
+	QStringList names;
+	SMCharacterStyle* styleItem = item<SMCharacterStyle>();
+	if (!styleItem)
+		return names;
+
+	StyleSet<CharStyle>& styles = styleItem->tmpStyles();
+	for (int i = 0; i < styles.count(); ++i)
+	{
+		if (styles[i].hasName())
+			names.append(styles[i].name());
+	}
+	return names;
+}
+
+QStringList StyleManager::cachedParagraphStyleNames()
+{
+	QStringList names;
+	SMParagraphStyle* styleItem = item<SMParagraphStyle>();
+	if (!styleItem)
+		return names;
+
+	StyleSet<ParagraphStyle>& styles = styleItem->tmpStyles();
+	for (int i = 0; i < styles.count(); ++i)
+	{
+		if (styles[i].hasName())
+			names.append(styles[i].name());
+	}
+	return names;
+}
+
+QString StyleManager::uniqueCharacterStyleName(const QString& baseName)
+{
+	QStringList names = cachedCharacterStyleNames();
+	if (m_doc)
+	{
+		for (int i = 0; i < m_doc->charStyles().count(); ++i)
+		{
+			const QString name = m_doc->charStyles()[i].name();
+			if (!names.contains(name))
+				names.append(name);
+		}
+	}
+	if (!names.contains(baseName))
+		return baseName;
+	for (int suffix = 2; ; ++suffix)
+	{
+		const QString candidate = QStringLiteral("%1 (%2)").arg(baseName).arg(suffix);
+		if (!names.contains(candidate))
+			return candidate;
+	}
+}
+
+QString StyleManager::uniqueParagraphStyleName(const QString& baseName)
+{
+	QStringList names = cachedParagraphStyleNames();
+	if (m_doc)
+	{
+		for (int i = 0; i < m_doc->paragraphStyles().count(); ++i)
+		{
+			const QString name = m_doc->paragraphStyles()[i].name();
+			if (!names.contains(name))
+				names.append(name);
+		}
+	}
+	if (!names.contains(baseName))
+		return baseName;
+	for (int suffix = 2; ; ++suffix)
+	{
+		const QString candidate = QStringLiteral("%1 (%2)").arg(baseName).arg(suffix);
+		if (!names.contains(candidate))
+			return candidate;
+	}
+}
+
+void StyleManager::syncCharacterStyle(const QString& name, bool present)
+{
+	if (!m_doc)
+		return;
+
+	SMCharacterStyle* styleItem = item<SMCharacterStyle>();
+	if (!styleItem)
+		return;
+
+	bool changed = false;
+	if (present)
+	{
+		const int index = m_doc->charStyles().find(name);
+		if (index >= 0)
+			changed = styleItem->mergeExternalStyle(m_doc->charStyles()[index]);
+	}
+	else
+	{
+		changed = styleItem->removeExternalStyle(name);
+		if (changed)
+		{
+			const QString key = styleItem->typeName() + SEPARATOR + name;
+			ScrAction* action = m_styleActions.take(key);
+			if (action)
+			{
+				if (m_selectedStyleAction == action)
+					m_selectedStyleAction = nullptr;
+				delete action;
+			}
+		}
+	}
+
+	if (changed)
+		reloadStyleViewPreservingState();
+}
+
+void StyleManager::syncParagraphStyle(const QString& name, bool present)
+{
+	if (!m_doc)
+		return;
+
+	SMParagraphStyle* styleItem = item<SMParagraphStyle>();
+	if (!styleItem)
+		return;
+
+	bool changed = false;
+	if (present)
+	{
+		const int index = m_doc->paragraphStyles().find(name);
+		if (index >= 0)
+			changed = styleItem->mergeExternalStyle(m_doc->paragraphStyles()[index]);
+	}
+	else
+	{
+		changed = styleItem->removeExternalStyle(name);
+		if (changed)
+		{
+			const QString key = styleItem->typeName() + SEPARATOR + name;
+			ScrAction* action = m_styleActions.take(key);
+			if (action)
+			{
+				if (m_selectedStyleAction == action)
+					m_selectedStyleAction = nullptr;
+				delete action;
+			}
+		}
+	}
+
+	if (changed)
+		reloadStyleViewPreservingState();
+}
+
 void StyleManager::showAsNewParagraphStyle()
 {
 	setPaletteShown(true);
@@ -1466,6 +1614,39 @@
 	uniqueLabel->hide();
 }
 
+void StyleManager::reloadStyleViewPreservingState()
+{
+	StyleItem* currentItem = m_item;
+	QSet<QString> dirty;
+	QTreeWidgetItemIterator it(styleView);
+
+	while (*it)
+	{
+		StyleViewItem* item = dynamic_cast<StyleViewItem*>(*it);
+		if (item && !item->isRoot() && item->isDirty())
+		{
+			const QString key = item->rootName() + SEPARATOR + item->text(NAME_COL);
+			dirty.insert(key);
+		}
+		++it;
+	}
+
+	reloadStyleView(false);
+	m_item = currentItem;
+
+	QTreeWidgetItemIterator restoredIt(styleView, QTreeWidgetItemIterator::Selectable);
+	while (*restoredIt)
+	{
+		StyleViewItem* item = dynamic_cast<StyleViewItem*>(*restoredIt);
+		if (item)
+		{
+			const QString key = item->rootName() + SEPARATOR + item->text(NAME_COL);
+			item->setDirty(dirty.contains(key));
+		}
+		++restoredIt;
+	}
+}
+
 void StyleManager::reloadStyleView(bool loadFromDoc)
 {
 	QTreeWidgetItemIterator it(styleView, QTreeWidgetItemIterator::Selected);
sfs3-create-para-from-selection-v1.03.patch (24,692 bytes)   
Index: scribus/scribus.cpp
===================================================================
--- scribus/scribus.cpp	(after sfs2)
+++ scribus/scribus.cpp	(working copy)
@@ -6437,6 +6437,92 @@
 	view->DrawNew();
 }
 
+void ScribusMainWindow::createParagraphStyleFromSelection()
+{
+	if (!HaveDoc || !doc || doc->m_Selection->count() != 1)
+		return;
+
+	PageItem* currItem = doc->m_Selection->itemAt(0);
+	if (currItem->isTable() && doc->appMode == modeEditTable)
+		currItem = currItem->asTable()->activeCell().textFrame();
+	if (!currItem || !currItem->itemText.hasSelection())
+		return;
+
+	const int start = currItem->itemText.startOfSelection();
+	const int length = currItem->itemText.endOfSelection() - start;
+	const auto extracted = TextStyleExtractor::extractParagraphStyle(currItem->itemText, start, length);
+	if (!extracted.valid)
+	{
+		ScMessageBox::information(this, tr("Create Paragraph Style"),
+		                         tr("Select text from a single paragraph and try again."));
+		return;
+	}
+
+	QStringList existingNames;
+	QList<QPair<QString, QString>> parentStyles;
+	const QList<int> sortedStyles = doc->getSortedStyleList();
+	for (int index : sortedStyles)
+	{
+		const ParagraphStyle& paragraphStyle = doc->paragraphStyles()[index];
+		if (!paragraphStyle.hasName())
+			continue;
+		existingNames.append(paragraphStyle.name());
+		parentStyles.append(qMakePair(paragraphStyle.displayName(), paragraphStyle.name()));
+	}
+
+	if (m_styleManager)
+	{
+		const QStringList cachedNames = m_styleManager->cachedParagraphStyleNames();
+		for (const QString& name : cachedNames)
+		{
+			if (!existingNames.contains(name))
+				existingNames.append(name);
+		}
+	}
+
+	QString suggestedParent;
+	if (!extracted.parent.isEmpty() && doc->paragraphStyles().contains(extracted.parent))
+		suggestedParent = extracted.parent;
+	const QString suggestedName = m_styleManager
+		? m_styleManager->uniqueParagraphStyleName(tr("New Paragraph Style"))
+		: doc->paragraphStyles().getUniqueCopyName(tr("New Paragraph Style"));
+
+	CreateStyleFromSelectionDialog dialog(tr("Create Paragraph Style"),
+	                                     suggestedName, existingNames, parentStyles,
+	                                     suggestedParent, this);
+	if (dialog.exec() != QDialog::Accepted)
+		return;
+
+	const QString parentName = dialog.parentStyle();
+	const ParagraphStyle* baseStyle = nullptr;
+	if (!parentName.isEmpty())
+	{
+		const int parentIndex = doc->paragraphStyles().find(parentName);
+		if (parentIndex >= 0)
+			baseStyle = &doc->paragraphStyles()[parentIndex];
+	}
+
+	ParagraphStyle newStyle = TextStyleExtractor::paragraphStyleDelta(extracted.effectiveStyle, baseStyle);
+	newStyle.setName(dialog.styleName());
+	newStyle.setDefaultStyle(false);
+	newStyle.setParent(parentName);
+
+	if (!doc->itemSelection_CreateNamedParagraphStyle(newStyle, extracted.commonCharStyle,
+	                                                  extracted.paragraphStart,
+	                                                  extracted.paragraphEnd - extracted.paragraphStart))
+	{
+		ScMessageBox::warning(this, CommonStrings::trWarning,
+		                      tr("The paragraph style could not be created."));
+		return;
+	}
+
+	if (m_styleManager)
+		m_styleManager->syncParagraphStyle(newStyle.name(), true);
+	requestUpdate(reqParaStylesUpdate | reqStyleComboDocUpdate);
+	setTBvals(currItem);
+	view->DrawNew();
+}
+
 void ScribusMainWindow::setAlignmentValue(int i)
 {
 	contentPalette->updateTextAlignment(i);
Index: scribus/scribus.h
===================================================================
--- scribus/scribus.h	(after sfs2)
+++ scribus/scribus.h	(working copy)
@@ -522,6 +522,7 @@
 	void setNewParStyle(const QString& name);
 	void setNewCharStyle(const QString& name);
 	void createCharStyleFromSelection();
+	void createParagraphStyleFromSelection();
 	void setAlignmentValue(int i);
 	void setDirectionValue(int i);
 	void editItemsFromOutlines(PageItem *ite);
Index: scribus/scribusdoc.cpp
===================================================================
--- scribus/scribusdoc.cpp	(after sfs2)
+++ scribus/scribusdoc.cpp	(working copy)
@@ -1519,6 +1519,30 @@
 	replaceNamedResources(newNames);
 }
 
+bool ScribusDoc::addParagraphStyle(const ParagraphStyle& style)
+{
+	if (!style.hasName() || m_docParagraphStyles.contains(style.name()))
+		return false;
+
+	ParagraphStyle newStyle(style);
+	newStyle.setDefaultStyle(false);
+	m_docParagraphStyles.create(newStyle);
+	m_docParagraphStyles.invalidate();
+	flag_Renumber = true;
+	flag_NumUpdateRequest = true;
+
+	if (UndoManager::undoEnabled())
+	{
+		auto* state = new ScOldNewState<ParagraphStyle>(tr("Create Paragraph Style"), style.name());
+		state->set("CREATE_PARASTYLE");
+		state->setStates(ParagraphStyle(), newStyle);
+		m_undoManager->action(this, state);
+	}
+
+	changed();
+	return true;
+}
+
 void ScribusDoc::redefineStyles(const StyleSet<ParagraphStyle>& newStyles, bool removeUnused)
 {
 	m_docParagraphStyles.redefine(newStyles, false);
@@ -2006,6 +2030,8 @@
 	}
 	else if (ss->contains("NSTYLE"))
 		restoreNoteStyle(ss, isUndo);
+	else if (ss->contains("CREATE_PARASTYLE"))
+		restoreParagraphStyleCreation(ss, isUndo);
 	else if (ss->contains("CREATE_CHARSTYLE"))
 		restoreCharStyleCreation(ss, isUndo);
 	else if (ss->contains("DELETE_NOTE"))
@@ -2538,6 +2564,38 @@
 		scMW()->nsEditor->updateNSList();
 }
 
+void ScribusDoc::restoreParagraphStyleCreation(SimpleState* ss, bool isUndo)
+{
+	const auto* state = dynamic_cast<ScOldNewState<ParagraphStyle>*>(ss);
+	if (!state)
+		return;
+
+	const ParagraphStyle& style = state->getNewState();
+	if (isUndo)
+	{
+		const int index = m_docParagraphStyles.find(style.name());
+		if (index >= 0 && !m_docParagraphStyles.isDefault(m_docParagraphStyles[index]))
+		{
+			ParagraphStyle* oldStyle = &m_docParagraphStyles[index];
+			m_docParagraphStyles.remove(index);
+			delete oldStyle;
+		}
+	}
+	else if (!m_docParagraphStyles.contains(style.name()))
+		m_docParagraphStyles.create(style);
+
+	m_docParagraphStyles.invalidate();
+	flag_Renumber = true;
+	flag_NumUpdateRequest = true;
+	changed();
+	if (ScCore->usingGUI())
+	{
+		if (scMW()->styleMgr())
+			scMW()->styleMgr()->syncParagraphStyle(style.name(), !isUndo);
+		scMW()->requestUpdate(reqParaStylesUpdate | reqStyleComboDocUpdate);
+	}
+}
+
 void ScribusDoc::restoreCharStyleCreation(SimpleState* ss, bool isUndo)
 {
 	const auto* state = dynamic_cast<ScOldNewState<CharStyle>*>(ss);
@@ -8251,6 +8309,122 @@
 	itemSelection_ApplyCharStyle(newStyle, customSelection, "NAMED_STYLE");
 }
 
+bool ScribusDoc::itemSelection_CreateNamedParagraphStyle(const ParagraphStyle& style, const CharStyle& cleanupStyle,
+                                                         int paragraphStart, int paragraphLength,
+                                                         Selection* customSelection)
+{
+	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
+	if (!itemSelection || itemSelection->count() != 1 || !style.hasName())
+		return false;
+
+	PageItem* currItem = itemSelection->itemAt(0);
+	if (currItem->isTable() && appMode == modeEditTable)
+		currItem = currItem->asTable()->activeCell().textFrame();
+	if (!currItem || paragraphStart < 0 || paragraphLength < 0
+	    || paragraphStart > currItem->itemText.length()
+	    || paragraphLength > currItem->itemText.length() - paragraphStart)
+		return false;
+
+	UndoTransaction transaction;
+	if (UndoManager::undoEnabled())
+	{
+		transaction = m_undoManager->beginTransaction(currItem->getUName(), currItem->getUPixmap(),
+		                                              tr("Create Paragraph Style"), style.name(), Um::IFont);
+	}
+
+	if (!addParagraphStyle(style))
+		return false;
+
+	ParagraphStyle namedStyle;
+	namedStyle.setParent(style.name());
+	if (UndoManager::undoEnabled())
+	{
+		auto* state = new ScOldNewState<ParagraphStyle>(Um::SetStyle);
+		state->set("APPLY_PARASTYLE");
+		state->set("POS", paragraphStart);
+		state->setStates(currItem->itemText.paragraphStyle(paragraphStart), namedStyle);
+		m_undoManager->action(currItem, state);
+	}
+	currItem->itemText.applyStyle(paragraphStart, namedStyle, false);
+
+	const int paragraphEnd = paragraphStart + paragraphLength;
+	int runStart = -1;
+	CharStyle runBefore;
+	CharStyle runAfter;
+	int runEnd = -1;
+	auto commitRun = [&]()
+	{
+		if (runStart < 0 || runEnd <= runStart)
+			return;
+		if (UndoManager::undoEnabled())
+		{
+			auto* state = new ScOldNewState<CharStyle>(Um::ApplyTextStyle);
+			state->set("SET_CHARSTYLE");
+			state->set("START", runStart);
+			state->set("LENGTH", runEnd - runStart);
+			state->setStates(runBefore, runAfter);
+			m_undoManager->action(currItem, state);
+		}
+		currItem->itemText.setCharStyle(runStart, runEnd - runStart, runAfter);
+		runStart = -1;
+		runEnd = -1;
+	};
+
+	for (int pos = paragraphStart; pos < paragraphEnd; ++pos)
+	{
+		CharStyle before = currItem->itemText.charStyle(pos);
+		CharStyle after(before);
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+		if (!cleanupStyle.isInh##attr_NAME() && !before.isInh##attr_NAME() \
+		    && isequiv(before.attr_GETTER(), cleanupStyle.attr_GETTER())) \
+		{ \
+			const attr_TYPE oldValue = before.attr_GETTER(); \
+			after.reset##attr_NAME(); \
+			if (!isequiv(after.attr_GETTER(), oldValue)) \
+				after.set##attr_NAME(oldValue); \
+		}
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+		after.updateFeatures();
+		if (before.equiv(after))
+		{
+			commitRun();
+			continue;
+		}
+
+		if (runStart >= 0 && before.equiv(runBefore) && after.equiv(runAfter))
+		{
+			runEnd = pos + 1;
+			continue;
+		}
+
+		commitRun();
+		runStart = pos;
+		runEnd = pos + 1;
+		runBefore = before;
+		runAfter = after;
+	}
+	commitRun();
+
+	currItem->invalid = true;
+	if (currItem->isPathText())
+		currItem->updatePolyClip();
+	if (currItem->isNoteFrame())
+		currItem->asNoteFrame()->updateNotesText();
+	else if (currItem->isTextFrame() && currItem->asTextFrame()->hasNoteFrame(nullptr, true))
+		setNotesChanged(true);
+	currItem->invalidateLayout();
+	flag_Renumber = true;
+	flag_NumUpdateRequest = true;
+
+	if (transaction)
+		transaction.commit();
+	changed();
+	regionsChanged()->update(QRectF());
+	changedPagePreview();
+	return true;
+}
+
 bool ScribusDoc::itemSelection_CreateNamedCharStyle(const CharStyle& style, Selection* customSelection)
 {
 	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
Index: scribus/scribusdoc.h
===================================================================
--- scribus/scribusdoc.h	(after sfs2)
+++ scribus/scribusdoc.h	(working copy)
@@ -683,6 +683,7 @@
 		QList<int> getSortedTableStyleList() const;
 		QList<int> getSortedCellStyleList() const;
 
+		bool addParagraphStyle(const ParagraphStyle& style);
 		void redefineStyles(const StyleSet<ParagraphStyle>& newStyles, bool removeUnused = false);
 		/**
 		 * @brief Remove any reference to old styles and replace with new name. This needs to be
@@ -861,6 +862,7 @@
 		 */
 		void restoreMarks(UndoState* ss, bool isUndo);
 		void restoreNoteStyle(SimpleState* ss, bool isUndo);
+		void restoreParagraphStyleCreation(SimpleState* ss, bool isUndo);
 		void restoreCharStyleCreation(SimpleState* ss, bool isUndo);
 		void restoreDeleteNote(UndoState* ss, bool isUndo);
 
@@ -1182,6 +1184,9 @@
 		void itemSelection_EraseCharStyle(Selection* customSelection = nullptr);
 		void itemSelection_SetNamedParagraphStyle(const QString & name, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedCharStyle(const QString & name, Selection* customSelection = nullptr);
+		bool itemSelection_CreateNamedParagraphStyle(const ParagraphStyle& style, const CharStyle& cleanupStyle,
+		                                             int paragraphStart, int paragraphLength,
+		                                             Selection* customSelection = nullptr);
 		bool itemSelection_CreateNamedCharStyle(const CharStyle& style, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedLineStyle(const QString & name, Selection* customSelection = nullptr);
 		void itemSelection_SetNamedCellStyle(const QString & name, Selection* customSelection = nullptr);
Index: scribus/tests/testStoryText.cpp
===================================================================
--- scribus/tests/testStoryText.cpp	(after sfs2)
+++ scribus/tests/testStoryText.cpp	(working copy)
@@ -167,6 +167,26 @@
 	QVERIFY(!extracted.commonStyle.isInhFontSize());
 	QCOMPARE(extracted.commonStyle.fontSize(), 120.0);
 	QVERIFY(extracted.commonStyle.isInhFillColor());
+	QVERIFY(!extracted.commonDirectStyle.isInhFontSize());
+	QCOMPARE(extracted.commonDirectStyle.fontSize(), 120.0);
+	QVERIFY(extracted.commonDirectStyle.isInhFillColor());
+}
+
+void TestStoryText::extractInheritedCharStyleNotDirect()
+{
+	StoryText story;
+	story.insertChars(0, QString("Inherited"));
+
+	ParagraphStyle paragraph;
+	paragraph.charStyle().setFontSize(105.0);
+	story.applyStyle(0, paragraph);
+
+	auto extracted = TextStyleExtractor::extractCharStyle(story, 0, story.length());
+	QVERIFY(extracted.valid);
+	QVERIFY(extracted.uniform);
+	QVERIFY(!extracted.commonStyle.isInhFontSize());
+	QCOMPARE(extracted.commonStyle.fontSize(), 105.0);
+	QVERIFY(extracted.commonDirectStyle.isInhFontSize());
 }
 
 void TestStoryText::extractCharStyleDelta()
@@ -187,3 +207,72 @@
 	QVERIFY(delta.isInhFillColor());
 	QVERIFY(delta.isInhTracking());
 }
+
+void TestStoryText::extractParagraphStyle()
+{
+	StoryText story;
+	story.insertChars(0, QString("Paragraph"));
+
+	ParagraphStyle paragraph;
+	paragraph.setAlignment(ParagraphStyle::Centered);
+	paragraph.charStyle().setFontSize(100.0);
+	paragraph.charStyle().setFillColor("Black");
+	story.applyStyle(0, paragraph);
+
+	CharStyle wholeParagraph;
+	wholeParagraph.setFontSize(110.0);
+	wholeParagraph.setFillColor("Blue");
+	story.applyCharStyle(0, story.length(), wholeParagraph);
+
+	CharStyle localException;
+	localException.setTracking(50.0);
+	story.applyCharStyle(3, 1, localException);
+
+	auto extracted = TextStyleExtractor::extractParagraphStyle(story, 2, 3);
+	QVERIFY(extracted.valid);
+	QCOMPARE(extracted.paragraphStart, 0);
+	QCOMPARE(extracted.paragraphEnd, story.length());
+	QCOMPARE(extracted.effectiveStyle.alignment(), ParagraphStyle::Centered);
+	QCOMPARE(extracted.effectiveStyle.charStyle().fontSize(), 110.0);
+	QCOMPARE(extracted.effectiveStyle.charStyle().fillColor(), QString("Blue"));
+	QVERIFY(!extracted.commonCharStyle.isInhFontSize());
+	QVERIFY(!extracted.commonCharStyle.isInhFillColor());
+	QVERIFY(extracted.commonCharStyle.isInhTracking());
+}
+
+void TestStoryText::extractParagraphStyleDelta()
+{
+	ParagraphStyle base;
+	base.setAlignment(ParagraphStyle::LeftAligned);
+	base.setGapAfter(0.0);
+	base.charStyle().setFontSize(100.0);
+	base.charStyle().setFillColor("Black");
+
+	ParagraphStyle effective;
+	effective.setAlignment(ParagraphStyle::Centered);
+	effective.setGapAfter(0.0);
+	effective.charStyle().setFontSize(110.0);
+	effective.charStyle().setFillColor("Black");
+
+	ParagraphStyle delta = TextStyleExtractor::paragraphStyleDelta(effective, &base);
+	QVERIFY(!delta.isInhAlignment());
+	QCOMPARE(delta.alignment(), ParagraphStyle::Centered);
+	QVERIFY(delta.isInhGapAfter());
+	QVERIFY(!delta.charStyle().isInhFontSize());
+	QCOMPARE(delta.charStyle().fontSize(), 110.0);
+	QVERIFY(delta.charStyle().isInhFillColor());
+}
+
+void TestStoryText::rejectMultiParagraphStyleExtraction()
+{
+	StoryText story;
+	story.insertChars(0, QString("One") + SpecialChars::PARSEP + QString("Two"));
+
+	auto firstParagraph = TextStyleExtractor::extractParagraphStyle(story, 0, 4);
+	QVERIFY(firstParagraph.valid);
+	QCOMPARE(firstParagraph.paragraphStart, 0);
+	QCOMPARE(firstParagraph.paragraphEnd, 3);
+
+	auto extracted = TextStyleExtractor::extractParagraphStyle(story, 0, story.length());
+	QVERIFY(!extracted.valid);
+}
Index: scribus/tests/testStoryText.h
===================================================================
--- scribus/tests/testStoryText.h	(after sfs2)
+++ scribus/tests/testStoryText.h	(working copy)
@@ -29,5 +29,9 @@
 	void removeCharStyle();
 	void extractUniformCharStyle();
 	void extractMixedCharStyle();
+	void extractInheritedCharStyleNotDirect();
 	void extractCharStyleDelta();
+	void extractParagraphStyle();
+	void extractParagraphStyleDelta();
+	void rejectMultiParagraphStyleExtraction();
 };
Index: scribus/text/textstyleextractor.cpp
===================================================================
--- scribus/text/textstyleextractor.cpp	(after sfs2)
+++ scribus/text/textstyleextractor.cpp	(working copy)
@@ -33,6 +33,27 @@
 	return result;
 }
 
+ParagraphStyle materializedParagraphStyle(const ParagraphStyle& source)
+{
+	ParagraphStyle result;
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT) \
+	result.set##attr_NAME(source.attr_GETTER());
+#include "styles/paragraphstyle.attrdefs.cxx"
+#undef ATTRDEF
+	result.charStyle() = materializedCharStyle(source.charStyle());
+	return result;
+}
+
+void applyCommonCharStyle(CharStyle& target, const CharStyle& commonStyle)
+{
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	if (!commonStyle.isInh##attr_NAME()) \
+		target.set##attr_NAME(commonStyle.attr_GETTER());
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+	target.updateFeatures();
+}
+
 } // namespace
 
 namespace TextStyleExtractor
@@ -49,7 +70,8 @@
 	QString parentName;
 
 #define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
-	bool common##attr_NAME = true;
+	bool common##attr_NAME = true; \
+	bool commonDirect##attr_NAME = true;
 #include "styles/charstyle.attrdefs.cxx"
 #undef ATTRDEF
 
@@ -62,6 +84,12 @@
 		if (first)
 		{
 			result.effectiveStyle = materializedCharStyle(current);
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+			commonDirect##attr_NAME = !current.isInh##attr_NAME(); \
+			if (commonDirect##attr_NAME) \
+				result.commonDirectStyle.set##attr_NAME(current.attr_GETTER());
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
 			parentName = current.parent();
 			first = false;
 		}
@@ -69,7 +97,10 @@
 		{
 #define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
 			if (common##attr_NAME && !isequiv(result.effectiveStyle.attr_GETTER(), current.attr_GETTER())) \
-				common##attr_NAME = false;
+				common##attr_NAME = false; \
+			if (commonDirect##attr_NAME \
+			    && (current.isInh##attr_NAME() || !isequiv(result.commonDirectStyle.attr_GETTER(), current.attr_GETTER()))) \
+				commonDirect##attr_NAME = false;
 #include "styles/charstyle.attrdefs.cxx"
 #undef ATTRDEF
 			if (commonParent && current.parent() != parentName)
@@ -91,6 +122,12 @@
 #include "styles/charstyle.attrdefs.cxx"
 #undef ATTRDEF
 	result.commonStyle.updateFeatures();
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT, attr_BREAKSHAPING) \
+	if (!commonDirect##attr_NAME) \
+		result.commonDirectStyle.reset##attr_NAME();
+#include "styles/charstyle.attrdefs.cxx"
+#undef ATTRDEF
+	result.commonDirectStyle.updateFeatures();
 
 	result.hasCommonParent = commonParent;
 	if (commonParent)
@@ -110,4 +147,53 @@
 	return result;
 }
 
+ParagraphStyleSelection extractParagraphStyle(const StoryText& story, int pos, int len)
+{
+	ParagraphStyleSelection result;
+	if (pos < 0 || len <= 0 || pos > story.length() || len > story.length() - pos)
+		return result;
+
+	const int lastPos = pos + len - 1;
+	const uint paragraphIndex = story.nrOfParagraph(pos);
+	if (story.nrOfParagraph(lastPos) != paragraphIndex)
+		return result;
+
+	result.paragraphStart = story.startOfParagraph(paragraphIndex);
+	result.paragraphEnd = story.endOfParagraph(paragraphIndex);
+	const ParagraphStyle& paragraphStyle = story.paragraphStyle(result.paragraphStart);
+	result.parent = paragraphStyle.parent();
+	result.effectiveStyle = materializedParagraphStyle(paragraphStyle);
+
+	if (result.paragraphEnd > result.paragraphStart)
+	{
+		const auto chars = extractCharStyle(story, result.paragraphStart, result.paragraphEnd - result.paragraphStart);
+		if (!chars.valid)
+			return result;
+		result.characterCount = chars.characterCount;
+		result.commonCharStyle = chars.commonDirectStyle;
+		applyCommonCharStyle(result.effectiveStyle.charStyle(), result.commonCharStyle);
+	}
+	else
+	{
+		result.commonCharStyle = materializedCharStyle(paragraphStyle.charStyle());
+	}
+
+	result.valid = true;
+	return result;
+}
+
+ParagraphStyle paragraphStyleDelta(const ParagraphStyle& effectiveStyle, const ParagraphStyle* baseStyle)
+{
+	ParagraphStyle result;
+#define ATTRDEF(attr_TYPE, attr_GETTER, attr_NAME, attr_DEFAULT) \
+	if (!baseStyle || !isequiv(effectiveStyle.attr_GETTER(), baseStyle->attr_GETTER())) \
+		result.set##attr_NAME(effectiveStyle.attr_GETTER());
+#include "styles/paragraphstyle.attrdefs.cxx"
+#undef ATTRDEF
+
+	const CharStyle* baseCharStyle = baseStyle ? &baseStyle->charStyle() : nullptr;
+	result.charStyle() = charStyleDelta(effectiveStyle.charStyle(), baseCharStyle);
+	return result;
+}
+
 } // namespace TextStyleExtractor
Index: scribus/text/textstyleextractor.h
===================================================================
--- scribus/text/textstyleextractor.h	(after sfs2)
+++ scribus/text/textstyleextractor.h	(working copy)
@@ -18,6 +18,7 @@
 
 #include "scribusapi.h"
 #include "styles/charstyle.h"
+#include "styles/paragraphstyle.h"
 
 class StoryText;
 
@@ -33,6 +34,18 @@
 	QString commonParent;
 	CharStyle effectiveStyle;
 	CharStyle commonStyle;
+	CharStyle commonDirectStyle;
+};
+
+struct SCRIBUS_API ParagraphStyleSelection
+{
+	bool valid { false };
+	int paragraphStart { 0 };
+	int paragraphEnd { 0 };
+	int characterCount { 0 };
+	QString parent;
+	ParagraphStyle effectiveStyle;
+	CharStyle commonCharStyle;
 };
 
 /**
@@ -40,8 +53,10 @@
  *
  * Paragraph separators are ignored. effectiveStyle is the fully materialised
  * style of the first analysed character. commonStyle contains only properties
- * whose effective values are common to every analysed character. uniform is
- * true when every effective character-style property is common to the range.
+ * whose effective values are common to every analysed character.
+ * commonDirectStyle contains only direct properties explicitly present with
+ * the same value on every analysed character. uniform is true when every
+ * effective character-style property is common to the range.
  */
 SCRIBUS_API CharStyleSelection extractCharStyle(const StoryText& story, int pos, int len);
 
@@ -52,6 +67,25 @@
  */
 SCRIBUS_API CharStyle charStyleDelta(const CharStyle& effectiveStyle, const CharStyle* baseStyle = nullptr);
 
+/**
+ * Analyse the single paragraph touched by a selection. The entire paragraph is
+ * inspected even when only part of it is selected. Selections spanning more
+ * than one paragraph are rejected. effectiveStyle combines the paragraph's
+ * effective paragraph/character defaults with direct character properties
+ * explicitly common to every character in the paragraph. commonCharStyle is
+ * the safe cleanup mask for those promoted direct properties.
+ */
+SCRIBUS_API ParagraphStyleSelection extractParagraphStyle(const StoryText& story, int pos, int len);
+
+/**
+ * Return the explicit paragraph and embedded character properties required to
+ * reproduce effectiveStyle against baseStyle. If baseStyle is null, all
+ * effective properties are materialised. Name, parent and shortcut are left
+ * unset for the caller.
+ */
+SCRIBUS_API ParagraphStyle paragraphStyleDelta(const ParagraphStyle& effectiveStyle,
+                                                const ParagraphStyle* baseStyle = nullptr);
+
 } // namespace TextStyleExtractor
 
 #endif
Index: scribus/ui/contextmenu.cpp
===================================================================
--- scribus/ui/contextmenu.cpp	(after sfs2)
+++ scribus/ui/contextmenu.cpp	(working copy)
@@ -357,6 +357,9 @@
 				QAction* charStyleAction = styleFromSelectionMenu->addAction(
 					iconManager.loadIcon("character-style"), tr("Character Style..."));
 				connect(charStyleAction, SIGNAL(triggered()), m_ScMW, SLOT(createCharStyleFromSelection()));
+				QAction* paragraphStyleAction = styleFromSelectionMenu->addAction(
+					iconManager.loadIcon("paragraph-style"), tr("Paragraph Style..."));
+				connect(paragraphStyleAction, SIGNAL(triggered()), m_ScMW, SLOT(createParagraphStyleFromSelection()));
 			}
 		}
 
PatchYes

Activities

buttar

2026-09-25 12:56

reporter   ~0054496

Seems very closely related to 0000579

Issue History

Date Modified Username Field Change
2026-09-24 05:01 buttar New Issue
2026-09-24 05:01 buttar Tag Attached: #please_test
2026-09-24 05:01 buttar File Added: sfs1-selection-style-extractor-v1.0.patch
2026-09-24 05:01 buttar File Added: sfs2-create-char-style-from-selection-v1.05.patch
2026-09-24 05:01 buttar File Added: sfs3-create-para-from-selection-v1.03.patch
2026-09-25 12:56 buttar Note Added: 0054496