redlining.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """
  2. Validator for tracked changes in Word documents.
  3. """
  4. import subprocess
  5. import tempfile
  6. import zipfile
  7. from pathlib import Path
  8. class RedliningValidator:
  9. def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"):
  10. self.unpacked_dir = Path(unpacked_dir)
  11. self.original_docx = Path(original_docx)
  12. self.verbose = verbose
  13. self.author = author
  14. self.namespaces = {
  15. "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  16. }
  17. def repair(self) -> int:
  18. return 0
  19. def validate(self):
  20. modified_file = self.unpacked_dir / "word" / "document.xml"
  21. if not modified_file.exists():
  22. print(f"FAILED - Modified document.xml not found at {modified_file}")
  23. return False
  24. try:
  25. import xml.etree.ElementTree as ET
  26. tree = ET.parse(modified_file)
  27. root = tree.getroot()
  28. del_elements = root.findall(".//w:del", self.namespaces)
  29. ins_elements = root.findall(".//w:ins", self.namespaces)
  30. author_del_elements = [
  31. elem
  32. for elem in del_elements
  33. if elem.get(f"{{{self.namespaces['w']}}}author") == self.author
  34. ]
  35. author_ins_elements = [
  36. elem
  37. for elem in ins_elements
  38. if elem.get(f"{{{self.namespaces['w']}}}author") == self.author
  39. ]
  40. if not author_del_elements and not author_ins_elements:
  41. if self.verbose:
  42. print(f"PASSED - No tracked changes by {self.author} found.")
  43. return True
  44. except Exception:
  45. pass
  46. with tempfile.TemporaryDirectory() as temp_dir:
  47. temp_path = Path(temp_dir)
  48. try:
  49. with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
  50. zip_ref.extractall(temp_path)
  51. except Exception as e:
  52. print(f"FAILED - Error unpacking original docx: {e}")
  53. return False
  54. original_file = temp_path / "word" / "document.xml"
  55. if not original_file.exists():
  56. print(
  57. f"FAILED - Original document.xml not found in {self.original_docx}"
  58. )
  59. return False
  60. try:
  61. import xml.etree.ElementTree as ET
  62. modified_tree = ET.parse(modified_file)
  63. modified_root = modified_tree.getroot()
  64. original_tree = ET.parse(original_file)
  65. original_root = original_tree.getroot()
  66. except ET.ParseError as e:
  67. print(f"FAILED - Error parsing XML files: {e}")
  68. return False
  69. self._remove_author_tracked_changes(original_root)
  70. self._remove_author_tracked_changes(modified_root)
  71. modified_text = self._extract_text_content(modified_root)
  72. original_text = self._extract_text_content(original_root)
  73. if modified_text != original_text:
  74. error_message = self._generate_detailed_diff(
  75. original_text, modified_text
  76. )
  77. print(error_message)
  78. return False
  79. if self.verbose:
  80. print(f"PASSED - All changes by {self.author} are properly tracked")
  81. return True
  82. def _generate_detailed_diff(self, original_text, modified_text):
  83. error_parts = [
  84. f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes",
  85. "",
  86. "Likely causes:",
  87. " 1. Modified text inside another author's <w:ins> or <w:del> tags",
  88. " 2. Made edits without proper tracked changes",
  89. " 3. Didn't nest <w:del> inside <w:ins> when deleting another's insertion",
  90. "",
  91. "For pre-redlined documents, use correct patterns:",
  92. " - To reject another's INSERTION: Nest <w:del> inside their <w:ins>",
  93. " - To restore another's DELETION: Add new <w:ins> AFTER their <w:del>",
  94. "",
  95. ]
  96. git_diff = self._get_git_word_diff(original_text, modified_text)
  97. if git_diff:
  98. error_parts.extend(["Differences:", "============", git_diff])
  99. else:
  100. error_parts.append("Unable to generate word diff (git not available)")
  101. return "\n".join(error_parts)
  102. def _get_git_word_diff(self, original_text, modified_text):
  103. try:
  104. with tempfile.TemporaryDirectory() as temp_dir:
  105. temp_path = Path(temp_dir)
  106. original_file = temp_path / "original.txt"
  107. modified_file = temp_path / "modified.txt"
  108. original_file.write_text(original_text, encoding="utf-8")
  109. modified_file.write_text(modified_text, encoding="utf-8")
  110. result = subprocess.run(
  111. [
  112. "git",
  113. "diff",
  114. "--word-diff=plain",
  115. "--word-diff-regex=.",
  116. "-U0",
  117. "--no-index",
  118. str(original_file),
  119. str(modified_file),
  120. ],
  121. capture_output=True,
  122. text=True,
  123. )
  124. if result.stdout.strip():
  125. lines = result.stdout.split("\n")
  126. content_lines = []
  127. in_content = False
  128. for line in lines:
  129. if line.startswith("@@"):
  130. in_content = True
  131. continue
  132. if in_content and line.strip():
  133. content_lines.append(line)
  134. if content_lines:
  135. return "\n".join(content_lines)
  136. result = subprocess.run(
  137. [
  138. "git",
  139. "diff",
  140. "--word-diff=plain",
  141. "-U0",
  142. "--no-index",
  143. str(original_file),
  144. str(modified_file),
  145. ],
  146. capture_output=True,
  147. text=True,
  148. )
  149. if result.stdout.strip():
  150. lines = result.stdout.split("\n")
  151. content_lines = []
  152. in_content = False
  153. for line in lines:
  154. if line.startswith("@@"):
  155. in_content = True
  156. continue
  157. if in_content and line.strip():
  158. content_lines.append(line)
  159. return "\n".join(content_lines)
  160. except (subprocess.CalledProcessError, FileNotFoundError, Exception):
  161. pass
  162. return None
  163. def _remove_author_tracked_changes(self, root):
  164. ins_tag = f"{{{self.namespaces['w']}}}ins"
  165. del_tag = f"{{{self.namespaces['w']}}}del"
  166. author_attr = f"{{{self.namespaces['w']}}}author"
  167. for parent in root.iter():
  168. to_remove = []
  169. for child in parent:
  170. if child.tag == ins_tag and child.get(author_attr) == self.author:
  171. to_remove.append(child)
  172. for elem in to_remove:
  173. parent.remove(elem)
  174. deltext_tag = f"{{{self.namespaces['w']}}}delText"
  175. t_tag = f"{{{self.namespaces['w']}}}t"
  176. for parent in root.iter():
  177. to_process = []
  178. for child in parent:
  179. if child.tag == del_tag and child.get(author_attr) == self.author:
  180. to_process.append((child, list(parent).index(child)))
  181. for del_elem, del_index in reversed(to_process):
  182. for elem in del_elem.iter():
  183. if elem.tag == deltext_tag:
  184. elem.tag = t_tag
  185. for child in reversed(list(del_elem)):
  186. parent.insert(del_index, child)
  187. parent.remove(del_elem)
  188. def _extract_text_content(self, root):
  189. p_tag = f"{{{self.namespaces['w']}}}p"
  190. t_tag = f"{{{self.namespaces['w']}}}t"
  191. paragraphs = []
  192. for p_elem in root.findall(f".//{p_tag}"):
  193. text_parts = []
  194. for t_elem in p_elem.findall(f".//{t_tag}"):
  195. if t_elem.text:
  196. text_parts.append(t_elem.text)
  197. paragraph_text = "".join(text_parts)
  198. if paragraph_text:
  199. paragraphs.append(paragraph_text)
  200. return "\n".join(paragraphs)
  201. if __name__ == "__main__":
  202. raise RuntimeError("This module should not be run directly.")