#
tokens: 44276/50000 5/35 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/gongrzhe/office-word-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── __init__.py
├── .gitignore
├── Dockerfile
├── LICENSE
├── mcp-config.json
├── office_word_mcp_server
│   └── __init__.py
├── pyproject.toml
├── README.md
├── RENDER_DEPLOYMENT.md
├── requirements.txt
├── setup_mcp.py
├── smithery.yaml
├── test_formatting.py
├── tests
│   └── test_convert_to_pdf.py
├── uv.lock
├── word_document_server
│   ├── __init__.py
│   ├── core
│   │   ├── __init__.py
│   │   ├── comments.py
│   │   ├── footnotes.py
│   │   ├── protection.py
│   │   ├── styles.py
│   │   ├── tables.py
│   │   └── unprotect.py
│   ├── main.py
│   ├── tools
│   │   ├── __init__.py
│   │   ├── comment_tools.py
│   │   ├── content_tools.py
│   │   ├── document_tools.py
│   │   ├── extended_document_tools.py
│   │   ├── footnote_tools.py
│   │   ├── format_tools.py
│   │   └── protection_tools.py
│   └── utils
│       ├── __init__.py
│       ├── document_utils.py
│       ├── extended_document_utils.py
│       └── file_utils.py
└── word_mcp_server.py
```

# Files

--------------------------------------------------------------------------------
/word_document_server/main.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Main entry point for the Word Document MCP Server.
  3 | Acts as the central controller for the MCP server that handles Word document operations.
  4 | Supports multiple transports: stdio, sse, and streamable-http using standalone FastMCP.
  5 | """
  6 | 
  7 | import os
  8 | import sys
  9 | from dotenv import load_dotenv
 10 | 
 11 | # Load environment variables from .env file
 12 | print("Loading configuration from .env file...")
 13 | load_dotenv()
 14 | # Set required environment variable for FastMCP 2.8.1+
 15 | os.environ.setdefault('FASTMCP_LOG_LEVEL', 'INFO')
 16 | from fastmcp import FastMCP
 17 | from word_document_server.tools import (
 18 |     document_tools,
 19 |     content_tools,
 20 |     format_tools,
 21 |     protection_tools,
 22 |     footnote_tools,
 23 |     extended_document_tools,
 24 |     comment_tools
 25 | )
 26 | from word_document_server.tools.content_tools import replace_paragraph_block_below_header_tool
 27 | from word_document_server.tools.content_tools import replace_block_between_manual_anchors_tool
 28 | 
 29 | def get_transport_config():
 30 |     """
 31 |     Get transport configuration from environment variables.
 32 |     
 33 |     Returns:
 34 |         dict: Transport configuration with type, host, port, and other settings
 35 |     """
 36 |     # Default configuration
 37 |     config = {
 38 |         'transport': 'stdio',  # Default to stdio for backward compatibility
 39 |         'host': '0.0.0.0',
 40 |         'port': 8000,
 41 |         'path': '/mcp',
 42 |         'sse_path': '/sse'
 43 |     }
 44 |     
 45 |     # Override with environment variables if provided
 46 |     transport = os.getenv('MCP_TRANSPORT', 'stdio').lower()
 47 |     print(f"Transport: {transport}")
 48 |     # Validate transport type
 49 |     valid_transports = ['stdio', 'streamable-http', 'sse']
 50 |     if transport not in valid_transports:
 51 |         print(f"Warning: Invalid transport '{transport}'. Falling back to 'stdio'.")
 52 |         transport = 'stdio'
 53 |     
 54 |     config['transport'] = transport
 55 |     config['host'] = os.getenv('MCP_HOST', config['host'])
 56 |     # Use PORT from Render if available, otherwise fall back to MCP_PORT or default
 57 |     config['port'] = int(os.getenv('PORT', os.getenv('MCP_PORT', config['port'])))
 58 |     config['path'] = os.getenv('MCP_PATH', config['path'])
 59 |     config['sse_path'] = os.getenv('MCP_SSE_PATH', config['sse_path'])
 60 |     
 61 |     return config
 62 | 
 63 | 
 64 | def setup_logging(debug_mode):
 65 |     """
 66 |     Setup logging based on debug mode.
 67 |     
 68 |     Args:
 69 |         debug_mode (bool): Whether to enable debug logging
 70 |     """
 71 |     import logging
 72 |     
 73 |     if debug_mode:
 74 |         logging.basicConfig(
 75 |             level=logging.DEBUG,
 76 |             format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
 77 |         )
 78 |         print("Debug logging enabled")
 79 |     else:
 80 |         logging.basicConfig(
 81 |             level=logging.INFO,
 82 |             format='%(asctime)s - %(levelname)s - %(message)s'
 83 |         )
 84 | 
 85 | 
 86 | # Initialize FastMCP server
 87 | mcp = FastMCP("Word Document Server")
 88 | 
 89 | 
 90 | def register_tools():
 91 |     """Register all tools with the MCP server using FastMCP decorators."""
 92 |     
 93 |     # Document tools (create, copy, info, etc.)
 94 |     @mcp.tool()
 95 |     def create_document(filename: str, title: str = None, author: str = None):
 96 |         """Create a new Word document with optional metadata."""
 97 |         return document_tools.create_document(filename, title, author)
 98 |     
 99 |     @mcp.tool()
100 |     def copy_document(source_filename: str, destination_filename: str = None):
101 |         """Create a copy of a Word document."""
102 |         return document_tools.copy_document(source_filename, destination_filename)
103 |     
104 |     @mcp.tool()
105 |     def get_document_info(filename: str):
106 |         """Get information about a Word document."""
107 |         return document_tools.get_document_info(filename)
108 |     
109 |     @mcp.tool()
110 |     def get_document_text(filename: str):
111 |         """Extract all text from a Word document."""
112 |         return document_tools.get_document_text(filename)
113 |     
114 |     @mcp.tool()
115 |     def get_document_outline(filename: str):
116 |         """Get the structure of a Word document."""
117 |         return document_tools.get_document_outline(filename)
118 |     
119 |     @mcp.tool()
120 |     def list_available_documents(directory: str = "."):
121 |         """List all .docx files in the specified directory."""
122 |         return document_tools.list_available_documents(directory)
123 |     
124 |     @mcp.tool()
125 |     def get_document_xml(filename: str):
126 |         """Get the raw XML structure of a Word document."""
127 |         return document_tools.get_document_xml_tool(filename)
128 |     
129 |     @mcp.tool()
130 |     def insert_header_near_text(filename: str, target_text: str = None, header_title: str = None, position: str = 'after', header_style: str = 'Heading 1', target_paragraph_index: int = None):
131 |         """Insert a header (with specified style) before or after the target paragraph. Specify by text or paragraph index. Args: filename (str), target_text (str, optional), header_title (str), position ('before' or 'after'), header_style (str, default 'Heading 1'), target_paragraph_index (int, optional)."""
132 |         return content_tools.insert_header_near_text_tool(filename, target_text, header_title, position, header_style, target_paragraph_index)
133 |     
134 |     @mcp.tool()
135 |     def insert_line_or_paragraph_near_text(filename: str, target_text: str = None, line_text: str = None, position: str = 'after', line_style: str = None, target_paragraph_index: int = None):
136 |         """
137 |         Insert a new line or paragraph (with specified or matched style) before or after the target paragraph. Specify by text or paragraph index. Args: filename (str), target_text (str, optional), line_text (str), position ('before' or 'after'), line_style (str, optional), target_paragraph_index (int, optional).
138 |         """
139 |         return content_tools.insert_line_or_paragraph_near_text_tool(filename, target_text, line_text, position, line_style, target_paragraph_index)
140 |     
141 |     @mcp.tool()
142 |     def insert_numbered_list_near_text(filename: str, target_text: str = None, list_items: list = None, position: str = 'after', target_paragraph_index: int = None, bullet_type: str = 'bullet'):
143 |         """Insert a bulleted or numbered list before or after the target paragraph. Specify by text or paragraph index. Args: filename (str), target_text (str, optional), list_items (list of str), position ('before' or 'after'), target_paragraph_index (int, optional), bullet_type ('bullet' for bullets or 'number' for numbered lists, default: 'bullet')."""
144 |         return content_tools.insert_numbered_list_near_text_tool(filename, target_text, list_items, position, target_paragraph_index, bullet_type)
145 |     # Content tools (paragraphs, headings, tables, etc.)
146 |     @mcp.tool()
147 |     def add_paragraph(filename: str, text: str, style: str = None,
148 |                       font_name: str = None, font_size: int = None,
149 |                       bold: bool = None, italic: bool = None, color: str = None):
150 |         """Add a paragraph to a Word document with optional formatting.
151 | 
152 |         Args:
153 |             filename: Path to Word document
154 |             text: Paragraph text content
155 |             style: Optional paragraph style name
156 |             font_name: Font family (e.g., 'Helvetica', 'Times New Roman')
157 |             font_size: Font size in points (e.g., 14, 36)
158 |             bold: Make text bold
159 |             italic: Make text italic
160 |             color: Text color as hex RGB (e.g., '000000')
161 |         """
162 |         return content_tools.add_paragraph(filename, text, style, font_name, font_size, bold, italic, color)
163 |     
164 |     @mcp.tool()
165 |     def add_heading(filename: str, text: str, level: int = 1,
166 |                     font_name: str = None, font_size: int = None,
167 |                     bold: bool = None, italic: bool = None, border_bottom: bool = False):
168 |         """Add a heading to a Word document with optional formatting.
169 | 
170 |         Args:
171 |             filename: Path to Word document
172 |             text: Heading text
173 |             level: Heading level (1-9)
174 |             font_name: Font family (e.g., 'Helvetica')
175 |             font_size: Font size in points (e.g., 14)
176 |             bold: Make heading bold
177 |             italic: Make heading italic
178 |             border_bottom: Add bottom border (for section headers)
179 |         """
180 |         return content_tools.add_heading(filename, text, level, font_name, font_size, bold, italic, border_bottom)
181 |     
182 |     @mcp.tool()
183 |     def add_picture(filename: str, image_path: str, width: float = None):
184 |         """Add an image to a Word document."""
185 |         return content_tools.add_picture(filename, image_path, width)
186 |     
187 |     @mcp.tool()
188 |     def add_table(filename: str, rows: int, cols: int, data: list = None):
189 |         """Add a table to a Word document."""
190 |         return content_tools.add_table(filename, rows, cols, data)
191 |     
192 |     @mcp.tool()
193 |     def add_page_break(filename: str):
194 |         """Add a page break to the document."""
195 |         return content_tools.add_page_break(filename)
196 |     
197 |     @mcp.tool()
198 |     def delete_paragraph(filename: str, paragraph_index: int):
199 |         """Delete a paragraph from a document."""
200 |         return content_tools.delete_paragraph(filename, paragraph_index)
201 |     
202 |     @mcp.tool()
203 |     def search_and_replace(filename: str, find_text: str, replace_text: str):
204 |         """Search for text and replace all occurrences."""
205 |         return content_tools.search_and_replace(filename, find_text, replace_text)
206 |     
207 |     # Format tools (styling, text formatting, etc.)
208 |     @mcp.tool()
209 |     def create_custom_style(filename: str, style_name: str, bold: bool = None, 
210 |                           italic: bool = None, font_size: int = None, 
211 |                           font_name: str = None, color: str = None, 
212 |                           base_style: str = None):
213 |         """Create a custom style in the document."""
214 |         return format_tools.create_custom_style(
215 |             filename, style_name, bold, italic, font_size, font_name, color, base_style
216 |         )
217 |     
218 |     @mcp.tool()
219 |     def format_text(filename: str, paragraph_index: int, start_pos: int, end_pos: int,
220 |                    bold: bool = None, italic: bool = None, underline: bool = None,
221 |                    color: str = None, font_size: int = None, font_name: str = None):
222 |         """Format a specific range of text within a paragraph."""
223 |         return format_tools.format_text(
224 |             filename, paragraph_index, start_pos, end_pos, bold, italic, 
225 |             underline, color, font_size, font_name
226 |         )
227 |     
228 |     @mcp.tool()
229 |     def format_table(filename: str, table_index: int, has_header_row: bool = None,
230 |                     border_style: str = None, shading: list = None):
231 |         """Format a table with borders, shading, and structure."""
232 |         return format_tools.format_table(filename, table_index, has_header_row, border_style, shading)
233 |     
234 |     # New table cell shading tools
235 |     @mcp.tool()
236 |     def set_table_cell_shading(filename: str, table_index: int, row_index: int, 
237 |                               col_index: int, fill_color: str, pattern: str = "clear"):
238 |         """Apply shading/filling to a specific table cell."""
239 |         return format_tools.set_table_cell_shading(filename, table_index, row_index, col_index, fill_color, pattern)
240 |     
241 |     @mcp.tool()
242 |     def apply_table_alternating_rows(filename: str, table_index: int, 
243 |                                    color1: str = "FFFFFF", color2: str = "F2F2F2"):
244 |         """Apply alternating row colors to a table for better readability."""
245 |         return format_tools.apply_table_alternating_rows(filename, table_index, color1, color2)
246 |     
247 |     @mcp.tool()
248 |     def highlight_table_header(filename: str, table_index: int, 
249 |                              header_color: str = "4472C4", text_color: str = "FFFFFF"):
250 |         """Apply special highlighting to table header row."""
251 |         return format_tools.highlight_table_header(filename, table_index, header_color, text_color)
252 |     
253 |     # Cell merging tools
254 |     @mcp.tool()
255 |     def merge_table_cells(filename: str, table_index: int, start_row: int, start_col: int, 
256 |                         end_row: int, end_col: int):
257 |         """Merge cells in a rectangular area of a table."""
258 |         return format_tools.merge_table_cells(filename, table_index, start_row, start_col, end_row, end_col)
259 |     
260 |     @mcp.tool()
261 |     def merge_table_cells_horizontal(filename: str, table_index: int, row_index: int, 
262 |                                    start_col: int, end_col: int):
263 |         """Merge cells horizontally in a single row."""
264 |         return format_tools.merge_table_cells_horizontal(filename, table_index, row_index, start_col, end_col)
265 |     
266 |     @mcp.tool()
267 |     def merge_table_cells_vertical(filename: str, table_index: int, col_index: int, 
268 |                                  start_row: int, end_row: int):
269 |         """Merge cells vertically in a single column."""
270 |         return format_tools.merge_table_cells_vertical(filename, table_index, col_index, start_row, end_row)
271 |     
272 |     # Cell alignment tools
273 |     @mcp.tool()
274 |     def set_table_cell_alignment(filename: str, table_index: int, row_index: int, col_index: int,
275 |                                horizontal: str = "left", vertical: str = "top"):
276 |         """Set text alignment for a specific table cell."""
277 |         return format_tools.set_table_cell_alignment(filename, table_index, row_index, col_index, horizontal, vertical)
278 |     
279 |     @mcp.tool()
280 |     def set_table_alignment_all(filename: str, table_index: int, 
281 |                               horizontal: str = "left", vertical: str = "top"):
282 |         """Set text alignment for all cells in a table."""
283 |         return format_tools.set_table_alignment_all(filename, table_index, horizontal, vertical)
284 |     
285 |     # Protection tools
286 |     @mcp.tool()
287 |     def protect_document(filename: str, password: str):
288 |         """Add password protection to a Word document."""
289 |         return protection_tools.protect_document(filename, password)
290 |     
291 |     @mcp.tool()
292 |     def unprotect_document(filename: str, password: str):
293 |         """Remove password protection from a Word document."""
294 |         return protection_tools.unprotect_document(filename, password)
295 |     
296 |     # Footnote tools
297 |     @mcp.tool()
298 |     def add_footnote_to_document(filename: str, paragraph_index: int, footnote_text: str):
299 |         """Add a footnote to a specific paragraph in a Word document."""
300 |         return footnote_tools.add_footnote_to_document(filename, paragraph_index, footnote_text)
301 |     
302 |     @mcp.tool()
303 |     def add_footnote_after_text(filename: str, search_text: str, footnote_text: str, 
304 |                                output_filename: str = None):
305 |         """Add a footnote after specific text with proper superscript formatting.
306 |         This enhanced function ensures footnotes display correctly as superscript."""
307 |         return footnote_tools.add_footnote_after_text(filename, search_text, footnote_text, output_filename)
308 |     
309 |     @mcp.tool()
310 |     def add_footnote_before_text(filename: str, search_text: str, footnote_text: str, 
311 |                                 output_filename: str = None):
312 |         """Add a footnote before specific text with proper superscript formatting.
313 |         This enhanced function ensures footnotes display correctly as superscript."""
314 |         return footnote_tools.add_footnote_before_text(filename, search_text, footnote_text, output_filename)
315 |     
316 |     @mcp.tool()
317 |     def add_footnote_enhanced(filename: str, paragraph_index: int, footnote_text: str,
318 |                              output_filename: str = None):
319 |         """Enhanced footnote addition with guaranteed superscript formatting.
320 |         Adds footnote at the end of a specific paragraph with proper style handling."""
321 |         return footnote_tools.add_footnote_enhanced(filename, paragraph_index, footnote_text, output_filename)
322 |     
323 |     @mcp.tool()
324 |     def add_endnote_to_document(filename: str, paragraph_index: int, endnote_text: str):
325 |         """Add an endnote to a specific paragraph in a Word document."""
326 |         return footnote_tools.add_endnote_to_document(filename, paragraph_index, endnote_text)
327 |     
328 |     @mcp.tool()
329 |     def customize_footnote_style(filename: str, numbering_format: str = "1, 2, 3",
330 |                                 start_number: int = 1, font_name: str = None,
331 |                                 font_size: int = None):
332 |         """Customize footnote numbering and formatting in a Word document."""
333 |         return footnote_tools.customize_footnote_style(
334 |             filename, numbering_format, start_number, font_name, font_size
335 |         )
336 |     
337 |     @mcp.tool()
338 |     def delete_footnote_from_document(filename: str, footnote_id: int = None,
339 |                                      search_text: str = None, output_filename: str = None):
340 |         """Delete a footnote from a Word document.
341 |         Identify the footnote either by ID (1, 2, 3, etc.) or by searching for text near it."""
342 |         return footnote_tools.delete_footnote_from_document(
343 |             filename, footnote_id, search_text, output_filename
344 |         )
345 |     
346 |     # Robust footnote tools - Production-ready with comprehensive validation
347 |     @mcp.tool()
348 |     def add_footnote_robust(filename: str, search_text: str = None, 
349 |                            paragraph_index: int = None, footnote_text: str = "",
350 |                            validate_location: bool = True, auto_repair: bool = False):
351 |         """Add footnote with robust validation and Word compliance.
352 |         This is the production-ready version with comprehensive error handling."""
353 |         return footnote_tools.add_footnote_robust_tool(
354 |             filename, search_text, paragraph_index, footnote_text, 
355 |             validate_location, auto_repair
356 |         )
357 |     
358 |     @mcp.tool()
359 |     def validate_document_footnotes(filename: str):
360 |         """Validate all footnotes in document for coherence and compliance.
361 |         Returns detailed report on ID conflicts, orphaned content, missing styles, etc."""
362 |         return footnote_tools.validate_footnotes_tool(filename)
363 |     
364 |     @mcp.tool()
365 |     def delete_footnote_robust(filename: str, footnote_id: int = None,
366 |                               search_text: str = None, clean_orphans: bool = True):
367 |         """Delete footnote with comprehensive cleanup and orphan removal.
368 |         Ensures complete removal from document.xml, footnotes.xml, and relationships."""
369 |         return footnote_tools.delete_footnote_robust_tool(
370 |             filename, footnote_id, search_text, clean_orphans
371 |         )
372 |     
373 |     # Extended document tools
374 |     @mcp.tool()
375 |     def get_paragraph_text_from_document(filename: str, paragraph_index: int):
376 |         """Get text from a specific paragraph in a Word document."""
377 |         return extended_document_tools.get_paragraph_text_from_document(filename, paragraph_index)
378 |     
379 |     @mcp.tool()
380 |     def find_text_in_document(filename: str, text_to_find: str, match_case: bool = True,
381 |                              whole_word: bool = False):
382 |         """Find occurrences of specific text in a Word document."""
383 |         return extended_document_tools.find_text_in_document(
384 |             filename, text_to_find, match_case, whole_word
385 |         )
386 |     
387 |     @mcp.tool()
388 |     def convert_to_pdf(filename: str, output_filename: str = None):
389 |         """Convert a Word document to PDF format."""
390 |         return extended_document_tools.convert_to_pdf(filename, output_filename)
391 | 
392 |     @mcp.tool()
393 |     def replace_paragraph_block_below_header(filename: str, header_text: str, new_paragraphs: list, detect_block_end_fn=None):
394 |         """Reemplaza el bloque de párrafos debajo de un encabezado, evitando modificar TOC."""
395 |         return replace_paragraph_block_below_header_tool(filename, header_text, new_paragraphs, detect_block_end_fn)
396 | 
397 |     @mcp.tool()
398 |     def replace_block_between_manual_anchors(filename: str, start_anchor_text: str, new_paragraphs: list, end_anchor_text: str = None, match_fn=None, new_paragraph_style: str = None):
399 |         """Replace all content between start_anchor_text and end_anchor_text (or next logical header if not provided)."""
400 |         return replace_block_between_manual_anchors_tool(filename, start_anchor_text, new_paragraphs, end_anchor_text, match_fn, new_paragraph_style)
401 | 
402 |     # Comment tools
403 |     @mcp.tool()
404 |     def get_all_comments(filename: str):
405 |         """Extract all comments from a Word document."""
406 |         return comment_tools.get_all_comments(filename)
407 |     
408 |     @mcp.tool()
409 |     def get_comments_by_author(filename: str, author: str):
410 |         """Extract comments from a specific author in a Word document."""
411 |         return comment_tools.get_comments_by_author(filename, author)
412 |     
413 |     @mcp.tool()
414 |     def get_comments_for_paragraph(filename: str, paragraph_index: int):
415 |         """Extract comments for a specific paragraph in a Word document."""
416 |         return comment_tools.get_comments_for_paragraph(filename, paragraph_index)
417 |     # New table column width tools
418 |     @mcp.tool()
419 |     def set_table_column_width(filename: str, table_index: int, col_index: int, 
420 |                               width: float, width_type: str = "points"):
421 |         """Set the width of a specific table column."""
422 |         return format_tools.set_table_column_width(filename, table_index, col_index, width, width_type)
423 | 
424 |     @mcp.tool()
425 |     def set_table_column_widths(filename: str, table_index: int, widths: list, 
426 |                                width_type: str = "points"):
427 |         """Set the widths of multiple table columns."""
428 |         return format_tools.set_table_column_widths(filename, table_index, widths, width_type)
429 | 
430 |     @mcp.tool()
431 |     def set_table_width(filename: str, table_index: int, width: float, 
432 |                        width_type: str = "points"):
433 |         """Set the overall width of a table."""
434 |         return format_tools.set_table_width(filename, table_index, width, width_type)
435 | 
436 |     @mcp.tool()
437 |     def auto_fit_table_columns(filename: str, table_index: int):
438 |         """Set table columns to auto-fit based on content."""
439 |         return format_tools.auto_fit_table_columns(filename, table_index)
440 | 
441 |     # New table cell text formatting and padding tools
442 |     @mcp.tool()
443 |     def format_table_cell_text(filename: str, table_index: int, row_index: int, col_index: int,
444 |                                text_content: str = None, bold: bool = None, italic: bool = None,
445 |                                underline: bool = None, color: str = None, font_size: int = None,
446 |                                font_name: str = None):
447 |         """Format text within a specific table cell."""
448 |         return format_tools.format_table_cell_text(filename, table_index, row_index, col_index,
449 |                                                    text_content, bold, italic, underline, color, font_size, font_name)
450 | 
451 |     @mcp.tool()
452 |     def set_table_cell_padding(filename: str, table_index: int, row_index: int, col_index: int,
453 |                                top: float = None, bottom: float = None, left: float = None, 
454 |                                right: float = None, unit: str = "points"):
455 |         """Set padding/margins for a specific table cell."""
456 |         return format_tools.set_table_cell_padding(filename, table_index, row_index, col_index,
457 |                                                    top, bottom, left, right, unit)
458 | 
459 | 
460 | 
461 | def run_server():
462 |     """Run the Word Document MCP Server with configurable transport."""
463 |     # Get transport configuration
464 |     config = get_transport_config()
465 |     
466 |     # Setup logging
467 |     # setup_logging(config['debug'])
468 |     
469 |     # Register all tools
470 |     register_tools()
471 |     
472 |     # Print startup information
473 |     transport_type = config['transport']
474 |     print(f"Starting Word Document MCP Server with {transport_type} transport...")
475 |     
476 |     # if config['debug']:
477 |     #     print(f"Configuration: {config}")
478 |     
479 |     try:
480 |         if transport_type == 'stdio':
481 |             # Run with stdio transport (default, backward compatible)
482 |             print("Server running on stdio transport")
483 |             mcp.run(transport='stdio')
484 |             
485 |         elif transport_type == 'streamable-http':
486 |             # Run with streamable HTTP transport
487 |             print(f"Server running on streamable-http transport at http://{config['host']}:{config['port']}{config['path']}")
488 |             mcp.run(
489 |                 transport='streamable-http',
490 |                 host=config['host'],
491 |                 port=config['port'],
492 |                 path=config['path']
493 |             )
494 |             
495 |         elif transport_type == 'sse':
496 |             # Run with SSE transport
497 |             print(f"Server running on SSE transport at http://{config['host']}:{config['port']}{config['sse_path']}")
498 |             mcp.run(
499 |                 transport='sse',
500 |                 host=config['host'],
501 |                 port=config['port'],
502 |                 path=config['sse_path']
503 |             )
504 |             
505 |     except KeyboardInterrupt:
506 |         print("\nShutting down server...")
507 |     except Exception as e:
508 |         print(f"Error starting server: {e}")
509 |         if config['debug']:
510 |             import traceback
511 |             traceback.print_exc()
512 |         sys.exit(1)
513 |     
514 |     return mcp
515 | 
516 | 
517 | def main():
518 |     """Main entry point for the server."""
519 |     run_server()
520 | 
521 | 
522 | if __name__ == "__main__":
523 |     main()
524 | 
```

--------------------------------------------------------------------------------
/word_document_server/tools/footnote_tools.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Footnote and endnote tools for Word Document Server.
  3 | 
  4 | These tools handle footnote and endnote functionality,
  5 | including adding, customizing, and converting between them.
  6 | 
  7 | This module combines both standard and robust implementations:
  8 | - String-return functions for backward compatibility
  9 | - Dict-return robust functions for structured responses
 10 | """
 11 | import os
 12 | from typing import Optional, Dict, Any
 13 | from docx import Document
 14 | from docx.shared import Pt
 15 | from docx.enum.style import WD_STYLE_TYPE
 16 | 
 17 | from word_document_server.utils.file_utils import check_file_writeable, ensure_docx_extension
 18 | from word_document_server.core.footnotes import (
 19 |     find_footnote_references,
 20 |     get_format_symbols,
 21 |     customize_footnote_formatting,
 22 |     add_footnote_robust,
 23 |     delete_footnote_robust,
 24 |     validate_document_footnotes,
 25 |     add_footnote_at_paragraph_end  # Compatibility function
 26 | )
 27 | 
 28 | 
 29 | async def add_footnote_to_document(filename: str, paragraph_index: int, footnote_text: str) -> str:
 30 |     """Add a footnote to a specific paragraph in a Word document.
 31 |     
 32 |     Args:
 33 |         filename: Path to the Word document
 34 |         paragraph_index: Index of the paragraph to add footnote to (0-based)
 35 |         footnote_text: Text content of the footnote
 36 |     """
 37 |     filename = ensure_docx_extension(filename)
 38 |     
 39 |     # Ensure paragraph_index is an integer
 40 |     try:
 41 |         paragraph_index = int(paragraph_index)
 42 |     except (ValueError, TypeError):
 43 |         return "Invalid parameter: paragraph_index must be an integer"
 44 |     
 45 |     if not os.path.exists(filename):
 46 |         return f"Document {filename} does not exist"
 47 |     
 48 |     # Check if file is writeable
 49 |     is_writeable, error_message = check_file_writeable(filename)
 50 |     if not is_writeable:
 51 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 52 |     
 53 |     try:
 54 |         doc = Document(filename)
 55 |         
 56 |         # Validate paragraph index
 57 |         if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs):
 58 |             return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})."
 59 |         
 60 |         paragraph = doc.paragraphs[paragraph_index]
 61 |         
 62 |         # In python-docx, we'd use paragraph.add_footnote(), but we'll use a more robust approach
 63 |         try:
 64 |             footnote = paragraph.add_run()
 65 |             footnote.text = ""
 66 |             
 67 |             # Create the footnote reference
 68 |             reference = footnote.add_footnote(footnote_text)
 69 |             
 70 |             doc.save(filename)
 71 |             return f"Footnote added to paragraph {paragraph_index} in {filename}"
 72 |         except AttributeError:
 73 |             # Fall back to a simpler approach if direct footnote addition fails
 74 |             last_run = paragraph.add_run()
 75 |             last_run.text = "¹"  # Unicode superscript 1
 76 |             last_run.font.superscript = True
 77 |             
 78 |             # Add a footnote section at the end if it doesn't exist
 79 |             found_footnote_section = False
 80 |             for p in doc.paragraphs:
 81 |                 if p.text.startswith("Footnotes:"):
 82 |                     found_footnote_section = True
 83 |                     break
 84 |             
 85 |             if not found_footnote_section:
 86 |                 doc.add_paragraph("\n").add_run()
 87 |                 doc.add_paragraph("Footnotes:").bold = True
 88 |             
 89 |             # Add footnote text
 90 |             footnote_para = doc.add_paragraph("¹ " + footnote_text)
 91 |             footnote_para.style = "Footnote Text" if "Footnote Text" in doc.styles else "Normal"
 92 |             
 93 |             doc.save(filename)
 94 |             return f"Footnote added to paragraph {paragraph_index} in {filename} (simplified approach)"
 95 |     except Exception as e:
 96 |         return f"Failed to add footnote: {str(e)}"
 97 | 
 98 | 
 99 | async def add_endnote_to_document(filename: str, paragraph_index: int, endnote_text: str) -> str:
100 |     """Add an endnote to a specific paragraph in a Word document.
101 |     
102 |     Args:
103 |         filename: Path to the Word document
104 |         paragraph_index: Index of the paragraph to add endnote to (0-based)
105 |         endnote_text: Text content of the endnote
106 |     """
107 |     filename = ensure_docx_extension(filename)
108 |     
109 |     # Ensure paragraph_index is an integer
110 |     try:
111 |         paragraph_index = int(paragraph_index)
112 |     except (ValueError, TypeError):
113 |         return "Invalid parameter: paragraph_index must be an integer"
114 |     
115 |     if not os.path.exists(filename):
116 |         return f"Document {filename} does not exist"
117 |     
118 |     # Check if file is writeable
119 |     is_writeable, error_message = check_file_writeable(filename)
120 |     if not is_writeable:
121 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
122 |     
123 |     try:
124 |         doc = Document(filename)
125 |         
126 |         # Validate paragraph index
127 |         if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs):
128 |             return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})."
129 |         
130 |         paragraph = doc.paragraphs[paragraph_index]
131 |         
132 |         # Add endnote reference
133 |         last_run = paragraph.add_run()
134 |         last_run.text = "†"  # Unicode dagger symbol common for endnotes
135 |         last_run.font.superscript = True
136 |         
137 |         # Check if endnotes section exists, if not create it
138 |         endnotes_heading_found = False
139 |         for para in doc.paragraphs:
140 |             if para.text == "Endnotes:" or para.text == "ENDNOTES":
141 |                 endnotes_heading_found = True
142 |                 break
143 |         
144 |         if not endnotes_heading_found:
145 |             # Add a page break before endnotes section
146 |             doc.add_page_break()
147 |             doc.add_heading("Endnotes:", level=1)
148 |         
149 |         # Add the endnote text
150 |         endnote_para = doc.add_paragraph("† " + endnote_text)
151 |         endnote_para.style = "Endnote Text" if "Endnote Text" in doc.styles else "Normal"
152 |         
153 |         doc.save(filename)
154 |         return f"Endnote added to paragraph {paragraph_index} in {filename}"
155 |     except Exception as e:
156 |         return f"Failed to add endnote: {str(e)}"
157 | 
158 | 
159 | async def convert_footnotes_to_endnotes_in_document(filename: str) -> str:
160 |     """Convert all footnotes to endnotes in a Word document.
161 |     
162 |     Args:
163 |         filename: Path to the Word document
164 |     """
165 |     filename = ensure_docx_extension(filename)
166 |     
167 |     if not os.path.exists(filename):
168 |         return f"Document {filename} does not exist"
169 |     
170 |     # Check if file is writeable
171 |     is_writeable, error_message = check_file_writeable(filename)
172 |     if not is_writeable:
173 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
174 |     
175 |     try:
176 |         doc = Document(filename)
177 |   
178 |       
179 |         # Find all runs that might be footnote references
180 |         footnote_references = []
181 |         
182 |         for para_idx, para in enumerate(doc.paragraphs):
183 |             for run_idx, run in enumerate(para.runs):
184 |                 # Check if this run is likely a footnote reference
185 |                 # (superscript number or special character)
186 |                 if run.font.superscript and (run.text.isdigit() or run.text in "¹²³⁴⁵⁶⁷⁸⁹"):
187 |                     footnote_references.append({
188 |                         "paragraph_index": para_idx,
189 |                         "run_index": run_idx,
190 |                         "text": run.text
191 |                     })
192 |         
193 |         if not footnote_references:
194 |             return f"No footnote references found in {filename}"
195 |         
196 |         # Create endnotes section
197 |         doc.add_page_break()
198 |         doc.add_heading("Endnotes:", level=1)
199 |         
200 |         # Create a placeholder for endnote content, we'll fill it later
201 |         endnote_content = []
202 |         
203 |         # Find the footnote text at the bottom of the page
204 |        
205 |         found_footnote_section = False
206 |         footnote_text = []
207 |         
208 |         for para in doc.paragraphs:
209 |             if not found_footnote_section and para.text.startswith("Footnotes:"):
210 |                 found_footnote_section = True
211 |                 continue
212 |             
213 |             if found_footnote_section:
214 |                 footnote_text.append(para.text)
215 |         
216 |         # Create endnotes based on footnote references
217 |         for i, ref in enumerate(footnote_references):
218 |             # Add a new endnote
219 |             endnote_para = doc.add_paragraph()
220 |             
221 |             # Try to match with footnote text, or use placeholder
222 |             if i < len(footnote_text):
223 |                 endnote_para.text = f"†{i+1} {footnote_text[i]}"
224 |             else:
225 |                 endnote_para.text = f"†{i+1} Converted from footnote {ref['text']}"
226 |             
227 |             # Change the footnote reference to an endnote reference
228 |             try:
229 |                 paragraph = doc.paragraphs[ref["paragraph_index"]]
230 |                 paragraph.runs[ref["run_index"]].text = f"†{i+1}"
231 |             except IndexError:
232 |                 # Skip if we can't locate the reference
233 |                 pass
234 |         
235 |         # Save the document
236 |         doc.save(filename)
237 |         
238 |         return f"Converted {len(footnote_references)} footnotes to endnotes in {filename}"
239 |     except Exception as e:
240 |         return f"Failed to convert footnotes to endnotes: {str(e)}"
241 | 
242 | 
243 | async def add_footnote_after_text(filename: str, search_text: str, footnote_text: str, 
244 |                                  output_filename: Optional[str] = None) -> str:
245 |     """Add a footnote after specific text in a Word document with proper formatting.
246 |     
247 |     This enhanced function ensures proper superscript formatting by managing styles at the XML level.
248 |     
249 |     Args:
250 |         filename: Path to the Word document
251 |         search_text: Text to search for (footnote will be added after this text)
252 |         footnote_text: Content of the footnote
253 |         output_filename: Optional output filename (if None, modifies in place)
254 |     """
255 |     filename = ensure_docx_extension(filename)
256 |     
257 |     if not os.path.exists(filename):
258 |         return f"Document {filename} does not exist"
259 |     
260 |     # Check if file is writeable
261 |     is_writeable, error_message = check_file_writeable(filename)
262 |     if not is_writeable:
263 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
264 |     
265 |     try:
266 |         # Use robust implementation
267 |         success, message, details = add_footnote_robust(
268 |             filename=filename,
269 |             search_text=search_text,
270 |             footnote_text=footnote_text,
271 |             output_filename=output_filename,
272 |             position="after",
273 |             validate_location=True
274 |         )
275 |         return message
276 |     except Exception as e:
277 |         return f"Failed to add footnote: {str(e)}"
278 | 
279 | 
280 | async def add_footnote_before_text(filename: str, search_text: str, footnote_text: str, 
281 |                                   output_filename: Optional[str] = None) -> str:
282 |     """Add a footnote before specific text in a Word document with proper formatting.
283 |     
284 |     This enhanced function ensures proper superscript formatting by managing styles at the XML level.
285 |     
286 |     Args:
287 |         filename: Path to the Word document
288 |         search_text: Text to search for (footnote will be added before this text)
289 |         footnote_text: Content of the footnote
290 |         output_filename: Optional output filename (if None, modifies in place)
291 |     """
292 |     filename = ensure_docx_extension(filename)
293 |     
294 |     if not os.path.exists(filename):
295 |         return f"Document {filename} does not exist"
296 |     
297 |     # Check if file is writeable
298 |     is_writeable, error_message = check_file_writeable(filename)
299 |     if not is_writeable:
300 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
301 |     
302 |     try:
303 |         # Use robust implementation
304 |         success, message, details = add_footnote_robust(
305 |             filename=filename,
306 |             search_text=search_text,
307 |             footnote_text=footnote_text,
308 |             output_filename=output_filename,
309 |             position="before",
310 |             validate_location=True
311 |         )
312 |         return message
313 |     except Exception as e:
314 |         return f"Failed to add footnote: {str(e)}"
315 | 
316 | 
317 | async def add_footnote_enhanced(filename: str, paragraph_index: int, footnote_text: str,
318 |                                output_filename: Optional[str] = None) -> str:
319 |     """Enhanced version of add_footnote_to_document with proper superscript formatting.
320 |     
321 |     Now uses the robust implementation for better reliability.
322 |     
323 |     Args:
324 |         filename: Path to the Word document
325 |         paragraph_index: Index of the paragraph to add footnote to (0-based)
326 |         footnote_text: Text content of the footnote
327 |         output_filename: Optional output filename (if None, modifies in place)
328 |     """
329 |     filename = ensure_docx_extension(filename)
330 |     
331 |     # Ensure paragraph_index is an integer
332 |     try:
333 |         paragraph_index = int(paragraph_index)
334 |     except (ValueError, TypeError):
335 |         return "Invalid parameter: paragraph_index must be an integer"
336 |     
337 |     if not os.path.exists(filename):
338 |         return f"Document {filename} does not exist"
339 |     
340 |     # Check if file is writeable
341 |     is_writeable, error_message = check_file_writeable(filename)
342 |     if not is_writeable:
343 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
344 |     
345 |     try:
346 |         # Use robust implementation
347 |         success, message, details = add_footnote_robust(
348 |             filename=filename,
349 |             paragraph_index=paragraph_index,
350 |             footnote_text=footnote_text,
351 |             output_filename=output_filename,
352 |             validate_location=True
353 |         )
354 |         return message
355 |     except Exception as e:
356 |         return f"Failed to add footnote: {str(e)}"
357 | 
358 | 
359 | async def customize_footnote_style(filename: str, numbering_format: str = "1, 2, 3", 
360 |                                   start_number: int = 1, font_name: Optional[str] = None,
361 |                                   font_size: Optional[int] = None) -> str:
362 |     """Customize footnote numbering and formatting in a Word document.
363 |     
364 |     Args:
365 |         filename: Path to the Word document
366 |         numbering_format: Format for footnote numbers (e.g., "1, 2, 3", "i, ii, iii", "a, b, c")
367 |         start_number: Number to start footnote numbering from
368 |         font_name: Optional font name for footnotes
369 |         font_size: Optional font size for footnotes (in points)
370 |     """
371 |     filename = ensure_docx_extension(filename)
372 |     
373 |     if not os.path.exists(filename):
374 |         return f"Document {filename} does not exist"
375 |     
376 |     # Check if file is writeable
377 |     is_writeable, error_message = check_file_writeable(filename)
378 |     if not is_writeable:
379 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
380 |     
381 |     try:
382 |         doc = Document(filename)
383 |         
384 |         # Create or get footnote style
385 |         footnote_style_name = "Footnote Text"
386 |         footnote_style = None
387 |         
388 |         try:
389 |             footnote_style = doc.styles[footnote_style_name]
390 |         except KeyError:
391 |             # Create the style if it doesn't exist
392 |             footnote_style = doc.styles.add_style(footnote_style_name, WD_STYLE_TYPE.PARAGRAPH)
393 |         
394 |         # Apply formatting to footnote style
395 |         if footnote_style:
396 |             if font_name:
397 |                 footnote_style.font.name = font_name
398 |             if font_size:
399 |                 footnote_style.font.size = Pt(font_size)
400 |         
401 |         # Find all existing footnote references
402 |         footnote_refs = find_footnote_references(doc)
403 |         
404 |         # Generate format symbols for the specified numbering format
405 |         format_symbols = get_format_symbols(numbering_format, len(footnote_refs) + start_number)
406 |         
407 |         # Apply custom formatting to footnotes
408 |         count = customize_footnote_formatting(doc, footnote_refs, format_symbols, start_number, footnote_style)
409 |         
410 |         # Save the document
411 |         doc.save(filename)
412 |         
413 |         return f"Footnote style and numbering customized in {filename}"
414 |     except Exception as e:
415 |         return f"Failed to customize footnote style: {str(e)}"
416 | 
417 | 
418 | async def delete_footnote_from_document(filename: str, footnote_id: Optional[int] = None,
419 |                                        search_text: Optional[str] = None, 
420 |                                        output_filename: Optional[str] = None) -> str:
421 |     """Delete a footnote from a Word document.
422 |     
423 |     You can identify the footnote to delete either by:
424 |     1. footnote_id: The numeric ID of the footnote (1, 2, 3, etc.)
425 |     2. search_text: Text near the footnote reference to find and delete
426 |     
427 |     Args:
428 |         filename: Path to the Word document
429 |         footnote_id: Optional ID of the footnote to delete (1-based)
430 |         search_text: Optional text to search near the footnote reference
431 |         output_filename: Optional output filename (if None, modifies in place)
432 |     """
433 |     filename = ensure_docx_extension(filename)
434 |     
435 |     if not os.path.exists(filename):
436 |         return f"Document {filename} does not exist"
437 |     
438 |     # Check if file is writeable
439 |     is_writeable, error_message = check_file_writeable(filename)
440 |     if not is_writeable:
441 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
442 |     
443 |     try:
444 |         # Use robust implementation with orphan cleanup
445 |         success, message, details = delete_footnote_robust(
446 |             filename=filename,
447 |             footnote_id=footnote_id,
448 |             search_text=search_text,
449 |             output_filename=output_filename,
450 |             clean_orphans=True
451 |         )
452 |         return message
453 |     except Exception as e:
454 |         return f"Failed to delete footnote: {str(e)}"
455 | 
456 | 
457 | # ============================================================================
458 | # Robust tool functions with Dict returns for structured responses
459 | # ============================================================================
460 | 
461 | 
462 | async def add_footnote_robust_tool(
463 |     filename: str,
464 |     search_text: Optional[str] = None,
465 |     paragraph_index: Optional[int] = None,
466 |     footnote_text: str = "",
467 |     validate_location: bool = True,
468 |     auto_repair: bool = False
469 | ) -> Dict[str, Any]:
470 |     """
471 |     Add a footnote with robust validation and error handling.
472 |     
473 |     This is the production-ready version with comprehensive Word compliance.
474 |     
475 |     Args:
476 |         filename: Path to the Word document
477 |         search_text: Text to search for (mutually exclusive with paragraph_index)
478 |         paragraph_index: Index of paragraph (mutually exclusive with search_text)
479 |         footnote_text: Content of the footnote
480 |         validate_location: Whether to validate placement restrictions
481 |         auto_repair: Whether to attempt automatic document repair
482 |     
483 |     Returns:
484 |         Dict with success status, message, and optional details
485 |     """
486 |     filename = ensure_docx_extension(filename)
487 |     
488 |     # Check if file is writeable
489 |     is_writeable, error_message = check_file_writeable(filename)
490 |     if not is_writeable:
491 |         return {
492 |             "success": False,
493 |             "message": f"Cannot modify document: {error_message}",
494 |             "details": None
495 |         }
496 |     
497 |     # Convert paragraph_index if provided as string
498 |     if paragraph_index is not None:
499 |         try:
500 |             paragraph_index = int(paragraph_index)
501 |         except (ValueError, TypeError):
502 |             return {
503 |                 "success": False,
504 |                 "message": "Invalid parameter: paragraph_index must be an integer",
505 |                 "details": None
506 |             }
507 |     
508 |     # Call robust implementation
509 |     success, message, details = add_footnote_robust(
510 |         filename=filename,
511 |         search_text=search_text,
512 |         paragraph_index=paragraph_index,
513 |         footnote_text=footnote_text,
514 |         validate_location=validate_location,
515 |         auto_repair=auto_repair
516 |     )
517 |     
518 |     return {
519 |         "success": success,
520 |         "message": message,
521 |         "details": details
522 |     }
523 | 
524 | 
525 | async def delete_footnote_robust_tool(
526 |     filename: str,
527 |     footnote_id: Optional[int] = None,
528 |     search_text: Optional[str] = None,
529 |     clean_orphans: bool = True
530 | ) -> Dict[str, Any]:
531 |     """
532 |     Delete a footnote with comprehensive cleanup.
533 |     
534 |     Args:
535 |         filename: Path to the Word document
536 |         footnote_id: ID of footnote to delete
537 |         search_text: Text near footnote reference
538 |         clean_orphans: Whether to remove orphaned content
539 |     
540 |     Returns:
541 |         Dict with success status, message, and optional details
542 |     """
543 |     filename = ensure_docx_extension(filename)
544 |     
545 |     # Check if file is writeable
546 |     is_writeable, error_message = check_file_writeable(filename)
547 |     if not is_writeable:
548 |         return {
549 |             "success": False,
550 |             "message": f"Cannot modify document: {error_message}",
551 |             "details": None
552 |         }
553 |     
554 |     # Convert footnote_id if provided as string
555 |     if footnote_id is not None:
556 |         try:
557 |             footnote_id = int(footnote_id)
558 |         except (ValueError, TypeError):
559 |             return {
560 |                 "success": False,
561 |                 "message": "Invalid parameter: footnote_id must be an integer",
562 |                 "details": None
563 |             }
564 |     
565 |     # Call robust implementation
566 |     success, message, details = delete_footnote_robust(
567 |         filename=filename,
568 |         footnote_id=footnote_id,
569 |         search_text=search_text,
570 |         clean_orphans=clean_orphans
571 |     )
572 |     
573 |     return {
574 |         "success": success,
575 |         "message": message,
576 |         "details": details
577 |     }
578 | 
579 | 
580 | async def validate_footnotes_tool(filename: str) -> Dict[str, Any]:
581 |     """
582 |     Validate all footnotes in a document.
583 |     
584 |     Provides comprehensive validation report including:
585 |     - ID conflicts
586 |     - Orphaned content
587 |     - Missing styles
588 |     - Invalid locations
589 |     - Coherence issues
590 |     
591 |     Args:
592 |         filename: Path to the Word document
593 |     
594 |     Returns:
595 |         Dict with validation status and detailed report
596 |     """
597 |     filename = ensure_docx_extension(filename)
598 |     
599 |     if not os.path.exists(filename):
600 |         return {
601 |             "valid": False,
602 |             "message": f"Document {filename} does not exist",
603 |             "report": {}
604 |         }
605 |     
606 |     # Call validation
607 |     is_valid, message, report = validate_document_footnotes(filename)
608 |     
609 |     return {
610 |         "valid": is_valid,
611 |         "message": message,
612 |         "report": report
613 |     }
614 | 
615 | 
616 | # ============================================================================
617 | # Compatibility wrappers for robust tools (maintain backward compatibility)
618 | # ============================================================================
619 | 
620 | async def add_footnote_to_document_robust(
621 |     filename: str, 
622 |     paragraph_index: int, 
623 |     footnote_text: str
624 | ) -> str:
625 |     """
626 |     Robust version of add_footnote_to_document.
627 |     Maintains backward compatibility with existing API.
628 |     """
629 |     result = await add_footnote_robust_tool(
630 |         filename=filename,
631 |         paragraph_index=paragraph_index,
632 |         footnote_text=footnote_text
633 |     )
634 |     return result["message"]
635 | 
636 | 
637 | async def add_footnote_after_text_robust(
638 |     filename: str,
639 |     search_text: str,
640 |     footnote_text: str,
641 |     output_filename: Optional[str] = None
642 | ) -> str:
643 |     """
644 |     Robust version of add_footnote_after_text.
645 |     Maintains backward compatibility with existing API.
646 |     """
647 |     # Handle output filename by copying first if needed
648 |     working_file = filename
649 |     if output_filename:
650 |         import shutil
651 |         shutil.copy2(filename, output_filename)
652 |         working_file = output_filename
653 |     
654 |     result = await add_footnote_robust_tool(
655 |         filename=working_file,
656 |         search_text=search_text,
657 |         footnote_text=footnote_text
658 |     )
659 |     return result["message"]
660 | 
661 | 
662 | async def add_footnote_before_text_robust(
663 |     filename: str,
664 |     search_text: str,
665 |     footnote_text: str,
666 |     output_filename: Optional[str] = None
667 | ) -> str:
668 |     """
669 |     Robust version of add_footnote_before_text.
670 |     Note: Current robust implementation defaults to 'after' position.
671 |     """
672 |     # Handle output filename
673 |     working_file = filename
674 |     if output_filename:
675 |         import shutil
676 |         shutil.copy2(filename, output_filename)
677 |         working_file = output_filename
678 |     
679 |     result = await add_footnote_robust_tool(
680 |         filename=working_file,
681 |         search_text=search_text,
682 |         footnote_text=footnote_text
683 |     )
684 |     return result["message"]
685 | 
686 | 
687 | async def delete_footnote_from_document_robust(
688 |     filename: str,
689 |     footnote_id: Optional[int] = None,
690 |     search_text: Optional[str] = None,
691 |     output_filename: Optional[str] = None
692 | ) -> str:
693 |     """
694 |     Robust version of delete_footnote_from_document.
695 |     Maintains backward compatibility with existing API.
696 |     """
697 |     # Handle output filename
698 |     working_file = filename
699 |     if output_filename:
700 |         import shutil
701 |         shutil.copy2(filename, output_filename)
702 |         working_file = output_filename
703 |     
704 |     result = await delete_footnote_robust_tool(
705 |         filename=working_file,
706 |         footnote_id=footnote_id,
707 |         search_text=search_text
708 |     )
709 |     return result["message"]
710 | 
```

--------------------------------------------------------------------------------
/word_document_server/core/tables.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Table-related operations for Word Document Server.
  3 | """
  4 | from docx.oxml.shared import OxmlElement, qn
  5 | from docx.oxml.ns import nsdecls
  6 | from docx.oxml import parse_xml
  7 | from docx.shared import RGBColor, Inches, Cm, Pt
  8 | from docx.enum.text import WD_ALIGN_PARAGRAPH
  9 | from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
 10 | 
 11 | 
 12 | def set_cell_border(cell, **kwargs):
 13 |     """
 14 |     Set cell border properties.
 15 |     
 16 |     Args:
 17 |         cell: The cell to modify
 18 |         **kwargs: Border properties (top, bottom, left, right, val, color)
 19 |     """
 20 |     tc = cell._tc
 21 |     tcPr = tc.get_or_add_tcPr()
 22 |     
 23 |     # Create border elements
 24 |     for key, value in kwargs.items():
 25 |         if key in ['top', 'left', 'bottom', 'right']:
 26 |             tag = 'w:{}'.format(key)
 27 |             
 28 |             element = OxmlElement(tag)
 29 |             element.set(qn('w:val'), kwargs.get('val', 'single'))
 30 |             element.set(qn('w:sz'), kwargs.get('sz', '4'))
 31 |             element.set(qn('w:space'), kwargs.get('space', '0'))
 32 |             element.set(qn('w:color'), kwargs.get('color', 'auto'))
 33 |             
 34 |             tcBorders = tcPr.first_child_found_in("w:tcBorders")
 35 |             if tcBorders is None:
 36 |                 tcBorders = OxmlElement('w:tcBorders')
 37 |                 tcPr.append(tcBorders)
 38 |                 
 39 |             tcBorders.append(element)
 40 | 
 41 | 
 42 | def apply_table_style(table, has_header_row=False, border_style=None, shading=None):
 43 |     """
 44 |     Apply formatting to a table.
 45 |     
 46 |     Args:
 47 |         table: The table to format
 48 |         has_header_row: If True, formats the first row as a header
 49 |         border_style: Style for borders ('none', 'single', 'double', 'thick')
 50 |         shading: 2D list of cell background colors (by row and column)
 51 |         
 52 |     Returns:
 53 |         True if successful, False otherwise
 54 |     """
 55 |     try:
 56 |         # Format header row if requested
 57 |         if has_header_row and table.rows:
 58 |             header_row = table.rows[0]
 59 |             for cell in header_row.cells:
 60 |                 for paragraph in cell.paragraphs:
 61 |                     if paragraph.runs:
 62 |                         for run in paragraph.runs:
 63 |                             run.bold = True
 64 |         
 65 |         # Apply border style if specified
 66 |         if border_style:
 67 |             val_map = {
 68 |                 'none': 'nil',
 69 |                 'single': 'single',
 70 |                 'double': 'double',
 71 |                 'thick': 'thick'
 72 |             }
 73 |             val = val_map.get(border_style.lower(), 'single')
 74 |             
 75 |             # Apply to all cells
 76 |             for row in table.rows:
 77 |                 for cell in row.cells:
 78 |                     set_cell_border(
 79 |                         cell,
 80 |                         top=True,
 81 |                         bottom=True,
 82 |                         left=True,
 83 |                         right=True,
 84 |                         val=val,
 85 |                         color="000000"
 86 |                     )
 87 |         
 88 |         # Apply cell shading if specified
 89 |         if shading:
 90 |             for i, row_colors in enumerate(shading):
 91 |                 if i >= len(table.rows):
 92 |                     break
 93 |                 for j, color in enumerate(row_colors):
 94 |                     if j >= len(table.rows[i].cells):
 95 |                         break
 96 |                     try:
 97 |                         # Apply shading to cell
 98 |                         cell = table.rows[i].cells[j]
 99 |                         shading_elm = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color}"/>')
100 |                         cell._tc.get_or_add_tcPr().append(shading_elm)
101 |                     except:
102 |                         # Skip if color format is invalid
103 |                         pass
104 |         
105 |         return True
106 |     except Exception:
107 |         return False
108 | 
109 | 
110 | def copy_table(source_table, target_doc):
111 |     """
112 |     Copy a table from one document to another.
113 |     
114 |     Args:
115 |         source_table: The table to copy
116 |         target_doc: The document to copy the table to
117 |         
118 |     Returns:
119 |         The new table in the target document
120 |     """
121 |     # Create a new table with the same dimensions
122 |     new_table = target_doc.add_table(rows=len(source_table.rows), cols=len(source_table.columns))
123 |     
124 |     # Try to apply the same style
125 |     try:
126 |         if source_table.style:
127 |             new_table.style = source_table.style
128 |     except:
129 |         # Fall back to default grid style
130 |         try:
131 |             new_table.style = 'Table Grid'
132 |         except:
133 |             pass
134 |     
135 |     # Copy cell contents
136 |     for i, row in enumerate(source_table.rows):
137 |         for j, cell in enumerate(row.cells):
138 |             for paragraph in cell.paragraphs:
139 |                 if paragraph.text:
140 |                     new_table.cell(i, j).text = paragraph.text
141 |     
142 |     return new_table
143 | 
144 | 
145 | def set_cell_shading(cell, fill_color=None, pattern="clear", pattern_color="auto"):
146 |     """
147 |     Apply shading/filling to a table cell.
148 |     
149 |     Args:
150 |         cell: The table cell to format
151 |         fill_color: Background color (hex string like "FF0000" or RGBColor)
152 |         pattern: Shading pattern ("clear", "solid", "pct10", "pct20", etc.)
153 |         pattern_color: Pattern color for patterned fills
154 |         
155 |     Returns:
156 |         True if successful, False otherwise
157 |     """
158 |     try:
159 |         # Get or create table cell properties
160 |         tc_pr = cell._tc.get_or_add_tcPr()
161 |         
162 |         # Remove existing shading
163 |         existing_shd = tc_pr.find(qn('w:shd'))
164 |         if existing_shd is not None:
165 |             tc_pr.remove(existing_shd)
166 |         
167 |         # Create shading element
168 |         shd_attrs = {
169 |             'w:val': pattern,
170 |             'w:color': pattern_color if pattern_color != "auto" else "auto"
171 |         }
172 |         
173 |         # Set fill color
174 |         if fill_color:
175 |             if isinstance(fill_color, str):
176 |                 # Hex color string - remove # if present
177 |                 fill_color = fill_color.lstrip('#').upper()
178 |                 if len(fill_color) == 6:  # Valid hex color
179 |                     shd_attrs['w:fill'] = fill_color
180 |             elif isinstance(fill_color, RGBColor):
181 |                 # RGBColor object
182 |                 hex_color = f"{fill_color.r:02X}{fill_color.g:02X}{fill_color.b:02X}"
183 |                 shd_attrs['w:fill'] = hex_color
184 |         
185 |         # Build XML string
186 |         attr_str = ' '.join([f'{k}="{v}"' for k, v in shd_attrs.items()])
187 |         shd_xml = f'<w:shd {nsdecls("w")} {attr_str}/>'
188 |         
189 |         # Parse and append shading element
190 |         shading_elm = parse_xml(shd_xml)
191 |         tc_pr.append(shading_elm)
192 |         
193 |         return True
194 |         
195 |     except Exception as e:
196 |         print(f"Error setting cell shading: {e}")
197 |         return False
198 | 
199 | 
200 | def apply_alternating_row_shading(table, color1="FFFFFF", color2="F2F2F2"):
201 |     """
202 |     Apply alternating row colors for better readability.
203 |     
204 |     Args:
205 |         table: The table to format
206 |         color1: Color for odd rows (hex string)
207 |         color2: Color for even rows (hex string)
208 |         
209 |     Returns:
210 |         True if successful, False otherwise
211 |     """
212 |     try:
213 |         for i, row in enumerate(table.rows):
214 |             fill_color = color1 if i % 2 == 0 else color2
215 |             for cell in row.cells:
216 |                 set_cell_shading(cell, fill_color=fill_color)
217 |         return True
218 |     except Exception as e:
219 |         print(f"Error applying alternating row shading: {e}")
220 |         return False
221 | 
222 | 
223 | def highlight_header_row(table, header_color="4472C4", text_color="FFFFFF"):
224 |     """
225 |     Apply special shading to header row.
226 |     
227 |     Args:
228 |         table: The table to format
229 |         header_color: Background color for header (hex string)
230 |         text_color: Text color for header (hex string)
231 |         
232 |     Returns:
233 |         True if successful, False otherwise
234 |     """
235 |     try:
236 |         if table.rows:
237 |             for cell in table.rows[0].cells:
238 |                 # Apply background shading
239 |                 set_cell_shading(cell, fill_color=header_color)
240 |                 
241 |                 # Apply text formatting
242 |                 for paragraph in cell.paragraphs:
243 |                     for run in paragraph.runs:
244 |                         run.bold = True
245 |                         if text_color and text_color != "auto":
246 |                             # Convert hex to RGB
247 |                             try:
248 |                                 text_color = text_color.lstrip('#')
249 |                                 r = int(text_color[0:2], 16)
250 |                                 g = int(text_color[2:4], 16)
251 |                                 b = int(text_color[4:6], 16)
252 |                                 run.font.color.rgb = RGBColor(r, g, b)
253 |                             except:
254 |                                 pass  # Skip if color format is invalid
255 |         return True
256 |     except Exception as e:
257 |         print(f"Error highlighting header row: {e}")
258 |         return False
259 | 
260 | 
261 | def set_cell_shading_by_position(table, row_index, col_index, fill_color, pattern="clear"):
262 |     """
263 |     Apply shading to a specific cell by row/column position.
264 |     
265 |     Args:
266 |         table: The table containing the cell
267 |         row_index: Row index (0-based)
268 |         col_index: Column index (0-based)
269 |         fill_color: Background color (hex string)
270 |         pattern: Shading pattern
271 |         
272 |     Returns:
273 |         True if successful, False otherwise
274 |     """
275 |     try:
276 |         if (0 <= row_index < len(table.rows) and 
277 |             0 <= col_index < len(table.rows[row_index].cells)):
278 |             cell = table.rows[row_index].cells[col_index]
279 |             return set_cell_shading(cell, fill_color=fill_color, pattern=pattern)
280 |         else:
281 |             return False
282 |     except Exception as e:
283 |         print(f"Error setting cell shading by position: {e}")
284 |         return False
285 | 
286 | 
287 | def merge_cells(table, start_row, start_col, end_row, end_col):
288 |     """
289 |     Merge cells in a rectangular area.
290 |     
291 |     Args:
292 |         table: The table containing cells to merge
293 |         start_row: Starting row index (0-based)
294 |         start_col: Starting column index (0-based)
295 |         end_row: Ending row index (0-based, inclusive)
296 |         end_col: Ending column index (0-based, inclusive)
297 |         
298 |     Returns:
299 |         True if successful, False otherwise
300 |     """
301 |     try:
302 |         # Validate indices
303 |         if (start_row < 0 or start_col < 0 or end_row < 0 or end_col < 0 or
304 |             start_row >= len(table.rows) or end_row >= len(table.rows) or
305 |             start_row > end_row or start_col > end_col):
306 |             return False
307 |         
308 |         # Check if all rows have enough columns
309 |         for row_idx in range(start_row, end_row + 1):
310 |             if (start_col >= len(table.rows[row_idx].cells) or 
311 |                 end_col >= len(table.rows[row_idx].cells)):
312 |                 return False
313 |         
314 |         # Get the start and end cells
315 |         start_cell = table.cell(start_row, start_col)
316 |         end_cell = table.cell(end_row, end_col)
317 |         
318 |         # Merge the cells
319 |         start_cell.merge(end_cell)
320 |         
321 |         return True
322 |         
323 |     except Exception as e:
324 |         print(f"Error merging cells: {e}")
325 |         return False
326 | 
327 | 
328 | def merge_cells_horizontal(table, row_index, start_col, end_col):
329 |     """
330 |     Merge cells horizontally in a single row.
331 |     
332 |     Args:
333 |         table: The table containing cells to merge
334 |         row_index: Row index (0-based)
335 |         start_col: Starting column index (0-based)
336 |         end_col: Ending column index (0-based, inclusive)
337 |         
338 |     Returns:
339 |         True if successful, False otherwise
340 |     """
341 |     return merge_cells(table, row_index, start_col, row_index, end_col)
342 | 
343 | 
344 | def merge_cells_vertical(table, col_index, start_row, end_row):
345 |     """
346 |     Merge cells vertically in a single column.
347 |     
348 |     Args:
349 |         table: The table containing cells to merge
350 |         col_index: Column index (0-based)
351 |         start_row: Starting row index (0-based)
352 |         end_row: Ending row index (0-based, inclusive)
353 |         
354 |     Returns:
355 |         True if successful, False otherwise
356 |     """
357 |     return merge_cells(table, start_row, col_index, end_row, col_index)
358 | 
359 | 
360 | def set_cell_alignment(cell, horizontal="left", vertical="top"):
361 |     """
362 |     Set text alignment within a cell.
363 |     
364 |     Args:
365 |         cell: The table cell to format
366 |         horizontal: Horizontal alignment ("left", "center", "right", "justify")
367 |         vertical: Vertical alignment ("top", "center", "bottom")
368 |         
369 |     Returns:
370 |         True if successful, False otherwise
371 |     """
372 |     try:
373 |         # Set horizontal alignment for all paragraphs in the cell
374 |         for paragraph in cell.paragraphs:
375 |             if horizontal.lower() == "center":
376 |                 paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
377 |             elif horizontal.lower() == "right":
378 |                 paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
379 |             elif horizontal.lower() == "justify":
380 |                 paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
381 |             else:  # default to left
382 |                 paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
383 |         
384 |         # Set vertical alignment for the cell using XML manipulation
385 |         tc_pr = cell._tc.get_or_add_tcPr()
386 |         
387 |         # Remove existing vertical alignment
388 |         existing_valign = tc_pr.find(qn('w:vAlign'))
389 |         if existing_valign is not None:
390 |             tc_pr.remove(existing_valign)
391 |         
392 |         # Create vertical alignment element
393 |         valign_element = OxmlElement('w:vAlign')
394 |         if vertical.lower() == "center":
395 |             valign_element.set(qn('w:val'), 'center')
396 |         elif vertical.lower() == "bottom":
397 |             valign_element.set(qn('w:val'), 'bottom')
398 |         else:  # default to top
399 |             valign_element.set(qn('w:val'), 'top')
400 |         
401 |         tc_pr.append(valign_element)
402 |         
403 |         return True
404 |         
405 |     except Exception as e:
406 |         print(f"Error setting cell alignment: {e}")
407 |         return False
408 | 
409 | 
410 | def set_cell_alignment_by_position(table, row_index, col_index, horizontal="left", vertical="top"):
411 |     """
412 |     Set text alignment for a specific cell by position.
413 |     
414 |     Args:
415 |         table: The table containing the cell
416 |         row_index: Row index (0-based)
417 |         col_index: Column index (0-based)
418 |         horizontal: Horizontal alignment ("left", "center", "right", "justify")
419 |         vertical: Vertical alignment ("top", "center", "bottom")
420 |         
421 |     Returns:
422 |         True if successful, False otherwise
423 |     """
424 |     try:
425 |         if (0 <= row_index < len(table.rows) and 
426 |             0 <= col_index < len(table.rows[row_index].cells)):
427 |             cell = table.rows[row_index].cells[col_index]
428 |             return set_cell_alignment(cell, horizontal, vertical)
429 |         else:
430 |             return False
431 |     except Exception as e:
432 |         print(f"Error setting cell alignment by position: {e}")
433 |         return False
434 | 
435 | 
436 | def set_table_alignment(table, horizontal="left", vertical="top"):
437 |     """
438 |     Set text alignment for all cells in a table.
439 |     
440 |     Args:
441 |         table: The table to format
442 |         horizontal: Horizontal alignment ("left", "center", "right", "justify")
443 |         vertical: Vertical alignment ("top", "center", "bottom")
444 |         
445 |     Returns:
446 |         True if successful, False otherwise
447 |     """
448 |     try:
449 |         for row in table.rows:
450 |             for cell in row.cells:
451 |                 set_cell_alignment(cell, horizontal, vertical)
452 |         return True
453 |     except Exception as e:
454 |         print(f"Error setting table alignment: {e}")
455 |         return False
456 | 
457 | 
458 | def set_column_width(table, col_index, width, width_type="dxa"):
459 |     """
460 |     Set the width of a specific column in a table.
461 |     
462 |     Args:
463 |         table: The table to modify
464 |         col_index: Column index (0-based)
465 |         width: Column width value
466 |         width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
467 |         
468 |     Returns:
469 |         True if successful, False otherwise
470 |     """
471 |     try:
472 |         # Validate column index
473 |         if col_index < 0 or col_index >= len(table.columns):
474 |             return False
475 |         
476 |         # Convert width based on type
477 |         if width_type == "dxa":
478 |             # DXA units (twentieths of a point)
479 |             if isinstance(width, (int, float)):
480 |                 width_value = str(int(width * 20))
481 |             else:
482 |                 width_value = str(width)
483 |         elif width_type == "pct":
484 |             # Percentage (multiply by 50 for Word format)
485 |             if isinstance(width, (int, float)):
486 |                 width_value = str(int(width * 50))
487 |             else:
488 |                 width_value = str(width)
489 |         else:
490 |             width_value = str(width)
491 |         
492 |         # Iterate through all rows and set width for cells in the specified column
493 |         for row in table.rows:
494 |             if col_index < len(row.cells):
495 |                 cell = row.cells[col_index]
496 |                 tc_pr = cell._tc.get_or_add_tcPr()
497 |                 
498 |                 # Remove existing width
499 |                 existing_width = tc_pr.find(qn('w:tcW'))
500 |                 if existing_width is not None:
501 |                     tc_pr.remove(existing_width)
502 |                 
503 |                 # Create new width element
504 |                 width_element = OxmlElement('w:tcW')
505 |                 width_element.set(qn('w:w'), width_value)
506 |                 width_element.set(qn('w:type'), width_type)
507 |                 
508 |                 tc_pr.append(width_element)
509 |         
510 |         return True
511 |         
512 |     except Exception as e:
513 |         print(f"Error setting column width: {e}")
514 |         return False
515 | 
516 | 
517 | def set_column_width_by_position(table, col_index, width, width_type="dxa"):
518 |     """
519 |     Set the width of a specific column by position.
520 |     
521 |     Args:
522 |         table: The table containing the column
523 |         col_index: Column index (0-based)
524 |         width: Column width value
525 |         width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
526 |         
527 |     Returns:
528 |         True if successful, False otherwise
529 |     """
530 |     return set_column_width(table, col_index, width, width_type)
531 | 
532 | 
533 | def set_column_widths(table, widths, width_type="dxa"):
534 |     """
535 |     Set widths for multiple columns in a table.
536 |     
537 |     Args:
538 |         table: The table to modify
539 |         widths: List of width values for each column
540 |         width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
541 |         
542 |     Returns:
543 |         True if successful, False otherwise
544 |     """
545 |     try:
546 |         for col_index, width in enumerate(widths):
547 |             if col_index >= len(table.columns):
548 |                 break
549 |             if not set_column_width(table, col_index, width, width_type):
550 |                 return False
551 |         return True
552 |     except Exception as e:
553 |         print(f"Error setting column widths: {e}")
554 |         return False
555 | 
556 | 
557 | def set_table_width(table, width, width_type="dxa"):
558 |     """
559 |     Set the overall width of a table.
560 |     
561 |     Args:
562 |         table: The table to modify
563 |         width: Table width value
564 |         width_type: Width type ("dxa" for points*20, "pct" for percentage*50, "auto")
565 |         
566 |     Returns:
567 |         True if successful, False otherwise
568 |     """
569 |     try:
570 |         # Convert width based on type
571 |         if width_type == "dxa":
572 |             # DXA units (twentieths of a point)
573 |             if isinstance(width, (int, float)):
574 |                 width_value = str(int(width * 20))
575 |             else:
576 |                 width_value = str(width)
577 |         elif width_type == "pct":
578 |             # Percentage (multiply by 50 for Word format)
579 |             if isinstance(width, (int, float)):
580 |                 width_value = str(int(width * 50))
581 |             else:
582 |                 width_value = str(width)
583 |         else:
584 |             width_value = str(width)
585 |         
586 |         # Get table element and properties
587 |         tbl = table._tbl
588 |         
589 |         # Get or create table properties
590 |         tbl_pr = tbl.find(qn('w:tblPr'))
591 |         if tbl_pr is None:
592 |             tbl_pr = OxmlElement('w:tblPr')
593 |             tbl.insert(0, tbl_pr)
594 |         
595 |         # Remove existing table width
596 |         existing_width = tbl_pr.find(qn('w:tblW'))
597 |         if existing_width is not None:
598 |             tbl_pr.remove(existing_width)
599 |         
600 |         # Create new table width element
601 |         width_element = OxmlElement('w:tblW')
602 |         width_element.set(qn('w:w'), width_value)
603 |         width_element.set(qn('w:type'), width_type)
604 |         
605 |         tbl_pr.append(width_element)
606 |         
607 |         return True
608 |         
609 |     except Exception as e:
610 |         print(f"Error setting table width: {e}")
611 |         return False
612 | 
613 | 
614 | def auto_fit_table(table):
615 |     """
616 |     Set table to auto-fit columns based on content.
617 |     
618 |     Args:
619 |         table: The table to modify
620 |         
621 |     Returns:
622 |         True if successful, False otherwise
623 |     """
624 |     try:
625 |         # Get table element and properties
626 |         tbl = table._tbl
627 |         
628 |         # Get or create table properties
629 |         tbl_pr = tbl.find(qn('w:tblPr'))
630 |         if tbl_pr is None:
631 |             tbl_pr = OxmlElement('w:tblPr')
632 |             tbl.insert(0, tbl_pr)
633 |         
634 |         # Remove existing layout
635 |         existing_layout = tbl_pr.find(qn('w:tblLayout'))
636 |         if existing_layout is not None:
637 |             tbl_pr.remove(existing_layout)
638 |         
639 |         # Create auto layout element
640 |         layout_element = OxmlElement('w:tblLayout')
641 |         layout_element.set(qn('w:type'), 'autofit')
642 |         
643 |         tbl_pr.append(layout_element)
644 |         
645 |         # Set all column widths to auto
646 |         for col_index in range(len(table.columns)):
647 |             set_column_width(table, col_index, 0, "auto")
648 |         
649 |         return True
650 |         
651 |     except Exception as e:
652 |         print(f"Error setting auto-fit table: {e}")
653 |         return False
654 | 
655 | 
656 | def format_cell_text(cell, text_content=None, bold=None, italic=None, underline=None, 
657 |                     color=None, font_size=None, font_name=None):
658 |     """
659 |     Format text within a table cell.
660 |     
661 |     Args:
662 |         cell: The table cell to format
663 |         text_content: Optional new text content for the cell
664 |         bold: Set text bold (True/False)
665 |         italic: Set text italic (True/False)
666 |         underline: Set text underlined (True/False)
667 |         color: Text color (hex string like "FF0000" or color name)
668 |         font_size: Font size in points
669 |         font_name: Font name/family
670 |         
671 |     Returns:
672 |         True if successful, False otherwise
673 |     """
674 |     try:
675 |         # Set text content if provided
676 |         if text_content is not None:
677 |             cell.text = str(text_content)
678 |         
679 |         # Apply formatting to all paragraphs and runs in the cell
680 |         for paragraph in cell.paragraphs:
681 |             for run in paragraph.runs:
682 |                 if bold is not None:
683 |                     run.bold = bold
684 |                 if italic is not None:
685 |                     run.italic = italic
686 |                 if underline is not None:
687 |                     run.underline = underline
688 |                     
689 |                 if font_size is not None:
690 |                     from docx.shared import Pt
691 |                     run.font.size = Pt(font_size)
692 |                     
693 |                 if font_name is not None:
694 |                     run.font.name = font_name
695 |                     
696 |                 if color is not None:
697 |                     from docx.shared import RGBColor
698 |                     # Define common RGB colors
699 |                     color_map = {
700 |                         'red': RGBColor(255, 0, 0),
701 |                         'blue': RGBColor(0, 0, 255),
702 |                         'green': RGBColor(0, 128, 0),
703 |                         'yellow': RGBColor(255, 255, 0),
704 |                         'black': RGBColor(0, 0, 0),
705 |                         'gray': RGBColor(128, 128, 128),
706 |                         'grey': RGBColor(128, 128, 128),
707 |                         'white': RGBColor(255, 255, 255),
708 |                         'purple': RGBColor(128, 0, 128),
709 |                         'orange': RGBColor(255, 165, 0)
710 |                     }
711 |                     
712 |                     try:
713 |                         if color.lower() in color_map:
714 |                             # Use predefined RGB color
715 |                             run.font.color.rgb = color_map[color.lower()]
716 |                         elif color.startswith('#'):
717 |                             # Hex color string
718 |                             hex_color = color.lstrip('#')
719 |                             if len(hex_color) == 6:
720 |                                 r = int(hex_color[0:2], 16)
721 |                                 g = int(hex_color[2:4], 16)
722 |                                 b = int(hex_color[4:6], 16)
723 |                                 run.font.color.rgb = RGBColor(r, g, b)
724 |                         else:
725 |                             # Try hex without #
726 |                             if len(color) == 6:
727 |                                 r = int(color[0:2], 16)
728 |                                 g = int(color[2:4], 16)
729 |                                 b = int(color[4:6], 16)
730 |                                 run.font.color.rgb = RGBColor(r, g, b)
731 |                     except Exception:
732 |                         # If color parsing fails, default to black
733 |                         run.font.color.rgb = RGBColor(0, 0, 0)
734 |         
735 |         return True
736 |         
737 |     except Exception as e:
738 |         print(f"Error formatting cell text: {e}")
739 |         return False
740 | 
741 | 
742 | def format_cell_text_by_position(table, row_index, col_index, text_content=None, 
743 |                                  bold=None, italic=None, underline=None, color=None, 
744 |                                  font_size=None, font_name=None):
745 |     """
746 |     Format text in a specific table cell by position.
747 |     
748 |     Args:
749 |         table: The table containing the cell
750 |         row_index: Row index (0-based)
751 |         col_index: Column index (0-based)
752 |         text_content: Optional new text content for the cell
753 |         bold: Set text bold (True/False)
754 |         italic: Set text italic (True/False)
755 |         underline: Set text underlined (True/False)
756 |         color: Text color (hex string or color name)
757 |         font_size: Font size in points
758 |         font_name: Font name/family
759 |         
760 |     Returns:
761 |         True if successful, False otherwise
762 |     """
763 |     try:
764 |         if (0 <= row_index < len(table.rows) and 
765 |             0 <= col_index < len(table.rows[row_index].cells)):
766 |             cell = table.rows[row_index].cells[col_index]
767 |             return format_cell_text(cell, text_content, bold, italic, underline, 
768 |                                    color, font_size, font_name)
769 |         else:
770 |             return False
771 |     except Exception as e:
772 |         print(f"Error formatting cell text by position: {e}")
773 |         return False
774 | 
775 | 
776 | def set_cell_padding(cell, top=None, bottom=None, left=None, right=None, unit="dxa"):
777 |     """
778 |     Set padding/margins for a table cell.
779 |     
780 |     Args:
781 |         cell: The table cell to format
782 |         top: Top padding value
783 |         bottom: Bottom padding value
784 |         left: Left padding value
785 |         right: Right padding value
786 |         unit: Unit type ("dxa" for twentieths of a point, "pct" for percentage)
787 |         
788 |     Returns:
789 |         True if successful, False otherwise
790 |     """
791 |     try:
792 |         # Get or create table cell properties
793 |         tc_pr = cell._tc.get_or_add_tcPr()
794 |         
795 |         # Remove existing margins
796 |         existing_margins = tc_pr.find(qn('w:tcMar'))
797 |         if existing_margins is not None:
798 |             tc_pr.remove(existing_margins)
799 |         
800 |         # Create margins element if any padding is specified
801 |         if any(p is not None for p in [top, bottom, left, right]):
802 |             margins_element = OxmlElement('w:tcMar')
803 |             
804 |             # Add individual margin elements
805 |             margin_sides = {
806 |                 'w:top': top,
807 |                 'w:bottom': bottom,
808 |                 'w:left': left,
809 |                 'w:right': right
810 |             }
811 |             
812 |             for side, value in margin_sides.items():
813 |                 if value is not None:
814 |                     margin_el = OxmlElement(side)
815 |                     if unit == "dxa":
816 |                         # DXA units (twentieths of a point)
817 |                         margin_el.set(qn('w:w'), str(int(value * 20)))
818 |                         margin_el.set(qn('w:type'), 'dxa')
819 |                     elif unit == "pct":
820 |                         # Percentage
821 |                         margin_el.set(qn('w:w'), str(int(value * 50)))
822 |                         margin_el.set(qn('w:type'), 'pct')
823 |                     else:
824 |                         # Default to DXA
825 |                         margin_el.set(qn('w:w'), str(int(value * 20)))
826 |                         margin_el.set(qn('w:type'), 'dxa')
827 |                     
828 |                     margins_element.append(margin_el)
829 |             
830 |             tc_pr.append(margins_element)
831 |         
832 |         return True
833 |         
834 |     except Exception as e:
835 |         print(f"Error setting cell padding: {e}")
836 |         return False
837 | 
838 | 
839 | def set_cell_padding_by_position(table, row_index, col_index, top=None, bottom=None, 
840 |                                 left=None, right=None, unit="dxa"):
841 |     """
842 |     Set padding for a specific table cell by position.
843 |     
844 |     Args:
845 |         table: The table containing the cell
846 |         row_index: Row index (0-based)
847 |         col_index: Column index (0-based)
848 |         top: Top padding value
849 |         bottom: Bottom padding value
850 |         left: Left padding value
851 |         right: Right padding value
852 |         unit: Unit type ("dxa" for twentieths of a point, "pct" for percentage)
853 |         
854 |     Returns:
855 |         True if successful, False otherwise
856 |     """
857 |     try:
858 |         if (0 <= row_index < len(table.rows) and 
859 |             0 <= col_index < len(table.rows[row_index].cells)):
860 |             cell = table.rows[row_index].cells[col_index]
861 |             return set_cell_padding(cell, top, bottom, left, right, unit)
862 |         else:
863 |             return False
864 |     except Exception as e:
865 |         print(f"Error setting cell padding by position: {e}")
866 |         return False
867 | 
```

--------------------------------------------------------------------------------
/word_document_server/core/footnotes.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | Consolidated footnote functionality for Word documents.
  3 | This module combines all footnote implementations with proper namespace handling and Word compliance.
  4 | """
  5 | 
  6 | import os
  7 | import zipfile
  8 | import tempfile
  9 | from typing import Optional, Tuple, Dict, Any, List
 10 | from lxml import etree
 11 | from docx import Document
 12 | from docx.oxml.ns import qn
 13 | 
 14 | # Namespace definitions
 15 | W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
 16 | R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
 17 | CT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types'
 18 | REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
 19 | 
 20 | # Constants
 21 | RESERVED_FOOTNOTE_IDS = {-1, 0, 1}  # Reserved for separators and Word internals
 22 | MIN_FOOTNOTE_ID = -2147483648
 23 | MAX_FOOTNOTE_ID = 32767
 24 | MAX_RELATIONSHIP_ID_LENGTH = 255
 25 | FOOTNOTE_REF_STYLE_INDEX = 38
 26 | FOOTNOTE_TEXT_STYLE_INDEX = 29
 27 | 
 28 | 
 29 | # ============================================================================
 30 | # BASIC UTILITIES (from footnotes.py)
 31 | # ============================================================================
 32 | 
 33 | def find_footnote_references(doc):
 34 |     """Find all footnote references in the document."""
 35 |     footnote_refs = []
 36 |     for para_idx, para in enumerate(doc.paragraphs):
 37 |         for run_idx, run in enumerate(para.runs):
 38 |             # Check if this run has superscript formatting
 39 |             if run.font.superscript:
 40 |                 # Check if it's likely a footnote reference
 41 |                 if run.text.isdigit() or run.text in "¹²³⁴⁵⁶⁷⁸⁹⁰†‡§¶":
 42 |                     footnote_refs.append({
 43 |                         'paragraph_index': para_idx,
 44 |                         'run_index': run_idx,
 45 |                         'text': run.text,
 46 |                         'paragraph': para,
 47 |                         'run': run
 48 |                     })
 49 |     return footnote_refs
 50 | 
 51 | 
 52 | def get_format_symbols(format_type: str, count: int) -> list:
 53 |     """Generate format symbols for footnote numbering."""
 54 |     symbols = []
 55 |     
 56 |     if format_type == "1, 2, 3":
 57 |         symbols = [str(i) for i in range(1, count + 1)]
 58 |     elif format_type == "i, ii, iii":
 59 |         # Roman numerals
 60 |         roman_map = [(10, 'x'), (9, 'ix'), (5, 'v'), (4, 'iv'), (1, 'i')]
 61 |         for i in range(1, count + 1):
 62 |             result = ''
 63 |             num = i
 64 |             for value, numeral in roman_map:
 65 |                 count_sym, num = divmod(num, value)
 66 |                 result += numeral * count_sym
 67 |             symbols.append(result)
 68 |     elif format_type == "a, b, c":
 69 |         # Alphabetic
 70 |         for i in range(1, count + 1):
 71 |             if i <= 26:
 72 |                 symbols.append(chr(96 + i))
 73 |             else:
 74 |                 # For numbers > 26, use aa, ab, etc.
 75 |                 first = (i - 1) // 26
 76 |                 second = (i - 1) % 26 + 1
 77 |                 symbols.append(chr(96 + first) + chr(96 + second))
 78 |     elif format_type == "*, †, ‡":
 79 |         # Special symbols
 80 |         special = ['*', '†', '‡', '§', '¶', '#']
 81 |         for i in range(1, count + 1):
 82 |             if i <= len(special):
 83 |                 symbols.append(special[i - 1])
 84 |             else:
 85 |                 # Repeat symbols with numbers
 86 |                 symbols.append(special[(i - 1) % len(special)] + str((i - 1) // len(special) + 1))
 87 |     else:
 88 |         # Default to numeric
 89 |         symbols = [str(i) for i in range(1, count + 1)]
 90 |     
 91 |     return symbols
 92 | 
 93 | 
 94 | def customize_footnote_formatting(doc, footnote_refs, format_symbols, start_number, footnote_style):
 95 |     """Apply custom formatting to footnotes."""
 96 |     count = 0
 97 |     for i, ref in enumerate(footnote_refs):
 98 |         if i < len(format_symbols):
 99 |             # Update the footnote reference text
100 |             ref['run'].text = format_symbols[i]
101 |             ref['run'].font.superscript = True
102 |             
103 |             # Apply style if available
104 |             if footnote_style:
105 |                 try:
106 |                     ref['paragraph'].style = footnote_style
107 |                 except:
108 |                     pass
109 |             count += 1
110 |     return count
111 | 
112 | 
113 | # ============================================================================
114 | # ROBUST IMPLEMENTATION (consolidated from footnotes_robust.py)
115 | # ============================================================================
116 | 
117 | def _get_safe_footnote_id(footnotes_root) -> int:
118 |     """Get a safe footnote ID avoiding conflicts and reserved values."""
119 |     nsmap = {'w': W_NS}
120 |     existing_footnotes = footnotes_root.xpath('//w:footnote', namespaces=nsmap)
121 |     
122 |     used_ids = set()
123 |     for fn in existing_footnotes:
124 |         fn_id = fn.get(f'{{{W_NS}}}id')
125 |         if fn_id:
126 |             try:
127 |                 used_ids.add(int(fn_id))
128 |             except ValueError:
129 |                 pass
130 |     
131 |     # Start from 2 to avoid reserved IDs
132 |     candidate_id = 2
133 |     while candidate_id in used_ids or candidate_id in RESERVED_FOOTNOTE_IDS:
134 |         candidate_id += 1
135 |         if candidate_id > MAX_FOOTNOTE_ID:
136 |             raise ValueError("No available footnote IDs")
137 |     
138 |     return candidate_id
139 | 
140 | 
141 | def _ensure_content_types(content_types_xml: bytes) -> bytes:
142 |     """Ensure content types with proper namespace handling."""
143 |     ct_tree = etree.fromstring(content_types_xml)
144 |     
145 |     # Content Types uses default namespace - must use namespace-aware XPath
146 |     nsmap = {'ct': CT_NS}
147 |     
148 |     # Check for existing override with proper namespace
149 |     existing_overrides = ct_tree.xpath(
150 |         "//ct:Override[@PartName='/word/footnotes.xml']",
151 |         namespaces=nsmap
152 |     )
153 |     
154 |     if existing_overrides:
155 |         return content_types_xml  # Already exists
156 |     
157 |     # Add override with proper namespace
158 |     override = etree.Element(f'{{{CT_NS}}}Override',
159 |         PartName='/word/footnotes.xml',
160 |         ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml'
161 |     )
162 |     ct_tree.append(override)
163 |     
164 |     return etree.tostring(ct_tree, encoding='UTF-8', xml_declaration=True, standalone="yes")
165 | 
166 | 
167 | def _ensure_document_rels(document_rels_xml: bytes) -> bytes:
168 |     """Ensure document relationships with proper namespace handling."""
169 |     rels_tree = etree.fromstring(document_rels_xml)
170 |     
171 |     # Relationships uses default namespace - must use namespace-aware XPath
172 |     nsmap = {'r': REL_NS}
173 |     
174 |     # Check for existing footnotes relationship with proper namespace
175 |     existing_footnote_rels = rels_tree.xpath(
176 |         "//r:Relationship[contains(@Type, 'footnotes')]",
177 |         namespaces=nsmap
178 |     )
179 |     
180 |     if existing_footnote_rels:
181 |         return document_rels_xml  # Already exists
182 |     
183 |     # Generate unique rId using namespace-aware XPath
184 |     all_rels = rels_tree.xpath('//r:Relationship', namespaces=nsmap)
185 |     existing_ids = {rel.get('Id') for rel in all_rels if rel.get('Id')}
186 |     rid_num = 1
187 |     while f'rId{rid_num}' in existing_ids:
188 |         rid_num += 1
189 |     
190 |     # Validate ID length
191 |     new_rid = f'rId{rid_num}'
192 |     if len(new_rid) > MAX_RELATIONSHIP_ID_LENGTH:
193 |         raise ValueError(f"Relationship ID too long: {new_rid}")
194 |     
195 |     # Create relationship with proper namespace
196 |     rel = etree.Element(f'{{{REL_NS}}}Relationship',
197 |         Id=new_rid,
198 |         Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes',
199 |         Target='footnotes.xml'
200 |     )
201 |     rels_tree.append(rel)
202 |     
203 |     return etree.tostring(rels_tree, encoding='UTF-8', xml_declaration=True, standalone="yes")
204 | 
205 | 
206 | def _create_minimal_footnotes_xml() -> bytes:
207 |     """Create minimal footnotes.xml with separators."""
208 |     xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
209 | <w:footnotes xmlns:w="{W_NS}">
210 |     <w:footnote w:type="separator" w:id="-1">
211 |         <w:p>
212 |             <w:pPr>
213 |                 <w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
214 |             </w:pPr>
215 |             <w:r>
216 |                 <w:separator/>
217 |             </w:r>
218 |         </w:p>
219 |     </w:footnote>
220 |     <w:footnote w:type="continuationSeparator" w:id="0">
221 |         <w:p>
222 |             <w:pPr>
223 |                 <w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
224 |             </w:pPr>
225 |             <w:r>
226 |                 <w:continuationSeparator/>
227 |             </w:r>
228 |         </w:p>
229 |     </w:footnote>
230 | </w:footnotes>'''
231 |     return xml.encode('utf-8')
232 | 
233 | 
234 | def _ensure_footnote_styles(styles_root):
235 |     """Ensure both FootnoteReference and FootnoteText styles exist."""
236 |     nsmap = {'w': W_NS}
237 |     
238 |     # Check for FootnoteReference style
239 |     ref_style = styles_root.xpath('//w:style[@w:styleId="FootnoteReference"]', namespaces=nsmap)
240 |     if not ref_style:
241 |         # Create FootnoteReference character style
242 |         style = etree.Element(f'{{{W_NS}}}style',
243 |             attrib={
244 |                 f'{{{W_NS}}}type': 'character',
245 |                 f'{{{W_NS}}}styleId': 'FootnoteReference'
246 |             }
247 |         )
248 |         name = etree.SubElement(style, f'{{{W_NS}}}name')
249 |         name.set(f'{{{W_NS}}}val', 'footnote reference')
250 |         
251 |         base = etree.SubElement(style, f'{{{W_NS}}}basedOn')
252 |         base.set(f'{{{W_NS}}}val', 'DefaultParagraphFont')
253 |         
254 |         rPr = etree.SubElement(style, f'{{{W_NS}}}rPr')
255 |         vert_align = etree.SubElement(rPr, f'{{{W_NS}}}vertAlign')
256 |         vert_align.set(f'{{{W_NS}}}val', 'superscript')
257 |         
258 |         styles_root.append(style)
259 |     
260 |     # Check for FootnoteText style
261 |     text_style = styles_root.xpath('//w:style[@w:styleId="FootnoteText"]', namespaces=nsmap)
262 |     if not text_style:
263 |         # Create FootnoteText paragraph style
264 |         style = etree.Element(f'{{{W_NS}}}style',
265 |             attrib={
266 |                 f'{{{W_NS}}}type': 'paragraph',
267 |                 f'{{{W_NS}}}styleId': 'FootnoteText'
268 |             }
269 |         )
270 |         name = etree.SubElement(style, f'{{{W_NS}}}name')
271 |         name.set(f'{{{W_NS}}}val', 'footnote text')
272 |         
273 |         base = etree.SubElement(style, f'{{{W_NS}}}basedOn')
274 |         base.set(f'{{{W_NS}}}val', 'Normal')
275 |         
276 |         pPr = etree.SubElement(style, f'{{{W_NS}}}pPr')
277 |         sz = etree.SubElement(pPr, f'{{{W_NS}}}sz')
278 |         sz.set(f'{{{W_NS}}}val', '20')  # 10pt
279 |         
280 |         styles_root.append(style)
281 | 
282 | 
283 | def add_footnote_robust(
284 |     filename: str,
285 |     search_text: Optional[str] = None,
286 |     paragraph_index: Optional[int] = None,
287 |     footnote_text: str = "",
288 |     output_filename: Optional[str] = None,
289 |     position: str = "after",
290 |     validate_location: bool = True,
291 |     auto_repair: bool = False
292 | ) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
293 |     """
294 |     Add a footnote with robust validation and error handling.
295 |     
296 |     This is the main production-ready function with all fixes applied.
297 |     """
298 |     
299 |     # Validate inputs
300 |     if not search_text and paragraph_index is None:
301 |         return False, "Must provide either search_text or paragraph_index", None
302 |     
303 |     if search_text and paragraph_index is not None:
304 |         return False, "Cannot provide both search_text and paragraph_index", None
305 |     
306 |     if not os.path.exists(filename):
307 |         return False, f"File not found: {filename}", None
308 |     
309 |     # Set working file
310 |     working_file = output_filename if output_filename else filename
311 |     if output_filename and filename != output_filename:
312 |         import shutil
313 |         shutil.copy2(filename, output_filename)
314 |     
315 |     try:
316 |         # Read document parts
317 |         doc_parts = {}
318 |         with zipfile.ZipFile(filename, 'r') as zin:
319 |             doc_parts['document'] = zin.read('word/document.xml')
320 |             doc_parts['content_types'] = zin.read('[Content_Types].xml')
321 |             doc_parts['document_rels'] = zin.read('word/_rels/document.xml.rels')
322 |             
323 |             # Read or create footnotes.xml
324 |             if 'word/footnotes.xml' in zin.namelist():
325 |                 doc_parts['footnotes'] = zin.read('word/footnotes.xml')
326 |             else:
327 |                 doc_parts['footnotes'] = _create_minimal_footnotes_xml()
328 |             
329 |             # Read styles
330 |             if 'word/styles.xml' in zin.namelist():
331 |                 doc_parts['styles'] = zin.read('word/styles.xml')
332 |             else:
333 |                 # Create minimal styles
334 |                 doc_parts['styles'] = b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>'
335 |         
336 |         # Parse XML documents
337 |         doc_root = etree.fromstring(doc_parts['document'])
338 |         footnotes_root = etree.fromstring(doc_parts['footnotes'])
339 |         styles_root = etree.fromstring(doc_parts['styles'])
340 |         
341 |         # Find target location
342 |         nsmap = {'w': W_NS}
343 |         
344 |         if search_text:
345 |             # Search for text in paragraphs
346 |             found = False
347 |             for para in doc_root.xpath('//w:p', namespaces=nsmap):
348 |                 para_text = ''.join(para.xpath('.//w:t/text()', namespaces=nsmap))
349 |                 if search_text in para_text:
350 |                     target_para = para
351 |                     found = True
352 |                     break
353 |             
354 |             if not found:
355 |                 return False, f"Text '{search_text}' not found in document", None
356 |         else:
357 |             # Use paragraph index
358 |             paragraphs = doc_root.xpath('//w:p', namespaces=nsmap)
359 |             if paragraph_index >= len(paragraphs):
360 |                 return False, f"Paragraph index {paragraph_index} out of range", None
361 |             target_para = paragraphs[paragraph_index]
362 |         
363 |         # Validate location if requested
364 |         if validate_location:
365 |             # Check if paragraph is in header/footer
366 |             parent = target_para.getparent()
367 |             while parent is not None:
368 |                 if parent.tag in [f'{{{W_NS}}}hdr', f'{{{W_NS}}}ftr']:
369 |                     return False, "Cannot add footnote in header/footer", None
370 |                 parent = parent.getparent()
371 |         
372 |         # Get safe footnote ID
373 |         footnote_id = _get_safe_footnote_id(footnotes_root)
374 |         
375 |         # Add footnote reference to document
376 |         if position == "after":
377 |             # Find last run in paragraph or create one
378 |             runs = target_para.xpath('.//w:r', namespaces=nsmap)
379 |             if runs:
380 |                 last_run = runs[-1]
381 |                 # Insert after last run
382 |                 insert_pos = target_para.index(last_run) + 1
383 |             else:
384 |                 insert_pos = len(target_para)
385 |         else:  # before
386 |             # Find first run with text
387 |             runs = target_para.xpath('.//w:r[w:t]', namespaces=nsmap)
388 |             if runs:
389 |                 first_run = runs[0]
390 |                 insert_pos = target_para.index(first_run)
391 |             else:
392 |                 insert_pos = 0
393 |         
394 |         # Create footnote reference run
395 |         ref_run = etree.Element(f'{{{W_NS}}}r')
396 |         
397 |         # Add run properties with superscript
398 |         rPr = etree.SubElement(ref_run, f'{{{W_NS}}}rPr')
399 |         rStyle = etree.SubElement(rPr, f'{{{W_NS}}}rStyle')
400 |         rStyle.set(f'{{{W_NS}}}val', 'FootnoteReference')
401 |         
402 |         # Add footnote reference
403 |         fn_ref = etree.SubElement(ref_run, f'{{{W_NS}}}footnoteReference')
404 |         fn_ref.set(f'{{{W_NS}}}id', str(footnote_id))
405 |         
406 |         # Insert the reference run
407 |         target_para.insert(insert_pos, ref_run)
408 |         
409 |         # Add footnote content
410 |         new_footnote = etree.Element(f'{{{W_NS}}}footnote',
411 |             attrib={f'{{{W_NS}}}id': str(footnote_id)}
412 |         )
413 |         
414 |         # Add paragraph to footnote
415 |         fn_para = etree.SubElement(new_footnote, f'{{{W_NS}}}p')
416 |         
417 |         # Add paragraph properties
418 |         pPr = etree.SubElement(fn_para, f'{{{W_NS}}}pPr')
419 |         pStyle = etree.SubElement(pPr, f'{{{W_NS}}}pStyle')
420 |         pStyle.set(f'{{{W_NS}}}val', 'FootnoteText')
421 |         
422 |         # Add the footnote reference marker
423 |         marker_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
424 |         marker_rPr = etree.SubElement(marker_run, f'{{{W_NS}}}rPr')
425 |         marker_rStyle = etree.SubElement(marker_rPr, f'{{{W_NS}}}rStyle')
426 |         marker_rStyle.set(f'{{{W_NS}}}val', 'FootnoteReference')
427 |         marker_ref = etree.SubElement(marker_run, f'{{{W_NS}}}footnoteRef')
428 |         
429 |         # Add space after marker
430 |         space_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
431 |         space_text = etree.SubElement(space_run, f'{{{W_NS}}}t')
432 |         space_text.set(f'{{{XML_NS}}}space', 'preserve')
433 |         space_text.text = ' '
434 |         
435 |         # Add footnote text
436 |         text_run = etree.SubElement(fn_para, f'{{{W_NS}}}r')
437 |         text_elem = etree.SubElement(text_run, f'{{{W_NS}}}t')
438 |         text_elem.text = footnote_text
439 |         
440 |         # Append footnote to footnotes.xml
441 |         footnotes_root.append(new_footnote)
442 |         
443 |         # Ensure styles exist
444 |         _ensure_footnote_styles(styles_root)
445 |         
446 |         # Ensure coherence
447 |         content_types_xml = _ensure_content_types(doc_parts['content_types'])
448 |         document_rels_xml = _ensure_document_rels(doc_parts['document_rels'])
449 |         
450 |         # Write modified document
451 |         temp_file = working_file + '.tmp'
452 |         with zipfile.ZipFile(temp_file, 'w', zipfile.ZIP_DEFLATED) as zout:
453 |             with zipfile.ZipFile(filename, 'r') as zin:
454 |                 # Copy unchanged files
455 |                 for item in zin.infolist():
456 |                     if item.filename not in [
457 |                         'word/document.xml', 'word/footnotes.xml', 'word/styles.xml',
458 |                         '[Content_Types].xml', 'word/_rels/document.xml.rels'
459 |                     ]:
460 |                         zout.writestr(item, zin.read(item.filename))
461 |             
462 |             # Write modified files
463 |             zout.writestr('word/document.xml',
464 |                 etree.tostring(doc_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
465 |             zout.writestr('word/footnotes.xml',
466 |                 etree.tostring(footnotes_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
467 |             zout.writestr('word/styles.xml',
468 |                 etree.tostring(styles_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
469 |             zout.writestr('[Content_Types].xml', content_types_xml)
470 |             zout.writestr('word/_rels/document.xml.rels', document_rels_xml)
471 |         
472 |         # Replace original with temp file
473 |         os.replace(temp_file, working_file)
474 |         
475 |         details = {
476 |             'footnote_id': footnote_id,
477 |             'location': 'search_text' if search_text else 'paragraph_index',
478 |             'styles_created': ['FootnoteReference', 'FootnoteText'],
479 |             'coherence_verified': True
480 |         }
481 |         
482 |         return True, f"Successfully added footnote (ID: {footnote_id}) to {working_file}", details
483 |         
484 |     except Exception as e:
485 |         # Clean up temp file if exists
486 |         temp_file = working_file + '.tmp'
487 |         if os.path.exists(temp_file):
488 |             os.remove(temp_file)
489 |         return False, f"Error adding footnote: {str(e)}", None
490 | 
491 | 
492 | def delete_footnote_robust(
493 |     filename: str,
494 |     footnote_id: Optional[int] = None,
495 |     search_text: Optional[str] = None,
496 |     output_filename: Optional[str] = None,
497 |     clean_orphans: bool = True
498 | ) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
499 |     """Delete a footnote with comprehensive cleanup."""
500 |     
501 |     if not footnote_id and not search_text:
502 |         return False, "Must provide either footnote_id or search_text", None
503 |     
504 |     if not os.path.exists(filename):
505 |         return False, f"File not found: {filename}", None
506 |     
507 |     # Set working file
508 |     working_file = output_filename if output_filename else filename
509 |     if output_filename and filename != output_filename:
510 |         import shutil
511 |         shutil.copy2(filename, output_filename)
512 |     
513 |     try:
514 |         # Read document parts
515 |         with zipfile.ZipFile(filename, 'r') as zin:
516 |             doc_xml = zin.read('word/document.xml')
517 |             
518 |             if 'word/footnotes.xml' not in zin.namelist():
519 |                 return False, "No footnotes in document", None
520 |             
521 |             footnotes_xml = zin.read('word/footnotes.xml')
522 |         
523 |         # Parse documents
524 |         doc_root = etree.fromstring(doc_xml)
525 |         footnotes_root = etree.fromstring(footnotes_xml)
526 |         nsmap = {'w': W_NS}
527 |         
528 |         # Find footnote to delete
529 |         if search_text:
530 |             # Find footnote reference near text
531 |             for para in doc_root.xpath('//w:p', namespaces=nsmap):
532 |                 para_text = ''.join(para.xpath('.//w:t/text()', namespaces=nsmap))
533 |                 if search_text in para_text:
534 |                     # Look for footnote reference in this paragraph
535 |                     fn_refs = para.xpath('.//w:footnoteReference', namespaces=nsmap)
536 |                     if fn_refs:
537 |                         footnote_id = int(fn_refs[0].get(f'{{{W_NS}}}id'))
538 |                         break
539 |             
540 |             if not footnote_id:
541 |                 return False, f"No footnote found near text '{search_text}'", None
542 |         
543 |         # Remove footnote reference from document
544 |         refs_removed = 0
545 |         for fn_ref in doc_root.xpath(f'//w:footnoteReference[@w:id="{footnote_id}"]', namespaces=nsmap):
546 |             # Remove the entire run containing the reference
547 |             run = fn_ref.getparent()
548 |             if run is not None and run.tag == f'{{{W_NS}}}r':
549 |                 para = run.getparent()
550 |                 if para is not None:
551 |                     para.remove(run)
552 |                     refs_removed += 1
553 |         
554 |         if refs_removed == 0:
555 |             return False, f"Footnote {footnote_id} not found", None
556 |         
557 |         # Remove footnote content
558 |         content_removed = 0
559 |         for fn in footnotes_root.xpath(f'//w:footnote[@w:id="{footnote_id}"]', namespaces=nsmap):
560 |             footnotes_root.remove(fn)
561 |             content_removed += 1
562 |         
563 |         # Clean orphans if requested
564 |         orphans_removed = []
565 |         if clean_orphans:
566 |             # Find all referenced IDs
567 |             referenced_ids = set()
568 |             for ref in doc_root.xpath('//w:footnoteReference', namespaces=nsmap):
569 |                 ref_id = ref.get(f'{{{W_NS}}}id')
570 |                 if ref_id:
571 |                     referenced_ids.add(ref_id)
572 |             
573 |             # Remove unreferenced footnotes (except separators)
574 |             for fn in footnotes_root.xpath('//w:footnote', namespaces=nsmap):
575 |                 fn_id = fn.get(f'{{{W_NS}}}id')
576 |                 if fn_id and fn_id not in referenced_ids and fn_id not in ['-1', '0']:
577 |                     footnotes_root.remove(fn)
578 |                     orphans_removed.append(fn_id)
579 |         
580 |         # Write modified document
581 |         temp_file = working_file + '.tmp'
582 |         with zipfile.ZipFile(temp_file, 'w', zipfile.ZIP_DEFLATED) as zout:
583 |             with zipfile.ZipFile(filename, 'r') as zin:
584 |                 for item in zin.infolist():
585 |                     if item.filename == 'word/document.xml':
586 |                         zout.writestr(item,
587 |                             etree.tostring(doc_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
588 |                     elif item.filename == 'word/footnotes.xml':
589 |                         zout.writestr(item,
590 |                             etree.tostring(footnotes_root, encoding='UTF-8', xml_declaration=True, standalone="yes"))
591 |                     else:
592 |                         zout.writestr(item, zin.read(item.filename))
593 |         
594 |         os.replace(temp_file, working_file)
595 |         
596 |         details = {
597 |             'footnote_id': footnote_id,
598 |             'references_removed': refs_removed,
599 |             'content_removed': content_removed,
600 |             'orphans_removed': orphans_removed
601 |         }
602 |         
603 |         message = f"Successfully deleted footnote {footnote_id}"
604 |         if orphans_removed:
605 |             message += f" and {len(orphans_removed)} orphaned footnotes"
606 |         
607 |         return True, message, details
608 |         
609 |     except Exception as e:
610 |         return False, f"Error deleting footnote: {str(e)}", None
611 | 
612 | 
613 | def validate_document_footnotes(filename: str) -> Tuple[bool, str, Dict[str, Any]]:
614 |     """Validate all footnotes in a document for coherence and compliance."""
615 |     
616 |     if not os.path.exists(filename):
617 |         return False, f"File not found: {filename}", {}
618 |     
619 |     report = {
620 |         'total_references': 0,
621 |         'total_content': 0,
622 |         'id_conflicts': [],
623 |         'orphaned_content': [],
624 |         'missing_references': [],
625 |         'invalid_locations': [],
626 |         'missing_styles': [],
627 |         'coherence_issues': []
628 |     }
629 |     
630 |     try:
631 |         with zipfile.ZipFile(filename, 'r') as zf:
632 |             # Check document.xml
633 |             doc_xml = zf.read('word/document.xml')
634 |             doc_root = etree.fromstring(doc_xml)
635 |             nsmap = {'w': W_NS}
636 |             
637 |             # Get all footnote references
638 |             ref_ids = set()
639 |             for ref in doc_root.xpath('//w:footnoteReference', namespaces=nsmap):
640 |                 ref_id = ref.get(f'{{{W_NS}}}id')
641 |                 if ref_id:
642 |                     ref_ids.add(ref_id)
643 |                     report['total_references'] += 1
644 |                     
645 |                     # Check location
646 |                     parent = ref.getparent()
647 |                     while parent is not None:
648 |                         if parent.tag in [f'{{{W_NS}}}hdr', f'{{{W_NS}}}ftr']:
649 |                             report['invalid_locations'].append(ref_id)
650 |                             break
651 |                         parent = parent.getparent()
652 |             
653 |             # Check footnotes.xml
654 |             if 'word/footnotes.xml' in zf.namelist():
655 |                 footnotes_xml = zf.read('word/footnotes.xml')
656 |                 footnotes_root = etree.fromstring(footnotes_xml)
657 |                 
658 |                 content_ids = set()
659 |                 for fn in footnotes_root.xpath('//w:footnote', namespaces=nsmap):
660 |                     fn_id = fn.get(f'{{{W_NS}}}id')
661 |                     if fn_id:
662 |                         content_ids.add(fn_id)
663 |                         if fn_id not in ['-1', '0']:  # Exclude separators
664 |                             report['total_content'] += 1
665 |                 
666 |                 # Find orphans and missing
667 |                 report['orphaned_content'] = list(content_ids - ref_ids - {'-1', '0'})
668 |                 report['missing_references'] = list(ref_ids - content_ids)
669 |             else:
670 |                 if report['total_references'] > 0:
671 |                     report['coherence_issues'].append('References exist but no footnotes.xml')
672 |             
673 |             # Check relationships
674 |             if 'word/_rels/document.xml.rels' in zf.namelist():
675 |                 rels_xml = zf.read('word/_rels/document.xml.rels')
676 |                 rels_root = etree.fromstring(rels_xml)
677 |                 rel_nsmap = {'r': REL_NS}
678 |                 
679 |                 fn_rels = rels_root.xpath(
680 |                     "//r:Relationship[contains(@Type, 'footnotes')]",
681 |                     namespaces=rel_nsmap
682 |                 )
683 |                 
684 |                 if report['total_content'] > 0 and len(fn_rels) == 0:
685 |                     report['coherence_issues'].append('Missing footnotes relationship')
686 |                 elif len(fn_rels) > 1:
687 |                     report['coherence_issues'].append(f'Multiple footnote relationships: {len(fn_rels)}')
688 |             
689 |             # Check content types
690 |             if '[Content_Types].xml' in zf.namelist():
691 |                 ct_xml = zf.read('[Content_Types].xml')
692 |                 ct_root = etree.fromstring(ct_xml)
693 |                 ct_nsmap = {'ct': CT_NS}
694 |                 
695 |                 fn_overrides = ct_root.xpath(
696 |                     "//ct:Override[@PartName='/word/footnotes.xml']",
697 |                     namespaces=ct_nsmap
698 |                 )
699 |                 
700 |                 if report['total_content'] > 0 and len(fn_overrides) == 0:
701 |                     report['coherence_issues'].append('Missing footnotes content type')
702 |                 elif len(fn_overrides) > 1:
703 |                     report['coherence_issues'].append(f'Multiple footnote content types: {len(fn_overrides)}')
704 |             
705 |             # Check styles
706 |             if 'word/styles.xml' in zf.namelist():
707 |                 styles_xml = zf.read('word/styles.xml')
708 |                 styles_root = etree.fromstring(styles_xml)
709 |                 
710 |                 ref_style = styles_root.xpath('//w:style[@w:styleId="FootnoteReference"]', namespaces=nsmap)
711 |                 text_style = styles_root.xpath('//w:style[@w:styleId="FootnoteText"]', namespaces=nsmap)
712 |                 
713 |                 if not ref_style:
714 |                     report['missing_styles'].append('FootnoteReference')
715 |                 if not text_style:
716 |                     report['missing_styles'].append('FootnoteText')
717 |         
718 |         # Determine if valid
719 |         is_valid = (
720 |             len(report['id_conflicts']) == 0 and
721 |             len(report['orphaned_content']) == 0 and
722 |             len(report['missing_references']) == 0 and
723 |             len(report['invalid_locations']) == 0 and
724 |             len(report['coherence_issues']) == 0
725 |         )
726 |         
727 |         if is_valid:
728 |             message = "Document footnotes are valid"
729 |         else:
730 |             message = "Document has footnote issues"
731 |         
732 |         return is_valid, message, report
733 |         
734 |     except Exception as e:
735 |         return False, f"Error validating document: {str(e)}", report
736 | 
737 | 
738 | # ============================================================================
739 | # COMPATIBILITY FUNCTIONS (for backward compatibility)
740 | # ============================================================================
741 | 
742 | def add_footnote_at_paragraph_end(
743 |     filename: str,
744 |     paragraph_index: int,
745 |     footnote_text: str,
746 |     output_filename: Optional[str] = None
747 | ) -> Tuple[bool, str]:
748 |     """Add footnote at the end of a specific paragraph (backward compatibility)."""
749 |     success, message, _ = add_footnote_robust(
750 |         filename=filename,
751 |         paragraph_index=paragraph_index,
752 |         footnote_text=footnote_text,
753 |         output_filename=output_filename,
754 |         position="after"
755 |     )
756 |     return success, message
757 | 
758 | 
759 | def add_footnote_with_proper_formatting(
760 |     filename: str,
761 |     search_text: str,
762 |     footnote_text: str,
763 |     output_filename: Optional[str] = None,
764 |     position: str = "after"
765 | ) -> Tuple[bool, str]:
766 |     """Add footnote with proper formatting (backward compatibility)."""
767 |     success, message, _ = add_footnote_robust(
768 |         filename=filename,
769 |         search_text=search_text,
770 |         footnote_text=footnote_text,
771 |         output_filename=output_filename,
772 |         position=position
773 |     )
774 |     return success, message
775 | 
776 | 
777 | def delete_footnote(
778 |     filename: str,
779 |     footnote_id: Optional[int] = None,
780 |     search_text: Optional[str] = None,
781 |     output_filename: Optional[str] = None
782 | ) -> Tuple[bool, str]:
783 |     """Delete a footnote (backward compatibility)."""
784 |     success, message, _ = delete_footnote_robust(
785 |         filename=filename,
786 |         footnote_id=footnote_id,
787 |         search_text=search_text,
788 |         output_filename=output_filename
789 |     )
790 |     return success, message
791 | 
792 | 
793 | # ============================================================================
794 | # LEGACY FUNCTIONS (for core/__init__.py compatibility)
795 | # ============================================================================
796 | 
797 | def add_footnote(doc, paragraph_index: int, footnote_text: str):
798 |     """Legacy function for adding footnotes to python-docx Document objects.
799 |     Note: This is a simplified version that doesn't create proper Word footnotes."""
800 |     if paragraph_index >= len(doc.paragraphs):
801 |         raise IndexError(f"Paragraph index {paragraph_index} out of range")
802 |     
803 |     para = doc.paragraphs[paragraph_index]
804 |     # Add superscript number
805 |     run = para.add_run()
806 |     run.text = "¹"
807 |     run.font.superscript = True
808 |     
809 |     # Add footnote text at document end
810 |     doc.add_paragraph("_" * 50)
811 |     footnote_para = doc.add_paragraph(f"¹ {footnote_text}")
812 |     footnote_para.style = "Caption"
813 |     
814 |     return doc
815 | 
816 | 
817 | def add_endnote(doc, paragraph_index: int, endnote_text: str):
818 |     """Legacy function for adding endnotes."""
819 |     if paragraph_index >= len(doc.paragraphs):
820 |         raise IndexError(f"Paragraph index {paragraph_index} out of range")
821 |     
822 |     para = doc.paragraphs[paragraph_index]
823 |     run = para.add_run()
824 |     run.text = "†"
825 |     run.font.superscript = True
826 |     
827 |     # Endnotes go at the very end
828 |     doc.add_page_break()
829 |     doc.add_heading("Endnotes", level=1)
830 |     endnote_para = doc.add_paragraph(f"† {endnote_text}")
831 |     
832 |     return doc
833 | 
834 | 
835 | def convert_footnotes_to_endnotes(doc):
836 |     """Legacy function to convert footnotes to endnotes in a Document object."""
837 |     # This is a placeholder - real conversion requires XML manipulation
838 |     return doc
839 | 
840 | 
841 | # Define XML_NS if needed
842 | XML_NS = 'http://www.w3.org/XML/1998/namespace'
```

--------------------------------------------------------------------------------
/word_document_server/tools/format_tools.py:
--------------------------------------------------------------------------------

```python
   1 | """
   2 | Formatting tools for Word Document Server.
   3 | 
   4 | These tools handle formatting operations for Word documents,
   5 | including text formatting, table formatting, and custom styles.
   6 | """
   7 | import os
   8 | from typing import List, Optional, Dict, Any
   9 | from docx import Document
  10 | from docx.shared import Pt, RGBColor
  11 | from docx.enum.text import WD_COLOR_INDEX
  12 | from docx.enum.style import WD_STYLE_TYPE
  13 | 
  14 | from word_document_server.utils.file_utils import check_file_writeable, ensure_docx_extension
  15 | from word_document_server.core.styles import create_style
  16 | from word_document_server.core.tables import (
  17 |     apply_table_style, set_cell_shading_by_position, apply_alternating_row_shading,
  18 |     highlight_header_row, merge_cells, merge_cells_horizontal, merge_cells_vertical,
  19 |     set_cell_alignment_by_position, set_table_alignment, set_column_width_by_position,
  20 |     set_column_widths, set_table_width as set_table_width_func, auto_fit_table,
  21 |     format_cell_text_by_position, set_cell_padding_by_position
  22 | )
  23 | 
  24 | 
  25 | async def format_text(filename: str, paragraph_index: int, start_pos: int, end_pos: int, 
  26 |                      bold: Optional[bool] = None, italic: Optional[bool] = None, 
  27 |                      underline: Optional[bool] = None, color: Optional[str] = None,
  28 |                      font_size: Optional[int] = None, font_name: Optional[str] = None) -> str:
  29 |     """Format a specific range of text within a paragraph.
  30 |     
  31 |     Args:
  32 |         filename: Path to the Word document
  33 |         paragraph_index: Index of the paragraph (0-based)
  34 |         start_pos: Start position within the paragraph text
  35 |         end_pos: End position within the paragraph text
  36 |         bold: Set text bold (True/False)
  37 |         italic: Set text italic (True/False)
  38 |         underline: Set text underlined (True/False)
  39 |         color: Text color (e.g., 'red', 'blue', etc.)
  40 |         font_size: Font size in points
  41 |         font_name: Font name/family
  42 |     """
  43 |     filename = ensure_docx_extension(filename)
  44 |     
  45 |     # Ensure numeric parameters are the correct type
  46 |     try:
  47 |         paragraph_index = int(paragraph_index)
  48 |         start_pos = int(start_pos)
  49 |         end_pos = int(end_pos)
  50 |         if font_size is not None:
  51 |             font_size = int(font_size)
  52 |     except (ValueError, TypeError):
  53 |         return "Invalid parameter: paragraph_index, start_pos, end_pos, and font_size must be integers"
  54 |     
  55 |     if not os.path.exists(filename):
  56 |         return f"Document {filename} does not exist"
  57 |     
  58 |     # Check if file is writeable
  59 |     is_writeable, error_message = check_file_writeable(filename)
  60 |     if not is_writeable:
  61 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
  62 |     
  63 |     try:
  64 |         doc = Document(filename)
  65 |         
  66 |         # Validate paragraph index
  67 |         if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs):
  68 |             return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})."
  69 |         
  70 |         paragraph = doc.paragraphs[paragraph_index]
  71 |         text = paragraph.text
  72 |         
  73 |         # Validate text positions
  74 |         if start_pos < 0 or end_pos > len(text) or start_pos >= end_pos:
  75 |             return f"Invalid text positions. Paragraph has {len(text)} characters."
  76 |         
  77 |         # Get the text to format
  78 |         target_text = text[start_pos:end_pos]
  79 |         
  80 |         # Clear existing runs and create three runs: before, target, after
  81 |         for run in paragraph.runs:
  82 |             run.clear()
  83 |         
  84 |         # Add text before target
  85 |         if start_pos > 0:
  86 |             run_before = paragraph.add_run(text[:start_pos])
  87 |         
  88 |         # Add target text with formatting
  89 |         run_target = paragraph.add_run(target_text)
  90 |         if bold is not None:
  91 |             run_target.bold = bold
  92 |         if italic is not None:
  93 |             run_target.italic = italic
  94 |         if underline is not None:
  95 |             run_target.underline = underline
  96 |         if color:
  97 |             # Define common RGB colors
  98 |             color_map = {
  99 |                 'red': RGBColor(255, 0, 0),
 100 |                 'blue': RGBColor(0, 0, 255),
 101 |                 'green': RGBColor(0, 128, 0),
 102 |                 'yellow': RGBColor(255, 255, 0),
 103 |                 'black': RGBColor(0, 0, 0),
 104 |                 'gray': RGBColor(128, 128, 128),
 105 |                 'white': RGBColor(255, 255, 255),
 106 |                 'purple': RGBColor(128, 0, 128),
 107 |                 'orange': RGBColor(255, 165, 0)
 108 |             }
 109 |             
 110 |             try:
 111 |                 if color.lower() in color_map:
 112 |                     # Use predefined RGB color
 113 |                     run_target.font.color.rgb = color_map[color.lower()]
 114 |                 else:
 115 |                     # Try to set color by name
 116 |                     run_target.font.color.rgb = RGBColor.from_string(color)
 117 |             except Exception as e:
 118 |                 # If all else fails, default to black
 119 |                 run_target.font.color.rgb = RGBColor(0, 0, 0)
 120 |         if font_size:
 121 |             run_target.font.size = Pt(font_size)
 122 |         if font_name:
 123 |             run_target.font.name = font_name
 124 |         
 125 |         # Add text after target
 126 |         if end_pos < len(text):
 127 |             run_after = paragraph.add_run(text[end_pos:])
 128 |         
 129 |         doc.save(filename)
 130 |         return f"Text '{target_text}' formatted successfully in paragraph {paragraph_index}."
 131 |     except Exception as e:
 132 |         return f"Failed to format text: {str(e)}"
 133 | 
 134 | 
 135 | async def create_custom_style(filename: str, style_name: str, 
 136 |                              bold: Optional[bool] = None, italic: Optional[bool] = None,
 137 |                              font_size: Optional[int] = None, font_name: Optional[str] = None,
 138 |                              color: Optional[str] = None, base_style: Optional[str] = None) -> str:
 139 |     """Create a custom style in the document.
 140 |     
 141 |     Args:
 142 |         filename: Path to the Word document
 143 |         style_name: Name for the new style
 144 |         bold: Set text bold (True/False)
 145 |         italic: Set text italic (True/False)
 146 |         font_size: Font size in points
 147 |         font_name: Font name/family
 148 |         color: Text color (e.g., 'red', 'blue')
 149 |         base_style: Optional existing style to base this on
 150 |     """
 151 |     filename = ensure_docx_extension(filename)
 152 |     
 153 |     if not os.path.exists(filename):
 154 |         return f"Document {filename} does not exist"
 155 |     
 156 |     # Check if file is writeable
 157 |     is_writeable, error_message = check_file_writeable(filename)
 158 |     if not is_writeable:
 159 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 160 |     
 161 |     try:
 162 |         doc = Document(filename)
 163 |         
 164 |         # Build font properties dictionary
 165 |         font_properties = {}
 166 |         if bold is not None:
 167 |             font_properties['bold'] = bold
 168 |         if italic is not None:
 169 |             font_properties['italic'] = italic
 170 |         if font_size is not None:
 171 |             font_properties['size'] = font_size
 172 |         if font_name is not None:
 173 |             font_properties['name'] = font_name
 174 |         if color is not None:
 175 |             font_properties['color'] = color
 176 |         
 177 |         # Create the style
 178 |         new_style = create_style(
 179 |             doc, 
 180 |             style_name, 
 181 |             WD_STYLE_TYPE.PARAGRAPH, 
 182 |             base_style=base_style,
 183 |             font_properties=font_properties
 184 |         )
 185 |         
 186 |         doc.save(filename)
 187 |         return f"Style '{style_name}' created successfully."
 188 |     except Exception as e:
 189 |         return f"Failed to create style: {str(e)}"
 190 | 
 191 | 
 192 | async def format_table(filename: str, table_index: int, 
 193 |                       has_header_row: Optional[bool] = None,
 194 |                       border_style: Optional[str] = None,
 195 |                       shading: Optional[List[List[str]]] = None) -> str:
 196 |     """Format a table with borders, shading, and structure.
 197 |     
 198 |     Args:
 199 |         filename: Path to the Word document
 200 |         table_index: Index of the table (0-based)
 201 |         has_header_row: If True, formats the first row as a header
 202 |         border_style: Style for borders ('none', 'single', 'double', 'thick')
 203 |         shading: 2D list of cell background colors (by row and column)
 204 |     """
 205 |     filename = ensure_docx_extension(filename)
 206 |     
 207 |     if not os.path.exists(filename):
 208 |         return f"Document {filename} does not exist"
 209 |     
 210 |     # Check if file is writeable
 211 |     is_writeable, error_message = check_file_writeable(filename)
 212 |     if not is_writeable:
 213 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 214 |     
 215 |     try:
 216 |         doc = Document(filename)
 217 |         
 218 |         # Validate table index
 219 |         if table_index < 0 or table_index >= len(doc.tables):
 220 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 221 |         
 222 |         table = doc.tables[table_index]
 223 |         
 224 |         # Apply formatting
 225 |         success = apply_table_style(table, has_header_row or False, border_style, shading)
 226 |         
 227 |         if success:
 228 |             doc.save(filename)
 229 |             return f"Table at index {table_index} formatted successfully."
 230 |         else:
 231 |             return f"Failed to format table at index {table_index}."
 232 |     except Exception as e:
 233 |         return f"Failed to format table: {str(e)}"
 234 | 
 235 | 
 236 | async def set_table_cell_shading(filename: str, table_index: int, row_index: int, 
 237 |                                 col_index: int, fill_color: str, pattern: str = "clear") -> str:
 238 |     """Apply shading/filling to a specific table cell.
 239 |     
 240 |     Args:
 241 |         filename: Path to the Word document
 242 |         table_index: Index of the table (0-based)
 243 |         row_index: Row index of the cell (0-based)
 244 |         col_index: Column index of the cell (0-based)
 245 |         fill_color: Background color (hex string like "FF0000" or "red")
 246 |         pattern: Shading pattern ("clear", "solid", "pct10", "pct20", etc.)
 247 |     """
 248 |     filename = ensure_docx_extension(filename)
 249 |     
 250 |     # Ensure numeric parameters are the correct type
 251 |     try:
 252 |         table_index = int(table_index)
 253 |         row_index = int(row_index)
 254 |         col_index = int(col_index)
 255 |     except (ValueError, TypeError):
 256 |         return "Invalid parameter: table_index, row_index, and col_index must be integers"
 257 |     
 258 |     if not os.path.exists(filename):
 259 |         return f"Document {filename} does not exist"
 260 |     
 261 |     # Check if file is writeable
 262 |     is_writeable, error_message = check_file_writeable(filename)
 263 |     if not is_writeable:
 264 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 265 |     
 266 |     try:
 267 |         doc = Document(filename)
 268 |         
 269 |         # Validate table index
 270 |         if table_index < 0 or table_index >= len(doc.tables):
 271 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 272 |         
 273 |         table = doc.tables[table_index]
 274 |         
 275 |         # Validate row and column indices
 276 |         if row_index < 0 or row_index >= len(table.rows):
 277 |             return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
 278 |         
 279 |         if col_index < 0 or col_index >= len(table.rows[row_index].cells):
 280 |             return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
 281 |         
 282 |         # Apply cell shading
 283 |         success = set_cell_shading_by_position(table, row_index, col_index, fill_color, pattern)
 284 |         
 285 |         if success:
 286 |             doc.save(filename)
 287 |             return f"Cell shading applied successfully to table {table_index}, row {row_index}, column {col_index}."
 288 |         else:
 289 |             return f"Failed to apply cell shading."
 290 |     except Exception as e:
 291 |         return f"Failed to apply cell shading: {str(e)}"
 292 | 
 293 | 
 294 | async def apply_table_alternating_rows(filename: str, table_index: int, 
 295 |                                      color1: str = "FFFFFF", color2: str = "F2F2F2") -> str:
 296 |     """Apply alternating row colors to a table for better readability.
 297 |     
 298 |     Args:
 299 |         filename: Path to the Word document
 300 |         table_index: Index of the table (0-based)
 301 |         color1: Color for odd rows (hex string, default white)
 302 |         color2: Color for even rows (hex string, default light gray)
 303 |     """
 304 |     filename = ensure_docx_extension(filename)
 305 |     
 306 |     # Ensure numeric parameters are the correct type
 307 |     try:
 308 |         table_index = int(table_index)
 309 |     except (ValueError, TypeError):
 310 |         return "Invalid parameter: table_index must be an integer"
 311 |     
 312 |     if not os.path.exists(filename):
 313 |         return f"Document {filename} does not exist"
 314 |     
 315 |     # Check if file is writeable
 316 |     is_writeable, error_message = check_file_writeable(filename)
 317 |     if not is_writeable:
 318 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 319 |     
 320 |     try:
 321 |         doc = Document(filename)
 322 |         
 323 |         # Validate table index
 324 |         if table_index < 0 or table_index >= len(doc.tables):
 325 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 326 |         
 327 |         table = doc.tables[table_index]
 328 |         
 329 |         # Apply alternating row shading
 330 |         success = apply_alternating_row_shading(table, color1, color2)
 331 |         
 332 |         if success:
 333 |             doc.save(filename)
 334 |             return f"Alternating row shading applied successfully to table {table_index}."
 335 |         else:
 336 |             return f"Failed to apply alternating row shading."
 337 |     except Exception as e:
 338 |         return f"Failed to apply alternating row shading: {str(e)}"
 339 | 
 340 | 
 341 | async def highlight_table_header(filename: str, table_index: int, 
 342 |                                header_color: str = "4472C4", text_color: str = "FFFFFF") -> str:
 343 |     """Apply special highlighting to table header row.
 344 |     
 345 |     Args:
 346 |         filename: Path to the Word document
 347 |         table_index: Index of the table (0-based)
 348 |         header_color: Background color for header (hex string, default blue)
 349 |         text_color: Text color for header (hex string, default white)
 350 |     """
 351 |     filename = ensure_docx_extension(filename)
 352 |     
 353 |     # Ensure numeric parameters are the correct type
 354 |     try:
 355 |         table_index = int(table_index)
 356 |     except (ValueError, TypeError):
 357 |         return "Invalid parameter: table_index must be an integer"
 358 |     
 359 |     if not os.path.exists(filename):
 360 |         return f"Document {filename} does not exist"
 361 |     
 362 |     # Check if file is writeable
 363 |     is_writeable, error_message = check_file_writeable(filename)
 364 |     if not is_writeable:
 365 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 366 |     
 367 |     try:
 368 |         doc = Document(filename)
 369 |         
 370 |         # Validate table index
 371 |         if table_index < 0 or table_index >= len(doc.tables):
 372 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 373 |         
 374 |         table = doc.tables[table_index]
 375 |         
 376 |         # Apply header highlighting
 377 |         success = highlight_header_row(table, header_color, text_color)
 378 |         
 379 |         if success:
 380 |             doc.save(filename)
 381 |             return f"Header highlighting applied successfully to table {table_index}."
 382 |         else:
 383 |             return f"Failed to apply header highlighting."
 384 |     except Exception as e:
 385 |         return f"Failed to apply header highlighting: {str(e)}"
 386 | 
 387 | 
 388 | async def merge_table_cells(filename: str, table_index: int, start_row: int, start_col: int, 
 389 |                           end_row: int, end_col: int) -> str:
 390 |     """Merge cells in a rectangular area of a table.
 391 |     
 392 |     Args:
 393 |         filename: Path to the Word document
 394 |         table_index: Index of the table (0-based)
 395 |         start_row: Starting row index (0-based)
 396 |         start_col: Starting column index (0-based)
 397 |         end_row: Ending row index (0-based, inclusive)
 398 |         end_col: Ending column index (0-based, inclusive)
 399 |     """
 400 |     filename = ensure_docx_extension(filename)
 401 |     
 402 |     # Ensure numeric parameters are the correct type
 403 |     try:
 404 |         table_index = int(table_index)
 405 |         start_row = int(start_row)
 406 |         start_col = int(start_col)
 407 |         end_row = int(end_row)
 408 |         end_col = int(end_col)
 409 |     except (ValueError, TypeError):
 410 |         return "Invalid parameter: all indices must be integers"
 411 |     
 412 |     if not os.path.exists(filename):
 413 |         return f"Document {filename} does not exist"
 414 |     
 415 |     # Check if file is writeable
 416 |     is_writeable, error_message = check_file_writeable(filename)
 417 |     if not is_writeable:
 418 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 419 |     
 420 |     try:
 421 |         doc = Document(filename)
 422 |         
 423 |         # Validate table index
 424 |         if table_index < 0 or table_index >= len(doc.tables):
 425 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 426 |         
 427 |         table = doc.tables[table_index]
 428 |         
 429 |         # Validate merge parameters
 430 |         if start_row > end_row or start_col > end_col:
 431 |             return "Invalid merge range: start indices must be <= end indices"
 432 |         
 433 |         if start_row == end_row and start_col == end_col:
 434 |             return "Invalid merge range: cannot merge a single cell with itself"
 435 |         
 436 |         # Apply cell merge
 437 |         success = merge_cells(table, start_row, start_col, end_row, end_col)
 438 |         
 439 |         if success:
 440 |             doc.save(filename)
 441 |             return f"Cells merged successfully in table {table_index} from ({start_row},{start_col}) to ({end_row},{end_col})."
 442 |         else:
 443 |             return f"Failed to merge cells. Check that indices are valid."
 444 |     except Exception as e:
 445 |         return f"Failed to merge cells: {str(e)}"
 446 | 
 447 | 
 448 | async def merge_table_cells_horizontal(filename: str, table_index: int, row_index: int, 
 449 |                                      start_col: int, end_col: int) -> str:
 450 |     """Merge cells horizontally in a single row.
 451 |     
 452 |     Args:
 453 |         filename: Path to the Word document
 454 |         table_index: Index of the table (0-based)
 455 |         row_index: Row index (0-based)
 456 |         start_col: Starting column index (0-based)
 457 |         end_col: Ending column index (0-based, inclusive)
 458 |     """
 459 |     filename = ensure_docx_extension(filename)
 460 |     
 461 |     # Ensure numeric parameters are the correct type
 462 |     try:
 463 |         table_index = int(table_index)
 464 |         row_index = int(row_index)
 465 |         start_col = int(start_col)
 466 |         end_col = int(end_col)
 467 |     except (ValueError, TypeError):
 468 |         return "Invalid parameter: all indices must be integers"
 469 |     
 470 |     if not os.path.exists(filename):
 471 |         return f"Document {filename} does not exist"
 472 |     
 473 |     # Check if file is writeable
 474 |     is_writeable, error_message = check_file_writeable(filename)
 475 |     if not is_writeable:
 476 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 477 |     
 478 |     try:
 479 |         doc = Document(filename)
 480 |         
 481 |         # Validate table index
 482 |         if table_index < 0 or table_index >= len(doc.tables):
 483 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 484 |         
 485 |         table = doc.tables[table_index]
 486 |         
 487 |         # Apply horizontal cell merge
 488 |         success = merge_cells_horizontal(table, row_index, start_col, end_col)
 489 |         
 490 |         if success:
 491 |             doc.save(filename)
 492 |             return f"Cells merged horizontally in table {table_index}, row {row_index}, columns {start_col}-{end_col}."
 493 |         else:
 494 |             return f"Failed to merge cells horizontally. Check that indices are valid."
 495 |     except Exception as e:
 496 |         return f"Failed to merge cells horizontally: {str(e)}"
 497 | 
 498 | 
 499 | async def merge_table_cells_vertical(filename: str, table_index: int, col_index: int, 
 500 |                                    start_row: int, end_row: int) -> str:
 501 |     """Merge cells vertically in a single column.
 502 |     
 503 |     Args:
 504 |         filename: Path to the Word document
 505 |         table_index: Index of the table (0-based)
 506 |         col_index: Column index (0-based)
 507 |         start_row: Starting row index (0-based)
 508 |         end_row: Ending row index (0-based, inclusive)
 509 |     """
 510 |     filename = ensure_docx_extension(filename)
 511 |     
 512 |     # Ensure numeric parameters are the correct type
 513 |     try:
 514 |         table_index = int(table_index)
 515 |         col_index = int(col_index)
 516 |         start_row = int(start_row)
 517 |         end_row = int(end_row)
 518 |     except (ValueError, TypeError):
 519 |         return "Invalid parameter: all indices must be integers"
 520 |     
 521 |     if not os.path.exists(filename):
 522 |         return f"Document {filename} does not exist"
 523 |     
 524 |     # Check if file is writeable
 525 |     is_writeable, error_message = check_file_writeable(filename)
 526 |     if not is_writeable:
 527 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 528 |     
 529 |     try:
 530 |         doc = Document(filename)
 531 |         
 532 |         # Validate table index
 533 |         if table_index < 0 or table_index >= len(doc.tables):
 534 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 535 |         
 536 |         table = doc.tables[table_index]
 537 |         
 538 |         # Apply vertical cell merge
 539 |         success = merge_cells_vertical(table, col_index, start_row, end_row)
 540 |         
 541 |         if success:
 542 |             doc.save(filename)
 543 |             return f"Cells merged vertically in table {table_index}, column {col_index}, rows {start_row}-{end_row}."
 544 |         else:
 545 |             return f"Failed to merge cells vertically. Check that indices are valid."
 546 |     except Exception as e:
 547 |         return f"Failed to merge cells vertically: {str(e)}"
 548 | 
 549 | 
 550 | async def set_table_cell_alignment(filename: str, table_index: int, row_index: int, col_index: int,
 551 |                                  horizontal: str = "left", vertical: str = "top") -> str:
 552 |     """Set text alignment for a specific table cell.
 553 |     
 554 |     Args:
 555 |         filename: Path to the Word document
 556 |         table_index: Index of the table (0-based)
 557 |         row_index: Row index (0-based)
 558 |         col_index: Column index (0-based)
 559 |         horizontal: Horizontal alignment ("left", "center", "right", "justify")
 560 |         vertical: Vertical alignment ("top", "center", "bottom")
 561 |     """
 562 |     filename = ensure_docx_extension(filename)
 563 |     
 564 |     # Ensure numeric parameters are the correct type
 565 |     try:
 566 |         table_index = int(table_index)
 567 |         row_index = int(row_index)
 568 |         col_index = int(col_index)
 569 |     except (ValueError, TypeError):
 570 |         return "Invalid parameter: table_index, row_index, and col_index must be integers"
 571 |     
 572 |     # Validate alignment parameters
 573 |     valid_horizontal = ["left", "center", "right", "justify"]
 574 |     valid_vertical = ["top", "center", "bottom"]
 575 |     
 576 |     if horizontal.lower() not in valid_horizontal:
 577 |         return f"Invalid horizontal alignment. Valid options: {', '.join(valid_horizontal)}"
 578 |     
 579 |     if vertical.lower() not in valid_vertical:
 580 |         return f"Invalid vertical alignment. Valid options: {', '.join(valid_vertical)}"
 581 |     
 582 |     if not os.path.exists(filename):
 583 |         return f"Document {filename} does not exist"
 584 |     
 585 |     # Check if file is writeable
 586 |     is_writeable, error_message = check_file_writeable(filename)
 587 |     if not is_writeable:
 588 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 589 |     
 590 |     try:
 591 |         doc = Document(filename)
 592 |         
 593 |         # Validate table index
 594 |         if table_index < 0 or table_index >= len(doc.tables):
 595 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 596 |         
 597 |         table = doc.tables[table_index]
 598 |         
 599 |         # Apply cell alignment
 600 |         success = set_cell_alignment_by_position(table, row_index, col_index, horizontal, vertical)
 601 |         
 602 |         if success:
 603 |             doc.save(filename)
 604 |             return f"Cell alignment set successfully for table {table_index}, cell ({row_index},{col_index}) to {horizontal}/{vertical}."
 605 |         else:
 606 |             return f"Failed to set cell alignment. Check that indices are valid."
 607 |     except Exception as e:
 608 |         return f"Failed to set cell alignment: {str(e)}"
 609 | 
 610 | 
 611 | async def set_table_alignment_all(filename: str, table_index: int, 
 612 |                                 horizontal: str = "left", vertical: str = "top") -> str:
 613 |     """Set text alignment for all cells in a table.
 614 |     
 615 |     Args:
 616 |         filename: Path to the Word document
 617 |         table_index: Index of the table (0-based)
 618 |         horizontal: Horizontal alignment ("left", "center", "right", "justify")
 619 |         vertical: Vertical alignment ("top", "center", "bottom")
 620 |     """
 621 |     filename = ensure_docx_extension(filename)
 622 |     
 623 |     # Ensure numeric parameters are the correct type
 624 |     try:
 625 |         table_index = int(table_index)
 626 |     except (ValueError, TypeError):
 627 |         return "Invalid parameter: table_index must be an integer"
 628 |     
 629 |     # Validate alignment parameters
 630 |     valid_horizontal = ["left", "center", "right", "justify"]
 631 |     valid_vertical = ["top", "center", "bottom"]
 632 |     
 633 |     if horizontal.lower() not in valid_horizontal:
 634 |         return f"Invalid horizontal alignment. Valid options: {', '.join(valid_horizontal)}"
 635 |     
 636 |     if vertical.lower() not in valid_vertical:
 637 |         return f"Invalid vertical alignment. Valid options: {', '.join(valid_vertical)}"
 638 |     
 639 |     if not os.path.exists(filename):
 640 |         return f"Document {filename} does not exist"
 641 |     
 642 |     # Check if file is writeable
 643 |     is_writeable, error_message = check_file_writeable(filename)
 644 |     if not is_writeable:
 645 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 646 |     
 647 |     try:
 648 |         doc = Document(filename)
 649 |         
 650 |         # Validate table index
 651 |         if table_index < 0 or table_index >= len(doc.tables):
 652 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 653 |         
 654 |         table = doc.tables[table_index]
 655 |         
 656 |         # Apply table alignment
 657 |         success = set_table_alignment(table, horizontal, vertical)
 658 |         
 659 |         if success:
 660 |             doc.save(filename)
 661 |             return f"Table alignment set successfully for table {table_index} to {horizontal}/{vertical} for all cells."
 662 |         else:
 663 |             return f"Failed to set table alignment."
 664 |     except Exception as e:
 665 |         return f"Failed to set table alignment: {str(e)}"
 666 | 
 667 | 
 668 | async def set_table_column_width(filename: str, table_index: int, col_index: int, 
 669 |                                 width: float, width_type: str = "points") -> str:
 670 |     """Set the width of a specific table column.
 671 |     
 672 |     Args:
 673 |         filename: Path to the Word document
 674 |         table_index: Index of the table (0-based)
 675 |         col_index: Column index (0-based)
 676 |         width: Column width value
 677 |         width_type: Width type ("points", "inches", "cm", "percent", "auto")
 678 |     """
 679 |     filename = ensure_docx_extension(filename)
 680 |     
 681 |     # Ensure numeric parameters are the correct type
 682 |     try:
 683 |         table_index = int(table_index)
 684 |         col_index = int(col_index)
 685 |         if width_type != "auto":
 686 |             width = float(width)
 687 |     except (ValueError, TypeError):
 688 |         return "Invalid parameter: table_index and col_index must be integers, width must be a number"
 689 |     
 690 |     # Validate width type
 691 |     valid_width_types = ["points", "inches", "cm", "percent", "auto"]
 692 |     if width_type.lower() not in valid_width_types:
 693 |         return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
 694 |     
 695 |     if not os.path.exists(filename):
 696 |         return f"Document {filename} does not exist"
 697 |     
 698 |     # Check if file is writeable
 699 |     is_writeable, error_message = check_file_writeable(filename)
 700 |     if not is_writeable:
 701 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 702 |     
 703 |     try:
 704 |         doc = Document(filename)
 705 |         
 706 |         # Validate table index
 707 |         if table_index < 0 or table_index >= len(doc.tables):
 708 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 709 |         
 710 |         table = doc.tables[table_index]
 711 |         
 712 |         # Validate column index
 713 |         if col_index < 0 or col_index >= len(table.columns):
 714 |             return f"Invalid column index. Table has {len(table.columns)} columns (0-{len(table.columns)-1})."
 715 |         
 716 |         # Convert width and type for Word format
 717 |         if width_type.lower() == "points":
 718 |             # Points to DXA (twentieths of a point)
 719 |             word_width = width
 720 |             word_type = "dxa"
 721 |         elif width_type.lower() == "inches":
 722 |             # Inches to points, then to DXA
 723 |             word_width = width * 72  # 72 points per inch
 724 |             word_type = "dxa"
 725 |         elif width_type.lower() == "cm":
 726 |             # CM to points, then to DXA
 727 |             word_width = width * 28.35  # ~28.35 points per cm
 728 |             word_type = "dxa"
 729 |         elif width_type.lower() == "percent":
 730 |             # Percentage (Word uses 50x the percentage value)
 731 |             word_width = width
 732 |             word_type = "pct"
 733 |         else:  # auto
 734 |             word_width = 0
 735 |             word_type = "auto"
 736 |         
 737 |         # Apply column width
 738 |         success = set_column_width_by_position(table, col_index, word_width, word_type)
 739 |         
 740 |         if success:
 741 |             doc.save(filename)
 742 |             return f"Column width set successfully for table {table_index}, column {col_index} to {width} {width_type}."
 743 |         else:
 744 |             return f"Failed to set column width. Check that indices are valid."
 745 |     except Exception as e:
 746 |         return f"Failed to set column width: {str(e)}"
 747 | 
 748 | 
 749 | async def set_table_column_widths(filename: str, table_index: int, widths: list, 
 750 |                                  width_type: str = "points") -> str:
 751 |     """Set the widths of multiple table columns.
 752 |     
 753 |     Args:
 754 |         filename: Path to the Word document
 755 |         table_index: Index of the table (0-based)
 756 |         widths: List of width values for each column
 757 |         width_type: Width type ("points", "inches", "cm", "percent", "auto")
 758 |     """
 759 |     filename = ensure_docx_extension(filename)
 760 |     
 761 |     # Ensure numeric parameters are the correct type
 762 |     try:
 763 |         table_index = int(table_index)
 764 |         if width_type != "auto":
 765 |             widths = [float(w) for w in widths]
 766 |     except (ValueError, TypeError):
 767 |         return "Invalid parameter: table_index must be an integer, widths must be a list of numbers"
 768 |     
 769 |     # Validate width type
 770 |     valid_width_types = ["points", "inches", "cm", "percent", "auto"]
 771 |     if width_type.lower() not in valid_width_types:
 772 |         return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
 773 |     
 774 |     if not os.path.exists(filename):
 775 |         return f"Document {filename} does not exist"
 776 |     
 777 |     # Check if file is writeable
 778 |     is_writeable, error_message = check_file_writeable(filename)
 779 |     if not is_writeable:
 780 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 781 |     
 782 |     try:
 783 |         doc = Document(filename)
 784 |         
 785 |         # Validate table index
 786 |         if table_index < 0 or table_index >= len(doc.tables):
 787 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 788 |         
 789 |         table = doc.tables[table_index]
 790 |         
 791 |         # Convert widths and type for Word format
 792 |         word_widths = []
 793 |         for width in widths:
 794 |             if width_type.lower() == "points":
 795 |                 word_widths.append(width)
 796 |             elif width_type.lower() == "inches":
 797 |                 word_widths.append(width * 72)  # 72 points per inch
 798 |             elif width_type.lower() == "cm":
 799 |                 word_widths.append(width * 28.35)  # ~28.35 points per cm
 800 |             elif width_type.lower() == "percent":
 801 |                 word_widths.append(width)
 802 |             else:  # auto
 803 |                 word_widths.append(0)
 804 |         
 805 |         # Determine Word type
 806 |         if width_type.lower() == "percent":
 807 |             word_type = "pct"
 808 |         elif width_type.lower() == "auto":
 809 |             word_type = "auto"
 810 |         else:
 811 |             word_type = "dxa"
 812 |         
 813 |         # Apply column widths
 814 |         success = set_column_widths(table, word_widths, word_type)
 815 |         
 816 |         if success:
 817 |             doc.save(filename)
 818 |             return f"Column widths set successfully for table {table_index} with {len(widths)} columns in {width_type}."
 819 |         else:
 820 |             return f"Failed to set column widths."
 821 |     except Exception as e:
 822 |         return f"Failed to set column widths: {str(e)}"
 823 | 
 824 | 
 825 | async def set_table_width(filename: str, table_index: int, width: float, 
 826 |                          width_type: str = "points") -> str:
 827 |     """Set the overall width of a table.
 828 |     
 829 |     Args:
 830 |         filename: Path to the Word document
 831 |         table_index: Index of the table (0-based)
 832 |         width: Table width value
 833 |         width_type: Width type ("points", "inches", "cm", "percent", "auto")
 834 |     """
 835 |     filename = ensure_docx_extension(filename)
 836 |     
 837 |     # Ensure numeric parameters are the correct type
 838 |     try:
 839 |         table_index = int(table_index)
 840 |         if width_type != "auto":
 841 |             width = float(width)
 842 |     except (ValueError, TypeError):
 843 |         return "Invalid parameter: table_index must be an integer, width must be a number"
 844 |     
 845 |     # Validate width type
 846 |     valid_width_types = ["points", "inches", "cm", "percent", "auto"]
 847 |     if width_type.lower() not in valid_width_types:
 848 |         return f"Invalid width type. Valid options: {', '.join(valid_width_types)}"
 849 |     
 850 |     if not os.path.exists(filename):
 851 |         return f"Document {filename} does not exist"
 852 |     
 853 |     # Check if file is writeable
 854 |     is_writeable, error_message = check_file_writeable(filename)
 855 |     if not is_writeable:
 856 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 857 |     
 858 |     try:
 859 |         doc = Document(filename)
 860 |         
 861 |         # Validate table index
 862 |         if table_index < 0 or table_index >= len(doc.tables):
 863 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 864 |         
 865 |         table = doc.tables[table_index]
 866 |         
 867 |         # Convert width and type for Word format
 868 |         if width_type.lower() == "points":
 869 |             word_width = width
 870 |             word_type = "dxa"
 871 |         elif width_type.lower() == "inches":
 872 |             word_width = width * 72  # 72 points per inch
 873 |             word_type = "dxa"
 874 |         elif width_type.lower() == "cm":
 875 |             word_width = width * 28.35  # ~28.35 points per cm
 876 |             word_type = "dxa"
 877 |         elif width_type.lower() == "percent":
 878 |             word_width = width
 879 |             word_type = "pct"
 880 |         else:  # auto
 881 |             word_width = 0
 882 |             word_type = "auto"
 883 |         
 884 |         # Apply table width
 885 |         success = set_table_width_func(table, word_width, word_type)
 886 |         
 887 |         if success:
 888 |             doc.save(filename)
 889 |             return f"Table width set successfully for table {table_index} to {width} {width_type}."
 890 |         else:
 891 |             return f"Failed to set table width."
 892 |     except Exception as e:
 893 |         return f"Failed to set table width: {str(e)}"
 894 | 
 895 | 
 896 | async def auto_fit_table_columns(filename: str, table_index: int) -> str:
 897 |     """Set table columns to auto-fit based on content.
 898 |     
 899 |     Args:
 900 |         filename: Path to the Word document
 901 |         table_index: Index of the table (0-based)
 902 |     """
 903 |     filename = ensure_docx_extension(filename)
 904 |     
 905 |     # Ensure numeric parameters are the correct type
 906 |     try:
 907 |         table_index = int(table_index)
 908 |     except (ValueError, TypeError):
 909 |         return "Invalid parameter: table_index must be an integer"
 910 |     
 911 |     if not os.path.exists(filename):
 912 |         return f"Document {filename} does not exist"
 913 |     
 914 |     # Check if file is writeable
 915 |     is_writeable, error_message = check_file_writeable(filename)
 916 |     if not is_writeable:
 917 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 918 |     
 919 |     try:
 920 |         doc = Document(filename)
 921 |         
 922 |         # Validate table index
 923 |         if table_index < 0 or table_index >= len(doc.tables):
 924 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 925 |         
 926 |         table = doc.tables[table_index]
 927 |         
 928 |         # Apply auto-fit
 929 |         success = auto_fit_table(table)
 930 |         
 931 |         if success:
 932 |             doc.save(filename)
 933 |             return f"Table {table_index} set to auto-fit columns based on content."
 934 |         else:
 935 |             return f"Failed to set table auto-fit."
 936 |     except Exception as e:
 937 |         return f"Failed to set table auto-fit: {str(e)}"
 938 | 
 939 | 
 940 | async def format_table_cell_text(filename: str, table_index: int, row_index: int, col_index: int,
 941 |                                  text_content: Optional[str] = None, bold: Optional[bool] = None, italic: Optional[bool] = None,
 942 |                                  underline: Optional[bool] = None, color: Optional[str] = None, font_size: Optional[int] = None,
 943 |                                  font_name: Optional[str] = None) -> str:
 944 |     """Format text within a specific table cell.
 945 |     
 946 |     Args:
 947 |         filename: Path to the Word document
 948 |         table_index: Index of the table (0-based)
 949 |         row_index: Row index (0-based)
 950 |         col_index: Column index (0-based)
 951 |         text_content: Optional new text content for the cell
 952 |         bold: Set text bold (True/False)
 953 |         italic: Set text italic (True/False)
 954 |         underline: Set text underlined (True/False)
 955 |         color: Text color (hex string like "FF0000" or color name like "red")
 956 |         font_size: Font size in points
 957 |         font_name: Font name/family
 958 |     """
 959 |     filename = ensure_docx_extension(filename)
 960 |     
 961 |     # Ensure numeric parameters are the correct type
 962 |     try:
 963 |         table_index = int(table_index)
 964 |         row_index = int(row_index)
 965 |         col_index = int(col_index)
 966 |         if font_size is not None:
 967 |             font_size = int(font_size)
 968 |     except (ValueError, TypeError):
 969 |         return "Invalid parameter: table_index, row_index, col_index must be integers, font_size must be integer"
 970 |     
 971 |     if not os.path.exists(filename):
 972 |         return f"Document {filename} does not exist"
 973 |     
 974 |     # Check if file is writeable
 975 |     is_writeable, error_message = check_file_writeable(filename)
 976 |     if not is_writeable:
 977 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
 978 |     
 979 |     try:
 980 |         doc = Document(filename)
 981 |         
 982 |         # Validate table index
 983 |         if table_index < 0 or table_index >= len(doc.tables):
 984 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
 985 |         
 986 |         table = doc.tables[table_index]
 987 |         
 988 |         # Validate row and column indices
 989 |         if row_index < 0 or row_index >= len(table.rows):
 990 |             return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
 991 |         
 992 |         if col_index < 0 or col_index >= len(table.rows[row_index].cells):
 993 |             return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
 994 |         
 995 |         # Apply cell text formatting
 996 |         success = format_cell_text_by_position(table, row_index, col_index, text_content, 
 997 |                                               bold, italic, underline, color, font_size, font_name)
 998 |         
 999 |         if success:
1000 |             doc.save(filename)
1001 |             format_desc = []
1002 |             if text_content is not None:
1003 |                 format_desc.append(f"content='{text_content[:30]}{'...' if len(text_content) > 30 else ''}'")
1004 |             if bold is not None:
1005 |                 format_desc.append(f"bold={bold}")
1006 |             if italic is not None:
1007 |                 format_desc.append(f"italic={italic}")
1008 |             if underline is not None:
1009 |                 format_desc.append(f"underline={underline}")
1010 |             if color is not None:
1011 |                 format_desc.append(f"color={color}")
1012 |             if font_size is not None:
1013 |                 format_desc.append(f"size={font_size}pt")
1014 |             if font_name is not None:
1015 |                 format_desc.append(f"font={font_name}")
1016 |             
1017 |             format_str = ", ".join(format_desc) if format_desc else "no changes"
1018 |             return f"Cell text formatted successfully in table {table_index}, cell ({row_index},{col_index}): {format_str}."
1019 |         else:
1020 |             return f"Failed to format cell text. Check that indices are valid."
1021 |     except Exception as e:
1022 |         return f"Failed to format cell text: {str(e)}"
1023 | 
1024 | 
1025 | async def set_table_cell_padding(filename: str, table_index: int, row_index: int, col_index: int,
1026 |                                  top: Optional[float] = None, bottom: Optional[float] = None, left: Optional[float] = None, 
1027 |                                  right: Optional[float] = None, unit: str = "points") -> str:
1028 |     """Set padding/margins for a specific table cell.
1029 |     
1030 |     Args:
1031 |         filename: Path to the Word document
1032 |         table_index: Index of the table (0-based)
1033 |         row_index: Row index (0-based)
1034 |         col_index: Column index (0-based)
1035 |         top: Top padding in specified units
1036 |         bottom: Bottom padding in specified units
1037 |         left: Left padding in specified units
1038 |         right: Right padding in specified units
1039 |         unit: Unit type ("points" or "percent")
1040 |     """
1041 |     filename = ensure_docx_extension(filename)
1042 |     
1043 |     # Ensure numeric parameters are the correct type
1044 |     try:
1045 |         table_index = int(table_index)
1046 |         row_index = int(row_index)
1047 |         col_index = int(col_index)
1048 |         if top is not None:
1049 |             top = float(top)
1050 |         if bottom is not None:
1051 |             bottom = float(bottom)
1052 |         if left is not None:
1053 |             left = float(left)
1054 |         if right is not None:
1055 |             right = float(right)
1056 |     except (ValueError, TypeError):
1057 |         return "Invalid parameter: indices must be integers, padding values must be numbers"
1058 |     
1059 |     # Validate unit
1060 |     valid_units = ["points", "percent"]
1061 |     if unit.lower() not in valid_units:
1062 |         return f"Invalid unit. Valid options: {', '.join(valid_units)}"
1063 |     
1064 |     if not os.path.exists(filename):
1065 |         return f"Document {filename} does not exist"
1066 |     
1067 |     # Check if file is writeable
1068 |     is_writeable, error_message = check_file_writeable(filename)
1069 |     if not is_writeable:
1070 |         return f"Cannot modify document: {error_message}. Consider creating a copy first."
1071 |     
1072 |     try:
1073 |         doc = Document(filename)
1074 |         
1075 |         # Validate table index
1076 |         if table_index < 0 or table_index >= len(doc.tables):
1077 |             return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})."
1078 |         
1079 |         table = doc.tables[table_index]
1080 |         
1081 |         # Validate row and column indices
1082 |         if row_index < 0 or row_index >= len(table.rows):
1083 |             return f"Invalid row index. Table has {len(table.rows)} rows (0-{len(table.rows)-1})."
1084 |         
1085 |         if col_index < 0 or col_index >= len(table.rows[row_index].cells):
1086 |             return f"Invalid column index. Row has {len(table.rows[row_index].cells)} cells (0-{len(table.rows[row_index].cells)-1})."
1087 |         
1088 |         # Convert unit for Word format
1089 |         word_unit = "dxa" if unit.lower() == "points" else "pct"
1090 |         
1091 |         # Apply cell padding
1092 |         success = set_cell_padding_by_position(table, row_index, col_index, top, bottom, 
1093 |                                               left, right, word_unit)
1094 |         
1095 |         if success:
1096 |             doc.save(filename)
1097 |             padding_desc = []
1098 |             if top is not None:
1099 |                 padding_desc.append(f"top={top}")
1100 |             if bottom is not None:
1101 |                 padding_desc.append(f"bottom={bottom}")
1102 |             if left is not None:
1103 |                 padding_desc.append(f"left={left}")
1104 |             if right is not None:
1105 |                 padding_desc.append(f"right={right}")
1106 |             
1107 |             padding_str = ", ".join(padding_desc) if padding_desc else "no padding"
1108 |             return f"Cell padding set successfully for table {table_index}, cell ({row_index},{col_index}): {padding_str} {unit}."
1109 |         else:
1110 |             return f"Failed to set cell padding. Check that indices are valid."
1111 |     except Exception as e:
1112 |         return f"Failed to set cell padding: {str(e)}"
1113 | 
```
Page 2/2FirstPrevNextLast