Merge branch 'main' into fix/74-layer-a-docx-odt

This commit is contained in:
Guillaume Meyer (The Opinionated Man)
2026-08-15 15:19:23 -07:00
2 changed files with 133 additions and 4 deletions
+60 -4
View File
@@ -7,6 +7,7 @@ Stdlib-first; PDF prefers optional exiftool/c2patool when present.
from __future__ import annotations
import io
import posixpath
import re
import subprocess
import zipfile
@@ -548,15 +549,53 @@ def _scrub_odt_text(xml_text: str) -> tuple[str, int, int]:
return new, removed, replaced
def _prune_dangling_relationships(
rels_name: str, raw: bytes, kept_names: set[str]
) -> tuple[bytes, int]:
"""Drop <Relationship> entries whose internal target part no longer exists.
Removing a part (e.g. a customXml tree) must also remove the relationships
that point at it, or the package is malformed: python-docx refuses to open
it and Word offers to repair it. External relationships (``TargetMode``)
and the package root (``Target="/"``) are left alone. ``rels_name`` is the
archive member like ``word/_rels/document.xml.rels``; ``kept_names`` is the
set of archive members that survive cleaning.
"""
base = posixpath.dirname(posixpath.dirname(rels_name))
text = raw.decode("utf-8", errors="replace")
dropped = [0]
def _target_attr(tag: str) -> str:
m = re.search(r'\bTarget\s*=\s*"([^"]*)"', tag, re.I)
return m.group(1) if m else ""
def _drop(m: re.Match[str]) -> str:
tag = m.group(0)
if re.search(r"\bTargetMode\s*=", tag, re.I):
return tag # external (http / mailto / ...) — never pruned
target = _target_attr(tag)
if target.startswith("/"):
resolved = posixpath.normpath(target.lstrip("/"))
else:
resolved = posixpath.normpath(posixpath.join(base, target))
if resolved in ("", "."):
return tag # points at the package root
if resolved in kept_names:
return tag
dropped[0] += 1
return ""
new = re.sub(r"<Relationship\b[^>]*/>", _drop, text, flags=re.I)
return new.encode("utf-8"), dropped[0]
def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, list[str]]:
actions: list[str] = []
out_buf = io.BytesIO()
budget = [0]
layer_removed = 0
layer_replaced = 0
with zipfile.ZipFile(io.BytesIO(data)) as zin, zipfile.ZipFile(
out_buf, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
kept: list[tuple[zipfile.ZipInfo, bytes]] = []
with zipfile.ZipFile(io.BytesIO(data)) as zin:
for info in zin.infolist():
name = info.filename
_check_zip_budget(info, budget)
@@ -634,6 +673,23 @@ def clean_docx(data: bytes, *, also_layer_a_text: bool = True) -> tuple[bytes, l
layer_removed += r
layer_replaced += rp
raw = new.encode("utf-8")
kept.append((info, raw))
# Removing parts must not leave relationships pointing at them: prune every
# rels member against the set of parts that actually survive.
kept_names = {info.filename for info, _ in kept}
final: list[tuple[zipfile.ZipInfo, bytes]] = []
for info, raw in kept:
if info.filename.endswith(".rels"):
new_raw, n = _prune_dangling_relationships(info.filename, raw, kept_names)
if n:
actions.append(f"prune dangling relationships x{n} in {info.filename}")
raw = new_raw
final.append((info, raw))
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for info, raw in final:
zout.writestr(info, raw)
if layer_removed or layer_replaced:
actions.append(f"layer A text: removed={layer_removed} replaced={layer_replaced}")
+73
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import io
import posixpath
import re
import sys
import zipfile
from pathlib import Path
@@ -204,6 +206,77 @@ def test_docx_strips_app_and_customxml(tmp_path: Path):
assert "Claude" not in app
def _dangling_rels(zip_bytes: bytes) -> list[str]:
"""Return every internal relationship whose target part is missing."""
bad: list[str] = []
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = set(zf.namelist())
for rels in (n for n in names if n.endswith(".rels")):
base = posixpath.dirname(posixpath.dirname(rels))
text = zf.read(rels).decode()
for m in re.finditer(
r'<Relationship\b[^>]*Target="([^"]*)"[^>]*/>', text, re.I
):
target, tag = m.group(1), m.group(0)
if re.search(r"\bTargetMode\s*=", tag, re.I):
continue # external
if target.startswith("/"):
resolved = posixpath.normpath(target.lstrip("/"))
else:
resolved = posixpath.normpath(posixpath.join(base, target))
if resolved not in ("", ".") and resolved not in names:
bad.append(f"{rels} -> {target}")
return bad
def _make_docx_with_rels() -> bytes:
"""DOCX whose document rels reference customXml, a kept part and a URL."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
"[Content_Types].xml",
"""<?xml version="1.0"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/customXml/item1.xml" ContentType="application/xml"/>
</Types>""",
)
zf.writestr(
"word/document.xml",
'<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Hello</w:t></w:r></w:p></w:body></w:document>',
)
zf.writestr(
"customXml/item1.xml",
'<?xml version="1.0"?><root>c2pa contentcredentials</root>',
)
zf.writestr(
"word/_rels/document.xml.rels",
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/" TargetMode="External"/>
</Relationships>""",
)
return buf.getvalue()
def test_docx_dropped_customxml_prunes_dangling_relationships():
data = _make_docx_with_rels()
assert _dangling_rels(data) == []
cleaned, actions = clean_docx(data)
with zipfile.ZipFile(io.BytesIO(cleaned)) as zf:
names = zf.namelist()
assert not any(n.startswith("customXml/") for n in names)
rels = zf.read("word/_rels/document.xml.rels").decode()
assert "../customXml/item1.xml" not in rels
assert 'Target="document.xml"' in rels
assert 'TargetMode="External"' in rels
assert _dangling_rels(cleaned) == []
assert any("prune dangling relationships" in a for a in actions)
def _make_docx_with_body_text(body_text: str = "Claude wrote this.") -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf: