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 |
```