Skip to content

Instantly share code, notes, and snippets.

@aarblaster
Created April 8, 2026 12:28
Show Gist options
  • Select an option

  • Save aarblaster/58548138bcffc904f3c8d9282fbf932f to your computer and use it in GitHub Desktop.

Select an option

Save aarblaster/58548138bcffc904f3c8d9282fbf932f to your computer and use it in GitHub Desktop.
Detach MS Word Templates
#!/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