Skip to content

replaceText drops adjacent Runs that carry only rawElements (commentReference / bookmarkStart / smartTag) #65

Description

@kiki830621

Problem

TextReplacementEngine.applyOneReplacement's multi-run path silently removes Runs whose only structural payload is rawElements. Word's strict OOXML validator subsequently rejects the resulting docx because the comment-marker triplet (commentRangeStart + commentRangeEnd + commentReference) becomes incomplete.

Discovered while rescuing the NTPU master's thesis (kiki830621/collaboration_guo_analysis#20). Word reported "the file is corrupt and cannot be opened" on every output of the rescue pipeline. Diagnosis:

Marker Target docx (input) Output docx Diff
<w:commentRangeStart w:id="23"/>
<w:commentRangeEnd w:id="23"/>
<w:commentReference w:id="23"/> missing dropped during replaceText("適應性", "配適度")

Root cause

// TextReplacementEngine.swift line 351
private static func isTextRun(_ run: Run) -> Bool {
    return run.rawXML == nil && run.drawing == nil   // ← misses rawElements
}

A Run shaped like

<w:r>
  <w:rPr><w:rStyle w:val="af2"/>…</w:rPr>
  <w:commentReference w:id="23"/>
</w:r>

parses with text == "", rawXML == nil, drawing == nil, rawElements == [RawElement(name: "commentReference", xml: ...)].

isTextRun returns true, so the multi-run remove pass at line 402-410 deletes this Run wholesale — even though it carries the comment payload.

Repro (failing test)

func testReplaceMultiRunPreservesCommentReferenceRun() throws {
    var commentRefRun = Run(text: "")
    commentRefRun.rawElements = [
        RawElement(name: "commentReference", xml: "<w:commentReference w:id=\"23\"/>")
    ]
    var runs = [
        Run(text: "適應性"),
        commentRefRun,                  // ← empty text + rawElements
        Run(text: ",本研究")
    ]
    // multi-run match: "性,本" spans runs[0..2] with the commentRef Run between
    let count = try TextReplacementEngine.replace(
        runs: &runs, find: "性,本", with: "X"
    )
    XCTAssertEqual(count, 1)
    XCTAssertEqual(runs.count, 3)        // ← FAILS pre-fix: runs.count == 2
    XCTAssertEqual(runs[1].rawElements?.first?.name, "commentReference")
}

Pre-fix output:

XCTAssertEqual failed: ("2") is not equal to ("3") - Run carrying only rawElements was deleted by multi-run remove pass

Fix

In applyOneReplacement at line 402-410, gate the removal on isTextRun(r) && (r.rawElements?.isEmpty ?? true) — a Run with non-empty rawElements carries structural payload that must survive replacement.

Branch / PR coming up via the v0.27.0 release.

Affected operations

Document.replaceText (and any caller using it: che-word-mcp replace_text / replace_text_batch MCP tools, this rescue script's caption fix + abstract substitution phases, …) hits this whenever:

  1. The match is multi-run (sRunIdx ≠ eRunIdx)
  2. A Run sits strictly between sRunIdx and eRunIdx
  3. That Run has only rawElements (no rawXML, no drawing, empty text)

Most common trigger: the <w:r><w:commentReference/></w:r> immediately following a <w:commentRangeEnd/> marker in Word-authored documents.

Severity

P0 — silently breaks docx schema. Word strict validator refuses to open. Lenient parsers (libxml2, pandoc, python-docx) read the broken file fine, masking the problem until end-user opens in Word.

Type

bug

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions