#
tokens: 9733/50000 11/11 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .gitignore
├── .python-version
├── .vscode
│   └── settings.json
├── Dockerfile
├── LICENSE
├── mcp_server_office
│   ├── __init__.py
│   ├── __main__.py
│   ├── office.py
│   └── tools.py
├── pyproject.toml
├── README.md
├── smithery.yaml
├── tests
│   └── test_office.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
3.12

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

```

--------------------------------------------------------------------------------
/mcp_server_office/__init__.py:
--------------------------------------------------------------------------------

```python
from .office import run

__all__ = ['run']

```

--------------------------------------------------------------------------------
/mcp_server_office/__main__.py:
--------------------------------------------------------------------------------

```python
from . import run
import asyncio
import sys

def main():
    asyncio.run(run())

if __name__ == "__main__":
    sys.exit(main())

```

--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------

```json
{
    "python.testing.pytestArgs": [
        "tests"
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.pytestEnabled": true
}
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml

startCommand:
  type: stdio
  configSchema:
    # JSON Schema defining the configuration options for the MCP.
    type: object
    properties: {}
    default: {}
  commandFunction:
    # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
    |-
    (config) => ({ command: 'mcp-server-office', args: [], env: {} })
  exampleConfig: {}

```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
FROM python:3.12-slim

WORKDIR /app

# Copy the current directory into the container
COPY . .

# Install build dependencies
RUN pip install --upgrade pip

# Build and install the MCP server using hatchling
RUN pip install --no-cache-dir .

# Expose a port if needed (not required for stdio, but might be useful for logging)

# Command to run the MCP server
ENTRYPOINT ["mcp-server-office"]

```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mcp-server-office"
version = "0.2.0"
description = "A Model Context Protocol server providing tools to read/write docx files"
authors = [{name = "famano"}]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "mcp[cli]>=1.2.0",
    "python-docx>=1.1.2",
]

[project.scripts]
mcp-server-office = "mcp_server_office.__main__:main"

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "pytest-asyncio>=0.23.0",
]

[tool.pytest.ini_options]
asyncio_mode = "strict"

```

--------------------------------------------------------------------------------
/mcp_server_office/tools.py:
--------------------------------------------------------------------------------

```python
from mcp import types

READ_DOCX = types.Tool(
    name="read_docx",
    description=(
        "Read complete contents of a docx file including tables and images."
        "Use this tool when you want to read file endswith '.docx'."
        "Paragraphs are separated with two line breaks."
        "This tool convert images into placeholder [Image]."
        "'--- Paragraph [number] ---' is indicator of each paragraph."
    ),
    inputSchema={
        "type": "object",
                "properties": {
                "path": {
                        "type": "string",
                        "description": "Absolute path to target file",
                    }
                },
        "required": ["path"]
    }
)

EDIT_DOCX_INSERT = types.Tool(
    name="edit_docx_insert",
    description=(
        "Insert new paragraphs into a docx file. "
        "Accepts a list of inserts with text and optional paragraph index. "
        "Each insert creates a new paragraph at the specified position. "
        "If paragraph_index is not specified, the paragraph is added at the end. "
        "When multiple inserts target the same paragraph_index, they are inserted in order. "
        "Returns a git-style diff showing the changes made."
    ),
    inputSchema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Absolute path to file to edit. It should be under your current working directory."
            },
            "inserts": {
                "type": "array",
                "description": "Sequence of paragraphs to insert.",
                "items": {
                    "type": "object",
                    "properties": {
                        "text": {
                            "type": "string",
                            "description": "Text to insert as a new paragraph."
                        },
                        "paragraph_index": {
                            "type": "integer",
                            "description": "0-based index of the paragraph before which to insert. If not specified, insert at the end."
                        }
                    },
                    "required": ["text"]
                }
            }
        },
        "required": ["path", "inserts"]
    }
)

WRITE_DOCX = types.Tool(
    name="write_docx",
    description=(
        "Create a new docx file with given content."
        "Editing exisiting docx file with this tool is not recomended."
    ),
    inputSchema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Absolute path to target file. It should be under your current working directory.",
            },
            "content": {
                "type": "string",
                "description": (
                    "Content to write to the file. Two line breaks in content represent new paragraph."
                    "Table should starts with [Table], and separated with '|'."
                    "Escape line break when you input multiple lines."
                ),
            }
        },
        "required": ["path", "content"]
    }
)

EDIT_DOCX_PARAGRAPH = types.Tool(
    name="edit_docx_paragraph",
    description=(
        "Make text replacements in specified paragraphs of a docx file. "
        "Accepts a list of edits with paragraph index and search/replace pairs. "
        "Each edit operates on a single paragraph and preserves the formatting of the first run. "
        "Returns a git-style diff showing the changes made. Only works within allowed directories."
    ),
    inputSchema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Absolute path to file to edit. It should be under your current working directory."
            },
            "edits": {
                "type": "array",
                "description": "Sequence of edits to apply to specific paragraphs.",
                "items": {
                    "type": "object",
                    "properties": {
                        "paragraph_index": {
                            "type": "integer",
                            "description": "0-based index of the paragraph to edit. tips: whole table is count as one paragraph."
                        },
                        "search": {
                            "type": "string",
                            "description": (
                                "Text to find within the specified paragraph. "
                                "The search is performed only within the target paragraph. "
                                "Escape line break when you input multiple lines."
                            )
                        },
                        "replace": {
                            "type": "string",
                            "description": (
                                "Text to replace the search string with. "
                                "The formatting of the first run in the paragraph will be applied to the entire replacement text. "
                                "Empty string represents deletion. "
                                "Escape line break when you input multiple lines."
                            )
                        }
                    },
                    "required": ["paragraph_index", "search", "replace"]
                }
            }
        },
        "required": ["path", "edits"]
    }
)

```

--------------------------------------------------------------------------------
/mcp_server_office/office.py:
--------------------------------------------------------------------------------

```python
import os
from typing import Dict
from docx import Document
from docx.table import Table
from docx.oxml import OxmlElement
from mcp.server.lowlevel import Server, NotificationOptions
from mcp.server.stdio import stdio_server
from mcp.server.models import InitializationOptions
from mcp import types
import difflib
from mcp_server_office.tools import READ_DOCX, WRITE_DOCX, EDIT_DOCX_PARAGRAPH, EDIT_DOCX_INSERT

# WordML namespace constants
WORDML_NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
W_P = f"{{{WORDML_NS['w']}}}p"    # paragraph
W_TBL = f"{{{WORDML_NS['w']}}}tbl"  # table
W_R = f"{{{WORDML_NS['w']}}}r"    # run
W_T = f"{{{WORDML_NS['w']}}}t"    # text
W_DRAWING = f"{{{WORDML_NS['w']}}}drawing"  # drawing

server = Server("office-file-server")

async def validate_path(path: str) -> bool:
    if not os.path.isabs(path):
        raise ValueError(f"Not a absolute path: {path}")
    if not os.path.isfile(path):
        raise ValueError(f"File not found: {path}")
    elif path.endswith(".docx"):
        return True
    else:
        return False

def extract_table_text(table: Table) -> str:
    """Extract text from table with formatting."""
    rows = []
    for row in table.rows:
        cells = []
        for cell in row.cells:
            cell_p_texts = [process_track_changes(paragraph._element).strip() for paragraph in cell.paragraphs]
            celltext = "<br>".join(cell_p_texts) #複数行に渡る場合、<br>で表現
            cells.append(celltext)
        rows.append(" | ".join(cells))
    return "\n".join(rows)

def create_table_from_text(text: str, props :any =None) -> Table:
    """add table from text representation. if props are passed, apply it to all cells"""
    rows = text.split("\n")
    temp_doc = Document()
    if rows:
        num_columns = len(rows[0].split(" | "))
        table = temp_doc.add_table(rows=len(rows), cols=num_columns)
                
    for i, row in enumerate(rows):
        cells = row.split(" | ")
        for j, cell in enumerate(cells):
            table.cell(i, j).text = ""
            new_run = table.cell(i, j).paragraphs[0].add_run(cell.strip()) #<br>が入って改行されている場合でも文字として処理してしまう。要検討。
            if props is not None:
                new_run._element.append(props)
    return table

def process_track_changes(element: OxmlElement) -> str:
    """Process track changes in a paragraph element."""
    text = ""
    for child in element:
        if child.tag == W_R:  # Normal run
            for run_child in child:
                if run_child.tag == W_T:
                    text += run_child.text if run_child.text else ""
        elif child.tag.endswith('ins'):  # Insertion
            inserted_text = ""
            for run in child.findall('.//w:t', WORDML_NS):
                inserted_text += run.text if run.text else ""
            if inserted_text:
                text += inserted_text
    return text

async def read_docx(path: str) -> str:
    """Read docx file as text including tables.
    
    Args:
        path: relative path to target docx file
    Returns:
        str: Text representation of the document including tables
    """
    if not await validate_path(path):
        raise ValueError(f"Not a docx file: {path}")
    
    document = Document(path)
    content = []

    paragraph_index = 0
    table_index = 0
    
    # 全要素を順番に処理
    for element in document._body._body:
        # パラグラフの処理
        if element.tag == W_P:
            paragraph = document.paragraphs[paragraph_index]
            paragraph_index += 1
            # 画像のチェック
            if paragraph._element.findall(f'.//{W_DRAWING}', WORDML_NS):
                content.append("[Image]")
            # テキストのチェック
            else:
                text = process_track_changes(paragraph._element)
                if text.strip():
                    content.append(text)
                else:
                    # 空行を抜くと編集時に困るので、空行でも追加
                    content.append("")
        # テーブルの処理
        elif element.tag == W_TBL:
            table = document.tables[table_index]
            table_index += 1
            table_text = extract_table_text(table)
            content.append(f"[Table]\n{table_text}")

    separator = [f"--- Paragraph {i} ---" for i in range(len(content))]
    
    result = []
    for i, p in enumerate(content):
        result.append(separator[i])
        result.append(p)
        
    return "\n".join(result)

async def write_docx(path: str, content: str) -> None:
    """Create a new docx file with the given content.
    
    Args:
        path: target path to create docx file
        content: text content to write
    """
    document = Document()
    
    # Split content into sections
    sections = content.split("\n\n")
    
    for section in sections:
        if section.startswith("[Table]"):
            table = create_table_from_text(section[7:].strip()) # Remove [Table] prefix
            document.element.body.append(table._element)
        elif section.startswith("[Image]"):
            document.add_paragraph("[Image placeholder]")
        else:
            document.add_paragraph(section)
    
    document.save(path)

async def edit_docx_insert(path: str, inserts: list[Dict[str, str | int]]) -> str:
    """Insert new paragraphs into a docx file.
    
    Args:
        path: path to target docx file
        inserts: list of dictionaries containing text and optional paragraph_index
            [{'text': 'text to insert', 'paragraph_index': 0}, ...]
            text: text to insert as a new paragraph (required)
            paragraph_index: 0-based index of the paragraph before which to insert (optional)
    Returns:
        str: A git-style diff showing the changes made
    """
    if not await validate_path(path):
        raise ValueError(f"Not a docx file: {path}")
    
    doc = Document(path)
    original = await read_docx(path)

    # パラグラフとテーブルを順番に収集
    elements = []
    paragraph_count = 0
    table_count = 0
    for element in doc._body._body:
        if element.tag == W_P:
            elements.append(('p', doc.paragraphs[paragraph_count]))
            paragraph_count += 1
        elif element.tag == W_TBL:
            elements.append(('tbl', doc.tables[table_count]))
            table_count += 1

    # 挿入位置でソート(同じ位置への挿入は指定順を保持)
    sorted_inserts = sorted(
        enumerate(inserts),
        key=lambda x: (x[1].get('paragraph_index', float('inf')), x[0])
    )

    # 各挿入を処理
    for _, insert in sorted_inserts:
        text = insert['text']
        paragraph_index = insert.get('paragraph_index')

        # 新しい段落を作成
        new_paragraph = doc.add_paragraph(text)

        if paragraph_index is None:
            # 文書の最後に追加
            doc.element.body.append(new_paragraph._element)
            elements.append(('p', new_paragraph))
        elif paragraph_index >= len(elements):
            raise ValueError(f"Paragraph index out of range: {paragraph_index}")
        else:
            # 指定位置に挿入
            element_type, element = elements[paragraph_index]
            if element_type == 'p':
                element._element.addprevious(new_paragraph._element)
            elif element_type == 'tbl':
                element._element.addprevious(new_paragraph._element)
            
            # elementsリストを更新(後続の挿入のために必要)
            elements.insert(paragraph_index, ('p', new_paragraph))

    doc.save(path)
    
    # 差分の生成
    modified = await read_docx(path)
    diff_lines = []
    original_lines = [line for line in original.split("\n") if line.strip()]
    modified_lines = [line for line in modified.split("\n") if line.strip()]
    
    for line in difflib.unified_diff(original_lines, modified_lines, n=0):
        if line.startswith('---') or line.startswith('+++'):
            continue
        if line.startswith('-') or line.startswith('+'):
            diff_lines.append(line)
    
    return "\n".join(diff_lines) if diff_lines else ""

async def edit_docx_paragraph(path: str, edits: list[Dict[str, str | int]]) -> str:
    """Edit docx file by replacing text.
    
    Args:
        path: path to target docx file
        edits: list of dictionaries containing search and replace text, and paragraph_index
            [{'search': 'text to find', 'replace': 'text to replace with', 'paragraph_index': 0}, ...]
            paragraph_index: 0-based index of the paragraph to edit (required)
            search: text to find
            replace: text to replace with
    Returns:
        str: A git-style diff showing the changes made
    """
    if not await validate_path(path):
        raise ValueError(f"Not a docx file: {path}")
    
    doc = Document(path)
    original = await read_docx(path)
    not_found = []

    # パラグラフとテーブルを順番に収集
    elements = []
    paragraph_count = 0
    table_count = 0
    for element in doc._body._body:
        if element.tag == W_P:
            elements.append(('p', doc.paragraphs[paragraph_count]))
            paragraph_count += 1
        elif element.tag == W_TBL:
            elements.append(('tbl', doc.tables[table_count]))
            table_count += 1

    for edit in edits:
        search = edit["search"]
        replace = edit["replace"]
        
        if "paragraph_index" not in edit:
            raise ValueError("paragraph_index is required")
            
        paragraph_index = edit["paragraph_index"]
        if paragraph_index >= len(elements):
            raise ValueError(f"Paragraph index out of range: {paragraph_index}")
        
        element_type, element = elements[paragraph_index]
        
        if element_type == 'p':
            paragraph = element
            if search not in paragraph.text:
                not_found.append(f"'{search}' in paragraph {paragraph_index}")
                continue

            # Store original XML element and get first run's properties
            original_element = paragraph._element
            first_run_props = None
            runs = original_element.findall(f'.//w:r', WORDML_NS)
            if runs:
                first_run = runs[0]
                if hasattr(first_run, 'rPr'):
                    first_run_props = first_run.rPr
            
            # Create new paragraph with the entire text
            new_paragraph = doc.add_paragraph()
            
            # Copy paragraph properties
            if original_element.pPr is not None:
                new_paragraph._p.append(original_element.pPr)
            
            # Replace text and create a single run with first run's properties
            new_text = process_track_changes(paragraph._element).replace(search, replace, 1)
            new_run = new_paragraph.add_run(new_text)
            if first_run_props is not None:
                new_run._element.append(first_run_props)
            
            # Replace original paragraph with new one
            original_element.getparent().replace(original_element, new_paragraph._element)
            
        elif element_type == 'tbl':
            # tableの場合、複数行に渡る書換では、特に行列が増減する場合、書式を保つことが困難なため、とりあえず0,0の書式を適用することとする。要検討。
            table = element
            table_paragraph = table._element.getprevious()
            table_text = extract_table_text(table)
            if search in table_text:
                # 既存tableを削除(親要素の参照を保持して安全に削除)
                parent = table._element.getparent()
                if parent is not None:
                    parent.remove(table._element)
                else:
                    # テーブルが文書のルート要素である場合(先頭の場合などにおそらく必要)
                    doc.element.body.remove(table._element)
                
                # Get first run's properties from the first cell
                first_run_props = None
                for paragraph in table.rows[0].cells[0].paragraphs:
                    for run in paragraph.runs:
                        if run._element.rPr is not None:
                            first_run_props = run._element.rPr
                            break
                
                new_text = table_text.replace(search, replace, 1)
                new_table = create_table_from_text(new_text, first_run_props)
                elements[paragraph_index] = ("tbl", new_table) # これがないと複数編集時に、あとの編集でtableがみつからなくなる
                if table_paragraph is not None:
                    table_paragraph.addnext(new_table._element)
                else:
                    # Noneの場合はtableの前がない、つまり先頭を意味する
                    doc.element.body.insert(0, new_table._element)
            else:
                not_found.append(f"'{search}' in table at paragraph {paragraph_index}")
            
    if not_found:
        raise ValueError(f"Search text not found: {', '.join(not_found)}")
    
    doc.save(path)
    
    # Read modified content and create diff
    modified = await read_docx(path)
    
    # 差分の生成
    diff_lines = []
    original_lines = [line for line in original.split("\n") if line.strip()]
    modified_lines = [line for line in modified.split("\n") if line.strip()]
    
    for line in difflib.unified_diff(original_lines, modified_lines, n=0):
        if line.startswith('---') or line.startswith('+++'):
            continue
        if line.startswith('-') or line.startswith('+'):
            diff_lines.append(line)
    
    return "\n".join(diff_lines) if diff_lines else ""

@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [READ_DOCX, EDIT_DOCX_PARAGRAPH, WRITE_DOCX, EDIT_DOCX_INSERT]

@server.call_tool()
async def call_tool(
    name: str,
    arguments: dict
) -> list[types.TextContent]:
    if name == "read_docx":
        content = await read_docx(arguments["path"])
        return [types.TextContent(type="text", text=content)]
    elif name == "write_docx":
        await write_docx(arguments["path"], arguments["content"])
        return [types.TextContent(type="text", text="Document created successfully")]
    elif name == "edit_docx_paragraph":
        result = await edit_docx_paragraph(arguments["path"], arguments["edits"])
        return [types.TextContent(type="text", text=result)]
    elif name == "edit_docx_insert":
        result = await edit_docx_insert(arguments["path"], arguments["inserts"])
        return [types.TextContent(type="text", text=result)]
    raise ValueError(f"Tool not found: {name}")

async def run():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="office-file-server",
                server_version="0.1.1",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={},
                ),
            )
        )

if __name__ == "__main__":
    import asyncio
    asyncio.run(run())

```

--------------------------------------------------------------------------------
/tests/test_office.py:
--------------------------------------------------------------------------------

```python
import os
import pytest
from mcp_server_office.office import validate_path, read_docx, write_docx, edit_docx_paragraph as edit_docx, edit_docx_insert, extract_table_text
from docx import Document
from docx.table import Table
from docx.oxml.shared import qn
from docx.oxml import OxmlElement
from docx.text.paragraph import Paragraph

@pytest.fixture
def sample_docx_with_track_changes():
    """Create a sample docx file with track changes for testing."""
    path = "test_track_changes.docx"
    doc = Document()
    paragraph = doc.add_paragraph()
    
    # Add text with track changes
    run = OxmlElement('w:r')
    text = OxmlElement('w:t')
    text.text = "Original"
    run.append(text)
    paragraph._element.append(run)
    
    # Add deletion
    del_element = OxmlElement('w:del')
    del_element.set(qn('w:author'), 'Test Author')
    del_element.set(qn('w:date'), '2024-01-27T00:00:00Z')
    del_run = OxmlElement('w:r')
    del_text = OxmlElement('w:delText')
    del_text.text = " deleted"
    del_run.append(del_text)
    del_element.append(del_run)
    paragraph._element.append(del_element)
    
    # Add insertion
    ins_element = OxmlElement('w:ins')
    ins_element.set(qn('w:author'), 'Test Author')
    ins_element.set(qn('w:date'), '2024-01-27T00:00:00Z')
    ins_run = OxmlElement('w:r')
    ins_text = OxmlElement('w:t')
    ins_text.text = " inserted"
    ins_run.append(ins_text)
    ins_element.append(ins_run)
    paragraph._element.append(ins_element)
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.fixture
def sample_docx():
    """Create a sample docx file for testing."""
    path = "test_sample.docx"
    doc = Document()
    doc.add_paragraph("Hello World")
    
    # Add table
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "A1"
    table.cell(0, 1).text = "B1"
    table.cell(1, 0).text = "A2"
    table.cell(1, 1).text = "B2"
    
    doc.add_paragraph("Goodbye World")
    doc.save(path)
    yield path
    # Cleanup
    if os.path.exists(path):
        os.remove(path)

@pytest.mark.asyncio
async def test_validate_path(sample_docx):
    """Test path validation."""
    assert await validate_path(os.path.abspath(sample_docx)) == True
    with pytest.raises(ValueError):
        await validate_path(sample_docx) 
    with pytest.raises(ValueError):
        await validate_path("nonexistent.docx")

@pytest.mark.asyncio
async def test_read_docx_with_track_changes(sample_docx_with_track_changes):
    """Test reading docx file with track changes."""
    content = await read_docx(os.path.abspath(sample_docx_with_track_changes))
    assert "Original" in content
    assert not "deleted" in content
    assert "inserted" in content

@pytest.mark.asyncio
async def test_read_docx(sample_docx):
    """Test reading docx file."""
    content = await read_docx(os.path.abspath(sample_docx))
    assert "Hello World" in content
    assert "Goodbye World" in content
    assert "[Table]" in content
    assert "A1 | B1" in content
    assert "A2 | B2" in content

    with pytest.raises(ValueError):
        await read_docx("nonexistent.docx")

@pytest.mark.asyncio
async def test_write_docx():
    """Test writing docx file."""
    test_path = "test_write.docx"
    test_content = "Test Paragraph\n\n[Table]\nC1 | D1\nC2 | D2\n\nFinal Paragraph"
    
    try:
        await write_docx(test_path, test_content)
        assert os.path.exists(test_path)
        
        # Verify content
        doc = Document(test_path)
        paragraphs = [p.text for p in doc.paragraphs if p.text]
        assert "Test Paragraph" in paragraphs
        assert "Final Paragraph" in paragraphs
        
        # Verify table
        table = doc.tables[0]
        assert table.cell(0, 0).text == "C1"
        assert table.cell(0, 1).text == "D1"
        assert table.cell(1, 0).text == "C2"
        assert table.cell(1, 1).text == "D2"
    
    finally:
        if os.path.exists(test_path):
            os.remove(test_path)

@pytest.mark.asyncio
async def test_edit_docx_with_track_changes(sample_docx_with_track_changes):
    """Test editing docx file with track changes."""
    abs_path = os.path.abspath(sample_docx_with_track_changes)
    await edit_docx(abs_path, [{"paragraph_index": 0,"search": "Original", "replace": "Modified"}])
    
    # Verify track changes are preserved
    content = await read_docx(abs_path)
    assert not "Original" in content
    assert "Modified" in content
    assert not "deleted" in content
    assert "inserted" in content

@pytest.fixture
def complex_docx():
    """Create a sample docx file with complex content for testing cross-paragraph and table edits."""
    path = "test_complex.docx"
    doc = Document()
    
    # Add paragraphs with text that spans multiple paragraphs
    doc.add_paragraph("First part of")
    doc.add_paragraph("a sentence that")
    doc.add_paragraph("spans multiple paragraphs")
    
    doc.add_paragraph("Some text before table")
    
    # Add table with text that will be edited
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "Table"
    table.cell(0, 1).text = "Content"
    table.cell(1, 0).text = "More"
    table.cell(1, 1).text = "Text"
    
    doc.add_paragraph("Some text after table")
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.fixture
def formatted_docx():
    """Create a sample docx file with formatted text for testing."""
    path = "test_formatted.docx"
    doc = Document()
    
    # Add paragraph with formatted text
    paragraph = doc.add_paragraph()
    run = paragraph.add_run("Bold")
    run.bold = True
    run = paragraph.add_run(" and ")
    run = paragraph.add_run("Italic")
    run.italic = True
    run = paragraph.add_run(" text")
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.mark.asyncio
async def test_edit_docx(sample_docx):
    abs_sample_docx = os.path.abspath(sample_docx)
    
    """Test editing docx file with paragraph index."""
    # Test single edit with paragraph index
    result = await edit_docx(abs_sample_docx, [{"paragraph_index": 0, "search": "Hello", "replace": "Hi"}])
    assert "-Hello" in result
    assert "+Hi" in result
    
    # Test multiple edits in different paragraphs
    result = await edit_docx(abs_sample_docx, [
        {"paragraph_index": 0, "search": "Hi", "replace": "Hellow"},
        {"paragraph_index": 2, "search": "Goodbye", "replace": "Bye"}
    ])
    assert "-Hi" in result
    assert "+Hellow" in result
    assert "-Goodbye" in result
    assert "+Bye" in result
    
    # Test non-existent text in paragraph
    with pytest.raises(ValueError) as exc_info:
        await edit_docx(abs_sample_docx, [{"paragraph_index": 0, "search": "NonexistentText", "replace": "NewText"}])
    assert "'NonexistentText' in paragraph 0" in str(exc_info.value)
    
    # Test invalid paragraph index
    with pytest.raises(ValueError) as exc_info:
        await edit_docx(abs_sample_docx, [{"paragraph_index": 999, "search": "Hello", "replace": "Hi"}])
    assert "Paragraph index out of range: 999" in str(exc_info.value)
    
    # Test invalid file
    with pytest.raises(ValueError):
        await edit_docx("nonexistent.docx", [{"paragraph_index": 0, "search": "Hello", "replace": "Hi"}])

@pytest.mark.asyncio
async def test_edit_docx_format_preservation(formatted_docx):
    """Test that formatting is preserved when editing text."""
    abs_formatted_docx = os.path.abspath(formatted_docx)
    
    # Edit text while preserving bold formatting
    result = await edit_docx(abs_formatted_docx, [
        {"paragraph_index": 0, "search": "Bold and Italic text", "replace": "Modified text"}
    ])
    
    # Verify content was changed
    assert "-Bold and Italic text" in result
    assert "+Modified text" in result
    
    # Verify formatting was preserved by checking the document directly
    doc = Document(abs_formatted_docx)
    paragraph = doc.paragraphs[0]
    assert len(paragraph.runs) == 1  # Should be consolidated into a single run
    run = paragraph.runs[0]
    assert run.bold  # Should inherit bold formatting from first run

@pytest.mark.asyncio
async def test_edit_docx_table_content(complex_docx):
    """Test editing text within table cells."""
    abs_complex_docx = os.path.abspath(complex_docx)
    
    # Test editing table content
    result = await edit_docx(abs_complex_docx, [
        {"paragraph_index": 4,"search": "Table | Content", "replace": "Modified | Cell"},
        {"paragraph_index": 4,"search": "More | Text", "replace": "Modification | Two"},
    ])
    assert "-Table | Content" in result
    assert "+Modified | Cell" in result
    assert "-More | Text" in result
    assert "+Modification | Two" in result


@pytest.fixture
def table_at_start_docx():
    """Create a docx file that starts with a table."""
    path = "test_table_at_start.docx"
    doc = Document()
    
    # Add table at the start
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "Start1"
    table.cell(0, 1).text = "Start2"
    table.cell(1, 0).text = "Start3"
    table.cell(1, 1).text = "Start4"
    
    # Add some text after table
    doc.add_paragraph("Text after table")
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.fixture
def table_after_empty_paragraph_docx():
    """Create a docx file with empty paragraph before table."""
    path = "test_table_after_empty.docx"
    doc = Document()
    
    # Add empty paragraph
    doc.add_paragraph("")
    
    # Add table after empty paragraph
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "Empty1"
    table.cell(0, 1).text = "Empty2"
    table.cell(1, 0).text = "Empty3"
    table.cell(1, 1).text = "Empty4"
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.mark.asyncio
async def test_edit_docx_table_at_start(table_at_start_docx):
    """Test editing table that appears at the start of document."""
    abs_path = os.path.abspath(table_at_start_docx)
    
    # Test editing the table content
    result = await edit_docx(abs_path, [
        {"paragraph_index": 0, "search": "Start1 | Start2", "replace": "Modified1 | Modified2"}
    ])
    
    # Verify changes
    assert "-Start1 | Start2" in result
    assert "+Modified1 | Modified2" in result
    
    # Verify the document structure is maintained
    content = await read_docx(abs_path)
    assert "Text after table" in content  # Verify text after table is preserved

@pytest.mark.asyncio
async def test_edit_docx_table_after_empty_paragraph(table_after_empty_paragraph_docx):
    """Test editing table that appears after an empty paragraph."""
    abs_path = os.path.abspath(table_after_empty_paragraph_docx)
    
    # Test editing the table content
    result = await edit_docx(abs_path, [
        {"paragraph_index": 1, "search": "Empty1 | Empty2", "replace": "Modified1 | Modified2"}
    ])
    
    # Verify changes
    assert "-Empty1 | Empty2" in result
    assert "+Modified1 | Modified2" in result
    
    # Verify the document structure is maintained
    content = await read_docx(abs_path)
    assert "Modified1" in content
    assert "Modified2" in content

@pytest.fixture
def deleted_text_before_table_docx():
    """Create a docx file with deleted text in a paragraph before table."""
    path = "test_deleted_text_before_table.docx"
    doc = Document()
    
    # 削除された文字を含む段落を追加
    paragraph = doc.add_paragraph()
    # 削除マークを付けたテキストを追加
    del_element = OxmlElement('w:del')
    del_element.set(qn('w:author'), 'Test Author')
    del_element.set(qn('w:date'), '2024-01-27T00:00:00Z')
    del_run = OxmlElement('w:r')
    del_text = OxmlElement('w:delText')
    del_text.text = "This text is deleted"
    del_run.append(del_text)
    del_element.append(del_run)
    paragraph._element.append(del_element)
    
    # テーブルを追加
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "Test1"
    table.cell(0, 1).text = "Test2"
    table.cell(1, 0).text = "Test3"
    table.cell(1, 1).text = "Test4"
    
    doc.save(path)
    yield path
    if os.path.exists(path):
        os.remove(path)

@pytest.mark.asyncio
async def test_edit_docx_table_after_deleted_text(deleted_text_before_table_docx):
    """Test editing table that appears after a paragraph with deleted text."""
    abs_path = os.path.abspath(deleted_text_before_table_docx)
    
    # テーブルの内容を編集
    result = await edit_docx(abs_path, [
        {"paragraph_index": 1, "search": "Test1 | Test2", "replace": "Modified1 | Modified2"}
    ])
    
    # 変更を確認
    assert "-Test1 | Test2" in result
    assert "+Modified1 | Modified2" in result
    
    # ドキュメント構造が維持されていることを確認
    content = await read_docx(abs_path)
    assert "Modified1" in content
    assert "Modified2" in content
    # 削除されたテキストが表示されないことを確認
    assert "This text is deleted" not in content

def test_extract_table_text():
    """Test table text extraction."""
    doc = Document()
    table = doc.add_table(rows=2, cols=2)
    table.cell(0, 0).text = "X1"
    table.cell(0, 1).text = "Y1"
    table.cell(1, 0).text = "X2"
    table.cell(1, 1).text = "Y2"
    
    result = extract_table_text(table)
    assert "X1 | Y1" in result
    assert "X2 | Y2" in result

@pytest.mark.asyncio
async def test_edit_docx_insert_basic(sample_docx):
    """Test basic paragraph insertion."""
    abs_path = os.path.abspath(sample_docx)
    
    result = await edit_docx_insert(abs_path, [
        {"text": "Inserted Text", "paragraph_index": 1}
    ])
    
    # 変更を確認
    content = await read_docx(abs_path)
    assert "Inserted Text" in content
    assert "+Inserted Text" in result

@pytest.mark.asyncio
async def test_edit_docx_insert_multiple(sample_docx):
    """Test inserting multiple paragraphs at different positions."""
    abs_path = os.path.abspath(sample_docx)
    
    result = await edit_docx_insert(abs_path, [
        {"text": "First Insert", "paragraph_index": 0},
        {"text": "Second Insert", "paragraph_index": 2}
    ])
    
    content = await read_docx(abs_path)
    assert "First Insert" in content
    assert "Second Insert" in content
    assert content.index("First Insert") < content.index("Second Insert")

@pytest.mark.asyncio
async def test_edit_docx_insert_same_position(sample_docx):
    """Test inserting multiple paragraphs at the same position."""
    abs_path = os.path.abspath(sample_docx)
    
    result = await edit_docx_insert(abs_path, [
        {"text": "First Same Position"},
        {"text": "Second Same Position"},
        {"text": "Third Same Position"}
    ])
    
    content = await read_docx(abs_path)
    # 指定順序で挿入されていることを確認
    assert content.index("First Same Position") < content.index("Second Same Position")
    assert content.index("Second Same Position") < content.index("Third Same Position")

@pytest.mark.asyncio
async def test_edit_docx_insert_at_end(sample_docx):
    """Test inserting paragraph at the end of document."""
    abs_path = os.path.abspath(sample_docx)
    
    result = await edit_docx_insert(abs_path, [
        {"text": "End of Document"}
    ])
    
    content = await read_docx(abs_path)
    assert "End of Document" in content
    assert content.rindex("End of Document") > content.rindex("Goodbye World")

@pytest.mark.asyncio
async def test_edit_docx_insert_before_table(complex_docx):
    """Test inserting paragraph before table."""
    abs_path = os.path.abspath(complex_docx)
    
    result = await edit_docx_insert(abs_path, [
        {"text": "Before Table Text", "paragraph_index": 4}
    ])
    
    content = await read_docx(abs_path)
    assert "Before Table Text" in content
    # テーブルの前に挿入されていることを確認
    table_index = content.index("[Table]")
    insert_index = content.index("Before Table Text")
    assert insert_index < table_index

@pytest.mark.asyncio
async def test_edit_docx_insert_errors(sample_docx):
    """Test error cases for paragraph insertion."""
    # 存在しないファイル
    with pytest.raises(ValueError) as exc_info:
        await edit_docx_insert(os.path.abspath("nonexistent.docx"), [{"text": "Test"}])
    assert "File not found" in str(exc_info.value)
    
    # 範囲外のインデックス
    abs_path = os.path.abspath(sample_docx)
    with pytest.raises(ValueError) as exc_info:
        await edit_docx_insert(abs_path, [{"text": "Test", "paragraph_index": 999}])
    assert "Paragraph index out of range" in str(exc_info.value)

```