Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""PPTX template fill — analyze a deck as a reusable slide library and fill text.
|
||||
|
||||
Direct OOXML editing (no SVG round-trip): select source slides, replace
|
||||
text / table / chart content from a fill plan, and write a new .pptx that keeps
|
||||
the original PowerPoint design. CLI stages mirror the direct-PPTX workflow:
|
||||
analyze -> scaffold -> check-plan -> apply -> validate.
|
||||
|
||||
Public entry: analyze_pptx(), scaffold_plan(), check_plan(), apply_plan(), main().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .analyzer import analyze_pptx
|
||||
from .applier import apply_plan
|
||||
from .checker import check_plan, print_check_report
|
||||
from .cli import main
|
||||
from .scaffolder import scaffold_plan
|
||||
from .validator import print_validate_report, validate_project
|
||||
|
||||
__all__ = [
|
||||
"analyze_pptx",
|
||||
"scaffold_plan",
|
||||
"check_plan",
|
||||
"print_check_report",
|
||||
"apply_plan",
|
||||
"validate_project",
|
||||
"print_validate_report",
|
||||
"main",
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Analyze a PPTX as reusable text, table, chart, and SmartArt source facts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .chart_read import empty_chart_data, read_chart_data
|
||||
from .diagram_read import read_smartart_diagrams
|
||||
from .edit_safety import (
|
||||
_chart_edit_capability,
|
||||
_chart_frames,
|
||||
_chart_reference,
|
||||
_table_cell_merge_info,
|
||||
_table_merge_topology,
|
||||
_unsupported_chart_capability,
|
||||
)
|
||||
from .ooxml import (
|
||||
CHART_REL_TYPE,
|
||||
NS,
|
||||
SlideRef,
|
||||
_container_geometry,
|
||||
_emu_to_px,
|
||||
_normalize_part,
|
||||
_paragraph_texts,
|
||||
_parse_slide_refs,
|
||||
_read_xml,
|
||||
_shape_identity,
|
||||
_slide_relationships,
|
||||
_table_containers,
|
||||
_text_containers,
|
||||
)
|
||||
|
||||
THANKS_KEYWORDS = ("thank", "thanks", "q&a", "qa", "contact", "致谢", "谢谢", "感谢", "答疑", "联系方式")
|
||||
TOC_KEYWORDS = ("agenda", "contents", "content", "outline", "目录", "议程")
|
||||
CHAPTER_KEYWORDS = ("chapter", "part", "section", "章节", "部分")
|
||||
|
||||
|
||||
def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str, Any]]:
|
||||
tables: list[dict[str, Any]] = []
|
||||
for order, container in enumerate(_table_containers(slide_root), start=1):
|
||||
shape_id, _shape_name = _shape_identity(container, order)
|
||||
table = container.find(".//a:tbl", NS)
|
||||
if table is None:
|
||||
continue
|
||||
merge_topology = _table_merge_topology(table)
|
||||
merge_anchors = {
|
||||
(int(item["row"]), int(item["col"])): item.get("anchor")
|
||||
for item in merge_topology["slave_cells"]
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
max_columns = 0
|
||||
for row_index, row in enumerate(table.findall("a:tr", NS)):
|
||||
cells: list[dict[str, Any]] = []
|
||||
for col_index, cell in enumerate(row.findall("a:tc", NS)):
|
||||
merge_info = _table_cell_merge_info(cell)
|
||||
merge_anchor = merge_anchors.get((row_index, col_index))
|
||||
if merge_anchor is not None:
|
||||
merge_info["merge_anchor"] = merge_anchor
|
||||
merge_info["anchor_row"] = merge_anchor["row"]
|
||||
merge_info["anchor_col"] = merge_anchor["col"]
|
||||
elif merge_info["is_merge_slave"]:
|
||||
merge_info["anchor_row"] = None
|
||||
merge_info["anchor_col"] = None
|
||||
else:
|
||||
merge_info["anchor_row"] = row_index
|
||||
merge_info["anchor_col"] = col_index
|
||||
cells.append(
|
||||
{
|
||||
"row": row_index,
|
||||
"col": col_index,
|
||||
"text": "\n".join(_paragraph_texts(cell)),
|
||||
**merge_info,
|
||||
}
|
||||
)
|
||||
max_columns = max(max_columns, len(cells))
|
||||
rows.append({"row": row_index, "cells": cells})
|
||||
tables.append(
|
||||
{
|
||||
"table_id": f"s{source_slide:02d}_tbl{shape_id}",
|
||||
"row_count": len(rows),
|
||||
"column_count": max_columns,
|
||||
"rows": rows,
|
||||
"merge_topology": merge_topology,
|
||||
}
|
||||
)
|
||||
return tables
|
||||
|
||||
|
||||
def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: SlideRef) -> list[dict[str, Any]]:
|
||||
charts: list[dict[str, Any]] = []
|
||||
relationships = _slide_relationships(zf, slide_ref.rels_name)
|
||||
for order, container in enumerate(_chart_frames(slide_root), start=1):
|
||||
shape_id, _shape_name = _shape_identity(container, order)
|
||||
chart_kind, rel_id = _chart_reference(container)
|
||||
payload: dict[str, Any] = {"chart_id": f"s{slide_ref.index:02d}_ch{shape_id}"}
|
||||
payload.update(empty_chart_data())
|
||||
payload["chart_kind"] = chart_kind
|
||||
if chart_kind == "chartex":
|
||||
payload["chart_type"] = "chartEx"
|
||||
payload["plot_types"] = ["chartEx"]
|
||||
payload["edit_capability"] = _unsupported_chart_capability(
|
||||
"chart_edit_chartex_unsupported",
|
||||
"template-fill chart edits do not support ChartEx",
|
||||
)
|
||||
charts.append(payload)
|
||||
continue
|
||||
if chart_kind != "classic":
|
||||
payload["edit_capability"] = _unsupported_chart_capability(
|
||||
"chart_edit_plot_type_unsupported",
|
||||
"template-fill chart edits require a classic DrawingML chart reference",
|
||||
)
|
||||
charts.append(payload)
|
||||
continue
|
||||
|
||||
rel = relationships.get(rel_id)
|
||||
if rel and rel.get("type") == CHART_REL_TYPE:
|
||||
chart_part = _normalize_part(rel["target"], slide_ref.part_name)
|
||||
try:
|
||||
chart_root = _read_xml(zf, chart_part)
|
||||
payload.update(read_chart_data(chart_root))
|
||||
payload["edit_capability"] = _chart_edit_capability(chart_root)
|
||||
except RuntimeError:
|
||||
payload.update(empty_chart_data())
|
||||
payload["edit_capability"] = _unsupported_chart_capability(
|
||||
"chart_edit_part_unavailable",
|
||||
"template-fill could not read the classic chart part",
|
||||
)
|
||||
else:
|
||||
payload["edit_capability"] = _unsupported_chart_capability(
|
||||
"chart_edit_relationship_unsupported",
|
||||
"template-fill chart edits require a classic chart relationship",
|
||||
)
|
||||
charts.append(payload)
|
||||
return charts
|
||||
|
||||
|
||||
def _slot_role(slot: dict[str, Any], order: int) -> str:
|
||||
text = str(slot.get("text") or "")
|
||||
name = str(slot.get("shape_name") or "").lower()
|
||||
geometry = slot.get("geometry") or {}
|
||||
y = geometry.get("y")
|
||||
if order == 1 or "title" in name or "标题" in name:
|
||||
return "title_candidate"
|
||||
if isinstance(y, int) and y < 160 and len(text) <= 80:
|
||||
return "title_candidate"
|
||||
if slot.get("text_node_count", 0) >= 4 or len(text) >= 120:
|
||||
return "body_candidate"
|
||||
return "label_candidate"
|
||||
|
||||
|
||||
def _font_size_px(container: ET.Element) -> float | None:
|
||||
sizes: list[float] = []
|
||||
for node in container.findall(".//a:rPr", NS) + container.findall(".//a:defRPr", NS):
|
||||
raw_size = node.attrib.get("sz")
|
||||
if not raw_size:
|
||||
continue
|
||||
try:
|
||||
sizes.append(int(raw_size) / 100 * 96 / 72)
|
||||
except ValueError:
|
||||
continue
|
||||
if not sizes:
|
||||
return None
|
||||
# Use the largest explicit run size as the conservative capacity baseline.
|
||||
return round(max(sizes), 2)
|
||||
|
||||
|
||||
def _text_metrics(container: ET.Element, paragraph_count: int) -> dict[str, Any]:
|
||||
font_size_px = _font_size_px(container)
|
||||
return {
|
||||
"font_size_px": font_size_px,
|
||||
"paragraph_count": paragraph_count,
|
||||
}
|
||||
|
||||
|
||||
def _classify_page_type(index: int, total: int, text: str, slots: list[dict[str, Any]]) -> str:
|
||||
normalized = text.lower()
|
||||
if index == 1:
|
||||
return "cover_candidate"
|
||||
if index == total or any(keyword in normalized for keyword in THANKS_KEYWORDS):
|
||||
return "ending_candidate"
|
||||
if any(keyword in normalized for keyword in TOC_KEYWORDS):
|
||||
return "toc_candidate"
|
||||
if any(keyword in normalized for keyword in CHAPTER_KEYWORDS):
|
||||
return "chapter_candidate"
|
||||
if len(slots) <= 2 and len(text) <= 80:
|
||||
return "chapter_candidate"
|
||||
return "content_candidate"
|
||||
|
||||
|
||||
def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]:
|
||||
size = pres_root.find("p:sldSz", NS)
|
||||
if size is None:
|
||||
return {"width": None, "height": None}
|
||||
return {
|
||||
"width": _emu_to_px(size.attrib.get("cx")),
|
||||
"height": _emu_to_px(size.attrib.get("cy")),
|
||||
}
|
||||
|
||||
|
||||
def _fill_risk(
|
||||
tables: list[dict[str, Any]],
|
||||
charts: list[dict[str, Any]],
|
||||
diagrams: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace.
|
||||
|
||||
Tables and charts may be covered by explicit edits. SmartArt is inventory-only:
|
||||
template-fill preserves it unchanged, so its source text may show through.
|
||||
"""
|
||||
kinds: list[str] = []
|
||||
if tables:
|
||||
kinds.append("table")
|
||||
if charts:
|
||||
kinds.append("chart")
|
||||
if diagrams:
|
||||
kinds.append("smartart")
|
||||
if not kinds:
|
||||
return None
|
||||
kind_str = "/".join(kinds)
|
||||
guidance: list[str] = []
|
||||
if tables or charts:
|
||||
guidance.append("cover tables/charts with explicit edits")
|
||||
if diagrams:
|
||||
guidance.append("review preserved SmartArt source text")
|
||||
guidance_text = "; ".join(guidance)
|
||||
return {
|
||||
"has_non_text_content": True,
|
||||
"kinds": kinds,
|
||||
"reason": f"has non-text content ({kind_str}) that text-fill does not replace automatically; {guidance_text}",
|
||||
}
|
||||
|
||||
|
||||
def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
|
||||
"""Extract a slide library with text replacement slots."""
|
||||
with zipfile.ZipFile(pptx_path) as zf:
|
||||
pres_root = _read_xml(zf, "ppt/presentation.xml")
|
||||
slide_refs = _parse_slide_refs(zf)
|
||||
slides: list[dict[str, Any]] = []
|
||||
for slide_ref in slide_refs:
|
||||
slide_root = _read_xml(zf, slide_ref.part_name)
|
||||
slots: list[dict[str, Any]] = []
|
||||
for order, container in enumerate(_text_containers(slide_root), start=1):
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
paragraphs = _paragraph_texts(container)
|
||||
text = "\n".join(paragraphs)
|
||||
geometry = _container_geometry(container)
|
||||
role = _slot_role(
|
||||
{
|
||||
"text": text,
|
||||
"shape_name": shape_name,
|
||||
"geometry": geometry,
|
||||
"text_node_count": len(container.findall(".//a:t", NS)),
|
||||
},
|
||||
order,
|
||||
)
|
||||
slots.append(
|
||||
{
|
||||
"slot_id": f"s{slide_ref.index:02d}_sh{shape_id}",
|
||||
"role": role,
|
||||
"text": text,
|
||||
"paragraph_count": len(paragraphs),
|
||||
"geometry": geometry,
|
||||
"text_metrics": _text_metrics(container, len(paragraphs)),
|
||||
}
|
||||
)
|
||||
|
||||
tables = _analyze_tables(slide_root, slide_ref.index)
|
||||
charts = _analyze_charts(zf, slide_root, slide_ref)
|
||||
diagrams = read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index)
|
||||
slide_text = "\n".join(
|
||||
[slot["text"] for slot in slots if slot["text"]]
|
||||
+ [
|
||||
str(text)
|
||||
for diagram in diagrams
|
||||
for text in diagram.get("text_items", [])
|
||||
if text
|
||||
]
|
||||
)
|
||||
slide: dict[str, Any] = {
|
||||
"slide_index": slide_ref.index,
|
||||
"page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots),
|
||||
"text_summary": slide_text[:500],
|
||||
"slots": slots,
|
||||
"tables": tables,
|
||||
"charts": charts,
|
||||
"diagrams": diagrams,
|
||||
}
|
||||
risk = _fill_risk(tables, charts, diagrams)
|
||||
if risk is not None:
|
||||
slide["fill_risk"] = risk
|
||||
slides.append(slide)
|
||||
|
||||
return {
|
||||
"schema": "template_fill_pptx_library.v1",
|
||||
"source_pptx": str(pptx_path),
|
||||
"slide_count": len(slides),
|
||||
"canvas_px": _canvas_px(pres_root),
|
||||
"slides": slides,
|
||||
"plan_contract": {
|
||||
"schema": "template_fill_pptx_plan.v1",
|
||||
"slides": [
|
||||
{
|
||||
"source_slide": 1,
|
||||
"purpose": "封面 / 章节 / 内容 / 结尾",
|
||||
"replacements": [
|
||||
{
|
||||
"slot_id": "s01_sh2",
|
||||
"text": "替换后的文字",
|
||||
}
|
||||
],
|
||||
"table_edits": [
|
||||
{
|
||||
"table_id": "s01_tbl3",
|
||||
"cells": [{"row": 0, "col": 0, "text": "替换后的单元格"}],
|
||||
}
|
||||
],
|
||||
"chart_edits": [
|
||||
{
|
||||
"chart_id": "s01_ch4",
|
||||
"categories": ["A", "B"],
|
||||
"series": [{"name": "系列1", "values": [1, 2]}],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"""apply: clone selected source slides into a new PPTX and write replacements.
|
||||
|
||||
Orchestrates the per-stage helpers (text / table / chart / transition / notes)
|
||||
and rebuilds the presentation slide list, relationships, and content types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_animations import (
|
||||
object_animation_fingerprint,
|
||||
validate_pptx_animation_package,
|
||||
)
|
||||
from pptx_transitions import (
|
||||
parse_source_xml,
|
||||
serialize_source_xml,
|
||||
set_package_use_timings,
|
||||
validate_pptx_transition_package,
|
||||
)
|
||||
|
||||
from .chart_fill import (
|
||||
_apply_chart_edits_to_slide_package,
|
||||
_max_chart_part_number,
|
||||
_max_embedding_part_number,
|
||||
)
|
||||
from .clone import _make_part_allocator, deep_clone_slide_private_parts
|
||||
from .notes import _find_notes_master_target, _slide_rels_with_notes
|
||||
from .ooxml import NS, REL_NS, SLIDE_REL_TYPE, _parse_slide_refs, _qn, _xml_bytes
|
||||
from .package import (
|
||||
_add_notes_override,
|
||||
_add_slide_override,
|
||||
_content_type_root,
|
||||
_empty_relationships_root,
|
||||
_max_numeric_rid,
|
||||
_max_slide_id,
|
||||
_max_slide_part_number,
|
||||
_prune_unreferenced_parts,
|
||||
)
|
||||
from .table_fill import _apply_table_edits_to_slide
|
||||
from .text_fill import _apply_replacements_to_slide
|
||||
from .transitions import (
|
||||
DEFAULT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
_resolve_slide_transition,
|
||||
_set_slide_transition,
|
||||
)
|
||||
|
||||
|
||||
def apply_plan(
|
||||
pptx_path: Path,
|
||||
plan: dict[str, Any],
|
||||
output_path: Path,
|
||||
*,
|
||||
transition: str | None = DEFAULT_TRANSITION,
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
) -> None:
|
||||
"""Create a filled PPTX by cloning selected source slides and replacing text."""
|
||||
plan_slides = plan.get("slides")
|
||||
if not isinstance(plan_slides, list) or not plan_slides:
|
||||
raise RuntimeError("Plan must contain a non-empty 'slides' list")
|
||||
|
||||
with zipfile.ZipFile(pptx_path) as zf:
|
||||
entries = {info.filename: zf.read(info.filename) for info in zf.infolist() if not info.is_dir()}
|
||||
slide_refs = {slide.index: slide for slide in _parse_slide_refs(zf)}
|
||||
notes_master_target = _find_notes_master_target(entries)
|
||||
|
||||
pres_root = ET.fromstring(entries["ppt/presentation.xml"])
|
||||
pres_rels_root = ET.fromstring(entries["ppt/_rels/presentation.xml.rels"])
|
||||
content_root = _content_type_root(ET.fromstring(entries["[Content_Types].xml"]))
|
||||
sld_id_lst = pres_root.find("p:sldIdLst", NS)
|
||||
if sld_id_lst is None:
|
||||
sld_id_lst = ET.SubElement(pres_root, _qn(NS["p"], "sldIdLst"))
|
||||
|
||||
for child in list(sld_id_lst):
|
||||
sld_id_lst.remove(child)
|
||||
for rel in list(pres_rels_root.findall(_qn(REL_NS, "Relationship"))):
|
||||
if rel.attrib.get("Type") == SLIDE_REL_TYPE:
|
||||
pres_rels_root.remove(rel)
|
||||
|
||||
next_slide_number = _max_slide_part_number(entries) + 1
|
||||
next_slide_id = _max_slide_id(sld_id_lst) + 1
|
||||
next_rel_number = _max_numeric_rid(pres_rels_root) + 1
|
||||
next_chart_number = _max_chart_part_number(entries)
|
||||
next_embedding_number = _max_embedding_part_number(entries)
|
||||
allocate_part = _make_part_allocator(entries)
|
||||
wrote_auto_advance = False
|
||||
|
||||
for offset, item in enumerate(plan_slides):
|
||||
source_slide = int(item.get("source_slide", 0))
|
||||
if source_slide not in slide_refs:
|
||||
raise RuntimeError(f"Plan references a missing source slide: {source_slide}")
|
||||
source_ref = slide_refs[source_slide]
|
||||
new_slide_number = next_slide_number + offset
|
||||
new_part = f"ppt/slides/slide{new_slide_number}.xml"
|
||||
new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels"
|
||||
new_rid = f"rId{next_rel_number + offset}"
|
||||
|
||||
source_slide_xml = entries[source_ref.part_name]
|
||||
source_animation_fingerprint = object_animation_fingerprint(
|
||||
source_slide_xml
|
||||
)
|
||||
slide_root = parse_source_xml(source_slide_xml)
|
||||
replacements = item.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
raise RuntimeError(f"Slide {source_slide} replacements must be a list")
|
||||
_apply_replacements_to_slide(
|
||||
slide_root,
|
||||
source_slide=source_slide,
|
||||
replacements=replacements,
|
||||
)
|
||||
table_edits = item.get("table_edits", [])
|
||||
if not isinstance(table_edits, list):
|
||||
raise RuntimeError(f"Slide {source_slide} table_edits must be a list")
|
||||
_apply_table_edits_to_slide(
|
||||
slide_root,
|
||||
source_slide=source_slide,
|
||||
table_edits=table_edits,
|
||||
)
|
||||
(
|
||||
slide_effect,
|
||||
slide_effect_options,
|
||||
slide_duration,
|
||||
slide_advance,
|
||||
) = _resolve_slide_transition(
|
||||
item,
|
||||
default_effect=transition,
|
||||
default_duration=transition_duration,
|
||||
)
|
||||
slide_has_auto_advance = _set_slide_transition(
|
||||
slide_root,
|
||||
effect=slide_effect,
|
||||
effect_options=slide_effect_options,
|
||||
duration=slide_duration,
|
||||
advance_after=slide_advance,
|
||||
)
|
||||
if slide_advance is not None and slide_has_auto_advance:
|
||||
wrote_auto_advance = True
|
||||
|
||||
source_rels = entries.get(source_ref.rels_name)
|
||||
slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root()
|
||||
deep_clone_slide_private_parts(
|
||||
slide_rels_root,
|
||||
new_slide_part=new_part,
|
||||
entries=entries,
|
||||
content_root=content_root,
|
||||
allocate=allocate_part,
|
||||
)
|
||||
chart_edits = item.get("chart_edits", [])
|
||||
if not isinstance(chart_edits, list):
|
||||
raise RuntimeError(f"Slide {source_slide} chart_edits must be a list")
|
||||
next_chart_number, next_embedding_number = _apply_chart_edits_to_slide_package(
|
||||
slide_root,
|
||||
slide_rels_root,
|
||||
entries,
|
||||
content_root,
|
||||
source_slide=source_slide,
|
||||
new_slide_part=new_part,
|
||||
chart_edits=chart_edits,
|
||||
next_chart_number=next_chart_number,
|
||||
next_embedding_number=next_embedding_number,
|
||||
)
|
||||
try:
|
||||
serialized_slide = serialize_source_xml(
|
||||
slide_root,
|
||||
source_slide_xml,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if (
|
||||
object_animation_fingerprint(serialized_slide)
|
||||
!= source_animation_fingerprint
|
||||
):
|
||||
raise RuntimeError(
|
||||
f'Slide {source_slide} object animations changed during template fill'
|
||||
)
|
||||
entries[new_part] = serialized_slide
|
||||
notes_text = str(item.get("notes") or item.get("speaker_notes") or "")
|
||||
entries[new_rels], note_entries = _slide_rels_with_notes(
|
||||
_xml_bytes(slide_rels_root),
|
||||
slide_number=new_slide_number,
|
||||
notes_text=notes_text,
|
||||
notes_master_target=notes_master_target,
|
||||
)
|
||||
entries.update(note_entries)
|
||||
_add_slide_override(content_root, new_part)
|
||||
if note_entries:
|
||||
_add_notes_override(content_root, f"ppt/notesSlides/notesSlide{new_slide_number}.xml")
|
||||
|
||||
ET.SubElement(
|
||||
pres_rels_root,
|
||||
_qn(REL_NS, "Relationship"),
|
||||
{
|
||||
"Id": new_rid,
|
||||
"Type": SLIDE_REL_TYPE,
|
||||
"Target": f"slides/slide{new_slide_number}.xml",
|
||||
},
|
||||
)
|
||||
ET.SubElement(
|
||||
sld_id_lst,
|
||||
_qn(NS["p"], "sldId"),
|
||||
{"id": str(next_slide_id + offset), _qn(NS["r"], "id"): new_rid},
|
||||
)
|
||||
|
||||
entries["ppt/presentation.xml"] = _xml_bytes(pres_root)
|
||||
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root)
|
||||
_prune_unreferenced_parts(entries, content_root)
|
||||
entries["[Content_Types].xml"] = _xml_bytes(content_root)
|
||||
if wrote_auto_advance:
|
||||
try:
|
||||
set_package_use_timings(entries)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="template-fill-pptx-",
|
||||
dir=output_path.parent,
|
||||
) as temp_dir:
|
||||
candidate_path = Path(temp_dir) / output_path.name
|
||||
with zipfile.ZipFile(
|
||||
candidate_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as out:
|
||||
for name, data in entries.items():
|
||||
out.writestr(name, data)
|
||||
try:
|
||||
validate_pptx_transition_package(
|
||||
candidate_path,
|
||||
require_use_timings=wrote_auto_advance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX transition package validation failed: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_animation_package(
|
||||
candidate_path,
|
||||
require_supported_effects=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX animation/timing package validation failed: {exc}"
|
||||
) from exc
|
||||
candidate_path.replace(output_path)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""apply: edit native PowerPoint chart data on cloned slides.
|
||||
|
||||
Each referenced chart part is cloned, its ``<c:ser>`` caches are rewritten from
|
||||
the plan's categories / series, and the embedded ``.xlsx`` workbook is rebuilt so
|
||||
PowerPoint's "Edit Data" view stays consistent. Chart styling / axes / legend
|
||||
layout are left untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .edit_safety import (
|
||||
_chart_frames,
|
||||
_chart_reference,
|
||||
_require_supported_chart_edit,
|
||||
)
|
||||
from .ooxml import (
|
||||
CHART_CONTENT_TYPE,
|
||||
CHART_REL_TYPE,
|
||||
NS,
|
||||
PACKAGE_REL_TYPE,
|
||||
REL_NS,
|
||||
XLSX_CONTENT_TYPE,
|
||||
_normalize_part,
|
||||
_qn,
|
||||
_rels_name_for_part,
|
||||
_shape_identity,
|
||||
_xml_bytes,
|
||||
)
|
||||
from .package import _add_content_type_override, _find_relationship, _relative_target
|
||||
from .selectors import _chart_selectors
|
||||
|
||||
|
||||
def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]:
|
||||
maps: dict[str, dict[str, str]] = {}
|
||||
for order, container in enumerate(_chart_frames(slide_root), start=1):
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
chart_kind, rel_id = _chart_reference(container)
|
||||
info = {
|
||||
"shape_id": shape_id,
|
||||
"shape_name": shape_name,
|
||||
"rel_id": rel_id,
|
||||
"chart_kind": chart_kind,
|
||||
}
|
||||
maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info
|
||||
maps[f"shape_id:{shape_id}"] = info
|
||||
if shape_name:
|
||||
maps[f"shape_name:{shape_name}"] = info
|
||||
return maps
|
||||
|
||||
|
||||
def _max_chart_part_number(entries: dict[str, bytes]) -> int:
|
||||
max_number = 0
|
||||
pattern = re.compile(r"^ppt/charts/chart(\d+)\.xml$")
|
||||
for name in entries:
|
||||
match = pattern.match(name)
|
||||
if match:
|
||||
max_number = max(max_number, int(match.group(1)))
|
||||
return max_number
|
||||
|
||||
|
||||
def _max_embedding_part_number(entries: dict[str, bytes]) -> int:
|
||||
max_number = 0
|
||||
pattern = re.compile(r"^ppt/embeddings/templateFillChart(\d+)\.xlsx$")
|
||||
for name in entries:
|
||||
match = pattern.match(name)
|
||||
if match:
|
||||
max_number = max(max_number, int(match.group(1)))
|
||||
return max_number
|
||||
|
||||
|
||||
def _chart_part_from_relationship(slide_part: str, rel: ET.Element) -> str:
|
||||
target = rel.attrib.get("Target", "")
|
||||
if rel.attrib.get("Type") != CHART_REL_TYPE or not target:
|
||||
raise RuntimeError("Matched chart shape does not point to a chart relationship")
|
||||
return _normalize_part(target, slide_part)
|
||||
|
||||
|
||||
def _chart_type_with_series(chart_root: ET.Element) -> ET.Element:
|
||||
plot_area = chart_root.find(".//c:plotArea", NS)
|
||||
if plot_area is None:
|
||||
raise RuntimeError("Chart XML has no plotArea")
|
||||
chart_types: list[ET.Element] = []
|
||||
for child in list(plot_area):
|
||||
if child.tag.endswith("Chart") and child.findall("c:ser", NS):
|
||||
chart_types.append(child)
|
||||
if len(chart_types) > 1:
|
||||
raise RuntimeError(
|
||||
"template-fill chart edits do not support multi-plot / combination charts; "
|
||||
"use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
|
||||
)
|
||||
if chart_types:
|
||||
return chart_types[0]
|
||||
raise RuntimeError("Chart XML has no editable series")
|
||||
|
||||
|
||||
def _ensure_child(parent: ET.Element, tag: str) -> ET.Element:
|
||||
child = parent.find(tag, NS)
|
||||
if child is not None:
|
||||
return child
|
||||
return ET.SubElement(parent, _qn(NS["c"], tag.split(":", 1)[1]))
|
||||
|
||||
|
||||
def _set_val_attr(element: ET.Element, value: str | int | float) -> None:
|
||||
element.set("val", str(value))
|
||||
|
||||
|
||||
def _excel_col(index: int) -> str:
|
||||
result = ""
|
||||
while index:
|
||||
index, remainder = divmod(index - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result or "A"
|
||||
|
||||
|
||||
def _write_string_cache(cache: ET.Element, values: list[str]) -> None:
|
||||
for child in list(cache):
|
||||
cache.remove(child)
|
||||
pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
|
||||
_set_val_attr(pt_count, len(values))
|
||||
for index, value in enumerate(values):
|
||||
pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
|
||||
v = ET.SubElement(pt, _qn(NS["c"], "v"))
|
||||
v.text = str(value)
|
||||
|
||||
|
||||
def _write_number_cache(cache: ET.Element, values: list[Any]) -> None:
|
||||
for child in list(cache):
|
||||
cache.remove(child)
|
||||
fmt = ET.SubElement(cache, _qn(NS["c"], "formatCode"))
|
||||
fmt.text = "General"
|
||||
pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
|
||||
_set_val_attr(pt_count, len(values))
|
||||
for index, value in enumerate(values):
|
||||
pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
|
||||
v = ET.SubElement(pt, _qn(NS["c"], "v"))
|
||||
v.text = str(value)
|
||||
|
||||
|
||||
def _set_series_name(series: ET.Element, name: str, column_index: int) -> None:
|
||||
tx = _ensure_child(series, "c:tx")
|
||||
for child in list(tx):
|
||||
tx.remove(child)
|
||||
str_ref = ET.SubElement(tx, _qn(NS["c"], "strRef"))
|
||||
formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
|
||||
formula.text = f"Sheet1!${_excel_col(column_index)}$1"
|
||||
cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
|
||||
_write_string_cache(cache, [name])
|
||||
|
||||
|
||||
def _set_category_cache(series: ET.Element, categories: list[str]) -> None:
|
||||
cat = _ensure_child(series, "c:cat")
|
||||
for child in list(cat):
|
||||
cat.remove(child)
|
||||
str_ref = ET.SubElement(cat, _qn(NS["c"], "strRef"))
|
||||
formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
|
||||
formula.text = f"Sheet1!$A$2:$A${len(categories) + 1}"
|
||||
cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
|
||||
_write_string_cache(cache, [str(item) for item in categories])
|
||||
|
||||
|
||||
def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) -> None:
|
||||
val = _ensure_child(series, "c:val")
|
||||
for child in list(val):
|
||||
val.remove(child)
|
||||
num_ref = ET.SubElement(val, _qn(NS["c"], "numRef"))
|
||||
formula = ET.SubElement(num_ref, _qn(NS["c"], "f"))
|
||||
formula.text = f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(values) + 1}"
|
||||
cache = ET.SubElement(num_ref, _qn(NS["c"], "numCache"))
|
||||
_write_number_cache(cache, values)
|
||||
|
||||
|
||||
def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None:
|
||||
capability = _require_supported_chart_edit(chart_root)
|
||||
for warning in capability.get("warnings", []):
|
||||
if not isinstance(warning, dict):
|
||||
continue
|
||||
code = warning.get("code") or "chart_edit_category_flattened"
|
||||
message = warning.get("message") or "chart categories will be flattened"
|
||||
print(f" Warning: {message} [{code}]", file=sys.stderr)
|
||||
categories = [str(item) for item in chart_edit.get("categories", [])]
|
||||
series_payload = chart_edit.get("series", [])
|
||||
if not categories or not isinstance(series_payload, list) or not series_payload:
|
||||
raise RuntimeError("Chart edit requires non-empty categories and series")
|
||||
chart_type = _chart_type_with_series(chart_root)
|
||||
series_nodes = chart_type.findall("c:ser", NS)
|
||||
if not series_nodes:
|
||||
raise RuntimeError("Chart XML has no editable series")
|
||||
template_series = series_nodes[-1]
|
||||
while len(series_nodes) < len(series_payload):
|
||||
clone = ET.fromstring(ET.tostring(template_series, encoding="utf-8"))
|
||||
chart_type.append(clone)
|
||||
series_nodes.append(clone)
|
||||
for extra in series_nodes[len(series_payload) :]:
|
||||
chart_type.remove(extra)
|
||||
series_nodes = chart_type.findall("c:ser", NS)
|
||||
for index, (series, payload) in enumerate(zip(series_nodes, series_payload), start=0):
|
||||
values = payload.get("values", [])
|
||||
if len(values) != len(categories):
|
||||
raise RuntimeError("Chart series values must match categories length")
|
||||
idx = _ensure_child(series, "c:idx")
|
||||
order = _ensure_child(series, "c:order")
|
||||
_set_val_attr(idx, index)
|
||||
_set_val_attr(order, index)
|
||||
_set_series_name(series, str(payload.get("name", f"系列{index + 1}")), index + 2)
|
||||
_set_category_cache(series, categories)
|
||||
_set_value_cache(series, values, index + 2)
|
||||
|
||||
|
||||
def _spreadsheet_relationships(xlsx_entries: dict[str, bytes], part_name: str) -> dict[str, str]:
|
||||
rels_name = _rels_name_for_part(part_name)
|
||||
if rels_name not in xlsx_entries:
|
||||
return {}
|
||||
root = ET.fromstring(xlsx_entries[rels_name])
|
||||
rels: dict[str, str] = {}
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
rel_id = rel.attrib.get("Id")
|
||||
target = rel.attrib.get("Target")
|
||||
if rel_id and target:
|
||||
rels[rel_id] = _normalize_part(target, part_name)
|
||||
return rels
|
||||
|
||||
|
||||
def _first_workbook_sheet(xlsx_entries: dict[str, bytes]) -> str | None:
|
||||
workbook_part = "xl/workbook.xml"
|
||||
if workbook_part not in xlsx_entries:
|
||||
return None
|
||||
root = ET.fromstring(xlsx_entries[workbook_part])
|
||||
sheets = root.find("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}sheets")
|
||||
if sheets is None:
|
||||
return None
|
||||
first = next(iter(list(sheets)), None)
|
||||
if first is None:
|
||||
return None
|
||||
rel_id = first.attrib.get(_qn(NS["r"], "id"))
|
||||
if not rel_id:
|
||||
return None
|
||||
return _spreadsheet_relationships(xlsx_entries, workbook_part).get(rel_id)
|
||||
|
||||
|
||||
def _spreadsheet_cell_ref(row: int, col: int) -> str:
|
||||
return f"{_excel_col(col)}{row}"
|
||||
|
||||
|
||||
def _spreadsheet_cell(value: Any, row: int, col: int) -> ET.Element:
|
||||
cell = ET.Element(
|
||||
"{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c",
|
||||
{"r": _spreadsheet_cell_ref(row, col)},
|
||||
)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
v = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v")
|
||||
v.text = str(value)
|
||||
return cell
|
||||
cell.set("t", "inlineStr")
|
||||
inline = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}is")
|
||||
text = ET.SubElement(inline, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t")
|
||||
text.text = str(value)
|
||||
return cell
|
||||
|
||||
|
||||
def _rewrite_chart_workbook(xlsx_bytes: bytes, chart_edit: dict[str, Any]) -> bytes:
|
||||
categories = chart_edit.get("categories", [])
|
||||
series_payload = chart_edit.get("series", [])
|
||||
with zipfile.ZipFile(io.BytesIO(xlsx_bytes)) as zin:
|
||||
xlsx_entries = {info.filename: zin.read(info.filename) for info in zin.infolist() if not info.is_dir()}
|
||||
sheet_part = _first_workbook_sheet(xlsx_entries) or "xl/worksheets/sheet1.xml"
|
||||
if sheet_part not in xlsx_entries:
|
||||
return xlsx_bytes
|
||||
sheet_root = ET.fromstring(xlsx_entries[sheet_part])
|
||||
sheet_ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
sheet_data = sheet_root.find(_qn(sheet_ns, "sheetData"))
|
||||
if sheet_data is None:
|
||||
sheet_data = ET.SubElement(sheet_root, _qn(sheet_ns, "sheetData"))
|
||||
for child in list(sheet_data):
|
||||
sheet_data.remove(child)
|
||||
|
||||
rows = [["Category"] + [str(item.get("name", f"系列{idx + 1}")) for idx, item in enumerate(series_payload)]]
|
||||
for row_index, category in enumerate(categories):
|
||||
rows.append([category] + [item.get("values", [])[row_index] for item in series_payload])
|
||||
for row_index, values in enumerate(rows, start=1):
|
||||
row = ET.SubElement(sheet_data, _qn(sheet_ns, "row"), {"r": str(row_index)})
|
||||
for col_index, value in enumerate(values, start=1):
|
||||
row.append(_spreadsheet_cell(value, row_index, col_index))
|
||||
xlsx_entries[sheet_part] = _xml_bytes(sheet_root)
|
||||
|
||||
out_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(out_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in xlsx_entries.items():
|
||||
zout.writestr(name, data)
|
||||
return out_buffer.getvalue()
|
||||
|
||||
|
||||
def _find_chart_workbook_rel(chart_rels_root: ET.Element) -> ET.Element | None:
|
||||
for rel in chart_rels_root.findall(_qn(REL_NS, "Relationship")):
|
||||
target = rel.attrib.get("Target", "")
|
||||
if rel.attrib.get("Type") == PACKAGE_REL_TYPE or target.lower().endswith(".xlsx"):
|
||||
return rel
|
||||
return None
|
||||
|
||||
|
||||
def _clone_and_update_chart_part(
|
||||
entries: dict[str, bytes],
|
||||
content_root: ET.Element,
|
||||
*,
|
||||
source_chart_part: str,
|
||||
new_chart_part: str,
|
||||
chart_edit: dict[str, Any],
|
||||
next_embedding_number: int,
|
||||
) -> int:
|
||||
if source_chart_part not in entries:
|
||||
raise RuntimeError(f"Missing chart part: {source_chart_part}")
|
||||
chart_root = ET.fromstring(entries[source_chart_part])
|
||||
_apply_chart_edit_to_chart_xml(chart_root, chart_edit)
|
||||
entries[new_chart_part] = _xml_bytes(chart_root)
|
||||
_add_content_type_override(content_root, new_chart_part, CHART_CONTENT_TYPE)
|
||||
|
||||
source_chart_rels = _rels_name_for_part(source_chart_part)
|
||||
if source_chart_rels not in entries:
|
||||
return next_embedding_number
|
||||
new_chart_rels = _rels_name_for_part(new_chart_part)
|
||||
chart_rels_root = ET.fromstring(entries[source_chart_rels])
|
||||
workbook_rel = _find_chart_workbook_rel(chart_rels_root)
|
||||
if workbook_rel is not None:
|
||||
workbook_target = workbook_rel.attrib.get("Target", "")
|
||||
workbook_part = _normalize_part(workbook_target, source_chart_part)
|
||||
if workbook_part in entries:
|
||||
next_embedding_number += 1
|
||||
new_workbook_part = f"ppt/embeddings/templateFillChart{next_embedding_number}.xlsx"
|
||||
entries[new_workbook_part] = _rewrite_chart_workbook(entries[workbook_part], chart_edit)
|
||||
workbook_rel.set("Target", _relative_target(new_chart_part, new_workbook_part))
|
||||
_add_content_type_override(content_root, new_workbook_part, XLSX_CONTENT_TYPE)
|
||||
entries[new_chart_rels] = _xml_bytes(chart_rels_root)
|
||||
return next_embedding_number
|
||||
|
||||
|
||||
def _apply_chart_edits_to_slide_package(
|
||||
slide_root: ET.Element,
|
||||
rels_root: ET.Element,
|
||||
entries: dict[str, bytes],
|
||||
content_root: ET.Element,
|
||||
*,
|
||||
source_slide: int,
|
||||
new_slide_part: str,
|
||||
chart_edits: list[dict[str, Any]],
|
||||
next_chart_number: int,
|
||||
next_embedding_number: int,
|
||||
) -> tuple[int, int]:
|
||||
maps = _chart_key_maps(slide_root, source_slide)
|
||||
cloned_by_rel_id: dict[str, str] = {}
|
||||
errors: list[str] = []
|
||||
for chart_edit in chart_edits:
|
||||
selectors = _chart_selectors(chart_edit)
|
||||
chart_info = next((maps[key] for key in selectors if key in maps), None)
|
||||
if chart_info is None:
|
||||
if chart_edit.get("optional"):
|
||||
continue
|
||||
errors.append(", ".join(selectors) or "<missing selector>")
|
||||
continue
|
||||
chart_kind = chart_info.get("chart_kind", "")
|
||||
if chart_kind != "classic":
|
||||
code = (
|
||||
"chart_edit_chartex_unsupported"
|
||||
if chart_kind == "chartex"
|
||||
else "chart_edit_plot_type_unsupported"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"template-fill chart edits require a supported classic chart "
|
||||
f"[{code}]"
|
||||
)
|
||||
rel_id = chart_info.get("rel_id", "")
|
||||
rel = _find_relationship(rels_root, rel_id)
|
||||
if rel is None:
|
||||
errors.append(f"{selectors[0] if selectors else '<chart>'} relationship={rel_id}")
|
||||
continue
|
||||
if rel_id not in cloned_by_rel_id:
|
||||
next_chart_number += 1
|
||||
source_chart_part = _chart_part_from_relationship(new_slide_part, rel)
|
||||
new_chart_part = f"ppt/charts/chart{next_chart_number}.xml"
|
||||
next_embedding_number = _clone_and_update_chart_part(
|
||||
entries,
|
||||
content_root,
|
||||
source_chart_part=source_chart_part,
|
||||
new_chart_part=new_chart_part,
|
||||
chart_edit=chart_edit,
|
||||
next_embedding_number=next_embedding_number,
|
||||
)
|
||||
rel.set("Target", _relative_target(new_slide_part, new_chart_part))
|
||||
cloned_by_rel_id[rel_id] = new_chart_part
|
||||
continue
|
||||
chart_root = ET.fromstring(entries[cloned_by_rel_id[rel_id]])
|
||||
_apply_chart_edit_to_chart_xml(chart_root, chart_edit)
|
||||
entries[cloned_by_rel_id[rel_id]] = _xml_bytes(chart_root)
|
||||
if errors:
|
||||
raise RuntimeError(f"Missing chart edit target(s) on slide {source_slide}: {'; '.join(errors)}")
|
||||
return next_chart_number, next_embedding_number
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Read native PowerPoint chart display caches for slide-library analysis.
|
||||
|
||||
The template-fill workflow edits chart data from explicit fill plans. This
|
||||
module only reads the data currently visible in a chart XML part, keeping
|
||||
workbook parsing out of the analyzer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import NS
|
||||
|
||||
|
||||
def empty_chart_data() -> dict[str, Any]:
|
||||
return {
|
||||
"chart_type": None,
|
||||
"category_count": 0,
|
||||
"series_count": 0,
|
||||
"categories": [],
|
||||
"series": [],
|
||||
}
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _chart_types_with_series(chart_root: ET.Element) -> list[ET.Element]:
|
||||
plot_area = chart_root.find(".//c:plotArea", NS)
|
||||
if plot_area is None:
|
||||
return []
|
||||
chart_types: list[ET.Element] = []
|
||||
for child in list(plot_area):
|
||||
if _local_name(child.tag).endswith("Chart") and child.findall("c:ser", NS):
|
||||
chart_types.append(child)
|
||||
return chart_types
|
||||
|
||||
|
||||
def _coerce_number(value: str) -> int | float | str:
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if number.is_integer():
|
||||
return int(number)
|
||||
return number
|
||||
|
||||
|
||||
def _cache_values(parent: ET.Element | None, *, numeric: bool = False) -> list[Any]:
|
||||
if parent is None:
|
||||
return []
|
||||
cache = parent.find(".//c:strCache", NS)
|
||||
if cache is None:
|
||||
cache = parent.find(".//c:numCache", NS)
|
||||
if cache is None:
|
||||
return []
|
||||
values: list[Any] = []
|
||||
for point in cache.findall("c:pt", NS):
|
||||
value = point.findtext("c:v", default="", namespaces=NS)
|
||||
values.append(_coerce_number(value) if numeric else value)
|
||||
return values
|
||||
|
||||
|
||||
def _series_name(series: ET.Element, fallback: str) -> str:
|
||||
tx = series.find("c:tx", NS)
|
||||
if tx is None:
|
||||
return fallback
|
||||
values = _cache_values(tx)
|
||||
if values:
|
||||
return str(values[0])
|
||||
direct = tx.findtext("c:v", default="", namespaces=NS)
|
||||
return direct or fallback
|
||||
|
||||
|
||||
def read_chart_data(chart_root: ET.Element) -> dict[str, Any]:
|
||||
"""Return a compact summary of chart type, categories, series, and values."""
|
||||
chart_types = _chart_types_with_series(chart_root)
|
||||
if not chart_types:
|
||||
return empty_chart_data()
|
||||
|
||||
first_series = chart_types[0].find("c:ser", NS)
|
||||
categories = _cache_values(first_series.find("c:cat", NS)) if first_series is not None else []
|
||||
series_payload: list[dict[str, Any]] = []
|
||||
plot_types: list[str] = []
|
||||
for chart_type in chart_types:
|
||||
plot_name = _local_name(chart_type.tag)
|
||||
plot_types.append(plot_name)
|
||||
for series in chart_type.findall("c:ser", NS):
|
||||
index = len(series_payload) + 1
|
||||
series_categories = _cache_values(series.find("c:cat", NS))
|
||||
if not categories and series_categories:
|
||||
categories = series_categories
|
||||
series_payload.append(
|
||||
{
|
||||
"name": _series_name(series, f"系列{index}"),
|
||||
"values": _cache_values(series.find("c:val", NS), numeric=True),
|
||||
"chart_type": plot_name,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"chart_type": plot_types[0] if len(set(plot_types)) == 1 else "comboChart",
|
||||
"plot_types": plot_types,
|
||||
"category_count": len(categories),
|
||||
"series_count": len(series_payload),
|
||||
"categories": categories,
|
||||
"series": series_payload,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Command-line interface: analyze / scaffold / check-plan / apply / validate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from attribution_guard import require_skill_integrity # noqa: E402
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from pptx_transitions import ( # noqa: E402
|
||||
LEGACY_TRANSITION_KEYS,
|
||||
NATIVE_TRANSITION_KEYS,
|
||||
)
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
if __package__ in {None, ''}:
|
||||
import types
|
||||
|
||||
package_dir = Path(__file__).resolve().parent
|
||||
while str(package_dir) in sys.path:
|
||||
sys.path.remove(str(package_dir))
|
||||
scripts_dir = Path(__file__).resolve().parents[1]
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
package = types.ModuleType("template_fill_pptx")
|
||||
package.__path__ = [str(package_dir)] # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("template_fill_pptx", package)
|
||||
__package__ = "template_fill_pptx"
|
||||
|
||||
from .analyzer import analyze_pptx
|
||||
from .applier import apply_plan
|
||||
from .checker import check_plan, print_check_report
|
||||
from .ooxml import _load_json, _write_json
|
||||
from .scaffolder import scaffold_plan
|
||||
from .transitions import (
|
||||
DEFAULT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
KEEP_TRANSITION,
|
||||
)
|
||||
from .validator import print_validate_report, validate_project
|
||||
|
||||
|
||||
def _parse_slide_list(value: str | None) -> list[int] | None:
|
||||
if not value:
|
||||
return None
|
||||
slides: list[int] = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
start, end = part.split("-", 1)
|
||||
slides.extend(range(int(start), int(end) + 1))
|
||||
else:
|
||||
slides.append(int(part))
|
||||
return slides
|
||||
|
||||
|
||||
def _timestamped_pptx_path(path: Path) -> Path:
|
||||
if path.suffix.lower() != ".pptx":
|
||||
return path
|
||||
if re.search(r"_\d{8}_\d{6}$", path.stem):
|
||||
return path
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return path.with_name(f"{path.stem}_{timestamp}{path.suffix}")
|
||||
|
||||
|
||||
def _plan_confirmed(plan: dict) -> bool:
|
||||
return plan.get("status") == "confirmed"
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze and fill native PPTX templates without converting slides to SVG.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
analyze = subparsers.add_parser("analyze", help="Extract slide library JSON from a PPTX")
|
||||
analyze.add_argument("pptx_file", help="Source PPTX file")
|
||||
analyze.add_argument("-o", "--output", required=True, help="Output <stem>.slide_library.json path")
|
||||
|
||||
scaffold = subparsers.add_parser("scaffold", help="Create an editable fill plan skeleton")
|
||||
scaffold.add_argument("library_json", help="<stem>.slide_library.json from analyze")
|
||||
scaffold.add_argument("-o", "--output", required=True, help="Output fill_plan.json path")
|
||||
scaffold.add_argument(
|
||||
"--slides",
|
||||
help="Comma/range source slide list, e.g. 1,3,5-7. Defaults to first six slides.",
|
||||
)
|
||||
scaffold.add_argument(
|
||||
"--include-empty",
|
||||
action="store_true",
|
||||
help="Include empty text slots in the scaffold. Defaults to text-bearing slots only.",
|
||||
)
|
||||
|
||||
check = subparsers.add_parser("check-plan", help="Check a fill plan against source slot capacity")
|
||||
check.add_argument("library_json", help="<stem>.slide_library.json from analyze")
|
||||
check.add_argument("plan_json", help="Fill plan JSON")
|
||||
check.add_argument("-o", "--output", help="Optional JSON report output path")
|
||||
|
||||
apply = subparsers.add_parser("apply", help="Apply fill plan and write a new PPTX")
|
||||
apply.add_argument("pptx_file", help="Source PPTX file")
|
||||
apply.add_argument("plan_json", help="Fill plan JSON")
|
||||
apply.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
required=True,
|
||||
help=(
|
||||
"Output PPTX path. A _YYYYMMDD_HHMMSS timestamp is appended "
|
||||
"automatically unless the stem already ends with one."
|
||||
),
|
||||
)
|
||||
apply.add_argument(
|
||||
"--transition",
|
||||
choices=[
|
||||
*NATIVE_TRANSITION_KEYS,
|
||||
*LEGACY_TRANSITION_KEYS,
|
||||
"none",
|
||||
KEEP_TRANSITION,
|
||||
],
|
||||
default=DEFAULT_TRANSITION,
|
||||
help=(
|
||||
"Page-to-page transition policy for every cloned slide "
|
||||
"(per-slide 'transition' in the plan overrides this). "
|
||||
"Use a PowerPoint-native key; old names are compatibility inputs. "
|
||||
f"Default: {DEFAULT_TRANSITION} (preserve the source). "
|
||||
"Use 'none' to remove visual motion."
|
||||
),
|
||||
)
|
||||
apply.add_argument(
|
||||
"--transition-duration",
|
||||
type=float,
|
||||
default=DEFAULT_TRANSITION_DURATION,
|
||||
help="Transition duration in seconds (default: 0.5).",
|
||||
)
|
||||
apply.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="apply without a confirmed fill plan (deliberate recovery/debug only)",
|
||||
)
|
||||
|
||||
validate = subparsers.add_parser("validate", help="Read back and validate the latest project export")
|
||||
validate.add_argument("project_path", help="Template-fill project directory")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
require_skill_integrity()
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.command == "analyze":
|
||||
pptx_path = Path(args.pptx_file).expanduser().resolve()
|
||||
if not pptx_path.exists():
|
||||
print(f"Error: file does not exist: {pptx_path}", file=sys.stderr)
|
||||
return 1
|
||||
library = analyze_pptx(pptx_path)
|
||||
_write_json(Path(args.output).expanduser().resolve(), library)
|
||||
print(f"Analyzed {library['slide_count']} slides -> {args.output}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if args.command == "scaffold":
|
||||
library = _load_json(Path(args.library_json).expanduser().resolve())
|
||||
plan = scaffold_plan(
|
||||
library,
|
||||
_parse_slide_list(args.slides),
|
||||
include_empty=args.include_empty,
|
||||
)
|
||||
_write_json(Path(args.output).expanduser().resolve(), plan)
|
||||
print(f"Plan scaffold -> {args.output}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if args.command == "check-plan":
|
||||
library = _load_json(Path(args.library_json).expanduser().resolve())
|
||||
plan = _load_json(Path(args.plan_json).expanduser().resolve())
|
||||
report = check_plan(library, plan)
|
||||
print_check_report(report)
|
||||
if args.output:
|
||||
_write_json(Path(args.output).expanduser().resolve(), report)
|
||||
print(f"Check report -> {args.output}", file=sys.stderr)
|
||||
return 0 if report["summary"]["error"] == 0 else 1
|
||||
|
||||
if args.command == "apply":
|
||||
pptx_path = Path(args.pptx_file).expanduser().resolve()
|
||||
plan = _load_json(Path(args.plan_json).expanduser().resolve())
|
||||
if not _plan_confirmed(plan) and not args.force:
|
||||
print(
|
||||
"Error: fill plan is not confirmed: "
|
||||
f"{Path(args.plan_json).expanduser().resolve()} "
|
||||
'(set status to "confirmed" after user approval, or pass --force)',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
output_path = _timestamped_pptx_path(Path(args.output).expanduser().resolve())
|
||||
apply_plan(
|
||||
pptx_path,
|
||||
plan,
|
||||
output_path,
|
||||
transition=args.transition,
|
||||
transition_duration=args.transition_duration,
|
||||
)
|
||||
print(f"Template-filled PPTX -> {output_path}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if args.command == "validate":
|
||||
report = validate_project(Path(args.project_path))
|
||||
print_validate_report(report)
|
||||
return 0 if report["summary"]["error"] == 0 else 1
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,176 @@
|
||||
"""apply: deep-clone a cloned slide's structured private dependency parts.
|
||||
|
||||
When the same source slide is reused for several output slides (the workflow
|
||||
lets a fill plan list one ``source_slide`` many times), copying its
|
||||
relationships verbatim leaves every clone pointing at one shared set of private
|
||||
parts — custom-data tags, per-slide theme overrides, SmartArt diagrams. The
|
||||
pages are not really independent: editing one output slide's structure would
|
||||
bleed into its siblings.
|
||||
|
||||
This helper gives each cloned slide its own copy of every *structured* private
|
||||
dependency (parts that carry an explicit content-type ``Override``) and rewrites
|
||||
the relationship targets. Cloning is recursive, so a private part's own private
|
||||
sub-parts (e.g. a diagram data part's drawing) are cloned too.
|
||||
|
||||
Two classes of target are deliberately left shared:
|
||||
|
||||
* **Shared structure** — slide layout / master / theme / notes master.
|
||||
* **Binary blobs typed by a ``Default`` extension rule** — media (png / jpeg /
|
||||
emf ...) and OLE embeddings. Picture/object edits happen in PowerPoint, which
|
||||
mints a new part and repoints only the edited shape's relationship, so sharing
|
||||
never bleeds — and it avoids duplicating large media when one source page
|
||||
drives many output slides.
|
||||
|
||||
Charts stay owned by ``chart_fill`` (which clones the chart part together with
|
||||
its embedded workbook when an edit is applied) and notes slides by ``notes``;
|
||||
both relationship types are skipped here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
from typing import Callable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import (
|
||||
CHART_REL_TYPE,
|
||||
CT_NS,
|
||||
NOTES_SLIDE_REL_TYPE,
|
||||
REL_NS,
|
||||
SLIDE_REL_TYPE,
|
||||
_normalize_part,
|
||||
_qn,
|
||||
_rels_name_for_part,
|
||||
_xml_bytes,
|
||||
)
|
||||
from .package import _add_content_type_override, _relative_target
|
||||
|
||||
_REL_TYPE_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
|
||||
|
||||
# Structure shared across every slide: never cloned, target kept as-is. Note that
|
||||
# ``themeOverride`` is a distinct, per-slide private type and is NOT listed here.
|
||||
SHARED_REL_TYPES = frozenset(
|
||||
_REL_TYPE_BASE + name
|
||||
for name in ("slideLayout", "slideMaster", "notesMaster", "theme", "presProps", "viewProps", "tableStyles")
|
||||
)
|
||||
|
||||
# Owned by other apply stages (chart_fill / notes) or a back-reference: skipped here.
|
||||
SKIPPED_REL_TYPES = frozenset({CHART_REL_TYPE, NOTES_SLIDE_REL_TYPE, SLIDE_REL_TYPE})
|
||||
|
||||
|
||||
def _make_part_allocator(entries: dict[str, bytes]) -> Callable[[str], str]:
|
||||
"""Return a function that mints a fresh part name beside a source part.
|
||||
|
||||
Names keep the source extension (so a content-type ``Default`` still covers
|
||||
media) and are unique against both existing entries and earlier allocations.
|
||||
"""
|
||||
used = set(entries)
|
||||
|
||||
def allocate(source_part: str) -> str:
|
||||
directory = posixpath.dirname(source_part)
|
||||
stem, ext = posixpath.splitext(posixpath.basename(source_part))
|
||||
index = 1
|
||||
while True:
|
||||
candidate = posixpath.join(directory, f"{stem}_tf{index}{ext}")
|
||||
if candidate not in used:
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
return allocate
|
||||
|
||||
|
||||
def _override_content_type(content_root: ET.Element, part: str) -> str | None:
|
||||
"""Return the part's explicit content-type ``Override``, or ``None``.
|
||||
|
||||
``None`` means the part is typed by a ``Default`` extension rule — i.e. a
|
||||
binary blob (media / OLE) we deliberately keep shared rather than clone.
|
||||
"""
|
||||
part_pn = "/" + part.lstrip("/")
|
||||
for override in content_root.findall(_qn(CT_NS, "Override")):
|
||||
if override.attrib.get("PartName") == part_pn:
|
||||
return override.attrib.get("ContentType")
|
||||
return None
|
||||
|
||||
|
||||
def _is_shared(rel_type: str | None) -> bool:
|
||||
return bool(rel_type) and rel_type in SHARED_REL_TYPES
|
||||
|
||||
|
||||
def _clone_part_private_deps(
|
||||
rels_root: ET.Element,
|
||||
*,
|
||||
owner_part: str,
|
||||
entries: dict[str, bytes],
|
||||
content_root: ET.Element,
|
||||
allocate: Callable[[str], str],
|
||||
cloned: dict[str, str],
|
||||
) -> None:
|
||||
"""Rewrite ``rels_root`` in place, cloning each private target it references.
|
||||
|
||||
``cloned`` maps an already-handled source part to its clone so a single slide
|
||||
that references the same asset twice reuses one copy.
|
||||
"""
|
||||
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
|
||||
if rel.attrib.get("TargetMode") == "External":
|
||||
continue
|
||||
rel_type = rel.attrib.get("Type")
|
||||
if _is_shared(rel_type) or rel_type in SKIPPED_REL_TYPES:
|
||||
continue
|
||||
target = rel.attrib.get("Target")
|
||||
if not target:
|
||||
continue
|
||||
source_part = _normalize_part(target, owner_part)
|
||||
if source_part not in entries:
|
||||
continue
|
||||
content_type = _override_content_type(content_root, source_part)
|
||||
if content_type is None:
|
||||
# Binary blob typed by a Default extension rule (media / OLE): keep
|
||||
# it shared. See the module docstring for why this never bleeds.
|
||||
continue
|
||||
|
||||
new_part = cloned.get(source_part)
|
||||
if new_part is None:
|
||||
new_part = allocate(source_part)
|
||||
entries[new_part] = entries[source_part]
|
||||
cloned[source_part] = new_part
|
||||
_add_content_type_override(content_root, new_part, content_type)
|
||||
|
||||
sub_rels_data = entries.get(_rels_name_for_part(source_part))
|
||||
if sub_rels_data:
|
||||
sub_rels_root = ET.fromstring(sub_rels_data)
|
||||
_clone_part_private_deps(
|
||||
sub_rels_root,
|
||||
owner_part=new_part,
|
||||
entries=entries,
|
||||
content_root=content_root,
|
||||
allocate=allocate,
|
||||
cloned=cloned,
|
||||
)
|
||||
entries[_rels_name_for_part(new_part)] = _xml_bytes(sub_rels_root)
|
||||
|
||||
rel.set("Target", _relative_target(owner_part, new_part))
|
||||
|
||||
|
||||
def deep_clone_slide_private_parts(
|
||||
slide_rels_root: ET.Element,
|
||||
*,
|
||||
new_slide_part: str,
|
||||
entries: dict[str, bytes],
|
||||
content_root: ET.Element,
|
||||
allocate: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Give one cloned slide private copies of its private dependency parts.
|
||||
|
||||
Mutates ``slide_rels_root`` (rewriting targets) and ``entries`` (adding the
|
||||
cloned parts and their content-type overrides). ``allocate`` is shared across
|
||||
every slide in the run so minted names never collide.
|
||||
"""
|
||||
_clone_part_private_deps(
|
||||
slide_rels_root,
|
||||
owner_part=new_slide_part,
|
||||
entries=entries,
|
||||
content_root=content_root,
|
||||
allocate=allocate,
|
||||
cloned={},
|
||||
)
|
||||
@@ -0,0 +1,569 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Classify template-fill chart and table edits before package mutation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import NS, _qn
|
||||
|
||||
_XY_PLOTS = frozenset({"bubbleChart", "scatterChart"})
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _tag_namespace(tag: str) -> str:
|
||||
if not tag.startswith("{") or "}" not in tag:
|
||||
return ""
|
||||
return tag[1:].split("}", 1)[0]
|
||||
|
||||
|
||||
def _chart_reference(frame: ET.Element) -> tuple[str, str]:
|
||||
"""Return ``(classic|chartex|unknown, relationship id)`` for a chart frame."""
|
||||
graphic_data = frame.find(".//a:graphicData", NS)
|
||||
if graphic_data is None:
|
||||
return "", ""
|
||||
|
||||
for node in graphic_data.iter():
|
||||
if _local_name(node.tag) != "chart":
|
||||
continue
|
||||
namespace = _tag_namespace(node.tag)
|
||||
rel_id = node.attrib.get(_qn(NS["r"], "id"), "")
|
||||
if namespace == NS["c"]:
|
||||
return "classic", rel_id
|
||||
if "chartex" in namespace.lower():
|
||||
return "chartex", rel_id
|
||||
return "unknown", rel_id
|
||||
|
||||
uri = graphic_data.attrib.get("uri", "").lower()
|
||||
if "chartex" in uri:
|
||||
return "chartex", ""
|
||||
if "chart" in uri:
|
||||
return "unknown", ""
|
||||
return "", ""
|
||||
|
||||
|
||||
def _chart_frames(slide_root: ET.Element) -> list[ET.Element]:
|
||||
"""Return classic, ChartEx, and unknown chart graphic frames."""
|
||||
return [
|
||||
frame
|
||||
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
||||
if _chart_reference(frame)[0]
|
||||
]
|
||||
|
||||
|
||||
def _unsupported_chart_capability(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
plot_type: str | None = None,
|
||||
plot_count: int = 0,
|
||||
data_model: str = "unknown",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"supported": False,
|
||||
"code": code,
|
||||
"message": message,
|
||||
"plot_type": plot_type,
|
||||
"plot_count": plot_count,
|
||||
"data_model": data_model,
|
||||
}
|
||||
|
||||
|
||||
def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
|
||||
"""Classify whether the category cache writer can safely edit a chart part."""
|
||||
root_namespace = _tag_namespace(chart_root.tag)
|
||||
if "chartex" in root_namespace.lower():
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_chartex_unsupported",
|
||||
"template-fill chart edits do not support ChartEx",
|
||||
)
|
||||
if root_namespace != NS["c"]:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_plot_type_unsupported",
|
||||
"template-fill chart edits require a classic DrawingML chart part",
|
||||
)
|
||||
|
||||
plot_area = chart_root.find(".//c:plotArea", NS)
|
||||
if plot_area is None:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_plot_type_unsupported",
|
||||
"template-fill chart edits require a classic chart plotArea",
|
||||
)
|
||||
|
||||
plot_nodes = [
|
||||
child
|
||||
for child in list(plot_area)
|
||||
if _tag_namespace(child.tag) == NS["c"] and _local_name(child.tag).endswith("Chart")
|
||||
]
|
||||
if len(plot_nodes) > 1:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_multi_plot_unsupported",
|
||||
"template-fill chart edits do not support multi-plot or combination charts",
|
||||
plot_count=len(plot_nodes),
|
||||
)
|
||||
if not plot_nodes:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_plot_type_unsupported",
|
||||
"template-fill chart edits require exactly one recognized chart plot",
|
||||
)
|
||||
|
||||
plot = plot_nodes[0]
|
||||
plot_type = _local_name(plot.tag)
|
||||
if plot_type == "scatterChart":
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_scatter_unsupported",
|
||||
"template-fill chart edits do not support scatter xVal/yVal data",
|
||||
plot_type=plot_type,
|
||||
plot_count=1,
|
||||
data_model="xy",
|
||||
)
|
||||
if plot_type == "bubbleChart":
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_bubble_unsupported",
|
||||
"template-fill chart edits do not support bubble xVal/yVal/bubbleSize data",
|
||||
plot_type=plot_type,
|
||||
plot_count=1,
|
||||
data_model="bubble",
|
||||
)
|
||||
series_nodes = plot.findall("c:ser", NS)
|
||||
if not series_nodes:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_no_series",
|
||||
"template-fill chart edits require at least one editable series",
|
||||
plot_type=plot_type,
|
||||
plot_count=1,
|
||||
data_model="category",
|
||||
)
|
||||
for series in series_nodes:
|
||||
if any(
|
||||
series.find(f"c:{tag}", NS) is not None
|
||||
for tag in ("xVal", "yVal", "bubbleSize")
|
||||
):
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_data_model_unsupported",
|
||||
"template-fill chart edits require c:cat/c:val series",
|
||||
plot_type=plot_type,
|
||||
plot_count=1,
|
||||
)
|
||||
category = series.find("c:cat", NS)
|
||||
values = series.find("c:val", NS)
|
||||
if category is None or values is None:
|
||||
return _unsupported_chart_capability(
|
||||
"chart_edit_data_model_unsupported",
|
||||
"template-fill chart edits require c:cat/c:val on every series",
|
||||
plot_type=plot_type,
|
||||
plot_count=1,
|
||||
)
|
||||
capability_warnings: list[dict[str, str]] = []
|
||||
if plot_area.find("c:dateAx", NS) is not None:
|
||||
capability_warnings.append(
|
||||
{
|
||||
"code": "chart_edit_date_axis_flattened",
|
||||
"message": (
|
||||
"template-fill will flatten date-axis categories to the "
|
||||
"replacement single-level category cache"
|
||||
),
|
||||
}
|
||||
)
|
||||
if any(
|
||||
series.find("c:cat/c:multiLvlStrRef", NS) is not None
|
||||
for series in series_nodes
|
||||
):
|
||||
capability_warnings.append(
|
||||
{
|
||||
"code": "chart_edit_multilevel_categories_flattened",
|
||||
"message": (
|
||||
"template-fill will flatten multi-level categories to the "
|
||||
"replacement single-level category cache"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"supported": True,
|
||||
"code": "chart_edit_category_single_plot",
|
||||
"message": "single classic plot uses c:cat/c:val series",
|
||||
"plot_type": plot_type,
|
||||
"plot_count": 1,
|
||||
"data_model": "category",
|
||||
"warnings": capability_warnings,
|
||||
}
|
||||
|
||||
|
||||
def _is_verified_category_capability(capability: Any) -> bool:
|
||||
"""Return whether an analyzer capability matches the runtime structural gate."""
|
||||
return bool(
|
||||
isinstance(capability, dict)
|
||||
and capability.get("supported") is True
|
||||
and capability.get("code") == "chart_edit_category_single_plot"
|
||||
and capability.get("data_model") == "category"
|
||||
and capability.get("plot_count") == 1
|
||||
and isinstance(capability.get("plot_type"), str)
|
||||
and bool(capability.get("plot_type"))
|
||||
and capability.get("plot_type") not in _XY_PLOTS
|
||||
)
|
||||
|
||||
|
||||
def _require_supported_chart_edit(chart_root: ET.Element) -> dict[str, Any]:
|
||||
"""Raise before mutation unless ``chart_root`` uses the verified category model."""
|
||||
capability = _chart_edit_capability(chart_root)
|
||||
if not _is_verified_category_capability(capability):
|
||||
code = capability.get("code") or "chart_edit_capability_unknown"
|
||||
message = capability.get("message") or "chart edit capability is unknown"
|
||||
raise RuntimeError(f"{message} [{code}]")
|
||||
return capability
|
||||
|
||||
|
||||
def _ooxml_bool(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "on", "true"}
|
||||
|
||||
|
||||
def _positive_span(value: str | None) -> int:
|
||||
try:
|
||||
return max(int(value or "1"), 1)
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
def _table_cell_merge_info(cell: ET.Element) -> dict[str, Any]:
|
||||
"""Return the merge role encoded directly on one physical ``a:tc`` cell."""
|
||||
h_merge = _ooxml_bool(cell.attrib.get("hMerge"))
|
||||
v_merge = _ooxml_bool(cell.attrib.get("vMerge"))
|
||||
row_span = _positive_span(cell.attrib.get("rowSpan"))
|
||||
col_span = _positive_span(cell.attrib.get("gridSpan"))
|
||||
is_merge_slave = h_merge or v_merge
|
||||
is_merge_anchor = not is_merge_slave and (row_span > 1 or col_span > 1)
|
||||
merge_role = "slave" if is_merge_slave else "anchor" if is_merge_anchor else "none"
|
||||
return {
|
||||
"merge_role": merge_role,
|
||||
"is_merge_anchor": is_merge_anchor,
|
||||
"is_merge_slave": is_merge_slave,
|
||||
"row_span": row_span,
|
||||
"col_span": col_span,
|
||||
"h_merge": h_merge,
|
||||
"v_merge": v_merge,
|
||||
}
|
||||
|
||||
|
||||
def _table_merge_topology(table: ET.Element) -> dict[str, Any]:
|
||||
"""Describe merge anchors and physical slave cells in a DrawingML table."""
|
||||
cell_states: list[tuple[int, int, dict[str, Any]]] = []
|
||||
anchors: list[dict[str, int]] = []
|
||||
for row_index, row in enumerate(table.findall("a:tr", NS)):
|
||||
for col_index, cell in enumerate(row.findall("a:tc", NS)):
|
||||
info = _table_cell_merge_info(cell)
|
||||
cell_states.append((row_index, col_index, info))
|
||||
if info["is_merge_anchor"]:
|
||||
anchors.append(
|
||||
{
|
||||
"row": row_index,
|
||||
"col": col_index,
|
||||
"row_span": info["row_span"],
|
||||
"col_span": info["col_span"],
|
||||
}
|
||||
)
|
||||
|
||||
covered_by: dict[tuple[int, int], dict[str, int]] = {}
|
||||
for anchor in anchors:
|
||||
for row_index in range(anchor["row"], anchor["row"] + anchor["row_span"]):
|
||||
for col_index in range(anchor["col"], anchor["col"] + anchor["col_span"]):
|
||||
if (row_index, col_index) == (anchor["row"], anchor["col"]):
|
||||
continue
|
||||
covered_by.setdefault(
|
||||
(row_index, col_index),
|
||||
{"row": anchor["row"], "col": anchor["col"]},
|
||||
)
|
||||
|
||||
slave_cells: list[dict[str, Any]] = []
|
||||
for row_index, col_index, info in cell_states:
|
||||
if not info["is_merge_slave"]:
|
||||
continue
|
||||
slave: dict[str, Any] = {"row": row_index, "col": col_index}
|
||||
anchor = covered_by.get((row_index, col_index))
|
||||
if anchor is not None:
|
||||
slave["anchor"] = anchor
|
||||
slave_cells.append(slave)
|
||||
|
||||
return {
|
||||
"has_merges": bool(anchors or slave_cells),
|
||||
"anchors": anchors,
|
||||
"slave_cells": slave_cells,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Speaker-notes parts for cloned slides.
|
||||
|
||||
Builds native PowerPoint notes-slide XML and the slide<->notesSlide<->notesMaster
|
||||
relationships from a plan's ``notes`` field, reusing the SVG pipeline's notes
|
||||
renderer so embedded notes also feed ``notes_to_audio.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from svg_to_pptx.pptx_package.notes import create_notes_slide_xml, markdown_to_plain_text
|
||||
|
||||
from .ooxml import NOTES_SLIDE_REL_TYPE, REL_NS, SLIDE_REL_TYPE, _qn, _xml_bytes
|
||||
from .package import _empty_relationships_root, _max_numeric_rid
|
||||
|
||||
|
||||
def _find_notes_master_target(entries: dict[str, bytes]) -> str | None:
|
||||
notes_master_rel_type = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
|
||||
)
|
||||
|
||||
for name, data in entries.items():
|
||||
if not name.startswith("ppt/notesSlides/_rels/notesSlide") or not name.endswith(".xml.rels"):
|
||||
continue
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
if rel.attrib.get("Type") == notes_master_rel_type:
|
||||
return rel.attrib.get("Target")
|
||||
|
||||
presentation_rels = entries.get("ppt/_rels/presentation.xml.rels")
|
||||
if not presentation_rels:
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(presentation_rels)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
if rel.attrib.get("Type") != notes_master_rel_type:
|
||||
continue
|
||||
target = rel.attrib.get("Target")
|
||||
if not target:
|
||||
return None
|
||||
if target.startswith("/"):
|
||||
target = target.lstrip("/")
|
||||
else:
|
||||
target = posixpath.normpath(posixpath.join("ppt", target))
|
||||
return posixpath.relpath(target, "ppt/notesSlides")
|
||||
return None
|
||||
|
||||
|
||||
def _create_notes_rels_xml(slide_number: int, notes_master_target: str | None) -> bytes:
|
||||
root = _empty_relationships_root()
|
||||
if notes_master_target:
|
||||
ET.SubElement(
|
||||
root,
|
||||
_qn(REL_NS, "Relationship"),
|
||||
{
|
||||
"Id": "rId1",
|
||||
"Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster",
|
||||
"Target": notes_master_target,
|
||||
},
|
||||
)
|
||||
slide_rel_id = "rId2"
|
||||
else:
|
||||
slide_rel_id = "rId1"
|
||||
ET.SubElement(
|
||||
root,
|
||||
_qn(REL_NS, "Relationship"),
|
||||
{
|
||||
"Id": slide_rel_id,
|
||||
"Type": SLIDE_REL_TYPE,
|
||||
"Target": f"../slides/slide{slide_number}.xml",
|
||||
},
|
||||
)
|
||||
return _xml_bytes(root)
|
||||
|
||||
|
||||
def _slide_rels_with_notes(
|
||||
rels_bytes: bytes | None,
|
||||
*,
|
||||
slide_number: int,
|
||||
notes_text: str,
|
||||
notes_master_target: str | None,
|
||||
) -> tuple[bytes, dict[str, bytes]]:
|
||||
root = ET.fromstring(rels_bytes) if rels_bytes else _empty_relationships_root()
|
||||
for rel in list(root.findall(_qn(REL_NS, "Relationship"))):
|
||||
if rel.attrib.get("Type") == NOTES_SLIDE_REL_TYPE:
|
||||
root.remove(rel)
|
||||
|
||||
note_entries: dict[str, bytes] = {}
|
||||
notes_text = notes_text.strip()
|
||||
if notes_text:
|
||||
rel_id = f"rId{_max_numeric_rid(root) + 1}"
|
||||
notes_part = f"ppt/notesSlides/notesSlide{slide_number}.xml"
|
||||
notes_rels_part = f"ppt/notesSlides/_rels/notesSlide{slide_number}.xml.rels"
|
||||
ET.SubElement(
|
||||
root,
|
||||
_qn(REL_NS, "Relationship"),
|
||||
{
|
||||
"Id": rel_id,
|
||||
"Type": NOTES_SLIDE_REL_TYPE,
|
||||
"Target": f"../notesSlides/notesSlide{slide_number}.xml",
|
||||
},
|
||||
)
|
||||
plain_notes = markdown_to_plain_text(notes_text)
|
||||
note_entries[notes_part] = create_notes_slide_xml(slide_number, plain_notes).encode("utf-8")
|
||||
note_entries[notes_rels_part] = _create_notes_rels_xml(slide_number, notes_master_target)
|
||||
|
||||
return _xml_bytes(root), note_entries
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Shared OOXML primitives for the template-fill pipeline.
|
||||
|
||||
Read-side helpers only: namespaces and content-type constants, part /
|
||||
relationship resolution, EMU unit conversion, slide-shape discovery, and small
|
||||
JSON readers / writers. Write-side package plumbing lives in ``package.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
NS = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
|
||||
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
C14_NS = "http://schemas.microsoft.com/office/drawing/2007/8/2/chart"
|
||||
C16_NS = "http://schemas.microsoft.com/office/drawing/2014/chart"
|
||||
C16R2_NS = "http://schemas.microsoft.com/office/drawing/2015/06/chart"
|
||||
|
||||
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
|
||||
NOTES_SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
|
||||
CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
|
||||
PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
|
||||
SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
|
||||
NOTES_SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
|
||||
CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
|
||||
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
EMU_PER_INCH = 914400
|
||||
PX_PER_INCH = 96
|
||||
|
||||
|
||||
for prefix, uri in NS.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
ET.register_namespace("", REL_NS)
|
||||
ET.register_namespace("mc", MC_NS)
|
||||
ET.register_namespace("c14", C14_NS)
|
||||
ET.register_namespace("c16", C16_NS)
|
||||
ET.register_namespace("c16r2", C16R2_NS)
|
||||
ET.register_namespace("p14", P14_NS)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlideRef:
|
||||
"""Presentation slide reference resolved from presentation.xml.rels."""
|
||||
|
||||
index: int
|
||||
rel_id: str
|
||||
target: str
|
||||
part_name: str
|
||||
rels_name: str
|
||||
|
||||
|
||||
def _qn(namespace: str, tag: str) -> str:
|
||||
return f"{{{namespace}}}{tag}"
|
||||
|
||||
|
||||
def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element:
|
||||
try:
|
||||
return ET.fromstring(zf.read(name))
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(f"Missing required PPTX part: {name}") from exc
|
||||
|
||||
|
||||
def _xml_bytes(root: ET.Element) -> bytes:
|
||||
root_namespace = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else ""
|
||||
if root_namespace in {REL_NS, CT_NS}:
|
||||
# OPC relationship/content-type roots conventionally use the default
|
||||
# namespace. ElementTree's global prefix registry can be changed while
|
||||
# parsing source parts; restore the package-root form before writing so
|
||||
# strict consumers such as LibreOffice accept the generated package.
|
||||
ET.register_namespace("", root_namespace)
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _normalize_part(target: str, base: str = "ppt/presentation.xml") -> str:
|
||||
if target.startswith("/"):
|
||||
return target.lstrip("/")
|
||||
normalized = posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
|
||||
return normalized.lstrip("/")
|
||||
|
||||
|
||||
def _rels_name_for_part(part_name: str) -> str:
|
||||
parent = posixpath.dirname(part_name)
|
||||
basename = posixpath.basename(part_name)
|
||||
return posixpath.join(parent, "_rels", f"{basename}.rels")
|
||||
|
||||
|
||||
def _emu_to_px(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return round(int(value) / EMU_PER_INCH * PX_PER_INCH)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_relationships(zf: zipfile.ZipFile) -> dict[str, dict[str, str]]:
|
||||
rels_root = _read_xml(zf, "ppt/_rels/presentation.xml.rels")
|
||||
relationships: dict[str, dict[str, str]] = {}
|
||||
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
|
||||
rel_id = rel.attrib.get("Id")
|
||||
target = rel.attrib.get("Target")
|
||||
rel_type = rel.attrib.get("Type")
|
||||
if rel_id and target and rel_type:
|
||||
relationships[rel_id] = {"target": target, "type": rel_type}
|
||||
return relationships
|
||||
|
||||
|
||||
def _parse_slide_refs(zf: zipfile.ZipFile) -> list[SlideRef]:
|
||||
pres_root = _read_xml(zf, "ppt/presentation.xml")
|
||||
relationships = _parse_relationships(zf)
|
||||
sld_id_lst = pres_root.find("p:sldIdLst", NS)
|
||||
if sld_id_lst is None:
|
||||
return []
|
||||
|
||||
slides: list[SlideRef] = []
|
||||
for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
|
||||
rel_id = sld_id.attrib.get(_qn(NS["r"], "id"))
|
||||
if not rel_id or rel_id not in relationships:
|
||||
continue
|
||||
rel = relationships[rel_id]
|
||||
if rel["type"] != SLIDE_REL_TYPE:
|
||||
continue
|
||||
part_name = _normalize_part(rel["target"])
|
||||
slides.append(
|
||||
SlideRef(
|
||||
index=index,
|
||||
rel_id=rel_id,
|
||||
target=rel["target"],
|
||||
part_name=part_name,
|
||||
rels_name=_rels_name_for_part(part_name),
|
||||
)
|
||||
)
|
||||
return slides
|
||||
|
||||
|
||||
def _slide_relationships(zf: zipfile.ZipFile, rels_name: str) -> dict[str, dict[str, str]]:
|
||||
try:
|
||||
rels_root = _read_xml(zf, rels_name)
|
||||
except RuntimeError:
|
||||
return {}
|
||||
relationships: dict[str, dict[str, str]] = {}
|
||||
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
|
||||
rel_id = rel.attrib.get("Id")
|
||||
target = rel.attrib.get("Target")
|
||||
rel_type = rel.attrib.get("Type")
|
||||
if rel_id and target and rel_type:
|
||||
relationships[rel_id] = {"target": target, "type": rel_type}
|
||||
return relationships
|
||||
|
||||
|
||||
def _paragraph_texts(container: ET.Element) -> list[str]:
|
||||
paragraphs: list[str] = []
|
||||
for paragraph in container.findall(".//a:p", NS):
|
||||
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)).strip()
|
||||
if text:
|
||||
paragraphs.append(text)
|
||||
if paragraphs:
|
||||
return paragraphs
|
||||
text = "".join(node.text or "" for node in container.findall(".//a:t", NS)).strip()
|
||||
return [text] if text else []
|
||||
|
||||
|
||||
def _container_geometry(container: ET.Element) -> dict[str, int | None]:
|
||||
xfrm = container.find("p:spPr/a:xfrm", NS)
|
||||
if xfrm is None:
|
||||
xfrm = container.find("p:xfrm", NS)
|
||||
if xfrm is None:
|
||||
xfrm = container.find(".//a:xfrm", NS)
|
||||
if xfrm is None:
|
||||
return {"x": None, "y": None, "width": None, "height": None}
|
||||
off = xfrm.find("a:off", NS)
|
||||
ext = xfrm.find("a:ext", NS)
|
||||
return {
|
||||
"x": _emu_to_px(off.attrib.get("x")) if off is not None else None,
|
||||
"y": _emu_to_px(off.attrib.get("y")) if off is not None else None,
|
||||
"width": _emu_to_px(ext.attrib.get("cx")) if ext is not None else None,
|
||||
"height": _emu_to_px(ext.attrib.get("cy")) if ext is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
containers: list[ET.Element] = []
|
||||
for tag in ("p:sp", "p:graphicFrame"):
|
||||
for element in slide_root.findall(f".//{tag}", NS):
|
||||
if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS):
|
||||
containers.append(element)
|
||||
return containers
|
||||
|
||||
|
||||
def _table_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
return [
|
||||
frame
|
||||
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
||||
if frame.find(".//a:tbl", NS) is not None
|
||||
]
|
||||
|
||||
|
||||
def _chart_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
return [
|
||||
frame
|
||||
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
||||
if frame.find(".//c:chart", NS) is not None
|
||||
]
|
||||
|
||||
|
||||
def _shape_identity(container: ET.Element, order: int) -> tuple[str, str]:
|
||||
c_nv_pr = container.find(".//p:cNvPr", NS)
|
||||
shape_id = c_nv_pr.attrib.get("id") if c_nv_pr is not None else str(order)
|
||||
shape_name = c_nv_pr.attrib.get("name") if c_nv_pr is not None else ""
|
||||
return shape_id, shape_name
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Write-side OOXML package plumbing for the apply stage.
|
||||
|
||||
Content-type override insertion, relationship-element construction / lookup, and
|
||||
part-number allocation used when cloning slides into a new package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import (
|
||||
CT_NS,
|
||||
NOTES_SLIDE_CONTENT_TYPE,
|
||||
NS,
|
||||
REL_NS,
|
||||
SLIDE_CONTENT_TYPE,
|
||||
_normalize_part,
|
||||
_qn,
|
||||
_rels_name_for_part,
|
||||
)
|
||||
|
||||
|
||||
def _content_type_root(root: ET.Element) -> ET.Element:
|
||||
if root.tag != _qn(CT_NS, "Types"):
|
||||
raise RuntimeError("[Content_Types].xml has an unexpected root element")
|
||||
return root
|
||||
|
||||
|
||||
def _add_content_type_override(content_root: ET.Element, part_name: str, content_type: str) -> None:
|
||||
part_name = "/" + part_name.lstrip("/")
|
||||
for override in content_root.findall(_qn(CT_NS, "Override")):
|
||||
if override.attrib.get("PartName") == part_name:
|
||||
return
|
||||
ET.SubElement(
|
||||
content_root,
|
||||
_qn(CT_NS, "Override"),
|
||||
{"PartName": part_name, "ContentType": content_type},
|
||||
)
|
||||
|
||||
|
||||
def _add_slide_override(content_root: ET.Element, part_name: str) -> None:
|
||||
_add_content_type_override(content_root, part_name, SLIDE_CONTENT_TYPE)
|
||||
|
||||
|
||||
def _add_notes_override(content_root: ET.Element, part_name: str) -> None:
|
||||
_add_content_type_override(content_root, part_name, NOTES_SLIDE_CONTENT_TYPE)
|
||||
|
||||
|
||||
def _empty_relationships_root() -> ET.Element:
|
||||
return ET.Element(_qn(REL_NS, "Relationships"))
|
||||
|
||||
|
||||
def _find_relationship(root: ET.Element, rel_id: str) -> ET.Element | None:
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
if rel.attrib.get("Id") == rel_id:
|
||||
return rel
|
||||
return None
|
||||
|
||||
|
||||
def _relative_target(from_part: str, to_part: str) -> str:
|
||||
return posixpath.relpath(to_part, posixpath.dirname(from_part))
|
||||
|
||||
|
||||
def _max_slide_part_number(entries: dict[str, bytes]) -> int:
|
||||
max_number = 0
|
||||
pattern = re.compile(r"^ppt/slides/slide(\d+)\.xml$")
|
||||
for name in entries:
|
||||
match = pattern.match(name)
|
||||
if match:
|
||||
max_number = max(max_number, int(match.group(1)))
|
||||
return max_number
|
||||
|
||||
|
||||
def _max_numeric_rid(root: ET.Element) -> int:
|
||||
max_id = 0
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
rel_id = rel.attrib.get("Id", "")
|
||||
match = re.fullmatch(r"rId(\d+)", rel_id)
|
||||
if match:
|
||||
max_id = max(max_id, int(match.group(1)))
|
||||
return max_id
|
||||
|
||||
|
||||
def _max_slide_id(sld_id_lst: ET.Element) -> int:
|
||||
max_id = 255
|
||||
for sld_id in sld_id_lst.findall("p:sldId", NS):
|
||||
try:
|
||||
max_id = max(max_id, int(sld_id.attrib.get("id", "0")))
|
||||
except ValueError:
|
||||
continue
|
||||
return max_id
|
||||
|
||||
|
||||
def _enqueue_rel_targets(
|
||||
entries: dict[str, bytes],
|
||||
rels_part: str,
|
||||
base_part: str,
|
||||
queue: list[str],
|
||||
) -> None:
|
||||
data = entries.get(rels_part)
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return
|
||||
for rel in root.findall(_qn(REL_NS, "Relationship")):
|
||||
if rel.attrib.get("TargetMode") == "External":
|
||||
continue
|
||||
target = rel.attrib.get("Target")
|
||||
if target:
|
||||
queue.append(_normalize_part(target, base_part or "x"))
|
||||
|
||||
|
||||
def _reachable_parts(entries: dict[str, bytes]) -> set[str]:
|
||||
"""Parts reachable from the package root by following relationships."""
|
||||
keep: set[str] = set()
|
||||
queue: list[str] = []
|
||||
_enqueue_rel_targets(entries, "_rels/.rels", "", queue)
|
||||
while queue:
|
||||
part = queue.pop()
|
||||
if part in keep:
|
||||
continue
|
||||
keep.add(part)
|
||||
_enqueue_rel_targets(entries, _rels_name_for_part(part), part, queue)
|
||||
return keep
|
||||
|
||||
|
||||
def _prune_unreferenced_parts(entries: dict[str, bytes], content_root: ET.Element) -> None:
|
||||
"""Drop parts not reachable from the package root through relationships.
|
||||
|
||||
After cloning only the planned slides, the original slide / notesSlide /
|
||||
chart / embedding parts left in ``entries`` are orphaned — nothing in the
|
||||
rebuilt presentation references them. Reachability GC removes that dead
|
||||
weight so the output deck carries only the selected pages and their assets,
|
||||
and prunes the matching ``[Content_Types].xml`` overrides.
|
||||
"""
|
||||
reachable = _reachable_parts(entries)
|
||||
keep = set(reachable)
|
||||
keep.update({"[Content_Types].xml", "_rels/.rels"})
|
||||
for part in reachable:
|
||||
rels = _rels_name_for_part(part)
|
||||
if rels in entries:
|
||||
keep.add(rels)
|
||||
|
||||
for name in list(entries):
|
||||
if name not in keep:
|
||||
del entries[name]
|
||||
|
||||
for override in list(content_root.findall(_qn(CT_NS, "Override"))):
|
||||
part_name = (override.attrib.get("PartName") or "").lstrip("/")
|
||||
if part_name and part_name not in reachable:
|
||||
content_root.remove(override)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""scaffold: turn a slide library into an editable fill-plan skeleton."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def scaffold_plan(
|
||||
library: dict[str, Any],
|
||||
selected_slides: list[int] | None = None,
|
||||
*,
|
||||
include_empty: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an editable fill plan skeleton from a slide library."""
|
||||
selected = set(selected_slides or [])
|
||||
source_slides = library.get("slides", [])
|
||||
if not selected:
|
||||
source_slides = source_slides[: min(6, len(source_slides))]
|
||||
else:
|
||||
source_slides = [slide for slide in source_slides if int(slide["slide_index"]) in selected]
|
||||
|
||||
slides: list[dict[str, Any]] = []
|
||||
for slide in source_slides:
|
||||
replacements = []
|
||||
for slot in slide.get("slots", []):
|
||||
if not include_empty and not str(slot.get("text") or "").strip():
|
||||
continue
|
||||
replacements.append(
|
||||
{
|
||||
"slot_id": slot["slot_id"],
|
||||
"old_text": slot["text"],
|
||||
"text": slot["text"],
|
||||
}
|
||||
)
|
||||
table_edits = []
|
||||
for table in slide.get("tables", []):
|
||||
table_edits.append(
|
||||
{
|
||||
"table_id": table["table_id"],
|
||||
"cells": [
|
||||
{
|
||||
"row": cell["row"],
|
||||
"col": cell["col"],
|
||||
"old_text": cell["text"],
|
||||
"text": cell["text"],
|
||||
}
|
||||
for row in table.get("rows", [])
|
||||
for cell in row.get("cells", [])
|
||||
],
|
||||
}
|
||||
)
|
||||
chart_edits = []
|
||||
for chart in slide.get("charts", []):
|
||||
chart_edits.append(
|
||||
{
|
||||
"chart_id": chart["chart_id"],
|
||||
"categories": chart.get("categories", []),
|
||||
"series": chart.get("series") or [{"name": "系列1", "values": []}],
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"source_slide": slide["slide_index"],
|
||||
"purpose": slide.get("page_type", "content_candidate"),
|
||||
"layout_rationale": {
|
||||
"layout_pattern": "",
|
||||
"why_fit": "",
|
||||
"risk": "",
|
||||
},
|
||||
"replacements": replacements,
|
||||
"table_edits": table_edits,
|
||||
"chart_edits": chart_edits,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "template_fill_pptx_plan.v1",
|
||||
"status": "draft",
|
||||
"source_pptx": library.get("source_pptx"),
|
||||
"accepted_warnings": [],
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Plan selector builders and text extraction shared by check-plan and apply.
|
||||
|
||||
A replacement / table / chart edit can target a slot by ``slot_id`` (or
|
||||
``table_id`` / ``chart_id``), ``shape_id``, or ``shape_name``; these helpers turn
|
||||
a plan entry into an ordered list of lookup keys and pull the new text out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _plain_len(value: str) -> int:
|
||||
return len("".join(value.split()))
|
||||
|
||||
|
||||
def _replacement_selectors(replacement: dict[str, Any]) -> list[str]:
|
||||
selectors = []
|
||||
if replacement.get("slot_id"):
|
||||
selectors.append(f"slot_id:{replacement['slot_id']}")
|
||||
if replacement.get("shape_id"):
|
||||
selectors.append(f"shape_id:{replacement['shape_id']}")
|
||||
if replacement.get("shape_name"):
|
||||
selectors.append(f"shape_name:{replacement['shape_name']}")
|
||||
return selectors
|
||||
|
||||
|
||||
def _table_selectors(table_edit: dict[str, Any]) -> list[str]:
|
||||
selectors = []
|
||||
if table_edit.get("table_id"):
|
||||
selectors.append(f"table_id:{table_edit['table_id']}")
|
||||
if table_edit.get("shape_id"):
|
||||
selectors.append(f"shape_id:{table_edit['shape_id']}")
|
||||
if table_edit.get("shape_name"):
|
||||
selectors.append(f"shape_name:{table_edit['shape_name']}")
|
||||
return selectors
|
||||
|
||||
|
||||
def _chart_selectors(chart_edit: dict[str, Any]) -> list[str]:
|
||||
selectors = []
|
||||
if chart_edit.get("chart_id"):
|
||||
selectors.append(f"chart_id:{chart_edit['chart_id']}")
|
||||
if chart_edit.get("shape_id"):
|
||||
selectors.append(f"shape_id:{chart_edit['shape_id']}")
|
||||
if chart_edit.get("shape_name"):
|
||||
selectors.append(f"shape_name:{chart_edit['shape_name']}")
|
||||
return selectors
|
||||
|
||||
|
||||
def _replacement_text(replacement: dict[str, Any]) -> str:
|
||||
if "paragraphs" in replacement:
|
||||
paragraphs = replacement["paragraphs"]
|
||||
if not isinstance(paragraphs, list):
|
||||
raise RuntimeError("Replacement field 'paragraphs' must be a list")
|
||||
return "\n".join(str(item) for item in paragraphs)
|
||||
return str(replacement.get("text", ""))
|
||||
|
||||
|
||||
def _table_cell_text(cell_edit: dict[str, Any]) -> str:
|
||||
if "paragraphs" in cell_edit:
|
||||
paragraphs = cell_edit["paragraphs"]
|
||||
if not isinstance(paragraphs, list):
|
||||
raise RuntimeError("Table cell field 'paragraphs' must be a list")
|
||||
return "\n".join(str(item) for item in paragraphs)
|
||||
return str(cell_edit.get("text", ""))
|
||||
@@ -0,0 +1,71 @@
|
||||
"""apply: edit native PowerPoint table cell text on cloned slides.
|
||||
|
||||
Edits existing cells only — row / column structure is left untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .edit_safety import _table_cell_merge_info
|
||||
from .ooxml import NS, _shape_identity, _table_containers
|
||||
from .selectors import _table_cell_text, _table_selectors
|
||||
from .text_fill import _set_container_text
|
||||
|
||||
|
||||
def _table_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, ET.Element]:
|
||||
maps: dict[str, ET.Element] = {}
|
||||
for order, container in enumerate(_table_containers(slide_root), start=1):
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
maps[f"table_id:s{source_slide:02d}_tbl{shape_id}"] = container
|
||||
maps[f"shape_id:{shape_id}"] = container
|
||||
if shape_name:
|
||||
maps[f"shape_name:{shape_name}"] = container
|
||||
return maps
|
||||
|
||||
|
||||
def _apply_table_edits_to_slide(
|
||||
slide_root: ET.Element,
|
||||
*,
|
||||
source_slide: int,
|
||||
table_edits: list[dict[str, Any]],
|
||||
) -> None:
|
||||
maps = _table_key_maps(slide_root, source_slide)
|
||||
errors: list[str] = []
|
||||
pending_edits: list[tuple[ET.Element, str]] = []
|
||||
for table_edit in table_edits:
|
||||
selectors = _table_selectors(table_edit)
|
||||
table_frame = next((maps[key] for key in selectors if key in maps), None)
|
||||
if table_frame is None:
|
||||
if table_edit.get("optional"):
|
||||
continue
|
||||
errors.append(", ".join(selectors) or "<missing selector>")
|
||||
continue
|
||||
rows = table_frame.findall(".//a:tbl/a:tr", NS)
|
||||
cells = table_edit.get("cells", [])
|
||||
if not isinstance(cells, list):
|
||||
raise RuntimeError(f"Slide {source_slide} table edit cells must be a list")
|
||||
for cell_edit in cells:
|
||||
row_index = int(cell_edit.get("row", -1))
|
||||
col_index = int(cell_edit.get("col", -1))
|
||||
if row_index < 0 or row_index >= len(rows):
|
||||
errors.append(f"{selectors[0] if selectors else '<table>'} row={row_index}")
|
||||
continue
|
||||
row_cells = rows[row_index].findall("a:tc", NS)
|
||||
if col_index < 0 or col_index >= len(row_cells):
|
||||
errors.append(f"{selectors[0] if selectors else '<table>'} row={row_index} col={col_index}")
|
||||
continue
|
||||
target_cell = row_cells[col_index]
|
||||
if _table_cell_merge_info(target_cell)["is_merge_slave"]:
|
||||
target = selectors[0] if selectors else "<table>"
|
||||
errors.append(
|
||||
f"{target} row={row_index} col={col_index} is a merged-cell slave "
|
||||
"[table_cell_is_merge_slave]; edit the merge anchor instead"
|
||||
)
|
||||
continue
|
||||
pending_edits.append((target_cell, _table_cell_text(cell_edit)))
|
||||
if errors:
|
||||
raise RuntimeError(f"Invalid table edit target(s) on slide {source_slide}: {'; '.join(errors)}")
|
||||
for target_cell, text in pending_edits:
|
||||
_set_container_text(target_cell, text)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""apply: replace text inside cloned slide shapes while keeping frames editable.
|
||||
|
||||
``_set_container_text`` is the shared text-writing primitive and is also reused
|
||||
by ``table_fill`` for table-cell edits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import NS, _qn, _shape_identity, _text_containers
|
||||
from .selectors import _replacement_text
|
||||
|
||||
|
||||
def _shape_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, ET.Element]:
|
||||
maps: dict[str, ET.Element] = {}
|
||||
for order, container in enumerate(_text_containers(slide_root), start=1):
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
maps[f"slot_id:s{source_slide:02d}_sh{shape_id}"] = container
|
||||
maps[f"shape_id:{shape_id}"] = container
|
||||
if shape_name:
|
||||
maps[f"shape_name:{shape_name}"] = container
|
||||
return maps
|
||||
|
||||
|
||||
def _ensure_text_nodes(container: ET.Element) -> list[ET.Element]:
|
||||
text_nodes = container.findall(".//a:t", NS)
|
||||
if text_nodes:
|
||||
return text_nodes
|
||||
tx_body = container.find(".//p:txBody", NS)
|
||||
if tx_body is None:
|
||||
tx_body = container.find(".//a:txBody", NS)
|
||||
if tx_body is None:
|
||||
return []
|
||||
paragraph = tx_body.find("a:p", NS)
|
||||
if paragraph is None:
|
||||
paragraph = ET.SubElement(tx_body, _qn(NS["a"], "p"))
|
||||
run = paragraph.find("a:r", NS)
|
||||
if run is None:
|
||||
run = ET.SubElement(paragraph, _qn(NS["a"], "r"))
|
||||
text_node = run.find("a:t", NS)
|
||||
if text_node is None:
|
||||
text_node = ET.SubElement(run, _qn(NS["a"], "t"))
|
||||
return [text_node]
|
||||
|
||||
|
||||
def _ensure_paragraph_text_node(paragraph: ET.Element) -> list[ET.Element]:
|
||||
text_nodes = paragraph.findall(".//a:t", NS)
|
||||
if text_nodes:
|
||||
return text_nodes
|
||||
run = paragraph.find("a:r", NS)
|
||||
if run is None:
|
||||
run = ET.SubElement(paragraph, _qn(NS["a"], "r"))
|
||||
text_node = run.find("a:t", NS)
|
||||
if text_node is None:
|
||||
text_node = ET.SubElement(run, _qn(NS["a"], "t"))
|
||||
return [text_node]
|
||||
|
||||
|
||||
def _set_paragraph_text(paragraph: ET.Element, text: str) -> None:
|
||||
text_nodes = _ensure_paragraph_text_node(paragraph)
|
||||
text_nodes[0].text = text
|
||||
for node in text_nodes[1:]:
|
||||
node.text = ""
|
||||
|
||||
|
||||
def _set_container_text(container: ET.Element, text: str) -> None:
|
||||
lines = text.splitlines() or [""]
|
||||
paragraphs = container.findall(".//a:p", NS)
|
||||
if len(lines) > 1 and paragraphs:
|
||||
for index, paragraph in enumerate(paragraphs):
|
||||
if index < len(lines):
|
||||
_set_paragraph_text(paragraph, lines[index])
|
||||
else:
|
||||
_set_paragraph_text(paragraph, "")
|
||||
if len(lines) > len(paragraphs):
|
||||
_set_paragraph_text(paragraphs[-1], "\n".join(lines[len(paragraphs) - 1 :]))
|
||||
return
|
||||
|
||||
text_nodes = _ensure_text_nodes(container)
|
||||
if not text_nodes:
|
||||
raise RuntimeError("Matched shape does not contain a text body")
|
||||
if len(lines) <= len(text_nodes):
|
||||
for index, node in enumerate(text_nodes):
|
||||
node.text = lines[index] if index < len(lines) else ""
|
||||
return
|
||||
text_nodes[0].text = text
|
||||
for node in text_nodes[1:]:
|
||||
node.text = ""
|
||||
|
||||
|
||||
def _apply_replacements_to_slide(
|
||||
slide_root: ET.Element,
|
||||
*,
|
||||
source_slide: int,
|
||||
replacements: list[dict[str, Any]],
|
||||
) -> None:
|
||||
maps = _shape_key_maps(slide_root, source_slide)
|
||||
errors: list[str] = []
|
||||
for replacement in replacements:
|
||||
selectors = []
|
||||
if replacement.get("slot_id"):
|
||||
selectors.append(f"slot_id:{replacement['slot_id']}")
|
||||
if replacement.get("shape_id"):
|
||||
selectors.append(f"shape_id:{replacement['shape_id']}")
|
||||
if replacement.get("shape_name"):
|
||||
selectors.append(f"shape_name:{replacement['shape_name']}")
|
||||
container = next((maps[key] for key in selectors if key in maps), None)
|
||||
if container is None:
|
||||
if replacement.get("optional"):
|
||||
continue
|
||||
errors.append(", ".join(selectors) or "<missing selector>")
|
||||
continue
|
||||
_set_container_text(container, _replacement_text(replacement))
|
||||
if errors:
|
||||
raise RuntimeError(f"Missing replacement target(s) on slide {source_slide}: {'; '.join(errors)}")
|
||||
@@ -0,0 +1,150 @@
|
||||
"""apply: page-to-page transitions for cloned slides.
|
||||
|
||||
Template Fill preserves each source transition unless the CLI or a per-slide
|
||||
plan entry requests a replacement. Effects and OOXML mutation come from the
|
||||
shared ``pptx_transitions`` core so every PPTX path uses the same writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_transitions import (
|
||||
AdvanceUpdate,
|
||||
EnterUpdate,
|
||||
apply_slide_motion,
|
||||
normalize_transition_effect_request,
|
||||
validate_seconds,
|
||||
)
|
||||
|
||||
KEEP_TRANSITION = "keep"
|
||||
# Preserve source transitions unless the CLI or a per-slide plan entry selects
|
||||
# a replacement. The duration is consumed only when a visual effect is written.
|
||||
DEFAULT_TRANSITION = KEEP_TRANSITION
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
TRANSITION_OBJECT_FIELDS = frozenset(
|
||||
{
|
||||
"effect",
|
||||
"effect_options",
|
||||
"duration",
|
||||
"advance_after",
|
||||
}
|
||||
)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def transition_unknown_fields(raw: dict[str, Any]) -> list[str]:
|
||||
"""Return unsupported fields from a per-slide transition object."""
|
||||
return sorted(set(raw) - TRANSITION_OBJECT_FIELDS)
|
||||
|
||||
|
||||
def _set_slide_transition(
|
||||
slide_root: ET.Element,
|
||||
*,
|
||||
effect: str | None,
|
||||
duration: float,
|
||||
effect_options: dict[str, object] | None = None,
|
||||
advance_after: float | None = None,
|
||||
) -> bool:
|
||||
"""Apply a legacy template-fill transition through the shared core.
|
||||
|
||||
``None`` and ``keep`` preserve the source transition. ``none`` removes the
|
||||
visual transition while retaining an explicitly requested auto-advance.
|
||||
Legacy ``advance_after`` allowed both click and timed advance, so it maps to
|
||||
``both`` rather than the stricter ``after`` mode. The return value reports
|
||||
whether the resulting slide contains an automatic advance.
|
||||
"""
|
||||
if effect is None or effect == KEEP_TRANSITION:
|
||||
enter = EnterUpdate(policy="preserve")
|
||||
advance = AdvanceUpdate(
|
||||
mode="preserve" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
elif effect == "none":
|
||||
enter = EnterUpdate(policy="none", effect=None, duration=duration)
|
||||
advance = AdvanceUpdate(
|
||||
mode="click" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
else:
|
||||
enter = EnterUpdate(
|
||||
policy="replace",
|
||||
effect=effect,
|
||||
duration=duration,
|
||||
effect_options=effect_options,
|
||||
)
|
||||
advance = AdvanceUpdate(
|
||||
mode="click" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
|
||||
try:
|
||||
return apply_slide_motion(
|
||||
slide_root,
|
||||
enter=enter,
|
||||
advance=advance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _resolve_slide_transition(
|
||||
item: dict[str, Any],
|
||||
*,
|
||||
default_effect: str | None,
|
||||
default_duration: float,
|
||||
) -> tuple[str | None, dict[str, object], float, float | None]:
|
||||
"""Pick a slide's transition from its plan entry, falling back to CLI defaults."""
|
||||
raw = item.get("transition", _UNSET)
|
||||
if raw is _UNSET:
|
||||
if default_effect in (None, "none", KEEP_TRANSITION):
|
||||
return default_effect, {}, default_duration, None
|
||||
effect, effect_options = normalize_transition_effect_request(
|
||||
default_effect,
|
||||
allow_none=False,
|
||||
)
|
||||
return effect, effect_options, default_duration, None
|
||||
if isinstance(raw, dict):
|
||||
unknown = transition_unknown_fields(raw)
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
"Transition has unknown field(s): " + ", ".join(unknown)
|
||||
)
|
||||
effect = raw.get("effect", default_effect)
|
||||
raw_options = raw.get("effect_options")
|
||||
if raw_options is not None and "effect" not in raw:
|
||||
raise RuntimeError(
|
||||
"Transition effect_options requires an explicit effect"
|
||||
)
|
||||
duration = raw.get("duration", default_duration)
|
||||
advance_after = raw.get("advance_after")
|
||||
else:
|
||||
effect = None if raw is None else str(raw)
|
||||
raw_options = None
|
||||
duration = default_duration
|
||||
advance_after = None
|
||||
effect_options: dict[str, object] = {}
|
||||
if effect is not None and effect not in ("none", KEEP_TRANSITION):
|
||||
try:
|
||||
effect, effect_options = normalize_transition_effect_request(
|
||||
effect,
|
||||
raw_options,
|
||||
allow_none=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
elif raw_options not in (None, {}):
|
||||
raise RuntimeError(
|
||||
"Transition effect_options requires an explicit native effect"
|
||||
)
|
||||
try:
|
||||
resolved_duration = validate_seconds(
|
||||
duration,
|
||||
"transition duration",
|
||||
allow_zero=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return effect, effect_options, resolved_duration, advance_after
|
||||
@@ -0,0 +1,491 @@
|
||||
"""validate: read back the latest template-fill export and check core contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .checker import _chart_lookup, _slot_lookup, _table_lookup
|
||||
from .ooxml import _load_json, _write_json
|
||||
from .selectors import (
|
||||
_chart_selectors,
|
||||
_replacement_selectors,
|
||||
_replacement_text,
|
||||
_table_cell_text,
|
||||
_table_selectors,
|
||||
)
|
||||
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+(\d+)\s*$", re.MULTILINE)
|
||||
_SPEAKER_NOTES_RE = re.compile(r"^### Speaker Notes\s*$", re.MULTILINE)
|
||||
_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\([^)]+\)")
|
||||
_LIST_PREFIX_RE = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+")
|
||||
_TABLE_BREAK_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^:?-{3,}:?$")
|
||||
_ESCAPED_READBACK_CONTROL_RE = re.compile(
|
||||
r"^\\(## Slide\s+\d+|### Speaker Notes)$"
|
||||
)
|
||||
|
||||
|
||||
def _latest_export(project_path: Path) -> Path:
|
||||
exports_dir = project_path / "exports"
|
||||
candidates = [path for path in exports_dir.glob("*.pptx") if path.is_file()]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"No PPTX exports found in: {exports_dir}")
|
||||
return max(candidates, key=lambda path: path.stat().st_mtime)
|
||||
|
||||
|
||||
def _readback_slide_count(markdown: str) -> int:
|
||||
return len(_SLIDE_HEADING_RE.findall(markdown))
|
||||
|
||||
|
||||
def _slide_sections(markdown: str) -> dict[int, str]:
|
||||
matches = list(_SLIDE_HEADING_RE.finditer(markdown))
|
||||
sections: dict[int, str] = {}
|
||||
for index, match in enumerate(matches):
|
||||
end = (
|
||||
matches[index + 1].start()
|
||||
if index + 1 < len(matches)
|
||||
else len(markdown)
|
||||
)
|
||||
sections[int(match.group(1))] = markdown[match.end():end].strip()
|
||||
return sections
|
||||
|
||||
|
||||
def _split_notes(section: str) -> tuple[str, str]:
|
||||
match = _SPEAKER_NOTES_RE.search(section)
|
||||
if match is None:
|
||||
return section, ""
|
||||
return section[:match.start()].strip(), section[match.end():].strip()
|
||||
|
||||
|
||||
def _normalize_line(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _unescape_readback_control_line(value: str) -> str:
|
||||
"""Restore the display text of an escaped read-back section marker."""
|
||||
match = _ESCAPED_READBACK_CONTROL_RE.fullmatch(value)
|
||||
return match.group(1) if match else value
|
||||
|
||||
|
||||
def _paragraph_lines(value: object) -> list[str]:
|
||||
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
return [
|
||||
normalized
|
||||
for line in text.split("\n")
|
||||
if (normalized := _normalize_line(line))
|
||||
]
|
||||
|
||||
|
||||
def _markdown_line_variants(markdown: str) -> list[set[str] | None]:
|
||||
variants: list[set[str] | None] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if (
|
||||
not stripped
|
||||
or stripped.startswith("|")
|
||||
or stripped.startswith("![")
|
||||
or stripped.startswith("> [Chart")
|
||||
or stripped == "_No extractable text content._"
|
||||
):
|
||||
variants.append(None)
|
||||
continue
|
||||
display = _MARKDOWN_LINK_RE.sub(r"\1", stripped)
|
||||
without_prefix = _LIST_PREFIX_RE.sub("", display, count=1)
|
||||
line_variants = {
|
||||
_unescape_readback_control_line(_normalize_line(candidate))
|
||||
for candidate in (stripped, display, without_prefix)
|
||||
}
|
||||
line_variants.discard("")
|
||||
variants.append(line_variants)
|
||||
return variants
|
||||
|
||||
|
||||
def _contains_paragraphs(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
actual = _markdown_line_variants(markdown)
|
||||
if len(expected) > len(actual):
|
||||
return False
|
||||
for start in range(len(actual) - len(expected) + 1):
|
||||
if all(
|
||||
actual[start + offset] is not None
|
||||
and expected[offset] in actual[start + offset]
|
||||
for offset in range(len(expected))
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _table_cells(markdown: str) -> list[list[str]]:
|
||||
cells: list[list[str]] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not (stripped.startswith("|") and stripped.endswith("|")):
|
||||
continue
|
||||
values = re.split(r"(?<!\\)\|", stripped[1:-1])
|
||||
normalized_values = [
|
||||
value.strip().replace(r"\|", "|")
|
||||
for value in values
|
||||
]
|
||||
if normalized_values and all(
|
||||
_TABLE_SEPARATOR_RE.fullmatch(value) is not None
|
||||
for value in normalized_values
|
||||
):
|
||||
continue
|
||||
for value in normalized_values:
|
||||
cells.append(_paragraph_lines(_TABLE_BREAK_RE.sub("\n", value)))
|
||||
return cells
|
||||
|
||||
|
||||
def _contains_table_cell(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
return expected in _table_cells(markdown)
|
||||
|
||||
|
||||
def _contains_chart_text(markdown: str, value: str) -> bool:
|
||||
return (
|
||||
_contains_paragraphs(markdown, value)
|
||||
or _contains_table_cell(markdown, value)
|
||||
)
|
||||
|
||||
|
||||
def _first_library(project_path: Path) -> dict[str, Any] | None:
|
||||
libraries = sorted((project_path / "analysis").glob("*.slide_library.json"))
|
||||
if not libraries:
|
||||
return None
|
||||
return _load_json(libraries[0])
|
||||
|
||||
|
||||
def _matched_library_target(
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] | None,
|
||||
source_slide: int,
|
||||
selectors: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the first library target matched by the runtime selector order."""
|
||||
if lookup is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
target
|
||||
for selector in selectors
|
||||
if (target := lookup.get((source_slide, selector))) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _replacement_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str, str, str]]:
|
||||
tokens: list[tuple[int, str, str, str]] = []
|
||||
slot_lookup = _slot_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
replacements = slide.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
continue
|
||||
slide_tokens: list[tuple[str, bool]] = []
|
||||
for replacement in replacements:
|
||||
if not isinstance(replacement, dict):
|
||||
continue
|
||||
text = _replacement_text(replacement)
|
||||
if not _paragraph_lines(text):
|
||||
continue
|
||||
slot = _matched_library_target(
|
||||
slot_lookup,
|
||||
source_slide,
|
||||
_replacement_selectors(replacement),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and replacement.get("optional")
|
||||
and slot is None
|
||||
):
|
||||
continue
|
||||
is_title = (
|
||||
slot is not None
|
||||
and str(slot.get("role") or "") == "title_candidate"
|
||||
)
|
||||
slide_tokens.append((text, is_title))
|
||||
if (
|
||||
library is None
|
||||
and slide_tokens
|
||||
and not any(is_title for _, is_title in slide_tokens)
|
||||
):
|
||||
first_text, _ = slide_tokens[0]
|
||||
slide_tokens[0] = (first_text, True)
|
||||
for text, is_title in slide_tokens:
|
||||
tokens.append(
|
||||
(
|
||||
plan_slide,
|
||||
text,
|
||||
(
|
||||
"title_missing_in_readback"
|
||||
if is_title
|
||||
else "body_text_missing_in_readback"
|
||||
),
|
||||
"title" if is_title else "replacement text",
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def _table_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
table_lookup = _table_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for table_edit in slide.get("table_edits", []) or []:
|
||||
table = _matched_library_target(
|
||||
table_lookup,
|
||||
source_slide,
|
||||
_table_selectors(table_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and table_edit.get("optional")
|
||||
and table is None
|
||||
):
|
||||
continue
|
||||
for cell in table_edit.get("cells", []) or []:
|
||||
text = _table_cell_text(cell)
|
||||
if _paragraph_lines(text):
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _format_chart_series_value(value: object) -> str:
|
||||
"""Match ppt_to_md's display formatting for chart series values."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _chart_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
chart_lookup = _chart_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for chart_edit in slide.get("chart_edits", []) or []:
|
||||
chart = _matched_library_target(
|
||||
chart_lookup,
|
||||
source_slide,
|
||||
_chart_selectors(chart_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and chart_edit.get("optional")
|
||||
and chart is None
|
||||
):
|
||||
continue
|
||||
for category in chart_edit.get("categories", []) or []:
|
||||
if str(category).strip():
|
||||
tokens.append((plan_slide, str(category)))
|
||||
for series in chart_edit.get("series", []) or []:
|
||||
name = str(series.get("name") or "").strip()
|
||||
if name:
|
||||
tokens.append((plan_slide, name))
|
||||
for value in series.get("values", []) or []:
|
||||
text = _format_chart_series_value(value)
|
||||
if text:
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _append_page_token_checks(
|
||||
*,
|
||||
results: list[dict[str, Any]],
|
||||
summary: dict[str, int],
|
||||
sections: dict[int, str],
|
||||
tokens: list[tuple[int, str, str, str]],
|
||||
status: str,
|
||||
matcher: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
summary_key = status.lower()
|
||||
for plan_slide, text, code, label in tokens:
|
||||
body, _ = _split_notes(sections.get(plan_slide, ""))
|
||||
if matcher(body, text):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary[summary_key] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": status,
|
||||
"code": code,
|
||||
"plan_slide": plan_slide,
|
||||
"message": f"{label} not found on the corresponding read-back slide",
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_project(project_path: Path) -> dict[str, Any]:
|
||||
"""Run read-back validation for a template-fill project."""
|
||||
project_path = project_path.expanduser().resolve()
|
||||
plan_path = project_path / "analysis" / "fill_plan.json"
|
||||
if not plan_path.is_file():
|
||||
raise RuntimeError(f"Missing fill plan: {plan_path}")
|
||||
|
||||
plan = _load_json(plan_path)
|
||||
output_path = _latest_export(project_path)
|
||||
validation_dir = project_path / "validation"
|
||||
validation_dir.mkdir(parents=True, exist_ok=True)
|
||||
readback_path = validation_dir / "readback.md"
|
||||
|
||||
ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py"
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, str(ppt_to_md), str(output_path), "-o", str(readback_path)],
|
||||
cwd=_SCRIPTS_DIR.parents[2],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f"Missing executable: {sys.executable}") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
details = (exc.stderr or exc.stdout or "").strip()
|
||||
raise RuntimeError(details or "ppt_to_md read-back failed") from exc
|
||||
|
||||
markdown = readback_path.read_text(encoding="utf-8", errors="replace")
|
||||
results: list[dict[str, Any]] = []
|
||||
summary = {"ok": 0, "warn": 0, "error": 0}
|
||||
sections = _slide_sections(markdown)
|
||||
|
||||
expected_slides = len(plan.get("slides", []) or [])
|
||||
actual_slides = _readback_slide_count(markdown)
|
||||
if expected_slides == actual_slides:
|
||||
summary["ok"] += 1
|
||||
else:
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "slide_count_mismatch",
|
||||
"expected": expected_slides,
|
||||
"actual": actual_slides,
|
||||
"message": "read-back slide count does not match fill_plan.slides",
|
||||
}
|
||||
)
|
||||
|
||||
for plan_slide in range(1, expected_slides + 1):
|
||||
if plan_slide in sections:
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "slide_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": "planned slide section is missing from read-back Markdown",
|
||||
}
|
||||
)
|
||||
|
||||
library = _first_library(project_path)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=_replacement_tokens(plan, library),
|
||||
status="ERROR",
|
||||
matcher=_contains_paragraphs,
|
||||
)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "table_text_missing_in_readback", "table cell text")
|
||||
for plan_slide, text in _table_tokens(plan, library)
|
||||
],
|
||||
status="ERROR",
|
||||
matcher=_contains_table_cell,
|
||||
)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "chart_text_missing_in_readback", "chart text")
|
||||
for plan_slide, text in _chart_tokens(plan, library)
|
||||
],
|
||||
status="WARN",
|
||||
matcher=_contains_chart_text,
|
||||
)
|
||||
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []) or [], start=1):
|
||||
_, notes = _split_notes(sections.get(plan_slide, ""))
|
||||
planned_notes = str(slide.get("notes") or slide.get("speaker_notes") or "")
|
||||
if _paragraph_lines(planned_notes):
|
||||
if _contains_paragraphs(notes, planned_notes):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "notes_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"planned speaker notes not found on the corresponding "
|
||||
"read-back slide"
|
||||
),
|
||||
"text": planned_notes,
|
||||
}
|
||||
)
|
||||
elif notes.strip():
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "unexpected_notes_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"read-back slide contains speaker notes although the "
|
||||
"plan slide has no notes field"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema": "template_fill_pptx_validate.v1",
|
||||
"project": str(project_path),
|
||||
"export": str(output_path),
|
||||
"readback": str(readback_path),
|
||||
"summary": summary,
|
||||
"results": results,
|
||||
}
|
||||
_write_json(validation_dir / "validate_report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def print_validate_report(report: dict[str, Any]) -> None:
|
||||
"""Print a compact validation report."""
|
||||
summary = report["summary"]
|
||||
print(f"validate: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
|
||||
print(f"export: {report['export']}")
|
||||
print(f"readback: {report['readback']}")
|
||||
for item in report["results"]:
|
||||
text = item.get("text")
|
||||
suffix = f" text={text!r}" if text else ""
|
||||
print(f"{item['status']} {item['code']}: {item['message']}{suffix}")
|
||||
Reference in New Issue
Block a user