返回 ppt-master
analyzer.py
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
331 lines PYTHON