mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
Merge branch 'main' into fix/74-layer-a-docx-odt
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user