Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions docx_mcp/document/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,50 @@ def update_paragraph(
para_id: str,
text: str | None = None,
style: str | None = None,
tracked: bool = False,
author: str = "Claude",
) -> dict:
doc = self._require("word/document.xml")
para = self._find_para(doc, para_id)
if para is None:
raise ValueError(f"Paragraph '{para_id}' not found")

if text is not None:
for run in list(para.findall(f"{W}r")):
para.remove(run)
run = etree.SubElement(para, f"{W}r")
t_el = etree.SubElement(run, f"{W}t")
_preserve(t_el, text)
if tracked:
# ── Tracked: wrap existing runs in w:del, append w:ins ──
cid = self._next_markup_id(doc)
now = _now_iso()

# Wrap each existing w:r in w:del
for run in list(para.findall(f"{W}r")):
para.remove(run)
# Convert w:t → w:delText inside runs
for t_el in run.iter(f"{W}t"):
t_el.tag = f"{W}delText"
del_el = etree.Element(f"{W}del")
del_el.set(f"{W}id", str(cid))
del_el.set(f"{W}author", author)
del_el.set(f"{W}date", now)
del_el.append(run)
para.append(del_el)

# Append w:ins with new text
ins_cid = self._next_markup_id(doc)
ins_el = etree.Element(f"{W}ins")
ins_el.set(f"{W}id", str(ins_cid))
ins_el.set(f"{W}author", author)
ins_el.set(f"{W}date", now)
new_run = etree.SubElement(ins_el, f"{W}r")
new_t = etree.SubElement(new_run, f"{W}t")
_preserve(new_t, text)
para.append(ins_el)
else:
# ── Silent: existing behavior ───────────────────────────
for run in list(para.findall(f"{W}r")):
para.remove(run)
run = etree.SubElement(para, f"{W}r")
t_el = etree.SubElement(run, f"{W}t")
_preserve(t_el, text)

if style is not None:
ppr = para.find(f"{W}pPr")
Expand Down
6 changes: 0 additions & 6 deletions docx_mcp/document/tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,6 @@ def _resolve(
f"Ambiguous: {find!r} found {len(m)} times in paragraph; "
"provide context_before or context_after"
)
# Unique in this paragraph — verify document-global uniqueness
if _doc_norm_count(doc, norm_find, ignore_case=ignore_case) > 1:
raise ValueError(
f"Ambiguous: {find!r} appears in multiple paragraphs; "
"provide context_before or context_after"
)
match = m[0]

ns, ne = match
Expand Down
20 changes: 16 additions & 4 deletions docx_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,16 +716,19 @@ def delete_image(rId: str) -> str:


@mcp.tool()
def update_image(rId: str, new_image_path: str) -> str:
def update_image(rId: str, new_image_path: str, document_handle: str = "") -> str:
"""Replace the binary for an existing image in-place.

The image dimensions and position in the document are preserved.

Args:
rId: The relationship ID of the image to replace.
new_image_path: Absolute path to the new image file on disk.
document_handle: Optional handle for concurrent session isolation.
Empty string uses the shared __default__ slot.
"""
return _js(_require_doc().update_image(rId, new_image_path))
_, doc = _resolve(document_handle)
return _js(doc.update_image(rId, new_image_path))


@mcp.tool()
Expand Down Expand Up @@ -2311,19 +2314,25 @@ def update_paragraph(
para_id: str,
text: str = "",
style: str = "",
tracked: bool = False,
author: str = "Claude",
) -> str:
"""Update the text and/or style of an existing paragraph.

Args:
para_id: paraId of the paragraph to update.
text: New text content. Empty string leaves text unchanged.
style: New paragraph style name. Empty string leaves style unchanged.
tracked: If True, wrap old runs in w:del and new text in w:ins.
author: Author name shown in Word's review pane (tracked=True only).
"""
return _js(
_require_doc().update_paragraph(
para_id,
text=text or None,
style=style or None,
tracked=tracked,
author=author,
)
)

Expand Down Expand Up @@ -2934,18 +2943,21 @@ def copy_table(table_idx: int) -> str:


@mcp.tool()
def copy_document(output_path: str) -> str:
def copy_document(output_path: str, document_handle: str = "") -> str:
"""Save a complete snapshot of the open document to a new path.

The active session and source path are unchanged.

Args:
output_path: Destination file path (must end in .docx).
document_handle: Optional handle for concurrent session isolation.
Empty string uses the shared __default__ slot.

Returns:
{"copied_to": output_path}
"""
return _js(_require_doc().copy_document(output_path))
_, doc = _resolve(document_handle)
return _js(doc.copy_document(output_path))


@mcp.tool()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ classifiers = [
"Topic :: Text Processing :: Markup :: XML",
]
dependencies = [
"mcp>=1.0.0",
"mcp>=1.28,<2",
"lxml>=4.9.0",
"mistune>=3.0",
"presidio-analyzer>=2.2",
Expand Down
14 changes: 9 additions & 5 deletions tests/test_anchor.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,19 +321,23 @@ def test_unique_find_anchor_no_context(self):
)
assert result["type"] == "deletion"

def test_ambiguous_find_raises(self):
def test_find_in_multi_para_succeeds_with_explicit_para_id(self):
"""
When find text appears in multiple paragraphs and no context is
provided to distinguish them, raises ValueError.
Find text in a specific paragraph succeeds even when the same text
appears in other paragraphs — the caller already disambiguated by
providing an explicit para_id (issue #6).
"""
# "paragraph" appears in both 00000011 and 00000012
with pytest.raises(ValueError, match="[Aa]mbiguous"):
# "paragraph" appears in both 00000011 and 00000012,
# but appears only once within 00000011 — should succeed
result = _j(
server.delete_text(
"00000011",
"paragraph",
context_before="",
context_after="",
)
)
assert result["type"] == "deletion"


# ─────────────────────────────────────────────────────────────────────────────
Expand Down
20 changes: 20 additions & 0 deletions tests/test_document_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import zipfile
from pathlib import Path

Expand Down Expand Up @@ -30,6 +31,25 @@ def test_copy_document_is_valid_docx(test_docx, tmp_path):
assert "word/document.xml" in zf.namelist()


def test_copy_document_with_explicit_handle(test_docx, tmp_path):
"""copy_document reads from the document under the given handle."""
server._doc = None
h1 = "copy-handle-1"
h2 = "copy-handle-2"
try:
server.open_document(str(test_docx), document_handle=h1)
server.open_document(str(test_docx), document_handle=h2)
out = str(tmp_path / "copy_h1.docx")
result = json.loads(server.copy_document(out, document_handle=h1))
assert result["copied_to"] == out
assert Path(out).exists()
with zipfile.ZipFile(out, "r") as zf:
assert "word/document.xml" in zf.namelist()
finally:
server.close_document(document_handle=h1)
server.close_document(document_handle=h2)


# ── flatten_document ─────────────────────────────────────────────────────────


Expand Down
41 changes: 41 additions & 0 deletions tests/test_image_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,44 @@ def test_set_alt_text_nonexistent_rid_raises(self, doc_with_image):
doc, _ = doc_with_image
with pytest.raises(ValueError, match="rId99"):
doc.set_image_alt_text("rId99", "some text")


# ── server-level: document_handle support (issue #8) ─────────────────────────


def test_update_image_with_explicit_handle(tmp_path, sample_png):
"""update_image can target a document under a non-default handle."""
import json

from docx_mcp import server

# Build a DOCX file with one image, saved to disk
doc_path = tmp_path / "with_image.docx"
doc = DocxDocument.create(str(doc_path))
tree = doc._tree("word/document.xml")
paras = tree.findall(f".//{W}p")
para_id = paras[0].get(f"{W14}paraId")
result = doc.insert_image(para_id, str(sample_png))
rid = result["rId"]
doc.save()
doc.close()

h1 = "image-handle-1"
h2 = "image-handle-2"
try:
server.open_document(str(doc_path), document_handle=h1)
server.open_document(str(doc_path), document_handle=h2)
new_img = tmp_path / "new.png"
new_img.write_bytes(_TINY_PNG + b"\x00")

out = json.loads(server.update_image(rid, str(new_img), document_handle=h1))
assert out["rId"] == rid
# Handle h2's doc should NOT have the updated binary
key2 = h2
doc2 = server._docs[key2]
assert not any(v == new_img.read_bytes() for v in doc2._binaries.values()), (
"h2 document must not be affected by update on h1"
)
finally:
server.close_document(document_handle=h1)
server.close_document(document_handle=h2)
38 changes: 38 additions & 0 deletions tests/test_mike_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,41 @@ def test_get_body_text_real_fixtures(fixture_name, tmp_path):
assert audit["footnotes"]["valid"], f"{fixture_name}: footnote audit failed"
assert audit["paraids"]["valid"], f"{fixture_name}: paraId audit failed"
assert not audit["relationships"]["missing_targets"], f"{fixture_name}: broken relationships"


# ── Issue #6: replace_text paragraph scope (no document-global uniqueness gate) ─


def test_replace_text_same_phrase_multiple_paras_with_para_id_succeeds(mike_corpus_docx, tmp_path):
"""replace_text with explicit para_id succeeds even when find text appears elsewhere.

The mike_corpus fixture has "twelve months" in at least two paragraphs
(para 00000104 in section 1 and another para in section 2 — verified by
test_replace_with_context_disambiguates). With the _doc_norm_count gate
removed, replace_text should locate the text within the targeted paragraph
and succeed without demanding context_before/context_after.
"""
server.open_document(str(mike_corpus_docx))

# No context — the call must NOT raise ValueError
result = json.loads(
server.replace_text(
para_id="00000104",
find="twelve months",
replace="eighteen months",
)
)
assert result["del_id"] is not None, "replace_text must produce a deletion"

# Save and re-read to verify scoped replacement
out = tmp_path / "scope_fix.docx"
server.save_document(str(out))
server.open_document(str(out))
body = json.loads(server.get_body_text())["body"]

# The other paragraph must still contain the original phrase
assert "twelve months" in body, (
"other paragraph must be untouched — 'twelve months' should still appear"
)
# The target paragraph should reflect the replacement
assert "eighteen months" in body, "target paragraph must contain the replacement text"
22 changes: 22 additions & 0 deletions tests/test_paragraph_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,25 @@ def test_set_paragraph_shading_sets_fill(self, test_docx: Path) -> None:
assert shd.get(f"{W}fill") == "FFFF00"
assert shd.get(f"{W}val") == "clear"
assert shd.get(f"{W}color") == "auto"

# ── update_paragraph tracked (issue #9) ──────────────────────────────

def test_update_paragraph_tracked_produces_del_and_ins(self, test_docx: Path) -> None:
"""tracked=True wraps old text in w:del and new text in w:ins."""
self._open(test_docx)
server.update_paragraph(
"00000002", text="Tracked replacement", tracked=True, author="Tester"
)
changes = json.loads(server.get_tracked_changes())
assert len(changes) >= 2, f"expected ≥2 changes, got {len(changes)}"
assert any(c["type"] == "deletion" for c in changes), "no deletion found"
assert any(c["type"] == "insertion" for c in changes), "no insertion found"

def test_update_paragraph_tracked_false_unchanged(self, test_docx: Path) -> None:
"""tracked=False (default) produces no tracked changes — silent replace."""
self._open(test_docx)
server.update_paragraph("00000002", text="Silent replacement")
changes = json.loads(server.get_tracked_changes())
assert len(changes) == 0, "no tracked changes expected for silent mode"
body = json.loads(server.get_body_text())["body"]
assert "Silent replacement" in body, "text should still be replaced"
Loading
Loading