RunPosition.run_index (Python add_comment) and rdocx comment add --start-run/--end-run (CLI) count only the paragraph's direct runs (paragraph.runs in validate_run_range, crates/rdocx/src/comments.rs:1003; the story variant at :562 uses the same count). The read APIs also count the runs inside an inline w:sdt: Paragraph.runs in Python and rdocx text --json list them in document order.
When a paragraph holds an inline content control before the target, a run index taken from either read API lands one run (or more) further right. The comment is written on the wrong text, the command exits 0, and the last runs of the paragraph cannot be addressed at all. Inline content controls are common in documents exported from Google Docs (goog_rdk_* controls) and in templates.
Reproduction (python-docx builds the input, rdocx on PATH):
import json
import re
import subprocess
import zipfile
import docx
import rdocx
from docx.oxml.ns import qn
# One paragraph: "before " | <w:sdt>"TARGET"</w:sdt> | " after"
d = docx.Document()
p = d.add_paragraph()
p.add_run("before ")
sdt = p._p.makeelement(qn("w:sdt"), {})
sdt.append(sdt.makeelement(qn("w:sdtPr"), {}))
content = sdt.makeelement(qn("w:sdtContent"), {})
r = p.add_run("TARGET")._r
p._p.remove(r)
content.append(r)
sdt.append(content)
p._p.append(sdt)
p.add_run(" after")
d.save("in.docx")
def anchored(path):
xml = zipfile.ZipFile(path).read("word/document.xml").decode()
m = re.search(r'<w:commentRangeStart w:id="(\d+)"/>(.*?)<w:commentRangeEnd w:id="\1"/>', xml, re.S)
return "".join(re.findall(r"<w:t[^>]*>([^<]*)</w:t>", m.group(2)))
doc = rdocx.Document.open("in.docx")
print("Paragraph.runs:", [run.text for run in doc.paragraphs[0].runs])
view = json.loads(subprocess.run(["rdocx", "text", "in.docx", "--json"], check=True, capture_output=True, text=True).stdout)
print("text --json runs:", [run["text"] for run in view["paragraphs"][0]["runs"]])
doc.add_comment(
rdocx.RunRange(start=rdocx.RunPosition(body_index=0, run_index=1), end=rdocx.RunPosition(body_index=0, run_index=2)),
author="A",
text="x",
)
doc.save("py.docx")
print("python, runs [1, 2):", repr(anchored("py.docx")), "expected 'TARGET'")
subprocess.run(
["rdocx", "comment", "add", "in.docx", "--start-paragraph", "0", "--start-run", "1", "--end-paragraph", "0", "--end-run", "2", "--author", "A", "--text", "x", "-o", "cli.docx"],
check=True, capture_output=True,
)
print("cli, --start-run 1 --end-run 2:", repr(anchored("cli.docx")), "expected 'TARGET'")
try:
rdocx.Document.open("in.docx").add_comment(
rdocx.RunRange(start=rdocx.RunPosition(body_index=0, run_index=2), end=rdocx.RunPosition(body_index=0, run_index=3)),
author="A",
text="x",
)
print("python, runs [2, 3): accepted")
except Exception as exc:
print("python, runs [2, 3):", type(exc).__name__, exc)
Output:
Paragraph.runs: ['before ', 'TARGET', ' after']
text --json runs: ['before ', 'TARGET', ' after']
python, runs [1, 2): ' after' expected 'TARGET'
cli, --start-run 1 --end-run 2: ' after' expected 'TARGET'
python, runs [2, 3): RdocxError comment range end run index 3 exceeds paragraph run count 2
Acceptance:
- Reading and anchoring share one run index space. A run index read from
Paragraph.runs or text --json anchors on that run, including runs inside inline content controls: the range markers go inside w:sdtContent, or around the whole w:sdt when the range covers it.
- A range that cannot be anchored exactly is refused with an error, not shifted.
- One test per entry point (Python
add_comment, add_story_comment, CLI comment add), on a paragraph with an inline w:sdt, asserting the anchored text.
Environment: main at 9a7ed714 (S75), release build, linux x86_64, python-docx 1.2.0 for the input.
RunPosition.run_index(Pythonadd_comment) andrdocx comment add --start-run/--end-run(CLI) count only the paragraph's direct runs (paragraph.runsinvalidate_run_range,crates/rdocx/src/comments.rs:1003; the story variant at:562uses the same count). The read APIs also count the runs inside an inlinew:sdt:Paragraph.runsin Python andrdocx text --jsonlist them in document order.When a paragraph holds an inline content control before the target, a run index taken from either read API lands one run (or more) further right. The comment is written on the wrong text, the command exits 0, and the last runs of the paragraph cannot be addressed at all. Inline content controls are common in documents exported from Google Docs (
goog_rdk_*controls) and in templates.Reproduction (python-docx builds the input,
rdocxon PATH):Output:
Acceptance:
Paragraph.runsortext --jsonanchors on that run, including runs inside inline content controls: the range markers go insidew:sdtContent, or around the wholew:sdtwhen the range covers it.add_comment,add_story_comment, CLIcomment add), on a paragraph with an inlinew:sdt, asserting the anchored text.Environment:
mainat9a7ed714(S75), release build, linux x86_64, python-docx 1.2.0 for the input.