unpack.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. """Unpack Office files (DOCX, PPTX, XLSX) for editing.
  2. Extracts the ZIP archive, pretty-prints XML files, and optionally:
  3. - Merges adjacent runs with identical formatting (DOCX only)
  4. - Simplifies adjacent tracked changes from same author (DOCX only)
  5. Usage:
  6. python unpack.py <office_file> <output_dir> [options]
  7. Examples:
  8. python unpack.py document.docx unpacked/
  9. python unpack.py presentation.pptx unpacked/
  10. python unpack.py document.docx unpacked/ --merge-runs false
  11. """
  12. import argparse
  13. import sys
  14. import zipfile
  15. from pathlib import Path
  16. import defusedxml.minidom
  17. from helpers.merge_runs import merge_runs as do_merge_runs
  18. from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines
  19. SMART_QUOTE_REPLACEMENTS = {
  20. "\u201c": "&#x201C;",
  21. "\u201d": "&#x201D;",
  22. "\u2018": "&#x2018;",
  23. "\u2019": "&#x2019;",
  24. }
  25. def unpack(
  26. input_file: str,
  27. output_directory: str,
  28. merge_runs: bool = True,
  29. simplify_redlines: bool = True,
  30. ) -> tuple[None, str]:
  31. input_path = Path(input_file)
  32. output_path = Path(output_directory)
  33. suffix = input_path.suffix.lower()
  34. if not input_path.exists():
  35. return None, f"Error: {input_file} does not exist"
  36. if suffix not in {".docx", ".pptx", ".xlsx"}:
  37. return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file"
  38. try:
  39. output_path.mkdir(parents=True, exist_ok=True)
  40. with zipfile.ZipFile(input_path, "r") as zf:
  41. zf.extractall(output_path)
  42. xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels"))
  43. for xml_file in xml_files:
  44. _pretty_print_xml(xml_file)
  45. message = f"Unpacked {input_file} ({len(xml_files)} XML files)"
  46. if suffix == ".docx":
  47. if simplify_redlines:
  48. simplify_count, _ = do_simplify_redlines(str(output_path))
  49. message += f", simplified {simplify_count} tracked changes"
  50. if merge_runs:
  51. merge_count, _ = do_merge_runs(str(output_path))
  52. message += f", merged {merge_count} runs"
  53. for xml_file in xml_files:
  54. _escape_smart_quotes(xml_file)
  55. return None, message
  56. except zipfile.BadZipFile:
  57. return None, f"Error: {input_file} is not a valid Office file"
  58. except Exception as e:
  59. return None, f"Error unpacking: {e}"
  60. def _pretty_print_xml(xml_file: Path) -> None:
  61. try:
  62. content = xml_file.read_text(encoding="utf-8")
  63. dom = defusedxml.minidom.parseString(content)
  64. xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8"))
  65. except Exception:
  66. pass
  67. def _escape_smart_quotes(xml_file: Path) -> None:
  68. try:
  69. content = xml_file.read_text(encoding="utf-8")
  70. for char, entity in SMART_QUOTE_REPLACEMENTS.items():
  71. content = content.replace(char, entity)
  72. xml_file.write_text(content, encoding="utf-8")
  73. except Exception:
  74. pass
  75. if __name__ == "__main__":
  76. parser = argparse.ArgumentParser(
  77. description="Unpack an Office file (DOCX, PPTX, XLSX) for editing"
  78. )
  79. parser.add_argument("input_file", help="Office file to unpack")
  80. parser.add_argument("output_directory", help="Output directory")
  81. parser.add_argument(
  82. "--merge-runs",
  83. type=lambda x: x.lower() == "true",
  84. default=True,
  85. metavar="true|false",
  86. help="Merge adjacent runs with identical formatting (DOCX only, default: true)",
  87. )
  88. parser.add_argument(
  89. "--simplify-redlines",
  90. type=lambda x: x.lower() == "true",
  91. default=True,
  92. metavar="true|false",
  93. help="Merge adjacent tracked changes from same author (DOCX only, default: true)",
  94. )
  95. args = parser.parse_args()
  96. _, message = unpack(
  97. args.input_file,
  98. args.output_directory,
  99. merge_runs=args.merge_runs,
  100. simplify_redlines=args.simplify_redlines,
  101. )
  102. print(message)
  103. if "Error" in message:
  104. sys.exit(1)