import os import posixpath import re import stat import tempfile import urllib.parse import zipfile from pathlib import Path OOXML_FAMILY = { ".docx": "docx", ".dotx": "docx", ".pptx": "pptx", ".potx": "pptx", ".xlsx": "xlsx", ".xltx": "xlsx", } _SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: if not target: return None if target_mode.lower() == "external": return None if _SCHEME_RE.match(target): return None target = urllib.parse.unquote(target) if "\\" in target: raise ValueError(f"relationship target is not a POSIX part name: {target!r}") if target.startswith("/"): joined = target.lstrip("/") else: joined = posixpath.join(posixpath.dirname(source_part), target) parts: list[str] = [] for segment in posixpath.normpath(joined).split("/"): if segment in ("", "."): continue if segment == "..": if not parts: raise ValueError(f"relationship target escapes the package: {target!r}") parts.pop() else: parts.append(segment) if not parts: raise ValueError(f"relationship target resolves to nothing: {target!r}") return "/".join(parts) def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") def part_text(data: bytes) -> str: return data.decode("utf-8", "surrogateescape") XML_SPACE = " \t\r\n" def rendered_text(text: str, preserve: bool) -> str: return text if preserve else text.strip(XML_SPACE) def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: dest = dest.resolve() for m in zf.infolist(): if stat.S_ISLNK(m.external_attr >> 16): raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") target = (dest / m.filename).resolve() if not target.is_relative_to(dest): raise ValueError(f"unsafe archive entry: {m.filename!r}") zf.extract(m, dest) def rezip(src_dir: Path, out_path: Path) -> None: files = sorted(p for p in src_dir.rglob("*") if p.is_file()) ct = src_dir / "[Content_Types].xml" fd, tmp_name = tempfile.mkstemp( prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent ) tmp_out = Path(tmp_name) try: with os.fdopen(fd, "wb") as fh: with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: if ct.exists(): zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) for f in files: if f == ct: continue zf.write(f, f.relative_to(src_dir)) if out_path.exists(): mode = out_path.stat().st_mode & 0o777 else: umask = os.umask(0) os.umask(umask) mode = 0o666 & ~umask os.chmod(tmp_out, mode) os.replace(tmp_out, out_path) finally: if tmp_out.exists(): tmp_out.unlink()