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