merge_runs.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Merge adjacent runs with identical formatting in DOCX.
  2. Merges adjacent <w:r> elements that have identical <w:rPr> properties.
  3. Works on runs in paragraphs and inside tracked changes (<w:ins>, <w:del>).
  4. Also:
  5. - Removes rsid attributes from runs (revision metadata that doesn't affect rendering)
  6. - Removes proofErr elements (spell/grammar markers that block merging)
  7. """
  8. from pathlib import Path
  9. import defusedxml.minidom
  10. def merge_runs(input_dir: str) -> tuple[int, str]:
  11. doc_xml = Path(input_dir) / "word" / "document.xml"
  12. if not doc_xml.exists():
  13. return 0, f"Error: {doc_xml} not found"
  14. try:
  15. dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
  16. root = dom.documentElement
  17. _remove_elements(root, "proofErr")
  18. _strip_run_rsid_attrs(root)
  19. containers = {run.parentNode for run in _find_elements(root, "r")}
  20. merge_count = 0
  21. for container in containers:
  22. merge_count += _merge_runs_in(container)
  23. doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
  24. return merge_count, f"Merged {merge_count} runs"
  25. except Exception as e:
  26. return 0, f"Error: {e}"
  27. def _find_elements(root, tag: str) -> list:
  28. results = []
  29. def traverse(node):
  30. if node.nodeType == node.ELEMENT_NODE:
  31. name = node.localName or node.tagName
  32. if name == tag or name.endswith(f":{tag}"):
  33. results.append(node)
  34. for child in node.childNodes:
  35. traverse(child)
  36. traverse(root)
  37. return results
  38. def _get_child(parent, tag: str):
  39. for child in parent.childNodes:
  40. if child.nodeType == child.ELEMENT_NODE:
  41. name = child.localName or child.tagName
  42. if name == tag or name.endswith(f":{tag}"):
  43. return child
  44. return None
  45. def _get_children(parent, tag: str) -> list:
  46. results = []
  47. for child in parent.childNodes:
  48. if child.nodeType == child.ELEMENT_NODE:
  49. name = child.localName or child.tagName
  50. if name == tag or name.endswith(f":{tag}"):
  51. results.append(child)
  52. return results
  53. def _is_adjacent(elem1, elem2) -> bool:
  54. node = elem1.nextSibling
  55. while node:
  56. if node == elem2:
  57. return True
  58. if node.nodeType == node.ELEMENT_NODE:
  59. return False
  60. if node.nodeType == node.TEXT_NODE and node.data.strip():
  61. return False
  62. node = node.nextSibling
  63. return False
  64. def _remove_elements(root, tag: str):
  65. for elem in _find_elements(root, tag):
  66. if elem.parentNode:
  67. elem.parentNode.removeChild(elem)
  68. def _strip_run_rsid_attrs(root):
  69. for run in _find_elements(root, "r"):
  70. for attr in list(run.attributes.values()):
  71. if "rsid" in attr.name.lower():
  72. run.removeAttribute(attr.name)
  73. def _merge_runs_in(container) -> int:
  74. merge_count = 0
  75. run = _first_child_run(container)
  76. while run:
  77. while True:
  78. next_elem = _next_element_sibling(run)
  79. if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
  80. _merge_run_content(run, next_elem)
  81. container.removeChild(next_elem)
  82. merge_count += 1
  83. else:
  84. break
  85. _consolidate_text(run)
  86. run = _next_sibling_run(run)
  87. return merge_count
  88. def _first_child_run(container):
  89. for child in container.childNodes:
  90. if child.nodeType == child.ELEMENT_NODE and _is_run(child):
  91. return child
  92. return None
  93. def _next_element_sibling(node):
  94. sibling = node.nextSibling
  95. while sibling:
  96. if sibling.nodeType == sibling.ELEMENT_NODE:
  97. return sibling
  98. sibling = sibling.nextSibling
  99. return None
  100. def _next_sibling_run(node):
  101. sibling = node.nextSibling
  102. while sibling:
  103. if sibling.nodeType == sibling.ELEMENT_NODE:
  104. if _is_run(sibling):
  105. return sibling
  106. sibling = sibling.nextSibling
  107. return None
  108. def _is_run(node) -> bool:
  109. name = node.localName or node.tagName
  110. return name == "r" or name.endswith(":r")
  111. def _can_merge(run1, run2) -> bool:
  112. rpr1 = _get_child(run1, "rPr")
  113. rpr2 = _get_child(run2, "rPr")
  114. if (rpr1 is None) != (rpr2 is None):
  115. return False
  116. if rpr1 is None:
  117. return True
  118. return rpr1.toxml() == rpr2.toxml()
  119. def _merge_run_content(target, source):
  120. for child in list(source.childNodes):
  121. if child.nodeType == child.ELEMENT_NODE:
  122. name = child.localName or child.tagName
  123. if name != "rPr" and not name.endswith(":rPr"):
  124. target.appendChild(child)
  125. def _consolidate_text(run):
  126. t_elements = _get_children(run, "t")
  127. for i in range(len(t_elements) - 1, 0, -1):
  128. curr, prev = t_elements[i], t_elements[i - 1]
  129. if _is_adjacent(prev, curr):
  130. prev_text = prev.firstChild.data if prev.firstChild else ""
  131. curr_text = curr.firstChild.data if curr.firstChild else ""
  132. merged = prev_text + curr_text
  133. if prev.firstChild:
  134. prev.firstChild.data = merged
  135. else:
  136. prev.appendChild(run.ownerDocument.createTextNode(merged))
  137. if merged.startswith(" ") or merged.endswith(" "):
  138. prev.setAttribute("xml:space", "preserve")
  139. elif prev.hasAttribute("xml:space"):
  140. prev.removeAttribute("xml:space")
  141. run.removeChild(curr)