pack.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """Pack a directory into a DOCX, PPTX, or XLSX file.
  2. Validates with auto-repair, condenses XML formatting, and creates the Office file.
  3. Usage:
  4. python pack.py <input_directory> <output_file> [--original <file>] [--validate true|false]
  5. Examples:
  6. python pack.py unpacked/ output.docx --original input.docx
  7. python pack.py unpacked/ output.pptx --validate false
  8. """
  9. import argparse
  10. import sys
  11. import shutil
  12. import tempfile
  13. import zipfile
  14. from pathlib import Path
  15. import defusedxml.minidom
  16. from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator
  17. def pack(
  18. input_directory: str,
  19. output_file: str,
  20. original_file: str | None = None,
  21. validate: bool = True,
  22. infer_author_func=None,
  23. ) -> tuple[None, str]:
  24. input_dir = Path(input_directory)
  25. output_path = Path(output_file)
  26. suffix = output_path.suffix.lower()
  27. if not input_dir.is_dir():
  28. return None, f"Error: {input_dir} is not a directory"
  29. if suffix not in {".docx", ".pptx", ".xlsx"}:
  30. return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file"
  31. if validate and original_file:
  32. original_path = Path(original_file)
  33. if original_path.exists():
  34. success, output = _run_validation(
  35. input_dir, original_path, suffix, infer_author_func
  36. )
  37. if output:
  38. print(output)
  39. if not success:
  40. return None, f"Error: Validation failed for {input_dir}"
  41. with tempfile.TemporaryDirectory() as temp_dir:
  42. temp_content_dir = Path(temp_dir) / "content"
  43. shutil.copytree(input_dir, temp_content_dir)
  44. for pattern in ["*.xml", "*.rels"]:
  45. for xml_file in temp_content_dir.rglob(pattern):
  46. _condense_xml(xml_file)
  47. output_path.parent.mkdir(parents=True, exist_ok=True)
  48. with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
  49. for f in temp_content_dir.rglob("*"):
  50. if f.is_file():
  51. zf.write(f, f.relative_to(temp_content_dir))
  52. return None, f"Successfully packed {input_dir} to {output_file}"
  53. def _run_validation(
  54. unpacked_dir: Path,
  55. original_file: Path,
  56. suffix: str,
  57. infer_author_func=None,
  58. ) -> tuple[bool, str | None]:
  59. output_lines = []
  60. validators = []
  61. if suffix == ".docx":
  62. author = "Claude"
  63. if infer_author_func:
  64. try:
  65. author = infer_author_func(unpacked_dir, original_file)
  66. except ValueError as e:
  67. print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr)
  68. validators = [
  69. DOCXSchemaValidator(unpacked_dir, original_file),
  70. RedliningValidator(unpacked_dir, original_file, author=author),
  71. ]
  72. elif suffix == ".pptx":
  73. validators = [PPTXSchemaValidator(unpacked_dir, original_file)]
  74. if not validators:
  75. return True, None
  76. total_repairs = sum(v.repair() for v in validators)
  77. if total_repairs:
  78. output_lines.append(f"Auto-repaired {total_repairs} issue(s)")
  79. success = all(v.validate() for v in validators)
  80. if success:
  81. output_lines.append("All validations PASSED!")
  82. return success, "\n".join(output_lines) if output_lines else None
  83. def _condense_xml(xml_file: Path) -> None:
  84. try:
  85. with open(xml_file, encoding="utf-8") as f:
  86. dom = defusedxml.minidom.parse(f)
  87. for element in dom.getElementsByTagName("*"):
  88. if element.tagName.endswith(":t"):
  89. continue
  90. for child in list(element.childNodes):
  91. if (
  92. child.nodeType == child.TEXT_NODE
  93. and child.nodeValue
  94. and child.nodeValue.strip() == ""
  95. ) or child.nodeType == child.COMMENT_NODE:
  96. element.removeChild(child)
  97. xml_file.write_bytes(dom.toxml(encoding="UTF-8"))
  98. except Exception as e:
  99. print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr)
  100. raise
  101. if __name__ == "__main__":
  102. parser = argparse.ArgumentParser(
  103. description="Pack a directory into a DOCX, PPTX, or XLSX file"
  104. )
  105. parser.add_argument("input_directory", help="Unpacked Office document directory")
  106. parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)")
  107. parser.add_argument(
  108. "--original",
  109. help="Original file for validation comparison",
  110. )
  111. parser.add_argument(
  112. "--validate",
  113. type=lambda x: x.lower() == "true",
  114. default=True,
  115. metavar="true|false",
  116. help="Run validation with auto-repair (default: true)",
  117. )
  118. args = parser.parse_args()
  119. _, message = pack(
  120. args.input_directory,
  121. args.output_file,
  122. original_file=args.original,
  123. validate=args.validate,
  124. )
  125. print(message)
  126. if "Error" in message:
  127. sys.exit(1)