From 10f9a102bb42fc121c10ab6dbe74d322fd46bdc8 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 1 Apr 2026 01:25:22 +0000
Subject: [PATCH 1/2] feat: Specialized Writer layout tools for margins,
columns, headers, footers
- Researched UNO API and created `docs/uno-api/layout-api-reference.md`.
- Renamed `plugin/modules/writer/frames.py` to `layout.py`.
- Added tools `GetPageStyleProperties`, `SetPageStyleProperties`,
`GetHeaderFooterText`, `SetHeaderFooterText`, `GetPageColumns`,
`SetPageColumns`, `InsertPageBreak`.
- Wrote tests in `plugin/tests/uno/test_writer_layout.py` mimicking UNO objects.
Co-authored-by: KeithCu <662157+KeithCu@users.noreply.github.com>
---
docs/uno-api/layout-api-reference.md | 117 +++++
plugin/modules/writer/frames.py | 281 ----------
plugin/modules/writer/layout.py | 699 +++++++++++++++++++++++++
plugin/tests/uno/test_writer_layout.py | 172 ++++++
4 files changed, 988 insertions(+), 281 deletions(-)
create mode 100644 docs/uno-api/layout-api-reference.md
delete mode 100644 plugin/modules/writer/frames.py
create mode 100644 plugin/modules/writer/layout.py
create mode 100644 plugin/tests/uno/test_writer_layout.py
diff --git a/docs/uno-api/layout-api-reference.md b/docs/uno-api/layout-api-reference.md
new file mode 100644
index 0000000..cc1f46f
--- /dev/null
+++ b/docs/uno-api/layout-api-reference.md
@@ -0,0 +1,117 @@
+# LibreOffice Writer Layout API Reference
+
+This document outlines the LibreOffice UNO API components used for advanced document layout in Writer, covering page styles, headers, footers, margins, and columns.
+
+## Overview
+
+In LibreOffice Writer, document layout is primarily controlled through **Page Styles** (`com.sun.star.style.PageStyle`). Unlike simple character or paragraph properties, page layout properties are applied to specific styles, which are then applied to the pages in the document.
+
+The document's style families can be accessed via `doc.getStyleFamilies()`. The family for page styles is `"PageStyles"`.
+
+## 1. Page Styles and Dimensions (`com.sun.star.style.PageStyle`)
+
+### Services and Interfaces
+- **Service:** `com.sun.star.style.PageStyle`
+- **Interfaces:** `com.sun.star.beans.XPropertySet`
+
+### Key Properties (Dimensions in 1/100th mm)
+- **`Width`** (`int`): The width of the page.
+- **`Height`** (`int`): The height of the page.
+- **`IsLandscape`** (`bool`): Indicates if the page orientation is landscape.
+- **`LeftMargin`**, **`RightMargin`**, **`TopMargin`**, **`BottomMargin`** (`int`): Page margins.
+
+### Python Example: Retrieving and Modifying Page Dimensions
+```python
+style_families = doc.getStyleFamilies()
+page_styles = style_families.getByName("PageStyles")
+default_style = page_styles.getByName("Standard") # or "Default Style" depending on locale/version
+
+# Get properties
+width = default_style.getPropertyValue("Width")
+left_margin = default_style.getPropertyValue("LeftMargin")
+
+# Set to Landscape A4
+default_style.setPropertyValue("IsLandscape", True)
+default_style.setPropertyValue("Width", 29700) # 297mm
+default_style.setPropertyValue("Height", 21000) # 210mm
+```
+
+## 2. Headers and Footers
+
+Headers and footers are also controlled via the Page Style properties. Each page style has separate properties for turning headers/footers on and accessing their text objects.
+
+### Key Properties
+- **`HeaderIsOn`** / **`FooterIsOn`** (`bool`): Enables or disables the header/footer.
+- **`HeaderIsShared`** / **`FooterIsShared`** (`bool`): If true, the same header/footer is used for left and right pages.
+- **`HeaderText`** / **`FooterText`** (`com.sun.star.text.XText`): The text object representing the header/footer content. This is a full text object, just like the main document body.
+- **`HeaderLeftText`** / **`HeaderRightText`** / **`FooterLeftText`** / **`FooterRightText`**: Used when left and right pages have different headers/footers (i.e., when `HeaderIsShared` is False).
+
+### Python Example: Enabling and Writing to a Header
+```python
+# Enable header
+default_style.setPropertyValue("HeaderIsOn", True)
+
+# Get the text object
+header_text = default_style.getPropertyValue("HeaderText")
+
+# Clear existing content and insert new text
+header_text.setString("My Document Header")
+```
+
+## 3. Columns (`com.sun.star.text.TextColumns`)
+
+Columns can be applied to Page Styles, Sections, or Text Frames. They are managed using the `com.sun.star.text.TextColumns` service.
+
+### Services and Interfaces
+- **Service:** `com.sun.star.text.TextColumns`
+- **Struct:** `com.sun.star.text.TextColumn`
+- **Interface:** `com.sun.star.text.XTextColumns`
+
+### Key Properties & Methods
+- `XTextColumns.getColumnCount()`: Returns the number of columns.
+- `XTextColumns.setColumnCount(short)`: Sets the number of columns.
+- `XTextColumns.getColumns()`: Returns a tuple of `TextColumn` structs.
+- `XTextColumns.setColumns(tuple)`: Applies the configuration defined by a tuple of `TextColumn` structs.
+
+### The `TextColumn` Struct
+Each column configuration is represented by a `TextColumn` struct:
+- **`Width`** (`int`): Relative width of the column.
+- **`LeftMargin`** (`int`): Spacing to the left of the column (1/100th mm).
+- **`RightMargin`** (`int`): Spacing to the right of the column (1/100th mm).
+
+### Python Example: Setting 2 Columns on a Page Style
+```python
+# Get current columns object
+columns = default_style.getPropertyValue("TextColumns")
+
+# Set to 2 columns
+columns.setColumnCount(2)
+
+# To add spacing (e.g., 5mm) between columns:
+cols = list(columns.getColumns())
+# First column right margin
+cols[0].RightMargin = 250 # 2.5mm
+# Second column left margin
+cols[1].LeftMargin = 250 # 2.5mm
+
+# Apply back
+columns.setColumns(tuple(cols))
+default_style.setPropertyValue("TextColumns", columns)
+```
+
+## 4. Paragraph and Page Breaks
+
+While not strictly layout, forcing content onto a new page is often part of layout manipulation.
+
+### Key Property
+- **`BreakType`** (`com.sun.star.style.BreakType` enum): Applied to a paragraph to force a break before or after.
+ - `PAGE_BEFORE`, `PAGE_AFTER`, `COLUMN_BEFORE`, `COLUMN_AFTER`, `NONE`.
+
+### Python Example: Forcing a Page Break Before a Paragraph
+```python
+from com.sun.star.style.BreakType import PAGE_BEFORE
+
+cursor = doc.getText().createTextCursor()
+# ... move cursor to desired paragraph ...
+cursor.setPropertyValue("BreakType", PAGE_BEFORE)
+```
diff --git a/plugin/modules/writer/frames.py b/plugin/modules/writer/frames.py
deleted file mode 100644
index b5856d4..0000000
--- a/plugin/modules/writer/frames.py
+++ /dev/null
@@ -1,281 +0,0 @@
-# WriterAgent - AI Writing Assistant for LibreOffice
-# Copyright (c) 2024 John Balis
-# Copyright (c) 2026 KeithCu (modifications and relicensing)
-#
-# 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 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-"""Writer text frame management tools (layout domain, specialized tier).
-
-Frame tool logic adapted from nelson-mcp (MPL 2.0):
-nelson-mcp/plugin/modules/writer/tools/frames.py
-"""
-
-import logging
-
-from plugin.modules.writer.base import ToolWriterLayoutBase
-
-log = logging.getLogger("writeragent.writer")
-
-
-class ListTextFrames(ToolWriterLayoutBase):
- """List all text frames in the document."""
-
- name = "list_text_frames"
- description = "List all text frames in the document."
- parameters = {
- "type": "object",
- "properties": {},
- "required": [],
- }
-
- def execute(self, ctx, **kwargs):
- doc = ctx.doc
- text_frames = self.get_collection(doc, "getTextFrames", "Document does not support text frames.")
- if isinstance(text_frames, dict):
- return text_frames
-
- frames = []
- for name in text_frames.getElementNames():
- try:
- frame = text_frames.getByName(name)
- size = frame.getPropertyValue("Size")
-
- # Text content preview (first 200 chars)
- content_preview = ""
- try:
- frame_text = frame.getText()
- cursor = frame_text.createTextCursor()
- cursor.gotoStart(False)
- cursor.gotoEnd(True)
- full_text = cursor.getString()
- if len(full_text) > 200:
- content_preview = full_text[:200] + "..."
- else:
- content_preview = full_text
- except Exception:
- pass
-
- frames.append({
- "name": name,
- "width_mm": size.Width / 100.0,
- "height_mm": size.Height / 100.0,
- "width_100mm": size.Width,
- "height_100mm": size.Height,
- "content_preview": content_preview,
- })
- except Exception as e:
- log.debug("list_text_frames: skip '%s': %s", name, e)
-
- return {"status": "ok", "frames": frames, "count": len(frames)}
-
-
-# ------------------------------------------------------------------
-# GetTextFrameInfo
-# ------------------------------------------------------------------
-
-class GetTextFrameInfo(ToolWriterLayoutBase):
- """Get detailed info about a text frame."""
-
- name = "get_text_frame_info"
- description = "Get detailed info about a text frame."
- parameters = {
- "type": "object",
- "properties": {
- "frame_name": {
- "type": "string",
- "description": "Name of the text frame (from list_text_frames).",
- },
- },
- "required": ["frame_name"],
- }
-
- def execute(self, ctx, **kwargs):
- frame_name = kwargs.get("frame_name", "")
- if not frame_name:
- return self._tool_error("frame_name is required.")
-
- frame = self.get_item(
- ctx.doc, "getTextFrames", frame_name,
- missing_msg="Document does not support text frames.",
- not_found_msg="Text frame '%s' not found." % frame_name
- )
- if isinstance(frame, dict):
- return frame
-
- size = frame.getPropertyValue("Size")
-
- # Anchor type
- anchor_type = None
- try:
- anchor_type = int(frame.getPropertyValue("AnchorType").value)
- except Exception:
- try:
- anchor_type = int(frame.getPropertyValue("AnchorType"))
- except Exception:
- pass
-
- # Orientation
- hori_orient = None
- vert_orient = None
- try:
- hori_orient = int(frame.getPropertyValue("HoriOrient"))
- except Exception:
- pass
- try:
- vert_orient = int(frame.getPropertyValue("VertOrient"))
- except Exception:
- pass
-
- # Full text content
- content = ""
- try:
- frame_text = frame.getText()
- cursor = frame_text.createTextCursor()
- cursor.gotoStart(False)
- cursor.gotoEnd(True)
- content = cursor.getString()
- except Exception:
- pass
-
- # Paragraph index via anchor
- paragraph_index = -1
- try:
- anchor = frame.getAnchor()
- doc_svc = ctx.services.document
- para_ranges = doc_svc.get_paragraph_ranges(ctx.doc)
- text_obj = ctx.doc.getText()
- paragraph_index = doc_svc.find_paragraph_for_range(
- anchor, para_ranges, text_obj
- )
- except Exception:
- pass
-
- return {
- "status": "ok",
- "frame_name": frame_name,
- "width_mm": size.Width / 100.0,
- "height_mm": size.Height / 100.0,
- "width_100mm": size.Width,
- "height_100mm": size.Height,
- "anchor_type": anchor_type,
- "hori_orient": hori_orient,
- "vert_orient": vert_orient,
- "content": content,
- "paragraph_index": paragraph_index,
- }
-
-
-# ------------------------------------------------------------------
-# SetTextFrameProperties
-# ------------------------------------------------------------------
-
-class SetTextFrameProperties(ToolWriterLayoutBase):
- """Resize or reposition a text frame."""
-
- name = "set_text_frame_properties"
- description = "Resize or reposition a text frame."
- parameters = {
- "type": "object",
- "properties": {
- "frame_name": {
- "type": "string",
- "description": "Name of the text frame (from list_text_frames).",
- },
- "width_mm": {
- "type": "number",
- "description": "New width in millimetres.",
- },
- "height_mm": {
- "type": "number",
- "description": "New height in millimetres.",
- },
- "anchor_type": {
- "type": "integer",
- "description": (
- "Anchor type: 0=AT_PARAGRAPH, 1=AS_CHARACTER, "
- "2=AT_PAGE, 3=AT_FRAME, 4=AT_CHARACTER."
- ),
- },
- "hori_orient": {
- "type": "integer",
- "description": "Horizontal orientation constant.",
- },
- "vert_orient": {
- "type": "integer",
- "description": "Vertical orientation constant.",
- },
- },
- "required": ["frame_name"],
- }
- is_mutation = True
-
- def execute(self, ctx, **kwargs):
- frame_name = kwargs.get("frame_name", "")
- if not frame_name:
- return self._tool_error("frame_name is required.")
-
- frame = self.get_item(
- ctx.doc, "getTextFrames", frame_name,
- missing_msg="Document does not support text frames.",
- not_found_msg="Text frame '%s' not found." % frame_name
- )
- if isinstance(frame, dict):
- return frame
-
- updated = []
-
- # Size
- width_mm = kwargs.get("width_mm")
- height_mm = kwargs.get("height_mm")
- if width_mm is not None or height_mm is not None:
- from com.sun.star.awt import Size
- current = frame.getPropertyValue("Size")
- new_size = Size()
- new_size.Width = int(width_mm * 100) if width_mm is not None else current.Width
- new_size.Height = int(height_mm * 100) if height_mm is not None else current.Height
- frame.setPropertyValue("Size", new_size)
- updated.append("size")
-
- # Anchor type
- anchor_type = kwargs.get("anchor_type")
- if anchor_type is not None:
- from com.sun.star.text.TextContentAnchorType import (
- AT_PARAGRAPH, AS_CHARACTER, AT_PAGE, AT_FRAME, AT_CHARACTER,
- )
- anchor_map = {
- 0: AT_PARAGRAPH,
- 1: AS_CHARACTER,
- 2: AT_PAGE,
- 3: AT_FRAME,
- 4: AT_CHARACTER,
- }
- if anchor_type in anchor_map:
- frame.setPropertyValue("AnchorType", anchor_map[anchor_type])
- updated.append("anchor_type")
-
- # Orientation
- hori_orient = kwargs.get("hori_orient")
- if hori_orient is not None:
- frame.setPropertyValue("HoriOrient", hori_orient)
- updated.append("hori_orient")
-
- vert_orient = kwargs.get("vert_orient")
- if vert_orient is not None:
- frame.setPropertyValue("VertOrient", vert_orient)
- updated.append("vert_orient")
-
- return {
- "status": "ok",
- "frame_name": frame_name,
- "updated": updated,
- }
diff --git a/plugin/modules/writer/layout.py b/plugin/modules/writer/layout.py
new file mode 100644
index 0000000..0d3ce68
--- /dev/null
+++ b/plugin/modules/writer/layout.py
@@ -0,0 +1,699 @@
+# WriterAgent - AI Writing Assistant for LibreOffice
+# Copyright (c) 2024 John Balis
+# Copyright (c) 2026 KeithCu (modifications and relicensing)
+#
+# 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 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+"""Writer text frame management tools (layout domain, specialized tier).
+
+Frame tool logic adapted from nelson-mcp (MPL 2.0):
+nelson-mcp/plugin/modules/writer/tools/frames.py
+"""
+
+import logging
+
+from plugin.modules.writer.base import ToolWriterLayoutBase
+
+log = logging.getLogger("writeragent.writer")
+
+
+class ListTextFrames(ToolWriterLayoutBase):
+ """List all text frames in the document."""
+
+ name = "list_text_frames"
+ description = "List all text frames in the document."
+ parameters = {
+ "type": "object",
+ "properties": {},
+ "required": [],
+ }
+
+ def execute(self, ctx, **kwargs):
+ doc = ctx.doc
+ text_frames = self.get_collection(doc, "getTextFrames", "Document does not support text frames.")
+ if isinstance(text_frames, dict):
+ return text_frames
+
+ frames = []
+ for name in text_frames.getElementNames():
+ try:
+ frame = text_frames.getByName(name)
+ size = frame.getPropertyValue("Size")
+
+ # Text content preview (first 200 chars)
+ content_preview = ""
+ try:
+ frame_text = frame.getText()
+ cursor = frame_text.createTextCursor()
+ cursor.gotoStart(False)
+ cursor.gotoEnd(True)
+ full_text = cursor.getString()
+ if len(full_text) > 200:
+ content_preview = full_text[:200] + "..."
+ else:
+ content_preview = full_text
+ except Exception:
+ pass
+
+ frames.append({
+ "name": name,
+ "width_mm": size.Width / 100.0,
+ "height_mm": size.Height / 100.0,
+ "width_100mm": size.Width,
+ "height_100mm": size.Height,
+ "content_preview": content_preview,
+ })
+ except Exception as e:
+ log.debug("list_text_frames: skip '%s': %s", name, e)
+
+ return {"status": "ok", "frames": frames, "count": len(frames)}
+
+
+# ------------------------------------------------------------------
+# GetTextFrameInfo
+# ------------------------------------------------------------------
+
+class GetTextFrameInfo(ToolWriterLayoutBase):
+ """Get detailed info about a text frame."""
+
+ name = "get_text_frame_info"
+ description = "Get detailed info about a text frame."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "frame_name": {
+ "type": "string",
+ "description": "Name of the text frame (from list_text_frames).",
+ },
+ },
+ "required": ["frame_name"],
+ }
+
+ def execute(self, ctx, **kwargs):
+ frame_name = kwargs.get("frame_name", "")
+ if not frame_name:
+ return self._tool_error("frame_name is required.")
+
+ frame = self.get_item(
+ ctx.doc, "getTextFrames", frame_name,
+ missing_msg="Document does not support text frames.",
+ not_found_msg="Text frame '%s' not found." % frame_name
+ )
+ if isinstance(frame, dict):
+ return frame
+
+ size = frame.getPropertyValue("Size")
+
+ # Anchor type
+ anchor_type = None
+ try:
+ anchor_type = int(frame.getPropertyValue("AnchorType").value)
+ except Exception:
+ try:
+ anchor_type = int(frame.getPropertyValue("AnchorType"))
+ except Exception:
+ pass
+
+ # Orientation
+ hori_orient = None
+ vert_orient = None
+ try:
+ hori_orient = int(frame.getPropertyValue("HoriOrient"))
+ except Exception:
+ pass
+ try:
+ vert_orient = int(frame.getPropertyValue("VertOrient"))
+ except Exception:
+ pass
+
+ # Full text content
+ content = ""
+ try:
+ frame_text = frame.getText()
+ cursor = frame_text.createTextCursor()
+ cursor.gotoStart(False)
+ cursor.gotoEnd(True)
+ content = cursor.getString()
+ except Exception:
+ pass
+
+ # Paragraph index via anchor
+ paragraph_index = -1
+ try:
+ anchor = frame.getAnchor()
+ doc_svc = ctx.services.document
+ para_ranges = doc_svc.get_paragraph_ranges(ctx.doc)
+ text_obj = ctx.doc.getText()
+ paragraph_index = doc_svc.find_paragraph_for_range(
+ anchor, para_ranges, text_obj
+ )
+ except Exception:
+ pass
+
+ return {
+ "status": "ok",
+ "frame_name": frame_name,
+ "width_mm": size.Width / 100.0,
+ "height_mm": size.Height / 100.0,
+ "width_100mm": size.Width,
+ "height_100mm": size.Height,
+ "anchor_type": anchor_type,
+ "hori_orient": hori_orient,
+ "vert_orient": vert_orient,
+ "content": content,
+ "paragraph_index": paragraph_index,
+ }
+
+
+# ------------------------------------------------------------------
+# SetTextFrameProperties
+# ------------------------------------------------------------------
+
+class SetTextFrameProperties(ToolWriterLayoutBase):
+ """Resize or reposition a text frame."""
+
+ name = "set_text_frame_properties"
+ description = "Resize or reposition a text frame."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "frame_name": {
+ "type": "string",
+ "description": "Name of the text frame (from list_text_frames).",
+ },
+ "width_mm": {
+ "type": "number",
+ "description": "New width in millimetres.",
+ },
+ "height_mm": {
+ "type": "number",
+ "description": "New height in millimetres.",
+ },
+ "anchor_type": {
+ "type": "integer",
+ "description": (
+ "Anchor type: 0=AT_PARAGRAPH, 1=AS_CHARACTER, "
+ "2=AT_PAGE, 3=AT_FRAME, 4=AT_CHARACTER."
+ ),
+ },
+ "hori_orient": {
+ "type": "integer",
+ "description": "Horizontal orientation constant.",
+ },
+ "vert_orient": {
+ "type": "integer",
+ "description": "Vertical orientation constant.",
+ },
+ },
+ "required": ["frame_name"],
+ }
+ is_mutation = True
+
+ def execute(self, ctx, **kwargs):
+ frame_name = kwargs.get("frame_name", "")
+ if not frame_name:
+ return self._tool_error("frame_name is required.")
+
+ frame = self.get_item(
+ ctx.doc, "getTextFrames", frame_name,
+ missing_msg="Document does not support text frames.",
+ not_found_msg="Text frame '%s' not found." % frame_name
+ )
+ if isinstance(frame, dict):
+ return frame
+
+ updated = []
+
+ # Size
+ width_mm = kwargs.get("width_mm")
+ height_mm = kwargs.get("height_mm")
+ if width_mm is not None or height_mm is not None:
+ from com.sun.star.awt import Size
+ current = frame.getPropertyValue("Size")
+ new_size = Size()
+ new_size.Width = int(width_mm * 100) if width_mm is not None else current.Width
+ new_size.Height = int(height_mm * 100) if height_mm is not None else current.Height
+ frame.setPropertyValue("Size", new_size)
+ updated.append("size")
+
+ # Anchor type
+ anchor_type = kwargs.get("anchor_type")
+ if anchor_type is not None:
+ from com.sun.star.text.TextContentAnchorType import (
+ AT_PARAGRAPH, AS_CHARACTER, AT_PAGE, AT_FRAME, AT_CHARACTER,
+ )
+ anchor_map = {
+ 0: AT_PARAGRAPH,
+ 1: AS_CHARACTER,
+ 2: AT_PAGE,
+ 3: AT_FRAME,
+ 4: AT_CHARACTER,
+ }
+ if anchor_type in anchor_map:
+ frame.setPropertyValue("AnchorType", anchor_map[anchor_type])
+ updated.append("anchor_type")
+
+ # Orientation
+ hori_orient = kwargs.get("hori_orient")
+ if hori_orient is not None:
+ frame.setPropertyValue("HoriOrient", hori_orient)
+ updated.append("hori_orient")
+
+ vert_orient = kwargs.get("vert_orient")
+ if vert_orient is not None:
+ frame.setPropertyValue("VertOrient", vert_orient)
+ updated.append("vert_orient")
+
+ return {
+ "status": "ok",
+ "frame_name": frame_name,
+ "updated": updated,
+ }
+
+# ------------------------------------------------------------------
+# GetPageStyleProperties
+# ------------------------------------------------------------------
+
+class GetPageStyleProperties(ToolWriterLayoutBase):
+ """Get dimensions, margins, and header/footer states of a page style."""
+
+ name = "get_page_style_properties"
+ description = "Get dimensions, margins, and header/footer states of a page style."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style (e.g., 'Standard' or 'Default Style'). Defaults to 'Standard'.",
+ },
+ },
+ "required": [],
+ }
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ try:
+ props = {
+ "style_name": style_name,
+ "width_mm": style.getPropertyValue("Width") / 100.0,
+ "height_mm": style.getPropertyValue("Height") / 100.0,
+ "is_landscape": style.getPropertyValue("IsLandscape"),
+ "left_margin_mm": style.getPropertyValue("LeftMargin") / 100.0,
+ "right_margin_mm": style.getPropertyValue("RightMargin") / 100.0,
+ "top_margin_mm": style.getPropertyValue("TopMargin") / 100.0,
+ "bottom_margin_mm": style.getPropertyValue("BottomMargin") / 100.0,
+ "header_is_on": style.getPropertyValue("HeaderIsOn"),
+ "footer_is_on": style.getPropertyValue("FooterIsOn"),
+ }
+ return {"status": "ok", "properties": props}
+ except Exception as e:
+ return self._tool_error(f"Error reading properties from page style '{style_name}': {e}")
+
+
+# ------------------------------------------------------------------
+# SetPageStyleProperties
+# ------------------------------------------------------------------
+
+class SetPageStyleProperties(ToolWriterLayoutBase):
+ """Modify dimensions, margins, and header/footer toggles of a page style."""
+
+ name = "set_page_style_properties"
+ description = "Modify dimensions, margins, and header/footer toggles of a page style."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style (e.g., 'Standard' or 'Default Style'). Defaults to 'Standard'.",
+ },
+ "width_mm": {"type": "number", "description": "New width in mm."},
+ "height_mm": {"type": "number", "description": "New height in mm."},
+ "is_landscape": {"type": "boolean", "description": "Set orientation to landscape."},
+ "left_margin_mm": {"type": "number", "description": "Left margin in mm."},
+ "right_margin_mm": {"type": "number", "description": "Right margin in mm."},
+ "top_margin_mm": {"type": "number", "description": "Top margin in mm."},
+ "bottom_margin_mm": {"type": "number", "description": "Bottom margin in mm."},
+ "header_is_on": {"type": "boolean", "description": "Enable or disable header."},
+ "footer_is_on": {"type": "boolean", "description": "Enable or disable footer."},
+ },
+ "required": [],
+ }
+ is_mutation = True
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ updated = []
+ try:
+ if "width_mm" in kwargs:
+ style.setPropertyValue("Width", int(kwargs["width_mm"] * 100))
+ updated.append("width")
+ if "height_mm" in kwargs:
+ style.setPropertyValue("Height", int(kwargs["height_mm"] * 100))
+ updated.append("height")
+ if "is_landscape" in kwargs:
+ style.setPropertyValue("IsLandscape", kwargs["is_landscape"])
+ updated.append("is_landscape")
+ if "left_margin_mm" in kwargs:
+ style.setPropertyValue("LeftMargin", int(kwargs["left_margin_mm"] * 100))
+ updated.append("left_margin")
+ if "right_margin_mm" in kwargs:
+ style.setPropertyValue("RightMargin", int(kwargs["right_margin_mm"] * 100))
+ updated.append("right_margin")
+ if "top_margin_mm" in kwargs:
+ style.setPropertyValue("TopMargin", int(kwargs["top_margin_mm"] * 100))
+ updated.append("top_margin")
+ if "bottom_margin_mm" in kwargs:
+ style.setPropertyValue("BottomMargin", int(kwargs["bottom_margin_mm"] * 100))
+ updated.append("bottom_margin")
+ if "header_is_on" in kwargs:
+ style.setPropertyValue("HeaderIsOn", kwargs["header_is_on"])
+ updated.append("header_is_on")
+ if "footer_is_on" in kwargs:
+ style.setPropertyValue("FooterIsOn", kwargs["footer_is_on"])
+ updated.append("footer_is_on")
+ except Exception as e:
+ return self._tool_error(f"Error setting properties on page style '{style_name}': {e}")
+
+ return {"status": "ok", "style_name": style_name, "updated": updated}
+
+
+# ------------------------------------------------------------------
+# GetHeaderFooterText
+# ------------------------------------------------------------------
+
+class GetHeaderFooterText(ToolWriterLayoutBase):
+ """Retrieve the text content of a page style's header or footer."""
+
+ name = "get_header_footer_text"
+ description = "Retrieve the text content of a page style's header or footer."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style (e.g., 'Standard' or 'Default Style'). Defaults to 'Standard'.",
+ },
+ "region": {
+ "type": "string",
+ "enum": ["header", "footer"],
+ "description": "Whether to get the header or footer text.",
+ },
+ },
+ "required": ["region"],
+ }
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ region = kwargs.get("region")
+ if not region:
+ return self._tool_error("region is required ('header' or 'footer').")
+
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ try:
+ if region == "header":
+ if not style.getPropertyValue("HeaderIsOn"):
+ return {"status": "ok", "style_name": style_name, "region": region, "content": "", "is_on": False}
+ text_obj = style.getPropertyValue("HeaderText")
+ else:
+ if not style.getPropertyValue("FooterIsOn"):
+ return {"status": "ok", "style_name": style_name, "region": region, "content": "", "is_on": False}
+ text_obj = style.getPropertyValue("FooterText")
+
+ content = text_obj.getString() if text_obj else ""
+ return {"status": "ok", "style_name": style_name, "region": region, "content": content, "is_on": True}
+ except Exception as e:
+ return self._tool_error(f"Error reading {region} text from page style '{style_name}': {e}")
+
+
+# ------------------------------------------------------------------
+# SetHeaderFooterText
+# ------------------------------------------------------------------
+
+class SetHeaderFooterText(ToolWriterLayoutBase):
+ """Set the text content of a page style's header or footer."""
+
+ name = "set_header_footer_text"
+ description = "Set the text content of a page style's header or footer. Automatically enables the header/footer if not already on."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style (e.g., 'Standard' or 'Default Style'). Defaults to 'Standard'.",
+ },
+ "region": {
+ "type": "string",
+ "enum": ["header", "footer"],
+ "description": "Whether to set the header or footer text.",
+ },
+ "content": {
+ "type": "string",
+ "description": "The text to insert into the header or footer.",
+ },
+ },
+ "required": ["region", "content"],
+ }
+ is_mutation = True
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ region = kwargs.get("region")
+ content = kwargs.get("content", "")
+
+ if not region:
+ return self._tool_error("region is required ('header' or 'footer').")
+
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ try:
+ if region == "header":
+ style.setPropertyValue("HeaderIsOn", True)
+ text_obj = style.getPropertyValue("HeaderText")
+ else:
+ style.setPropertyValue("FooterIsOn", True)
+ text_obj = style.getPropertyValue("FooterText")
+
+ if text_obj:
+ text_obj.setString(content)
+ return {"status": "ok", "style_name": style_name, "region": region, "updated": True}
+ else:
+ return self._tool_error(f"Could not retrieve text object for {region} on style '{style_name}'.")
+ except Exception as e:
+ return self._tool_error(f"Error writing to {region} text on page style '{style_name}': {e}")
+
+
+# ------------------------------------------------------------------
+# GetPageColumns
+# ------------------------------------------------------------------
+
+class GetPageColumns(ToolWriterLayoutBase):
+ """Get the column layout for a page style."""
+
+ name = "get_page_columns"
+ description = "Get the column layout for a page style."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style. Defaults to 'Standard'.",
+ },
+ },
+ "required": [],
+ }
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ try:
+ text_columns = style.getPropertyValue("TextColumns")
+ if not text_columns:
+ return self._tool_error(f"TextColumns property not found on style '{style_name}'.")
+
+ column_count = text_columns.getColumnCount()
+ cols = text_columns.getColumns()
+
+ columns_data = []
+ for col in cols:
+ columns_data.append({
+ "width": col.Width,
+ "left_margin_mm": col.LeftMargin / 100.0,
+ "right_margin_mm": col.RightMargin / 100.0,
+ })
+
+ return {"status": "ok", "style_name": style_name, "column_count": column_count, "columns": columns_data}
+ except Exception as e:
+ return self._tool_error(f"Error reading columns from page style '{style_name}': {e}")
+
+
+# ------------------------------------------------------------------
+# SetPageColumns
+# ------------------------------------------------------------------
+
+class SetPageColumns(ToolWriterLayoutBase):
+ """Set the number of columns and spacing for a page style."""
+
+ name = "set_page_columns"
+ description = "Set the number of columns and spacing for a page style."
+ parameters = {
+ "type": "object",
+ "properties": {
+ "style_name": {
+ "type": "string",
+ "description": "The name of the page style. Defaults to 'Standard'.",
+ },
+ "column_count": {
+ "type": "integer",
+ "description": "Number of columns (e.g., 2).",
+ },
+ "spacing_mm": {
+ "type": "number",
+ "description": "Spacing between columns in mm. Defaults to 0.",
+ },
+ },
+ "required": ["column_count"],
+ }
+ is_mutation = True
+
+ def execute(self, ctx, **kwargs):
+ style_name = kwargs.get("style_name", "Standard")
+ column_count = kwargs.get("column_count")
+ spacing_mm = kwargs.get("spacing_mm", 0)
+
+ if column_count is None or column_count < 1:
+ return self._tool_error("column_count must be at least 1.")
+
+ doc = ctx.doc
+
+ try:
+ style_families = doc.getStyleFamilies()
+ page_styles = style_families.getByName("PageStyles")
+ if not page_styles.hasByName(style_name):
+ return self._tool_error(f"Page style '{style_name}' not found.")
+ style = page_styles.getByName(style_name)
+ except Exception as e:
+ return self._tool_error(f"Error accessing page style '{style_name}': {e}")
+
+ try:
+ text_columns = style.getPropertyValue("TextColumns")
+ if not text_columns:
+ return self._tool_error(f"TextColumns property not found on style '{style_name}'.")
+
+ text_columns.setColumnCount(column_count)
+ cols = list(text_columns.getColumns())
+
+ spacing = int(spacing_mm * 100)
+
+ # Divide spacing between adjacent columns
+ # Column 1 right margin gets half spacing, Column 2 left margin gets half, etc.
+ if column_count > 1 and spacing > 0:
+ half_spacing = spacing // 2
+ for i in range(column_count - 1):
+ cols[i].RightMargin = half_spacing
+ cols[i + 1].LeftMargin = half_spacing
+
+ text_columns.setColumns(tuple(cols))
+ style.setPropertyValue("TextColumns", text_columns)
+
+ return {"status": "ok", "style_name": style_name, "column_count": column_count, "spacing_mm": spacing_mm}
+ except Exception as e:
+ return self._tool_error(f"Error setting columns on page style '{style_name}': {e}")
+
+
+# ------------------------------------------------------------------
+# InsertPageBreak
+# ------------------------------------------------------------------
+
+class InsertPageBreak(ToolWriterLayoutBase):
+ """Insert a page break at the current cursor position."""
+
+ name = "insert_page_break"
+ description = "Insert a page break at the current cursor position."
+ parameters = {
+ "type": "object",
+ "properties": {},
+ "required": [],
+ }
+ is_mutation = True
+
+ def execute(self, ctx, **kwargs):
+ doc = ctx.doc
+
+ try:
+ view_cursor = doc.getCurrentController().getViewCursor()
+ if not view_cursor:
+ return self._tool_error("Could not obtain view cursor.")
+
+ text = view_cursor.getText()
+ cursor = text.createTextCursorByRange(view_cursor)
+
+ from com.sun.star.style.BreakType import PAGE_BEFORE
+ cursor.setPropertyValue("BreakType", PAGE_BEFORE)
+
+ # Optionally insert a paragraph break so the break actually applies cleanly
+ text.insertControlCharacter(cursor, 0, False) # 0 = PARAGRAPH_BREAK
+
+ return {"status": "ok", "message": "Page break inserted."}
+ except Exception as e:
+ return self._tool_error(f"Error inserting page break: {e}")
diff --git a/plugin/tests/uno/test_writer_layout.py b/plugin/tests/uno/test_writer_layout.py
new file mode 100644
index 0000000..a64b8fd
--- /dev/null
+++ b/plugin/tests/uno/test_writer_layout.py
@@ -0,0 +1,172 @@
+import pytest
+from typing import Any
+from unittest.mock import MagicMock
+
+# Mock UNO imports required for testing layout tools outside of LibreOffice environment
+import sys
+import types
+sys.modules['uno'] = MagicMock()
+sys.modules['unohelper'] = MagicMock()
+sys.modules['com'] = MagicMock()
+
+com_sun_star = types.ModuleType('com.sun.star')
+sys.modules['com.sun.star'] = com_sun_star
+com_sun_star_text = types.ModuleType('com.sun.star.text')
+sys.modules['com.sun.star.text'] = com_sun_star_text
+com_sun_star_style = types.ModuleType('com.sun.star.style')
+sys.modules['com.sun.star.style'] = com_sun_star_style
+
+# Mock the BreakType module itself
+com_sun_star_style_breaktype = types.ModuleType('com.sun.star.style.BreakType')
+sys.modules['com.sun.star.style.BreakType'] = com_sun_star_style_breaktype
+setattr(com_sun_star_style_breaktype, "PAGE_BEFORE", 4)
+
+
+from plugin.modules.writer.layout import (
+ GetPageStyleProperties,
+ SetPageStyleProperties,
+ GetHeaderFooterText,
+ SetHeaderFooterText,
+ GetPageColumns,
+ SetPageColumns,
+ InsertPageBreak
+)
+
+class MockToolContext:
+ def __init__(self, doc):
+ self.doc = doc
+ self.services = MagicMock()
+
+def test_get_page_style_properties():
+ # Setup mock doc
+ doc = MagicMock()
+ families = MagicMock()
+ page_styles = MagicMock()
+ style = MagicMock()
+
+ doc.getStyleFamilies.return_value = families
+ families.getByName.return_value = page_styles
+ page_styles.hasByName.return_value = True
+ page_styles.getByName.return_value = style
+
+ def get_prop(name):
+ props = {
+ "Width": 21000,
+ "Height": 29700,
+ "IsLandscape": False,
+ "LeftMargin": 2000,
+ "RightMargin": 2000,
+ "TopMargin": 2000,
+ "BottomMargin": 2000,
+ "HeaderIsOn": True,
+ "FooterIsOn": False
+ }
+ return props[name]
+ style.getPropertyValue.side_effect = get_prop
+
+ ctx = MockToolContext(doc)
+ tool = GetPageStyleProperties()
+ res = tool.execute(ctx, style_name="Standard")
+
+ assert res["status"] == "ok"
+ assert res["properties"]["width_mm"] == 210.0
+ assert res["properties"]["height_mm"] == 297.0
+ assert res["properties"]["header_is_on"] is True
+ assert res["properties"]["footer_is_on"] is False
+
+def test_set_page_style_properties():
+ doc = MagicMock()
+ families = MagicMock()
+ page_styles = MagicMock()
+ style = MagicMock()
+
+ doc.getStyleFamilies.return_value = families
+ families.getByName.return_value = page_styles
+ page_styles.hasByName.return_value = True
+ page_styles.getByName.return_value = style
+
+ ctx = MockToolContext(doc)
+ tool = SetPageStyleProperties()
+ res = tool.execute(ctx, style_name="Standard", width_mm=300, is_landscape=True, header_is_on=False)
+
+ assert res["status"] == "ok"
+ assert "width" in res["updated"]
+ assert "is_landscape" in res["updated"]
+
+ style.setPropertyValue.assert_any_call("Width", 30000)
+ style.setPropertyValue.assert_any_call("IsLandscape", True)
+ style.setPropertyValue.assert_any_call("HeaderIsOn", False)
+
+def test_set_header_footer_text():
+ doc = MagicMock()
+ families = MagicMock()
+ page_styles = MagicMock()
+ style = MagicMock()
+
+ doc.getStyleFamilies.return_value = families
+ families.getByName.return_value = page_styles
+ page_styles.hasByName.return_value = True
+ page_styles.getByName.return_value = style
+
+ header_text_obj = MagicMock()
+ style.getPropertyValue.return_value = header_text_obj
+
+ ctx = MockToolContext(doc)
+ tool = SetHeaderFooterText()
+ res = tool.execute(ctx, style_name="Standard", region="header", content="My Header Content")
+
+ assert res["status"] == "ok"
+ assert res["region"] == "header"
+
+ style.setPropertyValue.assert_called_with("HeaderIsOn", True)
+ header_text_obj.setString.assert_called_with("My Header Content")
+
+def test_set_page_columns():
+ doc = MagicMock()
+ families = MagicMock()
+ page_styles = MagicMock()
+ style = MagicMock()
+ text_columns = MagicMock()
+
+ doc.getStyleFamilies.return_value = families
+ families.getByName.return_value = page_styles
+ page_styles.hasByName.return_value = True
+ page_styles.getByName.return_value = style
+ style.getPropertyValue.return_value = text_columns
+
+ col1 = MagicMock()
+ col2 = MagicMock()
+ text_columns.getColumns.return_value = (col1, col2)
+
+ ctx = MockToolContext(doc)
+ tool = SetPageColumns()
+ res = tool.execute(ctx, style_name="Standard", column_count=2, spacing_mm=5)
+
+ assert res["status"] == "ok"
+ text_columns.setColumnCount.assert_called_with(2)
+
+ # Check spacing
+ assert col1.RightMargin == 250
+ assert col2.LeftMargin == 250
+ text_columns.setColumns.assert_called_with((col1, col2))
+ style.setPropertyValue.assert_called_with("TextColumns", text_columns)
+
+def test_insert_page_break():
+ doc = MagicMock()
+ controller = MagicMock()
+ view_cursor = MagicMock()
+ text_obj = MagicMock()
+ text_cursor = MagicMock()
+
+ doc.getCurrentController.return_value = controller
+ controller.getViewCursor.return_value = view_cursor
+ view_cursor.getText.return_value = text_obj
+ text_obj.createTextCursorByRange.return_value = text_cursor
+
+ ctx = MockToolContext(doc)
+ tool = InsertPageBreak()
+ res = tool.execute(ctx)
+
+ assert res["status"] == "ok"
+ text_cursor.setPropertyValue.assert_called_with("BreakType", 4) # PAGE_BEFORE
+ text_obj.insertControlCharacter.assert_called_with(text_cursor, 0, False)
From 4d5cebe504e7614cd5c5d576eeeb40c14b2422ab Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 1 Apr 2026 20:49:01 +0000
Subject: [PATCH 2/2] feat: Specialized Writer layout tools for margins,
columns, headers, footers
- Researched UNO API and created `docs/uno-api/layout-api-reference.md`.
- Renamed `plugin/modules/writer/frames.py` to `layout.py`.
- Added tools `GetPageStyleProperties`, `SetPageStyleProperties`,
`GetHeaderFooterText`, `SetHeaderFooterText`, `GetPageColumns`,
`SetPageColumns`, `InsertPageBreak`.
- Wrote tests in `plugin/tests/uno/test_writer_layout.py` mimicking UNO objects.
Co-authored-by: KeithCu <662157+KeithCu@users.noreply.github.com>
---
docs/uno-api/layout-api-reference.md | 48 +++++++++++++++++
plugin/modules/writer/layout.py | 74 ++++++++++++++++++++++++++
plugin/tests/uno/test_writer_layout.py | 15 +++++-
3 files changed, 136 insertions(+), 1 deletion(-)
diff --git a/docs/uno-api/layout-api-reference.md b/docs/uno-api/layout-api-reference.md
index cc1f46f..2335b4f 100644
--- a/docs/uno-api/layout-api-reference.md
+++ b/docs/uno-api/layout-api-reference.md
@@ -115,3 +115,51 @@ cursor = doc.getText().createTextCursor()
# ... move cursor to desired paragraph ...
cursor.setPropertyValue("BreakType", PAGE_BEFORE)
```
+
+## 5. Comprehensive PageStyle Properties
+
+A complete dump of all available properties for `com.sun.star.style.PageStyle`. Most are `long` measurements in 1/100th mm, `boolean`, `string`, or specific enum/struct types.
+
+### Page Dimensions and Margins
+* `Width`, `Height` (long): Absolute page size.
+* `IsLandscape` (boolean): Page orientation.
+* `LeftMargin`, `RightMargin`, `TopMargin`, `BottomMargin` (long): Distance from page edge to content.
+* `GutterMargin` (long): Additional space for binding.
+
+### Background and Fill Properties
+Used to set the background color or image of the page itself.
+* `BackColor` (long), `BackTransparent` (boolean)
+* `FillStyle` (com.sun.star.drawing.FillStyle)
+* `FillColor`, `FillColor2` (long)
+* `FillBitmapURL`, `FillGradientName`, etc.
+
+### Header/Footer
+* `HeaderIsOn` / `FooterIsOn` (boolean): Master toggle.
+* `HeaderIsShared` / `FooterIsShared` (boolean): If left and right pages share the same header.
+* `HeaderHeight` / `FooterHeight` (long): Absolute height.
+* `HeaderBodyDistance` / `FooterBodyDistance` (long): Spacing between header/footer and main text.
+* `HeaderIsDynamicHeight` / `FooterIsDynamicHeight` (boolean): Auto-grow header/footer height.
+* `HeaderText`, `HeaderTextLeft`, `HeaderTextRight` (com.sun.star.text.XText): The text objects for headers.
+* Header/Footer backgrounds (`HeaderBackColor`, `HeaderFillStyle`, etc.) and borders (`HeaderLeftBorder`, etc.).
+
+### Borders
+* `TopBorder`, `BottomBorder`, `LeftBorder`, `RightBorder` (com.sun.star.table.BorderLine)
+* `BorderDistance` (long): Distance from text to borders.
+
+### Footnotes
+Properties defining how footnotes are displayed at the bottom of the page.
+* `FootnoteHeight` (long): Max height of footnote area.
+* `FootnoteLineWeight` (short), `FootnoteLineStyle` (byte), `FootnoteLineColor` (long)
+* `FootnoteLineRelativeWidth` (byte): Width as percentage of text area width.
+* `FootnoteLineDistance` (long): Distance from text area.
+* `FootnoteLineTextDistance` (long): Distance from separator line to footnote text.
+
+### Grid (Asian Typography)
+* `GridBaseHeight`, `GridBaseWidth` (long)
+* `GridDisplay`, `GridSnapToChars` (boolean)
+* `GridMode` (short)
+
+### Miscellaneous
+* `NumberingType` (short): Page numbering format (e.g., 4=Arabic, 0=Upper Roman, etc.).
+* `PageStyleLayout` (com.sun.star.style.PageStyleLayout enum): `ALL`, `LEFT`, `RIGHT`, `MIRRORED`.
+* `RegisterParagraphStyle` (string): Register-true (line up lines of text on both sides of a page).
diff --git a/plugin/modules/writer/layout.py b/plugin/modules/writer/layout.py
index 0d3ce68..8a96b3f 100644
--- a/plugin/modules/writer/layout.py
+++ b/plugin/modules/writer/layout.py
@@ -323,9 +323,27 @@ def execute(self, ctx, **kwargs):
"right_margin_mm": style.getPropertyValue("RightMargin") / 100.0,
"top_margin_mm": style.getPropertyValue("TopMargin") / 100.0,
"bottom_margin_mm": style.getPropertyValue("BottomMargin") / 100.0,
+ "gutter_margin_mm": style.getPropertyValue("GutterMargin") / 100.0,
"header_is_on": style.getPropertyValue("HeaderIsOn"),
"footer_is_on": style.getPropertyValue("FooterIsOn"),
+ "header_is_shared": style.getPropertyValue("HeaderIsShared"),
+ "footer_is_shared": style.getPropertyValue("FooterIsShared"),
+ "header_height_mm": style.getPropertyValue("HeaderHeight") / 100.0,
+ "footer_height_mm": style.getPropertyValue("FooterHeight") / 100.0,
+ "header_body_distance_mm": style.getPropertyValue("HeaderBodyDistance") / 100.0,
+ "footer_body_distance_mm": style.getPropertyValue("FooterBodyDistance") / 100.0,
+ "back_color": style.getPropertyValue("BackColor"),
+ "back_transparent": style.getPropertyValue("BackTransparent"),
+ "numbering_type": style.getPropertyValue("NumberingType"),
+ "footnote_height_mm": style.getPropertyValue("FootnoteHeight") / 100.0,
+ "register_paragraph_style": style.getPropertyValue("RegisterParagraphStyle"),
}
+ # Attempt to safely fetch PageStyleLayout enum
+ try:
+ psl = style.getPropertyValue("PageStyleLayout")
+ props["page_style_layout"] = psl.value if hasattr(psl, "value") else int(psl)
+ except Exception:
+ pass
return {"status": "ok", "properties": props}
except Exception as e:
return self._tool_error(f"Error reading properties from page style '{style_name}': {e}")
@@ -354,8 +372,21 @@ class SetPageStyleProperties(ToolWriterLayoutBase):
"right_margin_mm": {"type": "number", "description": "Right margin in mm."},
"top_margin_mm": {"type": "number", "description": "Top margin in mm."},
"bottom_margin_mm": {"type": "number", "description": "Bottom margin in mm."},
+ "gutter_margin_mm": {"type": "number", "description": "Gutter margin in mm (for binding)."},
"header_is_on": {"type": "boolean", "description": "Enable or disable header."},
"footer_is_on": {"type": "boolean", "description": "Enable or disable footer."},
+ "header_is_shared": {"type": "boolean", "description": "Share header between left/right pages."},
+ "footer_is_shared": {"type": "boolean", "description": "Share footer between left/right pages."},
+ "header_height_mm": {"type": "number", "description": "Absolute header height in mm."},
+ "footer_height_mm": {"type": "number", "description": "Absolute footer height in mm."},
+ "header_body_distance_mm": {"type": "number", "description": "Spacing from header to body in mm."},
+ "footer_body_distance_mm": {"type": "number", "description": "Spacing from footer to body in mm."},
+ "back_color": {"type": "integer", "description": "Background color (RGB long)."},
+ "back_transparent": {"type": "boolean", "description": "Make background transparent."},
+ "numbering_type": {"type": "integer", "description": "Numbering type enum (4=Arabic, 0=Roman)."},
+ "footnote_height_mm": {"type": "number", "description": "Max footnote area height in mm."},
+ "register_paragraph_style": {"type": "string", "description": "Register true reference style."},
+ "page_style_layout": {"type": "integer", "description": "0=ALL, 1=LEFT, 2=RIGHT, 3=MIRRORED"},
},
"required": [],
}
@@ -397,12 +428,55 @@ def execute(self, ctx, **kwargs):
if "bottom_margin_mm" in kwargs:
style.setPropertyValue("BottomMargin", int(kwargs["bottom_margin_mm"] * 100))
updated.append("bottom_margin")
+ if "gutter_margin_mm" in kwargs:
+ style.setPropertyValue("GutterMargin", int(kwargs["gutter_margin_mm"] * 100))
+ updated.append("gutter_margin")
if "header_is_on" in kwargs:
style.setPropertyValue("HeaderIsOn", kwargs["header_is_on"])
updated.append("header_is_on")
if "footer_is_on" in kwargs:
style.setPropertyValue("FooterIsOn", kwargs["footer_is_on"])
updated.append("footer_is_on")
+ if "header_is_shared" in kwargs:
+ style.setPropertyValue("HeaderIsShared", kwargs["header_is_shared"])
+ updated.append("header_is_shared")
+ if "footer_is_shared" in kwargs:
+ style.setPropertyValue("FooterIsShared", kwargs["footer_is_shared"])
+ updated.append("footer_is_shared")
+ if "header_height_mm" in kwargs:
+ style.setPropertyValue("HeaderHeight", int(kwargs["header_height_mm"] * 100))
+ updated.append("header_height")
+ if "footer_height_mm" in kwargs:
+ style.setPropertyValue("FooterHeight", int(kwargs["footer_height_mm"] * 100))
+ updated.append("footer_height")
+ if "header_body_distance_mm" in kwargs:
+ style.setPropertyValue("HeaderBodyDistance", int(kwargs["header_body_distance_mm"] * 100))
+ updated.append("header_body_distance")
+ if "footer_body_distance_mm" in kwargs:
+ style.setPropertyValue("FooterBodyDistance", int(kwargs["footer_body_distance_mm"] * 100))
+ updated.append("footer_body_distance")
+ if "back_color" in kwargs:
+ style.setPropertyValue("BackColor", kwargs["back_color"])
+ updated.append("back_color")
+ if "back_transparent" in kwargs:
+ style.setPropertyValue("BackTransparent", kwargs["back_transparent"])
+ updated.append("back_transparent")
+ if "numbering_type" in kwargs:
+ style.setPropertyValue("NumberingType", kwargs["numbering_type"])
+ updated.append("numbering_type")
+ if "footnote_height_mm" in kwargs:
+ style.setPropertyValue("FootnoteHeight", int(kwargs["footnote_height_mm"] * 100))
+ updated.append("footnote_height")
+ if "register_paragraph_style" in kwargs:
+ style.setPropertyValue("RegisterParagraphStyle", kwargs["register_paragraph_style"])
+ updated.append("register_paragraph_style")
+ if "page_style_layout" in kwargs:
+ from com.sun.star.style.PageStyleLayout import (ALL, LEFT, RIGHT, MIRRORED)
+ m = {0: ALL, 1: LEFT, 2: RIGHT, 3: MIRRORED}
+ val = m.get(kwargs["page_style_layout"])
+ if val is not None:
+ style.setPropertyValue("PageStyleLayout", val)
+ updated.append("page_style_layout")
except Exception as e:
return self._tool_error(f"Error setting properties on page style '{style_name}': {e}")
diff --git a/plugin/tests/uno/test_writer_layout.py b/plugin/tests/uno/test_writer_layout.py
index a64b8fd..be6af10 100644
--- a/plugin/tests/uno/test_writer_layout.py
+++ b/plugin/tests/uno/test_writer_layout.py
@@ -58,8 +58,21 @@ def get_prop(name):
"RightMargin": 2000,
"TopMargin": 2000,
"BottomMargin": 2000,
+ "GutterMargin": 0,
"HeaderIsOn": True,
- "FooterIsOn": False
+ "FooterIsOn": False,
+ "HeaderIsShared": True,
+ "FooterIsShared": True,
+ "HeaderHeight": 500,
+ "FooterHeight": 500,
+ "HeaderBodyDistance": 500,
+ "FooterBodyDistance": 500,
+ "BackColor": 16777215,
+ "BackTransparent": True,
+ "NumberingType": 4,
+ "FootnoteHeight": 0,
+ "RegisterParagraphStyle": "",
+ "PageStyleLayout": MagicMock(value=0)
}
return props[name]
style.getPropertyValue.side_effect = get_prop