Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
"""Check planned text/table/chart edits and preserved SmartArt source risks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from .edit_safety import _is_verified_category_capability
|
||||
from .selectors import (
|
||||
_chart_selectors,
|
||||
_replacement_selectors,
|
||||
_replacement_text,
|
||||
_table_selectors,
|
||||
)
|
||||
from .transitions import transition_unknown_fields
|
||||
|
||||
|
||||
def _slot_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for slide in library.get("slides", []):
|
||||
slide_index = int(slide.get("slide_index", 0))
|
||||
for slot in slide.get("slots", []):
|
||||
if slot.get("slot_id"):
|
||||
lookup[(slide_index, f"slot_id:{slot['slot_id']}")] = slot
|
||||
if slot.get("shape_id"):
|
||||
lookup[(slide_index, f"shape_id:{slot['shape_id']}")] = slot
|
||||
if slot.get("shape_name"):
|
||||
lookup[(slide_index, f"shape_name:{slot['shape_name']}")] = slot
|
||||
return lookup
|
||||
|
||||
|
||||
def _table_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for slide in library.get("slides", []):
|
||||
slide_index = int(slide.get("slide_index", 0))
|
||||
for table in slide.get("tables", []):
|
||||
if table.get("table_id"):
|
||||
lookup[(slide_index, f"table_id:{table['table_id']}")] = table
|
||||
if table.get("shape_id"):
|
||||
lookup[(slide_index, f"shape_id:{table['shape_id']}")] = table
|
||||
if table.get("shape_name"):
|
||||
lookup[(slide_index, f"shape_name:{table['shape_name']}")] = table
|
||||
return lookup
|
||||
|
||||
|
||||
def _chart_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for slide in library.get("slides", []):
|
||||
slide_index = int(slide.get("slide_index", 0))
|
||||
for chart in slide.get("charts", []):
|
||||
if chart.get("chart_id"):
|
||||
lookup[(slide_index, f"chart_id:{chart['chart_id']}")] = chart
|
||||
if chart.get("shape_id"):
|
||||
lookup[(slide_index, f"shape_id:{chart['shape_id']}")] = chart
|
||||
if chart.get("shape_name"):
|
||||
lookup[(slide_index, f"shape_name:{chart['shape_name']}")] = chart
|
||||
return lookup
|
||||
|
||||
|
||||
def _table_cell_lookup(table: dict[str, Any]) -> dict[tuple[int, int], dict[str, Any]]:
|
||||
lookup: dict[tuple[int, int], dict[str, Any]] = {}
|
||||
for row in table.get("rows", []):
|
||||
for cell in row.get("cells", []):
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
try:
|
||||
key = (int(cell.get("row", -1)), int(cell.get("col", -1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
lookup[key] = cell
|
||||
return lookup
|
||||
|
||||
|
||||
def _table_merge_slave_lookup(
|
||||
table: dict[str, Any],
|
||||
) -> dict[tuple[int, int], dict[str, int] | None]:
|
||||
lookup: dict[tuple[int, int], dict[str, int] | None] = {}
|
||||
topology = table.get("merge_topology")
|
||||
if isinstance(topology, dict):
|
||||
for slave in topology.get("slave_cells", []):
|
||||
if not isinstance(slave, dict):
|
||||
continue
|
||||
try:
|
||||
key = (int(slave.get("row", -1)), int(slave.get("col", -1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
anchor = slave.get("anchor")
|
||||
lookup[key] = anchor if isinstance(anchor, dict) else None
|
||||
|
||||
for key, cell in _table_cell_lookup(table).items():
|
||||
if cell.get("is_merge_slave") is True or cell.get("merge_role") == "slave":
|
||||
anchor = cell.get("merge_anchor")
|
||||
lookup.setdefault(key, anchor if isinstance(anchor, dict) else None)
|
||||
return lookup
|
||||
|
||||
|
||||
def _chart_edit_capability_review(
|
||||
chart: dict[str, Any],
|
||||
) -> tuple[tuple[str, str] | None, list[tuple[str, str]]]:
|
||||
capability = chart.get("edit_capability")
|
||||
if not isinstance(capability, dict):
|
||||
return None, [
|
||||
(
|
||||
"chart_edit_capability_unknown",
|
||||
"chart edit capability is missing; runtime will inspect the actual "
|
||||
"chart XML before mutation, and re-running template analysis is recommended",
|
||||
)
|
||||
]
|
||||
if capability.get("supported") is not True:
|
||||
return (
|
||||
str(capability.get("code") or "chart_edit_capability_unknown"),
|
||||
str(capability.get("message") or "chart edit capability is unsupported"),
|
||||
), []
|
||||
if not _is_verified_category_capability(capability):
|
||||
return (
|
||||
"chart_edit_capability_unknown",
|
||||
"chart edit capability is not the verified single-plot c:cat/c:val model",
|
||||
), []
|
||||
|
||||
capability_warnings: list[tuple[str, str]] = []
|
||||
raw_warnings = capability.get("warnings")
|
||||
if isinstance(raw_warnings, list):
|
||||
for item in raw_warnings:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = str(item.get("code") or "chart_edit_category_flattened")
|
||||
message = str(item.get("message") or "chart categories will be flattened")
|
||||
capability_warnings.append((code, message))
|
||||
return None, capability_warnings
|
||||
|
||||
|
||||
def _visual_width(text: str) -> float:
|
||||
"""Estimate rendered text width in Latin-character units.
|
||||
|
||||
``len(text)`` is too crude for mixed CJK / Latin decks: Chinese characters
|
||||
generally consume about twice the horizontal space of ASCII letters, while
|
||||
punctuation and digits are narrower. The checker only needs a conservative
|
||||
fit signal, so use Unicode East Asian Width instead of a font-specific
|
||||
renderer.
|
||||
"""
|
||||
width = 0.0
|
||||
for char in "".join(text.split()):
|
||||
east_asian_width = unicodedata.east_asian_width(char)
|
||||
if east_asian_width in {"F", "W"}:
|
||||
width += 2.0
|
||||
elif east_asian_width == "A":
|
||||
width += 1.5
|
||||
else:
|
||||
width += 1.0
|
||||
return width
|
||||
|
||||
|
||||
def _display_width(value: float) -> int | float:
|
||||
return int(value) if value.is_integer() else round(value, 1)
|
||||
|
||||
|
||||
def _fallback_font_size_px(role: str, geometry: dict[str, Any], old_paragraphs: int) -> float:
|
||||
height = geometry.get("height")
|
||||
if isinstance(height, int) and old_paragraphs > 0:
|
||||
inferred = height / max(old_paragraphs, 1) / 1.25
|
||||
if 8 <= inferred <= 56:
|
||||
return inferred
|
||||
if role == "title_candidate":
|
||||
return 28.0
|
||||
if role == "body_candidate":
|
||||
return 16.0
|
||||
return 14.0
|
||||
|
||||
|
||||
def _geometry_capacity_width(
|
||||
*,
|
||||
role: str,
|
||||
old_paragraphs: int,
|
||||
new_paragraphs: int,
|
||||
geometry: dict[str, Any],
|
||||
text_metrics: dict[str, Any],
|
||||
) -> float | None:
|
||||
width = geometry.get("width")
|
||||
height = geometry.get("height")
|
||||
if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
|
||||
return None
|
||||
|
||||
font_size_px = text_metrics.get("font_size_px")
|
||||
if not isinstance(font_size_px, (int, float)) or font_size_px <= 0:
|
||||
font_size_px = _fallback_font_size_px(role, geometry, old_paragraphs)
|
||||
|
||||
line_height = max(font_size_px * 1.25, 1.0)
|
||||
max_lines = max(int(height / line_height), old_paragraphs, new_paragraphs, 1)
|
||||
horizontal_padding = 24 if width >= 180 else 12
|
||||
usable_width = max(width - horizontal_padding, width * 0.72, 1)
|
||||
latin_units_per_line = usable_width / max(font_size_px * 0.52, 1)
|
||||
capacity = latin_units_per_line * max_lines
|
||||
|
||||
if role == "label_candidate":
|
||||
return capacity * 0.7
|
||||
if role == "title_candidate":
|
||||
return capacity * 0.85
|
||||
return capacity
|
||||
|
||||
|
||||
def _fit_status(
|
||||
*,
|
||||
role: str,
|
||||
old_width: float,
|
||||
new_width: float,
|
||||
old_paragraphs: int,
|
||||
new_paragraphs: int,
|
||||
geometry: dict[str, Any],
|
||||
text_metrics: dict[str, Any],
|
||||
) -> tuple[str, str]:
|
||||
old_width = max(old_width, 1.0)
|
||||
ratio = new_width / old_width
|
||||
width = geometry.get("width")
|
||||
height = geometry.get("height")
|
||||
capacity_width = _geometry_capacity_width(
|
||||
role=role,
|
||||
old_paragraphs=old_paragraphs,
|
||||
new_paragraphs=new_paragraphs,
|
||||
geometry=geometry,
|
||||
text_metrics=text_metrics,
|
||||
)
|
||||
|
||||
if role == "label_candidate" or (old_width <= 8 and old_paragraphs <= 1):
|
||||
if capacity_width is not None and new_width <= capacity_width and not (old_width <= 8):
|
||||
return "OK", "short label fits estimated text-box capacity"
|
||||
label_limit = old_width
|
||||
if isinstance(width, int) and width >= 220:
|
||||
label_limit = max(label_limit, old_width * 1.25)
|
||||
if new_width > label_limit:
|
||||
return "WARN", "short label exceeds original visual width; rewrite shorter"
|
||||
return "OK", "short label fits original visual width"
|
||||
|
||||
if role == "title_candidate" and old_paragraphs <= 1:
|
||||
if capacity_width is not None and new_width <= capacity_width:
|
||||
return "OK", "title fits estimated text-box capacity"
|
||||
limit = 1.15 if old_width <= 12 else 1.35
|
||||
if ratio > limit:
|
||||
return "WARN", "title is too long for the original slot; rewrite first"
|
||||
return "OK", "title stays near original capacity"
|
||||
|
||||
paragraph_limit = max(old_paragraphs + 2, old_paragraphs * 2, 2)
|
||||
if new_paragraphs > paragraph_limit:
|
||||
return "WARN", "body paragraph count changed too much; compress or split pages"
|
||||
|
||||
if isinstance(width, int) and isinstance(height, int) and width * height < 30000 and ratio > 2.0:
|
||||
return "WARN", "small text box with much longer text; rewrite shorter"
|
||||
|
||||
if capacity_width is not None and new_width > capacity_width:
|
||||
return "WARN", "text exceeds estimated text-box capacity; rewrite or split"
|
||||
|
||||
# Body text reflows, so a moderate amount of extra length is fine; only flag
|
||||
# gross overflow. Labels / titles keep their tighter guards above.
|
||||
body_limit = 3.0 if role == "body_candidate" else 2.2
|
||||
if ratio > body_limit:
|
||||
return "WARN", "text is much longer than source slot; rewrite or choose another page"
|
||||
return "OK", "within estimated slot capacity"
|
||||
|
||||
|
||||
def _capacity_for_report(
|
||||
*,
|
||||
role: str,
|
||||
old_width: float,
|
||||
old_paragraphs: int,
|
||||
new_paragraphs: int,
|
||||
geometry: dict[str, Any],
|
||||
text_metrics: dict[str, Any],
|
||||
) -> float | None:
|
||||
capacity = _geometry_capacity_width(
|
||||
role=role,
|
||||
old_paragraphs=old_paragraphs,
|
||||
new_paragraphs=new_paragraphs,
|
||||
geometry=geometry,
|
||||
text_metrics=text_metrics,
|
||||
)
|
||||
if capacity is None:
|
||||
return None
|
||||
return _display_width(max(capacity, old_width))
|
||||
|
||||
|
||||
def _library_slide_index(library: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
"""Build a mapping from slide_index to slide dict for O(1) lookup."""
|
||||
return {int(s.get("slide_index", 0)): s for s in library.get("slides", [])}
|
||||
|
||||
|
||||
def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare fill replacements against source slot capacity."""
|
||||
lookup = _slot_lookup(library)
|
||||
table_lookup = _table_lookup(library)
|
||||
chart_lookup = _chart_lookup(library)
|
||||
results: list[dict[str, Any]] = []
|
||||
summary = {"ok": 0, "warn": 0, "error": 0}
|
||||
|
||||
for slide_index, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
transition = slide.get("transition")
|
||||
if isinstance(transition, dict):
|
||||
unknown_transition_fields = transition_unknown_fields(transition)
|
||||
if unknown_transition_fields:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "transition_unknown_fields",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"fields": unknown_transition_fields,
|
||||
"message": (
|
||||
"transition has unknown field(s): "
|
||||
+ ", ".join(unknown_transition_fields)
|
||||
),
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
replacements = slide.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "replacements_not_list",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"message": "replacements must be a list",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
|
||||
for replacement in replacements:
|
||||
selectors = _replacement_selectors(replacement)
|
||||
slot = next((lookup.get((source_slide, selector)) for selector in selectors), None)
|
||||
text = _replacement_text(replacement)
|
||||
if slot is None:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "replacement_target_not_found",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "replacement target not found in slide library",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
|
||||
old_text = str(slot.get("text") or "")
|
||||
old_width = _visual_width(old_text)
|
||||
new_width = _visual_width(text)
|
||||
old_paragraphs = int(slot.get("paragraph_count") or 1)
|
||||
new_paragraphs = max(len([line for line in text.splitlines() if line.strip()]), 1)
|
||||
status, message = _fit_status(
|
||||
role=str(slot.get("role") or ""),
|
||||
old_width=old_width,
|
||||
new_width=new_width,
|
||||
old_paragraphs=old_paragraphs,
|
||||
new_paragraphs=new_paragraphs,
|
||||
geometry=slot.get("geometry") or {},
|
||||
text_metrics=slot.get("text_metrics") or {},
|
||||
)
|
||||
capacity_width = _capacity_for_report(
|
||||
role=str(slot.get("role") or ""),
|
||||
old_width=old_width,
|
||||
old_paragraphs=old_paragraphs,
|
||||
new_paragraphs=new_paragraphs,
|
||||
geometry=slot.get("geometry") or {},
|
||||
text_metrics=slot.get("text_metrics") or {},
|
||||
)
|
||||
summary["warn" if status == "WARN" else "ok"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": status,
|
||||
"code": "text_capacity" if status == "WARN" else "text_fit",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"slot_id": slot.get("slot_id"),
|
||||
"role": slot.get("role"),
|
||||
"old_len": _display_width(old_width),
|
||||
"new_len": _display_width(new_width),
|
||||
"old_visual_width": _display_width(old_width),
|
||||
"new_visual_width": _display_width(new_width),
|
||||
"capacity_visual_width": capacity_width,
|
||||
"ratio": round(new_width / max(old_width, 1.0), 2),
|
||||
"old_paragraphs": old_paragraphs,
|
||||
"new_paragraphs": new_paragraphs,
|
||||
"message": message,
|
||||
"old_text": old_text,
|
||||
"new_text": text,
|
||||
}
|
||||
)
|
||||
table_edits = slide.get("table_edits", [])
|
||||
if not isinstance(table_edits, list):
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "table_edits_not_list",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"message": "table_edits must be a list",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
for table_edit in table_edits:
|
||||
selectors = _table_selectors(table_edit)
|
||||
table = next((table_lookup.get((source_slide, selector)) for selector in selectors), None)
|
||||
if table is None:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "table_target_not_found",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "table target not found in slide library",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
cells = table_edit.get("cells", [])
|
||||
if not isinstance(cells, list):
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "table_cells_not_list",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "table edit cells must be a list",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
row_count = int(table.get("row_count") or 0)
|
||||
column_count = int(table.get("column_count") or 0)
|
||||
table_cells = _table_cell_lookup(table)
|
||||
merge_slaves = _table_merge_slave_lookup(table)
|
||||
for cell in cells:
|
||||
row = int(cell.get("row", -1))
|
||||
col = int(cell.get("col", -1))
|
||||
if (
|
||||
row < 0
|
||||
or col < 0
|
||||
or row >= row_count
|
||||
or col >= column_count
|
||||
or (table_cells and (row, col) not in table_cells)
|
||||
):
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "table_cell_out_of_bounds",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": f"table cell out of bounds: row={row} col={col}",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
if (row, col) in merge_slaves:
|
||||
anchor = merge_slaves[(row, col)]
|
||||
anchor_hint = ""
|
||||
if anchor is not None:
|
||||
anchor_hint = (
|
||||
f"; edit merge anchor row={anchor.get('row')} "
|
||||
f"col={anchor.get('col')} instead"
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "table_cell_is_merge_slave",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"table_id": table.get("table_id"),
|
||||
"row": row,
|
||||
"col": col,
|
||||
"message": (
|
||||
f"table cell row={row} col={col} is a merged-cell slave"
|
||||
f"{anchor_hint}"
|
||||
),
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
summary["ok"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "OK",
|
||||
"code": "table_target_exists",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"table_id": table.get("table_id"),
|
||||
"row": row,
|
||||
"col": col,
|
||||
"message": "table cell target exists",
|
||||
}
|
||||
)
|
||||
chart_edits = slide.get("chart_edits", [])
|
||||
if not isinstance(chart_edits, list):
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "chart_edits_not_list",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"message": "chart_edits must be a list",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
for chart_edit in chart_edits:
|
||||
selectors = _chart_selectors(chart_edit)
|
||||
chart = next((chart_lookup.get((source_slide, selector)) for selector in selectors), None)
|
||||
if chart is None:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "chart_target_not_found",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "chart target not found in slide library",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
capability_error, capability_warnings = _chart_edit_capability_review(chart)
|
||||
if capability_error is not None:
|
||||
error_code, error_message = capability_error
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": error_code,
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"chart_id": chart.get("chart_id"),
|
||||
"message": error_message,
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
for warning_code, warning_message in capability_warnings:
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": warning_code,
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"chart_id": chart.get("chart_id"),
|
||||
"message": warning_message,
|
||||
}
|
||||
)
|
||||
summary["warn"] += 1
|
||||
categories = chart_edit.get("categories", [])
|
||||
series = chart_edit.get("series", [])
|
||||
if not isinstance(categories, list) or not isinstance(series, list) or not series:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "chart_data_invalid",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "chart edit requires categories list and non-empty series list",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
bad_series = [
|
||||
item
|
||||
for item in series
|
||||
if not isinstance(item, dict)
|
||||
or not isinstance(item.get("values", []), list)
|
||||
or len(item.get("values", [])) != len(categories)
|
||||
]
|
||||
if bad_series:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "chart_series_length_mismatch",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"selector": selectors[0] if selectors else "",
|
||||
"message": "each chart series needs values matching categories length",
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
continue
|
||||
summary["ok"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "OK",
|
||||
"code": "chart_target_valid",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"chart_id": chart.get("chart_id"),
|
||||
"category_count": len(categories),
|
||||
"series_count": len(series),
|
||||
"message": "chart edit target and data shape are valid",
|
||||
}
|
||||
)
|
||||
# --- Guardrail 2: source slides with non-text content not covered by edits ---
|
||||
# Tables/charts may be covered by explicit edits. SmartArt is preserve-only,
|
||||
# so selecting a source slide that contains it always needs a content review.
|
||||
lib_slides = _library_slide_index(library)
|
||||
for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
lib_slide = lib_slides.get(source_slide)
|
||||
if lib_slide is None:
|
||||
continue
|
||||
lib_tables = lib_slide.get("tables", [])
|
||||
lib_charts = lib_slide.get("charts", [])
|
||||
lib_diagrams = lib_slide.get("diagrams", [])
|
||||
if not lib_tables and not lib_charts and not lib_diagrams:
|
||||
continue
|
||||
# Check whether the plan slide provides edits covering the non-text content.
|
||||
has_table_edits = bool(slide.get("table_edits"))
|
||||
has_chart_edits = bool(slide.get("chart_edits"))
|
||||
uncovered_kinds: list[str] = []
|
||||
if lib_tables and not has_table_edits:
|
||||
uncovered_kinds.append("table")
|
||||
if lib_charts and not has_chart_edits:
|
||||
uncovered_kinds.append("chart")
|
||||
if lib_diagrams:
|
||||
uncovered_kinds.append("smartart")
|
||||
if not uncovered_kinds:
|
||||
continue
|
||||
kind_str = "/".join(uncovered_kinds)
|
||||
guidance = "add table_edits/chart_edits, or pick another source slide"
|
||||
if lib_diagrams:
|
||||
guidance = "SmartArt remains unchanged, so pick another source slide or accept this warning"
|
||||
if any(kind in uncovered_kinds for kind in ("table", "chart")):
|
||||
guidance = f"add table/chart edits where supported; {guidance}"
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "non_text_content_unedited",
|
||||
"plan_slide": plan_slide_index,
|
||||
"source_slide": source_slide,
|
||||
"uncovered_kinds": uncovered_kinds,
|
||||
"diagram_ids": [
|
||||
diagram.get("diagram_id")
|
||||
for diagram in lib_diagrams
|
||||
if diagram.get("diagram_id")
|
||||
],
|
||||
"message": (
|
||||
f"source slide {source_slide} has non-text content ({kind_str}) "
|
||||
"outside the plan's supported edits; template-fill leaves it untouched "
|
||||
f"and original template content may show through ({guidance})"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# --- Guardrail 1: same source slide reused too many times while unused layouts exist ---
|
||||
# Use a relative condition rather than an absolute threshold: only warn when
|
||||
# (a) a source slide is reused >= REUSE_WARN_THRESHOLD times, AND
|
||||
# (b) there are library slides that the plan never uses at all.
|
||||
# Rationale: a small template where every layout is referenced is fine even at
|
||||
# high per-slide reuse; "15-page template where only 1 page is ever cloned and
|
||||
# the rest sit idle" is the real smell we want to surface.
|
||||
# Threshold of 3: any source appearing 3+ times in a plan is meaningful reuse
|
||||
# (cover / TOC / ending typically appear at most twice), so >= 3 is a practical
|
||||
# signal without being overly sensitive.
|
||||
REUSE_WARN_THRESHOLD = 3
|
||||
source_use_counts: dict[int, int] = {}
|
||||
for slide in plan.get("slides", []):
|
||||
src = int(slide.get("source_slide", 0))
|
||||
if src:
|
||||
source_use_counts[src] = source_use_counts.get(src, 0) + 1
|
||||
all_lib_indices = {int(s.get("slide_index", 0)) for s in library.get("slides", []) if s.get("slide_index")}
|
||||
used_lib_indices = set(source_use_counts.keys())
|
||||
unused_lib_indices = sorted(all_lib_indices - used_lib_indices)
|
||||
if unused_lib_indices:
|
||||
for src, count in sorted(source_use_counts.items()):
|
||||
if count >= REUSE_WARN_THRESHOLD:
|
||||
unused_list = ", ".join(str(i) for i in unused_lib_indices)
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "source_reuse_concentration",
|
||||
"source_slide": src,
|
||||
"reuse_count": count,
|
||||
"unused_source_slides": unused_lib_indices,
|
||||
"message": (
|
||||
f"source slide {src} is reused {count} times while "
|
||||
f"{len(unused_lib_indices)} source layout(s) are never used "
|
||||
f"(indices: {unused_list}); "
|
||||
"consider using other layouts for more variety"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {"schema": "template_fill_pptx_check.v1", "summary": summary, "results": results}
|
||||
|
||||
|
||||
def print_check_report(report: dict[str, Any]) -> None:
|
||||
summary = report["summary"]
|
||||
print(f"check-plan: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
|
||||
for item in report["results"]:
|
||||
if item["status"] == "OK":
|
||||
continue
|
||||
if "ratio" in item:
|
||||
line = (
|
||||
"{status} P{plan_slide:02d} source={source_slide} {slot_id} "
|
||||
"{role} old={old_len} new={new_len} ratio={ratio}: {message}".format(**item)
|
||||
)
|
||||
elif "plan_slide" in item:
|
||||
target = item.get("slot_id") or item.get("selector") or ""
|
||||
line = (
|
||||
f"{item['status']} P{item['plan_slide']:02d} "
|
||||
f"source={item['source_slide']} {target}: {item['message']}".strip()
|
||||
)
|
||||
else:
|
||||
# Guardrail 1 WARNs are source-level (no plan_slide); print source + message.
|
||||
line = f"{item['status']} source={item.get('source_slide', '?')}: {item['message']}"
|
||||
print(line)
|
||||
Reference in New Issue
Block a user