docx.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. """
  2. Validator for Word document XML files against XSD schemas.
  3. """
  4. import random
  5. import re
  6. import tempfile
  7. import zipfile
  8. import defusedxml.minidom
  9. import lxml.etree
  10. from .base import BaseSchemaValidator
  11. class DOCXSchemaValidator(BaseSchemaValidator):
  12. WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  13. W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml"
  14. W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid"
  15. ELEMENT_RELATIONSHIP_TYPES = {}
  16. def validate(self):
  17. if not self.validate_xml():
  18. return False
  19. all_valid = True
  20. if not self.validate_namespaces():
  21. all_valid = False
  22. if not self.validate_unique_ids():
  23. all_valid = False
  24. if not self.validate_file_references():
  25. all_valid = False
  26. if not self.validate_content_types():
  27. all_valid = False
  28. if not self.validate_against_xsd():
  29. all_valid = False
  30. if not self.validate_whitespace_preservation():
  31. all_valid = False
  32. if not self.validate_deletions():
  33. all_valid = False
  34. if not self.validate_insertions():
  35. all_valid = False
  36. if not self.validate_all_relationship_ids():
  37. all_valid = False
  38. if not self.validate_id_constraints():
  39. all_valid = False
  40. if not self.validate_comment_markers():
  41. all_valid = False
  42. self.compare_paragraph_counts()
  43. return all_valid
  44. def validate_whitespace_preservation(self):
  45. errors = []
  46. for xml_file in self.xml_files:
  47. if xml_file.name != "document.xml":
  48. continue
  49. try:
  50. root = lxml.etree.parse(str(xml_file)).getroot()
  51. for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
  52. if elem.text:
  53. text = elem.text
  54. if re.search(r"^[ \t\n\r]", text) or re.search(
  55. r"[ \t\n\r]$", text
  56. ):
  57. xml_space_attr = f"{{{self.XML_NAMESPACE}}}space"
  58. if (
  59. xml_space_attr not in elem.attrib
  60. or elem.attrib[xml_space_attr] != "preserve"
  61. ):
  62. text_preview = (
  63. repr(text)[:50] + "..."
  64. if len(repr(text)) > 50
  65. else repr(text)
  66. )
  67. errors.append(
  68. f" {xml_file.relative_to(self.unpacked_dir)}: "
  69. f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}"
  70. )
  71. except (lxml.etree.XMLSyntaxError, Exception) as e:
  72. errors.append(
  73. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  74. )
  75. if errors:
  76. print(f"FAILED - Found {len(errors)} whitespace preservation violations:")
  77. for error in errors:
  78. print(error)
  79. return False
  80. else:
  81. if self.verbose:
  82. print("PASSED - All whitespace is properly preserved")
  83. return True
  84. def validate_deletions(self):
  85. errors = []
  86. for xml_file in self.xml_files:
  87. if xml_file.name != "document.xml":
  88. continue
  89. try:
  90. root = lxml.etree.parse(str(xml_file)).getroot()
  91. namespaces = {"w": self.WORD_2006_NAMESPACE}
  92. for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces):
  93. if t_elem.text:
  94. text_preview = (
  95. repr(t_elem.text)[:50] + "..."
  96. if len(repr(t_elem.text)) > 50
  97. else repr(t_elem.text)
  98. )
  99. errors.append(
  100. f" {xml_file.relative_to(self.unpacked_dir)}: "
  101. f"Line {t_elem.sourceline}: <w:t> found within <w:del>: {text_preview}"
  102. )
  103. for instr_elem in root.xpath(
  104. ".//w:del//w:instrText", namespaces=namespaces
  105. ):
  106. text_preview = (
  107. repr(instr_elem.text or "")[:50] + "..."
  108. if len(repr(instr_elem.text or "")) > 50
  109. else repr(instr_elem.text or "")
  110. )
  111. errors.append(
  112. f" {xml_file.relative_to(self.unpacked_dir)}: "
  113. f"Line {instr_elem.sourceline}: <w:instrText> found within <w:del> (use <w:delInstrText>): {text_preview}"
  114. )
  115. except (lxml.etree.XMLSyntaxError, Exception) as e:
  116. errors.append(
  117. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  118. )
  119. if errors:
  120. print(f"FAILED - Found {len(errors)} deletion validation violations:")
  121. for error in errors:
  122. print(error)
  123. return False
  124. else:
  125. if self.verbose:
  126. print("PASSED - No w:t elements found within w:del elements")
  127. return True
  128. def count_paragraphs_in_unpacked(self):
  129. count = 0
  130. for xml_file in self.xml_files:
  131. if xml_file.name != "document.xml":
  132. continue
  133. try:
  134. root = lxml.etree.parse(str(xml_file)).getroot()
  135. paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
  136. count = len(paragraphs)
  137. except Exception as e:
  138. print(f"Error counting paragraphs in unpacked document: {e}")
  139. return count
  140. def count_paragraphs_in_original(self):
  141. original = self.original_file
  142. if original is None:
  143. return 0
  144. count = 0
  145. try:
  146. with tempfile.TemporaryDirectory() as temp_dir:
  147. with zipfile.ZipFile(original, "r") as zip_ref:
  148. zip_ref.extractall(temp_dir)
  149. doc_xml_path = temp_dir + "/word/document.xml"
  150. root = lxml.etree.parse(doc_xml_path).getroot()
  151. paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
  152. count = len(paragraphs)
  153. except Exception as e:
  154. print(f"Error counting paragraphs in original document: {e}")
  155. return count
  156. def validate_insertions(self):
  157. errors = []
  158. for xml_file in self.xml_files:
  159. if xml_file.name != "document.xml":
  160. continue
  161. try:
  162. root = lxml.etree.parse(str(xml_file)).getroot()
  163. namespaces = {"w": self.WORD_2006_NAMESPACE}
  164. invalid_elements = root.xpath(
  165. ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces
  166. )
  167. for elem in invalid_elements:
  168. text_preview = (
  169. repr(elem.text or "")[:50] + "..."
  170. if len(repr(elem.text or "")) > 50
  171. else repr(elem.text or "")
  172. )
  173. errors.append(
  174. f" {xml_file.relative_to(self.unpacked_dir)}: "
  175. f"Line {elem.sourceline}: <w:delText> within <w:ins>: {text_preview}"
  176. )
  177. except (lxml.etree.XMLSyntaxError, Exception) as e:
  178. errors.append(
  179. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  180. )
  181. if errors:
  182. print(f"FAILED - Found {len(errors)} insertion validation violations:")
  183. for error in errors:
  184. print(error)
  185. return False
  186. else:
  187. if self.verbose:
  188. print("PASSED - No w:delText elements within w:ins elements")
  189. return True
  190. def compare_paragraph_counts(self):
  191. original_count = self.count_paragraphs_in_original()
  192. new_count = self.count_paragraphs_in_unpacked()
  193. diff = new_count - original_count
  194. diff_str = f"+{diff}" if diff > 0 else str(diff)
  195. print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})")
  196. def _parse_id_value(self, val: str, base: int = 16) -> int:
  197. return int(val, base)
  198. def validate_id_constraints(self):
  199. errors = []
  200. para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId"
  201. durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId"
  202. for xml_file in self.xml_files:
  203. try:
  204. for elem in lxml.etree.parse(str(xml_file)).iter():
  205. if val := elem.get(para_id_attr):
  206. if self._parse_id_value(val, base=16) >= 0x80000000:
  207. errors.append(
  208. f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000"
  209. )
  210. if val := elem.get(durable_id_attr):
  211. if xml_file.name == "numbering.xml":
  212. try:
  213. if self._parse_id_value(val, base=10) >= 0x7FFFFFFF:
  214. errors.append(
  215. f" {xml_file.name}:{elem.sourceline}: "
  216. f"durableId={val} >= 0x7FFFFFFF"
  217. )
  218. except ValueError:
  219. errors.append(
  220. f" {xml_file.name}:{elem.sourceline}: "
  221. f"durableId={val} must be decimal in numbering.xml"
  222. )
  223. else:
  224. if self._parse_id_value(val, base=16) >= 0x7FFFFFFF:
  225. errors.append(
  226. f" {xml_file.name}:{elem.sourceline}: "
  227. f"durableId={val} >= 0x7FFFFFFF"
  228. )
  229. except Exception:
  230. pass
  231. if errors:
  232. print(f"FAILED - {len(errors)} ID constraint violations:")
  233. for e in errors:
  234. print(e)
  235. elif self.verbose:
  236. print("PASSED - All paraId/durableId values within constraints")
  237. return not errors
  238. def validate_comment_markers(self):
  239. errors = []
  240. document_xml = None
  241. comments_xml = None
  242. for xml_file in self.xml_files:
  243. if xml_file.name == "document.xml" and "word" in str(xml_file):
  244. document_xml = xml_file
  245. elif xml_file.name == "comments.xml":
  246. comments_xml = xml_file
  247. if not document_xml:
  248. if self.verbose:
  249. print("PASSED - No document.xml found (skipping comment validation)")
  250. return True
  251. try:
  252. doc_root = lxml.etree.parse(str(document_xml)).getroot()
  253. namespaces = {"w": self.WORD_2006_NAMESPACE}
  254. range_starts = {
  255. elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
  256. for elem in doc_root.xpath(
  257. ".//w:commentRangeStart", namespaces=namespaces
  258. )
  259. }
  260. range_ends = {
  261. elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
  262. for elem in doc_root.xpath(
  263. ".//w:commentRangeEnd", namespaces=namespaces
  264. )
  265. }
  266. references = {
  267. elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
  268. for elem in doc_root.xpath(
  269. ".//w:commentReference", namespaces=namespaces
  270. )
  271. }
  272. orphaned_ends = range_ends - range_starts
  273. for comment_id in sorted(
  274. orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0
  275. ):
  276. errors.append(
  277. f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart'
  278. )
  279. orphaned_starts = range_starts - range_ends
  280. for comment_id in sorted(
  281. orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0
  282. ):
  283. errors.append(
  284. f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd'
  285. )
  286. comment_ids = set()
  287. if comments_xml and comments_xml.exists():
  288. comments_root = lxml.etree.parse(str(comments_xml)).getroot()
  289. comment_ids = {
  290. elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
  291. for elem in comments_root.xpath(
  292. ".//w:comment", namespaces=namespaces
  293. )
  294. }
  295. marker_ids = range_starts | range_ends | references
  296. invalid_refs = marker_ids - comment_ids
  297. for comment_id in sorted(
  298. invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0
  299. ):
  300. if comment_id:
  301. errors.append(
  302. f' document.xml: marker id="{comment_id}" references non-existent comment'
  303. )
  304. except (lxml.etree.XMLSyntaxError, Exception) as e:
  305. errors.append(f" Error parsing XML: {e}")
  306. if errors:
  307. print(f"FAILED - {len(errors)} comment marker violations:")
  308. for error in errors:
  309. print(error)
  310. return False
  311. else:
  312. if self.verbose:
  313. print("PASSED - All comment markers properly paired")
  314. return True
  315. def repair(self) -> int:
  316. repairs = super().repair()
  317. repairs += self.repair_durableId()
  318. return repairs
  319. def repair_durableId(self) -> int:
  320. repairs = 0
  321. for xml_file in self.xml_files:
  322. try:
  323. content = xml_file.read_text(encoding="utf-8")
  324. dom = defusedxml.minidom.parseString(content)
  325. modified = False
  326. for elem in dom.getElementsByTagName("*"):
  327. if not elem.hasAttribute("w16cid:durableId"):
  328. continue
  329. durable_id = elem.getAttribute("w16cid:durableId")
  330. needs_repair = False
  331. if xml_file.name == "numbering.xml":
  332. try:
  333. needs_repair = (
  334. self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF
  335. )
  336. except ValueError:
  337. needs_repair = True
  338. else:
  339. try:
  340. needs_repair = (
  341. self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF
  342. )
  343. except ValueError:
  344. needs_repair = True
  345. if needs_repair:
  346. value = random.randint(1, 0x7FFFFFFE)
  347. if xml_file.name == "numbering.xml":
  348. new_id = str(value)
  349. else:
  350. new_id = f"{value:08X}"
  351. elem.setAttribute("w16cid:durableId", new_id)
  352. print(
  353. f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}"
  354. )
  355. repairs += 1
  356. modified = True
  357. if modified:
  358. xml_file.write_bytes(dom.toxml(encoding="UTF-8"))
  359. except Exception:
  360. pass
  361. return repairs
  362. if __name__ == "__main__":
  363. raise RuntimeError("This module should not be run directly.")