Created
April 8, 2026 12:28
-
-
Save aarblaster/58548138bcffc904f3c8d9282fbf932f to your computer and use it in GitHub Desktop.
Detach MS Word Templates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # detach_templates.py | |
| # Version: 1.1 | |
| # | |
| # Removes the attached template reference from .docx files so that | |
| # macros from the template no longer load. Formatting already applied | |
| # to the document is preserved. | |
| # | |
| # Usage: | |
| # python3 detach_templates.py /path/to/folder | |
| # python3 detach_templates.py /path/to/folder --recursive | |
| # python3 detach_templates.py /path/to/single_file.docx | |
| # | |
| # Options: | |
| # --recursive, -r Process subfolders as well | |
| # --dry-run, -n Show what would be changed without modifying files | |
| # --no-backup Skip creating .bak backup files (not recommended) | |
| # --diagnose Print raw rels and settings XML for inspection | |
| # | |
| # Created by Anthony Arblaster on 7 April 2026. | |
| # | |
| # Copyright Anthony Arblaster 2026. | |
| # – Web: https://codebyanthony.com | |
| # – Mastodon: https://mastodonapp.uk/@aarblaster | |
| # – GitHub: https://github.com/aarblaster | |
| # | |
| # MIT Licence. | |
| # | |
| import argparse | |
| import os | |
| import re | |
| import shutil | |
| import sys | |
| import tempfile | |
| import zipfile | |
| def diagnose(docx_path): | |
| """Print raw rels and settings XML so the template reference can be located manually.""" | |
| if not zipfile.is_zipfile(docx_path): | |
| print(f" Not a valid ZIP/docx: {docx_path}") | |
| return | |
| files_to_show = [ | |
| "word/_rels/document.xml.rels", | |
| "word/_rels/settings.xml.rels", | |
| "word/settings.xml", | |
| ] | |
| with zipfile.ZipFile(docx_path, "r") as zin: | |
| names = zin.namelist() | |
| print(f"\n=== Files in archive ===") | |
| for n in sorted(names): | |
| print(f" {n}") | |
| for path in files_to_show: | |
| print(f"\n=== {path} ===") | |
| if path in names: | |
| lines = zin.read(path).decode("utf-8").splitlines() | |
| for line in lines[:60]: | |
| print(line) | |
| else: | |
| print(" (not found)") | |
| def detach_template(docx_path, dry_run=False, backup=True): | |
| """ | |
| Remove the attached template reference from a .docx file. | |
| A .docx is a ZIP archive. The template attachment is stored in | |
| word/_rels/settings.xml.rels as a Relationship element with | |
| Type ending in 'attachedTemplate', and referenced in | |
| word/settings.xml as a w:attachedTemplate element. | |
| Older documents may instead store it in word/_rels/document.xml.rels. | |
| All three locations are checked and cleaned independently. | |
| Returns: | |
| str: Status message describing what happened. | |
| """ | |
| if not zipfile.is_zipfile(docx_path): | |
| return f" SKIPPED (not a valid ZIP/docx): {docx_path}" | |
| doc_rels_path = "word/_rels/document.xml.rels" | |
| settings_rels_path = "word/_rels/settings.xml.rels" | |
| settings_path = "word/settings.xml" | |
| rel_pattern = r'<Relationship[^>]*attachedTemplate[^>]*/>' | |
| settings_pattern = r'<w:attachedTemplate\b[^/]*/>' | |
| try: | |
| with zipfile.ZipFile(docx_path, "r") as zin: | |
| names = zin.namelist() | |
| # Read all three relevant files if present | |
| doc_rels_content = zin.read(doc_rels_path).decode("utf-8") if doc_rels_path in names else None | |
| settings_rels_content = zin.read(settings_rels_path).decode("utf-8") if settings_rels_path in names else None | |
| settings_content = zin.read(settings_path).decode("utf-8") if settings_path in names else None | |
| doc_rels_matches = re.findall(rel_pattern, doc_rels_content, re.DOTALL) if doc_rels_content else [] | |
| settings_rels_matches = re.findall(rel_pattern, settings_rels_content, re.DOTALL) if settings_rels_content else [] | |
| settings_changed = bool(settings_content and re.search(settings_pattern, settings_content)) | |
| if not doc_rels_matches and not settings_rels_matches and not settings_changed: | |
| return f" OK (no template attached): {docx_path}" | |
| # Extract template name for reporting | |
| first_match = (settings_rels_matches or doc_rels_matches or [None])[0] | |
| if first_match: | |
| target = re.search(r'Target="([^"]*)"', first_match) | |
| tname = target.group(1) if target else "unknown" | |
| else: | |
| tname = "unknown (settings.xml only)" | |
| if dry_run: | |
| return f" WOULD DETACH template '{tname}': {docx_path}" | |
| if backup: | |
| shutil.copy2(docx_path, docx_path + ".bak") | |
| # Build cleaned versions of any changed files | |
| new_doc_rels = doc_rels_content | |
| if doc_rels_matches and new_doc_rels: | |
| for m in doc_rels_matches: | |
| new_doc_rels = new_doc_rels.replace(m, "") | |
| new_doc_rels = re.sub(r"\n\s*\n", "\n", new_doc_rels) | |
| new_settings_rels = settings_rels_content | |
| if settings_rels_matches and new_settings_rels: | |
| for m in settings_rels_matches: | |
| new_settings_rels = new_settings_rels.replace(m, "") | |
| new_settings_rels = re.sub(r"\n\s*\n", "\n", new_settings_rels) | |
| new_settings = settings_content | |
| if settings_changed and new_settings: | |
| new_settings = re.sub(settings_pattern, "", new_settings) | |
| new_settings = re.sub(r"\n\s*\n", "\n", new_settings) | |
| # Rewrite the ZIP with modified content | |
| tmp_fd, tmp_path = tempfile.mkstemp(suffix=".docx") | |
| os.close(tmp_fd) | |
| try: | |
| with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout: | |
| for item in names: | |
| if item == doc_rels_path and doc_rels_matches: | |
| zout.writestr(item, new_doc_rels) | |
| elif item == settings_rels_path and settings_rels_matches: | |
| zout.writestr(item, new_settings_rels) | |
| elif item == settings_path and settings_changed: | |
| zout.writestr(item, new_settings) | |
| else: | |
| zout.writestr(item, zin.read(item)) | |
| shutil.move(tmp_path, docx_path) | |
| except Exception: | |
| if os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| raise | |
| return f" DETACHED template '{tname}': {docx_path}" | |
| except zipfile.BadZipFile: | |
| return f" ERROR (corrupt zip): {docx_path}" | |
| except Exception as e: | |
| return f" ERROR ({e}): {docx_path}" | |
| def find_docx_files(path, recursive=False): | |
| """Find all .docx files at the given path.""" | |
| if os.path.isfile(path): | |
| if path.lower().endswith(".docx") and not os.path.basename(path).startswith("~$"): | |
| return [path] | |
| return [] | |
| files = [] | |
| if recursive: | |
| for root, dirs, filenames in os.walk(path): | |
| # Skip hidden directories | |
| dirs[:] = [d for d in dirs if not d.startswith(".")] | |
| for f in filenames: | |
| if f.lower().endswith(".docx") and not f.startswith("~$"): | |
| files.append(os.path.join(root, f)) | |
| else: | |
| for f in os.listdir(path): | |
| if f.lower().endswith(".docx") and not f.startswith("~$"): | |
| files.append(os.path.join(path, f)) | |
| return sorted(files) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Detach templates from Word .docx files to remove inherited macros." | |
| ) | |
| parser.add_argument( | |
| "path", | |
| help="Path to a .docx file or a folder containing .docx files", | |
| ) | |
| parser.add_argument( | |
| "--recursive", "-r", | |
| action="store_true", | |
| help="Process subfolders recursively", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", "-n", | |
| action="store_true", | |
| help="Show what would be changed without modifying anything", | |
| ) | |
| parser.add_argument( | |
| "--no-backup", | |
| action="store_true", | |
| help="Skip creating .bak backup files (not recommended)", | |
| ) | |
| parser.add_argument( | |
| "--diagnose", | |
| action="store_true", | |
| help="Print raw rels and settings XML to help locate the template reference", | |
| ) | |
| args = parser.parse_args() | |
| if not os.path.exists(args.path): | |
| print(f"Error: path not found: {args.path}") | |
| sys.exit(1) | |
| files = find_docx_files(args.path, recursive=args.recursive) | |
| if not files: | |
| print("No .docx files found.") | |
| sys.exit(0) | |
| print(f"Found {len(files)} .docx file(s)") | |
| if args.diagnose: | |
| print("DIAGNOSE mode — printing raw XML, no files will be modified\n") | |
| for f in files: | |
| print(f"\n{'=' * 60}") | |
| print(f"File: {f}") | |
| diagnose(f) | |
| sys.exit(0) | |
| if args.dry_run: | |
| print("DRY RUN — no files will be modified\n") | |
| elif not args.no_backup: | |
| print("Backups will be created (.bak)\n") | |
| detached = 0 | |
| skipped = 0 | |
| errors = 0 | |
| for f in files: | |
| result = detach_template(f, dry_run=args.dry_run, backup=not args.no_backup) | |
| print(result) | |
| if "DETACHED" in result or "WOULD DETACH" in result: | |
| detached += 1 | |
| elif "ERROR" in result: | |
| errors += 1 | |
| else: | |
| skipped += 1 | |
| print(f"\nSummary: {detached} detached, {skipped} skipped, {errors} errors") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment