| 1 | """Analyze a PPTX as reusable text, table, chart, and SmartArt source facts.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import zipfile |
| 6 | from pathlib import Path |
| 7 | from typing import Any |
| 8 | from xml.etree import ElementTree as ET |
| 9 | |
| 10 | from .chart_read import empty_chart_data, read_chart_data |
| 11 | from .diagram_read import read_smartart_diagrams |
| 12 | from .edit_safety import ( |
| 13 | _chart_edit_capability, |
| 14 | _chart_frames, |
| 15 | _chart_reference, |
| 16 | _table_cell_merge_info, |
| 17 | _table_merge_topology, |
| 18 | _unsupported_chart_capability, |
| 19 | ) |
| 20 | from .ooxml import ( |
| 21 | CHART_REL_TYPE, |
| 22 | NS, |
| 23 | SlideRef, |
| 24 | _container_geometry, |
| 25 | _emu_to_px, |
| 26 | _normalize_part, |
| 27 | _paragraph_texts, |
| 28 | _parse_slide_refs, |
| 29 | _read_xml, |
| 30 | _shape_identity, |
| 31 | _slide_relationships, |
| 32 | _table_containers, |
| 33 | _text_containers, |
| 34 | ) |
| 35 | |
| 36 | THANKS_KEYWORDS = ("thank", "thanks", "q&a", "qa", "contact", "致谢", "谢谢", "感谢", "答疑", "联系方式") |
| 37 | TOC_KEYWORDS = ("agenda", "contents", "content", "outline", "目录", "议程") |
| 38 | CHAPTER_KEYWORDS = ("chapter", "part", "section", "章节", "部分") |
| 39 | |
| 40 | |
| 41 | def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str, Any]]: |
| 42 | tables: list[dict[str, Any]] = [] |
| 43 | for order, container in enumerate(_table_containers(slide_root), start=1): |
| 44 | shape_id, _shape_name = _shape_identity(container, order) |
| 45 | table = container.find(".//a:tbl", NS) |
| 46 | if table is None: |
| 47 | continue |
| 48 | merge_topology = _table_merge_topology(table) |
| 49 | merge_anchors = { |
| 50 | (int(item["row"]), int(item["col"])): item.get("anchor") |
| 51 | for item in merge_topology["slave_cells"] |
| 52 | } |
| 53 | rows: list[dict[str, Any]] = [] |
| 54 | max_columns = 0 |
| 55 | for row_index, row in enumerate(table.findall("a:tr", NS)): |
| 56 | cells: list[dict[str, Any]] = [] |
| 57 | for col_index, cell in enumerate(row.findall("a:tc", NS)): |
| 58 | merge_info = _table_cell_merge_info(cell) |
| 59 | merge_anchor = merge_anchors.get((row_index, col_index)) |
| 60 | if merge_anchor is not None: |
| 61 | merge_info["merge_anchor"] = merge_anchor |
| 62 | merge_info["anchor_row"] = merge_anchor["row"] |
| 63 | merge_info["anchor_col"] = merge_anchor["col"] |
| 64 | elif merge_info["is_merge_slave"]: |
| 65 | merge_info["anchor_row"] = None |
| 66 | merge_info["anchor_col"] = None |
| 67 | else: |
| 68 | merge_info["anchor_row"] = row_index |
| 69 | merge_info["anchor_col"] = col_index |
| 70 | cells.append( |
| 71 | { |
| 72 | "row": row_index, |
| 73 | "col": col_index, |
| 74 | "text": "\n".join(_paragraph_texts(cell)), |
| 75 | **merge_info, |
| 76 | } |
| 77 | ) |
| 78 | max_columns = max(max_columns, len(cells)) |
| 79 | rows.append({"row": row_index, "cells": cells}) |
| 80 | tables.append( |
| 81 | { |
| 82 | "table_id": f"s{source_slide:02d}_tbl{shape_id}", |
| 83 | "row_count": len(rows), |
| 84 | "column_count": max_columns, |
| 85 | "rows": rows, |
| 86 | "merge_topology": merge_topology, |
| 87 | } |
| 88 | ) |
| 89 | return tables |
| 90 | |
| 91 | |
| 92 | def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: SlideRef) -> list[dict[str, Any]]: |
| 93 | charts: list[dict[str, Any]] = [] |
| 94 | relationships = _slide_relationships(zf, slide_ref.rels_name) |
| 95 | for order, container in enumerate(_chart_frames(slide_root), start=1): |
| 96 | shape_id, _shape_name = _shape_identity(container, order) |
| 97 | chart_kind, rel_id = _chart_reference(container) |
| 98 | payload: dict[str, Any] = {"chart_id": f"s{slide_ref.index:02d}_ch{shape_id}"} |
| 99 | payload.update(empty_chart_data()) |
| 100 | payload["chart_kind"] = chart_kind |
| 101 | if chart_kind == "chartex": |
| 102 | payload["chart_type"] = "chartEx" |
| 103 | payload["plot_types"] = ["chartEx"] |
| 104 | payload["edit_capability"] = _unsupported_chart_capability( |
| 105 | "chart_edit_chartex_unsupported", |
| 106 | "template-fill chart edits do not support ChartEx", |
| 107 | ) |
| 108 | charts.append(payload) |
| 109 | continue |
| 110 | if chart_kind != "classic": |
| 111 | payload["edit_capability"] = _unsupported_chart_capability( |
| 112 | "chart_edit_plot_type_unsupported", |
| 113 | "template-fill chart edits require a classic DrawingML chart reference", |
| 114 | ) |
| 115 | charts.append(payload) |
| 116 | continue |
| 117 | |
| 118 | rel = relationships.get(rel_id) |
| 119 | if rel and rel.get("type") == CHART_REL_TYPE: |
| 120 | chart_part = _normalize_part(rel["target"], slide_ref.part_name) |
| 121 | try: |
| 122 | chart_root = _read_xml(zf, chart_part) |
| 123 | payload.update(read_chart_data(chart_root)) |
| 124 | payload["edit_capability"] = _chart_edit_capability(chart_root) |
| 125 | except RuntimeError: |
| 126 | payload.update(empty_chart_data()) |
| 127 | payload["edit_capability"] = _unsupported_chart_capability( |
| 128 | "chart_edit_part_unavailable", |
| 129 | "template-fill could not read the classic chart part", |
| 130 | ) |
| 131 | else: |
| 132 | payload["edit_capability"] = _unsupported_chart_capability( |
| 133 | "chart_edit_relationship_unsupported", |
| 134 | "template-fill chart edits require a classic chart relationship", |
| 135 | ) |
| 136 | charts.append(payload) |
| 137 | return charts |
| 138 | |
| 139 | |
| 140 | def _slot_role(slot: dict[str, Any], order: int) -> str: |
| 141 | text = str(slot.get("text") or "") |
| 142 | name = str(slot.get("shape_name") or "").lower() |
| 143 | geometry = slot.get("geometry") or {} |
| 144 | y = geometry.get("y") |
| 145 | if order == 1 or "title" in name or "标题" in name: |
| 146 | return "title_candidate" |
| 147 | if isinstance(y, int) and y < 160 and len(text) <= 80: |
| 148 | return "title_candidate" |
| 149 | if slot.get("text_node_count", 0) >= 4 or len(text) >= 120: |
| 150 | return "body_candidate" |
| 151 | return "label_candidate" |
| 152 | |
| 153 | |
| 154 | def _font_size_px(container: ET.Element) -> float | None: |
| 155 | sizes: list[float] = [] |
| 156 | for node in container.findall(".//a:rPr", NS) + container.findall(".//a:defRPr", NS): |
| 157 | raw_size = node.attrib.get("sz") |
| 158 | if not raw_size: |
| 159 | continue |
| 160 | try: |
| 161 | sizes.append(int(raw_size) / 100 * 96 / 72) |
| 162 | except ValueError: |
| 163 | continue |
| 164 | if not sizes: |
| 165 | return None |
| 166 | # Use the largest explicit run size as the conservative capacity baseline. |
| 167 | return round(max(sizes), 2) |
| 168 | |
| 169 | |
| 170 | def _text_metrics(container: ET.Element, paragraph_count: int) -> dict[str, Any]: |
| 171 | font_size_px = _font_size_px(container) |
| 172 | return { |
| 173 | "font_size_px": font_size_px, |
| 174 | "paragraph_count": paragraph_count, |
| 175 | } |
| 176 | |
| 177 | |
| 178 | def _classify_page_type(index: int, total: int, text: str, slots: list[dict[str, Any]]) -> str: |
| 179 | normalized = text.lower() |
| 180 | if index == 1: |
| 181 | return "cover_candidate" |
| 182 | if index == total or any(keyword in normalized for keyword in THANKS_KEYWORDS): |
| 183 | return "ending_candidate" |
| 184 | if any(keyword in normalized for keyword in TOC_KEYWORDS): |
| 185 | return "toc_candidate" |
| 186 | if any(keyword in normalized for keyword in CHAPTER_KEYWORDS): |
| 187 | return "chapter_candidate" |
| 188 | if len(slots) <= 2 and len(text) <= 80: |
| 189 | return "chapter_candidate" |
| 190 | return "content_candidate" |
| 191 | |
| 192 | |
| 193 | def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]: |
| 194 | size = pres_root.find("p:sldSz", NS) |
| 195 | if size is None: |
| 196 | return {"width": None, "height": None} |
| 197 | return { |
| 198 | "width": _emu_to_px(size.attrib.get("cx")), |
| 199 | "height": _emu_to_px(size.attrib.get("cy")), |
| 200 | } |
| 201 | |
| 202 | |
| 203 | def _fill_risk( |
| 204 | tables: list[dict[str, Any]], |
| 205 | charts: list[dict[str, Any]], |
| 206 | diagrams: list[dict[str, Any]], |
| 207 | ) -> dict[str, Any] | None: |
| 208 | """Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace. |
| 209 | |
| 210 | Tables and charts may be covered by explicit edits. SmartArt is inventory-only: |
| 211 | template-fill preserves it unchanged, so its source text may show through. |
| 212 | """ |
| 213 | kinds: list[str] = [] |
| 214 | if tables: |
| 215 | kinds.append("table") |
| 216 | if charts: |
| 217 | kinds.append("chart") |
| 218 | if diagrams: |
| 219 | kinds.append("smartart") |
| 220 | if not kinds: |
| 221 | return None |
| 222 | kind_str = "/".join(kinds) |
| 223 | guidance: list[str] = [] |
| 224 | if tables or charts: |
| 225 | guidance.append("cover tables/charts with explicit edits") |
| 226 | if diagrams: |
| 227 | guidance.append("review preserved SmartArt source text") |
| 228 | guidance_text = "; ".join(guidance) |
| 229 | return { |
| 230 | "has_non_text_content": True, |
| 231 | "kinds": kinds, |
| 232 | "reason": f"has non-text content ({kind_str}) that text-fill does not replace automatically; {guidance_text}", |
| 233 | } |
| 234 | |
| 235 | |
| 236 | def analyze_pptx(pptx_path: Path) -> dict[str, Any]: |
| 237 | """Extract a slide library with text replacement slots.""" |
| 238 | with zipfile.ZipFile(pptx_path) as zf: |
| 239 | pres_root = _read_xml(zf, "ppt/presentation.xml") |
| 240 | slide_refs = _parse_slide_refs(zf) |
| 241 | slides: list[dict[str, Any]] = [] |
| 242 | for slide_ref in slide_refs: |
| 243 | slide_root = _read_xml(zf, slide_ref.part_name) |
| 244 | slots: list[dict[str, Any]] = [] |
| 245 | for order, container in enumerate(_text_containers(slide_root), start=1): |
| 246 | shape_id, shape_name = _shape_identity(container, order) |
| 247 | paragraphs = _paragraph_texts(container) |
| 248 | text = "\n".join(paragraphs) |
| 249 | geometry = _container_geometry(container) |
| 250 | role = _slot_role( |
| 251 | { |
| 252 | "text": text, |
| 253 | "shape_name": shape_name, |
| 254 | "geometry": geometry, |
| 255 | "text_node_count": len(container.findall(".//a:t", NS)), |
| 256 | }, |
| 257 | order, |
| 258 | ) |
| 259 | slots.append( |
| 260 | { |
| 261 | "slot_id": f"s{slide_ref.index:02d}_sh{shape_id}", |
| 262 | "role": role, |
| 263 | "text": text, |
| 264 | "paragraph_count": len(paragraphs), |
| 265 | "geometry": geometry, |
| 266 | "text_metrics": _text_metrics(container, len(paragraphs)), |
| 267 | } |
| 268 | ) |
| 269 | |
| 270 | tables = _analyze_tables(slide_root, slide_ref.index) |
| 271 | charts = _analyze_charts(zf, slide_root, slide_ref) |
| 272 | diagrams = read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index) |
| 273 | slide_text = "\n".join( |
| 274 | [slot["text"] for slot in slots if slot["text"]] |
| 275 | + [ |
| 276 | str(text) |
| 277 | for diagram in diagrams |
| 278 | for text in diagram.get("text_items", []) |
| 279 | if text |
| 280 | ] |
| 281 | ) |
| 282 | slide: dict[str, Any] = { |
| 283 | "slide_index": slide_ref.index, |
| 284 | "page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots), |
| 285 | "text_summary": slide_text[:500], |
| 286 | "slots": slots, |
| 287 | "tables": tables, |
| 288 | "charts": charts, |
| 289 | "diagrams": diagrams, |
| 290 | } |
| 291 | risk = _fill_risk(tables, charts, diagrams) |
| 292 | if risk is not None: |
| 293 | slide["fill_risk"] = risk |
| 294 | slides.append(slide) |
| 295 | |
| 296 | return { |
| 297 | "schema": "template_fill_pptx_library.v1", |
| 298 | "source_pptx": str(pptx_path), |
| 299 | "slide_count": len(slides), |
| 300 | "canvas_px": _canvas_px(pres_root), |
| 301 | "slides": slides, |
| 302 | "plan_contract": { |
| 303 | "schema": "template_fill_pptx_plan.v1", |
| 304 | "slides": [ |
| 305 | { |
| 306 | "source_slide": 1, |
| 307 | "purpose": "封面 / 章节 / 内容 / 结尾", |
| 308 | "replacements": [ |
| 309 | { |
| 310 | "slot_id": "s01_sh2", |
| 311 | "text": "替换后的文字", |
| 312 | } |
| 313 | ], |
| 314 | "table_edits": [ |
| 315 | { |
| 316 | "table_id": "s01_tbl3", |
| 317 | "cells": [{"row": 0, "col": 0, "text": "替换后的单元格"}], |
| 318 | } |
| 319 | ], |
| 320 | "chart_edits": [ |
| 321 | { |
| 322 | "chart_id": "s01_ch4", |
| 323 | "categories": ["A", "B"], |
| 324 | "series": [{"name": "系列1", "values": [1, 2]}], |
| 325 | } |
| 326 | ], |
| 327 | } |
| 328 | ], |
| 329 | }, |
| 330 | } |
| 331 |