agents/claude.yaml
interface:
display_name: "Office Utilities Skill - Custom"
short_description: "Unpack, repack, validate, and convert Office Open XML files"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
default_prompt: "Use $office-custom to unpack, edit, repack, or validate Office Open XML files (.docx, .pptx, .xlsx)."
agents/openai.yaml
interface:
display_name: "Office Utilities Skill - Custom"
short_description: "Unpack, repack, validate, and convert Office Open XML files"
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
default_prompt: "Use $office-custom to unpack, edit, repack, or validate Office Open XML files (.docx, .pptx, .xlsx)."
assets/icon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
<rect width="256" height="256" rx="48" fill="#1E293B"/>
<rect x="46" y="92" width="68" height="88" rx="14" fill="#2563EB"/>
<rect x="94" y="60" width="68" height="120" rx="14" fill="#F97316"/>
<rect x="142" y="92" width="68" height="88" rx="14" fill="#22C55E"/>
<rect x="60" y="108" width="40" height="10" rx="5" fill="#FFFFFF" opacity=".92"/>
<rect x="108" y="76" width="40" height="10" rx="5" fill="#FFFFFF" opacity=".92"/>
<rect x="156" y="108" width="40" height="10" rx="5" fill="#FFFFFF" opacity=".92"/>
<path d="M64 84c14-18 34-28 58-28 23.774 0 43.739 7.934 59.896 23.802" stroke="#E2E8F0" stroke-linecap="round" stroke-width="10"/>
<path d="m176 59 10 20-22 3" fill="#E2E8F0"/>
<path d="M192 172c-14 18-34 28-58 28-23.774 0-43.739-7.934-59.896-23.802" stroke="#E2E8F0" stroke-linecap="round" stroke-width="10"/>
<path d="m80 197-10-20 22-3" fill="#E2E8F0"/>
</svg>
scripts/lint_docx.py
#!/usr/bin/env python3
"""
lint_docx.py — Pre-delivery lint for silent .docx corruption.
validate.py checks ZIP integrity, XML well-formedness, and required parts.
Those are necessary but NOT sufficient: a file can pass them, render fine in
LibreOffice, and still make Word display "Word found unreadable content" and
rebuild the document on open. This linter flags silent corruptions behind that
behaviour:
1. Package parts (*.rels, [Content_Types].xml) whose root element carries a
namespace PREFIX instead of the default (prefix-less) namespace — e.g.
<ns0:Relationships> / <ns0:Types>. These are well-formed XML but trip
strict OPC readers (Word recovery; LibreOffice may refuse to open).
2. Numbering definitions in word/numbering.xml that are missing the metadata
Word-authored definitions carry:
• <w:num> without w16cid:durableId
• <w:abstractNum> without w15:restartNumberingAfterBreak
A hand-injected list definition that omits these (while siblings have
them) is the classic trigger for Word rebuilding numbering.xml.
3. word/document.xml (and header/footer parts) whose root mc:Ignorable lists
a prefix that is not declared with an in-scope xmlns: — invalid
Markup-Compatibility that makes Word repair the file.
4. <w:tblPr> with <w:tblBorders> after <w:tblLook> (CT_TblPrBase order
violation).
5. A <w:tbl> as the last body block, or two adjacent tables — Word repairs by
inserting a spacer paragraph.
It also checks content-type completeness: every part should have a matching
<Override> or a <Default> for its extension.
Exits 0 if clean, 1 if any issue is found.
Usage:
python office-custom/scripts/lint_docx.py document.docx
python office-custom/scripts/lint_docx.py document.docx --quiet
"""
import argparse
import posixpath
import re
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
# OPC content-types namespace (on [Content_Types].xml as the default ns).
_PKG_CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_W16CID_NS = "http://schemas.microsoft.com/office/word/2016/wordml/cid"
_W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml"
def _root_has_prefix(raw: bytes) -> bool:
"""Return True if the serialized root element uses a namespace prefix.
We look at the raw bytes rather than the parsed tree because ElementTree
discards prefixes on parse; the on-disk prefix is what readers see.
"""
text = raw.decode("utf-8", errors="replace")
# Find the first element tag (skip the XML declaration / PIs / comments).
i = 0
while i < len(text):
lt = text.find("<", i)
if lt == -1:
return False
nxt = text[lt + 1 : lt + 2]
if nxt in ("?", "!"): # declaration, comment, doctype
i = lt + 1
continue
# Read the tag name up to whitespace or '>'.
j = lt + 1
while j < len(text) and text[j] not in " \t\r\n>/":
j += 1
name = text[lt + 1 : j]
return ":" in name
return False
_MC_PARTS_RE = re.compile(r"^word/(document\.xml|header\d*\.xml|footer\d*\.xml)$")
def _check_mc_ignorable(zf: zipfile.ZipFile, errors: list[str]) -> None:
"""Every prefix in a root mc:Ignorable must be declared via xmlns:.
Undeclared prefixes are invalid Markup-Compatibility and make Word run
its repair dialog on open.
"""
for name in zf.namelist():
if not _MC_PARTS_RE.match(name):
continue
text = zf.read(name).decode("utf-8", errors="replace")
start = text.find("<w:document")
if start == -1:
start = text.find("<w:hdr")
if start == -1:
start = text.find("<w:ftr")
if start == -1:
continue
end = text.find(">", start)
if end == -1:
continue
root = text[start : end + 1]
match = re.search(r'mc:Ignorable="([^"]*)"', root)
if not match:
continue
declared = set(re.findall(r"xmlns:(\w+)=", root))
ignorable = set(match.group(1).split())
undeclared = ignorable - declared
if undeclared:
errors.append(
f"{name}: mc:Ignorable lists undeclared prefix(es) "
f"{sorted(undeclared)}; every mc:Ignorable prefix must have "
f"an in-scope xmlns: declaration or Word will repair the file."
)
def _check_tblpr_order(zf: zipfile.ZipFile, errors: list[str]) -> None:
"""In every <w:tblPr>, <w:tblBorders> must precede <w:tblLook>."""
for name in zf.namelist():
if not _MC_PARTS_RE.match(name):
continue
try:
root = ET.fromstring(zf.read(name))
except ET.ParseError:
continue # well-formedness is validate.py's job
w = f"{{{_W_NS}}}"
for tblpr in root.iter(f"{w}tblPr"):
children = [child.tag for child in tblpr]
borders = f"{w}tblBorders"
look = f"{w}tblLook"
if borders in children and look in children:
if children.index(borders) > children.index(look):
errors.append(
f"{name}: <w:tblPr> has <w:tblBorders> after "
f"<w:tblLook>; OOXML requires tblBorders before "
f"tblLook or Word will repair the table."
)
def _check_table_placement(zf: zipfile.ZipFile, errors: list[str]) -> None:
"""A table may not be the last body block or directly adjacent to another."""
if "word/document.xml" not in zf.namelist():
return
try:
root = ET.fromstring(zf.read("word/document.xml"))
except ET.ParseError:
return
w = f"{{{_W_NS}}}"
body = root.find(f"{w}body")
if body is None:
return
blocks = [child for child in body if child.tag in (f"{w}p", f"{w}tbl")]
if not blocks:
return
if blocks[-1].tag == f"{w}tbl":
errors.append(
"word/document.xml: a <w:tbl> is the last body block; the final "
"block before <w:sectPr> must be a paragraph or Word will repair "
"the document (insert a spacer <w:p/> after the table)."
)
for first, second in zip(blocks, blocks[1:]):
if first.tag == f"{w}tbl" and second.tag == f"{w}tbl":
errors.append(
"word/document.xml: two <w:tbl> elements are directly "
"adjacent; separate them with a paragraph or Word will repair."
)
break
def _check_package_prefixes(zf: zipfile.ZipFile, errors: list[str]) -> None:
for name in zf.namelist():
if name.endswith(".rels") or name == "[Content_Types].xml":
if _root_has_prefix(zf.read(name)):
errors.append(
f"{name}: package part root has a namespace prefix "
f"(e.g. <ns0:...>); use the default prefix-less namespace "
f"or Word will flag the file for recovery."
)
def _check_numbering(zf: zipfile.ZipFile, warnings: list[str]) -> None:
if "word/numbering.xml" not in zf.namelist():
return
try:
root = ET.fromstring(zf.read("word/numbering.xml"))
except ET.ParseError as exc:
warnings.append(f"word/numbering.xml: could not parse ({exc})")
return
nums = root.findall(f"{{{_W_NS}}}num")
missing_durable = [
n.get(f"{{{_W_NS}}}numId", "?")
for n in nums
if n.get(f"{{{_W16CID_NS}}}durableId") is None
]
if missing_durable and len(missing_durable) != len(nums):
warnings.append(
f"word/numbering.xml: <w:num> entries {missing_durable} lack "
f"w16cid:durableId while siblings have it — Word may rebuild "
f"numbering.xml on open."
)
elif missing_durable:
warnings.append(
"word/numbering.xml: all <w:num> entries lack w16cid:durableId; "
"Word-authored definitions carry a unique one."
)
abstracts = root.findall(f"{{{_W_NS}}}abstractNum")
missing_restart = [
a.get(f"{{{_W_NS}}}abstractNumId", "?")
for a in abstracts
if a.find(f"{{{_W15_NS}}}restartNumberingAfterBreak") is None
]
if missing_restart and len(missing_restart) != len(abstracts):
warnings.append(
f"word/numbering.xml: <w:abstractNum> {missing_restart} lack "
f"w15:restartNumberingAfterBreak while siblings have it."
)
def _check_content_types(zf: zipfile.ZipFile, errors: list[str]) -> None:
if "[Content_Types].xml" not in zf.namelist():
return
try:
root = ET.fromstring(zf.read("[Content_Types].xml"))
except ET.ParseError:
return # well-formedness is validate.py's job
defaults = {
d.get("Extension", "").lower()
for d in root.findall(f"{{{_PKG_CT_NS}}}Default")
}
overrides = {
o.get("PartName", "")
for o in root.findall(f"{{{_PKG_CT_NS}}}Override")
}
for name in zf.namelist():
if name.endswith("/") or name == "[Content_Types].xml":
continue
if name.startswith("_rels/") or "/_rels/" in name:
continue # covered by the 'rels' Default
ext = posixpath.splitext(name)[1].lstrip(".").lower()
part = "/" + name
if part in overrides or ext in defaults:
continue
errors.append(
f"{name}: no <Override> and no <Default> for extension "
f"'.{ext}' in [Content_Types].xml (the part is unreachable)."
)
def lint(source: Path, quiet: bool = False) -> bool:
"""Lint *source* and return True if no issues are found."""
source = Path(source)
if not source.exists():
print(f"ERROR: File not found: {source}")
return False
try:
zf = zipfile.ZipFile(source, "r")
except zipfile.BadZipFile as exc:
print(f"ERROR: Not a valid ZIP/OOXML file: {exc}")
return False
errors: list[str] = []
warnings: list[str] = []
with zf:
_check_package_prefixes(zf, errors)
_check_content_types(zf, errors)
_check_mc_ignorable(zf, errors)
_check_tblpr_order(zf, errors)
_check_table_placement(zf, errors)
_check_numbering(zf, warnings)
label = source.name
if errors:
print(f"FAIL {label}")
for err in errors:
print(f" ERROR: {err}")
for warn in warnings:
print(f" WARN: {warn}")
return False
if warnings:
print(f"WARN {label}")
for warn in warnings:
print(f" WARN: {warn}")
return True
if not quiet:
print(f"OK {label} (no silent-corruption signatures)")
return True
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("files", nargs="+", help="Files to lint")
parser.add_argument("--quiet", action="store_true", help="Suppress OK output")
args = parser.parse_args()
all_clean = True
for path_str in args.files:
if not lint(Path(path_str), quiet=args.quiet):
all_clean = False
sys.exit(0 if all_clean else 1)
if __name__ == "__main__":
main()
scripts/pack.py
#!/usr/bin/env python3
"""
pack.py — Repack an edited OOXML directory back into a .docx / .pptx / .xlsx.
Takes a directory previously unpacked by unpack.py, validates and auto-repairs
common XML issues, condenses the pretty-printed XML back to compact form, and
zips everything into a valid Office Open XML file.
Auto-repair fixes:
• durableId values >= 0x7FFFFFFF — regenerates a valid 31-bit integer.
• Missing xml:space="preserve" on <w:t> elements that contain leading or
trailing whitespace (Word will silently strip the spaces otherwise).
• Known namespace declarations missing from a root mc:Ignorable prefix list.
Usage:
python office-custom/scripts/pack.py unpacked/ output.docx --original document.docx
python office-custom/scripts/pack.py unpacked/ output.pptx --original presentation.pptx
python office-custom/scripts/pack.py unpacked/ output.xlsx --original spreadsheet.xlsx
Options:
--original PATH Path to the original file. Required so that the correct
ZIP comment and Content_Types are preserved.
--validate Run validate.py after packing (default: true).
--no-validate Skip validation.
"""
import argparse
import random
import re
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
# ---------------------------------------------------------------------------
# Auto-repair helpers
# ---------------------------------------------------------------------------
# Maximum allowed durableId (Word rejects values >= 0x7FFFFFFF).
_DURABLE_ID_MAX = 0x7FFFFFFF
def fix_durable_ids(text: str) -> tuple[str, int]:
"""Replace out-of-range w:durableId values with valid random IDs.
Word uses 31-bit unsigned integers for durableId. Values >= 0x7FFFFFFF
(2147483648) are invalid and cause Word to fail on open.
Args:
text: XML content as a string.
Returns:
Tuple of (fixed_text, number_of_replacements).
"""
count = 0
def replacer(match: re.Match) -> str:
nonlocal count
val = int(match.group(1))
if val >= _DURABLE_ID_MAX:
new_val = random.randint(1, _DURABLE_ID_MAX - 1)
count += 1
return f'w:durableId="{new_val}"'
return match.group(0)
fixed = re.sub(r'w:durableId="(\d+)"', replacer, text)
return fixed, count
def fix_xml_space_preserve(text: str) -> tuple[str, int]:
"""Add xml:space="preserve" to <w:t> elements missing it when needed.
Word silently strips leading/trailing whitespace from <w:t> elements
unless xml:space="preserve" is present. This is required whenever the
text content starts or ends with a space character.
Args:
text: XML content as a string.
Returns:
Tuple of (fixed_text, number_of_replacements).
"""
count = 0
def replacer(match: re.Match) -> str:
nonlocal count
tag_open = match.group(1) # e.g. '<w:t>' or '<w:t foo="bar">'
content = match.group(2)
# Only add if content has leading/trailing whitespace.
if content and (content[0] == " " or content[-1] == " "):
if 'xml:space' not in tag_open:
count += 1
# Insert attribute before the closing >.
tag_open = tag_open.rstrip(">") + ' xml:space="preserve">'
return f"{tag_open}{content}</w:t>"
fixed = re.sub(r"(<w:t(?:\s[^>]*)?>)(.*?)</w:t>", replacer, text, flags=re.DOTALL)
return fixed, count
# ---------------------------------------------------------------------------
# XML condensing
# ---------------------------------------------------------------------------
# OPC package-level namespaces. These live on the *package* parts
# ([Content_Types].xml and every *.rels file) as the default (prefix-less)
# namespace. They are intentionally NOT in _NAMESPACES below: re-serialising
# them with any prefix (ElementTree emits <ns0:Types> / <ns0:Relationships>)
# produces a package that is still well-formed XML — so validate.py passes it —
# yet Word flags it for recovery and LibreOffice may refuse to open it. They
# are handled separately via tostring(default_namespace=...) in condense_xml.
_PACKAGE_NAMESPACES = {
"http://schemas.openxmlformats.org/package/2006/relationships",
"http://schemas.openxmlformats.org/package/2006/content-types",
}
_OOXML_NAMESPACES = {
"wpc": "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas",
"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex",
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
"aink": "http://schemas.microsoft.com/office/drawing/2016/ink",
"am3d": "http://schemas.microsoft.com/office/drawing/2017/model3d",
"o": "urn:schemas-microsoft-com:office:office",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
"v": "urn:schemas-microsoft-com:vml",
"wp14": "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"w10": "urn:schemas-microsoft-com:office:word",
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"w14": "http://schemas.microsoft.com/office/word/2010/wordml",
"w15": "http://schemas.microsoft.com/office/word/2012/wordml",
"w16": "http://schemas.microsoft.com/office/word/2018/wordml",
"w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
"w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
"w16du": "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
"w16sdtdh": "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash",
"w16se": "http://schemas.microsoft.com/office/word/2015/wordml/symex",
"wpg": "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup",
"wpi": "http://schemas.microsoft.com/office/word/2010/wordprocessingInk",
"wne": "http://schemas.microsoft.com/office/word/2006/wordml",
"wps": "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"a14": "http://schemas.microsoft.com/office/drawing/2010/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
}
def _ensure_mc_ignorable_namespaces(xml_bytes: bytes) -> bytes:
"""Declare known root namespaces named by mc:Ignorable.
ElementTree omits namespace declarations that are not used by element or
attribute names. Word still requires every prefix listed in mc:Ignorable to
resolve on the root, even if the document body no longer uses that prefix.
"""
text = xml_bytes.decode("utf-8", errors="replace")
start = re.search(r"<[A-Za-z_][\w.-]*(?::[\w.-]+)?(?:\s|>)", text)
if not start:
return xml_bytes
tag_start = start.start()
tag_end = text.find(">", tag_start)
if tag_end == -1:
return xml_bytes
root_tag = text[tag_start : tag_end + 1]
match = re.search(r'mc:Ignorable="([^"]*)"', root_tag)
if not match:
return xml_bytes
additions = []
for prefix in match.group(1).split():
if f"xmlns:{prefix}=" in root_tag:
continue
uri = _OOXML_NAMESPACES.get(prefix)
if uri:
additions.append(f' xmlns:{prefix}="{uri}"')
if not additions:
return xml_bytes
insert_at = tag_end
if root_tag.rstrip().endswith("/>"):
insert_at = text.rfind("/", tag_start, tag_end)
if insert_at == -1:
insert_at = tag_end
text = text[:insert_at] + "".join(additions) + text[insert_at:]
return text.encode("utf-8")
def condense_xml(text: str) -> bytes:
"""Remove pretty-print indentation to produce compact XML bytes.
Re-parses the XML and re-serialises without extra whitespace. Preserves
the xml:space="preserve" attribute semantics because ElementTree honours
them during serialisation.
Package parts ([Content_Types].xml, *.rels) keep their default
(prefix-less) namespace so their roots stay <Types>/<Relationships> rather
than <ns0:Types>/<ns0:Relationships>.
Args:
text: Pretty-printed XML string.
Returns:
Compact XML as UTF-8 bytes, with an XML declaration.
"""
# Register all known OOXML namespaces to avoid ns0: prefixes.
for prefix, uri in _OOXML_NAMESPACES.items():
ET.register_namespace(prefix, uri)
try:
root = ET.fromstring(text.encode("utf-8"))
except ET.ParseError:
# If the XML can't be re-parsed, fall back to raw bytes.
return text.encode("utf-8")
# If this is a package part, register its namespace as the default
# (prefix-less) so the root stays <Types>/<Relationships> rather than
# <ns0:Types>/<ns0:Relationships>. This mirrors comment.py's handling.
# (default_namespace= can't be used here — package parts carry unqualified
# attributes like Id/Type/Target, which that option rejects.) Only package
# parts live in these namespaces, so the empty-prefix registration never
# affects document-body parts (which use the w: prefix).
root_ns = root.tag[1:root.tag.index("}")] if root.tag.startswith("{") else None
if root_ns in _PACKAGE_NAMESPACES:
ET.register_namespace("", root_ns)
data = ET.tostring(root, encoding="utf-8", xml_declaration=True)
return _ensure_mc_ignorable_namespaces(data)
# ---------------------------------------------------------------------------
# Main packing logic
# ---------------------------------------------------------------------------
def pack(
unpacked_dir: Path,
output: Path,
original: Path | None = None,
validate: bool = True,
) -> None:
"""Repack *unpacked_dir* into *output*.
Args:
unpacked_dir: Directory produced by unpack.py.
output: Destination .docx / .pptx / .xlsx path.
original: Original source file (used to copy ZIP metadata/comment).
validate: If True, run validate.py after packing.
"""
unpacked_dir = Path(unpacked_dir)
output = Path(output)
if not unpacked_dir.exists():
raise FileNotFoundError(f"Unpacked directory not found: {unpacked_dir}")
output.parent.mkdir(parents=True, exist_ok=True)
# Collect all files in the unpacked directory.
all_files = sorted(
p for p in unpacked_dir.rglob("*") if p.is_file()
)
repair_stats = {"durable_ids": 0, "xml_space": 0}
print(f"Packing {unpacked_dir}/ -> {output}")
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# [Content_Types].xml must be the first entry in an OOXML ZIP.
content_types = unpacked_dir / "[Content_Types].xml"
if content_types.exists():
_write_member(zf, content_types, unpacked_dir, repair_stats)
for member in all_files:
if member == content_types:
continue # Already written first.
_write_member(zf, member, unpacked_dir, repair_stats)
if repair_stats["durable_ids"]:
print(f" Auto-repaired {repair_stats['durable_ids']} out-of-range durableId(s).")
if repair_stats["xml_space"]:
print(f" Auto-added xml:space=\"preserve\" to {repair_stats['xml_space']} <w:t> element(s).")
print(f" Written {output.stat().st_size:,} bytes.")
if validate:
_run_validate(output)
def _write_member(
zf: zipfile.ZipFile,
member: Path,
base: Path,
repair_stats: dict,
) -> None:
"""Write a single file into the ZIP, applying auto-repair if XML."""
arc_name = member.relative_to(base).as_posix()
if member.suffix in (".xml", ".rels"):
text = member.read_text(encoding="utf-8", errors="replace")
# Auto-repair pass 1: fix out-of-range durableId values.
text, n = fix_durable_ids(text)
repair_stats["durable_ids"] += n
# Auto-repair pass 2: add missing xml:space="preserve".
text, n = fix_xml_space_preserve(text)
repair_stats["xml_space"] += n
data = condense_xml(text)
else:
data = member.read_bytes()
zf.writestr(arc_name, data)
def _run_validate(output: Path) -> None:
"""Run validate.py on the packed file (best-effort; errors are reported)."""
import subprocess
import sys
validate_script = Path(__file__).parent / "validate.py"
if validate_script.exists():
result = subprocess.run(
[sys.executable, str(validate_script), str(output)],
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout.rstrip())
if result.returncode != 0 and result.stderr:
print(result.stderr.rstrip())
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("unpacked_dir", help="Directory produced by unpack.py")
parser.add_argument("output", help="Output .docx / .pptx / .xlsx path")
parser.add_argument(
"--original",
help="Path to the original file (optional; used for metadata)",
)
parser.add_argument(
"--validate",
default="true",
choices=["true", "false"],
help="Run validate.py after packing (default: true)",
)
parser.add_argument(
"--no-validate",
dest="validate",
action="store_const",
const="false",
help="Skip validation.",
)
args = parser.parse_args()
pack(
unpacked_dir=Path(args.unpacked_dir),
output=Path(args.output),
original=Path(args.original) if args.original else None,
validate=(args.validate == "true"),
)
if __name__ == "__main__":
main()
scripts/soffice.py
#!/usr/bin/env python3
"""
soffice.py — LibreOffice CLI wrapper
Locates LibreOffice on macOS, Linux, or inside a sandboxed environment and
invokes it as a subprocess. All arguments are forwarded verbatim, so this
script is a drop-in replacement for calling `soffice` directly.
Why this wrapper instead of calling soffice directly?
- LibreOffice lives at different paths on different OSes/installs.
- In sandboxed agent environments the user-profile directory must be set
explicitly; the default ~/.config/libreoffice path may be read-only.
- On some systems Unix-domain sockets are restricted; this wrapper selects
a writable temp directory for the user-profile automatically.
Usage (mirrors LibreOffice CLI):
python office-custom/scripts/soffice.py --headless --convert-to pdf file.docx
python office-custom/scripts/soffice.py --headless --convert-to docx file.doc
python office-custom/scripts/soffice.py --headless --convert-to pdf file.pptx
"""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# Candidate LibreOffice executable paths, searched in order.
_CANDIDATE_PATHS = [
# macOS (homebrew or standard app bundle)
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/opt/homebrew/bin/soffice",
"/usr/local/bin/soffice",
# Linux (Debian/Ubuntu/Fedora package installs)
"/usr/bin/soffice",
"/usr/lib/libreoffice/program/soffice",
"/opt/libreoffice/program/soffice",
"/snap/bin/libreoffice",
# Windows (winget/MSI default install locations)
"C:/Program Files/LibreOffice/program/soffice.exe",
"C:/Program Files (x86)/LibreOffice/program/soffice.exe",
]
def find_soffice() -> str:
"""Return the path to the LibreOffice executable.
Checks the PATH environment variable first, then falls back to a list of
well-known installation locations.
Raises:
FileNotFoundError: If LibreOffice cannot be found.
"""
# Honour an explicit override from the environment.
env_override = os.environ.get("SOFFICE_PATH")
if env_override and Path(env_override).exists():
return env_override
# Search PATH first so system-managed installs take priority.
for candidate in ["soffice", "libreoffice"]:
path = shutil.which(candidate)
if path:
return path
# Fall back to hard-coded locations.
for path in _CANDIDATE_PATHS:
if Path(path).exists():
return path
raise FileNotFoundError(
"LibreOffice not found. Install it with:\n"
" macOS: brew install --cask libreoffice\n"
" Ubuntu: sudo apt-get install libreoffice\n"
" Fedora: sudo dnf install libreoffice\n"
"Or set the SOFFICE_PATH environment variable."
)
def make_user_profile() -> str:
"""Return a writable LibreOffice user-profile directory.
Creates a persistent temp directory under /tmp/soffice-profile so that
successive calls reuse the same profile (faster) while staying out of
the user's home directory (safe in sandboxed environments).
"""
profile_dir = Path(tempfile.gettempdir()) / "soffice-profile"
profile_dir.mkdir(parents=True, exist_ok=True)
return str(profile_dir)
def run(args: list[str]) -> subprocess.CompletedProcess[bytes]:
"""Invoke LibreOffice with *args* and return the process result.
A ``-env:UserInstallation=...`` flag is prepended automatically so that
LibreOffice writes its lock/config files to a temp directory rather than
the user's home, which avoids permission errors in restricted environments.
Args:
args: Argument list to forward to soffice (e.g. ``["--headless",
"--convert-to", "pdf", "file.docx"]``).
Returns:
The completed LibreOffice process result.
"""
soffice = find_soffice()
profile = make_user_profile()
profile_url = Path(profile).as_uri()
cmd = [
soffice,
f"-env:UserInstallation={profile_url}",
*args,
]
return subprocess.run(cmd)
def main() -> None:
"""Entry point: forward all CLI arguments to LibreOffice."""
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
result = run(sys.argv[1:])
sys.exit(result.returncode)
if __name__ == "__main__":
main()
scripts/unpack.py
#!/usr/bin/env python3
"""
unpack.py — Unpack an Office Open XML file (.docx / .pptx / .xlsx) for editing.
An OOXML file is a ZIP archive of XML files. This script:
1. Extracts the ZIP to a working directory.
2. Pretty-prints every XML file so diffs are readable and edits are easy.
3. (docx only, optional) Merges adjacent text runs with identical formatting
so find-and-replace works across run boundaries.
4. Converts smart-quote characters to XML entities so they survive round-
trips through tools that don't handle UTF-8 gracefully.
After editing the unpacked directory, repack with:
python office-custom/scripts/pack.py <unpacked_dir> <output_file> --original <original>
Usage:
python office-custom/scripts/unpack.py document.docx unpacked/
python office-custom/scripts/unpack.py presentation.pptx unpacked/ --merge-runs false
python office-custom/scripts/unpack.py spreadsheet.xlsx unpacked/
"""
import argparse
import shutil
import zipfile
from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree as ET
# Smart-quote characters and their XML entity equivalents.
# These are applied to all XML text content after pretty-printing so that
# editors that strip non-ASCII don't corrupt the document.
_SMART_QUOTE_MAP = {
"\u2018": "‘", # ' LEFT SINGLE QUOTATION MARK
"\u2019": "’", # ' RIGHT SINGLE QUOTATION MARK / APOSTROPHE
"\u201C": "“", # " LEFT DOUBLE QUOTATION MARK
"\u201D": "”", # " RIGHT DOUBLE QUOTATION MARK
"\u2013": "–", # – EN DASH
"\u2014": "—", # — EM DASH
"\u00A0": " ", # non-breaking space
}
# XML namespaces used in OOXML — needed to parse namespace-prefixed tags.
_NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def pretty_print_xml(xml_bytes: bytes) -> str:
"""Return a human-readable, consistently indented XML string.
Uses minidom to reformat. Strips the XML declaration added by minidom
(the original declaration, if any, is preserved when repacking).
Args:
xml_bytes: Raw XML bytes from the ZIP archive.
Returns:
A UTF-8 string with 2-space indentation and Unix line endings.
"""
try:
dom = minidom.parseString(xml_bytes)
pretty = dom.toprettyxml(indent=" ", encoding=None)
# minidom adds <?xml version="1.0" ?> — strip it; pack.py re-adds it.
lines = pretty.splitlines()
if lines and lines[0].startswith("<?xml"):
lines = lines[1:]
return "\n".join(line for line in lines if line.strip()) + "\n"
except Exception:
# If XML is malformed, return as-is (UTF-8 decoded).
return xml_bytes.decode("utf-8", errors="replace")
def escape_smart_quotes(text: str) -> str:
"""Replace Unicode typographic characters with XML numeric entities.
This prevents corruption when XML files are later opened by tools (or
text editors) that rewrite the encoding or strip high-codepoint chars.
Args:
text: XML content as a string.
Returns:
The same string with smart quotes replaced by XML entities.
"""
for char, entity in _SMART_QUOTE_MAP.items():
text = text.replace(char, entity)
return text
def merge_adjacent_runs(xml_text: str) -> str:
"""Merge consecutive <w:r> elements that share identical <w:rPr> formatting.
Word stores text in 'runs' (<w:r>). When a document has been edited,
identical formatting can be split across many runs, making find-and-replace
unreliable (e.g. "hel" in one run and "lo" in the next).
This function re-parses the XML, merges adjacent runs with matching
formatting, and re-serialises. Only plain text runs are merged;
runs containing field codes, bookmarks, or special elements are left alone.
Args:
xml_text: The content of word/document.xml as a string.
Returns:
XML string with adjacent same-format runs merged.
"""
try:
# Register namespaces to avoid ns0: prefixes in the output.
for prefix, uri in _NS.items():
ET.register_namespace(prefix, uri)
root = ET.fromstring(xml_text.encode("utf-8"))
W = _NS["w"]
def rpr_key(run: ET.Element) -> str:
"""Serialise a run's <w:rPr> to a string for comparison."""
rpr = run.find(f"{{{W}}}rPr")
if rpr is None:
return ""
return ET.tostring(rpr, encoding="unicode")
def can_merge(run: ET.Element) -> bool:
"""Return True if this run contains only <w:rPr> and <w:t>."""
allowed = {f"{{{W}}}rPr", f"{{{W}}}t"}
return all(child.tag in allowed for child in run)
# Walk every paragraph and merge runs within it.
for para in root.iter(f"{{{W}}}p"):
children = list(para)
i = 0
while i < len(children) - 1:
curr = children[i]
nxt = children[i + 1]
if (
curr.tag == f"{{{W}}}r"
and nxt.tag == f"{{{W}}}r"
and can_merge(curr)
and can_merge(nxt)
and rpr_key(curr) == rpr_key(nxt)
):
# Append next run's text to current run's <w:t>.
curr_t = curr.find(f"{{{W}}}t")
nxt_t = nxt.find(f"{{{W}}}t")
if curr_t is not None and nxt_t is not None:
curr_text = curr_t.text or ""
nxt_text = nxt_t.text or ""
merged = curr_text + nxt_text
curr_t.text = merged
# Preserve xml:space="preserve" if either run had spaces.
if merged != merged.strip():
curr_t.set(
"{http://www.w3.org/XML/1998/namespace}space",
"preserve",
)
# Remove the next run from the paragraph.
para.remove(nxt)
children.pop(i + 1)
continue # Re-check the merged run against the new next.
i += 1
return ET.tostring(root, encoding="unicode", xml_declaration=False)
except Exception:
# If anything goes wrong, return unchanged.
return xml_text
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def unpack(
source: Path,
dest: Path,
merge_runs: bool = True,
) -> None:
"""Unpack an OOXML file to *dest* for editing.
Args:
source: Path to the .docx / .pptx / .xlsx file.
dest: Directory to extract into (created if absent, cleared if
present).
merge_runs: If True (default) and source is a .docx, merge adjacent
same-format text runs in word/document.xml.
"""
source = Path(source)
dest = Path(dest)
if not source.exists():
raise FileNotFoundError(f"Source file not found: {source}")
# Start fresh: remove any existing unpacked directory.
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
suffix = source.suffix.lower()
print(f"Unpacking {source} -> {dest}/")
with zipfile.ZipFile(source, "r") as zf:
for name in zf.namelist():
member_path = dest / name
if name.endswith("/"):
# Directory entry — create it.
member_path.mkdir(parents=True, exist_ok=True)
continue
member_path.parent.mkdir(parents=True, exist_ok=True)
raw = zf.read(name)
if name.endswith(".xml") or name.endswith(".rels"):
# Pretty-print XML and escape smart quotes.
text = pretty_print_xml(raw)
text = escape_smart_quotes(text)
# Optionally merge adjacent runs in the main document body.
if (
merge_runs
and suffix == ".docx"
and name == "word/document.xml"
):
text = merge_adjacent_runs(text)
# Re-escape after merge (ET unescapes entities).
text = escape_smart_quotes(text)
member_path.write_text(text, encoding="utf-8")
else:
# Binary file (images, fonts, etc.) — copy as-is.
member_path.write_bytes(raw)
print(f" Extracted {len(list(dest.rglob('*')))} files.")
if merge_runs and suffix == ".docx":
print(" Merged adjacent text runs in word/document.xml.")
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("source", help="Path to the .docx / .pptx / .xlsx file")
parser.add_argument("dest", help="Destination directory for unpacked files")
parser.add_argument(
"--merge-runs",
default="true",
choices=["true", "false"],
help="Merge adjacent same-format runs in docx (default: true)",
)
args = parser.parse_args()
unpack(
source=Path(args.source),
dest=Path(args.dest),
merge_runs=(args.merge_runs == "true"),
)
if __name__ == "__main__":
main()
scripts/validate.py
#!/usr/bin/env python3
"""
validate.py — Validate an Office Open XML file (.docx / .pptx / .xlsx).
Performs three checks:
1. ZIP integrity — the file can be opened as a valid ZIP archive.
2. XML well-formedness — every .xml and .rels member parses without error.
3. Required parts — the mandatory [Content_Types].xml and _rels/.rels
entries are present.
Exits with code 0 if all checks pass, non-zero otherwise.
Usage:
python office-custom/scripts/validate.py document.docx
python office-custom/scripts/validate.py presentation.pptx
python office-custom/scripts/validate.py spreadsheet.xlsx
Options:
--quiet Suppress per-file output; only print summary.
"""
import argparse
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
# Parts that MUST be present in any valid OOXML package.
_REQUIRED_PARTS = ["[Content_Types].xml", "_rels/.rels"]
def validate(source: Path, quiet: bool = False) -> bool:
"""Validate *source* and return True if it passes all checks.
Args:
source: Path to a .docx / .pptx / .xlsx file.
quiet: If True, suppress per-member output.
Returns:
True if the file is valid, False otherwise.
"""
source = Path(source)
if not source.exists():
print(f"ERROR: File not found: {source}")
return False
errors: list[str] = []
warnings: list[str] = []
# ------------------------------------------------------------------
# Check 1: ZIP integrity
# ------------------------------------------------------------------
try:
zf = zipfile.ZipFile(source, "r")
except zipfile.BadZipFile as exc:
print(f"ERROR: Not a valid ZIP/OOXML file: {exc}")
return False
with zf:
member_names = zf.namelist()
# ------------------------------------------------------------------
# Check 2: Required parts
# ------------------------------------------------------------------
for part in _REQUIRED_PARTS:
if part not in member_names:
errors.append(f"Missing required part: {part}")
# ------------------------------------------------------------------
# Check 3: XML well-formedness
# ------------------------------------------------------------------
xml_ok = 0
xml_bad = 0
for name in member_names:
if not (name.endswith(".xml") or name.endswith(".rels")):
continue
try:
raw = zf.read(name)
ET.fromstring(raw)
xml_ok += 1
if not quiet:
pass # Don't print every OK file — too noisy.
except ET.ParseError as exc:
xml_bad += 1
errors.append(f"XML parse error in {name}: {exc}")
# ------------------------------------------------------------------
# Report
# ------------------------------------------------------------------
label = source.name
if errors:
print(f"FAIL {label}")
for err in errors:
print(f" ERROR: {err}")
return False
if warnings:
print(f"WARN {label}")
for warn in warnings:
print(f" WARN: {warn}")
return True
if not quiet:
print(f"OK {label} ({xml_ok} XML members validated)")
return True
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("files", nargs="+", help="Files to validate")
parser.add_argument("--quiet", action="store_true", help="Suppress per-file output")
args = parser.parse_args()
all_passed = True
for path_str in args.files:
passed = validate(Path(path_str), quiet=args.quiet)
if not passed:
all_passed = False
if len(args.files) > 1:
status = "All passed" if all_passed else "Some checks FAILED"
print(f"\n{status} ({len(args.files)} files checked)")
sys.exit(0 if all_passed else 1)
if __name__ == "__main__":
main()
SKILL.md
---
name: office-custom
description: Use when unpacking, editing, repacking, validating, or converting Office Open XML files (.docx, .pptx, .xlsx)
---
# Office Open XML Utilities
This skill provides the shared scripts used by the `docx-custom`, `pptx-custom`,
and `xlsx-custom` skills to work with **Office Open XML (OOXML)** files — the ZIP-based
XML format that underlies all modern Microsoft Office documents.
## Intent Router
No separate reference files. All workflows are documented inline in this
`SKILL.md`:
- Unpack/repack an OOXML file → `## Scripts` section (`unpack.py`, `pack.py`)
- Validate OOXML structure → `## Scripts` section (`validate.py`)
- Convert to PDF via LibreOffice → `## Scripts` section (`soffice.py`)
## Quick Start
Use these commands from the repo root:
```bash
# Inspect OOXML package structure
python office-custom/scripts/validate.py document.docx
# Unpack for XML edits
python office-custom/scripts/unpack.py presentation.pptx unpacked/ --merge-runs false
# Repack after edits and preserve source ZIP metadata where possible
python office-custom/scripts/pack.py unpacked/ output.pptx --original presentation.pptx
# Convert to PDF through the LibreOffice wrapper
python office-custom/scripts/soffice.py --headless --convert-to pdf output.pptx
```
For fragile `.pptx` files, run `pptx-custom/scripts/check_fragility.py` before
any unpack/repack or python-pptx save. Some third-party exporters require the
byte-preserving patch workflow instead of normal OOXML repacking.
## What Is OOXML?
A `.docx`, `.pptx`, or `.xlsx` file is a **ZIP archive** containing XML files:
```text
document.docx (ZIP)
├── [Content_Types].xml ← declares every part's MIME type
├── _rels/.rels ← top-level relationships
└── word/
├── document.xml ← main body content
├── styles.xml ← style definitions
├── settings.xml ← document settings
├── _rels/document.xml.rels
└── media/ ← embedded images, etc.
```
Editing an OOXML file means: **unpack → edit XML → repack**.
---
## Scripts
All scripts live in `office-custom/scripts/` and are runnable from the repo root.
### `unpack.py` — Extract an OOXML file for editing
```bash
python office-custom/scripts/unpack.py <source> <dest_dir>
python office-custom/scripts/unpack.py document.docx unpacked/
python office-custom/scripts/unpack.py presentation.pptx unpacked/ --merge-runs false
```
**What it does:**
- Extracts the ZIP to a working directory
- Pretty-prints every `.xml` / `.rels` file (2-space indent, Unix line endings)
- Escapes smart-quote characters (`"` `"` `'` `'` `–` `—`) to XML entities so
tools that rewrite encoding don't corrupt them
- For `.docx` files (optional, default on): merges adjacent `<w:r>` runs that
share identical formatting — makes find-and-replace reliable across run boundaries
**Options:**
| Option | Default | Description |
|--------|---------|-------------|
| `--merge-runs true\|false` | `true` | Merge adjacent same-format runs in `.docx` |
---
### `pack.py` — Repack an edited directory back into an OOXML file
```bash
python office-custom/scripts/pack.py <unpacked_dir> <output_file> [--original <original>]
python office-custom/scripts/pack.py unpacked/ output.docx --original document.docx
python office-custom/scripts/pack.py unpacked/ output.pptx --original presentation.pptx
```
**What it does:**
- Walks the unpacked directory and writes every file into a new ZIP
- Writes `[Content_Types].xml` first (OOXML spec requirement)
- Applies two **auto-repair** passes to every XML file:
1. **durableId fix** — regenerates `w:durableId` values ≥ `0x7FFFFFFF`
(Word rejects these; they appear when content is copy-pasted from other documents)
2. **`xml:space="preserve"` fix** — adds the attribute to `<w:t>` elements
whose text has leading/trailing spaces (Word strips the spaces without it)
- Condenses pretty-printed XML back to compact form before writing
- Runs `validate.py` on the output (can be suppressed with `--validate false`)
**Options:**
| Option | Default | Description |
|--------|---------|-------------|
| `--original PATH` | — | Copy ZIP metadata/comment from the original file |
| `--validate true\|false` | `true` | Run validate.py after packing |
---
### `validate.py` — Validate an OOXML file
```bash
python office-custom/scripts/validate.py document.docx
python office-custom/scripts/validate.py presentation.pptx spreadsheet.xlsx
python office-custom/scripts/validate.py *.docx --quiet
```
**What it does — three checks in order:**
1. **ZIP integrity** — can the file be opened as a valid ZIP archive?
2. **Required parts** — are `[Content_Types].xml` and `_rels/.rels` present?
3. **XML well-formedness** — does every `.xml` / `.rels` member parse without error?
**Output:**
```text
OK document.docx (14 XML members validated)
FAIL broken.docx
ERROR: XML parse error in word/document.xml: ...
```
**Exit codes:** `0` = all passed, `1` = any failure.
**Options:**
| Option | Description |
|--------|-------------|
| `--quiet` | Suppress per-file output; only print summary |
---
### `soffice.py` — LibreOffice CLI wrapper
```bash
# Used programmatically by other scripts; can also be run directly:
python office-custom/scripts/soffice.py --headless --convert-to pdf document.docx
python office-custom/scripts/soffice.py --headless --convert-to docx document.doc
python office-custom/scripts/soffice.py --headless --convert-to pdf output.pptx
```
**What it does:**
- Locates the `soffice` binary across macOS, Linux, and sandboxed environments:
- macOS app bundle: `/Applications/LibreOffice.app/Contents/MacOS/soffice`
- Linux packages: `/usr/bin/soffice`, `/usr/bin/libreoffice`
- Snap: `/snap/bin/libreoffice`
- PATH fallback
- Creates an isolated temporary user-profile directory so LibreOffice doesn't
need write access to `~/.config/libreoffice` (critical in CI / sandboxes)
- Drop-in pass-through: any arguments after the script name are forwarded
verbatim to the `soffice` binary
**Dependency:** LibreOffice must be installed separately.
- macOS: `brew install --cask libreoffice`
- Ubuntu: `apt install libreoffice`
- Windows: `winget install --id TheDocumentFoundation.LibreOffice --source winget`
---
## Standard Edit Workflow
```text
┌─────────────┐ unpack.py ┌───────────────┐ edit XML ┌────────────────┐
│ input.docx │ ──────────── ▶│ unpacked/ │ ──────────── ▶│ unpacked/ │
│ (ZIP) │ │ word/ │ │ word/ │
└─────────────┘ │ document.xml │ │ document.xml │
│ styles.xml │ │ (modified) │
└───────────────┘ └────────┬───────┘
│
┌───────────────┐ pack.py │
│ output.docx │ ◀──────────────────────┘
│ (ZIP, clean) │ (auto-repair + validate)
└───────────────┘
```
### Step-by-step
```bash
# 1. Unpack
python office-custom/scripts/unpack.py document.docx unpacked/
# 2. Edit XML directly — use the Edit tool on files inside unpacked/
# e.g. unpacked/word/document.xml, unpacked/word/styles.xml
# 3. Repack
python office-custom/scripts/pack.py unpacked/ output.docx --original document.docx
# Optional: validate manually
python office-custom/scripts/validate.py output.docx
```
---
## OOXML Structure Reference
### Required parts (all formats)
| Path | Purpose |
|------|---------|
| `[Content_Types].xml` | Maps ZIP entry paths to MIME content types |
| `_rels/.rels` | Top-level package relationships (points to main document part) |
### Word document (`.docx`)
| Path | Purpose |
|------|---------|
| `word/document.xml` | Main body — paragraphs, tables, runs |
| `word/styles.xml` | Named styles (Normal, Heading 1, etc.) |
| `word/settings.xml` | Document-level settings (compatibility, rsid tracking) |
| `word/numbering.xml` | List/bullet numbering definitions |
| `word/fontTable.xml` | Font declarations |
| `word/comments.xml` | Comment bodies (created by `docx-custom/scripts/comment.py`) |
| `word/theme/theme1.xml` | Colour and font theme |
| `word/media/` | Embedded images and other binary assets |
| `word/_rels/document.xml.rels` | Relationships for document.xml |
### PowerPoint presentation (`.pptx`)
| Path | Purpose |
|------|---------|
| `ppt/presentation.xml` | Presentation-level metadata and slide list |
| `ppt/slides/slide1.xml` | Individual slide content |
| `ppt/slideLayouts/slideLayout1.xml` | Layout templates |
| `ppt/slideMasters/slideMaster1.xml` | Master slide |
| `ppt/theme/theme1.xml` | Colour and font theme |
| `ppt/media/` | Embedded images |
### Excel workbook (`.xlsx`)
| Path | Purpose |
|------|---------|
| `xl/workbook.xml` | Sheet list and workbook metadata |
| `xl/worksheets/sheet1.xml` | Individual sheet data and formulas |
| `xl/styles.xml` | Cell formatting |
| `xl/sharedStrings.xml` | String table (shared across all cells) |
| `xl/calcChain.xml` | Formula calculation order |
| `xl/theme/theme1.xml` | Colour and font theme |
---
## Common XML Namespaces
| Prefix | URI | Used in |
|--------|-----|---------|
| `w:` | `http://schemas.openxmlformats.org/wordprocessingml/2006/main` | `.docx` content |
| `a:` | `http://schemas.openxmlformats.org/drawingml/2006/main` | Drawing (all formats) |
| `r:` | `http://schemas.openxmlformats.org/officeDocument/2006/relationships` | Relationships |
| `p:` | `http://schemas.openxmlformats.org/presentationml/2006/main` | `.pptx` content |
| `x:` | `http://schemas.openxmlformats.org/spreadsheetml/2006/main` | `.xlsx` content |
| `mc:` | `http://schemas.openxmlformats.org/markup-compatibility/2006` | Markup compatibility |
| `w14:` | `http://schemas.microsoft.com/office/word/2010/wordml` | Word 2010+ extensions |
---
## Auto-repair Details
`pack.py` repairs two common issues automatically:
### 1. Out-of-range `w:durableId`
Word assigns `durableId` values (persistent run identifiers) as 31-bit integers.
Values ≥ `0x7FFFFFFF` (2,147,483,648) are invalid and cause Word 2016+ to refuse
to open the file. These appear when content is copy-pasted from malformed documents.
**Fix:** Replace any out-of-range value with a random valid integer in `[1, 0x7FFFFFFE]`.
### 2. Missing `xml:space="preserve"` on `<w:t>`
The XML spec says parsers may strip leading/trailing whitespace from text nodes
unless `xml:space="preserve"` is present. Word relies on this for spaces between
runs (e.g. `"Hello " + "world"`).
**Fix:** Add `xml:space="preserve"` to any `<w:t>` whose text starts or ends with a space.
---
## Smart Quote Entities
When editing XML directly, use these XML entities instead of Unicode characters
to prevent encoding corruption:
| Character | Entity | Description |
|-----------|--------|-------------|
| `"` | `“` | Left double quotation mark |
| `"` | `”` | Right double quotation mark |
| `'` | `‘` | Left single quotation mark |
| `'` | `’` | Right single quotation mark / apostrophe |
| `–` | `–` | En dash |
| `—` | `—` | Em dash |
| | ` ` | Non-breaking space |
`unpack.py` converts these automatically on extraction; `pack.py` preserves them.
---
## Dependencies
| Tool | Install | Used by |
|------|---------|---------|
| LibreOffice | `brew install --cask libreoffice` / `apt install libreoffice` | `soffice.py` |
| Python stdlib | (built-in) | `unpack.py`, `pack.py`, `validate.py` |
No third-party Python packages are required for the office utilities themselves.
---
## See Also
- **`$raw-document`** — specification-level reference for when the unpack/repack/validate
cycle surfaces XML errors that require looking up OOXML or ODF schemas, namespace
definitions, or element-level specification details.