446 lines
16 KiB
Python
446 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ABOUTME: Shared drawing/image extraction utilities for DOCX parsing and editing
|
|
ABOUTME: Resolves w:drawing -> a:blip relationships, exports embedded images, builds placeholders
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
import re
|
|
import shutil
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from html import escape, unescape
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Dict, Optional, Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
try:
|
|
from defusedxml import ElementTree as ET
|
|
except ImportError: # pragma: no cover
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
NS = {
|
|
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
|
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
"v": "urn:schemas-microsoft-com:vml",
|
|
}
|
|
|
|
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
CONTENT_TYPE_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
IMAGE_REL_TYPE = (
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
|
|
)
|
|
SOURCE_DOCUMENT_PART = "/word/document.xml"
|
|
|
|
# Match old and new drawing placeholders (requires id/name, allows extra attributes)
|
|
DRAWING_PATTERN = re.compile(
|
|
r'<drawing\b(?=[^>]*\bid="[^"]*")(?=[^>]*\bname="[^"]*")[^>]*/>'
|
|
)
|
|
DRAWING_TAG_PATTERN = re.compile(r"<drawing\b[^>]*/>")
|
|
DRAWING_ATTR_PATTERN = re.compile(r'([a-zA-Z_][\w:.-]*)="([^"]*)"')
|
|
|
|
|
|
@dataclass
|
|
class DrawingRelationship:
|
|
"""Relationship metadata for a single relationship ID."""
|
|
|
|
rel_id: str
|
|
target: str
|
|
target_mode: str
|
|
rel_type: str
|
|
part_name: Optional[str] = None
|
|
content_type: Optional[str] = None
|
|
image_format: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class DrawingExtractionContext:
|
|
"""Context used to resolve and export drawing images for one DOCX file."""
|
|
|
|
docx_path: Path
|
|
blocks_output_path: Optional[Path] = None
|
|
export_dir_name: Optional[str] = None
|
|
export_dir_path: Optional[Path] = None
|
|
relationships: Dict[str, DrawingRelationship] = field(default_factory=dict)
|
|
_exported_part_to_relpath: Dict[str, str] = field(default_factory=dict)
|
|
_used_filenames: Dict[str, str] = field(default_factory=dict)
|
|
|
|
def resolve_relationship(self, rel_id: str) -> Optional[DrawingRelationship]:
|
|
return self.relationships.get(rel_id)
|
|
|
|
def export_embedded_image(self, rel: DrawingRelationship) -> Optional[str]:
|
|
"""
|
|
Export an embedded image relationship target to export_dir.
|
|
|
|
Returns:
|
|
Relative path like "<blocks_stem>.image/image1.png" if exported,
|
|
or None when export is not applicable.
|
|
"""
|
|
if not self.export_dir_path or not self.export_dir_name:
|
|
return None
|
|
if rel.target_mode.lower() == "external":
|
|
return None
|
|
if not rel.part_name:
|
|
return None
|
|
if rel.part_name in self._exported_part_to_relpath:
|
|
return self._exported_part_to_relpath[rel.part_name]
|
|
|
|
zip_member = rel.part_name.lstrip("/")
|
|
try:
|
|
with zipfile.ZipFile(self.docx_path, "r") as zf:
|
|
blob = zf.read(zip_member)
|
|
except Exception:
|
|
return None
|
|
|
|
filename = self._dedupe_filename(PurePosixPath(rel.part_name).name or "image")
|
|
output_file = self.export_dir_path / filename
|
|
output_file.write_bytes(blob)
|
|
|
|
rel_path = str(PurePosixPath(self.export_dir_name) / filename)
|
|
self._exported_part_to_relpath[rel.part_name] = rel_path
|
|
return rel_path
|
|
|
|
def _dedupe_filename(self, base_name: str) -> str:
|
|
if base_name not in self._used_filenames:
|
|
self._used_filenames[base_name] = base_name
|
|
return base_name
|
|
|
|
stem = Path(base_name).stem
|
|
suffix = Path(base_name).suffix
|
|
index = 2
|
|
while True:
|
|
candidate = f"{stem}_{index}{suffix}"
|
|
if candidate not in self._used_filenames:
|
|
self._used_filenames[candidate] = candidate
|
|
return candidate
|
|
index += 1
|
|
|
|
|
|
def _normalize_image_format(ext_or_type: str) -> Optional[str]:
|
|
if not ext_or_type:
|
|
return None
|
|
value = ext_or_type.strip().lower()
|
|
|
|
# Content-Type
|
|
if value.startswith("image/"):
|
|
value = value.split("/", 1)[1]
|
|
if "+" in value:
|
|
value = value.split("+", 1)[0]
|
|
if value.startswith("x-"):
|
|
value = value[2:]
|
|
|
|
# Extension (with or without leading dot)
|
|
value = value.lstrip(".")
|
|
if value == "jpg":
|
|
return "jpeg"
|
|
if value in {"jpeg", "png", "gif", "bmp", "tiff", "webp", "svg", "emf", "wmf"}:
|
|
return value
|
|
return value or None
|
|
|
|
|
|
def _infer_format_from_target(target: str) -> Optional[str]:
|
|
if not target:
|
|
return None
|
|
parsed = urlparse(target)
|
|
path = parsed.path if parsed.scheme else target
|
|
suffix = PurePosixPath(path).suffix
|
|
return _normalize_image_format(suffix)
|
|
|
|
|
|
def _resolve_part_name(source_part_name: str, target: str) -> str:
|
|
if target.startswith("/"):
|
|
return posixpath.normpath(target)
|
|
source_dir = posixpath.dirname(source_part_name)
|
|
joined = posixpath.join(source_dir, target)
|
|
normalized = posixpath.normpath(joined)
|
|
if not normalized.startswith("/"):
|
|
normalized = "/" + normalized
|
|
return normalized
|
|
|
|
|
|
def create_drawing_context(
|
|
docx_path: str,
|
|
blocks_output_path: Optional[str] = None,
|
|
) -> DrawingExtractionContext:
|
|
"""
|
|
Create extraction context for a DOCX file.
|
|
|
|
If blocks_output_path is provided, this also prepares `<blocks_stem>.image/`
|
|
beside the blocks file and clears any previous content.
|
|
"""
|
|
docx_file = Path(docx_path)
|
|
ctx = DrawingExtractionContext(docx_path=docx_file)
|
|
|
|
if blocks_output_path:
|
|
output_path = Path(blocks_output_path)
|
|
export_dir_name = f"{output_path.stem}.image"
|
|
export_dir_path = output_path.parent / export_dir_name
|
|
if export_dir_path.exists():
|
|
shutil.rmtree(export_dir_path)
|
|
export_dir_path.mkdir(parents=True, exist_ok=True)
|
|
ctx.blocks_output_path = output_path
|
|
ctx.export_dir_name = export_dir_name
|
|
ctx.export_dir_path = export_dir_path
|
|
|
|
load_relationships(ctx)
|
|
return ctx
|
|
|
|
|
|
def load_relationships(ctx: DrawingExtractionContext) -> None:
|
|
rels_xml = "word/_rels/document.xml.rels"
|
|
content_types_xml = "[Content_Types].xml"
|
|
|
|
overrides: Dict[str, str] = {}
|
|
defaults: Dict[str, str] = {}
|
|
|
|
try:
|
|
with zipfile.ZipFile(ctx.docx_path, "r") as zf:
|
|
if content_types_xml in zf.namelist():
|
|
ct_root = ET.parse(zf.open(content_types_xml)).getroot()
|
|
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Override"):
|
|
part_name = node.get("PartName")
|
|
content_type = node.get("ContentType")
|
|
if part_name and content_type:
|
|
overrides[part_name] = content_type
|
|
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Default"):
|
|
ext = node.get("Extension")
|
|
content_type = node.get("ContentType")
|
|
if ext and content_type:
|
|
defaults[ext.lower()] = content_type
|
|
|
|
if rels_xml not in zf.namelist():
|
|
return
|
|
rels_root = ET.parse(zf.open(rels_xml)).getroot()
|
|
except Exception:
|
|
return
|
|
|
|
for rel in rels_root.findall(f".//{{{REL_NS}}}Relationship"):
|
|
rel_id = rel.get("Id")
|
|
target = rel.get("Target", "")
|
|
target_mode = rel.get("TargetMode", "")
|
|
rel_type = rel.get("Type", "")
|
|
if not rel_id:
|
|
continue
|
|
|
|
part_name = None
|
|
content_type = None
|
|
image_format = None
|
|
|
|
if target_mode.lower() != "external":
|
|
part_name = _resolve_part_name(SOURCE_DOCUMENT_PART, target)
|
|
if part_name:
|
|
content_type = overrides.get(part_name)
|
|
if not content_type:
|
|
ext = PurePosixPath(part_name).suffix.lower().lstrip(".")
|
|
content_type = defaults.get(ext)
|
|
image_format = _normalize_image_format(
|
|
content_type or _infer_format_from_target(part_name)
|
|
)
|
|
else:
|
|
image_format = _normalize_image_format(_infer_format_from_target(target))
|
|
|
|
ctx.relationships[rel_id] = DrawingRelationship(
|
|
rel_id=rel_id,
|
|
target=target,
|
|
target_mode=target_mode,
|
|
rel_type=rel_type,
|
|
part_name=part_name,
|
|
content_type=content_type,
|
|
image_format=image_format,
|
|
)
|
|
|
|
|
|
def _extract_blip_relationship(drawing_elem) -> Optional[Tuple[str, str]]:
|
|
for blip in drawing_elem.findall(".//a:blip", NS):
|
|
# Prefer explicit external links when both link/embed are present on one blip.
|
|
# Word may keep an embedded cache for linked pictures.
|
|
rel_link = blip.get(f"{{{NS['r']}}}link")
|
|
if rel_link:
|
|
return "link", rel_link
|
|
rel_embed = blip.get(f"{{{NS['r']}}}embed")
|
|
if rel_embed:
|
|
return "embed", rel_embed
|
|
return None
|
|
|
|
|
|
def _extract_imagedata_relationship(container_elem) -> Optional[str]:
|
|
"""Find an image relationship id from a w:pict / w:object via v:imagedata.
|
|
|
|
These legacy VML containers are how Word references EMF/WMF metafiles
|
|
(and the rendered preview of any embedded OLE object). v:imagedata uses
|
|
``r:id`` to point at the image part for both embedded and externally
|
|
linked images — the relationship's ``TargetMode`` is what disambiguates
|
|
the two cases, so the caller must inspect the resolved relationship.
|
|
"""
|
|
r_id_attr = f"{{{NS['r']}}}id"
|
|
for imgdata in container_elem.findall(".//v:imagedata", NS):
|
|
rel_id = imgdata.get(r_id_attr)
|
|
if rel_id:
|
|
return rel_id
|
|
return None
|
|
|
|
|
|
def _build_placeholder(attrs: Dict[str, str]) -> str:
|
|
ordered_keys = ["id", "name", "path", "format"]
|
|
pieces = []
|
|
for key in ordered_keys:
|
|
if key in attrs and attrs[key] is not None:
|
|
pieces.append(f'{key}="{escape(str(attrs[key]), quote=True)}"')
|
|
|
|
# Preserve extra attributes deterministically (sorted by name)
|
|
for key in sorted(k for k in attrs.keys() if k not in ordered_keys):
|
|
value = attrs[key]
|
|
if value is not None:
|
|
pieces.append(f'{key}="{escape(str(value), quote=True)}"')
|
|
|
|
return f"<drawing {' '.join(pieces)} />"
|
|
|
|
|
|
def extract_drawing_placeholder_from_element(
|
|
drawing_elem,
|
|
context: Optional[DrawingExtractionContext] = None,
|
|
include_extended_attrs: bool = True,
|
|
) -> str:
|
|
"""
|
|
Build a <drawing ... /> placeholder from a w:drawing element.
|
|
|
|
Behavior:
|
|
- Always emits id/name from wp:docPr when present.
|
|
- For embedded images (a:blip@r:embed): exports image and sets path/format.
|
|
- For linked images (a:blip@r:link): does not download; path is original link target.
|
|
- When no image reference exists (e.g. chart drawing): keeps id/name only.
|
|
"""
|
|
doc_pr = drawing_elem.find(".//wp:docPr", NS)
|
|
attrs = {
|
|
"id": doc_pr.get("id", "") if doc_pr is not None else "",
|
|
"name": doc_pr.get("name", "") if doc_pr is not None else "",
|
|
}
|
|
|
|
if include_extended_attrs:
|
|
rel_ref = _extract_blip_relationship(drawing_elem)
|
|
if rel_ref is not None and context is not None:
|
|
rel_kind, rel_id = rel_ref
|
|
rel = context.resolve_relationship(rel_id)
|
|
if rel is not None:
|
|
if rel_kind == "embed" and rel.rel_type == IMAGE_REL_TYPE:
|
|
rel_path = context.export_embedded_image(rel)
|
|
if rel_path:
|
|
attrs["path"] = rel_path
|
|
if rel.image_format:
|
|
attrs["format"] = rel.image_format
|
|
elif rel_kind == "link":
|
|
if rel.target:
|
|
attrs["path"] = rel.target
|
|
if rel.image_format:
|
|
attrs["format"] = rel.image_format
|
|
|
|
return _build_placeholder(attrs)
|
|
|
|
|
|
def extract_vml_image_placeholder_from_element(
|
|
container_elem,
|
|
context: Optional[DrawingExtractionContext] = None,
|
|
include_extended_attrs: bool = True,
|
|
) -> str:
|
|
"""
|
|
Build a <drawing ... /> placeholder from a w:pict or w:object element.
|
|
|
|
Legacy Word documents and OLE-embedded objects (Visio diagrams, equation
|
|
editor previews, etc.) expose their rendered image via VML rather than
|
|
DrawingML. The image is referenced through ``<v:imagedata r:id="..."/>``
|
|
inside ``<v:shape>``, and the underlying bytes are commonly EMF/WMF
|
|
metafiles. This function exports those bytes through the same context as
|
|
DrawingML images so EMF/WMF assets land in the blocks.assets directory
|
|
alongside PNG/JPEG ones.
|
|
|
|
The output placeholder format matches
|
|
``extract_drawing_placeholder_from_element`` so downstream consumers
|
|
treat both paths uniformly.
|
|
"""
|
|
shape = container_elem.find(".//v:shape", NS)
|
|
attrs = {
|
|
"id": shape.get("id", "") if shape is not None else "",
|
|
"name": shape.get("alt", "") if shape is not None else "",
|
|
}
|
|
|
|
if include_extended_attrs:
|
|
rel_id = _extract_imagedata_relationship(container_elem)
|
|
if rel_id and context is not None:
|
|
rel = context.resolve_relationship(rel_id)
|
|
if rel is not None and rel.rel_type == IMAGE_REL_TYPE:
|
|
# VML reuses r:id for both embedded image parts and externally
|
|
# linked images; only the resolved TargetMode tells us which.
|
|
# Treating an external relationship as embedded would call
|
|
# export_embedded_image() (which short-circuits on external)
|
|
# and silently drop the linked path.
|
|
if rel.target_mode.lower() == "external":
|
|
if rel.target:
|
|
attrs["path"] = rel.target
|
|
if rel.image_format:
|
|
attrs["format"] = rel.image_format
|
|
else:
|
|
rel_path = context.export_embedded_image(rel)
|
|
if rel_path:
|
|
attrs["path"] = rel_path
|
|
if rel.image_format:
|
|
attrs["format"] = rel.image_format
|
|
|
|
return _build_placeholder(attrs)
|
|
|
|
|
|
def parse_drawing_attributes(placeholder: str) -> Dict[str, str]:
|
|
"""Parse attributes from a <drawing ... /> placeholder."""
|
|
return {
|
|
name: unescape(value)
|
|
for name, value in DRAWING_ATTR_PATTERN.findall(placeholder)
|
|
}
|
|
|
|
|
|
def normalize_drawing_placeholder(
|
|
placeholder: str,
|
|
include_extended_attrs: bool = False,
|
|
) -> str:
|
|
"""
|
|
Normalize one drawing placeholder into canonical attribute order.
|
|
|
|
Args:
|
|
placeholder: Input placeholder string
|
|
include_extended_attrs: If False, keeps only id/name.
|
|
"""
|
|
attrs = parse_drawing_attributes(placeholder)
|
|
normalized = {
|
|
"id": attrs.get("id", ""),
|
|
"name": attrs.get("name", ""),
|
|
}
|
|
if include_extended_attrs:
|
|
if "path" in attrs:
|
|
normalized["path"] = attrs["path"]
|
|
if "format" in attrs:
|
|
normalized["format"] = attrs["format"]
|
|
for key, value in attrs.items():
|
|
if key not in {"id", "name", "path", "format"}:
|
|
normalized[key] = value
|
|
return _build_placeholder(normalized)
|
|
|
|
|
|
def normalize_drawing_placeholders_in_text(
|
|
text: str,
|
|
include_extended_attrs: bool = False,
|
|
) -> str:
|
|
"""Normalize all drawing placeholders inside a text blob."""
|
|
if not text:
|
|
return text
|
|
|
|
def _replace(match: re.Match) -> str:
|
|
return normalize_drawing_placeholder(
|
|
match.group(0),
|
|
include_extended_attrs=include_extended_attrs,
|
|
)
|
|
|
|
return DRAWING_TAG_PATTERN.sub(_replace, text)
|