返回 ppt-master
checker.py
1 """Check planned text/table/chart edits and preserved SmartArt source risks."""
2
3 from __future__ import annotations
4
5 import unicodedata
6 from typing import Any
7
8 from .edit_safety import _is_verified_category_capability
9 from .selectors import (
10 _chart_selectors,
11 _replacement_selectors,
12 _replacement_text,
13 _table_selectors,
14 )
15 from .transitions import transition_unknown_fields
16
17
18 def _slot_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
19 lookup: dict[tuple[int, str], dict[str, Any]] = {}
20 for slide in library.get("slides", []):
21 slide_index = int(slide.get("slide_index", 0))
22 for slot in slide.get("slots", []):
23 if slot.get("slot_id"):
24 lookup[(slide_index, f"slot_id:{slot['slot_id']}")] = slot
25 if slot.get("shape_id"):
26 lookup[(slide_index, f"shape_id:{slot['shape_id']}")] = slot
27 if slot.get("shape_name"):
28 lookup[(slide_index, f"shape_name:{slot['shape_name']}")] = slot
29 return lookup
30
31
32 def _table_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
33 lookup: dict[tuple[int, str], dict[str, Any]] = {}
34 for slide in library.get("slides", []):
35 slide_index = int(slide.get("slide_index", 0))
36 for table in slide.get("tables", []):
37 if table.get("table_id"):
38 lookup[(slide_index, f"table_id:{table['table_id']}")] = table
39 if table.get("shape_id"):
40 lookup[(slide_index, f"shape_id:{table['shape_id']}")] = table
41 if table.get("shape_name"):
42 lookup[(slide_index, f"shape_name:{table['shape_name']}")] = table
43 return lookup
44
45
46 def _chart_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
47 lookup: dict[tuple[int, str], dict[str, Any]] = {}
48 for slide in library.get("slides", []):
49 slide_index = int(slide.get("slide_index", 0))
50 for chart in slide.get("charts", []):
51 if chart.get("chart_id"):
52 lookup[(slide_index, f"chart_id:{chart['chart_id']}")] = chart
53 if chart.get("shape_id"):
54 lookup[(slide_index, f"shape_id:{chart['shape_id']}")] = chart
55 if chart.get("shape_name"):
56 lookup[(slide_index, f"shape_name:{chart['shape_name']}")] = chart
57 return lookup
58
59
60 def _table_cell_lookup(table: dict[str, Any]) -> dict[tuple[int, int], dict[str, Any]]:
61 lookup: dict[tuple[int, int], dict[str, Any]] = {}
62 for row in table.get("rows", []):
63 for cell in row.get("cells", []):
64 if not isinstance(cell, dict):
65 continue
66 try:
67 key = (int(cell.get("row", -1)), int(cell.get("col", -1)))
68 except (TypeError, ValueError):
69 continue
70 lookup[key] = cell
71 return lookup
72
73
74 def _table_merge_slave_lookup(
75 table: dict[str, Any],
76 ) -> dict[tuple[int, int], dict[str, int] | None]:
77 lookup: dict[tuple[int, int], dict[str, int] | None] = {}
78 topology = table.get("merge_topology")
79 if isinstance(topology, dict):
80 for slave in topology.get("slave_cells", []):
81 if not isinstance(slave, dict):
82 continue
83 try:
84 key = (int(slave.get("row", -1)), int(slave.get("col", -1)))
85 except (TypeError, ValueError):
86 continue
87 anchor = slave.get("anchor")
88 lookup[key] = anchor if isinstance(anchor, dict) else None
89
90 for key, cell in _table_cell_lookup(table).items():
91 if cell.get("is_merge_slave") is True or cell.get("merge_role") == "slave":
92 anchor = cell.get("merge_anchor")
93 lookup.setdefault(key, anchor if isinstance(anchor, dict) else None)
94 return lookup
95
96
97 def _chart_edit_capability_review(
98 chart: dict[str, Any],
99 ) -> tuple[tuple[str, str] | None, list[tuple[str, str]]]:
100 capability = chart.get("edit_capability")
101 if not isinstance(capability, dict):
102 return None, [
103 (
104 "chart_edit_capability_unknown",
105 "chart edit capability is missing; runtime will inspect the actual "
106 "chart XML before mutation, and re-running template analysis is recommended",
107 )
108 ]
109 if capability.get("supported") is not True:
110 return (
111 str(capability.get("code") or "chart_edit_capability_unknown"),
112 str(capability.get("message") or "chart edit capability is unsupported"),
113 ), []
114 if not _is_verified_category_capability(capability):
115 return (
116 "chart_edit_capability_unknown",
117 "chart edit capability is not the verified single-plot c:cat/c:val model",
118 ), []
119
120 capability_warnings: list[tuple[str, str]] = []
121 raw_warnings = capability.get("warnings")
122 if isinstance(raw_warnings, list):
123 for item in raw_warnings:
124 if not isinstance(item, dict):
125 continue
126 code = str(item.get("code") or "chart_edit_category_flattened")
127 message = str(item.get("message") or "chart categories will be flattened")
128 capability_warnings.append((code, message))
129 return None, capability_warnings
130
131
132 def _visual_width(text: str) -> float:
133 """Estimate rendered text width in Latin-character units.
134
135 ``len(text)`` is too crude for mixed CJK / Latin decks: Chinese characters
136 generally consume about twice the horizontal space of ASCII letters, while
137 punctuation and digits are narrower. The checker only needs a conservative
138 fit signal, so use Unicode East Asian Width instead of a font-specific
139 renderer.
140 """
141 width = 0.0
142 for char in "".join(text.split()):
143 east_asian_width = unicodedata.east_asian_width(char)
144 if east_asian_width in {"F", "W"}:
145 width += 2.0
146 elif east_asian_width == "A":
147 width += 1.5
148 else:
149 width += 1.0
150 return width
151
152
153 def _display_width(value: float) -> int | float:
154 return int(value) if value.is_integer() else round(value, 1)
155
156
157 def _fallback_font_size_px(role: str, geometry: dict[str, Any], old_paragraphs: int) -> float:
158 height = geometry.get("height")
159 if isinstance(height, int) and old_paragraphs > 0:
160 inferred = height / max(old_paragraphs, 1) / 1.25
161 if 8 <= inferred <= 56:
162 return inferred
163 if role == "title_candidate":
164 return 28.0
165 if role == "body_candidate":
166 return 16.0
167 return 14.0
168
169
170 def _geometry_capacity_width(
171 *,
172 role: str,
173 old_paragraphs: int,
174 new_paragraphs: int,
175 geometry: dict[str, Any],
176 text_metrics: dict[str, Any],
177 ) -> float | None:
178 width = geometry.get("width")
179 height = geometry.get("height")
180 if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
181 return None
182
183 font_size_px = text_metrics.get("font_size_px")
184 if not isinstance(font_size_px, (int, float)) or font_size_px <= 0:
185 font_size_px = _fallback_font_size_px(role, geometry, old_paragraphs)
186
187 line_height = max(font_size_px * 1.25, 1.0)
188 max_lines = max(int(height / line_height), old_paragraphs, new_paragraphs, 1)
189 horizontal_padding = 24 if width >= 180 else 12
190 usable_width = max(width - horizontal_padding, width * 0.72, 1)
191 latin_units_per_line = usable_width / max(font_size_px * 0.52, 1)
192 capacity = latin_units_per_line * max_lines
193
194 if role == "label_candidate":
195 return capacity * 0.7
196 if role == "title_candidate":
197 return capacity * 0.85
198 return capacity
199
200
201 def _fit_status(
202 *,
203 role: str,
204 old_width: float,
205 new_width: float,
206 old_paragraphs: int,
207 new_paragraphs: int,
208 geometry: dict[str, Any],
209 text_metrics: dict[str, Any],
210 ) -> tuple[str, str]:
211 old_width = max(old_width, 1.0)
212 ratio = new_width / old_width
213 width = geometry.get("width")
214 height = geometry.get("height")
215 capacity_width = _geometry_capacity_width(
216 role=role,
217 old_paragraphs=old_paragraphs,
218 new_paragraphs=new_paragraphs,
219 geometry=geometry,
220 text_metrics=text_metrics,
221 )
222
223 if role == "label_candidate" or (old_width <= 8 and old_paragraphs <= 1):
224 if capacity_width is not None and new_width <= capacity_width and not (old_width <= 8):
225 return "OK", "short label fits estimated text-box capacity"
226 label_limit = old_width
227 if isinstance(width, int) and width >= 220:
228 label_limit = max(label_limit, old_width * 1.25)
229 if new_width > label_limit:
230 return "WARN", "short label exceeds original visual width; rewrite shorter"
231 return "OK", "short label fits original visual width"
232
233 if role == "title_candidate" and old_paragraphs <= 1:
234 if capacity_width is not None and new_width <= capacity_width:
235 return "OK", "title fits estimated text-box capacity"
236 limit = 1.15 if old_width <= 12 else 1.35
237 if ratio > limit:
238 return "WARN", "title is too long for the original slot; rewrite first"
239 return "OK", "title stays near original capacity"
240
241 paragraph_limit = max(old_paragraphs + 2, old_paragraphs * 2, 2)
242 if new_paragraphs > paragraph_limit:
243 return "WARN", "body paragraph count changed too much; compress or split pages"
244
245 if isinstance(width, int) and isinstance(height, int) and width * height < 30000 and ratio > 2.0:
246 return "WARN", "small text box with much longer text; rewrite shorter"
247
248 if capacity_width is not None and new_width > capacity_width:
249 return "WARN", "text exceeds estimated text-box capacity; rewrite or split"
250
251 # Body text reflows, so a moderate amount of extra length is fine; only flag
252 # gross overflow. Labels / titles keep their tighter guards above.
253 body_limit = 3.0 if role == "body_candidate" else 2.2
254 if ratio > body_limit:
255 return "WARN", "text is much longer than source slot; rewrite or choose another page"
256 return "OK", "within estimated slot capacity"
257
258
259 def _capacity_for_report(
260 *,
261 role: str,
262 old_width: float,
263 old_paragraphs: int,
264 new_paragraphs: int,
265 geometry: dict[str, Any],
266 text_metrics: dict[str, Any],
267 ) -> float | None:
268 capacity = _geometry_capacity_width(
269 role=role,
270 old_paragraphs=old_paragraphs,
271 new_paragraphs=new_paragraphs,
272 geometry=geometry,
273 text_metrics=text_metrics,
274 )
275 if capacity is None:
276 return None
277 return _display_width(max(capacity, old_width))
278
279
280 def _library_slide_index(library: dict[str, Any]) -> dict[int, dict[str, Any]]:
281 """Build a mapping from slide_index to slide dict for O(1) lookup."""
282 return {int(s.get("slide_index", 0)): s for s in library.get("slides", [])}
283
284
285 def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
286 """Compare fill replacements against source slot capacity."""
287 lookup = _slot_lookup(library)
288 table_lookup = _table_lookup(library)
289 chart_lookup = _chart_lookup(library)
290 results: list[dict[str, Any]] = []
291 summary = {"ok": 0, "warn": 0, "error": 0}
292
293 for slide_index, slide in enumerate(plan.get("slides", []), start=1):
294 source_slide = int(slide.get("source_slide", 0))
295 transition = slide.get("transition")
296 if isinstance(transition, dict):
297 unknown_transition_fields = transition_unknown_fields(transition)
298 if unknown_transition_fields:
299 results.append(
300 {
301 "status": "ERROR",
302 "code": "transition_unknown_fields",
303 "plan_slide": slide_index,
304 "source_slide": source_slide,
305 "fields": unknown_transition_fields,
306 "message": (
307 "transition has unknown field(s): "
308 + ", ".join(unknown_transition_fields)
309 ),
310 }
311 )
312 summary["error"] += 1
313 replacements = slide.get("replacements", [])
314 if not isinstance(replacements, list):
315 results.append(
316 {
317 "status": "ERROR",
318 "code": "replacements_not_list",
319 "plan_slide": slide_index,
320 "source_slide": source_slide,
321 "message": "replacements must be a list",
322 }
323 )
324 summary["error"] += 1
325 continue
326
327 for replacement in replacements:
328 selectors = _replacement_selectors(replacement)
329 slot = next((lookup.get((source_slide, selector)) for selector in selectors), None)
330 text = _replacement_text(replacement)
331 if slot is None:
332 results.append(
333 {
334 "status": "ERROR",
335 "code": "replacement_target_not_found",
336 "plan_slide": slide_index,
337 "source_slide": source_slide,
338 "selector": selectors[0] if selectors else "",
339 "message": "replacement target not found in slide library",
340 }
341 )
342 summary["error"] += 1
343 continue
344
345 old_text = str(slot.get("text") or "")
346 old_width = _visual_width(old_text)
347 new_width = _visual_width(text)
348 old_paragraphs = int(slot.get("paragraph_count") or 1)
349 new_paragraphs = max(len([line for line in text.splitlines() if line.strip()]), 1)
350 status, message = _fit_status(
351 role=str(slot.get("role") or ""),
352 old_width=old_width,
353 new_width=new_width,
354 old_paragraphs=old_paragraphs,
355 new_paragraphs=new_paragraphs,
356 geometry=slot.get("geometry") or {},
357 text_metrics=slot.get("text_metrics") or {},
358 )
359 capacity_width = _capacity_for_report(
360 role=str(slot.get("role") or ""),
361 old_width=old_width,
362 old_paragraphs=old_paragraphs,
363 new_paragraphs=new_paragraphs,
364 geometry=slot.get("geometry") or {},
365 text_metrics=slot.get("text_metrics") or {},
366 )
367 summary["warn" if status == "WARN" else "ok"] += 1
368 results.append(
369 {
370 "status": status,
371 "code": "text_capacity" if status == "WARN" else "text_fit",
372 "plan_slide": slide_index,
373 "source_slide": source_slide,
374 "slot_id": slot.get("slot_id"),
375 "role": slot.get("role"),
376 "old_len": _display_width(old_width),
377 "new_len": _display_width(new_width),
378 "old_visual_width": _display_width(old_width),
379 "new_visual_width": _display_width(new_width),
380 "capacity_visual_width": capacity_width,
381 "ratio": round(new_width / max(old_width, 1.0), 2),
382 "old_paragraphs": old_paragraphs,
383 "new_paragraphs": new_paragraphs,
384 "message": message,
385 "old_text": old_text,
386 "new_text": text,
387 }
388 )
389 table_edits = slide.get("table_edits", [])
390 if not isinstance(table_edits, list):
391 results.append(
392 {
393 "status": "ERROR",
394 "code": "table_edits_not_list",
395 "plan_slide": slide_index,
396 "source_slide": source_slide,
397 "message": "table_edits must be a list",
398 }
399 )
400 summary["error"] += 1
401 continue
402 for table_edit in table_edits:
403 selectors = _table_selectors(table_edit)
404 table = next((table_lookup.get((source_slide, selector)) for selector in selectors), None)
405 if table is None:
406 results.append(
407 {
408 "status": "ERROR",
409 "code": "table_target_not_found",
410 "plan_slide": slide_index,
411 "source_slide": source_slide,
412 "selector": selectors[0] if selectors else "",
413 "message": "table target not found in slide library",
414 }
415 )
416 summary["error"] += 1
417 continue
418 cells = table_edit.get("cells", [])
419 if not isinstance(cells, list):
420 results.append(
421 {
422 "status": "ERROR",
423 "code": "table_cells_not_list",
424 "plan_slide": slide_index,
425 "source_slide": source_slide,
426 "selector": selectors[0] if selectors else "",
427 "message": "table edit cells must be a list",
428 }
429 )
430 summary["error"] += 1
431 continue
432 row_count = int(table.get("row_count") or 0)
433 column_count = int(table.get("column_count") or 0)
434 table_cells = _table_cell_lookup(table)
435 merge_slaves = _table_merge_slave_lookup(table)
436 for cell in cells:
437 row = int(cell.get("row", -1))
438 col = int(cell.get("col", -1))
439 if (
440 row < 0
441 or col < 0
442 or row >= row_count
443 or col >= column_count
444 or (table_cells and (row, col) not in table_cells)
445 ):
446 results.append(
447 {
448 "status": "ERROR",
449 "code": "table_cell_out_of_bounds",
450 "plan_slide": slide_index,
451 "source_slide": source_slide,
452 "selector": selectors[0] if selectors else "",
453 "message": f"table cell out of bounds: row={row} col={col}",
454 }
455 )
456 summary["error"] += 1
457 continue
458 if (row, col) in merge_slaves:
459 anchor = merge_slaves[(row, col)]
460 anchor_hint = ""
461 if anchor is not None:
462 anchor_hint = (
463 f"; edit merge anchor row={anchor.get('row')} "
464 f"col={anchor.get('col')} instead"
465 )
466 results.append(
467 {
468 "status": "ERROR",
469 "code": "table_cell_is_merge_slave",
470 "plan_slide": slide_index,
471 "source_slide": source_slide,
472 "selector": selectors[0] if selectors else "",
473 "table_id": table.get("table_id"),
474 "row": row,
475 "col": col,
476 "message": (
477 f"table cell row={row} col={col} is a merged-cell slave"
478 f"{anchor_hint}"
479 ),
480 }
481 )
482 summary["error"] += 1
483 continue
484 summary["ok"] += 1
485 results.append(
486 {
487 "status": "OK",
488 "code": "table_target_exists",
489 "plan_slide": slide_index,
490 "source_slide": source_slide,
491 "table_id": table.get("table_id"),
492 "row": row,
493 "col": col,
494 "message": "table cell target exists",
495 }
496 )
497 chart_edits = slide.get("chart_edits", [])
498 if not isinstance(chart_edits, list):
499 results.append(
500 {
501 "status": "ERROR",
502 "code": "chart_edits_not_list",
503 "plan_slide": slide_index,
504 "source_slide": source_slide,
505 "message": "chart_edits must be a list",
506 }
507 )
508 summary["error"] += 1
509 continue
510 for chart_edit in chart_edits:
511 selectors = _chart_selectors(chart_edit)
512 chart = next((chart_lookup.get((source_slide, selector)) for selector in selectors), None)
513 if chart is None:
514 results.append(
515 {
516 "status": "ERROR",
517 "code": "chart_target_not_found",
518 "plan_slide": slide_index,
519 "source_slide": source_slide,
520 "selector": selectors[0] if selectors else "",
521 "message": "chart target not found in slide library",
522 }
523 )
524 summary["error"] += 1
525 continue
526 capability_error, capability_warnings = _chart_edit_capability_review(chart)
527 if capability_error is not None:
528 error_code, error_message = capability_error
529 results.append(
530 {
531 "status": "ERROR",
532 "code": error_code,
533 "plan_slide": slide_index,
534 "source_slide": source_slide,
535 "selector": selectors[0] if selectors else "",
536 "chart_id": chart.get("chart_id"),
537 "message": error_message,
538 }
539 )
540 summary["error"] += 1
541 continue
542 for warning_code, warning_message in capability_warnings:
543 results.append(
544 {
545 "status": "WARN",
546 "code": warning_code,
547 "plan_slide": slide_index,
548 "source_slide": source_slide,
549 "selector": selectors[0] if selectors else "",
550 "chart_id": chart.get("chart_id"),
551 "message": warning_message,
552 }
553 )
554 summary["warn"] += 1
555 categories = chart_edit.get("categories", [])
556 series = chart_edit.get("series", [])
557 if not isinstance(categories, list) or not isinstance(series, list) or not series:
558 results.append(
559 {
560 "status": "ERROR",
561 "code": "chart_data_invalid",
562 "plan_slide": slide_index,
563 "source_slide": source_slide,
564 "selector": selectors[0] if selectors else "",
565 "message": "chart edit requires categories list and non-empty series list",
566 }
567 )
568 summary["error"] += 1
569 continue
570 bad_series = [
571 item
572 for item in series
573 if not isinstance(item, dict)
574 or not isinstance(item.get("values", []), list)
575 or len(item.get("values", [])) != len(categories)
576 ]
577 if bad_series:
578 results.append(
579 {
580 "status": "ERROR",
581 "code": "chart_series_length_mismatch",
582 "plan_slide": slide_index,
583 "source_slide": source_slide,
584 "selector": selectors[0] if selectors else "",
585 "message": "each chart series needs values matching categories length",
586 }
587 )
588 summary["error"] += 1
589 continue
590 summary["ok"] += 1
591 results.append(
592 {
593 "status": "OK",
594 "code": "chart_target_valid",
595 "plan_slide": slide_index,
596 "source_slide": source_slide,
597 "chart_id": chart.get("chart_id"),
598 "category_count": len(categories),
599 "series_count": len(series),
600 "message": "chart edit target and data shape are valid",
601 }
602 )
603 # --- Guardrail 2: source slides with non-text content not covered by edits ---
604 # Tables/charts may be covered by explicit edits. SmartArt is preserve-only,
605 # so selecting a source slide that contains it always needs a content review.
606 lib_slides = _library_slide_index(library)
607 for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
608 source_slide = int(slide.get("source_slide", 0))
609 lib_slide = lib_slides.get(source_slide)
610 if lib_slide is None:
611 continue
612 lib_tables = lib_slide.get("tables", [])
613 lib_charts = lib_slide.get("charts", [])
614 lib_diagrams = lib_slide.get("diagrams", [])
615 if not lib_tables and not lib_charts and not lib_diagrams:
616 continue
617 # Check whether the plan slide provides edits covering the non-text content.
618 has_table_edits = bool(slide.get("table_edits"))
619 has_chart_edits = bool(slide.get("chart_edits"))
620 uncovered_kinds: list[str] = []
621 if lib_tables and not has_table_edits:
622 uncovered_kinds.append("table")
623 if lib_charts and not has_chart_edits:
624 uncovered_kinds.append("chart")
625 if lib_diagrams:
626 uncovered_kinds.append("smartart")
627 if not uncovered_kinds:
628 continue
629 kind_str = "/".join(uncovered_kinds)
630 guidance = "add table_edits/chart_edits, or pick another source slide"
631 if lib_diagrams:
632 guidance = "SmartArt remains unchanged, so pick another source slide or accept this warning"
633 if any(kind in uncovered_kinds for kind in ("table", "chart")):
634 guidance = f"add table/chart edits where supported; {guidance}"
635 summary["warn"] += 1
636 results.append(
637 {
638 "status": "WARN",
639 "code": "non_text_content_unedited",
640 "plan_slide": plan_slide_index,
641 "source_slide": source_slide,
642 "uncovered_kinds": uncovered_kinds,
643 "diagram_ids": [
644 diagram.get("diagram_id")
645 for diagram in lib_diagrams
646 if diagram.get("diagram_id")
647 ],
648 "message": (
649 f"source slide {source_slide} has non-text content ({kind_str}) "
650 "outside the plan's supported edits; template-fill leaves it untouched "
651 f"and original template content may show through ({guidance})"
652 ),
653 }
654 )
655
656 # --- Guardrail 1: same source slide reused too many times while unused layouts exist ---
657 # Use a relative condition rather than an absolute threshold: only warn when
658 # (a) a source slide is reused >= REUSE_WARN_THRESHOLD times, AND
659 # (b) there are library slides that the plan never uses at all.
660 # Rationale: a small template where every layout is referenced is fine even at
661 # high per-slide reuse; "15-page template where only 1 page is ever cloned and
662 # the rest sit idle" is the real smell we want to surface.
663 # Threshold of 3: any source appearing 3+ times in a plan is meaningful reuse
664 # (cover / TOC / ending typically appear at most twice), so >= 3 is a practical
665 # signal without being overly sensitive.
666 REUSE_WARN_THRESHOLD = 3
667 source_use_counts: dict[int, int] = {}
668 for slide in plan.get("slides", []):
669 src = int(slide.get("source_slide", 0))
670 if src:
671 source_use_counts[src] = source_use_counts.get(src, 0) + 1
672 all_lib_indices = {int(s.get("slide_index", 0)) for s in library.get("slides", []) if s.get("slide_index")}
673 used_lib_indices = set(source_use_counts.keys())
674 unused_lib_indices = sorted(all_lib_indices - used_lib_indices)
675 if unused_lib_indices:
676 for src, count in sorted(source_use_counts.items()):
677 if count >= REUSE_WARN_THRESHOLD:
678 unused_list = ", ".join(str(i) for i in unused_lib_indices)
679 summary["warn"] += 1
680 results.append(
681 {
682 "status": "WARN",
683 "code": "source_reuse_concentration",
684 "source_slide": src,
685 "reuse_count": count,
686 "unused_source_slides": unused_lib_indices,
687 "message": (
688 f"source slide {src} is reused {count} times while "
689 f"{len(unused_lib_indices)} source layout(s) are never used "
690 f"(indices: {unused_list}); "
691 "consider using other layouts for more variety"
692 ),
693 }
694 )
695
696 return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results}
697
698
699 def print_check_report(report: dict[str, Any]) -> None:
700 summary = report["summary"]
701 print(f"check-plan: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
702 for item in report["results"]:
703 if item["status"] == "OK":
704 continue
705 if "ratio" in item:
706 line = (
707 "{status} P{plan_slide:02d} source={source_slide} {slot_id} "
708 "{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item)
709 )
710 elif "plan_slide" in item:
711 target = item.get("slot_id") or item.get("selector") or ""
712 line = (
713 f"{item['status']} P{item['plan_slide']:02d} "
714 f"source={item['source_slide']} {target}: {item['message']}".strip()
715 )
716 else:
717 # Guardrail 1 WARNs are source-level (no plan_slide); print source + message.
718 line = f"{item['status']} source={item.get('source_slide', '?')}: {item['message']}"
719 print(line)
720
720 lines PYTHON