docx.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """
  2. Validator for Word document XML files against XSD schemas.
  3. """
  4. import re
  5. import tempfile
  6. import zipfile
  7. import lxml.etree
  8. from .base import BaseSchemaValidator
  9. class DOCXSchemaValidator(BaseSchemaValidator):
  10. """Validator for Word document XML files against XSD schemas."""
  11. # Word-specific namespace
  12. WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  13. # Word-specific element to relationship type mappings
  14. # Start with empty mapping - add specific cases as we discover them
  15. ELEMENT_RELATIONSHIP_TYPES = {}
  16. def validate(self):
  17. """Run all validation checks and return True if all pass."""
  18. # Test 0: XML well-formedness
  19. if not self.validate_xml():
  20. return False
  21. # Test 1: Namespace declarations
  22. all_valid = True
  23. if not self.validate_namespaces():
  24. all_valid = False
  25. # Test 2: Unique IDs
  26. if not self.validate_unique_ids():
  27. all_valid = False
  28. # Test 3: Relationship and file reference validation
  29. if not self.validate_file_references():
  30. all_valid = False
  31. # Test 4: Content type declarations
  32. if not self.validate_content_types():
  33. all_valid = False
  34. # Test 5: XSD schema validation
  35. if not self.validate_against_xsd():
  36. all_valid = False
  37. # Test 6: Whitespace preservation
  38. if not self.validate_whitespace_preservation():
  39. all_valid = False
  40. # Test 7: Deletion validation
  41. if not self.validate_deletions():
  42. all_valid = False
  43. # Test 8: Insertion validation
  44. if not self.validate_insertions():
  45. all_valid = False
  46. # Test 9: Relationship ID reference validation
  47. if not self.validate_all_relationship_ids():
  48. all_valid = False
  49. # Count and compare paragraphs
  50. self.compare_paragraph_counts()
  51. return all_valid
  52. def validate_whitespace_preservation(self):
  53. """
  54. Validate that w:t elements with whitespace have xml:space='preserve'.
  55. """
  56. errors = []
  57. for xml_file in self.xml_files:
  58. # Only check document.xml files
  59. if xml_file.name != "document.xml":
  60. continue
  61. try:
  62. root = lxml.etree.parse(str(xml_file)).getroot()
  63. # Find all w:t elements
  64. for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
  65. if elem.text:
  66. text = elem.text
  67. # Check if text starts or ends with whitespace
  68. if re.match(r"^\s.*", text) or re.match(r".*\s$", text):
  69. # Check if xml:space="preserve" attribute exists
  70. xml_space_attr = f"{{{self.XML_NAMESPACE}}}space"
  71. if (
  72. xml_space_attr not in elem.attrib
  73. or elem.attrib[xml_space_attr] != "preserve"
  74. ):
  75. # Show a preview of the text
  76. text_preview = (
  77. repr(text)[:50] + "..."
  78. if len(repr(text)) > 50
  79. else repr(text)
  80. )
  81. errors.append(
  82. f" {xml_file.relative_to(self.unpacked_dir)}: "
  83. f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}"
  84. )
  85. except (lxml.etree.XMLSyntaxError, Exception) as e:
  86. errors.append(
  87. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  88. )
  89. if errors:
  90. print(f"FAILED - Found {len(errors)} whitespace preservation violations:")
  91. for error in errors:
  92. print(error)
  93. return False
  94. else:
  95. if self.verbose:
  96. print("PASSED - All whitespace is properly preserved")
  97. return True
  98. def validate_deletions(self):
  99. """
  100. Validate that w:t elements are not within w:del elements.
  101. For some reason, XSD validation does not catch this, so we do it manually.
  102. """
  103. errors = []
  104. for xml_file in self.xml_files:
  105. # Only check document.xml files
  106. if xml_file.name != "document.xml":
  107. continue
  108. try:
  109. root = lxml.etree.parse(str(xml_file)).getroot()
  110. # Find all w:t elements that are descendants of w:del elements
  111. namespaces = {"w": self.WORD_2006_NAMESPACE}
  112. xpath_expression = ".//w:del//w:t"
  113. problematic_t_elements = root.xpath(
  114. xpath_expression, namespaces=namespaces
  115. )
  116. for t_elem in problematic_t_elements:
  117. if t_elem.text:
  118. # Show a preview of the text
  119. text_preview = (
  120. repr(t_elem.text)[:50] + "..."
  121. if len(repr(t_elem.text)) > 50
  122. else repr(t_elem.text)
  123. )
  124. errors.append(
  125. f" {xml_file.relative_to(self.unpacked_dir)}: "
  126. f"Line {t_elem.sourceline}: <w:t> found within <w:del>: {text_preview}"
  127. )
  128. except (lxml.etree.XMLSyntaxError, Exception) as e:
  129. errors.append(
  130. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  131. )
  132. if errors:
  133. print(f"FAILED - Found {len(errors)} deletion validation violations:")
  134. for error in errors:
  135. print(error)
  136. return False
  137. else:
  138. if self.verbose:
  139. print("PASSED - No w:t elements found within w:del elements")
  140. return True
  141. def count_paragraphs_in_unpacked(self):
  142. """Count the number of paragraphs in the unpacked document."""
  143. count = 0
  144. for xml_file in self.xml_files:
  145. # Only check document.xml files
  146. if xml_file.name != "document.xml":
  147. continue
  148. try:
  149. root = lxml.etree.parse(str(xml_file)).getroot()
  150. # Count all w:p elements
  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 unpacked document: {e}")
  155. return count
  156. def count_paragraphs_in_original(self):
  157. """Count the number of paragraphs in the original docx file."""
  158. count = 0
  159. try:
  160. # Create temporary directory to unpack original
  161. with tempfile.TemporaryDirectory() as temp_dir:
  162. # Unpack original docx
  163. with zipfile.ZipFile(self.original_file, "r") as zip_ref:
  164. zip_ref.extractall(temp_dir)
  165. # Parse document.xml
  166. doc_xml_path = temp_dir + "/word/document.xml"
  167. root = lxml.etree.parse(doc_xml_path).getroot()
  168. # Count all w:p elements
  169. paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
  170. count = len(paragraphs)
  171. except Exception as e:
  172. print(f"Error counting paragraphs in original document: {e}")
  173. return count
  174. def validate_insertions(self):
  175. """
  176. Validate that w:delText elements are not within w:ins elements.
  177. w:delText is only allowed in w:ins if nested within a w:del.
  178. """
  179. errors = []
  180. for xml_file in self.xml_files:
  181. if xml_file.name != "document.xml":
  182. continue
  183. try:
  184. root = lxml.etree.parse(str(xml_file)).getroot()
  185. namespaces = {"w": self.WORD_2006_NAMESPACE}
  186. # Find w:delText in w:ins that are NOT within w:del
  187. invalid_elements = root.xpath(
  188. ".//w:ins//w:delText[not(ancestor::w:del)]",
  189. namespaces=namespaces
  190. )
  191. for elem in invalid_elements:
  192. text_preview = (
  193. repr(elem.text or "")[:50] + "..."
  194. if len(repr(elem.text or "")) > 50
  195. else repr(elem.text or "")
  196. )
  197. errors.append(
  198. f" {xml_file.relative_to(self.unpacked_dir)}: "
  199. f"Line {elem.sourceline}: <w:delText> within <w:ins>: {text_preview}"
  200. )
  201. except (lxml.etree.XMLSyntaxError, Exception) as e:
  202. errors.append(
  203. f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
  204. )
  205. if errors:
  206. print(f"FAILED - Found {len(errors)} insertion validation violations:")
  207. for error in errors:
  208. print(error)
  209. return False
  210. else:
  211. if self.verbose:
  212. print("PASSED - No w:delText elements within w:ins elements")
  213. return True
  214. def compare_paragraph_counts(self):
  215. """Compare paragraph counts between original and new document."""
  216. original_count = self.count_paragraphs_in_original()
  217. new_count = self.count_paragraphs_in_unpacked()
  218. diff = new_count - original_count
  219. diff_str = f"+{diff}" if diff > 0 else str(diff)
  220. print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})")
  221. if __name__ == "__main__":
  222. raise RuntimeError("This module should not be run directly.")