diff --git a/libreoffice_mcp_server.py b/libreoffice_mcp_server.py
index 263ee4c..415f8c9 100644
--- a/libreoffice_mcp_server.py
+++ b/libreoffice_mcp_server.py
@@ -20,10 +20,11 @@
"""
import httpx
+import os
from fastmcp import FastMCP
# LibreOffice HTTP API endpoint
-LIBREOFFICE_URL = "http://localhost:8765"
+LIBREOFFICE_URL = os.environ.get("LIBREOFFICE_URL", "http://localhost:8765")
# Create the MCP server
mcp = FastMCP("LibreOffice")
@@ -46,13 +47,19 @@ def call_libreoffice(path: str, method: str = "GET", data: dict = None) -> dict:
# =============================================================================
# CONSOLIDATED TOOL 1: document
-# Actions: create, info, list, content, status
+# Actions: create, info, list, content, status, styles,
+# create_style, edit_style, delete_style, style_properties
# =============================================================================
@mcp.tool
-def document(action: str, doc_type: str = "writer") -> dict:
+def document(action: str, doc_type: str = "writer",
+ style_name: str = None, parent_style: str = None,
+ font_name: str = None, font_size: float = None,
+ bold: bool = None, italic: bool = None,
+ underline: bool = None, alignment: str = None) -> dict:
"""
- Manage LibreOffice documents - create, get info, list, get content, or check status.
+ Manage LibreOffice documents - create, get info, list, content, status,
+ paragraph styles (list, create, edit, delete).
Args:
action: The operation to perform. Options:
@@ -61,7 +68,20 @@ def document(action: str, doc_type: str = "writer") -> dict:
- "list": List all open documents
- "content": Get full text content of active document
- "status": Check LibreOffice MCP server health
+ - "styles": List all available paragraph styles in the active document
+ - "create_style": Create a new paragraph style (requires style_name)
+ - "edit_style": Modify an existing paragraph style (requires style_name)
+ - "delete_style": Delete a paragraph style (requires style_name)
+ - "style_properties": Get detailed properties of a named style (requires style_name)
doc_type: Type of document for "create" action. Options: "writer", "calc", "impress", "draw"
+ style_name: Style name for create_style / edit_style / delete_style actions
+ parent_style: Parent style for create_style / edit_style (default: "Standard")
+ font_name: Font family for create_style / edit_style
+ font_size: Font size in points for create_style / edit_style
+ bold: Bold weight for create_style / edit_style
+ italic: Italic posture for create_style / edit_style
+ underline: Underline for create_style / edit_style
+ alignment: "left", "right", "center", or "justify" for create_style / edit_style
Returns:
Result based on action performed
@@ -76,8 +96,48 @@ def document(action: str, doc_type: str = "writer") -> dict:
return call_libreoffice("/tools/get_text_content_live", "POST", {})
elif action == "status":
return call_libreoffice("/health")
+ elif action == "styles":
+ return call_libreoffice("/tools/list_paragraph_styles_live", "POST", {})
+ elif action == "create_style":
+ if style_name is None:
+ return {"error": "Action 'create_style' requires parameter 'style_name'"}
+ data = {"style_name": style_name}
+ if parent_style:
+ data["parent_style"] = parent_style
+ if font_name: data["font_name"] = font_name
+ if font_size is not None: data["font_size"] = font_size
+ if bold is not None: data["bold"] = bold
+ if italic is not None: data["italic"] = italic
+ if underline is not None: data["underline"] = underline
+ if alignment: data["alignment"] = alignment
+ return call_libreoffice("/tools/create_paragraph_style_live", "POST", data)
+ elif action == "edit_style":
+ if style_name is None:
+ return {"error": "Action 'edit_style' requires parameter 'style_name'"}
+ data = {"style_name": style_name}
+ if parent_style: data["parent_style"] = parent_style
+ if font_name: data["font_name"] = font_name
+ if font_size is not None: data["font_size"] = font_size
+ if bold is not None: data["bold"] = bold
+ if italic is not None: data["italic"] = italic
+ if underline is not None: data["underline"] = underline
+ if alignment: data["alignment"] = alignment
+ return call_libreoffice("/tools/edit_paragraph_style_live", "POST", data)
+ elif action == "delete_style":
+ if style_name is None:
+ return {"error": "Action 'delete_style' requires parameter 'style_name'"}
+ return call_libreoffice("/tools/delete_paragraph_style_live", "POST",
+ {"style_name": style_name})
+ elif action == "style_properties":
+ if style_name is None:
+ return {"error": "Action 'style_properties' requires parameter 'style_name'"}
+ return call_libreoffice("/tools/get_style_properties_live", "POST",
+ {"style_name": style_name})
else:
- return {"error": f"Invalid action '{action}'", "valid_actions": ["create", "info", "list", "content", "status"]}
+ return {"error": f"Invalid action '{action}'",
+ "valid_actions": ["create", "info", "list", "content", "status",
+ "styles", "create_style", "edit_style", "delete_style",
+ "style_properties"]}
# =============================================================================
@@ -356,25 +416,30 @@ def save(action: str, file_path: str = None, export_format: str = "pdf") -> dict
# =============================================================================
# CONSOLIDATED TOOL 9: text
-# Actions: insert, format
+# Actions: insert, format, style
# =============================================================================
@mcp.tool
def text(action: str, content: str = None, bold: bool = None, italic: bool = None,
- underline: bool = None, font_size: int = None, font_name: str = None) -> dict:
+ underline: bool = None, font_size: int = None, font_name: str = None,
+ style_name: str = None, paragraph_n: int = None) -> dict:
"""
- Insert and format text in the document.
+ Insert, format text, or apply paragraph styles in the document.
Args:
action: The operation to perform. Options:
- "insert": Insert text at cursor position (requires content)
- "format": Apply formatting to selected text (use formatting params)
+ - "style": Apply a paragraph style (e.g. "Heading 1") to selected text
+ or a specific paragraph (use style_name + optional paragraph_n)
content: Text to insert for "insert" action
bold: Set bold formatting (True/False) for "format" action
italic: Set italic formatting (True/False) for "format" action
underline: Set underline formatting (True/False) for "format" action
font_size: Font size in points for "format" action
font_name: Font family name for "format" action
+ style_name: Name of paragraph style for "style" action (e.g. "Heading 1", "Heading 2", "Title")
+ paragraph_n: Optional paragraph number (1-indexed) to target with "style" action directly
Returns:
Result with success status
@@ -396,9 +461,22 @@ def text(action: str, content: str = None, bold: bool = None, italic: bool = Non
if font_name is not None:
formatting["font_name"] = font_name
return call_libreoffice("/tools/format_text_live", "POST", {"formatting": formatting})
+ elif action == "style":
+ if style_name is None:
+ return {"error": "Action 'style' requires parameter 'style_name' (e.g. 'Heading 1', 'Heading 2')"}
+ data = {"style_name": style_name}
+ if paragraph_n is not None:
+ data["paragraph_n"] = paragraph_n
+ return call_libreoffice("/tools/format_paragraph_live", "POST", data)
else:
- return {"error": f"Invalid action '{action}'", "valid_actions": ["insert", "format"]}
+ return {"error": f"Invalid action '{action}'", "valid_actions": ["insert", "format", "style"]}
if __name__ == "__main__":
- mcp.run()
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
+ if transport == "sse":
+ host = os.environ.get("MCP_HOST", "127.0.0.1")
+ port = int(os.environ.get("MCP_PORT", "8766"))
+ mcp.run(transport="sse", host=host, port=port)
+ else:
+ mcp.run()
diff --git a/plugin/META-INF/manifest.xml b/plugin/META-INF/manifest.xml
index 2ca3ff2..49ff582 100644
--- a/plugin/META-INF/manifest.xml
+++ b/plugin/META-INF/manifest.xml
@@ -2,6 +2,9 @@
+
+
+
diff --git a/plugin/ProtocolHandler.xcu b/plugin/ProtocolHandler.xcu
index a01b9b1..7d5d1f0 100644
--- a/plugin/ProtocolHandler.xcu
+++ b/plugin/ProtocolHandler.xcu
@@ -9,7 +9,7 @@
- service:org.mcp.libreoffice.MCPExtension*
+ *
diff --git a/plugin/pythonpath/mcp_server.py b/plugin/pythonpath/mcp_server.py
index a044822..9c968bc 100644
--- a/plugin/pythonpath/mcp_server.py
+++ b/plugin/pythonpath/mcp_server.py
@@ -107,6 +107,168 @@ def _register_tools(self):
"handler": self.format_text_live
}
+ # Paragraph style tools
+ self.tools["format_paragraph_live"] = {
+ "description": "Apply a paragraph style (e.g., Heading 1) to selected text or a specific paragraph",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "Name of the paragraph style to apply (e.g. 'Heading 1', 'Heading 2', 'Title', 'Text body')"
+ },
+ "paragraph_n": {
+ "type": "integer",
+ "description": "Optional paragraph number (1-indexed) to target directly instead of current selection"
+ }
+ },
+ "required": ["style_name"]
+ },
+ "handler": self.format_paragraph_live
+ }
+
+ self.tools["list_paragraph_styles_live"] = {
+ "description": "List all available paragraph styles in the active document (built-in and user-defined)",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "handler": self.list_paragraph_styles_live
+ }
+
+ self.tools["get_paragraph_style_live"] = {
+ "description": "Get the paragraph style name of a specific paragraph or the current selection",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "paragraph_n": {
+ "type": "integer",
+ "description": "Optional paragraph number (1-indexed) to query. If not provided, queries the current selection."
+ }
+ }
+ },
+ "handler": self.get_paragraph_style_live
+ }
+
+ self.tools["create_paragraph_style_live"] = {
+ "description": "Create a new paragraph style in the active document",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "Name for the new style (must not already exist)"
+ },
+ "parent_style": {
+ "type": "string",
+ "description": "Parent style to inherit from (default: 'Standard')",
+ "default": "Standard"
+ },
+ "font_name": {
+ "type": "string",
+ "description": "Font family name (optional)"
+ },
+ "font_size": {
+ "type": "number",
+ "description": "Font size in points (optional)"
+ },
+ "bold": {
+ "type": "boolean",
+ "description": "Bold weight (optional)"
+ },
+ "italic": {
+ "type": "boolean",
+ "description": "Italic posture (optional)"
+ },
+ "underline": {
+ "type": "boolean",
+ "description": "Single underline (optional)"
+ },
+ "alignment": {
+ "type": "string",
+ "enum": ["left", "right", "center", "justify"],
+ "description": "Paragraph alignment (optional)"
+ }
+ },
+ "required": ["style_name"]
+ },
+ "handler": self.create_paragraph_style_live
+ }
+
+ self.tools["edit_paragraph_style_live"] = {
+ "description": "Modify an existing paragraph style's properties",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "Name of the existing style to modify"
+ },
+ "parent_style": {
+ "type": "string",
+ "description": "New parent style (optional)"
+ },
+ "font_name": {
+ "type": "string",
+ "description": "Font family name (optional)"
+ },
+ "font_size": {
+ "type": "number",
+ "description": "Font size in points (optional)"
+ },
+ "bold": {
+ "type": "boolean",
+ "description": "Bold weight (optional)"
+ },
+ "italic": {
+ "type": "boolean",
+ "description": "Italic posture (optional)"
+ },
+ "underline": {
+ "type": "boolean",
+ "description": "Single underline (optional)"
+ },
+ "alignment": {
+ "type": "string",
+ "enum": ["left", "right", "center", "justify"],
+ "description": "Paragraph alignment (optional)"
+ }
+ },
+ "required": ["style_name"]
+ },
+ "handler": self.edit_paragraph_style_live
+ }
+
+ self.tools["delete_paragraph_style_live"] = {
+ "description": "Delete a paragraph style from the active document",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "Name of the style to delete"
+ }
+ },
+ "required": ["style_name"]
+ },
+ "handler": self.delete_paragraph_style_live
+ }
+
+ self.tools["get_style_properties_live"] = {
+ "description": "Get all properties (font, size, bold, italic, underline, alignment, parent) of a named paragraph style",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "Name of the paragraph style to inspect"
+ }
+ },
+ "required": ["style_name"]
+ },
+ "handler": self.get_style_properties_live
+ }
+
# Document saving tools
self.tools["save_document_live"] = {
"description": "Save the currently active document",
@@ -501,6 +663,78 @@ def _register_tools(self):
"handler": self.reject_all_changes_live
}
+ # ── Calc / Spreadsheet Tools ──
+
+ self.tools["get_cell_value_live"] = {
+ "description": "Get the value of a cell in a Calc spreadsheet (e.g., 'A1' or 'Sheet1.B5')",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "cell_address": {
+ "type": "string",
+ "description": "Cell address like 'A1', 'B5', or 'Sheet1.A1'"
+ },
+ "sheet_name": {
+ "type": "string",
+ "description": "Sheet name (optional; uses active sheet or address prefix if omitted)"
+ }
+ },
+ "required": ["cell_address"]
+ },
+ "handler": self.get_cell_value_live
+ }
+
+ self.tools["set_cell_value_live"] = {
+ "description": "Set the value of a cell in a Calc spreadsheet",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "cell_address": {
+ "type": "string",
+ "description": "Cell address like 'A1' or 'Sheet1.B5'"
+ },
+ "value": {
+ "type": "string",
+ "description": "Value to set (number or text)"
+ },
+ "sheet_name": {
+ "type": "string",
+ "description": "Sheet name (optional)"
+ }
+ },
+ "required": ["cell_address", "value"]
+ },
+ "handler": self.set_cell_value_live
+ }
+
+ self.tools["get_cell_range_live"] = {
+ "description": "Get a rectangular range of cells as a 2D array (e.g., 'A1:C10' or 'Sheet1.A1:C10')",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "range_address": {
+ "type": "string",
+ "description": "Range like 'A1:B10' or 'Sheet1.A1:B10'"
+ },
+ "sheet_name": {
+ "type": "string",
+ "description": "Sheet name (optional)"
+ }
+ },
+ "required": ["range_address"]
+ },
+ "handler": self.get_cell_range_live
+ }
+
+ self.tools["list_sheets_live"] = {
+ "description": "List all sheet names in the current Calc document",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "handler": self.list_sheets_live
+ }
+
logger.info(f"Registered {len(self.tools)} MCP tools")
async def execute_tool(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
@@ -583,6 +817,54 @@ def format_text_live(self, **formatting) -> Dict[str, Any]:
"""Apply formatting to selected text"""
return self.uno_bridge.format_text(formatting)
+ def format_paragraph_live(self, style_name: str, paragraph_n: Optional[int] = None) -> Dict[str, Any]:
+ """Apply a paragraph style to selected text or a specific paragraph"""
+ return self.uno_bridge.set_paragraph_style(style_name, paragraph_n)
+
+ def list_paragraph_styles_live(self) -> Dict[str, Any]:
+ """List all available paragraph styles"""
+ return self.uno_bridge.list_paragraph_styles()
+
+ def get_paragraph_style_live(self, paragraph_n: Optional[int] = None) -> Dict[str, Any]:
+ """Get the paragraph style of a specific paragraph or current selection"""
+ return self.uno_bridge.get_paragraph_style(paragraph_n)
+
+ def create_paragraph_style_live(self, style_name: str, parent_style: str = "Standard",
+ font_name: Optional[str] = None,
+ font_size: Optional[float] = None,
+ bold: Optional[bool] = None,
+ italic: Optional[bool] = None,
+ underline: Optional[bool] = None,
+ alignment: Optional[str] = None) -> Dict[str, Any]:
+ """Create a new paragraph style"""
+ return self.uno_bridge.create_paragraph_style(
+ style_name=style_name, parent_style=parent_style,
+ font_name=font_name, font_size=font_size,
+ bold=bold, italic=italic, underline=underline,
+ alignment=alignment)
+
+ def edit_paragraph_style_live(self, style_name: str, parent_style: Optional[str] = None,
+ font_name: Optional[str] = None,
+ font_size: Optional[float] = None,
+ bold: Optional[bool] = None,
+ italic: Optional[bool] = None,
+ underline: Optional[bool] = None,
+ alignment: Optional[str] = None) -> Dict[str, Any]:
+ """Modify an existing paragraph style"""
+ return self.uno_bridge.edit_paragraph_style(
+ style_name=style_name, parent_style=parent_style,
+ font_name=font_name, font_size=font_size,
+ bold=bold, italic=italic, underline=underline,
+ alignment=alignment)
+
+ def delete_paragraph_style_live(self, style_name: str) -> Dict[str, Any]:
+ """Delete a user-defined paragraph style"""
+ return self.uno_bridge.delete_paragraph_style(style_name)
+
+ def get_style_properties_live(self, style_name: str) -> Dict[str, Any]:
+ """Get all properties of a named paragraph style"""
+ return self.uno_bridge.get_style_properties(style_name)
+
def save_document_live(self, file_path: Optional[str] = None) -> Dict[str, Any]:
"""Save the currently active document"""
return self.uno_bridge.save_document(file_path=file_path)
@@ -727,6 +1009,29 @@ def reject_all_changes_live(self) -> Dict[str, Any]:
"""Reject all tracked changes in the document"""
return self.uno_bridge.reject_all_changes()
+ # ── Calc / Spreadsheet Handlers ──
+
+ def get_cell_value_live(self, cell_address: str, sheet_name: str = None) -> Dict[str, Any]:
+ """Get the value of a cell in a Calc spreadsheet"""
+ return self.uno_bridge.get_cell_value(sheet_name, cell_address)
+
+ def set_cell_value_live(self, cell_address: str, value: str, sheet_name: str = None) -> Dict[str, Any]:
+ """Set the value of a cell in a Calc spreadsheet"""
+ # Try numeric conversion
+ try:
+ numeric = float(value)
+ return self.uno_bridge.set_cell_value(sheet_name, cell_address, numeric)
+ except ValueError:
+ return self.uno_bridge.set_cell_value(sheet_name, cell_address, value)
+
+ def get_cell_range_live(self, range_address: str, sheet_name: str = None) -> Dict[str, Any]:
+ """Get a range of cells as a 2D array"""
+ return self.uno_bridge.get_cell_range(sheet_name, range_address)
+
+ def list_sheets_live(self) -> Dict[str, Any]:
+ """List all sheet names"""
+ return self.uno_bridge.list_sheets()
+
# Global instance
mcp_server = None
diff --git a/plugin/pythonpath/registration.py b/plugin/pythonpath/registration.py
index 447d8ef..9568e02 100644
--- a/plugin/pythonpath/registration.py
+++ b/plugin/pythonpath/registration.py
@@ -13,16 +13,25 @@
import os
from com.sun.star.lang import XServiceInfo
from com.sun.star.frame import XDispatchProvider, XDispatch
-from com.sun.star.lang import XInitialization
# Add the pythonpath directory to sys.path for imports
_this_dir = os.path.dirname(__file__)
if _this_dir not in sys.path:
sys.path.insert(0, _this_dir)
-# Set up logging
-logging.basicConfig(level=logging.DEBUG)
+# ── File-based logging so we can see what happens inside LO ──
+LOG_FILE = os.path.join(os.environ.get("TEMP", "/tmp"), "mcp_extension.log")
+_file_handler = None
+try:
+ _file_handler = logging.FileHandler(LOG_FILE, mode="w")
+ _file_handler.setLevel(logging.DEBUG)
+ _file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
+ logging.getLogger().addHandler(_file_handler)
+ logging.getLogger().setLevel(logging.DEBUG)
+except Exception:
+ pass
logger = logging.getLogger("MCPExtension")
+logger.info("=== Extension module loaded, log at %s ===", LOG_FILE)
# Implementation name and service name for the extension
IMPLEMENTATION_NAME = "org.mcp.libreoffice.MCPExtension"
@@ -80,19 +89,13 @@ def _stop_server():
logger.error(traceback.format_exc())
-class MCPProtocolHandler(unohelper.Base, XServiceInfo, XDispatchProvider, XDispatch, XInitialization):
+class MCPProtocolHandler(unohelper.Base, XServiceInfo, XDispatchProvider, XDispatch):
"""Protocol handler for MCP extension menu commands"""
def __init__(self, ctx):
self.ctx = ctx
self.frame = None
- logger.debug("MCPProtocolHandler created")
-
- # XInitialization
- def initialize(self, args):
- if args:
- self.frame = args[0]
- logger.debug("MCPProtocolHandler initialized with frame")
+ logger.info("MCPProtocolHandler.__init__ called, ctx=%s", ctx)
# XServiceInfo
def getImplementationName(self):
@@ -106,8 +109,11 @@ def getSupportedServiceNames(self):
# XDispatchProvider
def queryDispatch(self, url, target, flags):
- logger.debug(f"queryDispatch: {url.Complete}")
- if url.Protocol == "service:":
+ logger.info("queryDispatch: Complete=%r Protocol=%r Path=%r",
+ url.Complete, url.Protocol, url.Path)
+ # Accept ALL service: URLs for our protocol
+ if url.Complete and "org.mcp.libreoffice.MCPExtension" in url.Complete:
+ logger.info("queryDispatch: ACCEPTING")
return self
return None
@@ -116,11 +122,11 @@ def queryDispatches(self, requests):
# XDispatch
def dispatch(self, url, args):
- logger.info(f"dispatch called: {url.Complete}")
+ logger.info("dispatch called: Complete=%r", url.Complete)
try:
if "?" in url.Complete:
command = url.Complete.split("?")[1]
- logger.info(f"Executing command: {command}")
+ logger.info("Executing command: %s", command)
if command == "start_mcp_server":
# Run in thread to not block UI
@@ -155,3 +161,13 @@ def removeStatusListener(self, listener, url):
IMPLEMENTATION_NAME,
SERVICE_NAMES
)
+logger.info("=== addImplementation completed ===")
+
+# ── Auto-start the MCP server on extension load ──
+logger.info("=== Auto-starting MCP server... ===")
+try:
+ _start_server()
+ logger.info("=== Auto-start complete, server_started=%s ===", _server_started)
+except Exception as e:
+ logger.error("=== Auto-start FAILED: %s ===", e)
+ logger.error(traceback.format_exc())
diff --git a/plugin/pythonpath/uno_bridge.py b/plugin/pythonpath/uno_bridge.py
index 30336f4..e66fc96 100644
--- a/plugin/pythonpath/uno_bridge.py
+++ b/plugin/pythonpath/uno_bridge.py
@@ -177,8 +177,9 @@ def insert_text(self, text: str, position: Optional[int] = None, doc: Any = None
text_obj = doc.getText()
if position is None:
- # Insert at current cursor position
- cursor = doc.getCurrentController().getViewCursor()
+ # Insert at end of document (most reliable)
+ cursor = text_obj.createTextCursor()
+ cursor.gotoEnd(False)
else:
# Insert at specific position
cursor = text_obj.createTextCursor()
@@ -195,7 +196,9 @@ def insert_text(self, text: str, position: Optional[int] = None, doc: Any = None
except Exception as e:
logger.error(f"Failed to insert text: {e}")
- return {"success": False, "error": str(e)}
+ import traceback as _tb
+ logger.error(_tb.format_exc())
+ return {"success": False, "error": str(e) or repr(e)}
def format_text(self, formatting: Dict[str, Any], doc: Any = None) -> Dict[str, Any]:
"""
@@ -246,6 +249,480 @@ def format_text(self, formatting: Dict[str, Any], doc: Any = None) -> Dict[str,
logger.error(f"Failed to format text: {e}")
return {"success": False, "error": str(e)}
+ def set_paragraph_style(self, style_name: str, paragraph_n: Optional[int] = None,
+ doc: Any = None) -> Dict[str, Any]:
+ """
+ Apply a paragraph style (e.g., "Heading 1") to selected text or a specific paragraph.
+
+ Args:
+ style_name: Name of the paragraph style to apply (e.g. "Heading 1", "Heading 2", "Title")
+ paragraph_n: Optional paragraph number (1-indexed) to target directly.
+ If provided, selects that paragraph before applying the style.
+ doc: Document to work with (None for active document)
+
+ Returns:
+ Result dictionary with success status and paragraph number
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ # Validate that the style exists in the document
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+ if not para_styles.hasByName(style_name):
+ # Collect a few known style names as hints
+ known = ["Heading 1", "Heading 2", "Heading 3", "Title", "Subtitle",
+ "Text body", "Standard"]
+ existing = [s for s in known if para_styles.hasByName(s)]
+ hint = ", ".join(existing[:6]) if existing else "none of the expected styles found"
+ return {
+ "success": False,
+ "error": f"Paragraph style '{style_name}' not found in document. "
+ f"Known styles available: {hint}"
+ }
+
+ # If paragraph_n is given, select that paragraph first
+ applied_paragraph = None
+ if paragraph_n is not None:
+ select_result = self.select_paragraph(paragraph_n, doc)
+ if not select_result.get("success"):
+ return {
+ "success": False,
+ "error": f"Failed to select paragraph {paragraph_n}: "
+ f"{select_result.get('error', 'unknown error')}"
+ }
+ applied_paragraph = paragraph_n
+
+ # Get current selection and apply the style
+ selection = doc.getCurrentController().getSelection()
+ if selection.getCount() == 0:
+ return {"success": False, "error": "No text selected. "
+ "Select a paragraph first or pass paragraph_n to target directly."}
+
+ text_range = selection.getByIndex(0)
+ text_range.ParaStyleName = style_name
+
+ # Determine which paragraph was affected
+ if applied_paragraph is None:
+ applied_paragraph = "current selection"
+
+ logger.info(f"Applied paragraph style '{style_name}' to paragraph {applied_paragraph}")
+ return {
+ "success": True,
+ "message": f"Applied paragraph style '{style_name}'",
+ "paragraph": applied_paragraph,
+ "style_name": style_name
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to set paragraph style: {e}")
+ return {"success": False, "error": str(e)}
+
+ def list_paragraph_styles(self, doc: Any = None) -> Dict[str, Any]:
+ """
+ List all available paragraph styles in the document.
+
+ Args:
+ doc: Document to query (None for active document)
+
+ Returns:
+ Result dictionary with list of style names and count
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+
+ styles = []
+ style_names = para_styles.getElementNames()
+ for name in style_names:
+ styles.append({"name": name})
+
+ styles.sort(key=lambda x: x["name"])
+
+ logger.info(f"Found {len(styles)} paragraph styles")
+ return {
+ "success": True,
+ "styles": styles,
+ "count": len(styles)
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to list paragraph styles: {e}")
+ return {"success": False, "error": str(e)}
+
+ def get_paragraph_style(self, paragraph_n: Optional[int] = None,
+ doc: Any = None) -> Dict[str, Any]:
+ """
+ Get the paragraph style name of a specific paragraph or the current selection.
+
+ Args:
+ paragraph_n: Optional paragraph number (1-indexed) to query.
+ If None, queries the current selection.
+ doc: Document to query (None for active document)
+
+ Returns:
+ Result dictionary with style_name and paragraph number
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ queried_paragraph = paragraph_n
+
+ if paragraph_n is not None:
+ # Find the specific paragraph
+ text = doc.getText()
+ enum = text.createEnumeration()
+ current = 0
+ target_para = None
+ while enum.hasMoreElements():
+ para = enum.nextElement()
+ if hasattr(para, 'supportsService') and para.supportsService("com.sun.star.text.Paragraph"):
+ current += 1
+ if current == paragraph_n:
+ target_para = para
+ break
+
+ if target_para is None:
+ return {"success": False, "error": f"Paragraph {paragraph_n} out of range. "
+ f"Valid range: 1-{current}"}
+
+ style_name = target_para.ParaStyleName if hasattr(target_para, 'ParaStyleName') else ""
+ else:
+ # Query current selection
+ selection = doc.getCurrentController().getSelection()
+ if selection.getCount() == 0:
+ return {"success": False, "error": "No text selected. "
+ "Select a paragraph first or pass paragraph_n to query directly."}
+
+ text_range = selection.getByIndex(0)
+ style_name = text_range.ParaStyleName if hasattr(text_range, 'ParaStyleName') else ""
+ queried_paragraph = "current selection"
+
+ logger.info(f"Paragraph {queried_paragraph} has style: '{style_name}'")
+ return {
+ "success": True,
+ "paragraph": queried_paragraph,
+ "style_name": style_name
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to get paragraph style: {e}")
+ return {"success": False, "error": str(e)}
+
+ # ── Paragraph Style Management ───────────────────────────────────────
+
+ # Alignment constants for UNO ParagraphAdjust
+ _ALIGNMENT_VALUES = {
+ "left": 0,
+ "right": 1,
+ "center": 2,
+ "justify": 3,
+ }
+
+ def get_style_properties(self, style_name: str, doc: Any = None) -> Dict[str, Any]:
+ """
+ Get all properties of a named paragraph style.
+
+ Args:
+ style_name: Name of the paragraph style to inspect
+ doc: Document (None for active document)
+
+ Returns:
+ Result dictionary with style properties
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+
+ if not para_styles.hasByName(style_name):
+ return {"success": False, "error": f"Style '{style_name}' not found"}
+
+ style = para_styles.getByName(style_name)
+
+ # Read all accessible properties
+ props = {
+ "name": style_name,
+ "parent_style": getattr(style, 'ParentStyle', '') if hasattr(style, 'ParentStyle') else '',
+ "font_name": getattr(style, 'CharFontName', '') if hasattr(style, 'CharFontName') else '',
+ "font_size": getattr(style, 'CharHeight', 0.0) if hasattr(style, 'CharHeight') else 0.0,
+ "bold": getattr(style, 'CharWeight', 100.0) if hasattr(style, 'CharWeight') else 100.0,
+ "italic": getattr(style, 'CharPosture', 0) if hasattr(style, 'CharPosture') else 0,
+ "underline": getattr(style, 'CharUnderline', 0) if hasattr(style, 'CharUnderline') else 0,
+ "alignment": getattr(style, 'ParaAdjust', -1) if hasattr(style, 'ParaAdjust') else -1,
+ }
+
+ # Convert numeric values to human-readable
+ props["bold"] = props["bold"] > 120.0 # > 120 = bold
+ props["italic"] = props["italic"] == 2 # 2 = ITALIC
+ props["underline"] = props["underline"] != 0 # non-zero = underlined
+ alignment_map = {0: "left", 1: "right", 2: "center", 3: "justify"}
+ props["alignment"] = alignment_map.get(props["alignment"], "unknown")
+
+ logger.info(f"Retrieved properties for style '{style_name}'")
+ return {"success": True, "style_name": style_name, "properties": props}
+
+ except Exception as e:
+ logger.error(f"Failed to get style properties: {e}")
+ return {"success": False, "error": str(e)}
+
+ def create_paragraph_style(self, style_name: str, parent_style: str = "Standard",
+ font_name: Optional[str] = None,
+ font_size: Optional[float] = None,
+ bold: Optional[bool] = None,
+ italic: Optional[bool] = None,
+ underline: Optional[bool] = None,
+ alignment: Optional[str] = None,
+ doc: Any = None) -> Dict[str, Any]:
+ """
+ Create a new paragraph style in the document.
+
+ Args:
+ style_name: Name for the new style (must not already exist)
+ parent_style: Parent style to inherit from (default: "Standard")
+ font_name: Font family name (optional)
+ font_size: Font size in points (optional)
+ bold: Bold weight (optional)
+ italic: Italic posture (optional)
+ underline: Single underline (optional)
+ alignment: "left", "right", "center", or "justify" (optional)
+ doc: Document (None for active document)
+
+ Returns:
+ Result dictionary
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+
+ if para_styles.hasByName(style_name):
+ return {"success": False, "error": f"Style '{style_name}' already exists. "
+ "Use edit_paragraph_style to modify it."}
+
+ # Validate parent style exists
+ if not para_styles.hasByName(parent_style):
+ return {"success": False, "error": f"Parent style '{parent_style}' not found"}
+
+ # Validate alignment value if provided
+ if alignment is not None and alignment not in self._ALIGNMENT_VALUES:
+ valid = ", ".join(self._ALIGNMENT_VALUES.keys())
+ return {"success": False, "error": f"Invalid alignment '{alignment}'. "
+ f"Valid values: {valid}"}
+
+ # Create the style object
+ # Use the document itself as a factory (XMultiServiceFactory)
+ style = None
+ try:
+ style = doc.createInstance("com.sun.star.style.ParagraphStyle")
+ except Exception:
+ pass
+
+ if style is None:
+ # Fallback: try ServiceManager
+ try:
+ style = self.smgr.createInstanceWithContext(
+ "com.sun.star.style.ParagraphStyle", self.ctx)
+ except Exception:
+ pass
+
+ if style is None:
+ return {"success": False,
+ "error": "Failed to create paragraph style object. "
+ "The UNO service 'com.sun.star.style.ParagraphStyle' "
+ "could not be instantiated."}
+
+ style.ParentStyle = parent_style
+
+ # Apply character-level properties
+ if font_name is not None:
+ style.CharFontName = font_name
+ if font_size is not None:
+ style.CharHeight = font_size
+ if bold is not None:
+ style.CharWeight = 150.0 if bold else 100.0
+ if italic is not None:
+ style.CharPosture = 2 if italic else 0
+ if underline is not None:
+ style.CharUnderline = 1 if underline else 0
+
+ # Apply paragraph-level properties
+ if alignment is not None:
+ style.ParaAdjust = self._ALIGNMENT_VALUES[alignment]
+
+ # Insert into the document
+ para_styles.insertByName(style_name, style)
+
+ logger.info(f"Created paragraph style '{style_name}' (parent: {parent_style})")
+ return {
+ "success": True,
+ "message": f"Created paragraph style '{style_name}'",
+ "style_name": style_name,
+ "parent_style": parent_style
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to create paragraph style: {e}")
+ return {"success": False, "error": str(e)}
+
+ def edit_paragraph_style(self, style_name: str, parent_style: Optional[str] = None,
+ font_name: Optional[str] = None,
+ font_size: Optional[float] = None,
+ bold: Optional[bool] = None,
+ italic: Optional[bool] = None,
+ underline: Optional[bool] = None,
+ alignment: Optional[str] = None,
+ doc: Any = None) -> Dict[str, Any]:
+ """
+ Modify an existing paragraph style's properties.
+
+ Args:
+ style_name: Name of the existing style to modify
+ parent_style: New parent style (optional)
+ font_name: Font family name (optional)
+ font_size: Font size in points (optional)
+ bold: Bold weight (optional)
+ italic: Italic posture (optional)
+ underline: Single underline (optional)
+ alignment: "left", "right", "center", or "justify" (optional)
+ doc: Document (None for active document)
+
+ Returns:
+ Result dictionary
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+
+ if not para_styles.hasByName(style_name):
+ return {"success": False, "error": f"Style '{style_name}' not found. "
+ "Use create_paragraph_style to create it first."}
+
+ style = para_styles.getByName(style_name)
+ changed = []
+
+ if parent_style is not None:
+ if not para_styles.hasByName(parent_style):
+ return {"success": False, "error": f"Parent style '{parent_style}' not found"}
+ style.ParentStyle = parent_style
+ changed.append("parent_style")
+
+ if font_name is not None:
+ style.CharFontName = font_name
+ changed.append("font_name")
+ if font_size is not None:
+ style.CharHeight = font_size
+ changed.append("font_size")
+ if bold is not None:
+ style.CharWeight = 150.0 if bold else 100.0
+ changed.append("bold")
+ if italic is not None:
+ style.CharPosture = 2 if italic else 0
+ changed.append("italic")
+ if underline is not None:
+ style.CharUnderline = 1 if underline else 0
+ changed.append("underline")
+ if alignment is not None:
+ if alignment not in self._ALIGNMENT_VALUES:
+ valid = ", ".join(self._ALIGNMENT_VALUES.keys())
+ return {"success": False, "error": f"Invalid alignment '{alignment}'. "
+ f"Valid values: {valid}"}
+ style.ParaAdjust = self._ALIGNMENT_VALUES[alignment]
+ changed.append("alignment")
+
+ if not changed:
+ return {"success": False, "error": "No properties specified to change"}
+
+ logger.info(f"Edited paragraph style '{style_name}': changed {changed}")
+ return {
+ "success": True,
+ "message": f"Updated paragraph style '{style_name}'",
+ "style_name": style_name,
+ "changed": changed
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to edit paragraph style: {e}")
+ return {"success": False, "error": str(e)}
+
+ def delete_paragraph_style(self, style_name: str, doc: Any = None) -> Dict[str, Any]:
+ """
+ Delete a paragraph style from the document.
+
+ Args:
+ style_name: Name of the style to delete
+ doc: Document (None for active document)
+
+ Returns:
+ Result dictionary
+ """
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+
+ if not doc:
+ return {"success": False, "error": "No document available"}
+ if self._get_document_type(doc) != "writer":
+ return {"success": False, "error": "Paragraph styles only supported for Writer documents"}
+
+ style_families = doc.getStyleFamilies()
+ para_styles = style_families.getByName("ParagraphStyles")
+
+ if not para_styles.hasByName(style_name):
+ return {"success": False, "error": f"Style '{style_name}' not found"}
+
+ para_styles.removeByName(style_name)
+
+ logger.info(f"Deleted paragraph style '{style_name}'")
+ return {"success": True, "message": f"Deleted paragraph style '{style_name}'",
+ "style_name": style_name}
+
+ except Exception as e:
+ logger.error(f"Failed to delete paragraph style: {e}")
+ return {"success": False, "error": str(e)}
+
def save_document(self, doc: Any = None, file_path: Optional[str] = None) -> Dict[str, Any]:
"""
Save a document
@@ -349,14 +826,30 @@ def get_text_content(self, doc: Any = None) -> Dict[str, Any]:
hasattr(doc, 'getText')
if is_writer:
- text = doc.getText().getString()
- return {"success": True, "content": text, "length": len(text)}
+ text_obj = doc.getText()
+ # Try getting string directly first
+ content = text_obj.getString()
+ # If empty, try iterating through text portions (handles tables, frames, etc.)
+ if not content:
+ try:
+ portions = []
+ enum = text_obj.createEnumeration()
+ while enum.hasMoreElements():
+ portion = enum.nextElement()
+ if hasattr(portion, 'getString'):
+ portions.append(portion.getString())
+ content = "".join(portions)
+ except Exception:
+ pass
+ return {"success": True, "content": content, "length": len(content)}
else:
return {"success": False, "error": f"Text extraction not supported for {self._get_document_type(doc)}"}
except Exception as e:
logger.error(f"Failed to get text content: {e}")
- return {"success": False, "error": str(e)}
+ import traceback as _tb
+ logger.error(_tb.format_exc())
+ return {"success": False, "error": str(e) or repr(e)}
def get_comments(self, doc: Any = None) -> Dict[str, Any]:
"""Get all comments/annotations from the document"""
@@ -1911,6 +2404,151 @@ def find_and_replace_all(self, old: str, new: str, doc: Any = None) -> Dict[str,
logger.error(f"Failed to find and replace all: {e}")
return {"success": False, "error": str(e)}
+ # ═══════════════════════════════════════════════════════════════════════
+ # Calc / Spreadsheet Operations
+ # ═══════════════════════════════════════════════════════════════════════
+
+ def _get_sheet(self, doc: Any, sheet_name: Optional[str] = None) -> Any:
+ """Helper: get a sheet by name, or the active sheet."""
+ sheets = doc.getSheets()
+ if sheet_name:
+ return sheets.getByName(sheet_name)
+ # Get active sheet via controller
+ controller = doc.getCurrentController()
+ return controller.getActiveSheet()
+
+ def get_cell_value(self, sheet_name: Optional[str], cell_address: str,
+ doc: Any = None) -> Dict[str, Any]:
+ """Get the value of a single cell (e.g., 'A1', 'Sheet1.B5')."""
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+ if not doc:
+ return {"success": False, "error": "No active document"}
+ if not self._is_calc(doc):
+ return {"success": False, "error": "Active document is not a spreadsheet"}
+
+ # Handle "Sheet1.A1" notation
+ if "." in cell_address and not sheet_name:
+ sheet_name, cell_address = cell_address.split(".", 1)
+
+ sheet = self._get_sheet(doc, sheet_name)
+ actual_sheet = sheet.getName()
+ cell = sheet.getCellRangeByName(cell_address)
+ # Determine value type
+ cell_type = cell.getType().value if hasattr(cell.getType(), 'value') else "unknown"
+ if cell_type == "VALUE" or cell_type == 0:
+ val = cell.getValue()
+ elif cell_type == "FORMULA" or cell_type == 3:
+ val = cell.getFormula()
+ else:
+ val = cell.getString()
+
+ return {
+ "success": True,
+ "cell": cell_address,
+ "sheet": actual_sheet,
+ "value": val,
+ "type": str(cell.getType()) if hasattr(cell, 'getType') else "unknown"
+ }
+ except Exception as e:
+ logger.error(f"get_cell_value failed: {e}")
+ return {"success": False, "error": str(e)}
+
+ def set_cell_value(self, sheet_name: Optional[str], cell_address: str,
+ value, doc: Any = None) -> Dict[str, Any]:
+ """Set the value of a single cell."""
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+ if not doc:
+ return {"success": False, "error": "No active document"}
+ if not self._is_calc(doc):
+ return {"success": False, "error": "Active document is not a spreadsheet"}
+
+ if "." in cell_address and not sheet_name:
+ sheet_name, cell_address = cell_address.split(".", 1)
+
+ sheet = self._get_sheet(doc, sheet_name)
+ cell = sheet.getCellRangeByName(cell_address)
+ if isinstance(value, (int, float)):
+ cell.setValue(float(value))
+ else:
+ cell.setFormula(str(value))
+ return {"success": True, "cell": cell_address, "sheet": sheet.getName()}
+ except Exception as e:
+ logger.error(f"set_cell_value failed: {e}")
+ return {"success": False, "error": str(e)}
+
+ def get_cell_range(self, sheet_name: Optional[str], range_address: str,
+ doc: Any = None) -> Dict[str, Any]:
+ """Get a rectangular range of cells as a 2D array."""
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+ if not doc:
+ return {"success": False, "error": "No active document"}
+ if not self._is_calc(doc):
+ return {"success": False, "error": "Active document is not a spreadsheet"}
+
+ if "." in range_address and not sheet_name:
+ sheet_name, range_address = range_address.split(".", 1)
+
+ sheet = self._get_sheet(doc, sheet_name)
+ cell_range = sheet.getCellRangeByName(range_address)
+ data = cell_range.getDataArray() # returns tuple of tuples
+ # Convert to list of lists
+ result = [list(row) for row in data]
+ return {
+ "success": True,
+ "range": range_address,
+ "sheet": sheet.getName(),
+ "rows": len(result),
+ "cols": len(result[0]) if result else 0,
+ "data": result
+ }
+ except Exception as e:
+ logger.error(f"get_cell_range failed: {e}")
+ return {"success": False, "error": str(e)}
+
+ def list_sheets(self, doc: Any = None) -> Dict[str, Any]:
+ """List all sheet names in the document."""
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+ if not doc:
+ return {"success": False, "error": "No active document"}
+ if not self._is_calc(doc):
+ return {"success": False, "error": "Active document is not a spreadsheet"}
+
+ sheets = doc.getSheets()
+ names = [sheets.getByIndex(i).getName() for i in range(sheets.getCount())]
+ active = self.get_active_sheet_name(doc).get("name", "")
+ return {"success": True, "sheets": names, "active": active, "count": len(names)}
+ except Exception as e:
+ logger.error(f"list_sheets failed: {e}")
+ return {"success": False, "error": str(e)}
+
+ def get_active_sheet_name(self, doc: Any = None) -> Dict[str, Any]:
+ """Get the name of the currently active sheet."""
+ try:
+ if doc is None:
+ doc = self.get_active_document()
+ if not doc:
+ return {"success": False, "error": "No active document"}
+ controller = doc.getCurrentController()
+ sheet = controller.getActiveSheet()
+ return {"success": True, "name": sheet.getName()}
+ except Exception as e:
+ logger.error(f"get_active_sheet_name failed: {e}")
+ return {"success": False, "error": str(e)}
+
+ def _is_calc(self, doc: Any) -> bool:
+ """Check if document is a Calc spreadsheet."""
+ return self._get_document_type(doc) == "calc"
+
+ # ═══════════════════════════════════════════════════════════════════════
+
def _get_document_type(self, doc: Any) -> str:
"""Determine document type"""
# Try isinstance first if types are available
diff --git a/plugin/test_mcp_server.py b/plugin/test_mcp_server.py
index 79ccd19..196dc9a 100644
--- a/plugin/test_mcp_server.py
+++ b/plugin/test_mcp_server.py
@@ -531,6 +531,120 @@ def test_reject_all_changes():
return True
+# Paragraph Style Tools Tests
+
+def test_list_paragraph_styles():
+ """Test listing available paragraph styles"""
+ print("Testing list_paragraph_styles_live tool...")
+ result = make_request("/tools/list_paragraph_styles_live", method="POST", data={})
+
+ if "error" in result:
+ print(f" ⚠ Error: {result['error']}")
+ elif result.get("success"):
+ count = result.get("count", 0)
+ builtin = result.get("builtin_count", 0)
+ user = result.get("user_defined_count", 0)
+ print(f" ✓ Found {count} paragraph styles ({builtin} built-in, {user} user-defined)")
+ # Verify some expected built-in styles exist
+ style_names = [s["name"] for s in result.get("styles", [])]
+ expected = ["Heading 1", "Heading 2", "Standard", "Text body"]
+ found_expected = [s for s in expected if s in style_names]
+ print(f" ✓ Standard styles found: {', '.join(found_expected)}")
+ else:
+ print(f" ⚠ Unexpected result: {result}")
+ return True
+
+
+def test_apply_paragraph_style_by_selection():
+ """Test applying a paragraph style via selection"""
+ print("Testing format_paragraph_live via selection...")
+
+ # First ensure we have a document with paragraphs
+ make_request("/tools/create_document_live", method="POST", data={"doc_type": "writer"})
+ # Insert a paragraph to work with
+ make_request("/tools/insert_text_live", method="POST", data={"text": "Test Heading"})
+ make_request("/tools/insert_text_live", method="POST", data={"text": "\n"})
+ make_request("/tools/insert_text_live", method="POST", data={"text": "Test body paragraph"})
+
+ # Select the first paragraph
+ select_result = make_request("/tools/select_paragraph_live", method="POST", data={"n": 1})
+ if not select_result.get("success"):
+ print(f" ⚠ Could not select paragraph: {select_result.get('error', 'unknown')}")
+ return True
+
+ # Apply heading style to the selection
+ result = make_request("/tools/format_paragraph_live", method="POST",
+ data={"style_name": "Heading 1"})
+
+ if result.get("success"):
+ print(f" ✓ Applied '{result.get('style_name')}' to {result.get('paragraph')}")
+ else:
+ print(f" ⚠ Error: {result.get('error', 'unknown')}")
+
+ # Verify the style was applied by checking the outline
+ outline_result = make_request("/tools/get_document_outline_live", method="POST", data={})
+ if outline_result.get("success"):
+ headings = outline_result.get("outline", [])
+ if len(headings) > 0:
+ print(f" ✓ Verified: heading appears in document outline ({len(headings)} heading(s))")
+ else:
+ print(f" ⚠ Warning: heading not found in outline after applying style")
+
+ return True
+
+
+def test_apply_paragraph_style_by_number():
+ """Test applying a paragraph style directly by paragraph number"""
+ print("Testing format_paragraph_live via paragraph_n...")
+
+ # Apply Heading 2 to paragraph 3 directly (no selection needed)
+ result = make_request("/tools/format_paragraph_live", method="POST",
+ data={"style_name": "Heading 2", "paragraph_n": 3})
+
+ if result.get("success"):
+ print(f" ✓ Applied '{result.get('style_name')}' directly to paragraph {result.get('paragraph')}")
+ else:
+ print(f" ⚠ Error (may need document with 3+ paragraphs): {result.get('error', 'unknown')}")
+
+ return True
+
+
+def test_get_paragraph_style():
+ """Test getting the style of a specific paragraph"""
+ print("Testing get_paragraph_style_live tool...")
+
+ # Query paragraph 1 (should have Heading 1 from previous test)
+ result = make_request("/tools/get_paragraph_style_live", method="POST",
+ data={"paragraph_n": 1})
+
+ if result.get("success"):
+ style_name = result.get("style_name", "unknown")
+ print(f" ✓ Paragraph {result.get('paragraph')} has style: '{style_name}'")
+ else:
+ print(f" ⚠ Error: {result.get('error', 'unknown')}")
+
+ return True
+
+
+def test_apply_paragraph_style_invalid_name():
+ """Test applying an invalid style name returns a helpful error"""
+ print("Testing format_paragraph_live with invalid style name...")
+
+ result = make_request("/tools/format_paragraph_live", method="POST",
+ data={"style_name": "NonExistentStyleXYZ"})
+
+ if not result.get("success"):
+ error_msg = result.get("error", "")
+ if "not found" in error_msg.lower():
+ print(f" ✓ Got expected error for invalid style name")
+ else:
+ print(f" ⚠ Error but unexpected message: {error_msg}")
+ else:
+ print(f" ⚠ Expected failure but got success: {result}")
+
+ return True
+
+
def run_all_tests():
"""Run all tests"""
print("=" * 60)
@@ -588,6 +702,12 @@ def run_all_tests():
test_reject_tracked_change,
test_accept_all_changes,
test_reject_all_changes,
+ # Paragraph Style Tools
+ test_list_paragraph_styles,
+ test_apply_paragraph_style_by_selection,
+ test_apply_paragraph_style_by_number,
+ test_get_paragraph_style,
+ test_apply_paragraph_style_invalid_name,
]
passed = 0