Files

570 lines
19 KiB
Python

"""Read SmartArt content and structure from DrawingML diagram parts.
The reader exposes source facts only. It does not edit DiagramML or promise
native SmartArt regeneration; generated decks continue to redraw the extracted
content through the SVG-to-DrawingML shape pipeline.
"""
from __future__ import annotations
import zipfile
from typing import Any
from xml.etree import ElementTree as ET
from .ooxml import (
MC_NS,
NS,
_container_geometry,
_normalize_part,
_paragraph_texts,
_qn,
_read_xml,
_rels_name_for_part,
_shape_identity,
_slide_relationships,
)
DIAGRAM_NS = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
DIAGRAM_DRAWING_NS = "http://schemas.microsoft.com/office/drawing/2008/diagram"
DIAGRAM_URI = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
_DIAGRAM_REL_SUFFIXES = {
"colors": "diagramColors",
"data": "diagramData",
"layout": "diagramLayout",
"quick_style": "diagramQuickStyle",
}
_REL_ATTRS = {
"colors": "cs",
"data": "dm",
"layout": "lo",
"quick_style": "qs",
}
_CONTENT_POINT_TYPES = {"asst", "node"}
DIAGRAM_NS_MAP = {
**NS,
"dgm": DIAGRAM_NS,
"dsp": DIAGRAM_DRAWING_NS,
"mc": MC_NS,
}
def _relationship_matches(rel_type: str, suffix: str) -> bool:
return rel_type.rsplit("/", 1)[-1] == suffix
def _read_optional_xml(
zf: zipfile.ZipFile,
part_name: str | None,
) -> tuple[ET.Element | None, str | None]:
if not part_name:
return None, "missing-part-reference"
try:
return _read_xml(zf, part_name), None
except RuntimeError:
return None, "missing-part"
except ET.ParseError:
return None, "invalid-xml"
def _diagram_containers(slide_root: ET.Element) -> list[ET.Element]:
containers: list[ET.Element] = []
for frame in slide_root.findall(".//p:graphicFrame", DIAGRAM_NS_MAP):
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
if graphic_data is not None and graphic_data.attrib.get("uri") == DIAGRAM_URI:
containers.append(frame)
return containers
def _fallback_preview_shape_ids(slide_root: ET.Element) -> set[str]:
shape_ids: set[str] = set()
for alternate in slide_root.findall(".//mc:AlternateContent", DIAGRAM_NS_MAP):
frame = alternate.find("mc:Choice//p:graphicFrame", DIAGRAM_NS_MAP)
if frame is None:
continue
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
if graphic_data is None or graphic_data.attrib.get("uri") != DIAGRAM_URI:
continue
if alternate.find("mc:Fallback//p:pic", DIAGRAM_NS_MAP) is None:
continue
shape_id, _shape_name = _shape_identity(frame, len(shape_ids) + 1)
shape_ids.add(shape_id)
return shape_ids
def _diagram_parts(
rel_ids: ET.Element | None,
relationships: dict[str, dict[str, str]],
slide_part: str,
) -> tuple[dict[str, str], str | None]:
if rel_ids is None:
return {}, "missing-rel-ids"
parts: dict[str, str] = {}
data_error: str | None = None
for key, attr_name in _REL_ATTRS.items():
rel_id = rel_ids.attrib.get(_qn(NS["r"], attr_name), "")
if not rel_id:
if key == "data":
data_error = "missing-data-relationship"
continue
relationship = relationships.get(rel_id)
if relationship is None:
if key == "data":
data_error = "missing-data-relationship"
continue
if not _relationship_matches(
relationship.get("type", ""),
_DIAGRAM_REL_SUFFIXES[key],
):
if key == "data":
data_error = "invalid-data-relationship"
continue
parts[key] = _normalize_part(relationship["target"], slide_part)
return parts, data_error
def _persisted_drawing_part(
zf: zipfile.ZipFile,
data_part: str | None,
data_root: ET.Element | None,
slide_part: str,
slide_relationships: dict[str, dict[str, str]],
) -> tuple[str | None, list[str]]:
if not data_part or data_root is None:
return None, []
data_model_ext = data_root.find(".//dsp:dataModelExt", DIAGRAM_NS_MAP)
rel_id = data_model_ext.attrib.get("relId", "") if data_model_ext is not None else ""
if not rel_id:
return None, []
warnings: list[str] = []
relationship = slide_relationships.get(rel_id)
relationship_owner = slide_part
if relationship is not None and not _relationship_matches(
relationship.get("type", ""),
"diagramDrawing",
):
relationship = None
if relationship is None:
try:
data_relationships = _slide_relationships(zf, _rels_name_for_part(data_part))
except ET.ParseError:
data_relationships = {}
warnings.append("invalid-persisted-drawing-relationships")
relationship = data_relationships.get(rel_id)
relationship_owner = data_part
if relationship is not None and not _relationship_matches(
relationship.get("type", ""),
"diagramDrawing",
):
relationship = None
if relationship is None:
warnings.append("unresolved-persisted-drawing-relationship")
return None, warnings
part_name = _normalize_part(relationship["target"], relationship_owner)
if part_name not in zf.namelist():
warnings.append("missing-persisted-drawing-part")
return None, warnings
return part_name, warnings
def _layout_info(
layout_root: ET.Element | None,
data_root: ET.Element | None,
) -> dict[str, Any]:
unique_id = layout_root.attrib.get("uniqueId") if layout_root is not None else None
if not unique_id and data_root is not None:
document_properties = data_root.find(
".//dgm:pt[@type='doc']/dgm:prSet",
DIAGRAM_NS_MAP,
)
if document_properties is not None:
unique_id = document_properties.attrib.get("loTypeId")
title = layout_root.find("dgm:title", DIAGRAM_NS_MAP) if layout_root is not None else None
name = title.attrib.get("val") if title is not None else None
if not name and unique_id:
name = unique_id.rstrip("/").rsplit("/", 1)[-1]
categories = (
[
category.attrib["type"]
for category in layout_root.findall("dgm:catLst/dgm:cat", DIAGRAM_NS_MAP)
if category.attrib.get("type")
]
if layout_root is not None
else []
)
return {
"name": name,
"unique_id": unique_id,
"categories": categories,
}
def _integer_or_none(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def _point_text(point: ET.Element) -> str:
text_body = point.find("dgm:t", DIAGRAM_NS_MAP)
if text_body is None:
return ""
return "\n".join(_paragraph_texts(text_body)).strip()
def _connections(data_root: ET.Element) -> list[dict[str, Any]]:
connections: list[dict[str, Any]] = []
for connection in data_root.findall(
".//dgm:cxnLst/dgm:cxn",
DIAGRAM_NS_MAP,
):
source_id = connection.attrib.get("srcId", "")
destination_id = connection.attrib.get("destId", "")
if not source_id or not destination_id:
continue
connections.append(
{
"type": connection.attrib.get("type") or "parOf",
"source_id": source_id,
"destination_id": destination_id,
"source_order": _integer_or_none(connection.attrib.get("srcOrd")),
"destination_order": _integer_or_none(connection.attrib.get("destOrd")),
}
)
return connections
def _nearest_content_parent(
node_id: str,
parent_by_id: dict[str, str],
content_ids: set[str],
) -> str | None:
current = parent_by_id.get(node_id)
visited = {node_id}
while current:
if current in content_ids:
return current
if current in visited:
return None
visited.add(current)
current = parent_by_id.get(current)
return None
def _break_parent_cycles(
parent_by_node: dict[str, str | None],
source_order: dict[str, int],
) -> list[str]:
"""Break cycles in the Markdown tree projection while retaining raw connections."""
warnings: list[str] = []
for start_id in sorted(parent_by_node, key=lambda node_id: source_order[node_id]):
path: list[str] = []
path_index: dict[str, int] = {}
current_id: str | None = start_id
while current_id is not None and current_id in parent_by_node:
if current_id in path_index:
cycle = path[path_index[current_id] :]
root_id = min(cycle, key=lambda node_id: source_order[node_id])
parent_by_node[root_id] = None
warnings.append(f"parent-cycle-broken-at:{root_id}")
break
path_index[current_id] = len(path)
path.append(current_id)
current_id = parent_by_node.get(current_id)
return warnings
def _ordered_nodes(
data_root: ET.Element,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
raw_points: list[dict[str, Any]] = []
for source_index, point in enumerate(
data_root.findall(".//dgm:ptLst/dgm:pt", DIAGRAM_NS_MAP),
):
model_id = point.attrib.get("modelId", "")
point_type = point.attrib.get("type") or "node"
text = _point_text(point)
if not model_id or point_type not in _CONTENT_POINT_TYPES:
continue
raw_points.append(
{
"id": model_id,
"type": point_type,
"text": text,
"source_index": source_index,
}
)
all_connections = _connections(data_root)
parent_by_id: dict[str, str] = {}
order_by_id: dict[str, int | None] = {}
for connection in all_connections:
if connection["type"] != "parOf":
continue
destination_id = connection["destination_id"]
parent_by_id.setdefault(destination_id, connection["source_id"])
order_by_id.setdefault(destination_id, connection["source_order"])
content_ids = {point["id"] for point in raw_points}
source_order = {point["id"]: int(point["source_index"]) for point in raw_points}
parent_by_node = {
point["id"]: _nearest_content_parent(
point["id"],
parent_by_id,
content_ids,
)
for point in raw_points
}
structure_warnings = _break_parent_cycles(parent_by_node, source_order)
nodes_by_id: dict[str, dict[str, Any]] = {}
for point in raw_points:
node = {
"id": point["id"],
"type": point["type"],
"text": point["text"],
"parent_id": parent_by_node[point["id"]],
"order": order_by_id.get(point["id"]),
"depth": 0,
"_source_index": point["source_index"],
}
nodes_by_id[point["id"]] = node
children: dict[str | None, list[dict[str, Any]]] = {}
for node in nodes_by_id.values():
children.setdefault(node["parent_id"], []).append(node)
def sort_key(node: dict[str, Any]) -> tuple[int, int]:
order = node["order"]
return (
order if isinstance(order, int) else 1_000_000,
int(node["_source_index"]),
)
for siblings in children.values():
siblings.sort(key=sort_key)
ordered: list[dict[str, Any]] = []
visited: set[str] = set()
def visit(node: dict[str, Any], depth: int) -> None:
node_id = str(node["id"])
if node_id in visited:
return
visited.add(node_id)
node["depth"] = depth
ordered.append(node)
for child in children.get(node_id, []):
visit(child, depth + 1)
for root in children.get(None, []):
visit(root, 0)
for node in sorted(nodes_by_id.values(), key=sort_key):
if node["id"] not in visited:
visit(node, 0)
for node in ordered:
node.pop("_source_index", None)
visible_connections = [
connection
for connection in all_connections
if connection["type"] == "parOf"
and connection["source_id"] in content_ids
and connection["destination_id"] in content_ids
]
return ordered, visible_connections, structure_warnings
def _read_diagram_container(
zf: zipfile.ZipFile,
container: ET.Element,
*,
slide_part: str,
slide_index: int,
order: int,
relationships: dict[str, dict[str, str]],
relationship_error: str | None,
fallback_shape_ids: set[str],
) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(container, order)
graphic_data = container.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
rel_ids = (
graphic_data.find("dgm:relIds", DIAGRAM_NS_MAP)
if graphic_data is not None
else None
)
parts, diagram_relation_error = _diagram_parts(
rel_ids,
relationships,
slide_part,
)
data_root, data_error = _read_optional_xml(zf, parts.get("data"))
layout_root, layout_error = _read_optional_xml(zf, parts.get("layout"))
metadata_warnings = [f"layout:{layout_error}"] if layout_error else []
nodes: list[dict[str, Any]] = []
connections: list[dict[str, Any]] = []
structure_warnings: list[str] = []
if data_root is not None:
nodes, connections, structure_warnings = _ordered_nodes(data_root)
status = relationship_error or diagram_relation_error or data_error
if status is None and structure_warnings:
status = "structure-cycle"
persisted_drawing, drawing_warnings = _persisted_drawing_part(
zf,
parts.get("data"),
data_root,
slide_part,
relationships,
)
text_items = [node["text"] for node in nodes if node["text"]]
return {
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"layout": _layout_info(layout_root, data_root),
"root_ids": [node["id"] for node in nodes if node["parent_id"] is None],
"nodes": nodes,
"connections": connections,
"text_items": text_items,
"node_count": len(nodes),
"text_count": len(text_items),
"connection_count": len(connections),
"max_depth": max((int(node["depth"]) for node in nodes), default=0),
"text_extracted": data_root is not None,
"has_persisted_drawing": persisted_drawing is not None,
"has_fallback_preview": shape_id in fallback_shape_ids,
"status": status or "ok",
"warnings": metadata_warnings + structure_warnings + drawing_warnings,
}
def _failed_diagram(
container: ET.Element,
*,
slide_index: int,
order: int,
error: Exception,
fallback_shape_ids: set[str],
) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(container, order)
return {
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"layout": {"name": None, "unique_id": None, "categories": []},
"root_ids": [],
"nodes": [],
"connections": [],
"text_items": [],
"node_count": 0,
"text_count": 0,
"connection_count": 0,
"max_depth": 0,
"text_extracted": False,
"has_persisted_drawing": False,
"has_fallback_preview": shape_id in fallback_shape_ids,
"status": "diagram-read-error",
"warnings": [f"{type(error).__name__}:{error}"],
}
def read_smartart_diagrams(
zf: zipfile.ZipFile,
slide_part: str,
slide_index: int,
) -> list[dict[str, Any]]:
"""Return SmartArt source facts for one slide part."""
slide_root = _read_xml(zf, slide_part)
relationship_error: str | None = None
try:
relationships = _slide_relationships(zf, _rels_name_for_part(slide_part))
except ET.ParseError:
relationships = {}
relationship_error = "invalid-slide-relationships"
fallback_shape_ids = _fallback_preview_shape_ids(slide_root)
diagrams: list[dict[str, Any]] = []
for order, container in enumerate(_diagram_containers(slide_root), start=1):
try:
diagram = _read_diagram_container(
zf,
container,
slide_part=slide_part,
slide_index=slide_index,
order=order,
relationships=relationships,
relationship_error=relationship_error,
fallback_shape_ids=fallback_shape_ids,
)
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError, KeyError, ValueError) as exc:
diagram = _failed_diagram(
container,
slide_index=slide_index,
order=order,
error=exc,
fallback_shape_ids=fallback_shape_ids,
)
diagrams.append(diagram)
return diagrams
def smartart_to_markdown(diagram: dict[str, Any]) -> str:
"""Render one extracted SmartArt diagram as hierarchical Markdown."""
name = str(diagram.get("shape_name") or diagram.get("diagram_id") or "SmartArt")
layout = diagram.get("layout") or {}
layout_name = str(layout.get("name") or "") if isinstance(layout, dict) else ""
heading = f"### SmartArt: {name}"
if layout_name and layout_name.lower() not in name.lower():
heading += f" — {layout_name}"
lines = [heading, ""]
nodes = diagram.get("nodes") or []
text_nodes = [node for node in nodes if str(node.get("text") or "").strip()]
if text_nodes:
nodes_by_id = {
str(node.get("id")): node
for node in nodes
if node.get("id") is not None
}
rendered_depths: dict[str, int] = {}
def rendered_depth(node: dict[str, Any]) -> int:
node_id = str(node.get("id") or "")
if node_id in rendered_depths:
return rendered_depths[node_id]
parent = nodes_by_id.get(str(node.get("parent_id") or ""))
if parent is None:
depth = 0
else:
depth = rendered_depth(parent)
if str(parent.get("text") or "").strip():
depth += 1
rendered_depths[node_id] = depth
return depth
for node in text_nodes:
text = " / ".join(str(node.get("text") or "").splitlines()).strip()
indent = " " * rendered_depth(node)
lines.append(f"{indent}- {text}")
elif nodes:
lines.append(f"> [SmartArt structure has {len(nodes)} node(s), but no text]")
else:
status = str(diagram.get("status") or "content-unavailable")
if diagram.get("text_extracted") and status == "ok":
lines.append("> [SmartArt data is readable but has no semantic nodes]")
else:
lines.append(f"> [SmartArt content unavailable: {status}]")
return "\n".join(lines).rstrip()