Skip to content

Comment run positions skip runs inside inline content controls, so comments anchor on the wrong text without an error #172

Description

@hadim

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions