feat: 内置技能全量收录 presentation-forge (#83)

## 概述
将 PPT 工作流技能 **presentation-forge**(原 codex-ppt-skill)以 **builtin
内置技能**形态全量收录进 DesireCore 官方市场。

> 注:本 PR 最初以 entry.json pointer 形态提交,现按仓库负责人意见改为 builtin 层全量
vendoring。分支保留两个提交(pointer → builtin 转换)。

## 形态:builtin 全量 vendoring(遵循 guizang-ppt 先例)
- 全量内容进 `skills/presentation-forge/`:`SKILL.md` + `SKILL.zh-CN.md` +
`references/` + `scripts/` + `styles/`(9 套风格)+ `templates/` +
`schemas/`。
- builtin frontmatter:`version/type/risk_level/status/tags` +
`metadata.i18n`(zh-CN/en-US)+
`market.icon/category/maintainer`,`category: creative`。
- 列入 `builtin-skills.json`(33 项)。
- 附 `LICENSE`(MIT)+ `NOTICE.md` +
`_desirecore/{frontmatter.yaml,upstream.json}` 溯源。
- 溯源 commit:`mashagua/presentation-forge@b7b1a9c`(MIT)。
- 排除非运行内容:`tests/` 夹具、未被任何 vendored 文件引用的 1.3MB 流程图、Codex `agents/` 绑定。

## 变更
- 新增 `skills/presentation-forge/`(全量内容,约 440KB)
- `builtin-skills.json`:新增 `presentation-forge`(32 → 33)
- `manifest.json`:`stats.totalSkills` 60 → 61,`version` 1.2.26 →
1.2.27,`lastUpdated` → 2026-08-10
- `README.md`:built-in 32 → 33、external 29 → 28、total 61 不变,两处代码块同步

## 验证
```
uv run scripts/i18n/validate-i18n.py
OK: no i18n issues found.
```
stats 公式核对:entry.json 目录 28 + SKILL.md 目录 33 = 61 = manifest.totalSkills
✓

---------

Co-authored-by: Yige <a@wyr.me>
Co-authored-by: yige <yige@yigedeMacBook-Neo.local>
This commit is contained in:
mashagua
2026-08-23 23:31:26 +08:00
committed by GitHub
parent 5a0a80d31c
commit 98ffc8fe92
84 changed files with 11125 additions and 5 deletions

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Audit whether a PPTX is likely image-only or practically editable."""
from __future__ import annotations
import argparse
import json
import re
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
NS = {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx", help="PPTX file to audit.")
parser.add_argument("--json", dest="json_path", help="Optional JSON report path.")
return parser.parse_args()
def natural_slide_key(name: str) -> list[object]:
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", name)]
def audit_pptx(pptx_path: Path) -> dict:
with zipfile.ZipFile(pptx_path) as zf:
names = zf.namelist()
media = [name for name in names if name.startswith("ppt/media/")]
slides = sorted(
[name for name in names if re.match(r"ppt/slides/slide\d+\.xml$", name)],
key=natural_slide_key,
)
slide_reports = []
for idx, name in enumerate(slides, start=1):
root = ET.fromstring(zf.read(name))
text_runs = [node.text or "" for node in root.findall(".//a:t", NS)]
text_chars = sum(len(text.strip()) for text in text_runs)
shape_count = len(root.findall(".//p:sp", NS))
picture_count = len(root.findall(".//p:pic", NS))
group_count = len(root.findall(".//p:grpSp", NS))
graphic_frame_count = len(root.findall(".//p:graphicFrame", NS))
flags = []
if picture_count > 0 and text_chars == 0 and shape_count <= 1:
flags.append("likely_image_only_slide")
if text_chars > 0 and picture_count > 0:
flags.append("mixed_native_text_and_images")
if group_count > 20:
flags.append("many_group_shapes_may_be_fragile")
slide_reports.append(
{
"slide": idx,
"shape_count": shape_count,
"picture_count": picture_count,
"group_count": group_count,
"graphic_frame_count": graphic_frame_count,
"text_run_count": len(text_runs),
"text_char_count": text_chars,
"flags": flags,
}
)
media_by_ext: dict[str, int] = {}
for item in media:
ext = Path(item).suffix.lower() or "<none>"
media_by_ext[ext] = media_by_ext.get(ext, 0) + 1
deck_flags = []
if all("likely_image_only_slide" in slide["flags"] for slide in slide_reports):
deck_flags.append("deck_looks_image_only")
if media_by_ext.get(".svg", 0) > 0:
deck_flags.append("contains_svg_media_check_powerpoint_editability")
return {
"pptx": str(pptx_path),
"slide_count": len(slide_reports),
"media_count": len(media),
"media_by_ext": media_by_ext,
"deck_flags": deck_flags,
"slides": slide_reports,
}
def print_report(report: dict) -> None:
print(f"PPTX: {report['pptx']}")
print(f"Slides: {report['slide_count']} | Media: {report['media_count']} | Flags: {', '.join(report['deck_flags']) or 'none'}")
for slide in report["slides"]:
flags = ", ".join(slide["flags"]) or "none"
print(
f"- slide {slide['slide']:02d}: text={slide['text_char_count']} chars, "
f"shapes={slide['shape_count']}, pics={slide['picture_count']}, "
f"groups={slide['group_count']}, frames={slide['graphic_frame_count']}, flags={flags}"
)
def main() -> None:
args = parse_args()
report = audit_pptx(Path(args.pptx))
print_report(report)
if args.json_path:
out = Path(args.json_path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Scan a public skill package for obvious private paths, secrets, and client residue."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
DEFAULT_BLOCK_PATTERNS = [
r"/" + "Users" + r"/",
r"/" + "home" + r"/",
r"/" + "Volumes" + r"/",
r"Bearer\s+[A-Za-z0-9._-]+",
r"sk-[A-Za-z0-9_-]{12,}",
r"(?i)(api[_-]?key|OPENAI_API_KEY|password|passwd|cookie|secret|token)\s*[:=]",
]
PROJECT_RESIDUE_PATTERNS: list[str] = []
TEXT_SUFFIXES = {
".md",
".txt",
".py",
".js",
".ts",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".xml",
".html",
".css",
".svg",
}
SKIP_DIR_NAMES = {
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"env",
"node_modules",
"output",
"venv",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=".", help="Repository or skill root to scan.")
parser.add_argument("--extra-pattern", action="append", default=[], help="Additional regex pattern to block.")
parser.add_argument("--allow-project-words", action="store_true", help="Do not fail on built-in project residue word list.")
return parser.parse_args()
def should_scan(path: Path) -> bool:
if any(part in SKIP_DIR_NAMES for part in path.parts):
return False
if path.name in {".DS_Store"}:
return False
return path.suffix.lower() in TEXT_SUFFIXES
def main() -> None:
args = parse_args()
root = Path(args.root)
patterns = [re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCK_PATTERNS + args.extra_pattern]
if not args.allow_project_words:
patterns.extend(re.compile(re.escape(p), re.IGNORECASE) for p in PROJECT_RESIDUE_PATTERNS)
findings = []
for path in sorted(p for p in root.rglob("*") if p.is_file() and should_scan(p)):
rel = path.relative_to(root)
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for line_no, line in enumerate(text.splitlines(), start=1):
for pattern in patterns:
if pattern.search(line):
findings.append((str(rel), line_no, pattern.pattern))
if findings:
for rel, line_no, pattern in findings:
print(f"{rel}:{line_no}: matched {pattern}")
raise SystemExit(1)
print("Public skill audit passed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,702 @@
#!/usr/bin/env python3
"""Build an AIPPT-style native editable deck with optional image-2 visual assets."""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
from build_semantic_deck import fit_contain, hex_to_rgb, patch_theme_east_asian_fonts, set_run_typefaces
from style_presets import merged_design_tokens, resolve_style
from validate_design_quality import analyze_design
ROOT = Path(__file__).resolve().parents[1]
SLIDE_W = 13.333333
SLIDE_H = 7.5
POLICIES = {"native-only", "native-image-assisted", "image-led-editable"}
ARCHETYPES = {"cover", "content-structured", "process-flow", "comparison-two-zone", "data-callouts", "table", "architecture", "closing-action"}
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"body": "body",
"label": "label",
"caption": "caption",
"table": "table",
}
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def first_color(value: Any, fallback: str) -> str:
if isinstance(value, list):
return str(value[0]) if value else fallback
return str(value or fallback)
def second_color(value: Any, fallback: str) -> str:
if isinstance(value, list) and len(value) > 1:
return str(value[1])
return fallback
def load_design(style_id: str, variant_id: str | None, profile_id: str | None, table_id: str | None) -> tuple[dict[str, str], dict[str, Any], dict[str, Any], str, str, str]:
style, variant, raw = merged_design_tokens(style_id, variant_id)
text_values = raw.get("text") if isinstance(raw.get("text"), list) else [raw.get("text")]
background = first_color(raw.get("background"), "FFFFFF")
colors = {
"background": background,
"primary": first_color(raw.get("primary"), "2563EB"),
"secondary": first_color(raw.get("secondary"), "60A5FA"),
"text": str((text_values or ["111827"])[0] or "111827"),
"muted": str((text_values or ["111827", "64748B"])[1] if len(text_values or []) > 1 else "64748B"),
"panel": second_color(raw.get("background"), "F3F6FA"),
}
profiles = read_json(ROOT / "styles" / "typography-profiles.json")
resolved_profile = profile_id or style.get("typography_profile")
resolved_table = table_id or style.get("table_profile")
typography = next((row for row in profiles.get("typography_profiles", []) if row.get("id") == resolved_profile), None)
table_policy = next((row for row in profiles.get("table_profiles", []) if row.get("id") == resolved_table), None)
if not typography or not table_policy:
raise ValueError(f"unknown typography/table profile: {resolved_profile}/{resolved_table}")
return colors, typography, table_policy, str(resolved_profile), str(resolved_table), str(variant["id"])
def set_role(shape: Any, role: str, object_id: str) -> None:
shape._element.nvSpPr.cNvPr.set("name", f"{object_id} [pf-role={role}]")
def set_object_name(shape: Any, object_id: str) -> None:
for attr in ("nvSpPr", "nvCxnSpPr", "nvPicPr", "nvGraphicFramePr", "nvGrpSpPr"):
node = getattr(shape._element, attr, None)
if node is not None and getattr(node, "cNvPr", None) is not None:
node.cNvPr.set("name", object_id)
return
def object_id(shape: Any) -> str:
return str(getattr(shape, "name", "")).split(" [pf-role=", 1)[0]
def object_role(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
return name.split("[pf-role=", 1)[1].rstrip("]") if "[pf-role=" in name else None
def role_settings(profile: dict[str, Any], role: str) -> tuple[float, str, str, dict[str, Any]]:
size_key = ROLE_SIZE_KEY[role]
size = float(profile["tokens"][size_key])
heading = size_key in {"hero", "section_title", "page_title", "subtitle", "minor_title"}
fonts = profile["fonts"]
east_asia = fonts["heading_east_asia"] if heading else fonts["body_east_asia"]
latin = fonts["latin"]
paragraph_group = "title" if heading else "caption" if size_key in {"label", "caption"} else "body"
return size, east_asia, latin, profile.get("paragraph", {}).get(paragraph_group, {})
def add_text(
slide: Any,
text: str,
box: tuple[float, float, float, float],
role: str,
profile: dict[str, Any],
color: str,
object_id: str,
*,
align: str = "left",
bold: bool | None = None,
vertical: str = "middle",
) -> Any:
x, y, w, h = box
shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
set_role(shape, role, object_id)
frame = shape.text_frame
frame.clear()
frame.word_wrap = True
frame.margin_left = Inches(0.04)
frame.margin_right = Inches(0.04)
frame.margin_top = Inches(0.02)
frame.margin_bottom = Inches(0.02)
shape.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(vertical, MSO_ANCHOR.MIDDLE)
size, east_asia, latin, paragraph_policy = role_settings(profile, role)
lines = str(text).split("\n")
for index, line in enumerate(lines):
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
run = paragraph.add_run()
run.text = line
run.font.name = latin
run.font.size = Pt(size)
run.font.bold = bool(role in {"hero", "section_title", "title", "header"}) if bold is None else bold
run.font.color.rgb = hex_to_rgb(color, "111827")
set_run_typefaces(run, east_asia, latin, profile["fonts"].get("complex_script") or latin)
paragraph.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(align, PP_ALIGN.LEFT)
paragraph.line_spacing = float(paragraph_policy.get("line_spacing_multiple", 1.0))
paragraph.space_before = Pt(size * float(paragraph_policy.get("space_before_lines", 0)))
paragraph.space_after = Pt(size * float(paragraph_policy.get("space_after_lines", 0)))
return shape
def add_box(slide: Any, box: tuple[float, float, float, float], fill: str, line: str, object_id: str, radius: bool = True) -> Any:
x, y, w, h = box
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
shape = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
shape._element.nvSpPr.cNvPr.set("name", object_id)
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill, "FFFFFF")
shape.line.color.rgb = hex_to_rgb(line, "D7E2F0")
shape.line.width = Pt(0.8)
return shape
def add_title(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str]) -> None:
add_text(slide, str(row.get("title", "")), (0.65, 0.34, 12.0, 0.7), "title", profile, colors["text"], f"s{row['slide_number']:03d}_title", vertical="top")
if row.get("key_message"):
add_text(slide, str(row["key_message"]), (0.68, 1.02, 11.7, 0.42), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_message", vertical="top")
def add_visual(slide: Any, slot: dict[str, Any], base_dir: Path, default_box: tuple[float, float, float, float], state: dict[str, int], object_name: str) -> None:
asset = Path(str(slot["asset_path"]))
asset_path = asset if asset.is_absolute() else base_dir / asset
raw_box = slot.get("bbox_in") or list(default_box)
box = tuple(float(value) for value in raw_box)
fitted = fit_contain(asset_path, box)
picture = slide.shapes.add_picture(str(asset_path), Inches(fitted[0]), Inches(fitted[1]), width=Inches(fitted[2]), height=Inches(fitted[3]))
set_object_name(picture, object_name)
state["image_objects"] += 1
if slot.get("backend") == "image-2":
state["image2_assets"] += 1
def validate_visual_slot(slot: dict[str, Any], base_dir: Path, policy: str, slide_number: int) -> list[str]:
errors: list[str] = []
backend = str(slot.get("backend") or "provided")
source_type = str(slot.get("source_type") or "provided_asset")
status = str(slot.get("status") or "planned")
if policy == "native-only" and backend == "image-2":
errors.append(f"slide_{slide_number}:native_only_rejects_image2")
if backend == "image-2":
if source_type != "imagegen_asset":
errors.append(f"slide_{slide_number}:image2_source_type_must_be_imagegen_asset")
if status not in {"generated", "validated"}:
errors.append(f"slide_{slide_number}:image2_asset_not_generated")
prompt = slot.get("prompt_record")
if not prompt:
errors.append(f"slide_{slide_number}:image2_prompt_record_missing")
else:
prompt_path = Path(str(prompt))
prompt_path = prompt_path if prompt_path.is_absolute() else base_dir / prompt_path
if not prompt_path.is_file():
errors.append(f"slide_{slide_number}:image2_prompt_record_not_found")
asset = slot.get("asset_path")
if not asset:
errors.append(f"slide_{slide_number}:visual_asset_path_missing")
else:
asset_path = Path(str(asset))
asset_path = asset_path if asset_path.is_absolute() else base_dir / asset_path
if not asset_path.is_file():
errors.append(f"slide_{slide_number}:visual_asset_not_found")
return errors
def build_cover(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], base_dir: Path, state: dict[str, int]) -> None:
visual = row.get("visual_slot")
title_box = (0.85, 1.35, 6.8 if visual else 11.6, 1.45)
add_text(slide, str(row.get("title", "")), title_box, "hero", profile, colors["text"], f"s{row['slide_number']:03d}_hero", vertical="top")
if row.get("subtitle"):
add_text(slide, str(row["subtitle"]), (0.9, 3.0, 6.4 if visual else 10.8, 0.8), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_subtitle", vertical="top")
if row.get("meta"):
add_text(slide, " · ".join(str(value) for value in row["meta"]), (0.9, 6.45, 8.0, 0.35), "caption", profile, colors["muted"], f"s{row['slide_number']:03d}_meta")
if visual:
add_visual(slide, visual, base_dir, (8.0, 0.9, 4.5, 5.8), state, f"s{row['slide_number']:03d}_visual")
def build_content(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
cards = row.get("cards", [])
columns = 2 if len(cards) <= 4 else 3
rows = (len(cards) + columns - 1) // columns
gap = 0.25
card_w = (12.0 - gap * (columns - 1)) / columns
card_h = (5.35 - gap * (rows - 1)) / max(1, rows)
for index, card in enumerate(cards):
col, row_index = index % columns, index // columns
x = 0.65 + col * (card_w + gap)
y = 1.62 + row_index * (card_h + gap)
add_box(slide, (x, y, card_w, card_h), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_card_{index+1}")
add_text(slide, str(card.get("title", "")), (x + 0.22, y + 0.18, card_w - 0.44, 0.45), "header", profile, colors["text"], f"s{row['slide_number']:03d}_card_{index+1}_title", vertical="top")
add_text(slide, str(card.get("body", "")), (x + 0.22, y + 0.75, card_w - 0.44, card_h - 0.95), "body", profile, colors["muted"], f"s{row['slide_number']:03d}_card_{index+1}_body", vertical="top")
state["native_shapes"] += 1
def build_process(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
steps = row.get("steps", [])
gap = 0.28
width = (12.0 - gap * (len(steps) - 1)) / max(1, len(steps))
for index, step in enumerate(steps):
x = 0.65 + index * (width + gap)
add_box(slide, (x, 2.0, width, 3.65), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_step_{index+1}")
add_text(slide, f"{index + 1:02d}", (x + 0.18, 2.2, 0.7, 0.42), "label", profile, colors["primary"], f"s{row['slide_number']:03d}_step_{index+1}_number")
add_text(slide, str(step.get("title", "")), (x + 0.18, 2.85, width - 0.36, 0.55), "header", profile, colors["text"], f"s{row['slide_number']:03d}_step_{index+1}_title", vertical="top")
add_text(slide, str(step.get("body", "")), (x + 0.18, 3.55, width - 0.36, 1.55), "body", profile, colors["muted"], f"s{row['slide_number']:03d}_step_{index+1}_body", vertical="top")
state["native_shapes"] += 1
if index < len(steps) - 1:
connector = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x + width), Inches(3.8), Inches(x + width + gap), Inches(3.8))
set_object_name(connector, f"s{row['slide_number']:03d}_connector_{index+1}")
connector.line.color.rgb = hex_to_rgb(colors["secondary"], "60A5FA")
connector.line.width = Pt(1.5)
state["connectors"] += 1
def add_bullet_list(slide: Any, values: list[Any], box: tuple[float, float, float, float], profile: dict[str, Any], color: str, object_id: str) -> None:
add_text(slide, "\n".join(f"{value}" for value in values), box, "body", profile, color, object_id, vertical="top")
def build_comparison(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
sides = [row.get("left", {}), row.get("right", {})]
for index, side in enumerate(sides):
x = 0.65 + index * 6.15
add_box(slide, (x, 1.75, 5.85, 4.9), colors["panel"], colors["primary"] if index else colors["secondary"], f"s{row['slide_number']:03d}_compare_{index+1}")
add_text(slide, str(side.get("title", "")), (x + 0.3, 2.05, 5.25, 0.6), "header", profile, colors["text"], f"s{row['slide_number']:03d}_compare_{index+1}_title")
add_bullet_list(slide, list(side.get("points", [])), (x + 0.35, 2.85, 5.1, 3.35), profile, colors["muted"], f"s{row['slide_number']:03d}_compare_{index+1}_points")
state["native_shapes"] += 1
def add_native_chart(slide: Any, chart_spec: dict[str, Any], box: tuple[float, float, float, float], colors: dict[str, str], state: dict[str, int], object_name: str) -> None:
chart_data = ChartData()
chart_data.categories = [str(value) for value in chart_spec.get("categories", [])]
for series in chart_spec.get("series", []):
chart_data.add_series(str(series.get("name", "Series")), [float(value) for value in series.get("values", [])])
chart_type = {
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"line": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
}.get(str(chart_spec.get("type", "column")), XL_CHART_TYPE.COLUMN_CLUSTERED)
x, y, w, h = box
chart_shape = slide.shapes.add_chart(chart_type, Inches(x), Inches(y), Inches(w), Inches(h), chart_data)
set_object_name(chart_shape, object_name)
chart = chart_shape.chart
chart.has_legend = len(chart_spec.get("series", [])) > 1 or chart_type == XL_CHART_TYPE.PIE
if chart.has_legend:
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.has_title = bool(chart_spec.get("title"))
if chart.has_title:
chart.chart_title.text_frame.text = str(chart_spec["title"])
state["office_charts"] += 1
def build_data(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
metrics = row.get("metrics", [])
width = 12.0 / max(1, len(metrics))
for index, metric in enumerate(metrics):
x = 0.65 + index * width
add_text(slide, str(metric.get("value", "")), (x, 1.55, width - 0.2, 0.72), "section_title", profile, colors["primary"], f"s{row['slide_number']:03d}_metric_{index+1}_value")
add_text(slide, str(metric.get("label", "")), (x, 2.25, width - 0.2, 0.38), "label", profile, colors["muted"], f"s{row['slide_number']:03d}_metric_{index+1}_label", vertical="top")
if row.get("chart"):
add_native_chart(slide, row["chart"], (0.8, 3.0, 11.7, 3.65), colors, state, f"s{row['slide_number']:03d}_chart")
def build_table(slide: Any, row: dict[str, Any], profile: dict[str, Any], table_policy: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
headers = [str(value) for value in row.get("headers", [])]
data_rows = [[str(value) for value in values] for values in row.get("rows", [])]
columns = len(headers)
table_shape = slide.shapes.add_table(len(data_rows) + 1, columns, Inches(0.7), Inches(1.65), Inches(11.95), Inches(4.95))
table_shape._element.nvGraphicFramePr.cNvPr.set("name", f"s{row['slide_number']:03d}_table")
table = table_shape.table
column_types = list(row.get("column_types", []))
size, east_asia, latin, paragraph_policy = role_settings(profile, "table")
for row_index, values in enumerate([headers, *data_rows]):
for col_index, value in enumerate(values):
cell = table.cell(row_index, col_index)
cell.text = value
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
cell.fill.solid()
cell.fill.fore_color.rgb = hex_to_rgb(colors["primary"] if row_index == 0 else colors["panel"], "FFFFFF")
paragraph = cell.text_frame.paragraphs[0]
kind = column_types[col_index] if col_index < len(column_types) else ("index" if col_index == 0 else "text")
expected = table_policy["header_alignment"] if row_index == 0 else table_policy["numeric_alignment"] if kind == "numeric" else table_policy["index_alignment"] if kind == "index" else table_policy["text_alignment"]
paragraph.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}[expected]
paragraph.line_spacing = float(paragraph_policy.get("line_spacing_multiple", 1.0))
paragraph.space_before = Pt(0)
paragraph.space_after = Pt(0)
for run in paragraph.runs:
run.font.name = latin
run.font.size = Pt(size)
run.font.bold = row_index == 0
run.font.color.rgb = hex_to_rgb("FFFFFF" if row_index == 0 else colors["text"], "111827")
set_run_typefaces(run, east_asia, latin, profile["fonts"].get("complex_script") or latin)
state["native_tables"] += 1
def build_architecture(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_title(slide, row, profile, colors)
layers = row.get("layers", [])
height = 4.9 / max(1, len(layers))
for index, layer in enumerate(layers):
y = 1.65 + index * height
add_box(slide, (1.0, y, 11.3, height - 0.16), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_layer_{index+1}", radius=False)
add_text(slide, str(layer.get("title", "")), (1.25, y + 0.14, 2.3, height - 0.42), "header", profile, colors["primary"], f"s{row['slide_number']:03d}_layer_{index+1}_title")
add_text(slide, str(layer.get("body", "")), (3.6, y + 0.14, 8.35, height - 0.42), "body", profile, colors["text"], f"s{row['slide_number']:03d}_layer_{index+1}_body")
state["native_shapes"] += 1
def build_closing(slide: Any, row: dict[str, Any], profile: dict[str, Any], colors: dict[str, str], state: dict[str, int]) -> None:
add_text(slide, str(row.get("title", "")), (1.0, 1.2, 11.3, 1.2), "hero", profile, colors["text"], f"s{row['slide_number']:03d}_closing_title", align="center")
if row.get("key_message"):
add_text(slide, str(row["key_message"]), (1.4, 2.55, 10.5, 0.75), "subtitle", profile, colors["muted"], f"s{row['slide_number']:03d}_closing_message", align="center")
actions = row.get("actions", [])
width = 9.6 / max(1, len(actions))
for index, action in enumerate(actions):
x = 1.85 + index * width
add_box(slide, (x, 4.05, width - 0.25, 1.25), colors["panel"], colors["primary"], f"s{row['slide_number']:03d}_action_{index+1}")
add_text(slide, str(action), (x + 0.14, 4.25, width - 0.53, 0.75), "body", profile, colors["text"], f"s{row['slide_number']:03d}_action_{index+1}_text", align="center")
state["native_shapes"] += 1
def apply_text_override(shape: Any, element: dict[str, Any], profile: dict[str, Any]) -> None:
if not getattr(shape, "has_text_frame", False):
return
role = str(element.get("role") or object_role(shape) or "body")
if role not in ROLE_SIZE_KEY:
role = "body"
default_size, east_asia, latin, paragraph_policy = role_settings(profile, role)
style = element.get("style") if isinstance(element.get("style"), dict) else {}
size = float(style.get("font_size_pt", default_size))
font_family = str(style.get("font_family") or latin)
font_color = str(style.get("font_color") or "111827")
bold = bool(style.get("bold", role in {"hero", "section_title", "title", "header"}))
align = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(str(style.get("align", "left")), PP_ALIGN.LEFT)
frame = shape.text_frame
frame.clear()
frame.word_wrap = True
vertical = str(style.get("vertical_align", "middle"))
frame.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(vertical, MSO_ANCHOR.MIDDLE)
for index, line in enumerate(str(element.get("text", "")).split("\n")):
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
run = paragraph.add_run(); run.text = line
run.font.name = font_family
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = hex_to_rgb(font_color, "111827")
set_run_typefaces(run, east_asia, font_family, profile["fonts"].get("complex_script") or font_family)
paragraph.alignment = align
paragraph.line_spacing = float(style.get("line_spacing", paragraph_policy.get("line_spacing_multiple", 1.0)))
paragraph.space_before = Pt(size * float(paragraph_policy.get("space_before_lines", 0)))
paragraph.space_after = Pt(size * float(paragraph_policy.get("space_after_lines", 0)))
def materialize_scene_element(slide: Any, element: dict[str, Any], canvas_w: float, canvas_h: float) -> Any | None:
"""Create native PowerPoint objects that exist on the canvas but not in the base archetype."""
element_id = str(element.get("id") or "")
bbox = element.get("bbox")
if not element_id or not isinstance(bbox, list) or len(bbox) != 4:
return None
x, y, w, h = (float(value) for value in bbox)
left, top = Inches(x / canvas_w * SLIDE_W), Inches(y / canvas_h * SLIDE_H)
width, height = Inches(max(1.0, w) / canvas_w * SLIDE_W), Inches(max(1.0, h) / canvas_h * SLIDE_H)
kind = str(element.get("type") or "")
if kind == "text":
shape = slide.shapes.add_textbox(left, top, width, height)
set_role(shape, str(element.get("role") or "body"), element_id)
return shape
if kind == "shape":
geometry = str(element.get("geometry") or "rectangle").lower()
shape_type = {
"ellipse": MSO_SHAPE.OVAL,
"oval": MSO_SHAPE.OVAL,
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
"roundrect": MSO_SHAPE.ROUNDED_RECTANGLE,
}.get(geometry, MSO_SHAPE.RECTANGLE)
shape = slide.shapes.add_shape(shape_type, left, top, width, height)
set_object_name(shape, element_id)
return shape
if kind == "connector":
shape = slide.shapes.add_connector(
MSO_CONNECTOR.STRAIGHT,
left,
top,
Inches((x + w) / canvas_w * SLIDE_W),
Inches((y + h) / canvas_h * SLIDE_H),
)
set_object_name(shape, element_id)
return shape
return None
def apply_scene_overrides(slide: Any, scene: dict[str, Any], profile: dict[str, Any]) -> tuple[int, int, int]:
canvas = scene.get("canvas", {})
canvas_w = float(canvas.get("width", 1920))
canvas_h = float(canvas.get("height", 1080))
shapes = {object_id(shape): shape for shape in slide.shapes if object_id(shape)}
applied = 0
materialized_shapes = 0
materialized_connectors = 0
z_rows: list[tuple[int, Any]] = []
for element in scene.get("elements", []):
shape = shapes.get(str(element.get("id")))
if shape is None:
shape = materialize_scene_element(slide, element, canvas_w, canvas_h)
if shape is None:
continue
shapes[str(element.get("id"))] = shape
if str(element.get("type")) == "connector":
materialized_connectors += 1
else:
materialized_shapes += 1
bbox = element.get("bbox")
if isinstance(bbox, list) and len(bbox) == 4:
x, y, w, h = (float(value) for value in bbox)
shape.left = Inches(x / canvas_w * SLIDE_W)
shape.top = Inches(y / canvas_h * SLIDE_H)
shape.width = Inches(w / canvas_w * SLIDE_W)
shape.height = Inches(h / canvas_h * SLIDE_H)
if "rotation" in element and hasattr(shape, "rotation"):
shape.rotation = float(element["rotation"])
style = element.get("style") if isinstance(element.get("style"), dict) else {}
if style.get("fill") and hasattr(shape, "fill"):
shape.fill.solid(); shape.fill.fore_color.rgb = hex_to_rgb(str(style["fill"]), "FFFFFF")
if style.get("line") and hasattr(shape, "line"):
shape.line.color.rgb = hex_to_rgb(str(style["line"]), "D7E2F0")
if style.get("line_width_pt") is not None and hasattr(shape, "line"):
shape.line.width = Pt(float(style["line_width_pt"]))
if "text" in element:
apply_text_override(shape, element, profile)
z_rows.append((int(element.get("z_index", 0)), shape))
applied += 1
tree = slide.shapes._spTree
for _, shape in sorted(z_rows, key=lambda row: row[0]):
tree.remove(shape._element)
tree.append(shape._element)
return applied, materialized_shapes, materialized_connectors
def validate_spec(spec: dict[str, Any], base_dir: Path) -> list[str]:
errors: list[str] = []
policy = str(spec.get("visual_asset_policy") or "native-image-assisted")
if policy not in POLICIES:
errors.append(f"invalid_visual_asset_policy:{policy}")
try:
resolve_style(str(spec.get("style_id") or "consulting-blue-white"), spec.get("style_variant"))
except ValueError as exc:
errors.append(str(exc))
slides = spec.get("slides")
if not isinstance(slides, list) or not slides:
return [*errors, "slides_missing"]
image2_count = 0
for expected_number, row in enumerate(slides, start=1):
number = int(row.get("slide_number", 0))
if number != expected_number:
errors.append(f"slide_number_not_contiguous:{number}:{expected_number}")
archetype = str(row.get("archetype") or "")
if archetype not in ARCHETYPES:
errors.append(f"slide_{number}:unsupported_archetype:{archetype}")
if not str(row.get("title") or "").strip():
errors.append(f"slide_{number}:title_missing")
try:
resolve_style(str(row.get("style_id") or spec.get("style_id") or "consulting-blue-white"), row.get("style_variant") or spec.get("style_variant"))
except ValueError as exc:
errors.append(f"slide_{number}:{exc}")
visual = row.get("visual_slot")
if visual:
errors.extend(validate_visual_slot(visual, base_dir, policy, number))
if visual.get("backend") == "image-2":
image2_count += 1
if archetype == "content-structured" and not 2 <= len(row.get("cards", [])) <= 6:
errors.append(f"slide_{number}:cards_must_be_2_to_6")
if archetype == "process-flow" and not 3 <= len(row.get("steps", [])) <= 5:
errors.append(f"slide_{number}:steps_must_be_3_to_5")
if archetype == "data-callouts" and not 2 <= len(row.get("metrics", [])) <= 5:
errors.append(f"slide_{number}:metrics_must_be_2_to_5")
if archetype == "table" and (not row.get("headers") or not row.get("rows")):
errors.append(f"slide_{number}:table_data_missing")
if policy == "image-led-editable" and image2_count == 0:
errors.append("image_led_editable_requires_image2_asset")
return errors
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--spec", required=True)
parser.add_argument("--out-pptx", required=True)
parser.add_argument("--base-dir")
parser.add_argument("--scene-dir", help="Optional scene directory whose element geometry/text/style overrides the generated layout.")
parser.add_argument("--session", help="Optional session to mark editor canvas rebuilds complete.")
parser.add_argument("--report")
parser.add_argument("--design-report", help="Optional Anti-AI-slop design-gate report path.")
args = parser.parse_args()
spec_path = Path(args.spec).resolve()
base_dir = Path(args.base_dir).resolve() if args.base_dir else spec_path.parent
spec = read_json(spec_path)
if args.session:
session_path = Path(args.session).resolve()
metadata_path = session_path / "metadata.json"
if metadata_path.is_file():
editor_metadata = read_json(metadata_path)
out_candidate = Path(args.out_pptx).resolve()
final_dir = (session_path / "final").resolve()
if (
editor_metadata.get("editor_workflow_mode") == "canvas-first"
and (out_candidate == final_dir or final_dir in out_candidate.parents)
and editor_metadata.get("editor_export_approval") != "approved"
):
report = {"schema_version": 1, "status": "FAIL", "errors": ["canvas_export:not_approved"]}
print(json.dumps(report, ensure_ascii=False))
raise SystemExit(2)
errors = validate_spec(spec, base_dir)
if errors:
report = {"schema_version": 1, "status": "FAIL", "spec": str(spec_path), "errors": errors}
if args.report:
out_report = Path(args.report).resolve()
out_report.parent.mkdir(parents=True, exist_ok=True)
out_report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
raise SystemExit(2)
scene_dir = Path(args.scene_dir).resolve() if args.scene_dir else None
scenes = [read_json(path) for path in sorted(scene_dir.glob("*.scene.json"))] if scene_dir and scene_dir.is_dir() else []
design_report = analyze_design(spec, scenes)
design_report_path = Path(args.design_report).resolve() if args.design_report else (Path(args.session).resolve() / "reports" / "design-quality.json" if args.session else None)
if design_report_path:
design_report_path.parent.mkdir(parents=True, exist_ok=True)
design_report_path.write_text(json.dumps(design_report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if design_report["status"] == "FAIL":
print(json.dumps(design_report, ensure_ascii=False))
raise SystemExit(2)
style_id = str(spec.get("style_id") or "consulting-blue-white")
colors, profile, table_policy, profile_id, table_id, style_variant = load_design(style_id, spec.get("style_variant"), spec.get("typography_profile"), spec.get("table_profile"))
presentation = Presentation()
presentation.slide_width = Inches(SLIDE_W)
presentation.slide_height = Inches(SLIDE_H)
blank = presentation.slide_layouts[6]
state: dict[str, int] = {"native_shapes": 0, "connectors": 0, "native_tables": 0, "office_charts": 0, "image_objects": 0, "image2_assets": 0, "canvas_overrides_applied": 0}
for row in spec["slides"]:
slide_style_id = str(row.get("style_id") or style_id)
slide_colors, slide_profile, slide_table_policy, _, _, _ = load_design(
slide_style_id,
row.get("style_variant") or spec.get("style_variant"),
spec.get("typography_profile"),
spec.get("table_profile"),
)
builders = {
"cover": lambda slide, item: build_cover(slide, item, slide_profile, slide_colors, base_dir, state),
"content-structured": lambda slide, item: build_content(slide, item, slide_profile, slide_colors, state),
"process-flow": lambda slide, item: build_process(slide, item, slide_profile, slide_colors, state),
"comparison-two-zone": lambda slide, item: build_comparison(slide, item, slide_profile, slide_colors, state),
"data-callouts": lambda slide, item: build_data(slide, item, slide_profile, slide_colors, state),
"table": lambda slide, item: build_table(slide, item, slide_profile, slide_table_policy, slide_colors, state),
"architecture": lambda slide, item: build_architecture(slide, item, slide_profile, slide_colors, state),
"closing-action": lambda slide, item: build_closing(slide, item, slide_profile, slide_colors, state),
}
slide = presentation.slides.add_slide(blank)
slide.background.fill.solid()
slide.background.fill.fore_color.rgb = hex_to_rgb(slide_colors["background"], "FFFFFF")
builders[str(row["archetype"])](slide, row)
if row.get("visual_slot") and row["archetype"] != "cover":
add_visual(slide, row["visual_slot"], base_dir, (9.4, 5.2, 3.1, 1.75), state, f"s{row['slide_number']:03d}_visual")
if scene_dir:
scene_path = scene_dir / f"slide-{int(row['slide_number']):03d}.scene.json"
if scene_path.is_file():
applied, added_shapes, added_connectors = apply_scene_overrides(slide, read_json(scene_path), profile)
state["canvas_overrides_applied"] += applied
state["native_shapes"] += added_shapes
state["connectors"] += added_connectors
out_pptx = Path(args.out_pptx).resolve()
out_pptx.parent.mkdir(parents=True, exist_ok=True)
presentation.save(out_pptx)
patch_theme_east_asian_fonts(out_pptx, profile["fonts"]["heading_east_asia"], profile["fonts"]["body_east_asia"])
if args.session:
session = Path(args.session).resolve()
metadata_path = session / "metadata.json"
metadata = read_json(metadata_path)
try:
artifact_ref = str(out_pptx.relative_to(session))
except ValueError:
artifact_ref = str(out_pptx)
built_at = datetime.now(timezone.utc).isoformat()
variant = metadata.setdefault("variants", {}).setdefault("editable", {"status": "not_built", "artifact": None, "slides": {}})
variant["status"] = "built"
variant["artifact"] = artifact_ref
for row in spec["slides"]:
number = int(row["slide_number"])
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
scene_hash = read_json(scene_path).get("dependencies", {}).get("scene_hash") if scene_path.is_file() else None
variant.setdefault("slides", {})[str(number)] = {
"status": "built", "scene_hash": scene_hash, "artifact": artifact_ref,
"artifact_hash": sha256(out_pptx), "built_at": built_at,
}
if isinstance(metadata.get("editor"), dict):
metadata["editor"]["status"] = "ready"
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
events_path = session / "reports" / "editor-events.jsonl"
events_path.parent.mkdir(parents=True, exist_ok=True)
with events_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps({
"event": "editor.rebuild.completed", "artifact": artifact_ref,
"slides": [int(row["slide_number"]) for row in spec["slides"]], "created_at": built_at,
}, ensure_ascii=False) + "\n")
state["native_text_objects"] = sum(
1
for slide in presentation.slides
for shape in slide.shapes
if getattr(shape, "has_text_frame", False) and shape.text_frame.text.strip()
)
report = {
"schema_version": 1,
"status": "PASS",
"route": "native-editable-deck",
"pptx": str(out_pptx),
"slide_count": len(spec["slides"]),
"style_id": style_id,
"style_variant": style_variant,
"typography_profile": profile_id,
"table_profile": table_id,
"visual_asset_policy": spec.get("visual_asset_policy") or "native-image-assisted",
"design_quality": design_report["status"],
"session": str(Path(args.session).resolve()) if args.session else None,
**state,
"errors": [],
}
if args.report:
report_path = Path(args.report).resolve()
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,770 @@
#!/usr/bin/env python3
"""Build an editable PPTX from semantic inventory and asset manifest files."""
from __future__ import annotations
import argparse
import json
import os
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from PIL import Image
from pptx import Presentation
from pptx.oxml.ns import qn
from pptx.oxml.xmlchemy import OxmlElement
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.util import Inches, Pt
EMU_PER_INCH = 914400
DEFAULT_SLIDE_W = 13.333333
DEFAULT_SLIDE_H = 7.5
VALID_ASSET_SOURCES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
TEXT_WIDTH_FACTOR = 1.08
NO_WRAP_DEFAULT_ROLES = {"title", "subtitle", "header", "micro_label"}
ROOT = Path(__file__).resolve().parents[1]
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"body": "body",
"label": "label",
"micro_label": "caption",
"caption": "caption",
"table": "table",
}
def set_run_typefaces(run, east_asia_font: str, latin_font: str | None = None, complex_script_font: str | None = None) -> None:
"""Write explicit Latin, East Asian, and complex-script typefaces on a text run."""
r_pr = run._r.get_or_add_rPr()
faces = {
"a:latin": latin_font or east_asia_font,
"a:ea": east_asia_font,
"a:cs": complex_script_font or latin_font or east_asia_font,
}
for tag, font_face in faces.items():
node = r_pr.find(qn(tag))
if node is None:
node = OxmlElement(tag)
r_pr.append(node)
node.set("typeface", font_face)
def patch_theme_east_asian_fonts(pptx_path: Path, heading_font: str, body_font: str | None = None) -> None:
"""Set major/minor theme East Asian and complex-script defaults without changing Latin theme fonts."""
from lxml import etree
namespaces = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
handle = tempfile.NamedTemporaryFile(prefix=pptx_path.stem + "-font-", suffix=".pptx", dir=pptx_path.parent, delete=False)
temp_path = Path(handle.name)
handle.close()
try:
with zipfile.ZipFile(pptx_path, "r") as source, zipfile.ZipFile(temp_path, "w") as target:
for info in source.infolist():
data = source.read(info.filename)
if info.filename.startswith("ppt/theme/theme") and info.filename.endswith(".xml"):
root = etree.fromstring(data)
for branch, font_face in (("majorFont", heading_font), ("minorFont", body_font or heading_font)):
for leaf in ("ea", "cs"):
nodes = root.xpath(f".//a:fontScheme/a:{branch}/a:{leaf}", namespaces=namespaces)
for node in nodes:
node.set("typeface", font_face)
data = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
target.writestr(info, data)
os.replace(temp_path, pptx_path)
finally:
if temp_path.exists():
temp_path.unlink()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inventory", required=True, help="visual_inventory.json path.")
parser.add_argument("--manifest", required=True, help="asset_manifest.json path.")
parser.add_argument("--out-pptx", required=True, help="Output editable PPTX path.")
parser.add_argument("--base-dir", help="Base directory for relative asset paths. Defaults to inventory parent.")
parser.add_argument("--layout-rules", help="Optional layout_rules.json path for font and QA policy.")
parser.add_argument("--slide-width", type=float, default=DEFAULT_SLIDE_W, help="Slide width in inches.")
parser.add_argument("--slide-height", type=float, default=DEFAULT_SLIDE_H, help="Slide height in inches.")
parser.add_argument("--report", help="Optional JSON build report path.")
parser.add_argument("--min-body-font", type=float, default=5.8, help="QA warning floor for body text after fitting.")
parser.add_argument("--min-title-font", type=float, default=8.0, help="QA error floor for title/header text after fitting.")
parser.add_argument("--max-overflow-ratio", type=float, default=1.03, help="Allowed estimated text overflow ratio.")
parser.add_argument("--collision-threshold", type=float, default=0.08, help="Minimum text overlap share to report as collision.")
parser.add_argument("--fail-on-layout-qa", action="store_true", help="Exit non-zero when layout QA has errors.")
return parser.parse_args()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def resolve_typography_profile(inventory: dict[str, Any], layout_rules: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
profile_id = layout_rules.get("typography_profile") or inventory.get("typography_profile")
style_id = layout_rules.get("style_id") or inventory.get("style_id")
if not profile_id and style_id:
catalog = read_json(ROOT / "styles" / "catalog.json")
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
profile_id = style.get("typography_profile")
if not profile_id:
return None, None
profiles = read_json(ROOT / "styles" / "typography-profiles.json")
profile = next((row for row in profiles.get("typography_profiles", []) if row.get("id") == profile_id), None)
if profile is None:
raise ValueError(f"unknown typography_profile: {profile_id}")
return str(profile_id), profile
def typography_defaults(item: dict[str, Any], role: str, profile: dict[str, Any] | None) -> dict[str, Any]:
styled = dict(item)
if not profile:
return styled
size_key = ROLE_SIZE_KEY.get(role, "label")
styled.setdefault("font_size", profile["tokens"][size_key])
paragraph_group = "title" if size_key in {"hero", "section_title", "page_title", "subtitle", "minor_title"} else "caption" if size_key in {"label", "caption"} else "body"
paragraph = profile.get("paragraph", {}).get(paragraph_group, {})
styled.setdefault("line_spacing", paragraph.get("line_spacing_multiple", 1.0))
styled.setdefault("space_before_lines", paragraph.get("space_before_lines", 0))
styled.setdefault("space_after_lines", paragraph.get("space_after_lines", 0))
fonts = profile.get("fonts", {})
styled.setdefault("font_east_asia", fonts.get("heading_east_asia") if paragraph_group == "title" else fonts.get("body_east_asia"))
styled.setdefault("font_latin", fonts.get("latin"))
styled.setdefault("font_complex_script", fonts.get("complex_script") or fonts.get("latin"))
styled.setdefault("typography_token", size_key)
return styled
def hex_to_rgb(value: str | None, default: str = "FFFFFF") -> RGBColor:
value = (value or default).strip().lstrip("#")
if len(value) != 6:
value = default
return RGBColor(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
def rel_or_abs(base: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else base / path
def px_box_to_inches(box: list[float], slide_size_px: list[float], slide_w: float, slide_h: float) -> tuple[float, float, float, float]:
if len(box) != 4:
raise ValueError(f"bbox must contain 4 numbers: {box}")
sx = slide_w / float(slide_size_px[0])
sy = slide_h / float(slide_size_px[1])
return box[0] * sx, box[1] * sy, box[2] * sx, box[3] * sy
def as_emu(value_in: float) -> int:
return int(round(value_in * EMU_PER_INCH))
def text_units(value: str) -> float:
units = 0.0
for char in value:
code = ord(char)
if char.isspace():
units += 0.35
elif code < 128:
units += 0.55
elif "\u3000" <= char <= "\u303f" or "\uff00" <= char <= "\uffef":
units += 0.55
else:
units += 1.0
return units
def wrap_line_by_units(value: str, max_units: float) -> list[str]:
if not value:
return [""]
if max_units <= 1:
return [value]
lines: list[str] = []
current = ""
current_units = 0.0
for char in value:
unit = text_units(char)
if current and current_units + unit > max_units:
lines.append(current)
current = char
current_units = unit
else:
current += char
current_units += unit
if current:
lines.append(current)
return lines or [value]
def infer_text_role(item: dict[str, Any]) -> str:
value = str(item.get("text_role") or item.get("role") or "").lower()
if value:
return value
item_id = str(item.get("id") or "").lower()
font_size = float(item.get("font_size", 14))
if any(token in item_id for token in ("tag", "pill", "chip", "badge")):
return "micro_label"
if "title" in item_id or font_size >= 24:
return "title"
if "head" in item_id or "_h" in item_id or font_size >= 14:
return "header"
if "sub" in item_id:
return "subtitle"
if "body" in item_id or "_b" in item_id or font_size <= 9:
return "body"
return "label"
def role_min_font(role: str, item: dict[str, Any], args: argparse.Namespace) -> float:
if item.get("min_font_size") is not None:
return float(item["min_font_size"])
if role == "micro_label":
return 4.8
if role == "header":
return 7.0
if role in {"title", "subtitle"}:
return args.min_title_font
return args.min_body_font
def line_step_pt(font_size: float, item: dict[str, Any]) -> float:
if item.get("line_spacing_pt") is not None:
return float(item["line_spacing_pt"])
if item.get("line_spacing") is not None:
raw = float(item["line_spacing"])
return raw if raw > 3 else font_size * raw
return font_size * float(item.get("line_height", 1.0))
def width_factor_for_role(role: str) -> float:
if role == "title":
return 1.32
if role in {"subtitle", "header"}:
return 1.16
if role == "micro_label":
return 1.10
return TEXT_WIDTH_FACTOR
def text_margins_in(item: dict[str, Any], box: tuple[float, float, float, float] | None = None) -> tuple[float, float, float, float]:
if box:
_, _, w, h = box
default_lr = 0.005 if w < 0.75 else 0.015 if w < 1.25 else 0.02
default_tb = 0.0 if h < 0.13 else 0.004 if h < 0.2 else 0.01
else:
default_lr = 0.02
default_tb = 0.01
return (
float(item.get("margin_left", default_lr)),
float(item.get("margin_right", default_lr)),
float(item.get("margin_top", default_tb)),
float(item.get("margin_bottom", default_tb)),
)
def estimate_text_layout(
item: dict[str, Any],
box: tuple[float, float, float, float],
font_size: float,
) -> dict[str, Any]:
x, y, w, h = box
ml, mr, mt, mb = text_margins_in(item, box)
inner_w_pt = max(1.0, (w - ml - mr) * 72.0)
inner_h_pt = max(1.0, (h - mt - mb) * 72.0)
role = infer_text_role(item)
width_factor = width_factor_for_role(role)
max_units = max(1.0, inner_w_pt / max(1.0, font_size * width_factor))
word_wrap = bool(item.get("word_wrap", role not in NO_WRAP_DEFAULT_ROLES))
visual_lines: list[str] = []
for raw_line in str(item.get("text", "")).split("\n"):
if word_wrap:
visual_lines.extend(wrap_line_by_units(raw_line, max_units))
else:
visual_lines.append(raw_line)
step = line_step_pt(font_size, item)
estimated_h_pt = max(step, len(visual_lines) * step)
longest_units = max((text_units(line) for line in visual_lines), default=0.0)
estimated_w_pt = longest_units * font_size * width_factor
overflow_h = estimated_h_pt / inner_h_pt
overflow_w = estimated_w_pt / inner_w_pt
vertical = str(item.get("vertical_anchor", "middle")).lower()
content_h_in = estimated_h_pt / 72.0
inner_x = x + ml
inner_y = y + mt
inner_w = max(0.001, w - ml - mr)
inner_h = max(0.001, h - mt - mb)
if vertical == "bottom":
content_y = y + h - mb - content_h_in
elif vertical == "top":
content_y = inner_y
else:
content_y = y + (h - content_h_in) / 2.0
return {
"visual_line_count": len(visual_lines),
"estimated_height_pt": round(estimated_h_pt, 3),
"estimated_width_pt": round(estimated_w_pt, 3),
"inner_width_pt": round(inner_w_pt, 3),
"inner_height_pt": round(inner_h_pt, 3),
"overflow_height_ratio": round(overflow_h, 4),
"overflow_width_ratio": round(overflow_w, 4),
"max_units_per_line": round(max_units, 3),
"line_spacing_pt": round(step, 3),
"inner_bbox_in": [round(inner_x, 4), round(inner_y, 4), round(inner_w, 4), round(inner_h, 4)],
"estimated_text_bbox_in": [
round(inner_x, 4),
round(content_y, 4),
round(min(inner_w, max(inner_w, estimated_w_pt / 72.0)), 4),
round(content_h_in, 4),
],
"wrapped_lines_preview": visual_lines[:8],
}
def choose_effective_font(
item: dict[str, Any],
box: tuple[float, float, float, float],
args: argparse.Namespace,
) -> tuple[float, dict[str, Any], str]:
start = float(item.get("font_size", 14))
role = infer_text_role(item)
min_size = role_min_font(role, item, args)
fit_mode = str(item.get("fit_mode", "shrink")).lower()
if fit_mode not in {"shrink", "none"}:
fit_mode = "shrink"
size = start
layout = estimate_text_layout(item, box, size)
if fit_mode == "shrink":
while size > min_size and (
layout["overflow_height_ratio"] > args.max_overflow_ratio
or layout["overflow_width_ratio"] > args.max_overflow_ratio
):
size = max(min_size, size - 0.5)
layout = estimate_text_layout(item, box, size)
return round(size, 2), layout, role
def add_text(
slide,
item: dict[str, Any],
box: tuple[float, float, float, float],
default_font: str,
args: argparse.Namespace,
typography_profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
x, y, w, h = box
shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
initial_role = infer_text_role(item)
styled_item = typography_defaults(item, initial_role, typography_profile)
styled_item["text_role"] = initial_role
effective_font, layout, role = choose_effective_font(styled_item, box, args)
c_nv_pr = shape._element.nvSpPr.cNvPr
c_nv_pr.set("name", f"{item.get('id') or shape.name} [pf-role={role}]")
vertical = str(styled_item.get("vertical_anchor", "middle")).lower()
shape.vertical_anchor = {
"top": MSO_ANCHOR.TOP,
"middle": MSO_ANCHOR.MIDDLE,
"bottom": MSO_ANCHOR.BOTTOM,
}.get(vertical, MSO_ANCHOR.MIDDLE)
if styled_item.get("rotation") is not None:
shape.rotation = float(styled_item.get("rotation"))
tf = shape.text_frame
tf.clear()
tf.word_wrap = bool(styled_item.get("word_wrap", role not in NO_WRAP_DEFAULT_ROLES))
ml, mr, mt, mb = text_margins_in(styled_item, box)
tf.margin_left = Inches(ml)
tf.margin_right = Inches(mr)
tf.margin_top = Inches(mt)
tf.margin_bottom = Inches(mb)
lines = str(styled_item.get("text", "")).split("\n")
p = tf.paragraphs[0]
p.text = lines[0] if lines else ""
align = str(styled_item.get("align", "left")).lower()
p.alignment = {
"left": PP_ALIGN.LEFT,
"center": PP_ALIGN.CENTER,
"right": PP_ALIGN.RIGHT,
}.get(align, PP_ALIGN.LEFT)
for line in lines[1:]:
next_p = tf.add_paragraph()
next_p.text = line
next_p.alignment = p.alignment
for para in tf.paragraphs:
para.space_before = Pt(effective_font * float(styled_item.get("space_before_lines", 0)))
para.space_after = Pt(effective_font * float(styled_item.get("space_after_lines", 0)))
if styled_item.get("line_spacing_pt") is not None:
para.line_spacing = Pt(float(styled_item.get("line_spacing_pt")))
elif styled_item.get("line_spacing") is not None:
raw_spacing = float(styled_item.get("line_spacing"))
para.line_spacing = Pt(raw_spacing) if raw_spacing > 3 else raw_spacing
for run in para.runs:
east_asia_font = styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font
latin_font = styled_item.get("font_latin") or east_asia_font
complex_script_font = styled_item.get("font_complex_script") or latin_font
run.font.name = latin_font
set_run_typefaces(run, east_asia_font, latin_font, complex_script_font)
run.font.size = Pt(effective_font)
run.font.bold = bool(styled_item.get("bold", False))
run.font.italic = bool(styled_item.get("italic", False))
run.font.color.rgb = hex_to_rgb(styled_item.get("color"), "003B7A")
return {
"id": item.get("id"),
"slide": int(item.get("slide", 1)),
"z_index": float(item.get("z_index", item.get("z", 0))),
"text": str(styled_item.get("text", "")),
"text_role": role,
"typography_token": styled_item.get("typography_token"),
"bbox_in": [round(v, 4) for v in box],
"font_size": float(styled_item.get("font_size", 14)),
"effective_font_size": effective_font,
"fit_mode": str(styled_item.get("fit_mode", "shrink")).lower(),
"min_font_size": role_min_font(role, styled_item, args),
"align": str(styled_item.get("align", "left")).lower(),
"font_east_asia": styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font,
"font_latin": styled_item.get("font_latin") or styled_item.get("font_east_asia") or styled_item.get("font_face") or default_font,
"vertical_anchor": vertical,
"margins_in": [round(v, 4) for v in (ml, mr, mt, mb)],
"parent_id": item.get("parent_id"),
"text_overlay_allowed": bool(item.get("text_overlay_allowed", False)),
**layout,
}
def add_native_shape(slide, item: dict[str, Any], box: tuple[float, float, float, float]) -> None:
x, y, w, h = box
shape_name = str(item.get("shape", "round_rect")).lower()
shape_type = {
"rect": MSO_SHAPE.RECTANGLE,
"rectangle": MSO_SHAPE.RECTANGLE,
"round_rect": MSO_SHAPE.ROUNDED_RECTANGLE,
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
"ellipse": MSO_SHAPE.OVAL,
"oval": MSO_SHAPE.OVAL,
}.get(shape_name, MSO_SHAPE.ROUNDED_RECTANGLE)
shape = slide.shapes.add_shape(shape_type, Inches(x), Inches(y), Inches(w), Inches(h))
if item.get("rotation") is not None:
shape.rotation = float(item.get("rotation"))
fill = str(item.get("fill", "FFFFFF"))
if fill.lower() in {"none", "transparent"}:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = hex_to_rgb(fill)
transparency = float(item.get("fill_transparency", 0))
if transparency:
shape.fill.transparency = max(0, min(100, transparency)) / 100
line = item.get("line", "C7DAF6")
if str(line).lower() in {"none", "transparent"}:
shape.line.fill.background()
else:
shape.line.color.rgb = hex_to_rgb(str(line), "C7DAF6")
shape.line.width = Pt(float(item.get("line_width", 1)))
if item.get("shadow"):
try:
shadow = shape.shadow
shadow.inherit = False
shadow.visible = True
shadow.distance = Pt(float(item.get("shadow", {}).get("distance", 2)))
shadow.blur_radius = Pt(float(item.get("shadow", {}).get("blur", 2)))
shadow.transparency = float(item.get("shadow", {}).get("transparency", 55)) / 100
except Exception:
pass
def add_native_line(slide, item: dict[str, Any], box: tuple[float, float, float, float]) -> None:
x, y, w, h = box
connector = slide.shapes.add_connector(
MSO_CONNECTOR.STRAIGHT,
Inches(x),
Inches(y),
Inches(x + w),
Inches(y + h),
)
connector.line.color.rgb = hex_to_rgb(str(item.get("line", item.get("color", "5A93EA"))), "5A93EA")
connector.line.width = Pt(float(item.get("line_width", 1.5)))
def fit_contain(img_path: Path, box: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
x, y, w, h = box
with Image.open(img_path) as img:
src_ratio = img.width / img.height
slot_ratio = w / h
if src_ratio > slot_ratio:
fw = w
fh = w / src_ratio
fx = x
fy = y + (h - fh) / 2
else:
fh = h
fw = h * src_ratio
fx = x + (w - fw) / 2
fy = y
return fx, fy, fw, fh
def add_asset(slide, item: dict[str, Any], asset: dict[str, Any], box: tuple[float, float, float, float], base_dir: Path) -> dict[str, Any]:
source = asset.get("source_type")
if source not in VALID_ASSET_SOURCES:
raise ValueError(f"{item.get('id')} has invalid source_type: {source}")
if asset.get("semantic_unit_count") != 1:
raise ValueError(f"{item.get('id')} must have semantic_unit_count=1")
img_path = rel_or_abs(base_dir, str(asset["asset_path"]))
if not img_path.exists():
raise FileNotFoundError(f"asset not found for {item.get('id')}: {img_path}")
fx, fy, fw, fh = fit_contain(img_path, box)
pic = slide.shapes.add_picture(str(img_path), Inches(fx), Inches(fy), width=Inches(fw), height=Inches(fh))
if item.get("rotation") is not None:
pic.rotation = float(item.get("rotation"))
return {
"semantic_unit_id": item.get("id"),
"asset_path": str(img_path),
"slot_in": [round(v, 4) for v in box],
"fitted_in": [round(v, 4) for v in (fx, fy, fw, fh)],
}
def get_items(inventory: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(inventory.get("slides"), list):
items = []
for idx, slide in enumerate(inventory["slides"], start=1):
for item in slide.get("items", []):
item = dict(item)
item.setdefault("slide", idx)
items.append(item)
return sorted(items, key=lambda item: (int(item.get("slide", 1)), float(item.get("z_index", item.get("z", 0)))))
return sorted([dict(item) for item in inventory.get("items", [])], key=lambda item: (int(item.get("slide", 1)), float(item.get("z_index", item.get("z", 0)))))
def intersect_area(a: list[float], b: list[float]) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
iy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
return ix * iy
def area(box: list[float]) -> float:
return max(0.0, box[2]) * max(0.0, box[3])
def qa_layout(
records: list[dict[str, Any]],
text_records: list[dict[str, Any]],
args: argparse.Namespace,
slide_w: float,
slide_h: float,
) -> dict[str, Any]:
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
by_id = {rec["id"]: rec for rec in records if rec.get("id")}
for rec in records:
x, y, w, h = rec["bbox_in"]
if x < -0.02 or y < -0.02 or x + w > slide_w + 0.02 or y + h > slide_h + 0.02:
warnings.append({"type": "bounds", "id": rec.get("id"), "slide": rec.get("slide"), "bbox_in": rec["bbox_in"]})
for text in text_records:
overflow = max(float(text["overflow_height_ratio"]), float(text["overflow_width_ratio"]))
if overflow > args.max_overflow_ratio:
errors.append({
"type": "text_overflow",
"id": text["id"],
"slide": text["slide"],
"overflow_ratio": round(overflow, 4),
"effective_font_size": text["effective_font_size"],
"bbox_in": text["bbox_in"],
})
min_allowed = role_min_font(str(text["text_role"]), {"min_font_size": text.get("min_font_size")}, args)
if text["text_role"] in {"title", "subtitle", "header"} and text["effective_font_size"] < min_allowed:
errors.append({"type": "title_font_too_small", "id": text["id"], "slide": text["slide"], "effective_font_size": text["effective_font_size"]})
elif text["text_role"] == "micro_label":
pass
elif text["text_role"] not in {"title", "subtitle", "header"} and text["effective_font_size"] < args.min_body_font:
warnings.append({"type": "body_font_too_small", "id": text["id"], "slide": text["slide"], "effective_font_size": text["effective_font_size"]})
parent_id = text.get("parent_id")
if parent_id and parent_id in by_id:
parent = by_id[parent_id]["bbox_in"]
tx, ty, tw, th = text["bbox_in"]
px, py, pw, ph = parent
if tx < px - 0.03 or ty < py - 0.03 or tx + tw > px + pw + 0.03 or ty + th > py + ph + 0.03:
warnings.append({"type": "parent_containment", "id": text["id"], "parent_id": parent_id, "slide": text["slide"]})
for idx, a in enumerate(text_records):
if not a.get("text", "").strip():
continue
for b in text_records[idx + 1:]:
if a["slide"] != b["slide"] or not b.get("text", "").strip():
continue
if a.get("parent_id") and a.get("parent_id") == b.get("parent_id"):
continue
ia = intersect_area(a["estimated_text_bbox_in"], b["estimated_text_bbox_in"])
min_area = max(0.0001, min(area(a["estimated_text_bbox_in"]), area(b["estimated_text_bbox_in"])))
overlap = ia / min_area
if overlap >= args.collision_threshold:
warnings.append({
"type": "text_collision",
"slide": a["slide"],
"a": a["id"],
"b": b["id"],
"overlap_share": round(overlap, 4),
})
for text in text_records:
for rec in records:
if rec["slide"] != text["slide"] or rec["class"] not in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
continue
role = str(rec.get("asset_role") or rec.get("role") or "").lower()
if text.get("text_overlay_allowed") or rec.get("text_overlay_allowed"):
continue
if role in {"decor", "decoration", "underlay", "background", "icon", "badge"}:
continue
ia = intersect_area(text["estimated_text_bbox_in"], rec["bbox_in"])
if ia / max(0.0001, area(text["estimated_text_bbox_in"])) >= 0.12:
errors.append({
"type": "text_on_image",
"slide": text["slide"],
"text_id": text["id"],
"image_id": rec["id"],
"overlap_share": round(ia / max(0.0001, area(text["estimated_text_bbox_in"])), 4),
})
return {
"status": "FAIL" if errors else "PASS_WITH_WARNINGS" if warnings else "PASS",
"error_count": len(errors),
"warning_count": len(warnings),
"errors": errors,
"warnings": warnings,
}
def main() -> None:
args = parse_args()
inventory_path = Path(args.inventory)
manifest_path = Path(args.manifest)
base_dir = Path(args.base_dir) if args.base_dir else inventory_path.parent
inventory = read_json(inventory_path)
manifest = read_json(manifest_path)
layout_rules = read_json(Path(args.layout_rules)) if args.layout_rules else {}
manifest_by_id = {item["semantic_unit_id"]: item for item in manifest}
slide_size_px = inventory.get("slide_size_px") or inventory.get("canvas_px") or [1920, 1080]
typography_profile_id, typography_profile = resolve_typography_profile(inventory, layout_rules)
font_override = (
layout_rules.get("font_policy", {}).get("default_font_face")
or inventory.get("font_face")
)
if typography_profile and font_override:
typography_profile = dict(typography_profile)
typography_profile["fonts"] = dict(typography_profile.get("fonts", {}))
typography_profile["fonts"]["heading_east_asia"] = font_override
typography_profile["fonts"]["body_east_asia"] = font_override
profile_fonts = (typography_profile or {}).get("fonts", {})
default_font = font_override or profile_fonts.get("body_east_asia") or "Arial Unicode MS"
heading_font = font_override or profile_fonts.get("heading_east_asia") or default_font
prs = Presentation()
prs.slide_width = Inches(args.slide_width)
prs.slide_height = Inches(args.slide_height)
blank = prs.slide_layouts[6]
slides = {}
report = {
"text_objects": 0,
"native_layout_objects": 0,
"asset_objects": 0,
"text_placements": [],
"native_layout_placements": [],
"asset_placements": [],
}
records: list[dict[str, Any]] = []
for item in get_items(inventory):
slide_no = int(item.get("slide", 1))
while len(slides) < slide_no:
slides[len(slides) + 1] = prs.slides.add_slide(blank)
slide = slides[slide_no]
cls = item.get("class") or item.get("type")
bbox = item.get("bbox_px") or item.get("bbox")
if not bbox:
raise ValueError(f"item {item.get('id')} missing bbox_px")
box = px_box_to_inches(bbox, slide_size_px, args.slide_width, args.slide_height)
if cls == "text":
text_report = add_text(slide, item, box, default_font, args, typography_profile)
report["text_placements"].append(text_report)
report["text_objects"] += 1
elif cls == "layout_native":
add_native_shape(slide, item, box)
report["native_layout_placements"].append({
"id": item.get("id"),
"slide": slide_no,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"shape": item.get("shape", "round_rect"),
})
report["native_layout_objects"] += 1
elif cls in {"line_native", "connector_native"}:
add_native_line(slide, item, box)
report["native_layout_placements"].append({
"id": item.get("id"),
"slide": slide_no,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"shape": "line",
})
report["native_layout_objects"] += 1
elif cls in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
asset = manifest_by_id.get(item.get("id"))
if not asset:
raise ValueError(f"no manifest entry for asset item: {item.get('id')}")
report["asset_placements"].append(add_asset(slide, item, asset, box, base_dir))
report["asset_objects"] += 1
elif cls == "unresolved":
raise ValueError(f"unresolved item cannot be built: {item.get('id')}")
else:
raise ValueError(f"unsupported item class for {item.get('id')}: {cls}")
records.append({
"id": item.get("id"),
"slide": slide_no,
"class": cls,
"z_index": float(item.get("z_index", item.get("z", 0))),
"bbox_in": [round(v, 4) for v in box],
"parent_id": item.get("parent_id"),
"role": item.get("role"),
"asset_role": item.get("asset_role"),
"text_overlay_allowed": bool(item.get("text_overlay_allowed", False)),
})
out_pptx = Path(args.out_pptx)
out_pptx.parent.mkdir(parents=True, exist_ok=True)
prs.save(out_pptx)
patch_theme_east_asian_fonts(out_pptx, heading_font, default_font)
report["pptx"] = str(out_pptx)
report["slide_count"] = len(slides)
report["layout_qa"] = qa_layout(records, report["text_placements"], args, args.slide_width, args.slide_height)
report["layout_policy"] = {
"default_font_face": default_font,
"heading_east_asia_font": heading_font,
"typography_profile": typography_profile_id,
"typography_tokens": (typography_profile or {}).get("tokens"),
"min_body_font": args.min_body_font,
"min_title_font": args.min_title_font,
"max_overflow_ratio": args.max_overflow_ratio,
"collision_threshold": args.collision_threshold,
"east_asian_font_declared": True,
}
if args.report:
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if args.fail_on_layout_qa and report["layout_qa"]["error_count"]:
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Clean transparent semantic asset PNG files after grid cutting."""
from __future__ import annotations
import argparse
import json
from collections import deque
from pathlib import Path
from PIL import Image, ImageFilter
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", action="append", default=[], help="PNG asset file. Repeat as needed.")
parser.add_argument("--input-dir", help="Directory of PNG assets to clean.")
parser.add_argument("--out-dir", help="Output directory. Required unless --in-place is set.")
parser.add_argument("--in-place", action="store_true", help="Overwrite input files.")
parser.add_argument("--trim-pad", type=int, default=4, help="Transparent trim padding in pixels.")
parser.add_argument("--min-component-area", type=int, default=80, help="Remove alpha components smaller than this area.")
parser.add_argument("--alpha-threshold", type=int, default=10, help="Pixels below this alpha become fully transparent.")
parser.add_argument("--soften-alpha", type=float, default=0.0, help="Optional alpha blur radius.")
parser.add_argument("--report", help="Optional JSON report path.")
return parser.parse_args()
def trim_alpha(img: Image.Image, pad: int) -> Image.Image:
bbox = img.getchannel("A").getbbox()
if not bbox:
return img
left, top, right, bottom = bbox
return img.crop((
max(0, left - pad),
max(0, top - pad),
min(img.width, right + pad),
min(img.height, bottom + pad),
))
def threshold_alpha(img: Image.Image, threshold: int) -> Image.Image:
img = img.convert("RGBA")
pix = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = pix[x, y]
if a <= threshold:
pix[x, y] = (255, 255, 255, 0)
return img
def remove_small_components(img: Image.Image, min_area: int) -> Image.Image:
if min_area <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A")
w, h = alpha.size
a = alpha.load()
visited = bytearray(w * h)
keep = bytearray(w * h)
for yy in range(h):
for xx in range(w):
idx = yy * w + xx
if visited[idx] or a[xx, yy] < 12:
continue
queue = deque([(xx, yy)])
visited[idx] = 1
comp = []
while queue:
x, y = queue.popleft()
comp.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if nx < 0 or ny < 0 or nx >= w or ny >= h:
continue
ni = ny * w + nx
if visited[ni] or a[nx, ny] < 12:
continue
visited[ni] = 1
queue.append((nx, ny))
if len(comp) >= min_area:
for x, y in comp:
keep[y * w + x] = 1
pix = img.load()
for yy in range(h):
for xx in range(w):
if a[xx, yy] >= 12 and not keep[yy * w + xx]:
pix[xx, yy] = (255, 255, 255, 0)
return img
def soften_alpha(img: Image.Image, radius: float) -> Image.Image:
if radius <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A").filter(ImageFilter.GaussianBlur(radius))
img.putalpha(alpha)
return img
def input_files(args: argparse.Namespace) -> list[Path]:
files = [Path(p) for p in args.input]
if args.input_dir:
files.extend(sorted(Path(args.input_dir).glob("*.png")))
return files
def main() -> None:
args = parse_args()
files = input_files(args)
if not files:
raise SystemExit("no input PNG files")
if not args.in_place and not args.out_dir:
raise SystemExit("--out-dir is required unless --in-place is set")
out_dir = Path(args.out_dir) if args.out_dir else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
rows = []
for src in files:
if not src.exists():
raise SystemExit(f"missing input: {src}")
img = Image.open(src).convert("RGBA")
before = img.size
img = threshold_alpha(img, args.alpha_threshold)
img = remove_small_components(img, args.min_component_area)
img = trim_alpha(img, args.trim_pad)
img = soften_alpha(img, args.soften_alpha)
dst = src if args.in_place else out_dir / src.name
img.save(dst)
rows.append({"input": str(src), "output": str(dst), "before_size": list(before), "after_size": list(img.size)})
if args.report:
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
Path(args.report).write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"cleaned": len(rows)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Create render comparison metrics, diff heatmaps, and a contact sheet."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageOps, ImageStat
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reference", action="append", required=True, help="Reference slide image. Repeat per slide.")
parser.add_argument("--render", action="append", required=True, help="Rendered output slide image. Repeat per slide.")
parser.add_argument("--out-dir", required=True, help="Comparison output directory.")
parser.add_argument("--thumb-width", type=int, default=520, help="Contact sheet column width.")
parser.add_argument("--max-changed-pixel-pct", type=float, default=0.35, help="Warn when changed pixel share is above this value.")
parser.add_argument("--max-mad", type=float, default=35.0, help="Warn when mean absolute difference is above this value.")
return parser.parse_args()
def fit_width(img: Image.Image, width: int) -> Image.Image:
height = round(img.height * width / img.width)
return img.resize((width, height), Image.LANCZOS)
def main() -> None:
args = parse_args()
refs = [Path(p) for p in args.reference]
renders = [Path(p) for p in args.render]
if len(refs) != len(renders):
raise SystemExit("--reference and --render counts must match")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
metrics = []
rows = []
for idx, (ref_path, render_path) in enumerate(zip(refs, renders), start=1):
ref = Image.open(ref_path).convert("RGB")
render = Image.open(render_path).convert("RGB")
render_for_diff = render.resize(ref.size, Image.LANCZOS)
diff = ImageChops.difference(ref, render_for_diff)
stat = ImageStat.Stat(diff)
mad = sum(stat.mean) / 3.0
changed = sum(1 for px in diff.getdata() if max(px) > 18) / (ref.width * ref.height)
gray = ImageEnhance.Contrast(ImageOps.grayscale(diff)).enhance(2.2)
heat = ImageOps.colorize(gray, black=(255, 255, 255), white=(255, 74, 74))
overlay = Image.blend(ref, heat, 0.45)
heat_path = out_dir / f"slide_{idx:02d}_diff_heatmap.png"
overlay.save(heat_path)
metrics.append({
"slide": idx,
"reference": str(ref_path),
"render": str(render_path),
"reference_size": list(ref.size),
"render_size": list(render.size),
"mean_absolute_difference": round(mad, 3),
"changed_pixel_pct_threshold_18": round(changed, 4),
"status": "PASS" if mad <= args.max_mad and changed <= args.max_changed_pixel_pct else "REVIEW",
"repair_hints": [
hint for hint, enabled in [
("large visual drift: inspect missing assets, z-order, panel sizes, and shape fills", mad > args.max_mad),
("many changed pixels: compare title/card positions and large decorative regions first", changed > args.max_changed_pixel_pct),
("if text is visibly noisy, rerun build with text-fit report and collision QA enabled", mad > args.max_mad or changed > args.max_changed_pixel_pct),
] if enabled
],
"heatmap": str(heat_path),
})
thumbs = [fit_width(ref, args.thumb_width), fit_width(render, args.thumb_width), fit_width(overlay, args.thumb_width)]
row_h = max(t.height for t in thumbs) + 34
row = Image.new("RGB", (args.thumb_width * 3 + 28, row_h), "white")
draw = ImageDraw.Draw(row)
labels = [f"Slide {idx} reference", f"Slide {idx} render", f"Slide {idx} diff"]
for col, thumb in enumerate(thumbs):
x = col * (args.thumb_width + 14)
row.paste(thumb, (x, 30))
draw.rectangle((x, 2, x + 220, 25), fill=(255, 255, 255))
draw.text((x + 8, 6), labels[col], fill=(2, 64, 148))
rows.append(row)
sheet = Image.new("RGB", (max(r.width for r in rows), sum(r.height for r in rows) + 20 * (len(rows) - 1)), "white")
y = 0
for row in rows:
sheet.paste(row, (0, y))
y += row.height + 20
sheet_path = out_dir / "contact_sheet.png"
sheet.save(sheet_path)
(out_dir / "comparison_metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8")
status = "PASS" if all(item["status"] == "PASS" for item in metrics) else "REVIEW"
print(json.dumps({"contact_sheet": str(sheet_path), "slides": len(metrics), "status": status}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Compile per-page scene files from slides_plan.md and prompts.json."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
HEADING = re.compile(r"^##\s+(\d+)\.\s+\[([^]]+)]\s+(.+?)\s*$")
ROOT = Path(__file__).resolve().parents[1]
TYPE_MIGRATION = {"layout_native": "shape", "line_native": "line", "connector_native": "connector", "semantic_visual": "image", "imagegen_asset": "image", "provided_asset": "image"}
def upgrade_element(element: dict, index: int) -> dict:
upgraded = dict(element)
upgraded["type"] = TYPE_MIGRATION.get(str(upgraded.get("type")), upgraded.get("type", "unresolved"))
upgraded.setdefault("rotation", 0)
upgraded.setdefault("z_index", index)
upgraded.setdefault("editable", True)
upgraded.setdefault("locked", False)
upgraded.setdefault("style", {})
upgraded.setdefault("capabilities", ["text", "geometry", "style", "rotation", "z-order"] if upgraded["type"] == "text" else ["geometry", "style", "rotation", "z-order"])
return upgraded
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def parse_plan(path: Path) -> dict[int, dict[str, str]]:
result: dict[int, dict[str, str]] = {}
current: dict[str, str] | None = None
for line in path.read_text(encoding="utf-8").splitlines():
match = HEADING.match(line)
if match:
number = int(match.group(1))
current = {"page_type": match.group(2), "title": match.group(3), "layout_id": ""}
result[number] = current
elif current and (line.startswith("布局:") or line.startswith("布局:")):
current["layout_id"] = line.split(":" if ":" in line else "", 1)[1].strip()
return result
def upgrade_metadata(path: Path, prompts: dict) -> dict:
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["schema_version"] = max(int(metadata.get("schema_version", 1)), 4)
metadata.setdefault("current_revision", None)
metadata.setdefault("style_id", prompts.get("style_id"))
style_id = metadata.get("style_id")
if style_id and (not metadata.get("style_variant") or not metadata.get("typography_profile") or not metadata.get("table_profile")):
catalog = json.loads((ROOT / "styles" / "catalog.json").read_text(encoding="utf-8"))
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
if not metadata.get("style_variant"):
metadata["style_variant"] = prompts.get("style_variant") or style.get("default_variant")
if not metadata.get("typography_profile"):
metadata["typography_profile"] = style.get("typography_profile")
if not metadata.get("table_profile"):
metadata["table_profile"] = style.get("table_profile")
metadata.setdefault("variants", {
"image": {"status": "not_built", "artifact": None, "slides": {}},
"editable": {"status": "not_built", "artifact": None, "slides": {}},
})
metadata.setdefault("environment", {"preflight_report": None, "status": "pending"})
metadata.setdefault("gui_validation_mode", "final-only")
metadata.setdefault("final_powerpoint_validation", "pending")
path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return metadata
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--force", action="store_true", help="Replace existing scene content instead of preserving elements.")
parser.add_argument("--emit-editable-inventory", action="store_true", help="Derive a full editable inventory under cache/editable/.")
args = parser.parse_args()
session = Path(args.session).resolve()
prompts_path = session / "prompts.json"
plan_path = session / "slides_plan.md"
metadata_path = session / "metadata.json"
for path in (prompts_path, plan_path, metadata_path):
if not path.is_file():
raise SystemExit(f"missing required session file: {path}")
prompts = json.loads(prompts_path.read_text(encoding="utf-8"))
metadata = upgrade_metadata(metadata_path, prompts)
plan = parse_plan(plan_path)
prompt_slides = {int(row["slide_number"]): row for row in prompts.get("slides", [])}
numbers = sorted(set(plan) | set(prompt_slides))
if not numbers:
raise SystemExit("no slides found in slides_plan.md or prompts.json")
scenes_dir = session / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
written = []
compiled_scenes = []
for number in numbers:
info = plan.get(number, {})
prompt = prompt_slides.get(number, {})
scene_path = scenes_dir / f"slide-{number:03d}.scene.json"
old = json.loads(scene_path.read_text(encoding="utf-8")) if scene_path.is_file() and not args.force else {}
generation = {
"prompt": prompt.get("prompt", old.get("generation", {}).get("prompt", "")),
"reference_images": prompt.get("reference_images", old.get("generation", {}).get("reference_images", [])),
"asset_reference_images": prompt.get("asset_reference_images", old.get("generation", {}).get("asset_reference_images", [])),
}
prompt_hash = canonical_hash(generation)
old_canvas = old.get("canvas", {"width": 1920, "height": 1080})
canvas = {"width": int(old_canvas.get("width", 1920)), "height": int(old_canvas.get("height", 1080)), "unit": "px"}
scene = {
"schema_version": 2,
"slide_id": f"slide-{number:03d}",
"slide_number": number,
"revision": int(old.get("revision", 1)),
"page_type": prompt.get("page_type") or info.get("page_type") or old.get("page_type", "other"),
"style_id": prompt.get("style_id") or prompts.get("style_id") or metadata.get("style_id"),
"style_variant": prompt.get("style_variant") or prompts.get("style_variant") or metadata.get("style_variant"),
"layout_id": prompt.get("layout_id") or info.get("layout_id") or old.get("layout_id") or None,
"typography_profile": metadata.get("typography_profile"),
"table_profile": metadata.get("table_profile"),
"visual_asset_policy": metadata.get("visual_asset_policy"),
"canvas": canvas,
"content": {
"title": info.get("title") or old.get("content", {}).get("title", ""),
"message": old.get("content", {}).get("message", ""),
"facts": old.get("content", {}).get("facts", []),
},
"generation": generation,
"elements": [upgrade_element(element, index) for index, element in enumerate(old.get("elements", []))],
"dependencies": {"scene_hash": "", "prompt_hash": prompt_hash, "asset_hashes": old.get("dependencies", {}).get("asset_hashes", [])},
}
hashable = dict(scene)
hashable.pop("revision", None)
hashable["dependencies"] = {"prompt_hash": prompt_hash, "asset_hashes": scene["dependencies"]["asset_hashes"]}
next_hash = canonical_hash(hashable)
previous_hash = old.get("dependencies", {}).get("scene_hash")
if old and previous_hash != next_hash:
scene["revision"] = int(old.get("revision", 1)) + 1
scene["dependencies"]["scene_hash"] = next_hash
scene_path.write_text(json.dumps(scene, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
written.append(str(scene_path))
compiled_scenes.append(scene)
inventory_path = None
if args.emit_editable_inventory:
items = []
for scene in compiled_scenes:
for element in scene.get("elements", []):
item = dict(element)
element_type = item.pop("type", "unresolved")
item["class"] = "unresolved" if element_type == "semantic_visual" else element_type
item["bbox_px"] = item.pop("bbox")
item["slide"] = scene["slide_number"]
items.append(item)
inventory = {
"schema_version": 1,
"source": "scene-derived",
"slide_size_px": [compiled_scenes[0]["canvas"]["width"], compiled_scenes[0]["canvas"]["height"]],
"font_face": metadata.get("environment", {}).get("selected_font"),
"style_id": metadata.get("style_id"),
"typography_profile": metadata.get("typography_profile"),
"table_profile": metadata.get("table_profile"),
"visual_asset_policy": metadata.get("visual_asset_policy"),
"items": items,
}
inventory_path = session / "cache" / "editable" / "visual_inventory.json"
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"session": str(session), "scene_count": len(written), "scenes": written, "editable_inventory": str(inventory_path) if inventory_path else None}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""Compile slides.md into a native editable spec, scenes, and an optional editor canvas."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
from style_presets import resolve_style
try:
import yaml as _yaml
except ModuleNotFoundError: # Keep the editor bridge usable with the system Python.
_yaml = None
ROOT = Path(__file__).resolve().parents[1]
CANVAS = {"width": 1920, "height": 1080, "unit": "px"}
LAYOUT_TO_ARCHETYPE = {
"cover": "cover", "cover-left": "cover",
"content": "content-structured", "cards": "content-structured", "columns": "content-structured",
"timeline": "process-flow", "process": "process-flow",
"comparison": "comparison-two-zone",
"data": "data-callouts", "metrics": "data-callouts",
"table": "table", "architecture": "architecture",
"closing": "closing-action", "action": "closing-action",
}
ARCHETYPE_TO_LAYOUT = {
"cover": "cover", "content-structured": "content", "process-flow": "timeline",
"comparison-two-zone": "comparison", "data-callouts": "metrics", "table": "table",
"architecture": "architecture", "closing-action": "closing",
}
DIRECTIVE = re.compile(r'^::(?P<name>[\w-]+)\{(?P<attrs>.*)}\s*$')
ATTR = re.compile(r'([\w-]+)\s*=\s*"([^"]*)"')
def yaml_load(text: str) -> dict[str, Any]:
if _yaml is not None:
value = _yaml.safe_load(text) or {}
if not isinstance(value, dict):
raise ValueError("slides.md frontmatter must be a mapping")
return value
result: dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
raise ValueError(f"unsupported frontmatter line without PyYAML: {raw}")
key, value = line.split(":", 1)
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
result[key.strip()] = value
return result
def yaml_dump(value: dict[str, Any]) -> str:
if _yaml is not None:
return _yaml.safe_dump(value, allow_unicode=True, sort_keys=False).strip()
rows = []
for key, item in value.items():
rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, str) else str(item).lower() if isinstance(item, bool) else str(item)
rows.append(f"{key}: {rendered}")
return "\n".join(rows)
def load_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def save_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end < 0:
raise ValueError("slides.md frontmatter is not closed")
value = yaml_load(text[4:end])
return value, text[end + 5:]
def split_slides(body: str) -> list[str]:
return [part.strip() for part in re.split(r"(?m)^---\s*$", body) if part.strip()]
def directive_attrs(line: str, name: str) -> dict[str, str] | None:
match = DIRECTIVE.match(line.strip())
if not match or match.group("name") != name:
return None
return {key: value for key, value in ATTR.findall(match.group("attrs"))}
def parse_sections(lines: list[str]) -> tuple[list[dict[str, Any]], list[str], list[list[str]]]:
sections: list[dict[str, Any]] = []
bullets: list[str] = []
table_rows: list[list[str]] = []
current: dict[str, Any] | None = None
for raw in lines:
line = raw.strip()
if line.startswith("## "):
current = {"title": line[3:].strip(), "body_lines": [], "points": []}
sections.append(current)
elif re.match(r"^[-*+]\s+", line):
value = re.sub(r"^[-*+]\s+", "", line).strip()
(current["points"] if current else bullets).append(value)
elif line.startswith("|") and line.endswith("|"):
cells = [cell.strip() for cell in line.strip("|").split("|")]
if not all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
table_rows.append(cells)
elif line and not line.startswith("::") and current:
current["body_lines"].append(line)
for section in sections:
section["body"] = "\n".join(section.pop("body_lines")).strip()
return sections, bullets, table_rows
def infer_layout(index: int, count: int, explicit: str | None) -> str:
if explicit:
if explicit not in LAYOUT_TO_ARCHETYPE:
raise ValueError(f"unsupported Markdown layout: {explicit}")
return explicit
if index == 0:
return "cover"
return "closing" if index == count - 1 else "content"
def parse_slide(block: str, index: int, count: int) -> dict[str, Any]:
lines = block.splitlines()
title = next((line[2:].strip() for line in lines if line.startswith("# ")), "")
if not title:
raise ValueError(f"slide {index + 1} is missing a '# ' title")
quote = "\n".join(line[1:].strip() for line in lines if line.startswith(">" )).strip()
layout_value: str | None = None
visual: dict[str, str] | None = None
for line in lines:
attrs = directive_attrs(line, "layout")
if attrs is not None:
layout_value = attrs.get("type")
visual_attrs = directive_attrs(line, "visual")
if visual_attrs is not None:
visual = visual_attrs
layout = infer_layout(index, count, layout_value)
archetype = LAYOUT_TO_ARCHETYPE[layout]
sections, bullets, table_rows = parse_sections(lines)
row: dict[str, Any] = {"slide_number": index + 1, "archetype": archetype, "title": title}
if quote:
row["subtitle" if archetype == "cover" else "key_message"] = quote
if archetype == "content-structured":
cards = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not cards:
cards = [{"title": item, "body": ""} for item in bullets]
if not 2 <= len(cards) <= 6:
raise ValueError(f"slide {index + 1} content layout requires 2-6 '##' sections or bullets")
row["cards"] = cards
elif archetype == "process-flow":
steps = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not 3 <= len(steps) <= 5:
raise ValueError(f"slide {index + 1} timeline layout requires 3-5 '##' steps")
row["steps"] = steps
elif archetype == "comparison-two-zone":
if len(sections) != 2:
raise ValueError(f"slide {index + 1} comparison layout requires exactly two '##' sections")
for key, item in zip(("left", "right"), sections):
points = item["points"] or ([item["body"]] if item["body"] else [])
row[key] = {"title": item["title"], "points": points}
elif archetype == "data-callouts":
metrics = [{"value": item["title"], "label": item["body"] or "\n".join(item["points"])} for item in sections]
if not 2 <= len(metrics) <= 5:
raise ValueError(f"slide {index + 1} metrics layout requires 2-5 '##' sections")
row["metrics"] = metrics
elif archetype == "table":
if len(table_rows) < 2:
raise ValueError(f"slide {index + 1} table layout requires a Markdown table")
row["headers"], row["rows"] = table_rows[0], table_rows[1:]
row["column_types"] = ["text"] * len(row["headers"])
elif archetype == "architecture":
layers = [{"title": item["title"], "body": item["body"] or "\n".join(item["points"])} for item in sections]
if not layers:
raise ValueError(f"slide {index + 1} architecture layout requires '##' layers")
row["layers"] = layers
elif archetype == "closing-action":
actions = bullets or [item["title"] for item in sections]
if actions:
row["actions"] = actions[:5]
if visual and visual.get("asset"):
backend = visual.get("backend", "provided")
row["visual_slot"] = {
"backend": backend,
"source_type": "imagegen_asset" if backend == "image-2" else "provided_asset",
"status": visual.get("status", "generated"),
"asset_path": visual["asset"],
"prompt_record": visual.get("prompt"),
}
return row
def parse_markdown(text: str, metadata: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
frontmatter, body = split_frontmatter(text)
blocks = split_slides(body)
if not blocks:
raise ValueError("slides.md contains no slides")
metadata = metadata or {}
style_id = str(frontmatter.get("style") or metadata.get("style_id") or "consulting-blue-white")
requested_variant = frontmatter.get("variant") or metadata.get("style_variant")
style, variant = resolve_style(style_id, None if requested_variant in {None, "", "default", "待确认"} else str(requested_variant))
policy = str(frontmatter.get("visualAssetPolicy") or metadata.get("visual_asset_policy") or "native-only")
slides = [parse_slide(block, index, len(blocks)) for index, block in enumerate(blocks)]
spec = {
"schema_version": 1,
"title": str(frontmatter.get("title") or slides[0]["title"]),
"style_id": style["id"],
"style_variant": variant["id"],
"typography_profile": style.get("typography_profile"),
"table_profile": style.get("table_profile"),
"visual_asset_policy": policy,
"slides": slides,
}
return frontmatter, spec
def render_markdown(spec: dict[str, Any], existing_frontmatter: dict[str, Any] | None = None) -> str:
fm = dict(existing_frontmatter or {})
fm.update({
"title": spec.get("title"), "style": spec.get("style_id"),
"variant": spec.get("style_variant"), "ratio": fm.get("ratio", "16:9"),
"fontCN": fm.get("fontCN", "微软雅黑"),
"visualAssetPolicy": spec.get("visual_asset_policy", "native-only"),
})
parts = ["---\n" + yaml_dump(fm) + "\n---"]
for row in spec.get("slides", []):
lines = [f"# {row.get('title', '')}"]
message = row.get("subtitle") or row.get("key_message")
if message:
lines.extend(["", f"> {message}"])
lines.extend(["", f'::layout{{type="{ARCHETYPE_TO_LAYOUT.get(str(row.get("archetype")), "content")}"}}'])
archetype = row.get("archetype")
collection = {
"content-structured": ("cards", "title", "body"),
"process-flow": ("steps", "title", "body"),
"data-callouts": ("metrics", "value", "label"),
"architecture": ("layers", "title", "body"),
}.get(str(archetype))
if collection:
key, title_key, body_key = collection
for item in row.get(key, []):
lines.extend(["", f"## {item.get(title_key, '')}"])
if item.get(body_key):
lines.extend(["", str(item[body_key])])
elif archetype == "comparison-two-zone":
for key in ("left", "right"):
item = row.get(key, {})
lines.extend(["", f"## {item.get('title', '')}"])
lines.extend(["", *[f"- {value}" for value in item.get("points", [])]])
elif archetype == "table":
headers = [str(value) for value in row.get("headers", [])]
lines.extend(["", "| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"])
lines.extend("| " + " | ".join(str(value) for value in values) + " |" for values in row.get("rows", []))
elif archetype == "closing-action":
lines.extend(["", *[f"- {value}" for value in row.get("actions", [])]])
visual = row.get("visual_slot")
if isinstance(visual, dict) and visual.get("asset_path"):
attrs = [f'asset="{visual["asset_path"]}"', f'backend="{visual.get("backend", "provided")}"', f'status="{visual.get("status", "generated")}"']
if visual.get("prompt_record"):
attrs.append(f'prompt="{visual["prompt_record"]}"')
lines.extend(["", "::visual{" + " ".join(attrs) + "}"])
parts.append("\n".join(lines).rstrip())
return "\n\n---\n\n".join(parts) + "\n"
def write_plan(session: Path, spec: dict[str, Any]) -> None:
lines = [
"---", f"title: {spec['title']}", "delivery_type: editable-pptx",
f"style_id: {spec['style_id']}", f"style_variant: {spec['style_variant']}",
f"typography_profile: {spec.get('typography_profile')}", f"table_profile: {spec.get('table_profile')}",
f"visual_asset_policy: {spec.get('visual_asset_policy')}", "---", "", "# 逐页计划", "",
]
for row in spec["slides"]:
lines.extend([
f"## {row['slide_number']}. [{row['archetype']}] {row['title']}", "",
f"布局:{ARCHETYPE_TO_LAYOUT.get(row['archetype'], 'content')}", "",
f"页面目标:{row.get('key_message') or row.get('subtitle') or row['title']}", "",
"事实边界:沿用用户提供内容;禁止补造案例、参数、准确率或收益数据。", "",
])
(session / "slides_plan.md").write_text("\n".join(lines), encoding="utf-8")
def write_prompts(session: Path, spec: dict[str, Any]) -> None:
metadata = load_json(session / "metadata.json")
prompts = {
"schema_version": 1, "session_id": metadata.get("session_id"), "delivery_type": "editable-pptx",
"style_id": spec["style_id"], "style_variant": spec["style_variant"],
"typography_profile": spec.get("typography_profile"), "table_profile": spec.get("table_profile"),
"visual_asset_policy": spec.get("visual_asset_policy"),
"slides": [{
"slide_number": row["slide_number"], "page_type": row["archetype"],
"style_id": row.get("style_id") or spec["style_id"], "style_variant": row.get("style_variant") or spec["style_variant"],
"layout_id": ARCHETYPE_TO_LAYOUT.get(row["archetype"], "content"),
"layout_intent": "由 slides.md 结构化组件确定",
"prompt": "Markdown-first 原生可编辑页面;不生成整页图片。", "reference_images": [],
"asset_reference_images": [], "status": "planned", "output_image": None,
"qa": {"status": "pending", "notes": []},
} for row in spec["slides"]],
}
save_json(session / "prompts.json", prompts)
def write_scenes(session: Path, spec: dict[str, Any]) -> None:
scenes_dir = session / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
expected: set[Path] = set()
for row in spec["slides"]:
number = int(row["slide_number"])
path = scenes_dir / f"slide-{number:03d}.scene.json"
expected.add(path)
old = load_json(path) if path.is_file() else {}
content = {"title": row["title"], "message": row.get("key_message") or row.get("subtitle") or "", "facts": []}
scene = {
"schema_version": 2, "slide_id": f"slide-{number:03d}", "slide_number": number,
"revision": int(old.get("revision", 1)), "page_type": row["archetype"],
"style_id": row.get("style_id") or spec["style_id"], "style_variant": row.get("style_variant") or spec["style_variant"],
"layout_id": ARCHETYPE_TO_LAYOUT.get(row["archetype"]),
"typography_profile": spec.get("typography_profile"), "table_profile": spec.get("table_profile"),
"visual_asset_policy": spec.get("visual_asset_policy"), "canvas": CANVAS, "content": content,
"generation": {"prompt": "", "reference_images": [], "asset_reference_images": []},
"elements": old.get("elements", []),
"dependencies": {"scene_hash": "", "prompt_hash": canonical_hash(""), "asset_hashes": old.get("dependencies", {}).get("asset_hashes", [])},
}
hashable = dict(scene); hashable.pop("revision", None)
hashable["dependencies"] = {"prompt_hash": scene["dependencies"]["prompt_hash"], "asset_hashes": scene["dependencies"]["asset_hashes"]}
next_hash = canonical_hash(hashable)
if old and old.get("dependencies", {}).get("scene_hash") != next_hash:
scene["revision"] += 1
scene["dependencies"]["scene_hash"] = next_hash
save_json(path, scene)
for path in scenes_dir.glob("slide-*.scene.json"):
if path not in expected:
path.unlink()
def run_checked(*args: str) -> dict[str, Any]:
result = subprocess.run([sys.executable, *args], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode:
raise RuntimeError(f"command failed: {' '.join(args)}\n{result.stdout}\n{result.stderr}")
return json.loads(result.stdout)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--source", help="Default: <session>/slides.md")
parser.add_argument("--build-preview", action="store_true", help="Build cache/editable/preview.pptx and sync its native objects to scenes.")
parser.add_argument("--render-canvas", action="store_true", help="Export manifest and write reports/editor-canvas.html; implies --build-preview.")
args = parser.parse_args()
session = Path(args.session).resolve()
source = Path(args.source).resolve() if args.source else session / "slides.md"
if not source.is_file():
raise SystemExit(f"missing Markdown source: {source}")
metadata_path = session / "metadata.json"
metadata = load_json(metadata_path)
frontmatter, spec = parse_markdown(source.read_text(encoding="utf-8"), metadata)
spec_path = session / "analysis" / "native_deck_spec.json"
save_json(spec_path, spec)
write_plan(session, spec)
write_prompts(session, spec)
write_scenes(session, spec)
metadata.update({
"authoring_mode": "markdown-canvas", "content_source": "slides.md", "style_id": spec["style_id"],
"style_variant": spec["style_variant"], "typography_profile": spec.get("typography_profile"),
"table_profile": spec.get("table_profile"), "visual_asset_policy": spec.get("visual_asset_policy"),
"markdown_source_hash": hashlib.sha256(source.read_bytes()).hexdigest(), "status": "canvas_editing",
})
save_json(metadata_path, metadata)
result: dict[str, Any] = {"status": "PASS", "source": str(source), "slide_count": len(spec["slides"]), "spec": str(spec_path)}
if args.build_preview or args.render_canvas:
preview = session / "cache" / "editable" / "preview.pptx"
build = run_checked("scripts/build_native_editable_deck.py", "--spec", str(spec_path), "--session", str(session), "--out-pptx", str(preview), "--report", str(session / "reports" / "markdown-preview-build.json"))
sync = run_checked("scripts/sync_canvas_scene.py", "--session", str(session), "--pptx", str(preview))
result.update({"preview": str(preview), "preview_build": build["status"], "scene_sync": sync["status"]})
if args.render_canvas:
manifest = run_checked("scripts/editor_bridge.py", "export", "--session", str(session))
canvas = session / "reports" / "editor-canvas.html"
rendered = run_checked("scripts/render_editor_canvas.py", "--manifest", str(session / "reports" / "editor-manifest.json"), "--out", str(canvas))
result.update({"manifest_slide_count": manifest["slide_count"], "canvas": str(canvas), "canvas_status": rendered["status"]})
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""Export an editor manifest or apply optimistic-concurrency patches to a deck session."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from revision_session import snapshot
from style_presets import flattened_presets, resolve_style
from compile_slides_markdown import render_markdown, split_frontmatter
PROTECTED_KEYS = {
"schema_version", "slide_number", "backend", "source_type", "status",
"asset_path", "prompt_record", "visual_asset_policy", "style_id", "style_variant",
"typography_profile", "table_profile", "archetype",
}
ELEMENT_CHANGE_KEYS = {"bbox", "text", "style", "rotation", "z_index"}
STYLE_KEYS = {"fill", "line", "line_width_pt", "font_size_pt", "font_family", "font_color", "bold", "align", "vertical_align", "line_spacing"}
def validate_style_changes(style: dict) -> None:
if set(style) - STYLE_KEYS:
raise ValueError("unsupported style field")
for key in ("fill", "line", "font_color"):
if key in style and (not isinstance(style[key], str) or not re.fullmatch(r"[A-Fa-f0-9]{6}", style[key])):
raise ValueError(f"{key} must be a six-digit hex color")
if "font_size_pt" in style and (not isinstance(style["font_size_pt"], (int, float)) or not 1 <= float(style["font_size_pt"]) <= 200):
raise ValueError("font_size_pt must be between 1 and 200")
if "line_width_pt" in style and (not isinstance(style["line_width_pt"], (int, float)) or not 0 <= float(style["line_width_pt"]) <= 50):
raise ValueError("line_width_pt must be between 0 and 50")
if "line_spacing" in style and (not isinstance(style["line_spacing"], (int, float)) or not 0.5 <= float(style["line_spacing"]) <= 5):
raise ValueError("line_spacing must be between 0.5 and 5")
if "font_family" in style and (not isinstance(style["font_family"], str) or not style["font_family"].strip()):
raise ValueError("font_family must be a non-empty string")
if "bold" in style and not isinstance(style["bold"], bool):
raise ValueError("bold must be boolean")
if "align" in style and style["align"] not in {"left", "center", "right", "justify"}:
raise ValueError("invalid text alignment")
if "vertical_align" in style and style["vertical_align"] not in {"top", "middle", "bottom"}:
raise ValueError("invalid vertical alignment")
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def pointer_escape(value: str) -> str:
return value.replace("~", "~0").replace("/", "~1")
def pointer_unescape(value: str) -> str:
return value.replace("~1", "/").replace("~0", "~")
def editable_paths(value: object, prefix: str = "") -> list[str]:
paths: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
if key in PROTECTED_KEYS:
continue
child_prefix = f"{prefix}/{pointer_escape(str(key))}"
paths.extend(editable_paths(child, child_prefix))
elif isinstance(value, list):
for index, child in enumerate(value):
paths.extend(editable_paths(child, f"{prefix}/{index}"))
elif prefix:
paths.append(prefix)
return paths
def source_document(session: Path) -> tuple[str, Path, dict]:
native = session / "analysis" / "native_deck_spec.json"
if native.is_file():
return "native-editable-deck", native, load(native)
raise ValueError("editor bridge v1 requires analysis/native_deck_spec.json")
def thumbnail_for(session: Path, slide_number: int) -> str | None:
names = (f"slide-{slide_number:03d}.png", f"slide-{slide_number}.png")
for folder in ("render", "generated", "final"):
for name in names:
path = session / folder / name
if path.is_file():
return str(path.relative_to(session))
return None
def current_style(document: dict) -> dict:
style, variant = resolve_style(str(document.get("style_id") or "consulting-blue-white"), document.get("style_variant"))
return {
"style_id": style["id"], "variant_id": variant["id"],
"family_name": style["name"], "name": variant["name"],
}
def build_manifest(session: Path) -> dict:
metadata = load(session / "metadata.json")
document_kind, document_path, document = source_document(session)
allowed = set(editable_paths(document))
slides = []
canvas_state = []
for index, slide in enumerate(document.get("slides", [])):
number = int(slide["slide_number"])
prefix = f"/slides/{index}/"
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
scene = load(scene_path) if scene_path.is_file() else {"canvas": {"width": 1920, "height": 1080, "unit": "px"}, "elements": []}
canvas_state.append({"slide_number": number, "revision": scene.get("revision"), "elements": scene.get("elements", [])})
slides.append({
"slide_number": number,
"title": slide.get("title", ""),
"archetype": slide.get("archetype"),
"style": {
"style_id": slide.get("style_id") or document.get("style_id"),
"variant_id": slide.get("style_variant") or document.get("style_variant") or resolve_style(str(document.get("style_id") or "consulting-blue-white"), None)[1]["id"],
},
"scene": f"scenes/slide-{number:03d}.scene.json",
"thumbnail": thumbnail_for(session, number),
"editable_paths": sorted(path for path in allowed if path.startswith(prefix)),
"canvas": scene.get("canvas"),
"elements": scene.get("elements", []),
})
validate_slide_contract(session, document, slides)
markdown_path = session / "slides.md"
markdown_source = markdown_path.read_text(encoding="utf-8") if markdown_path.is_file() else None
return {
"schema_version": 3,
"protocol": "presentation-forge-editor.v3",
"session_id": metadata.get("session_id"),
"base_revision": metadata.get("current_revision"),
"route": metadata.get("route"),
"workflow": {
"mode": metadata.get("editor_workflow_mode", "direct-build"),
"style_selection_status": metadata.get("style_selection_status", "auto-selected"),
"export_approval": metadata.get("editor_export_approval", "auto-proceed"),
"final_pptx_ready": metadata.get("editor_export_approval") in {"approved", "auto-proceed"},
},
"document_kind": document_kind,
"editable_document": str(document_path.relative_to(session)),
"authoring": {
"mode": metadata.get("authoring_mode", "slides-plan"),
"source": metadata.get("content_source", "slides_plan.md"),
"markdown": markdown_source,
"markdown_sha256": hashlib.sha256(markdown_source.encode()).hexdigest() if markdown_source is not None else None,
"round_trip": markdown_source is not None,
},
"document_sha256": canonical_hash(document),
"canvas_sha256": canonical_hash(canvas_state),
"global_editable_paths": sorted(path for path in allowed if not path.startswith("/slides/")),
"current_style": current_style(document),
"style_catalog": flattened_presets(),
"slides": slides,
"slide_count": len(slides),
"capabilities": {
"replace_scalar": True,
"element_canvas": True,
"move_resize": True,
"text_edit": True,
"style_edit": True,
"rotation": True,
"z_order": True,
"page_level_invalidation": True,
"optimistic_concurrency": True,
"revision_rollback": True,
"deck_style_switch": True,
"slide_variant_switch": True,
"style_switch_modes": ["replace-theme", "preserve-layout"],
"explicit_export_approval": metadata.get("editor_workflow_mode") == "canvas-first",
"markdown_source_edit": markdown_source is not None,
"markdown_round_trip": markdown_source is not None,
"asset_replace": False,
"direct_ooxml_edit": False,
},
"events": "reports/editor-events.jsonl",
}
def validate_slide_contract(session: Path, document: dict, slides: list[dict]) -> None:
document_numbers = [int(row["slide_number"]) for row in document.get("slides", [])]
manifest_numbers = [int(row["slide_number"]) for row in slides]
scene_numbers = sorted(int(path.stem.split("-")[1].split(".")[0]) for path in (session / "scenes").glob("slide-*.scene.json"))
expected = list(range(1, len(document_numbers) + 1))
if document_numbers != expected:
raise ValueError(f"canvas slide contract failed: non-contiguous document slides {document_numbers}")
if manifest_numbers != document_numbers:
raise ValueError(f"canvas slide contract failed: manifest {manifest_numbers} != document {document_numbers}")
if scene_numbers != document_numbers:
raise ValueError(f"canvas slide contract failed: scenes {scene_numbers} != document {document_numbers}")
def export_manifest(session: Path, out: Path | None = None) -> dict:
manifest = build_manifest(session)
target = out or session / "reports" / "editor-manifest.json"
save(target, manifest)
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
previous_editor = metadata.get("editor") if isinstance(metadata.get("editor"), dict) else {}
metadata["editor"] = {
"protocol": manifest["protocol"],
"manifest": str(target.relative_to(session)) if target.is_relative_to(session) else str(target),
"status": "editing" if metadata.get("editor_workflow_mode") == "canvas-first" and metadata.get("editor_export_approval") != "approved" else previous_editor.get("status", "ready"),
}
save(metadata_path, metadata)
return manifest
def resolve_pointer(document: object, pointer: str) -> tuple[object, str | int]:
if not pointer.startswith("/"):
raise ValueError(f"invalid JSON pointer: {pointer}")
parts = [pointer_unescape(part) for part in pointer[1:].split("/")]
current = document
for part in parts[:-1]:
if isinstance(current, list):
current = current[int(part)]
elif isinstance(current, dict):
current = current[part]
else:
raise ValueError(f"pointer traverses scalar: {pointer}")
key: str | int = int(parts[-1]) if isinstance(current, list) else parts[-1]
return current, key
def replace_value(document: dict, pointer: str, value: object) -> None:
parent, key = resolve_pointer(document, pointer)
old = parent[key] # type: ignore[index]
if isinstance(old, bool) != isinstance(value, bool):
raise ValueError(f"type mismatch at {pointer}")
if old is not None and value is not None and not isinstance(value, type(old)):
if not (isinstance(old, (int, float)) and isinstance(value, (int, float)) and not isinstance(value, bool)):
raise ValueError(f"type mismatch at {pointer}")
parent[key] = value # type: ignore[index]
def refresh_scene_hash(scene: dict) -> None:
hashable = dict(scene)
hashable.pop("revision", None)
dependencies = dict(scene.get("dependencies", {}))
dependencies.pop("scene_hash", None)
hashable["dependencies"] = dependencies
scene.setdefault("dependencies", {})["scene_hash"] = canonical_hash(hashable)
def clear_scene_style_overrides(scene: dict, mode: str) -> None:
if mode == "replace-theme":
scene["elements"] = []
return
for element in scene.get("elements", []):
element["style"] = {}
def update_frontmatter(text: str, values: dict[str, str]) -> str:
if not text.startswith("---\n") or "\n---\n" not in text[4:]:
return text
frontmatter, body = text[4:].split("\n---\n", 1)
lines = frontmatter.splitlines()
found: set[str] = set()
for index, line in enumerate(lines):
key = line.split(":", 1)[0].strip() if ":" in line else ""
if key in values:
lines[index] = f"{key}: {values[key]}"
found.add(key)
for key, value in values.items():
if key not in found:
lines.append(f"{key}: {value}")
return "---\n" + "\n".join(lines) + "\n---\n" + body
def sync_scenes(session: Path, document: dict, changed_slides: set[int]) -> None:
by_number = {int(slide["slide_number"]): slide for slide in document.get("slides", [])}
for number in sorted(changed_slides):
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file() or number not in by_number:
continue
slide = by_number[number]
scene = load(scene_path)
scene["revision"] = int(scene.get("revision", 1)) + 1
scene["page_type"] = slide.get("archetype", scene.get("page_type", "other"))
scene.setdefault("content", {})["title"] = slide.get("title", "")
scene["content"]["message"] = slide.get("key_message") or slide.get("subtitle") or ""
for element in scene.get("elements", []):
binding = element.get("source_binding")
if not isinstance(binding, dict) or binding.get("document") != "analysis/native_deck_spec.json" or "text" not in element:
continue
try:
parent, key = resolve_pointer(document, str(binding["path"]))
value = parent[key] # type: ignore[index]
except (KeyError, IndexError, TypeError, ValueError):
continue
if isinstance(value, (str, int, float)):
element["text"] = str(value)
refresh_scene_hash(scene)
save(scene_path, scene)
def invalidate_slides(metadata: dict, changed_slides: set[int]) -> None:
variant = metadata.setdefault("variants", {}).setdefault("editable", {"status": "not_built", "artifact": None, "slides": {}})
slides = variant.setdefault("slides", {})
for number in changed_slides:
previous = slides.get(str(number), {})
slides[str(number)] = {**previous, "status": "stale", "reason": "editor_patch"}
if changed_slides:
variant["status"] = "partial_stale"
def append_event(session: Path, event: dict) -> None:
path = session / "reports" / "editor-events.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
def apply_patch(session: Path, patch_path: Path) -> dict:
patch = load(patch_path)
if patch.get("schema_version") not in {1, 2, 3}:
raise ValueError("unsupported editor patch schema_version")
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
current_revision = metadata.get("current_revision")
if patch.get("base_revision") != current_revision:
raise ValueError(f"revision conflict: expected {current_revision!r}, got {patch.get('base_revision')!r}")
_, document_path, document = source_document(session)
current_hash = canonical_hash(document)
if patch.get("document_sha256") != current_hash:
raise ValueError("document hash conflict")
current_manifest = build_manifest(session)
if patch.get("schema_version") in {2, 3} and patch.get("canvas_sha256") != current_manifest.get("canvas_sha256"):
raise ValueError("canvas hash conflict")
allowed = set(editable_paths(document))
prompts_path = session / "prompts.json"
prompts = load(prompts_path) if prompts_path.is_file() else {}
plan_path = session / "slides_plan.md"
plan_text = plan_path.read_text(encoding="utf-8") if plan_path.is_file() else ""
deck_style_change: dict | None = None
changed_slides: set[int] = set()
changed_scene_docs: dict[int, tuple[Path, dict]] = {}
for operation in patch.get("operations", []):
if operation.get("op") == "apply-style":
scope = operation.get("scope")
mode = operation.get("mode")
if scope not in {"deck", "slide"} or mode not in {"replace-theme", "preserve-layout"}:
raise ValueError("invalid style scope or mode")
target_style, target_variant = resolve_style(str(operation.get("style_id") or document.get("style_id")), operation.get("variant_id"))
if scope == "slide" and target_style["id"] != document.get("style_id"):
raise ValueError("slide style switch must remain inside the current deck style family")
target_numbers = [int(row["slide_number"]) for row in document.get("slides", [])] if scope == "deck" else [int(operation.get("slide_number", 0))]
if not target_numbers or any(number < 1 for number in target_numbers):
raise ValueError("style operation targets no valid slides")
by_number = {int(row["slide_number"]): row for row in document.get("slides", [])}
if any(number not in by_number for number in target_numbers):
raise ValueError("style operation targets an unknown slide")
if scope == "deck":
document["style_id"] = target_style["id"]
document["style_variant"] = target_variant["id"]
document["typography_profile"] = target_style["typography_profile"]
document["table_profile"] = target_style["table_profile"]
for row in document.get("slides", []):
row.pop("style_id", None); row.pop("style_variant", None)
prompts["style_id"] = target_style["id"]
prompts["style_variant"] = target_variant["id"]
prompts["typography_profile"] = target_style["typography_profile"]
prompts["table_profile"] = target_style["table_profile"]
plan_text = update_frontmatter(plan_text, {
"style_id": target_style["id"], "style_variant": target_variant["id"],
"typography_profile": target_style["typography_profile"], "table_profile": target_style["table_profile"],
})
deck_style_change = {"style_id": target_style["id"], "style_variant": target_variant["id"], "typography_profile": target_style["typography_profile"], "table_profile": target_style["table_profile"]}
else:
row = by_number[target_numbers[0]]
row["style_id"] = target_style["id"]
row["style_variant"] = target_variant["id"]
for prompt_row in prompts.get("slides", []):
if int(prompt_row.get("slide_number", 0)) in target_numbers:
prompt_row["style_id"] = target_style["id"]
prompt_row["style_variant"] = target_variant["id"]
for number in target_numbers:
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise ValueError(f"scene not found for slide {number}")
scene = changed_scene_docs.get(number, (scene_path, load(scene_path)))[1]
scene["style_id"] = target_style["id"]
scene["style_variant"] = target_variant["id"]
scene["typography_profile"] = target_style["typography_profile"]
scene["table_profile"] = target_style["table_profile"]
clear_scene_style_overrides(scene, str(mode))
changed_scene_docs[number] = (scene_path, scene)
changed_slides.add(number)
continue
if operation.get("op") == "update-element":
number = int(operation.get("slide_number", 0))
scene_path = session / "scenes" / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise ValueError(f"scene not found for slide {number}")
scene = changed_scene_docs.get(number, (scene_path, load(scene_path)))[1]
element = next((row for row in scene.get("elements", []) if row.get("id") == operation.get("element_id")), None)
if element is None:
raise ValueError(f"element not found: {operation.get('element_id')}")
if element.get("locked") or not element.get("editable"):
raise ValueError(f"element is locked: {operation.get('element_id')}")
changes = operation.get("changes")
if not isinstance(changes, dict) or not changes or set(changes) - ELEMENT_CHANGE_KEYS:
raise ValueError("invalid element changes")
capabilities = set(element.get("capabilities", []))
if "bbox" in changes:
if "geometry" not in capabilities:
raise ValueError("element geometry is not editable")
bbox = changes["bbox"]
canvas = scene.get("canvas", {})
if not isinstance(bbox, list) or len(bbox) != 4 or not all(isinstance(value, (int, float)) for value in bbox):
raise ValueError("bbox must contain four numbers")
if bbox[2] <= 0 or bbox[3] <= 0 or bbox[0] < 0 or bbox[1] < 0 or bbox[0] + bbox[2] > canvas.get("width", 0) or bbox[1] + bbox[3] > canvas.get("height", 0):
raise ValueError("bbox must remain inside canvas")
element["bbox"] = bbox
if "text" in changes:
if "text" not in capabilities or not isinstance(changes["text"], str):
raise ValueError("element text is not editable")
element["text"] = changes["text"]
binding = element.get("source_binding")
if isinstance(binding, dict) and binding.get("document") == "analysis/native_deck_spec.json":
replace_value(document, str(binding["path"]), changes["text"])
if "style" in changes:
if "style" not in capabilities or not isinstance(changes["style"], dict) or set(changes["style"]) - STYLE_KEYS:
raise ValueError("element style is not editable")
validate_style_changes(changes["style"])
element.setdefault("style", {}).update(changes["style"])
if "rotation" in changes:
if "rotation" not in capabilities or not isinstance(changes["rotation"], (int, float)) or not -360 <= float(changes["rotation"]) <= 360:
raise ValueError("element rotation is not editable")
element["rotation"] = float(changes["rotation"])
if "z_index" in changes:
if "z-order" not in capabilities or not isinstance(changes["z_index"], int) or not 0 <= changes["z_index"] <= 10000:
raise ValueError("element z-order is not editable")
element["z_index"] = changes["z_index"]
changed_scene_docs[number] = (scene_path, scene)
changed_slides.add(number)
continue
if operation.get("op") != "replace":
raise ValueError("unsupported editor operation")
pointer = operation.get("path")
if pointer not in allowed:
raise ValueError(f"path is not editable: {pointer}")
parts = pointer.split("/")
if len(parts) > 3 and parts[1] == "slides":
index = int(parts[2])
changed_slides.add(int(document["slides"][index]["slide_number"]))
elif pointer == "/title":
changed_slides.update(int(slide["slide_number"]) for slide in document.get("slides", []))
if pointer.endswith("/title") and (not isinstance(operation.get("value"), str) or not operation.get("value").strip()):
raise ValueError(f"title cannot be empty: {pointer}")
replace_value(document, pointer, operation.get("value"))
if not patch.get("operations"):
raise ValueError("patch has no operations")
revision = snapshot(session, f"editor patch {patch.get('request_id', '')}".strip())
save(document_path, document)
if prompts_path.is_file():
save(prompts_path, prompts)
if plan_path.is_file():
plan_path.write_text(plan_text, encoding="utf-8")
markdown_path = session / "slides.md"
if metadata.get("authoring_mode") == "markdown-canvas" or markdown_path.is_file():
existing_frontmatter = split_frontmatter(markdown_path.read_text(encoding="utf-8"))[0] if markdown_path.is_file() else {}
markdown_path.write_text(render_markdown(document, existing_frontmatter), encoding="utf-8")
for _, (scene_path, scene) in changed_scene_docs.items():
scene["revision"] = int(scene.get("revision", 1)) + 1
refresh_scene_hash(scene)
save(scene_path, scene)
sync_scenes(session, document, changed_slides)
metadata = load(metadata_path)
if deck_style_change:
metadata.update(deck_style_change)
metadata["style_selection_status"] = "confirmed"
invalidate_slides(metadata, changed_slides)
metadata.setdefault("editor", {})["status"] = "rebuild_required" if changed_slides else "ready"
save(metadata_path, metadata)
event = {
"event": "editor.patch.applied",
"request_id": patch.get("request_id"),
"revision": revision,
"changed_slides": sorted(changed_slides),
"created_at": datetime.now(timezone.utc).isoformat(),
}
append_event(session, event)
manifest = export_manifest(session)
return {"status": "applied", **event, "manifest": manifest}
def approve_export(session: Path) -> dict:
metadata_path = session / "metadata.json"
metadata = load(metadata_path)
if metadata.get("editor_workflow_mode") != "canvas-first":
raise ValueError("export approval is only used by canvas-first sessions")
if metadata.get("style_selection_status") != "confirmed":
raise ValueError("select and confirm a style before exporting PPTX")
revision = snapshot(session, "canvas export approved")
metadata = load(metadata_path)
metadata["editor_export_approval"] = "approved"
metadata.setdefault("editor", {})["status"] = "export_approved"
save(metadata_path, metadata)
event = {
"event": "editor.export.approved",
"revision": revision,
"created_at": datetime.now(timezone.utc).isoformat(),
}
append_event(session, event)
return {"status": "approved", **event}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
export_cmd = sub.add_parser("export")
export_cmd.add_argument("--session", required=True)
export_cmd.add_argument("--out")
apply_cmd = sub.add_parser("apply")
apply_cmd.add_argument("--session", required=True)
apply_cmd.add_argument("--patch", required=True)
approve_cmd = sub.add_parser("approve-export")
approve_cmd.add_argument("--session", required=True)
args = parser.parse_args()
session = Path(args.session).resolve()
try:
if args.command == "export":
result = export_manifest(session, Path(args.out).resolve() if args.out else None)
elif args.command == "approve-export":
result = approve_export(session)
else:
result = apply_patch(session, Path(args.patch).resolve())
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"status": "BLOCKED", "error": str(exc)}, ensure_ascii=False))
raise SystemExit(2)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Cut image-generated asset grids into transparent per-object PNG assets."""
from __future__ import annotations
import argparse
import json
from collections import deque
from pathlib import Path
from typing import Any
from PIL import Image
VALID_SOURCE_TYPES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--grid", help="Generated asset grid image.")
parser.add_argument("--rows", type=int, help="Grid row count.")
parser.add_argument("--cols", type=int, help="Grid column count.")
parser.add_argument("--names", help="Comma-separated asset ids or a JSON file containing a string list.")
parser.add_argument("--grid-manifest", help="JSON prompt/grid manifest. Supports one or many grids.")
parser.add_argument("--out-dir", required=True, help="Output directory for cut PNG assets.")
parser.add_argument("--manifest-out", help="Optional asset_manifest.json output path.")
parser.add_argument("--source-type", default="imagegen_asset", choices=sorted(VALID_SOURCE_TYPES), help="Source type written to manifest.")
parser.add_argument("--background", default="chroma", choices=["chroma", "white", "auto"], help="Background removal mode.")
parser.add_argument("--chroma", default="00ff00", help="Chroma key color as RRGGBB. Default: 00ff00.")
parser.add_argument("--tolerance", type=int, default=70, help="Background color distance tolerance.")
parser.add_argument("--trim-pad", type=int, default=4, help="Transparent trim padding in pixels.")
parser.add_argument("--min-component-area", type=int, default=80, help="Remove alpha components smaller than this area.")
parser.add_argument("--absolute-paths", action="store_true", help="Write absolute asset paths to manifest.")
return parser.parse_args()
def load_names(value: str | list[Any]) -> list[str]:
if isinstance(value, list):
names = []
for item in value:
if isinstance(item, str):
names.append(item)
elif isinstance(item, dict):
sid = item.get("semantic_unit_id") or item.get("id") or item.get("name")
if sid:
names.append(str(sid))
return names
path = Path(value)
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
return load_names(data)
return [x.strip() for x in value.split(",") if x.strip()]
def hex_rgb(value: str) -> tuple[int, int, int]:
value = value.strip().lstrip("#")
if len(value) != 6:
raise SystemExit("--chroma must be RRGGBB")
return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
def bg_distance(pixel: tuple[int, int, int, int], target: tuple[int, int, int]) -> int:
r, g, b, _ = pixel
tr, tg, tb = target
return abs(r - tr) + abs(g - tg) + abs(b - tb)
def remove_background(img: Image.Image, mode: str, chroma: tuple[int, int, int], tolerance: int) -> Image.Image:
img = img.convert("RGBA")
pix = img.load()
for y in range(img.height):
for x in range(img.width):
r, g, b, a = pix[x, y]
remove = False
if mode in {"chroma", "auto"}:
remove = bg_distance((r, g, b, a), chroma) <= tolerance or (g > 150 and r < 140 and b < 140)
if mode in {"white", "auto"} and not remove:
remove = r >= 245 and g >= 245 and b >= 245
if remove:
pix[x, y] = (255, 255, 255, 0)
return img
def remove_small_components(img: Image.Image, min_area: int) -> Image.Image:
if min_area <= 0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A")
w, h = alpha.size
a = alpha.load()
visited = bytearray(w * h)
keep = bytearray(w * h)
for yy in range(h):
for xx in range(w):
idx = yy * w + xx
if visited[idx] or a[xx, yy] < 12:
continue
queue = deque([(xx, yy)])
visited[idx] = 1
comp = []
while queue:
x, y = queue.popleft()
comp.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if nx < 0 or ny < 0 or nx >= w or ny >= h:
continue
ni = ny * w + nx
if visited[ni] or a[nx, ny] < 12:
continue
visited[ni] = 1
queue.append((nx, ny))
if len(comp) >= min_area:
for x, y in comp:
keep[y * w + x] = 1
pix = img.load()
for yy in range(h):
for xx in range(w):
if a[xx, yy] >= 12 and not keep[yy * w + xx]:
pix[xx, yy] = (255, 255, 255, 0)
return img
def trim_alpha(img: Image.Image, pad: int) -> Image.Image:
bbox = img.getchannel("A").getbbox()
if not bbox:
return img
left, top, right, bottom = bbox
return img.crop((
max(0, left - pad),
max(0, top - pad),
min(img.width, right + pad),
min(img.height, bottom + pad),
))
def normalize_grid_specs(args: argparse.Namespace) -> list[dict[str, Any]]:
if args.grid_manifest:
manifest_path = Path(args.grid_manifest)
data = json.loads(manifest_path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("grids"), list):
grids = data["grids"]
elif isinstance(data, list):
grids = data
elif isinstance(data, dict):
grids = [data]
else:
raise SystemExit("--grid-manifest must contain an object or list")
specs = []
for spec in grids:
if not isinstance(spec, dict):
raise SystemExit("grid manifest entries must be objects")
grid_meta = spec.get("grid") if isinstance(spec.get("grid"), dict) else {}
grid = spec.get("output") or spec.get("grid_image") or (spec.get("grid") if isinstance(spec.get("grid"), str) else None)
if not grid:
raise SystemExit("grid manifest entry missing grid/output")
grid_path = Path(grid)
if not grid_path.is_absolute():
grid_path = manifest_path.parent / grid_path
rows = spec.get("rows") or spec.get("grid_rows") or grid_meta.get("rows")
cols = spec.get("cols") or spec.get("grid_cols") or grid_meta.get("cols")
if not rows or not cols:
raise SystemExit("grid manifest entry missing rows/cols")
objects = spec.get("objects") or spec.get("names")
specs.append({
"grid": str(grid_path),
"rows": int(rows),
"cols": int(cols),
"names": load_names(objects),
"prompt_id": spec.get("prompt_id"),
"source_reference": spec.get("source_reference") or spec.get("reference"),
"quality_notes": spec.get("quality_notes"),
"source_type": spec.get("source_type") or args.source_type,
"background": spec.get("background") or args.background,
"chroma": spec.get("chroma") or args.chroma,
"tolerance": int(spec.get("tolerance") or args.tolerance),
})
return specs
if not (args.grid and args.rows and args.cols and args.names):
raise SystemExit("either --grid-manifest or --grid/--rows/--cols/--names is required")
return [{
"grid": args.grid,
"rows": args.rows,
"cols": args.cols,
"names": load_names(args.names),
"prompt_id": None,
"source_reference": None,
"quality_notes": None,
"source_type": args.source_type,
"background": args.background,
"chroma": args.chroma,
"tolerance": args.tolerance,
}]
def manifest_path_for(out: Path, manifest_out: str | None, absolute: bool) -> str:
if absolute or not manifest_out:
return str(out)
try:
return str(out.relative_to(Path(manifest_out).parent))
except ValueError:
return str(out)
def cut_one_grid(spec: dict[str, Any], out_dir: Path, args: argparse.Namespace) -> list[dict[str, Any]]:
names = spec["names"]
rows = int(spec["rows"])
cols = int(spec["cols"])
expected = rows * cols
if len(names) != expected:
raise SystemExit(f"expected {expected} names for {rows}x{cols}, got {len(names)}")
source_type = spec.get("source_type") or args.source_type
if source_type not in VALID_SOURCE_TYPES:
raise SystemExit(f"invalid source_type: {source_type}")
grid_path = Path(spec["grid"])
src = Image.open(grid_path).convert("RGBA")
cell_w = src.width / cols
cell_h = src.height / rows
chroma = hex_rgb(spec.get("chroma") or args.chroma)
manifest = []
for idx, name in enumerate(names):
row, col = divmod(idx, cols)
box = (
int(round(col * cell_w)),
int(round(row * cell_h)),
int(round((col + 1) * cell_w)),
int(round((row + 1) * cell_h)),
)
asset = src.crop(box)
asset = remove_background(asset, spec.get("background") or args.background, chroma, int(spec.get("tolerance") or args.tolerance))
asset = remove_small_components(asset, args.min_component_area)
asset = trim_alpha(asset, args.trim_pad)
out = out_dir / f"{name}.png"
asset.save(out)
manifest.append({
"semantic_unit_id": name,
"source_type": source_type,
"asset_path": manifest_path_for(out, args.manifest_out, args.absolute_paths),
"semantic_unit_count": 1,
"generated_grid": str(grid_path),
"grid_cell": [row, col],
"prompt_id": spec.get("prompt_id"),
"source_reference": spec.get("source_reference"),
"quality_notes": spec.get("quality_notes"),
})
return manifest
def main() -> None:
args = parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
manifest = []
for spec in normalize_grid_specs(args):
manifest.extend(cut_one_grid(spec, out_dir, args))
if args.manifest_out:
Path(args.manifest_out).parent.mkdir(parents=True, exist_ok=True)
Path(args.manifest_out).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"assets": len(manifest), "out_dir": str(out_dir)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
DELIVERY_TYPES = ("image-pptx", "editable-pptx", "pdf", "png", "svg")
SESSION_DIRS = (
"sources", "references", "analysis", "scenes", "versions", "cache/image", "cache/editable",
"generated", "assets", "final", "render", "compare", "reports",
)
ROOT = Path(__file__).resolve().parents[1]
def available_style_ids() -> tuple[str, ...]:
catalog_path = ROOT / "styles" / "catalog.json"
if not catalog_path.is_file():
return ()
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
return tuple(entry["id"] for entry in catalog.get("styles", []))
def style_policies(style_id: str | None) -> tuple[str | None, str | None, str | None]:
if not style_id:
return None, None, None
catalog_path = ROOT / "styles" / "catalog.json"
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
entry = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), {})
return entry.get("typography_profile"), entry.get("table_profile"), entry.get("default_variant")
def slugify(value: str) -> str:
slug = re.sub(r"[^\w]+", "-", value.lower(), flags=re.UNICODE).replace("_", "-").strip("-")
return slug[:48] or "deck"
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize a session for a new PPT workflow.")
parser.add_argument("--title", required=True, help="Presentation title.")
parser.add_argument("--delivery-type", required=True, choices=DELIVERY_TYPES)
parser.add_argument("--out-root", default="outputs", help="Parent directory for sessions.")
parser.add_argument("--session-id", help="Explicit session id. Default: timestamp-title slug.")
style_ids = available_style_ids()
parser.add_argument("--style-id", choices=style_ids or None, help="Built-in style id from styles/catalog.json.")
parser.add_argument("--visual-asset-policy", choices=("native-only", "native-image-assisted", "image-led-editable"), help="Image-2 usage policy for native editable decks.")
parser.add_argument(
"--editor-workflow-mode",
choices=("direct-build", "canvas-first"),
help="Default: canvas-first for editable-pptx, direct-build for other deliveries.",
)
parser.add_argument(
"--authoring-mode",
choices=("slides-plan", "markdown-canvas"),
help="Default: markdown-canvas for editable-pptx, slides-plan for other deliveries.",
)
parser.add_argument(
"--outline-review-mode",
choices=("continuous", "explicit"),
default="continuous",
help="continuous proceeds after writing the plan; explicit pauses until the user approves it.",
)
parser.add_argument(
"--gui-validation-mode",
choices=("final-only", "eager", "never"),
default="final-only",
help="Control when PowerPoint/GUI rendering may run. Default: final artifact only.",
)
args = parser.parse_args()
editor_workflow_mode = args.editor_workflow_mode or ("canvas-first" if args.delivery_type == "editable-pptx" else "direct-build")
authoring_mode = args.authoring_mode or ("markdown-canvas" if args.delivery_type == "editable-pptx" else "slides-plan")
typography_profile, table_profile, style_variant = style_policies(args.style_id)
visual_asset_policy = args.visual_asset_policy or ("native-image-assisted" if args.delivery_type == "editable-pptx" else None)
session_id = args.session_id or f"{datetime.now():%Y%m%d-%H%M%S}-{slugify(args.title)}"
session = Path(args.out_root).expanduser().resolve() / session_id
if session.exists():
raise SystemExit(f"Session already exists: {session}")
session.mkdir(parents=True)
for name in SESSION_DIRS:
(session / name).mkdir(parents=True)
plan = f"""---
title: {args.title}
delivery_type: {args.delivery_type}
style_id: {args.style_id or '待确认'}
style_variant: {style_variant or '待确认'}
typography_profile: {typography_profile or '待确认'}
table_profile: {table_profile or '待确认'}
visual_asset_policy: {visual_asset_policy or '不适用'}
audience: 待确认
goal: 待确认
---
# 逐页计划
<!-- 按 references/planning-workflow.md 添加页面;此文件是内容 source of truth。 -->
"""
prompts = {
"schema_version": 1,
"session_id": session_id,
"delivery_type": args.delivery_type,
"style_id": args.style_id,
"style_variant": style_variant,
"typography_profile": typography_profile,
"table_profile": table_profile,
"visual_asset_policy": visual_asset_policy,
"slides": [],
}
metadata = {
"schema_version": 4,
"session_id": session_id,
"title": args.title,
"delivery_type": args.delivery_type,
"style_id": args.style_id,
"style_variant": style_variant,
"typography_profile": typography_profile,
"table_profile": table_profile,
"visual_asset_policy": visual_asset_policy,
"authoring_mode": authoring_mode,
"content_source": "slides.md" if authoring_mode == "markdown-canvas" else "slides_plan.md",
"editor_workflow_mode": editor_workflow_mode,
"style_selection_status": "pending" if editor_workflow_mode == "canvas-first" else "auto-selected",
"editor_export_approval": "pending" if editor_workflow_mode == "canvas-first" else "auto-proceed",
"current_revision": None,
"editability_confirmed": True,
"status": "planning",
"route": None,
"route_status": "pending",
"route_report": None,
"outline_review_mode": args.outline_review_mode,
"outline_approval": "auto-proceed" if args.outline_review_mode == "continuous" else "pending",
"gui_validation_mode": args.gui_validation_mode,
"final_powerpoint_validation": "skipped" if args.gui_validation_mode == "never" else "pending",
"smoke_slide": None,
"smoke_approval": "pending",
"final_qa": "pending",
"quality_gate_report": None,
"variants": {
"image": {"status": "not_built", "artifact": None, "slides": {}},
"editable": {"status": "not_built", "artifact": None, "slides": {}},
},
"environment": {"preflight_report": None, "status": "pending", "render_probe": "deferred" if args.gui_validation_mode == "final-only" else "pending"},
}
(session / "slides_plan.md").write_text(plan, encoding="utf-8")
if authoring_mode == "markdown-canvas":
slides_markdown = f"""---
title: {args.title}
style: {args.style_id or 'consulting-blue-white'}
variant: {style_variant or 'default'}
ratio: 16:9
fontCN: 微软雅黑
visualAssetPolicy: {visual_asset_policy or 'native-only'}
---
# {args.title}
> 在这里编辑副标题
::layout{{type="cover"}}
"""
(session / "slides.md").write_text(slides_markdown, encoding="utf-8")
(session / "prompts.json").write_text(json.dumps(prompts, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(session / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"session_id": session_id, "path": str(session)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Initialize a reusable semantic visual-replica project folder."""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from PIL import Image
SUBDIRS = [
"reference",
"reference_crops",
"generated",
"assets",
"render",
"compare",
"reports",
"prompts",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out-dir", required=True, help="Project directory to create.")
parser.add_argument("--reference", action="append", default=[], help="Reference slide image. Repeat per slide.")
parser.add_argument("--force", action="store_true", help="Allow writing into an existing directory.")
return parser.parse_args()
def slide_size(path: Path) -> list[int]:
with Image.open(path) as img:
return [img.width, img.height]
def main() -> None:
args = parse_args()
out = Path(args.out_dir)
if out.exists() and any(out.iterdir()) and not args.force:
raise SystemExit(f"{out} already exists and is not empty; pass --force to reuse it")
out.mkdir(parents=True, exist_ok=True)
for subdir in SUBDIRS:
(out / subdir).mkdir(parents=True, exist_ok=True)
references = []
slide_px = [1920, 1080]
for idx, ref in enumerate(args.reference, start=1):
src = Path(ref)
if not src.exists():
raise SystemExit(f"reference image not found: {src}")
ext = src.suffix.lower() or ".png"
dst = out / "reference" / f"page-{idx:02d}{ext}"
shutil.copy2(src, dst)
size = slide_size(dst)
if idx == 1:
slide_px = size
references.append({"slide": idx, "path": str(dst.relative_to(out)), "size_px": size})
inventory = {
"slide_size_px": slide_px,
"final_deck_type": "semantic_editable_replica",
"source_image_policy": "reference only; do not embed the full source image in the final PPTX",
"font_face": "Microsoft YaHei",
"references": references,
"slides": [
{
"slide": ref["slide"],
"reference": ref["path"],
"items": [],
}
for ref in references
] or [{"slide": 1, "reference": "", "items": []}],
}
layout_rules = {
"slide_size_px": slide_px,
"font_policy": {
"default_font_face": "Microsoft YaHei",
"text_box_extra_room_pct": 12,
},
"image_fit": "uniform_contain_only",
"allowed_source_types": ["imagegen_asset", "api_generated_asset", "provided_asset"],
"forbidden_media": [
"full_slide_reference_image",
"near_full_slide_reference_image",
"svg_media",
"raw_crop_asset",
"prompt_only_asset",
],
"comparison": {
"diff_threshold": 18,
"changed_pixel_pct_target": 0.25,
},
}
prompt_stub = {
"slide": 1,
"prompt_id": "assets_cycle_1_grid_01",
"output": "generated/assets_cycle_1_grid_01.png",
"grid": {"rows": 1, "cols": 1},
"objects": [{"semantic_unit_id": "domain_icon_01", "description": "replace with target semantic visual"}],
"prompt": "Create an isolated asset grid for a PowerPoint visual replica. Use the full reference and crops for style. No readable text, no numbers, no labels, no watermark.",
"negative": "no card frames, no crop borders, no fake logos, no surrounding slide context",
}
(out / "visual_inventory.json").write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
(out / "asset_anchors.json").write_text("[]\n", encoding="utf-8")
(out / "asset_manifest.json").write_text("[]\n", encoding="utf-8")
(out / "layout_rules.json").write_text(json.dumps(layout_rules, ensure_ascii=False, indent=2), encoding="utf-8")
(out / "prompts" / "assets_cycle_1.jsonl").write_text(json.dumps(prompt_stub, ensure_ascii=False) + "\n", encoding="utf-8")
(out / "conversion_report.md").write_text(
"# Semantic Visual-Replica Conversion Report\n\n"
"- Reference images: see `reference/`\n"
"- Generated grids: see `generated/`\n"
"- Final assets: see `assets/`\n"
"- Render comparison: see `compare/`\n"
"- Validation reports: see `reports/`\n",
encoding="utf-8",
)
print(json.dumps({"project": str(out), "references": len(references)}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""Analyze, plan, check, apply, and validate conservative native PPTX fills."""
from __future__ import annotations
import argparse
import copy
import json
import posixpath
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from lxml import etree as ET
from pptx import Presentation
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL = "http://schemas.openxmlformats.org/package/2006/relationships"
XML = "http://www.w3.org/XML/1998/namespace"
NS = {"p": P, "a": A, "r": R, "rel": REL}
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def role_for_shape(shape: object) -> str:
if getattr(shape, "is_placeholder", False):
try:
name = shape.placeholder_format.type.name.lower()
except (AttributeError, ValueError):
name = "placeholder"
if "title" in name:
return "title"
if "sub" in name:
return "subtitle"
if any(word in name for word in ("body", "object", "text")):
return "body"
return name
name = str(getattr(shape, "name", "")).lower()
return "title" if "title" in name else "text"
def font_size_pt(text_frame: object, default: float = 18.0) -> float:
for paragraph in text_frame.paragraphs:
for run in paragraph.runs:
if run.font.size:
return float(run.font.size.pt)
return default
def capacity_units(width: int, height: int, font_pt: float) -> int:
width_pt = max(1.0, width / 12700)
height_pt = max(1.0, height / 12700)
chars_per_line = max(1.0, width_pt / max(font_pt * 0.95, 1.0))
lines = max(1, int(height_pt / max(font_pt * 1.25, 1.0)))
return max(4, int(chars_per_line * lines))
def visual_units(text: str) -> float:
units = 0.0
for char in text:
if char.isspace():
units += 0.25
elif ord(char) >= 0x2E80:
units += 1.0
elif char.isalnum():
units += 0.55
else:
units += 0.45
return units
def analyze_pptx(source: Path) -> dict[str, object]:
prs = Presentation(str(source))
slides: list[dict[str, object]] = []
for slide_number, slide in enumerate(prs.slides, start=1):
slots: list[dict[str, object]] = []
for shape in slide.shapes:
geometry = {
"left": int(shape.left), "top": int(shape.top),
"width": int(shape.width), "height": int(shape.height),
}
if getattr(shape, "has_text_frame", False):
size = font_size_pt(shape.text_frame)
slots.append({
"slot_id": f"s{slide_number:03d}_sh{shape.shape_id}",
"kind": "text",
"shape_id": int(shape.shape_id),
"shape_name": shape.name,
"role": role_for_shape(shape),
"geometry": geometry,
"font_size_pt": size,
"paragraph_count": len(shape.text_frame.paragraphs),
"capacity_visual_units": capacity_units(shape.width, shape.height, size),
"old_text": shape.text,
})
if getattr(shape, "has_table", False):
rows = len(shape.table.rows)
cols = len(shape.table.columns)
cell_width = max(1, int(shape.width / max(cols, 1)))
cell_height = max(1, int(shape.height / max(rows, 1)))
for row_index, row in enumerate(shape.table.rows):
for col_index, cell in enumerate(row.cells):
size = font_size_pt(cell.text_frame, 14.0)
slots.append({
"slot_id": f"s{slide_number:03d}_tbl{shape.shape_id}_r{row_index}_c{col_index}",
"kind": "table-cell",
"shape_id": int(shape.shape_id),
"shape_name": shape.name,
"role": "table-cell",
"row": row_index,
"col": col_index,
"geometry": geometry,
"font_size_pt": size,
"paragraph_count": len(cell.text_frame.paragraphs),
"capacity_visual_units": capacity_units(cell_width, cell_height, size),
"old_text": cell.text,
})
summary = " | ".join(slot["old_text"] for slot in slots if slot["old_text"] and slot["role"] in {"title", "subtitle", "body"})
slides.append({"slide_number": slide_number, "text_summary": summary[:500], "slots": slots})
return {
"schema": "native_template_library.v1",
"source_pptx": str(source),
"slide_size": {"width": int(prs.slide_width), "height": int(prs.slide_height)},
"slide_count": len(slides),
"slides": slides,
"boundaries": {
"repeat_source_slide": False,
"replace_images": False,
"edit_charts": False,
"edit_smartart": False,
},
}
def slot_map(library: dict[str, object]) -> dict[str, dict[str, object]]:
return {
str(slot["slot_id"]): slot
for slide in library.get("slides", [])
for slot in slide.get("slots", [])
}
def check_plan(library: dict[str, object], plan: dict[str, object]) -> dict[str, object]:
errors: list[dict[str, object]] = []
warnings: list[dict[str, object]] = []
slots = slot_map(library)
max_slide = int(library.get("slide_count", 0))
seen: set[int] = set()
planned = plan.get("slides")
font_policy = plan.get("font_policy")
if font_policy is not None:
if not isinstance(font_policy, dict) or not str(font_policy.get("font_face", "")).strip():
errors.append({"code": "font_policy_invalid", "message": "font_policy requires a non-empty font_face."})
elif font_policy.get("scope", "replaced-text") not in {"replaced-text", "all-selected-text", "theme-and-replaced"}:
errors.append({"code": "font_scope_invalid", "scope": font_policy.get("scope")})
if not isinstance(planned, list) or not planned:
errors.append({"code": "slides_missing", "message": "Plan must contain at least one slide."})
planned = []
for plan_index, slide in enumerate(planned, start=1):
source_slide = int(slide.get("source_slide", 0))
if source_slide < 1 or source_slide > max_slide:
errors.append({"code": "source_slide_missing", "plan_slide": plan_index, "source_slide": source_slide})
continue
if source_slide in seen:
errors.append({"code": "repeat_source_slide_unsupported", "plan_slide": plan_index, "source_slide": source_slide})
seen.add(source_slide)
if not slide.get("layout_rationale"):
warnings.append({"code": "layout_rationale_missing", "plan_slide": plan_index})
for replacement in slide.get("replacements", []):
slot_id = str(replacement.get("slot_id", ""))
slot = slots.get(slot_id)
if not slot:
errors.append({"code": "slot_missing", "plan_slide": plan_index, "slot_id": slot_id})
continue
expected_prefix = f"s{source_slide:03d}_"
if not slot_id.startswith(expected_prefix):
errors.append({"code": "slot_slide_mismatch", "plan_slide": plan_index, "slot_id": slot_id})
continue
text = str(replacement.get("text", ""))
capacity = float(slot.get("capacity_visual_units", 0) or 0)
used = visual_units(text)
ratio = used / capacity if capacity else 0
if ratio > 1.15:
warnings.append({
"code": "text_capacity", "plan_slide": plan_index, "slot_id": slot_id,
"used_visual_units": round(used, 2), "capacity_visual_units": capacity,
"ratio": round(ratio, 2),
})
return {
"schema": "native_template_check.v1",
"status": "FAIL" if errors else "WARN" if warnings else "PASS",
"error_count": len(errors),
"warning_count": len(warnings),
"errors": errors,
"warnings": warnings,
}
def presentation_slide_parts(files: dict[str, bytes]) -> tuple[ET.Element, list[ET.Element], list[str]]:
presentation = ET.fromstring(files["ppt/presentation.xml"])
rels = ET.fromstring(files["ppt/_rels/presentation.xml.rels"])
targets = {
rel.get("Id"): posixpath.normpath(posixpath.join("ppt", rel.get("Target", "")))
for rel in rels.findall(f"{{{REL}}}Relationship")
}
slide_list = presentation.find(f"{{{P}}}sldIdLst")
if slide_list is None:
raise ValueError("presentation has no slide list")
slide_ids = list(slide_list)
parts = [targets.get(node.get(f"{{{R}}}id"), "") for node in slide_ids]
return presentation, slide_ids, parts
def set_rpr_typefaces(r_pr: ET.Element, font_face: str) -> None:
for child in list(r_pr):
if ET.QName(child).namespace == A and ET.QName(child).localname in {"latin", "ea", "cs"}:
r_pr.remove(child)
late_children = {"sym", "hlinkClick", "hlinkMouseOver", "extLst"}
insertion_index = len(r_pr)
for index, child in enumerate(r_pr):
if ET.QName(child).namespace == A and ET.QName(child).localname in late_children:
insertion_index = index
break
for offset, leaf in enumerate(("latin", "ea", "cs")):
node = ET.Element(f"{{{A}}}{leaf}")
node.set("typeface", font_face)
r_pr.insert(insertion_index + offset, node)
def replace_text_body(tx_body: ET.Element, text: str, font_face: str | None = None) -> None:
paragraphs = tx_body.findall(f"{{{A}}}p")
template = paragraphs[0] if paragraphs else ET.Element(f"{{{A}}}p")
template_ppr = template.find(f"{{{A}}}pPr")
first_run = template.find(f"{{{A}}}r")
template_rpr = first_run.find(f"{{{A}}}rPr") if first_run is not None else None
template_end = template.find(f"{{{A}}}endParaRPr")
for paragraph in paragraphs:
tx_body.remove(paragraph)
lines = text.splitlines() or [""]
for line in lines:
paragraph = ET.Element(f"{{{A}}}p")
if template_ppr is not None:
paragraph.append(copy.deepcopy(template_ppr))
run = ET.SubElement(paragraph, f"{{{A}}}r")
run_rpr = copy.deepcopy(template_rpr) if template_rpr is not None else None
if font_face:
run_rpr = run_rpr if run_rpr is not None else ET.Element(f"{{{A}}}rPr")
set_rpr_typefaces(run_rpr, font_face)
if run_rpr is not None:
run.append(run_rpr)
text_node = ET.SubElement(run, f"{{{A}}}t")
if line[:1].isspace() or line[-1:].isspace():
text_node.set(f"{{{XML}}}space", "preserve")
text_node.text = line
if template_end is not None:
paragraph.append(copy.deepcopy(template_end))
tx_body.append(paragraph)
def apply_replacement(root: ET.Element, slot_id: str, text: str, font_face: str | None = None) -> None:
text_match = re.fullmatch(r"s\d{3}_sh(\d+)", slot_id)
table_match = re.fullmatch(r"s\d{3}_tbl(\d+)_r(\d+)_c(\d+)", slot_id)
if text_match:
shape_id = text_match.group(1)
for shape in root.findall(f".//{{{P}}}sp"):
c_nv_pr = shape.find(f"{{{P}}}nvSpPr/{{{P}}}cNvPr")
if c_nv_pr is not None and c_nv_pr.get("id") == shape_id:
tx_body = shape.find(f"{{{P}}}txBody")
if tx_body is None:
raise ValueError(f"text body missing for {slot_id}")
replace_text_body(tx_body, text, font_face)
return
elif table_match:
shape_id, row_index, col_index = map(int, table_match.groups())
for frame in root.findall(f".//{{{P}}}graphicFrame"):
c_nv_pr = frame.find(f"{{{P}}}nvGraphicFramePr/{{{P}}}cNvPr")
if c_nv_pr is None or int(c_nv_pr.get("id", -1)) != shape_id:
continue
rows = frame.findall(f".//{{{A}}}tbl/{{{A}}}tr")
if row_index >= len(rows):
break
cells = rows[row_index].findall(f"{{{A}}}tc")
if col_index >= len(cells):
break
tx_body = cells[col_index].find(f"{{{A}}}txBody")
if tx_body is None:
raise ValueError(f"table text body missing for {slot_id}")
replace_text_body(tx_body, text, font_face)
return
raise ValueError(f"slot not found in slide XML: {slot_id}")
def patch_cjk_runs(root: ET.Element, font_face: str) -> int:
updated = 0
for run in root.xpath(".//a:r | .//a:fld", namespaces={"a": A}):
text = "".join(run.xpath("./a:t/text()", namespaces={"a": A}))
if not any(ord(char) >= 0x2E80 for char in text):
continue
r_pr = run.find(f"{{{A}}}rPr")
if r_pr is None:
r_pr = ET.Element(f"{{{A}}}rPr")
run.insert(0, r_pr)
set_rpr_typefaces(r_pr, font_face)
updated += 1
return updated
def patch_theme_fonts(files: dict[str, bytes], font_face: str) -> None:
for name in list(files):
if not name.startswith("ppt/theme/theme") or not name.endswith(".xml"):
continue
root = ET.fromstring(files[name])
for node in root.xpath(".//a:fontScheme/a:majorFont/a:ea | .//a:fontScheme/a:majorFont/a:cs | .//a:fontScheme/a:minorFont/a:ea | .//a:fontScheme/a:minorFont/a:cs", namespaces={"a": A}):
node.set("typeface", font_face)
files[name] = ET.tostring(root, encoding="UTF-8", xml_declaration=True, standalone=True)
def apply_plan(
source: Path,
library: dict[str, object],
plan: dict[str, object],
output: Path,
font_face: str | None = None,
font_scope: str = "replaced-text",
) -> None:
if plan.get("status") != "confirmed":
raise ValueError("fill plan must be confirmed before apply")
report = check_plan(library, plan)
if report["error_count"]:
raise ValueError(f"fill plan has {report['error_count']} blocking error(s)")
if source.resolve() == output.resolve():
raise ValueError("output must not overwrite the source PPTX")
with zipfile.ZipFile(source) as archive:
files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
infos = archive.infolist()
presentation, slide_ids, parts = presentation_slide_parts(files)
slide_list = presentation.find(f"{{{P}}}sldIdLst")
assert slide_list is not None
for child in list(slide_list):
slide_list.remove(child)
for entry in plan["slides"]:
source_slide = int(entry["source_slide"])
slide_list.append(copy.deepcopy(slide_ids[source_slide - 1]))
part = parts[source_slide - 1]
root = ET.fromstring(files[part])
for replacement in entry.get("replacements", []):
apply_replacement(root, str(replacement["slot_id"]), str(replacement.get("text", "")), font_face)
if font_face and font_scope == "all-selected-text":
patch_cjk_runs(root, font_face)
files[part] = ET.tostring(root, encoding="UTF-8", xml_declaration=True, standalone=True)
if font_face and font_scope == "theme-and-replaced":
patch_theme_fonts(files, font_face)
files["ppt/presentation.xml"] = ET.tostring(presentation, encoding="UTF-8", xml_declaration=True, standalone=True)
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".pptx", delete=False) as handle:
temp_path = Path(handle.name)
try:
with zipfile.ZipFile(temp_path, "w") as archive:
for info in infos:
archive.writestr(info, files[info.filename])
shutil.move(str(temp_path), output)
finally:
temp_path.unlink(missing_ok=True)
def validate_output(pptx: Path, plan: dict[str, object]) -> dict[str, object]:
errors: list[str] = []
try:
prs = Presentation(str(pptx))
except Exception as exc: # python-pptx surfaces malformed-package details
return {"status": "FAIL", "errors": [f"pptx_open_failed:{exc}"], "slide_count": 0}
if len(prs.slides) != len(plan.get("slides", [])):
errors.append(f"slide_count_mismatch:{len(prs.slides)}:{len(plan.get('slides', []))}")
for index, entry in enumerate(plan.get("slides", [])):
if index >= len(prs.slides):
break
visible = "\n".join(shape.text for shape in prs.slides[index].shapes if getattr(shape, "has_text_frame", False))
visible += "\n" + "\n".join(
cell.text
for shape in prs.slides[index].shapes if getattr(shape, "has_table", False)
for row in shape.table.rows for cell in row.cells
)
for replacement in entry.get("replacements", []):
value = str(replacement.get("text", ""))
if value and value not in visible:
errors.append(f"replacement_not_readable:slide={index + 1}:slot={replacement.get('slot_id')}")
return {"status": "FAIL" if errors else "PASS", "errors": errors, "slide_count": len(prs.slides)}
def parse_slide_selection(value: str | None, slide_count: int) -> list[int]:
if not value:
return list(range(1, slide_count + 1))
selected = [int(item.strip()) for item in value.split(",") if item.strip()]
if any(item < 1 or item > slide_count for item in selected):
raise ValueError("slide selection is out of range")
if len(set(selected)) != len(selected):
raise ValueError("repeating a source slide is not supported in v1")
return selected
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
analyze = sub.add_parser("analyze"); analyze.add_argument("source"); analyze.add_argument("--out", required=True)
scaffold = sub.add_parser("scaffold"); scaffold.add_argument("library"); scaffold.add_argument("--slides"); scaffold.add_argument("--out", required=True)
check = sub.add_parser("check-plan"); check.add_argument("library"); check.add_argument("plan"); check.add_argument("--out", required=True)
apply = sub.add_parser("apply"); apply.add_argument("source"); apply.add_argument("library"); apply.add_argument("plan"); apply.add_argument("--out", required=True); apply.add_argument("--font"); apply.add_argument("--font-scope", choices=("replaced-text", "all-selected-text", "theme-and-replaced"))
validate = sub.add_parser("validate"); validate.add_argument("pptx"); validate.add_argument("plan"); validate.add_argument("--out", required=True)
args = parser.parse_args()
if args.command == "analyze":
result = analyze_pptx(Path(args.source).resolve()); write_json(Path(args.out), result)
elif args.command == "scaffold":
library = json.loads(Path(args.library).read_text(encoding="utf-8"))
selected = parse_slide_selection(args.slides, int(library["slide_count"]))
result = {
"schema": "native_template_fill_plan.v1", "status": "draft",
"source_pptx": library["source_pptx"],
"font_policy": None,
"slides": [{"source_slide": number, "purpose": "待填写", "layout_rationale": None, "replacements": []} for number in selected],
}
write_json(Path(args.out), result)
elif args.command == "check-plan":
library = json.loads(Path(args.library).read_text(encoding="utf-8")); plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
result = check_plan(library, plan); write_json(Path(args.out), result)
if result["error_count"]:
raise SystemExit(2)
elif args.command == "apply":
source = Path(args.source).resolve(); library = json.loads(Path(args.library).read_text(encoding="utf-8")); plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
font_policy = plan.get("font_policy") if isinstance(plan.get("font_policy"), dict) else {}
font_face = args.font or font_policy.get("font_face")
font_scope = args.font_scope or font_policy.get("scope") or "replaced-text"
apply_plan(source, library, plan, Path(args.out).resolve(), font_face, font_scope)
result = {"status": "PASS", "output": str(Path(args.out).resolve()), "font_face": font_face, "font_scope": font_scope}
else:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8")); result = validate_output(Path(args.pptx).resolve(), plan); write_json(Path(args.out), result)
if result["status"] != "PASS":
raise SystemExit(2)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""将整页生图封装为图片型 PPTX并可选叠加真实 Logo。"""
from __future__ import annotations
import argparse
import math
import re
from pathlib import Path
from PIL import Image, ImageDraw
from pptx import Presentation
from pptx.util import Inches
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--images-dir", required=True, help="包含原始整页生图的目录。")
parser.add_argument("--out-pptx", required=True, help="输出 PPTX 路径。")
parser.add_argument("--final-dir", help="后处理后的最终 slide PNG 输出目录。")
parser.add_argument("--contact-sheet", help="contact sheet 缩略图输出路径。")
parser.add_argument("--slide-count", type=int, help="期望页数,用于检查缺页。")
parser.add_argument("--width", type=int, default=1920, help="最终 slide 图片宽度。")
parser.add_argument("--height", type=int, default=1080, help="最终 slide 图片高度。")
parser.add_argument("--logo", help="每页叠加的 Logo通常放在右上角。")
parser.add_argument("--logo-width", type=int, default=160, help="每页 Logo 的像素宽度。")
parser.add_argument("--logo-margin-x", type=int, default=70, help="每页 Logo 距右侧边距。")
parser.add_argument("--logo-margin-y", type=int, default=45, help="每页 Logo 距顶部边距。")
parser.add_argument("--mask-logo-zone", action="store_true", help="在右上角 Logo 后方绘制白色遮罩,覆盖预留框。")
parser.add_argument(
"--footer-logo",
action="append",
default=[],
metavar="PATH:WIDTH",
help="封面/尾页页脚 Logo格式为 PATH:WIDTH可重复传入。",
)
parser.add_argument("--footer-logo-slides", default="first,last", help="页脚 Logo 应用范围first,last,all,none可逗号分隔。")
parser.add_argument("--export-pdf", action="store_true", help="如果本机有 soffice/libreoffice则同时导出 PDF。")
return parser.parse_args()
def natural_key(path: Path):
return [int(s) if s.isdigit() else s.lower() for s in re.split(r"(\\d+)", path.name)]
def find_images(images_dir: Path) -> list[Path]:
exts = {".png", ".jpg", ".jpeg", ".webp"}
return sorted([p for p in images_dir.iterdir() if p.suffix.lower() in exts], key=natural_key)
def crop_to_size(img: Image.Image, width: int, height: int) -> Image.Image:
img = img.convert("RGB")
src_ratio = img.width / img.height
dst_ratio = width / height
if src_ratio > dst_ratio:
new_h = height
new_w = round(height * src_ratio)
else:
new_w = width
new_h = round(width / src_ratio)
resized = img.resize((new_w, new_h), Image.LANCZOS)
left = (new_w - width) // 2
top = (new_h - height) // 2
return resized.crop((left, top, left + width, top + height))
def load_logo(spec: str) -> tuple[Image.Image, int]:
if ":" in spec:
path_s, width_s = spec.rsplit(":", 1)
width = int(width_s)
else:
path_s = spec
width = 180
logo = Image.open(path_s).convert("RGBA")
h = round(logo.height * (width / logo.width))
return logo.resize((width, h), Image.LANCZOS), width
def overlay_logo(base: Image.Image, logo_path: str, width: int, mx: int, my: int, mask: bool) -> None:
logo = Image.open(logo_path).convert("RGBA")
h = round(logo.height * (width / logo.width))
logo = logo.resize((width, h), Image.LANCZOS)
x = base.width - width - mx
y = my
if mask:
draw = ImageDraw.Draw(base)
pad = 26
draw.rounded_rectangle(
[x - pad, y - pad, x + width + pad, y + h + pad],
radius=12,
fill=(255, 255, 255),
)
base.paste(logo, (x, y), logo)
def footer_slide_indices(count: int, mode: str) -> set[int]:
parts = {p.strip().lower() for p in mode.split(",") if p.strip()}
if "none" in parts:
return set()
if "all" in parts:
return set(range(count))
indices = set()
if "first" in parts:
indices.add(0)
if "last" in parts:
indices.add(count - 1)
return indices
def overlay_footer_logos(base: Image.Image, specs: list[str]) -> None:
if not specs:
return
logos = [load_logo(spec)[0] for spec in specs]
x = 95
y = base.height - 155
mask_w = sum(l.width for l in logos) + 55 * max(0, len(logos) - 1) + 70
mask_h = max(l.height for l in logos) + 46
draw = ImageDraw.Draw(base)
draw.rounded_rectangle([x - 25, y - 22, x - 25 + mask_w, y - 22 + mask_h], radius=10, fill=(255, 255, 255))
for logo in logos:
base.paste(logo, (x, y), logo)
x += logo.width + 55
def make_contact_sheet(files: list[Path], out: Path) -> None:
thumb_w, thumb_h, label_h = 384, 216, 34
cols = 3
rows = math.ceil(len(files) / cols)
sheet = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
draw = ImageDraw.Draw(sheet)
for idx, file in enumerate(files):
img = Image.open(file).convert("RGB").resize((thumb_w, thumb_h), Image.LANCZOS)
col, row = idx % cols, idx // cols
x, y = col * thumb_w, row * (thumb_h + label_h)
sheet.paste(img, (x, y))
draw.text((x + 10, y + thumb_h + 8), f"slide-{idx + 1:02d}", fill=(80, 80, 80))
out.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out, quality=92)
def build_pptx(files: list[Path], out_pptx: Path) -> None:
prs = Presentation()
prs.slide_width = Inches(13.333333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
for file in files:
slide = prs.slides.add_slide(blank)
slide.shapes.add_picture(str(file), 0, 0, width=prs.slide_width, height=prs.slide_height)
out_pptx.parent.mkdir(parents=True, exist_ok=True)
prs.save(out_pptx)
def export_image_pdf(files: list[Path], out_pdf: Path) -> None:
"""Create a raster PDF directly from final slide images; never round-trip through office software."""
pages = [Image.open(path).convert("RGB") for path in files]
if not pages:
raise ValueError("cannot export an empty image deck")
out_pdf.parent.mkdir(parents=True, exist_ok=True)
pages[0].save(out_pdf, "PDF", resolution=150.0, save_all=True, append_images=pages[1:])
for page in pages:
page.close()
def main() -> None:
args = parse_args()
images_dir = Path(args.images_dir)
out_pptx = Path(args.out_pptx)
final_dir = Path(args.final_dir) if args.final_dir else out_pptx.with_suffix("").parent / "final_slides"
final_dir.mkdir(parents=True, exist_ok=True)
images = find_images(images_dir)
if args.slide_count and len(images) != args.slide_count:
raise SystemExit(f"期望 {args.slide_count} 张图片,但在 {images_dir} 找到 {len(images)} 张。")
if not images:
raise SystemExit(f"目录中没有找到图片:{images_dir}")
footer_indices = footer_slide_indices(len(images), args.footer_logo_slides)
final_files = []
for idx, img_path in enumerate(images):
img = crop_to_size(Image.open(img_path), args.width, args.height)
if args.logo:
overlay_logo(img, args.logo, args.logo_width, args.logo_margin_x, args.logo_margin_y, args.mask_logo_zone)
if idx in footer_indices:
overlay_footer_logos(img, args.footer_logo)
out = final_dir / f"slide-{idx + 1:02d}.png"
img.save(out)
final_files.append(out)
build_pptx(final_files, out_pptx)
if args.contact_sheet:
make_contact_sheet(final_files, Path(args.contact_sheet))
if args.export_pdf:
export_image_pdf(final_files, out_pptx.with_suffix(".pdf"))
print(f"已写入 {out_pptx}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Inspect fonts, build dependencies, and PPT rendering backends before production."""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_FONTS = ("Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Arial Unicode MS")
def font_families() -> set[str]:
fc_list = shutil.which("fc-list")
if fc_list:
result = subprocess.run([fc_list, ":", "family"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
if result.returncode == 0:
return {name.strip().lower() for line in result.stdout.splitlines() for name in line.split(",") if name.strip()}
roots = [Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library/Fonts", Path("/usr/share/fonts")]
return {path.stem.lower() for root in roots if root.exists() for path in root.rglob("*") if path.suffix.lower() in {".ttf", ".otf", ".ttc"}}
def contains_font(available: set[str], requested: str) -> bool:
needle = requested.lower().replace(" ", "")
return any(needle in value.replace(" ", "") or value.replace(" ", "") in needle for value in available)
def render_backends() -> list[dict[str, str]]:
found: list[dict[str, str]] = []
if platform.system() == "Darwin":
app = Path("/Applications/Microsoft PowerPoint.app")
if app.exists():
found.append({"id": "powerpoint-macos", "path": str(app), "fidelity": "target"})
for name in ("soffice", "libreoffice"):
path = shutil.which(name)
if path and not any(row["path"] == path for row in found):
found.append({"id": "libreoffice", "path": path, "fidelity": "approximate"})
return found
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--target", choices=("image-pptx", "editable-pptx", "pdf"), required=True)
parser.add_argument("--font", action="append", default=[])
parser.add_argument("--strict-render", action="store_true", help="Fail when no rendering backend is available.")
parser.add_argument("--render-backend", choices=("auto", "powerpoint", "libreoffice"), default="auto")
parser.add_argument("--gui-validation-mode", choices=("final-only", "eager", "never"), help="Override session GUI validation policy.")
parser.add_argument("--skip-render-probe", action="store_true", help="Only discover renderers; do not execute a Chinese smoke export.")
parser.add_argument("--allow-approximate-render", action="store_true", help="Permit LibreOffice-only smoke export as WARN instead of BLOCKED.")
args = parser.parse_args()
session = Path(args.session).resolve(); metadata_path = session / "metadata.json"
if not metadata_path.is_file():
raise SystemExit(f"metadata.json missing: {metadata_path}")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
gui_validation_mode = args.gui_validation_mode or metadata.get("gui_validation_mode", "final-only")
requested = tuple(args.font) or DEFAULT_FONTS
available = font_families()
fonts = [{"name": name, "available": contains_font(available, name)} for name in requested]
selected_font = next((row["name"] for row in fonts if row["available"]), None)
dependencies = {name: importlib.util.find_spec(module) is not None for name, module in {"Pillow": "PIL", "python-pptx": "pptx"}.items()}
backends = render_backends()
render_probe = None
errors: list[str] = []; warnings: list[str] = []
if not all(dependencies.values()):
errors.append("missing Python build dependency")
if not selected_font:
errors.append("none of the requested CJK fonts is available")
elif selected_font != requested[0]:
warnings.append(f"font fallback selected: {selected_font}")
require_backend_now = args.strict_render or gui_validation_mode == "eager"
if not backends:
message = "no PowerPoint or LibreOffice rendering backend found"
if require_backend_now:
errors.append(message)
else:
warnings.append(message)
elif backends[0]["fidelity"] != "target" and args.target == "editable-pptx":
warnings.append("LibreOffice render is approximate for Chinese PowerPoint font metrics")
run_probe = gui_validation_mode == "eager" and not args.skip_render_probe
if args.target in {"editable-pptx", "pdf"} and run_probe and all(dependencies.values()) and selected_font:
reports_dir = session / "reports"; reports_dir.mkdir(parents=True, exist_ok=True)
probe_dir = reports_dir / "render-probe"; probe_dir.mkdir(parents=True, exist_ok=True)
inventory = {"slide_size_px": [1920, 1080], "font_face": selected_font, "items": [{"id": "probe_title", "class": "text", "text": "企业 AI 知识库 0123 ABC", "bbox_px": [120, 160, 1600, 120], "font_size": 36}]}
(probe_dir / "inventory.json").write_text(json.dumps(inventory, ensure_ascii=False), encoding="utf-8")
(probe_dir / "manifest.json").write_text("[]\n", encoding="utf-8")
smoke_pptx = probe_dir / "font-render-smoke.pptx"
build = subprocess.run([sys.executable, str(Path(__file__).with_name("build_semantic_deck.py")), "--inventory", str(probe_dir / "inventory.json"), "--manifest", str(probe_dir / "manifest.json"), "--out-pptx", str(smoke_pptx)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
render_report_path = reports_dir / "render-probe.json"
if render_report_path.exists():
render_report_path.unlink()
render_cmd = [
sys.executable, str(Path(__file__).with_name("render_pptx.py")),
"--input", str(smoke_pptx), "--out-dir", str(probe_dir),
"--backend", args.render_backend, "--validation-stage", "intermediate",
"--gui-validation-mode", gui_validation_mode, "--report", str(render_report_path),
]
if args.allow_approximate_render:
render_cmd.append("--allow-approximate")
probe_timed_out = False
try:
render = subprocess.run(render_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=240) if build.returncode == 0 else None
except subprocess.TimeoutExpired:
render = None
probe_timed_out = True
errors.append("render probe timed out")
if render_report_path.is_file():
render_probe = json.loads(render_report_path.read_text(encoding="utf-8"))
if build.returncode != 0:
errors.append("Chinese font render probe PPTX build failed")
elif not probe_timed_out and (render is None or render.returncode != 0):
errors.append(f"requested render probe failed: {args.render_backend}")
elif render_probe and render_probe.get("status") == "APPROXIMATE":
warnings.append("render probe passed only through approximate LibreOffice output")
probed_backend_id = render_probe.get("selected_backend") if render_probe else None
selected_backend = next((row for row in backends if row["id"] == probed_backend_id), None)
if selected_backend is None and backends:
selected_backend = backends[0]
status = "BLOCKED" if errors else "WARN" if warnings else "PASS"
probe_state = "completed" if render_probe else "skipped" if args.skip_render_probe or gui_validation_mode == "never" else "deferred"
report = {
"schema_version": 1,
"checked_at": datetime.now(timezone.utc).isoformat(),
"target": args.target,
"platform": {"system": platform.system(), "release": platform.release(), "python": platform.python_version()},
"dependencies": dependencies,
"fonts": fonts,
"selected_font": selected_font,
"render_backends": backends,
"selected_render_backend": selected_backend,
"gui_validation_mode": gui_validation_mode,
"render_probe_state": probe_state,
"render_probe": render_probe,
"status": status,
"errors": errors,
"warnings": warnings,
}
out = session / "reports" / "preflight.json"; out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
metadata["environment"] = {
"preflight_report": str(out.relative_to(session)),
"status": status,
"selected_font": selected_font,
"render_backend": selected_backend["id"] if selected_backend else None,
"render_probe": probe_state,
}
metadata["gui_validation_mode"] = gui_validation_mode
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "BLOCKED":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Prepare or commit a page-level rebuild while preserving other slide caches."""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("prepare", "commit"):
cmd = sub.add_parser(name)
cmd.add_argument("--session", required=True)
cmd.add_argument("--slide", type=int, required=True)
cmd.add_argument("--variant", choices=("image", "editable"), required=True)
if name == "commit":
cmd.add_argument("--artifact", required=True, help="New page image or editable page artifact.")
args = parser.parse_args()
session = Path(args.session).resolve()
scene_path = session / "scenes" / f"slide-{args.slide:03d}.scene.json"
metadata_path = session / "metadata.json"
if not scene_path.is_file() or not metadata_path.is_file():
raise SystemExit("scene or metadata.json missing; run compile_scenes.py first")
scene = load(scene_path)
metadata = load(metadata_path)
variants = metadata.setdefault("variants", {})
variant = variants.setdefault(args.variant, {"status": "not_built", "artifact": None, "slides": {}})
slides = variant.setdefault("slides", {})
key = str(args.slide)
state = slides.get(key, {})
scene_hash = scene["dependencies"]["scene_hash"]
cache_dir = session / "cache" / args.variant / f"slide-{args.slide:03d}"
cache_dir.mkdir(parents=True, exist_ok=True)
if args.command == "prepare":
stale = state.get("scene_hash") != scene_hash or not state.get("artifact") or not (session / state.get("artifact", "")).exists()
payload = {
"slide": args.slide,
"variant": args.variant,
"scene": str(scene_path),
"scene_hash": scene_hash,
"status": "stale" if stale else "clean",
"reason": "scene_or_artifact_changed" if stale else "cache_hit",
"generation": scene.get("generation", {}),
"elements": scene.get("elements", []) if args.variant == "editable" else [],
}
save(cache_dir / "rebuild-plan.json", payload)
if args.variant == "image":
save(cache_dir / "prompt.json", {
"slide_number": args.slide,
"scene_hash": scene_hash,
"style_id": scene.get("style_id"),
"layout_id": scene.get("layout_id"),
**scene.get("generation", {}),
})
else:
class_map = {"semantic_visual": "unresolved"}
items = []
for element in scene.get("elements", []):
item = dict(element)
item["class"] = class_map.get(item.pop("type", "unresolved"), element.get("type"))
item["bbox_px"] = item.pop("bbox")
item["slide"] = 1
items.append(item)
save(cache_dir / "visual_inventory.json", {
"schema_version": 1,
"source_slide_number": args.slide,
"scene_hash": scene_hash,
"slide_size_px": [scene["canvas"]["width"], scene["canvas"]["height"]],
"font_face": metadata.get("environment", {}).get("selected_font"),
"items": items,
})
if stale:
slides[key] = {**state, "status": "stale", "scene_hash": scene_hash}
variant["status"] = "partial_stale"
save(metadata_path, metadata)
print(json.dumps(payload, ensure_ascii=False))
return
artifact = Path(args.artifact).resolve()
if not artifact.is_file():
raise SystemExit(f"artifact not found: {artifact}")
try:
artifact_ref = str(artifact.relative_to(session))
except ValueError:
artifact_ref = str(artifact)
slides[key] = {
"status": "built",
"scene_hash": scene_hash,
"artifact": artifact_ref,
"artifact_hash": sha256(artifact),
"built_at": datetime.now(timezone.utc).isoformat(),
}
scene_count = len(list((session / "scenes").glob("*.scene.json")))
built_count = sum(row.get("status") == "built" for row in slides.values())
variant["status"] = "built" if scene_count and built_count == scene_count else "partial"
save(metadata_path, metadata)
payload = {"slide": args.slide, "variant": args.variant, **slides[key]}
save(cache_dir / "build-state.json", payload)
print(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Render the real multi-page editor UI from an editor manifest."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True)
parser.add_argument("--out", required=True)
args = parser.parse_args()
helper = Path(__file__).resolve().parents[1] / "assets" / "editor" / "render_editor_canvas.py"
result = subprocess.run([sys.executable, str(helper), "--manifest", args.manifest, "--out", args.out], check=False)
raise SystemExit(result.returncode)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Render PPTX to PDF through a tested PowerPoint or isolated LibreOffice backend."""
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
POWERPOINT_APP = Path("/Applications/Microsoft PowerPoint.app")
def powerpoint_available() -> bool:
return platform.system() == "Darwin" and POWERPOINT_APP.exists() and shutil.which("osascript") is not None
def libreoffice_path() -> str | None:
return shutil.which("soffice") or shutil.which("libreoffice")
def valid_pdf(path: Path) -> bool:
if not path.is_file() or path.stat().st_size <= 100:
return False
data = path.read_bytes()
return data.startswith(b"%PDF-") and b"%%EOF" in data[-2048:]
def run_powerpoint(source: Path, output: Path) -> dict[str, object]:
script = r'''
on run argv
set sourcePath to item 1 of argv
set outputPath to item 2 of argv
set openedDeck to missing value
try
with timeout of 180 seconds
tell application "Microsoft PowerPoint"
activate
open POSIX file sourcePath
delay 1
set openedDeck to active presentation
save openedDeck in POSIX file outputPath as save as PDF
delay 1
close openedDeck saving no
end tell
end timeout
on error errorMessage number errorNumber
try
if openedDeck is not missing value then
tell application "Microsoft PowerPoint" to close openedDeck saving no
end if
end try
error errorMessage number errorNumber
end try
end run
'''
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
try:
result = subprocess.run(
["osascript", "-", str(source), str(output)],
input=script,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=210,
)
return {"backend": "powerpoint-macos", "fidelity": "target", "returncode": result.returncode, "stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:], "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
except subprocess.TimeoutExpired as exc:
return {"backend": "powerpoint-macos", "fidelity": "target", "returncode": 124, "stdout": (exc.stdout or "")[-2000:], "stderr": "PowerPoint automation timed out", "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
def run_libreoffice(source: Path, output: Path) -> dict[str, object]:
executable = libreoffice_path()
if not executable:
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": 127, "stderr": "soffice/libreoffice not found", "output_exists": False, "output_size": 0}
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
with tempfile.TemporaryDirectory(prefix="codex-ppt-lo-profile-") as profile:
env = os.environ.copy()
env.setdefault("SAL_USE_VCLPLUGIN", "svp")
cache_dir = Path(profile) / "cache"
cache_dir.mkdir()
env["XDG_CACHE_HOME"] = str(cache_dir)
try:
result = subprocess.run(
[executable, f"-env:UserInstallation={Path(profile).as_uri()}", "--headless", "--convert-to", "pdf", "--outdir", str(output.parent), str(source)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=180,
env=env,
)
except subprocess.TimeoutExpired as exc:
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": 124, "stdout": (exc.stdout or "")[-2000:], "stderr": "LibreOffice conversion timed out", "output_exists": False, "output_size": 0, "valid_pdf": False}
generated = output.parent / source.with_suffix(".pdf").name
if generated.is_file() and generated != output:
generated.replace(output)
return {"backend": "libreoffice", "fidelity": "approximate", "returncode": result.returncode, "stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:], "output_exists": output.is_file(), "output_size": output.stat().st_size if output.is_file() else 0, "valid_pdf": valid_pdf(output)}
def succeeded(attempt: dict[str, object]) -> bool:
return attempt.get("returncode") == 0 and attempt.get("output_exists") is True and attempt.get("valid_pdf") is True
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True)
parser.add_argument("--out-dir", required=True)
parser.add_argument("--backend", choices=("auto", "powerpoint", "libreoffice"), default="auto")
parser.add_argument("--validation-stage", choices=("intermediate", "final"), default="final")
parser.add_argument("--gui-validation-mode", choices=("final-only", "eager", "never"), default="final-only")
parser.add_argument("--allow-approximate", action="store_true", help="Allow LibreOffice output to pass instead of remaining QA-only.")
parser.add_argument("--report")
args = parser.parse_args()
source = Path(args.input).resolve()
if not source.is_file():
raise SystemExit(f"input PPTX not found: {source}")
output = Path(args.out_dir).resolve() / source.with_suffix(".pdf").name
if args.gui_validation_mode == "never" or (args.gui_validation_mode == "final-only" and args.validation_stage == "intermediate"):
status = "SKIPPED" if args.gui_validation_mode == "never" else "DEFERRED"
report = {
"status": status,
"input": str(source),
"output": None,
"selected_backend": None,
"attempts": [],
"validation_stage": args.validation_stage,
"gui_validation_mode": args.gui_validation_mode,
}
if args.report:
report_path = Path(args.report); report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
return
attempts: list[dict[str, object]] = []
if args.backend in {"auto", "powerpoint"}:
if powerpoint_available():
attempts.append(run_powerpoint(source, output))
else:
attempts.append({"backend": "powerpoint-macos", "fidelity": "target", "returncode": 127, "stderr": "PowerPoint automation unavailable", "output_exists": False, "output_size": 0})
if not any(succeeded(row) for row in attempts) and args.backend in {"auto", "libreoffice"}:
attempts.append(run_libreoffice(source, output))
selected = next((row for row in attempts if succeeded(row)), None)
status = "FAIL"
if selected:
status = "PASS" if selected["fidelity"] == "target" else "APPROXIMATE"
report = {
"status": status,
"input": str(source),
"output": str(output) if selected else None,
"selected_backend": selected["backend"] if selected else None,
"attempts": attempts,
"validation_stage": args.validation_stage,
"gui_validation_mode": args.gui_validation_mode,
}
if args.report:
report_path = Path(args.report); report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "FAIL" or (status == "APPROXIMATE" and not args.allow_approximate):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Snapshot, list, and non-destructively roll back a deck session."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
TRACKED_ROOT_FILES = ("metadata.json", "slides.md", "slides_plan.md", "prompts.json", "visual_inventory.json", "asset_manifest.json", "asset_anchors.json", "layout_rules.json")
TRACKED_RELATIVE_FILES = ("analysis/native_deck_spec.json", "analysis/fill_plan.json")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def next_revision(versions: Path) -> str:
values = [int(p.name[1:]) for p in versions.glob("r[0-9][0-9][0-9][0-9]") if p.is_dir()]
return f"r{(max(values, default=0) + 1):04d}"
def tracked_files(session: Path) -> list[Path]:
files = [session / name for name in TRACKED_ROOT_FILES if (session / name).is_file()]
files.extend(session / name for name in TRACKED_RELATIVE_FILES if (session / name).is_file())
files.extend(sorted((session / "scenes").glob("*.scene.json")))
return files
def snapshot(session: Path, reason: str, source_revision: str | None = None) -> str:
versions = session / "versions"; versions.mkdir(parents=True, exist_ok=True)
revision = next_revision(versions)
target = versions / revision; target.mkdir()
records = []
for source in tracked_files(session):
rel = source.relative_to(session)
destination = target / rel
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
records.append({"path": str(rel), "sha256": sha256(source), "size": source.stat().st_size})
manifest = {
"revision": revision,
"created_at": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"source_revision": source_revision,
"files": records,
}
(target / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
metadata_path = session / "metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["current_revision"] = revision
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return revision
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
snap = sub.add_parser("snapshot"); snap.add_argument("--session", required=True); snap.add_argument("--reason", required=True)
listing = sub.add_parser("list"); listing.add_argument("--session", required=True)
rollback = sub.add_parser("rollback"); rollback.add_argument("--session", required=True); rollback.add_argument("--revision", required=True)
args = parser.parse_args()
session = Path(args.session).resolve(); versions = session / "versions"
if args.command == "snapshot":
print(json.dumps({"revision": snapshot(session, args.reason)}, ensure_ascii=False)); return
if args.command == "list":
rows = [json.loads((p / "manifest.json").read_text(encoding="utf-8")) for p in sorted(versions.glob("r[0-9][0-9][0-9][0-9]")) if (p / "manifest.json").is_file()]
print(json.dumps(rows, ensure_ascii=False)); return
target = versions / args.revision
manifest_path = target / "manifest.json"
if not manifest_path.is_file():
raise SystemExit(f"revision not found: {args.revision}")
before = snapshot(session, f"automatic snapshot before rollback to {args.revision}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
target_paths = {row["path"] for row in manifest["files"]}
for current in tracked_files(session):
if str(current.relative_to(session)) not in target_paths:
current.unlink()
for row in manifest["files"]:
source = target / row["path"]
destination = session / row["path"]
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
new_revision = snapshot(session, f"rollback to {args.revision}", source_revision=args.revision)
print(json.dumps({"status": "rolled_back", "target": args.revision, "safety_snapshot": before, "current_revision": new_revision}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Select exactly one PPT production route from a structured request."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROUTES = {
"image-generation": "references/planning-workflow.md",
"native-editable-deck": "references/native-editable-workflow.md",
"element-rebuild": "references/semantic-replica-workflow.md",
"svg-redraw": "SKILL.md#路径-csvg-拆解",
"native-template-fill": "references/native-template-fill-workflow.md",
}
VALID_VALUES = {
"delivery_type": {"image-pptx", "editable-pptx", "pdf", "png", "svg", "pptx", "unspecified"},
"operation": {"create", "fill", "rebuild", "enhance"},
"input_kind": {"topic", "document", "pptx-template", "pptx-finished", "slide-images", "mixed"},
"editability": {"image", "editable", "unspecified"},
"visual_asset_policy": {"native-only", "native-image-assisted", "image-led-editable"},
"authoring_mode": {"slides-plan", "markdown-canvas"},
"editor_workflow_mode": {"direct-build", "canvas-first"},
}
DEFAULT_VALUES = {
"delivery_type": "unspecified",
"operation": "create",
"input_kind": "topic",
"editability": "unspecified",
"visual_asset_policy": "native-image-assisted",
"authoring_mode": "markdown-canvas",
"editor_workflow_mode": "canvas-first",
}
def truthy(value: object) -> bool:
return value is True
def decide(request: dict[str, object]) -> dict[str, object]:
delivery = str(request.get("delivery_type") or "unspecified")
operation = str(request.get("operation") or "create")
input_kind = str(request.get("input_kind") or "topic")
editability = str(request.get("editability") or "unspecified")
visual_asset_policy = str(request.get("visual_asset_policy") or "native-image-assisted")
authoring_mode = str(request.get("authoring_mode") or "markdown-canvas")
editor_workflow_mode = str(request.get("editor_workflow_mode") or "canvas-first")
has_source_pptx = truthy(request.get("has_source_pptx")) or input_kind in {"pptx-template", "pptx-finished"}
has_new_content = truthy(request.get("has_new_content"))
has_reference_slides = truthy(request.get("has_reference_slides")) or input_kind == "slide-images"
preserve_native = truthy(request.get("preserve_native_design"))
template_fill = truthy(request.get("explicit_template_fill")) or operation == "fill"
invalid_fields = {
field: value
for field, allowed in VALID_VALUES.items()
if (value := str(request.get(field) or DEFAULT_VALUES[field])) not in allowed
}
if invalid_fields:
return {
"schema_version": 1,
"status": "BLOCKED",
"route": None,
"authority": None,
"reason_codes": ["invalid_request_value"],
"missing_prerequisites": [],
"blocking_question": None,
"invalid_fields": invalid_fields,
}
reasons: list[str] = []
missing: list[str] = []
question = None
route = None
status = "PASS"
if delivery == "pptx" or (delivery == "unspecified" and truthy(request.get("requests_pptx")) and editability == "unspecified"):
status = "NEEDS_INPUT"
reasons.append("pptx_editability_ambiguous")
question = "需要图片型 PPTX还是可在 PowerPoint 中逐字逐对象编辑的 PPTX"
elif operation == "enhance":
status = "BLOCKED"
reasons.append("native_enhancement_not_implemented")
missing.append("notes/audio/animation enhancement route")
elif template_fill or (has_source_pptx and preserve_native and has_new_content):
route = "native-template-fill"
reasons.append("raw_pptx_plus_native_fill_intent")
if not has_source_pptx:
missing.append("source_pptx")
if not has_new_content:
missing.append("new_content")
elif delivery == "svg":
route = "svg-redraw"
reasons.append("svg_delivery_requested")
if not has_reference_slides:
missing.append("reference_slide_image")
elif editability == "editable" or delivery == "editable-pptx":
if operation == "create" and not has_reference_slides:
route = "native-editable-deck"
reasons.append("new_native_editable_deck_requested")
else:
route = "element-rebuild"
reasons.append("reference_driven_object_editability_required")
if not has_reference_slides:
missing.append("reference_slide_image")
elif editability == "image" or delivery in {"image-pptx", "pdf", "png"}:
route = "image-generation"
reasons.append("visual_delivery_without_object_editability")
elif has_reference_slides and operation == "rebuild":
status = "NEEDS_INPUT"
reasons.append("rebuild_delivery_ambiguous")
question = "重建后需要可编辑 PPTX还是 SVG"
elif operation == "create" and input_kind in {"topic", "document", "mixed"} and delivery == "unspecified":
route = "native-editable-deck"
reasons.append("new_presentation_defaults_to_markdown_canvas")
else:
status = "NEEDS_INPUT"
reasons.append("delivery_not_resolved")
question = "最终需要图片型 PPTX、可编辑 PPTX还是 SVG"
if missing:
status = "BLOCKED"
return {
"schema_version": 1,
"status": status,
"route": route,
"authority": ROUTES.get(route) if route else None,
"reason_codes": reasons,
"missing_prerequisites": missing,
"blocking_question": question,
"visual_asset_policy": visual_asset_policy if route == "native-editable-deck" else None,
"authoring_mode": authoring_mode if route == "native-editable-deck" else None,
"editor_workflow_mode": editor_workflow_mode if route == "native-editable-deck" else None,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--request", required=True, help="Structured request JSON.")
parser.add_argument("--session", help="Optional initialized deck session to update.")
parser.add_argument("--out", help="Decision report path; defaults to session/reports/route-decision.json.")
args = parser.parse_args()
request_path = Path(args.request).resolve()
request = json.loads(request_path.read_text(encoding="utf-8"))
result = decide(request)
canonical = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
result["request_fingerprint"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
result["request"] = request
session = Path(args.session).resolve() if args.session else None
if args.out:
out = Path(args.out).resolve()
elif session:
out = session / "reports" / "route-decision.json"
else:
out = request_path.with_name("route-decision.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if session:
metadata_path = session / "metadata.json"
if not metadata_path.is_file():
raise SystemExit(f"metadata.json missing: {metadata_path}")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["route"] = result["route"]
metadata["route_status"] = result["status"]
metadata["route_report"] = str(out.relative_to(session))
if result.get("visual_asset_policy"):
metadata["visual_asset_policy"] = result["visual_asset_policy"]
if result.get("authoring_mode"):
metadata["authoring_mode"] = result["authoring_mode"]
metadata["content_source"] = "slides.md" if result["authoring_mode"] == "markdown-canvas" else "slides_plan.md"
if result.get("editor_workflow_mode"):
metadata["editor_workflow_mode"] = result["editor_workflow_mode"]
metadata["style_selection_status"] = "pending" if result["editor_workflow_mode"] == "canvas-first" else "auto-selected"
metadata["editor_export_approval"] = "pending" if result["editor_workflow_mode"] == "canvas-first" else "auto-proceed"
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
if result["status"] == "BLOCKED":
raise SystemExit(2)
if result["status"] == "NEEDS_INPUT":
raise SystemExit(3)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Run the single final quality gate for every supported PPT route."""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from lxml import etree
from validate_ooxml_namespaces import validate_package
def read_json(path: Path, errors: list[str], code: str) -> dict[str, object] | None:
if not path.is_file():
errors.append(f"{code}:missing:{path}")
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"{code}:invalid:{exc}")
return None
if not isinstance(value, dict):
errors.append(f"{code}:not_object:{path}")
return None
return value
def pptx_slide_count(path: Path) -> int:
if not zipfile.is_zipfile(path):
return 0
with zipfile.ZipFile(path) as archive:
root = etree.fromstring(archive.read("ppt/presentation.xml"))
namespace = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
return len(root.xpath("./p:sldIdLst/p:sldId", namespaces=namespace))
def status_is_pass(report: dict[str, object] | None) -> bool:
return bool(report) and str(report.get("status", "")).upper() in {"PASS", "OK"}
def check_final_render(session: Path, artifact: Path, metadata: dict[str, object], errors: list[str], warnings: list[str], checks: list[dict[str, object]]) -> None:
mode = str(metadata.get("gui_validation_mode") or "legacy")
if mode == "legacy":
return
if mode == "never":
warnings.append("final_render:skipped_by_policy")
checks.append({"id": "final_powerpoint_render", "status": "SKIPPED", "mode": mode})
return
report = read_json(session / "reports" / "final-render.json", errors, "final_render")
if not report:
return
status = str(report.get("status", "FAIL")).upper()
checks.append({"id": "final_powerpoint_render", "status": status, "mode": mode, "backend": report.get("selected_backend")})
if status != "PASS":
errors.append(f"final_render:{status.lower()}")
if report.get("validation_stage") != "final":
errors.append("final_render:not_final_stage")
if report.get("gui_validation_mode") != mode:
errors.append("final_render:mode_mismatch")
if report.get("selected_backend") != "powerpoint-macos":
errors.append("final_render:not_target_powerpoint")
try:
rendered_input = Path(str(report.get("input"))).resolve()
except (TypeError, ValueError):
rendered_input = Path()
if rendered_input != artifact:
errors.append("final_render:artifact_mismatch")
try:
rendered_output = Path(str(report.get("output"))).resolve()
except (TypeError, ValueError):
rendered_output = Path()
if not rendered_output.is_file() or not rendered_output.read_bytes().startswith(b"%PDF-"):
errors.append("final_render:output_missing_or_invalid")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--artifact", required=True)
parser.add_argument("--route", choices=("image-generation", "native-editable-deck", "element-rebuild", "svg-redraw", "native-template-fill"))
parser.add_argument("--out")
args = parser.parse_args()
session = Path(args.session).resolve()
artifact = Path(args.artifact).resolve()
errors: list[str] = []
warnings: list[str] = []
checks: list[dict[str, object]] = []
metadata_path = session / "metadata.json"
metadata = read_json(metadata_path, errors, "metadata") or {}
decision = read_json(session / "reports" / "route-decision.json", errors, "route_decision")
route = args.route or str(metadata.get("route") or (decision or {}).get("route") or "")
if route not in {"image-generation", "native-editable-deck", "element-rebuild", "svg-redraw", "native-template-fill"}:
errors.append(f"route:unsupported:{route or 'missing'}")
if decision:
if decision.get("status") != "PASS":
errors.append(f"route_decision:not_pass:{decision.get('status')}")
if decision.get("route") != route:
errors.append("route_decision:mismatch")
if metadata.get("route") != route:
errors.append("metadata_route:mismatch")
if not artifact.is_file():
errors.append(f"artifact:missing:{artifact}")
elif artifact.suffix.lower() == ".pptx":
count = pptx_slide_count(artifact)
checks.append({"id": "pptx_package", "slide_count": count})
if count < 1:
errors.append("artifact:invalid_pptx")
namespace_report = validate_package(artifact)
checks.append({
"id": "ooxml_namespace_integrity",
"status": namespace_report["status"],
"checked_parts": namespace_report["checked_parts"],
"checked_references": namespace_report["checked_references"],
})
if namespace_report["status"] != "PASS":
for item in namespace_report["errors"]:
errors.append(f"ooxml_namespace:{item.get('code')}:{item.get('part', '')}:{item.get('prefix', '')}")
if route in {"native-editable-deck", "element-rebuild", "native-template-fill"} and metadata.get("typography_profile"):
typography = read_json(session / "reports" / "typography-validation.json", errors, "typography_validation")
if typography:
typography_status = str(typography.get("status", "FAIL")).upper()
checks.append({
"id": "typography_validation",
"status": typography_status,
"profile": typography.get("typography_profile"),
"table_profile": typography.get("table_profile"),
})
if typography.get("typography_profile") != metadata.get("typography_profile"):
errors.append("typography_validation:profile_mismatch")
if typography.get("table_profile") != metadata.get("table_profile"):
errors.append("typography_validation:table_profile_mismatch")
if typography_status in {"FAIL", "ERROR", "BLOCKED"}:
errors.append(f"typography_validation:{typography_status.lower()}")
elif typography_status in {"WARN", "NOT_CHECKED"}:
warnings.append(f"typography_validation:{typography_status.lower()}")
outline_mode = metadata.get("outline_review_mode", "legacy")
outline_states = {"approved", "confirmed", "waived"}
if outline_mode == "continuous":
outline_states.add("auto-proceed")
if route in {"image-generation", "native-editable-deck", "element-rebuild"}:
design_quality = read_json(session / "reports" / "design-quality.json", errors, "design_quality")
if design_quality:
design_status = str(design_quality.get("status", "FAIL")).upper()
checks.append({
"id": "anti_ai_slop_design_gate",
"status": design_status,
"error_count": design_quality.get("error_count", 0),
"warning_count": design_quality.get("warning_count", 0),
})
if design_status in {"FAIL", "ERROR", "BLOCKED"}:
errors.append(f"design_quality:{design_status.lower()}")
elif design_status == "WARN":
warnings.append("design_quality:warn")
if route == "image-generation":
if metadata.get("outline_approval") not in outline_states:
errors.append("outline_approval:not_approved")
if metadata.get("smoke_approval") not in {"approved", "confirmed", "skipped", "waived"}:
errors.append("smoke_approval:not_approved")
images = sorted({*session.glob("generated/*.png"), *session.glob("final/*.png")})
checks.append({"id": "final_images", "count": len(images)})
if not images:
errors.append("final_images:missing")
if artifact.suffix.lower() == ".pptx" and images and pptx_slide_count(artifact) != len(images):
errors.append("image_slide_count:mismatch")
elif route == "native-editable-deck":
if metadata.get("editor_workflow_mode") == "canvas-first":
if metadata.get("style_selection_status") != "confirmed":
errors.append("canvas_style:not_confirmed")
if metadata.get("editor_export_approval") != "approved":
errors.append("canvas_export:not_approved")
if metadata.get("outline_approval") not in outline_states:
errors.append("outline_approval:not_approved")
spec = read_json(session / "analysis" / "native_deck_spec.json", errors, "native_deck_spec")
editor_manifest = read_json(session / "reports" / "editor-manifest.json", errors, "editor_manifest") if metadata.get("editor_workflow_mode") == "canvas-first" else None
build_report = read_json(session / "reports" / "native-editable-build.json", errors, "native_editable_build")
visual_policy = str(metadata.get("visual_asset_policy") or "native-image-assisted")
if spec and spec.get("visual_asset_policy") != visual_policy:
errors.append("native_deck_spec:visual_asset_policy_mismatch")
if spec and editor_manifest:
spec_count = len(spec.get("slides", [])) if isinstance(spec.get("slides"), list) else 0
manifest_slides = editor_manifest.get("slides", []) if isinstance(editor_manifest.get("slides"), list) else []
manifest_count = int(editor_manifest.get("slide_count", len(manifest_slides)))
artifact_count = pptx_slide_count(artifact)
checks.append({"id": "canvas_slide_parity", "spec": spec_count, "manifest": manifest_count, "pptx": artifact_count})
if not (spec_count == manifest_count == len(manifest_slides) == artifact_count):
errors.append(f"canvas_slide_parity:mismatch:{spec_count}:{manifest_count}:{len(manifest_slides)}:{artifact_count}")
if build_report:
if not status_is_pass(build_report):
errors.append("native_editable_build:not_pass")
if build_report.get("visual_asset_policy") != visual_policy:
errors.append("native_editable_build:visual_asset_policy_mismatch")
if visual_policy == "image-led-editable" and int(build_report.get("image2_assets", 0)) < 1:
errors.append("native_editable_build:image2_asset_required")
checks.append({
"id": "native_editable_objects",
"native_text_objects": build_report.get("native_text_objects", 0),
"native_shapes": build_report.get("native_shapes", 0),
"native_tables": build_report.get("native_tables", 0),
"office_charts": build_report.get("office_charts", 0),
"image_objects": build_report.get("image_objects", 0),
"image2_assets": build_report.get("image2_assets", 0),
})
font_report = read_json(session / "reports" / "font-validation.json", errors, "font_validation")
if font_report and not status_is_pass(font_report):
errors.append("font_validation:not_pass")
editability = read_json(session / "reports" / "editability-audit.json", errors, "editability_audit")
if editability:
if str(editability.get("status", "PASS")).upper() not in {"PASS", "OK"}:
errors.append("editability_audit:not_pass")
if "deck_looks_image_only" in editability.get("deck_flags", []):
errors.append("editability_audit:image_only")
if isinstance(metadata.get("editor"), dict) and metadata["editor"].get("status") == "rebuild_required":
errors.append("editor_canvas:changes_not_rebuilt")
check_final_render(session, artifact, metadata, errors, warnings, checks)
elif route == "element-rebuild":
preflight = read_json(session / "reports" / "preflight.json", errors, "preflight")
if preflight and preflight.get("status") == "BLOCKED":
errors.append("preflight:blocked")
elif preflight and preflight.get("status") == "WARN":
warnings.append("preflight:warn")
font_report = read_json(session / "reports" / "font-validation.json", errors, "font_validation")
if font_report and not status_is_pass(font_report):
errors.append("font_validation:not_pass")
build_report = read_json(session / "reports" / "build_report.json", errors, "build_report")
if build_report:
if build_report.get("status") == "FAIL":
errors.append("build_report:fail")
error_count = ((build_report.get("layout_qa") or {}) if isinstance(build_report.get("layout_qa"), dict) else {}).get("error_count")
if error_count not in {0, None}:
errors.append(f"layout_qa:error_count:{error_count}")
editability = read_json(session / "reports" / "editability-audit.json", errors, "editability_audit")
if editability:
if str(editability.get("status", "PASS")).upper() not in {"PASS", "OK"}:
errors.append("editability_audit:not_pass")
if "deck_looks_image_only" in editability.get("deck_flags", []):
errors.append("editability_audit:image_only")
semantic_report = session / "reports" / "semantic-validation.md"
if not semantic_report.is_file():
errors.append(f"semantic_validation:missing:{semantic_report}")
elif "- Result status: PASS" not in semantic_report.read_text(encoding="utf-8"):
errors.append("semantic_validation:not_pass")
check_final_render(session, artifact, metadata, errors, warnings, checks)
elif route == "svg-redraw":
svgs = sorted(session.glob("final/*.svg"))
checks.append({"id": "final_svg", "count": len(svgs)})
if not svgs:
errors.append("final_svg:missing")
validation = read_json(session / "reports" / "svg-validation.json", errors, "svg_validation")
if validation and not status_is_pass(validation):
errors.append("svg_validation:not_pass")
elif route == "native-template-fill":
plan = read_json(session / "analysis" / "fill_plan.json", errors, "fill_plan")
if plan and plan.get("status") != "confirmed":
errors.append("fill_plan:not_confirmed")
check_report = read_json(session / "analysis" / "check_report.json", errors, "fill_check")
if check_report and int(check_report.get("error_count", 0)) != 0:
errors.append(f"fill_check:error_count:{check_report.get('error_count')}")
validation = read_json(session / "reports" / "native-template-validation.json", errors, "native_template_validation")
if validation and not status_is_pass(validation):
errors.append("native_template_validation:not_pass")
check_final_render(session, artifact, metadata, errors, warnings, checks)
status = "BLOCKED" if errors else "WARN" if warnings else "PASS"
report = {
"schema_version": 1,
"status": status,
"route": route,
"artifact": str(artifact),
"checks": checks,
"errors": errors,
"warnings": warnings,
}
out = Path(args.out).resolve() if args.out else session / "reports" / "quality-gate.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if metadata_path.is_file():
metadata["final_qa"] = status.lower()
if metadata.get("gui_validation_mode") == "never":
metadata["final_powerpoint_validation"] = "skipped"
elif any(check.get("id") == "final_powerpoint_render" and check.get("status") == "PASS" for check in checks):
metadata["final_powerpoint_validation"] = "pass"
elif metadata.get("gui_validation_mode") in {"final-only", "eager"}:
metadata["final_powerpoint_validation"] = "blocked"
metadata["quality_gate_report"] = str(out.relative_to(session))
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if status == "BLOCKED":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Resolve presentation style families and their selectable visual variants."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
def load_catalog() -> dict[str, Any]:
value = json.loads((ROOT / "styles" / "catalog.json").read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("styles/catalog.json must contain an object")
return value
def resolve_style(style_id: str, variant_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
catalog = load_catalog()
style = next((row for row in catalog.get("styles", []) if row.get("id") == style_id), None)
if not style:
raise ValueError(f"unknown style_id: {style_id}")
variants = style.get("variants") or []
resolved_variant_id = variant_id or style.get("default_variant")
variant = next((row for row in variants if row.get("id") == resolved_variant_id), None)
if not variant:
raise ValueError(f"unknown style variant: {style_id}/{resolved_variant_id}")
return style, variant
def merged_design_tokens(style_id: str, variant_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
style, variant = resolve_style(style_id, variant_id)
layout_data = json.loads((ROOT / "styles" / style_id / "layouts.json").read_text(encoding="utf-8"))
tokens = dict(layout_data.get("design_tokens", {}))
tokens.update(variant.get("design_tokens", {}))
return style, variant, tokens
def flattened_presets() -> list[dict[str, Any]]:
presets: list[dict[str, Any]] = []
for style in load_catalog().get("styles", []):
for variant in style.get("variants", []):
_, _, tokens = merged_design_tokens(str(style["id"]), str(variant["id"]))
presets.append({
"preset_id": f"{style['id']}--{variant['id']}",
"style_id": style["id"],
"variant_id": variant["id"],
"family_name": style["name"],
"name": variant["name"],
"description": variant.get("description", ""),
"best_for": style.get("best_for", []),
"typography_profile": style.get("typography_profile"),
"table_profile": style.get("table_profile"),
"design_tokens": tokens,
})
return presets

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Extract editable PowerPoint objects into unified scene element v2 canvas records."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN
SLIDE_W = 13.333333
SLIDE_H = 7.5
CANVAS_W = 1920
CANVAS_H = 1080
def load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def save(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def canonical_hash(value: object) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def object_id(shape: Any) -> str:
return str(getattr(shape, "name", "")).split(" [pf-role=", 1)[0]
def object_role(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
return name.split("[pf-role=", 1)[1].rstrip("]") if "[pf-role=" in name else None
def rgb_value(color: Any) -> str | None:
try:
rgb = color.rgb
return str(rgb) if rgb is not None else None
except (AttributeError, TypeError, ValueError):
return None
def first_run(shape: Any) -> Any | None:
if not getattr(shape, "has_text_frame", False):
return None
for paragraph in shape.text_frame.paragraphs:
if paragraph.runs:
return paragraph.runs[0]
return None
def shape_style(shape: Any) -> dict[str, Any]:
style: dict[str, Any] = {}
try:
fill = rgb_value(shape.fill.fore_color)
if fill:
style["fill"] = fill
except (AttributeError, TypeError):
pass
try:
line = rgb_value(shape.line.color)
if line:
style["line"] = line
if shape.line.width:
style["line_width_pt"] = round(float(shape.line.width.pt), 2)
except (AttributeError, TypeError):
pass
run = first_run(shape)
if run is not None:
if run.font.size:
style["font_size_pt"] = round(float(run.font.size.pt), 2)
if run.font.name:
style["font_family"] = run.font.name
font_color = rgb_value(run.font.color)
if font_color:
style["font_color"] = font_color
if run.font.bold is not None:
style["bold"] = bool(run.font.bold)
paragraph = run._parent
style["align"] = {
PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right", PP_ALIGN.JUSTIFY: "justify",
}.get(paragraph.alignment, "left")
try:
anchor = shape.text_frame.vertical_anchor
style["vertical_align"] = {1: "top", 3: "middle", 4: "bottom"}.get(int(anchor), "middle") if anchor is not None else "middle"
except (AttributeError, TypeError, ValueError):
pass
return style
def shape_kind(shape: Any) -> str:
if getattr(shape, "has_table", False):
return "table"
if getattr(shape, "has_chart", False):
return "chart"
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
return "image"
if shape.shape_type == MSO_SHAPE_TYPE.LINE:
return "connector"
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
return "group"
if getattr(shape, "has_text_frame", False) and str(shape.text).strip():
return "text"
return "shape"
def shape_geometry(shape: Any) -> str | None:
try:
value = shape.auto_shape_type
name = getattr(value, "name", None)
if name:
return str(name).lower()
text = str(value).split(" ", 1)[0].lower()
return text if text and text != "none" else None
except (AttributeError, TypeError, ValueError):
return None
def source_binding(slide_index: int, shape_id: str) -> dict[str, str] | None:
suffix = re.sub(r"^s\d{3}_", "", shape_id)
direct = {
"hero": "title", "title": "title", "closing_title": "title",
"subtitle": "subtitle", "message": "key_message", "closing_message": "key_message",
}
if suffix in direct:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{direct[suffix]}"}
patterns = (
(r"card_(\d+)_(title|body)$", "cards"),
(r"step_(\d+)_(title|body)$", "steps"),
(r"metric_(\d+)_(value|label)$", "metrics"),
(r"layer_(\d+)_(title|body)$", "layers"),
)
for pattern, collection in patterns:
match = re.fullmatch(pattern, suffix)
if match:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{collection}/{int(match.group(1)) - 1}/{match.group(2)}"}
match = re.fullmatch(r"compare_(\d+)_title", suffix)
if match:
side = "left" if int(match.group(1)) == 1 else "right"
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/{side}/title"}
match = re.fullmatch(r"action_(\d+)_text", suffix)
if match:
return {"document": "analysis/native_deck_spec.json", "path": f"/slides/{slide_index}/actions/{int(match.group(1)) - 1}"}
return None
def capabilities(kind: str) -> list[str]:
if kind == "text":
return ["text", "geometry", "style", "rotation", "z-order"]
if kind in {"shape", "connector"}:
return ["geometry", "style", "rotation", "z-order"]
if kind in {"image", "chart", "table", "group"}:
return ["geometry", "rotation", "z-order"]
return ["geometry"]
def element_from_shape(shape: Any, slide_index: int, z_index: int) -> dict[str, Any] | None:
shape_id = object_id(shape)
if not re.fullmatch(r"s\d{3}_.+", shape_id):
return None
x = float(shape.left.inches) / SLIDE_W * CANVAS_W
y = float(shape.top.inches) / SLIDE_H * CANVAS_H
w = max(1.0, float(shape.width.inches) / SLIDE_W * CANVAS_W)
h = max(1.0, float(shape.height.inches) / SLIDE_H * CANVAS_H)
kind = shape_kind(shape)
element: dict[str, Any] = {
"id": shape_id,
"type": kind,
"bbox": [round(x, 2), round(y, 2), round(w, 2), round(h, 2)],
"rotation": round(float(getattr(shape, "rotation", 0) or 0), 2),
"z_index": z_index,
"editable": True,
"locked": False,
"capabilities": capabilities(kind),
"role": object_role(shape),
"style": shape_style(shape),
}
if kind == "text":
element["text"] = str(shape.text)
geometry = shape_geometry(shape)
if geometry:
element["geometry"] = geometry
binding = source_binding(slide_index, shape_id)
if binding:
element["source_binding"] = binding
return element
def refresh_hash(scene: dict[str, Any]) -> None:
hashable = dict(scene)
hashable.pop("revision", None)
dependencies = dict(scene.get("dependencies", {})); dependencies.pop("scene_hash", None)
hashable["dependencies"] = dependencies
scene.setdefault("dependencies", {})["scene_hash"] = canonical_hash(hashable)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session", required=True)
parser.add_argument("--pptx", required=True)
parser.add_argument("--report")
args = parser.parse_args()
session = Path(args.session).resolve()
pptx = Path(args.pptx).resolve()
presentation = Presentation(pptx)
scenes_dir = session / "scenes"; scenes_dir.mkdir(parents=True, exist_ok=True)
counts: list[dict[str, int]] = []
for slide_index, slide in enumerate(presentation.slides):
number = slide_index + 1
scene_path = scenes_dir / f"slide-{number:03d}.scene.json"
if not scene_path.is_file():
raise SystemExit(f"scene missing: {scene_path}; run compile_scenes.py first")
scene = load(scene_path)
elements = [element for z, shape in enumerate(slide.shapes) if (element := element_from_shape(shape, slide_index, z))]
previous = canonical_hash(scene.get("elements", []))
scene["schema_version"] = 2
scene["canvas"] = {"width": CANVAS_W, "height": CANVAS_H, "unit": "px"}
scene["elements"] = elements
if previous != canonical_hash(elements):
scene["revision"] = int(scene.get("revision", 1)) + 1
refresh_hash(scene)
save(scene_path, scene)
counts.append({"slide_number": number, "element_count": len(elements)})
report = {"schema_version": 1, "status": "PASS", "pptx": str(pptx), "slide_count": len(counts), "slides": counts}
report_path = Path(args.report).resolve() if args.report else session / "reports" / "canvas-sync.json"
save(report_path, report)
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Run the pre-build Anti-AI-slop design gate for presentation sessions."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
NOTE_MARKER = re.compile(r"(?:设计说明|讲者备注|演讲备注|配图建议|排版说明|speaker\s*notes?|design\s*notes?|prompt\s*:)", re.I)
HARD_EFFECT_KEYS = {"shadow": "default-shadow", "gradient": "default-gradient"}
def load(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def finding(code: str, severity: str, message: str, slide_number: int | None = None, element_ids: list[str] | None = None) -> dict[str, Any]:
return {"code": code, "severity": severity, "slide_number": slide_number, "message": message, "element_ids": element_ids or []}
def iter_effects(value: Any, path: str = ""):
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}/{key}"
disabled = child is None or child is False or (isinstance(child, str) and child.lower() in {"none", "off"})
if key in HARD_EFFECT_KEYS and not disabled:
yield HARD_EFFECT_KEYS[key], child_path
yield from iter_effects(child, child_path)
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_effects(child, f"{path}/{index}")
def iter_slide_text(value: Any, path: str = ""):
if isinstance(value, dict):
for key, child in value.items():
if key in {"visual_slot", "prompt", "prompt_record", "asset_path", "design_exceptions"}:
continue
yield from iter_slide_text(child, f"{path}/{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_slide_text(child, f"{path}/{index}")
elif isinstance(value, str):
yield path, value
def area_ratio(bbox: list[float], width: float, height: float) -> float:
return float(bbox[2]) * float(bbox[3]) / max(1.0, width * height)
def contains(outer: list[float], inner: list[float]) -> bool:
margin = 2.0
return (
inner[0] >= outer[0] - margin and inner[1] >= outer[1] - margin
and inner[0] + inner[2] <= outer[0] + outer[2] + margin
and inner[1] + inner[3] <= outer[1] + outer[3] + margin
)
def analyze_design(spec: dict[str, Any], scenes: list[dict[str, Any]]) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
for code, path in iter_effects(spec):
findings.append(finding(code, "ERROR", f"禁止默认启用阴影或渐变:{path}"))
for slide in spec.get("slides", []):
number = int(slide.get("slide_number", 0)) or None
cards = slide.get("cards") if isinstance(slide.get("cards"), list) else []
if len(cards) > 4:
findings.append(finding("meaningless-cardification", "WARN", f"同页出现 {len(cards)} 个卡片;应确认它们确有同级语义,或改用分区、流程、表格。", number))
for path, text in iter_slide_text(slide):
if NOTE_MARKER.search(text):
findings.append(finding("design-notes-in-body", "ERROR", f"页面正文包含设计说明或讲者提示:{path}", number))
visual = slide.get("visual_slot")
if isinstance(visual, dict):
bbox = visual.get("bbox_in")
if isinstance(bbox, list) and len(bbox) == 4 and area_ratio([float(v) for v in bbox], 13.333333, 7.5) >= 0.85:
findings.append(finding("full-slide-movable-image", "ERROR", "独立图片覆盖整页或近整页;可编辑模式禁止用可移动图片冒充页面背景。", number))
for scene in scenes:
number = int(scene.get("slide_number", 0)) or None
canvas = scene.get("canvas", {})
width, height = float(canvas.get("width", 1920)), float(canvas.get("height", 1080))
elements = scene.get("elements", []) if isinstance(scene.get("elements"), list) else []
rounded = [row for row in elements if str(row.get("geometry", "")).lower() in {"rounded_rectangle", "round_rect", "rounded-rectangle"}]
if len(rounded) > 5:
findings.append(finding("excessive-rounded-rectangles", "WARN", f"本页包含 {len(rounded)} 个圆角矩形;请减少容器层级或改用留白分组。", number, [str(row.get("id")) for row in rounded]))
shapes = [row for row in elements if row.get("type") == "shape" and isinstance(row.get("bbox"), list)]
texts = [row for row in elements if row.get("type") == "text" and isinstance(row.get("bbox"), list)]
for row in elements:
bbox = row.get("bbox")
if row.get("type") == "image" and isinstance(bbox, list) and area_ratio(bbox, width, height) >= 0.85:
findings.append(finding("full-slide-movable-image", "ERROR", "画布中存在覆盖整页或近整页的独立图片对象。", number, [str(row.get("id"))]))
if row.get("type") == "shape" and isinstance(bbox, list):
short, long = min(float(bbox[2]), float(bbox[3])), max(float(bbox[2]), float(bbox[3]))
if short <= 18 and long / max(short, 1.0) >= 12:
findings.append(finding("unjustified-accent-strip", "WARN", "检测到窄边强调条;若它不是坐标轴、时间线或有语义的分隔线,请删除。", number, [str(row.get("id"))]))
for shape in shapes:
contained = [text for text in texts if contains(shape["bbox"], text["bbox"])]
if contained:
findings.append(finding("shape-with-overlay-textbox", "WARN", "形状上叠加了独立文本框;优先把文字写入形状本身,或使用无容器的排版分组。", number, [str(shape.get("id")), *[str(row.get("id")) for row in contained]]))
for code, path in iter_effects(scene):
findings.append(finding(code, "ERROR", f"禁止默认启用阴影或渐变:{path}", number))
exceptions = spec.get("design_exceptions", []) if isinstance(spec.get("design_exceptions"), list) else []
for item in findings:
waiver = next((row for row in exceptions if row.get("code") == item["code"] and row.get("slide_number") in {None, item["slide_number"]} and str(row.get("reason", "")).strip()), None)
if waiver:
item["waived"] = True
item["waiver_reason"] = waiver["reason"]
active = [item for item in findings if not item.get("waived")]
errors = [item for item in active if item["severity"] == "ERROR"]
warnings = [item for item in active if item["severity"] == "WARN"]
return {
"schema_version": 1,
"status": "FAIL" if errors else "WARN" if warnings else "PASS",
"policy": "anti-ai-slop.v1",
"error_count": len(errors),
"warning_count": len(warnings),
"waived_count": len(findings) - len(active),
"findings": findings,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--session")
parser.add_argument("--spec")
parser.add_argument("--scene-dir")
parser.add_argument("--out")
parser.add_argument("--strict-warnings", action="store_true")
args = parser.parse_args()
session = Path(args.session).resolve() if args.session else None
spec_path = Path(args.spec).resolve() if args.spec else None
if not spec_path and session:
native = session / "analysis" / "native_deck_spec.json"
spec_path = native if native.is_file() else session / "prompts.json"
if not spec_path or not spec_path.is_file():
raise SystemExit("native deck spec is required")
scene_dir = Path(args.scene_dir).resolve() if args.scene_dir else (session / "scenes" if session else None)
scenes = [load(path) for path in sorted(scene_dir.glob("*.scene.json"))] if scene_dir and scene_dir.is_dir() else []
report = analyze_design(load(spec_path), scenes)
out = Path(args.out).resolve() if args.out else (session / "reports" / "design-quality.json" if session else None)
if out:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] == "FAIL" or (args.strict_warnings and report["status"] == "WARN"):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Validate namespace prefixes referenced by OOXML compatibility attributes."""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from lxml import etree
MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"
PREFIX_LIST_ATTRIBUTES = {"Requires", "Ignorable"}
QNAME_LIST_ATTRIBUTES = {"PreserveAttributes", "PreserveElements", "ProcessContent"}
def attribute_local_name(name: str) -> str:
return etree.QName(name).localname if name.startswith("{") else name
def referenced_prefixes(element: etree._Element) -> set[str]:
prefixes: set[str] = set()
for name, value in element.attrib.items():
local = attribute_local_name(name)
if local in PREFIX_LIST_ATTRIBUTES:
prefixes.update(token for token in value.split() if token)
elif local in QNAME_LIST_ATTRIBUTES:
prefixes.update(token.split(":", 1)[0] for token in value.split() if ":" in token)
return prefixes
def validate_package(pptx: Path) -> dict[str, object]:
errors: list[dict[str, object]] = []
checked_parts = 0
checked_references = 0
if not zipfile.is_zipfile(pptx):
return {"status": "FAIL", "pptx": str(pptx), "checked_parts": 0, "checked_references": 0, "errors": [{"code": "not_zip_package"}]}
with zipfile.ZipFile(pptx) as package:
for name in package.namelist():
if not name.endswith((".xml", ".rels", ".vml")):
continue
checked_parts += 1
try:
root = etree.fromstring(package.read(name))
except etree.XMLSyntaxError as exc:
errors.append({"code": "xml_parse_error", "part": name, "message": str(exc)})
continue
tree = root.getroottree()
for element in root.iter():
prefixes = referenced_prefixes(element)
checked_references += len(prefixes)
for prefix in sorted(prefixes):
if prefix not in element.nsmap:
errors.append({
"code": "compatibility_prefix_undeclared",
"part": name,
"path": tree.getpath(element),
"prefix": prefix,
})
return {
"status": "FAIL" if errors else "PASS",
"pptx": str(pptx),
"checked_parts": checked_parts,
"checked_references": checked_references,
"errors": errors,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--out")
args = parser.parse_args()
report = validate_package(Path(args.pptx).resolve())
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] != "PASS":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Validate explicit East Asian font declarations in a PPTX package."""
from __future__ import annotations
import argparse
import json
import re
import zipfile
from pathlib import Path
from lxml import etree
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
NS = {"a": A_NS, "p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
ROLE = re.compile(r"\[pf-role=([a-z0-9_-]+)]", re.I)
HEADING_ROLES = {"hero", "section_title", "section-title", "title", "page_title", "page-title", "subtitle", "header", "minor_title", "minor-title"}
def run_role(run) -> str | None:
shape = next(iter(run.xpath("ancestor::p:sp[1]", namespaces=NS)), None)
if shape is None:
return None
names = shape.xpath("./p:nvSpPr/p:cNvPr/@name", namespaces=NS)
match = ROLE.search(names[0] if names else "")
return match.group(1).lower() if match else None
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--font", help="Require this exact East Asian typeface.")
parser.add_argument("--heading-font", help="Expected East Asian font for heading roles and the major theme font.")
parser.add_argument("--body-font", help="Expected East Asian font for body roles and the minor theme font.")
parser.add_argument("--latin-font", help="Require this Latin and complex-script typeface on checked CJK runs.")
parser.add_argument("--out")
args = parser.parse_args()
pptx = Path(args.pptx)
errors: list[dict[str, object]] = []
checked_runs = 0
theme_nodes = 0
with zipfile.ZipFile(pptx) as package:
for name in package.namelist():
if name.startswith("ppt/slides/slide") and name.endswith(".xml"):
root = etree.fromstring(package.read(name))
for run in root.xpath(".//a:r", namespaces=NS):
text = "".join(run.xpath("./a:t/text()", namespaces=NS))
if not CJK.search(text):
continue
checked_runs += 1
role = run_role(run)
expected_ea = args.font or (args.heading_font if role in HEADING_ROLES else args.body_font)
ea_nodes = run.xpath("./a:rPr/a:ea", namespaces=NS)
ea_face = ea_nodes[0].get("typeface") if ea_nodes else None
if not ea_face or (expected_ea and ea_face != expected_ea):
errors.append({"part": name, "text": text, "role": role, "east_asian_typeface": ea_face, "expected": expected_ea})
for tag in ("latin", "cs"):
nodes = run.xpath(f"./a:rPr/a:{tag}", namespaces=NS)
face = nodes[0].get("typeface") if nodes else None
if not face or (args.latin_font and face != args.latin_font):
errors.append({"part": name, "text": text, "role": role, "font_slot": tag, "typeface": face, "expected": args.latin_font})
elif name.startswith("ppt/theme/theme") and name.endswith(".xml"):
root = etree.fromstring(package.read(name))
for branch, expected in (("majorFont", args.font or args.heading_font), ("minorFont", args.font or args.body_font)):
nodes = root.xpath(f".//a:fontScheme/a:{branch}/a:ea", namespaces=NS)
theme_nodes += len(nodes)
for node in nodes:
face = node.get("typeface")
if not face or (expected and face != expected):
errors.append({"part": name, "theme_node": branch, "east_asian_typeface": face, "expected": expected})
if checked_runs == 0:
errors.append({"type": "no_cjk_runs_found"})
if theme_nodes < 2:
errors.append({"type": "missing_major_or_minor_theme_east_asian_font", "count": theme_nodes})
report = {"status": "FAIL" if errors else "PASS", "pptx": str(pptx), "checked_cjk_runs": checked_runs, "theme_east_asian_nodes": theme_nodes, "errors": errors}
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if errors:
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Validate semantic typography tokens and table alignment in a PPTX."""
from __future__ import annotations
import argparse
import json
import math
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
from pptx import Presentation
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PROFILES = ROOT / "styles" / "typography-profiles.json"
DEFAULT_CATALOG = ROOT / "styles" / "catalog.json"
ROLE_MARKER = re.compile(r"\[pf-role=([a-z0-9_-]+)]", re.I)
NUMERIC = re.compile(r"^\s*[¥¥$€£]?\(?[-+]?\d[\d,]*(?:\.\d+)?%?\)?\s*$")
ROLE_SIZE_KEY = {
"hero": "hero",
"section_title": "section_title",
"section-title": "section_title",
"title": "page_title",
"page_title": "page_title",
"page-title": "page_title",
"subtitle": "subtitle",
"header": "minor_title",
"minor_title": "minor_title",
"minor-title": "minor_title",
"body": "body",
"label": "label",
"micro_label": "caption",
"micro-label": "caption",
"caption": "caption",
"table": "table",
}
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected JSON object: {path}")
return value
def select_policies(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any], str, str]:
profiles = read_json(Path(args.profiles))
typography = {row["id"]: row for row in profiles.get("typography_profiles", [])}
tables = {row["id"]: row for row in profiles.get("table_profiles", [])}
profile_id = args.profile
table_id = args.table_profile
if args.style_id:
catalog = read_json(Path(args.catalog))
style = next((row for row in catalog.get("styles", []) if row.get("id") == args.style_id), None)
if not style:
raise ValueError(f"unknown style_id: {args.style_id}")
profile_id = profile_id or style.get("typography_profile")
table_id = table_id or style.get("table_profile")
if not profile_id or profile_id not in typography:
raise ValueError(f"unknown or missing typography profile: {profile_id!r}")
if not table_id or table_id not in tables:
raise ValueError(f"unknown or missing table profile: {table_id!r}")
return typography[profile_id], tables[table_id], profile_id, table_id
def add_finding(store: dict[tuple[str, str], dict[str, Any]], code: str, severity: str, sample: dict[str, Any]) -> None:
key = (code, severity)
finding = store.setdefault(key, {"code": code, "severity": severity, "count": 0, "samples": []})
finding["count"] += 1
if len(finding["samples"]) < 8:
finding["samples"].append(sample)
def role_from_shape(shape: Any) -> str | None:
name = str(getattr(shape, "name", ""))
marker = ROLE_MARKER.search(name)
if marker:
return marker.group(1).lower()
lowered = name.lower()
for token, role in (("subtitle", "subtitle"), ("caption", "caption"), ("label", "label"), ("title", "title"), ("body", "body")):
if token in lowered:
return role
if getattr(shape, "is_placeholder", False):
placeholder = str(shape.placeholder_format.type).upper()
if "SUBTITLE" in placeholder:
return "subtitle"
if "TITLE" in placeholder:
return "title"
if "BODY" in placeholder or "OBJECT" in placeholder or "TEXT" in placeholder:
return "body"
return None
def alignment_name(value: Any) -> str | None:
return {
PP_ALIGN.LEFT: "left",
PP_ALIGN.CENTER: "center",
PP_ALIGN.RIGHT: "right",
PP_ALIGN.JUSTIFY: "justify",
PP_ALIGN.DISTRIBUTE: "distribute",
}.get(value)
def check_run_sizes(
paragraphs: Any,
role: str,
profile: dict[str, Any],
location: dict[str, Any],
findings: dict[tuple[str, str], dict[str, Any]],
counts: dict[str, int],
) -> None:
size_key = ROLE_SIZE_KEY.get(role)
if not size_key:
add_finding(findings, "text_role_unresolved", "not_checked", {**location, "role": role})
return
expected = float(profile["tokens"][size_key])
grid = float(profile.get("font_size_grid", 0.5))
for paragraph_index, paragraph in enumerate(paragraphs):
if paragraph.text and not paragraph.runs:
add_finding(findings, "font_size_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "role": role})
for run_index, run in enumerate(paragraph.runs):
if not run.text.strip():
continue
counts["text_runs"] += 1
if run.font.size is None:
add_finding(findings, "font_size_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "run": run_index, "role": role})
continue
size = float(run.font.size.pt)
grid_units = size / grid
if not math.isclose(grid_units, round(grid_units), abs_tol=0.04):
add_finding(findings, "font_size_off_grid", "warning", {**location, "role": role, "font_size": round(size, 3), "grid": grid})
if size + 0.04 < expected:
add_finding(findings, "font_size_below_token", "warning", {**location, "role": role, "font_size": round(size, 3), "size_key": size_key, "expected": expected})
def check_paragraph_policy(
paragraphs: Any,
role: str,
profile: dict[str, Any],
location: dict[str, Any],
findings: dict[tuple[str, str], dict[str, Any]],
) -> None:
group = "title" if ROLE_SIZE_KEY.get(role) in {"hero", "section_title", "page_title", "subtitle", "minor_title"} else "caption" if ROLE_SIZE_KEY.get(role) in {"label", "caption"} else "body"
expected = profile.get("paragraph", {}).get(group, {})
expected_spacing = expected.get("line_spacing_multiple")
for paragraph_index, paragraph in enumerate(paragraphs):
spacing = paragraph.line_spacing
if spacing is None:
add_finding(findings, "line_spacing_unresolved", "not_checked", {**location, "paragraph": paragraph_index, "role": role})
elif isinstance(spacing, float) and expected_spacing is not None and not math.isclose(spacing, float(expected_spacing), abs_tol=0.03):
add_finding(findings, "line_spacing_mismatch", "warning", {**location, "paragraph": paragraph_index, "role": role, "actual": round(spacing, 3), "expected": expected_spacing})
def expected_cell_alignment(row: int, col: int, text: str, table_policy: dict[str, Any]) -> str:
if row == 0:
return str(table_policy["header_alignment"])
if NUMERIC.fullmatch(text or ""):
return str(table_policy["numeric_alignment"])
if col == 0:
return str(table_policy["index_alignment"])
return str(table_policy["text_alignment"])
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pptx")
parser.add_argument("--profile")
parser.add_argument("--table-profile")
parser.add_argument("--style-id")
parser.add_argument("--profiles", default=str(DEFAULT_PROFILES))
parser.add_argument("--catalog", default=str(DEFAULT_CATALOG))
parser.add_argument("--out")
parser.add_argument("--fail-on-warning", action="store_true")
args = parser.parse_args()
pptx_path = Path(args.pptx).resolve()
findings: dict[tuple[str, str], dict[str, Any]] = {}
counts: dict[str, int] = defaultdict(int)
try:
profile, table_policy, profile_id, table_id = select_policies(args)
presentation = Presentation(pptx_path)
for slide_index, slide in enumerate(presentation.slides, start=1):
for shape in slide.shapes:
location = {"slide": slide_index, "shape": getattr(shape, "name", "")}
if getattr(shape, "has_table", False):
table = shape.table
for row_index, row in enumerate(table.rows):
for col_index, cell in enumerate(row.cells):
counts["table_cells"] += 1
cell_location = {**location, "row": row_index, "column": col_index}
if cell.vertical_anchor is None:
add_finding(findings, "table_vertical_alignment_unresolved", "not_checked", cell_location)
elif cell.vertical_anchor != MSO_ANCHOR.MIDDLE:
add_finding(findings, "table_vertical_alignment_mismatch", "warning", {**cell_location, "expected": "middle"})
expected_alignment = expected_cell_alignment(row_index, col_index, cell.text, table_policy)
for paragraph_index, paragraph in enumerate(cell.text_frame.paragraphs):
actual_alignment = alignment_name(paragraph.alignment)
if actual_alignment is None:
add_finding(findings, "table_horizontal_alignment_unresolved", "not_checked", {**cell_location, "paragraph": paragraph_index, "expected": expected_alignment})
elif actual_alignment != expected_alignment:
add_finding(findings, "table_horizontal_alignment_mismatch", "warning", {**cell_location, "paragraph": paragraph_index, "actual": actual_alignment, "expected": expected_alignment})
if paragraph.level:
add_finding(findings, "table_paragraph_special_indent", "warning", {**cell_location, "paragraph": paragraph_index, "level": paragraph.level})
check_run_sizes(cell.text_frame.paragraphs, "table", profile, cell_location, findings, counts)
continue
if not getattr(shape, "has_text_frame", False) or not shape.text_frame.text.strip():
continue
role = role_from_shape(shape)
if not role:
add_finding(findings, "text_role_unresolved", "not_checked", location)
continue
check_run_sizes(shape.text_frame.paragraphs, role, profile, location, findings, counts)
check_paragraph_policy(shape.text_frame.paragraphs, role, profile, location, findings)
if not counts["text_runs"] and not counts["table_cells"]:
add_finding(findings, "no_typography_content", "not_checked", {"pptx": str(pptx_path)})
findings_list = sorted(findings.values(), key=lambda row: (row["severity"], row["code"]))
warning_count = sum(row["count"] for row in findings_list if row["severity"] == "warning")
not_checked_count = sum(row["count"] for row in findings_list if row["severity"] == "not_checked")
status = "WARN" if warning_count else "NOT_CHECKED" if not_checked_count else "PASS"
report = {
"schema_version": 1,
"status": status,
"pptx": str(pptx_path),
"typography_profile": profile_id,
"table_profile": table_id,
"checked": dict(counts),
"warning_count": warning_count,
"not_checked_count": not_checked_count,
"findings": findings_list,
}
except Exception as exc:
report = {"schema_version": 1, "status": "FAIL", "pptx": str(pptx_path), "errors": [str(exc)]}
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.out:
out = Path(args.out).resolve()
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(payload, encoding="utf-8")
print(payload, end="")
if report["status"] == "FAIL" or (args.fail_on_warning and report["status"] == "WARN"):
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Validate scene files without external jsonschema dependencies."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
ALLOWED_TYPES = {"text", "shape", "line", "connector", "image", "chart", "table", "group", "layout_native", "line_native", "connector_native", "semantic_visual", "imagegen_asset", "provided_asset", "unresolved"}
ALLOWED_CAPABILITIES = {"text", "geometry", "style", "rotation", "z-order", "asset"}
def validate(path: Path) -> list[str]:
errors: list[str] = []
try:
scene = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
return [f"invalid JSON: {exc}"]
required = {"schema_version", "slide_id", "slide_number", "revision", "page_type", "canvas", "content", "elements", "dependencies"}
if missing := required - scene.keys():
errors.append(f"missing keys: {sorted(missing)}")
if scene.get("schema_version") != 2:
errors.append("schema_version must be 2")
if not re.fullmatch(r"slide-\d{3}", str(scene.get("slide_id", ""))):
errors.append("slide_id must match slide-001")
canvas = scene.get("canvas", {})
if not all(isinstance(canvas.get(k), int) and canvas[k] > 0 for k in ("width", "height")):
errors.append("canvas width and height must be positive integers")
seen: set[str] = set()
for idx, element in enumerate(scene.get("elements", [])):
element_id = element.get("id")
if not element_id or element_id in seen:
errors.append(f"element {idx}: missing or duplicate id")
seen.add(element_id)
if element.get("type") not in ALLOWED_TYPES:
errors.append(f"element {element_id}: unsupported type {element.get('type')}")
bbox = element.get("bbox")
if not isinstance(bbox, list) or len(bbox) != 4 or not all(isinstance(v, (int, float)) for v in bbox):
errors.append(f"element {element_id}: bbox must contain four numbers")
elif bbox[2] <= 0 or bbox[3] <= 0:
errors.append(f"element {element_id}: bbox width and height must be positive")
elif bbox[0] < 0 or bbox[1] < 0 or bbox[0] + bbox[2] > canvas.get("width", 0) + 0.1 or bbox[1] + bbox[3] > canvas.get("height", 0) + 0.1:
errors.append(f"element {element_id}: bbox must remain inside canvas")
capabilities = element.get("capabilities", [])
if not isinstance(capabilities, list) or any(value not in ALLOWED_CAPABILITIES for value in capabilities):
errors.append(f"element {element_id}: invalid capabilities")
if not isinstance(element.get("editable"), bool):
errors.append(f"element {element_id}: editable must be boolean")
if not isinstance(element.get("z_index"), int) or element.get("z_index", -1) < 0:
errors.append(f"element {element_id}: z_index must be a non-negative integer")
if not isinstance(element.get("rotation", 0), (int, float)):
errors.append(f"element {element_id}: rotation must be numeric")
deps = scene.get("dependencies", {})
if not all(key in deps for key in ("scene_hash", "prompt_hash", "asset_hashes")):
errors.append("dependencies must include scene_hash, prompt_hash and asset_hashes")
return errors
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="Scene JSON file or session/scenes directory.")
parser.add_argument("--out")
args = parser.parse_args()
target = Path(args.path)
paths = [target] if target.is_file() else sorted(target.glob("*.scene.json"))
if not paths:
raise SystemExit(f"no scene files found: {target}")
results = {str(path): validate(path) for path in paths}
report = {"status": "FAIL" if any(results.values()) else "PASS", "scene_count": len(paths), "results": results}
if args.out:
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if report["status"] == "FAIL":
raise SystemExit(2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Validate a semantic editable PPTX reconstruction."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from io import BytesIO
from pathlib import Path
from zipfile import ZipFile
from PIL import Image
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pptx", required=True, help="PPTX to validate.")
parser.add_argument("--reference", action="append", default=[], help="Reference image to hash-check. Repeat per slide.")
parser.add_argument("--manifest", help="asset_manifest.json path.")
parser.add_argument("--inventory", help="visual_inventory.json path.")
parser.add_argument("--build-report", help="Optional build_report.json with text fitting and layout QA facts.")
parser.add_argument("--out", required=True, help="Markdown validation report path.")
parser.add_argument("--full-slide-size", default="", help="Optional WxH full-slide media size to reject, e.g. 1672x941.")
parser.add_argument("--near-full-slide-ratio", type=float, default=0.85, help="Flag media covering at least this share of full-slide area.")
return parser.parse_args()
def parse_size(value: str) -> tuple[int, int] | None:
if not value:
return None
match = re.fullmatch(r"(\d+)x(\d+)", value)
if not match:
raise SystemExit("--full-slide-size must be WxH")
return int(match.group(1)), int(match.group(2))
def count_slide_objects(zf: ZipFile) -> list[dict]:
slides = sorted(
[n for n in zf.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)],
key=lambda n: int(re.search(r"slide(\d+)", n).group(1)),
)
rows = []
for slide_name in slides:
xml = zf.read(slide_name).decode("utf-8", errors="ignore")
rows.append({
"slide_xml": slide_name,
"picture_objects": xml.count("<p:pic>"),
"shape_objects": xml.count("<p:sp>"),
"text_runs": xml.count("<a:t>"),
})
return rows
def inventory_items(inventory: object) -> list[dict]:
if not isinstance(inventory, dict):
return []
if isinstance(inventory.get("slides"), list):
items = []
for slide in inventory["slides"]:
if isinstance(slide, dict):
items.extend([item for item in slide.get("items", []) if isinstance(item, dict)])
return items
if isinstance(inventory.get("semantic_assets"), list):
items = []
for item in inventory["semantic_assets"]:
if not isinstance(item, dict):
continue
normalized = dict(item)
normalized.setdefault("id", item.get("semantic_unit_id"))
normalized.setdefault("class", item.get("source_type", "imagegen_asset"))
items.append(normalized)
return items
return [item for item in inventory.get("items", []) if isinstance(item, dict)]
def main() -> None:
args = parse_args()
pptx = Path(args.pptx)
reject_size = parse_size(args.full_slide_size)
ref_hashes = {}
for ref in args.reference:
path = Path(ref)
ref_hashes[hashlib.sha256(path.read_bytes()).hexdigest()] = str(path)
errors = []
media = []
exact_ref_hits = []
full_slide_hits = []
near_full_slide_hits = []
svg_media = []
with ZipFile(pptx) as zf:
bad_member = zf.testzip()
if bad_member:
errors.append(f"zip test failed at {bad_member}")
for name in sorted(n for n in zf.namelist() if n.startswith("ppt/media/")):
data = zf.read(name)
sha = hashlib.sha256(data).hexdigest()
dims = None
try:
with Image.open(BytesIO(data)) as img:
dims = img.size
except Exception:
pass
item = {"name": name, "bytes": len(data), "size": list(dims) if dims else None}
media.append(item)
if Path(name).suffix.lower() == ".svg":
svg_media.append(item)
if sha in ref_hashes:
exact_ref_hits.append({"name": name, "reference": ref_hashes[sha]})
if reject_size and dims == reject_size:
full_slide_hits.append(item)
if reject_size and dims:
media_area = dims[0] * dims[1]
slide_area = reject_size[0] * reject_size[1]
if media_area >= slide_area * args.near_full_slide_ratio:
near_full_slide_hits.append(item)
slide_counts = count_slide_objects(zf)
if exact_ref_hits:
errors.append("reference image hash found in PPTX media")
if full_slide_hits:
errors.append(f"full-slide media size found: {reject_size[0]}x{reject_size[1]}")
if near_full_slide_hits:
errors.append("near-full-slide media found")
if svg_media:
errors.append("svg media found in semantic PPTX")
manifest = None
manifest_by_id = {}
if args.manifest:
manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
for idx, item in enumerate(manifest):
if item.get("semantic_unit_count") != 1:
errors.append(f"manifest item {idx} is not semantic_unit_count=1")
if item.get("source_type") not in {"imagegen_asset", "api_generated_asset", "provided_asset"}:
errors.append(f"manifest item {idx} has invalid source_type")
if item.get("source_type") in {"raw_crop", "reference_crop", "screenshot_crop", "placeholder", "prompt_only_asset"}:
errors.append(f"manifest item {idx} has forbidden source_type")
asset_path = item.get("asset_path")
if asset_path:
manifest_path = Path(args.manifest)
resolved = Path(asset_path)
if not resolved.is_absolute():
resolved = manifest_path.parent / resolved
if not resolved.exists():
errors.append(f"manifest item {idx} asset file missing: {asset_path}")
if item.get("semantic_unit_id") in manifest_by_id:
errors.append(f"duplicate manifest semantic_unit_id: {item.get('semantic_unit_id')}")
manifest_by_id[item.get("semantic_unit_id")] = item
inventory = None
semantic_inventory_items = []
if args.inventory:
inventory = json.loads(Path(args.inventory).read_text(encoding="utf-8"))
semantic_inventory_items = [
item for item in inventory_items(inventory)
if item.get("class") in {"imagegen_asset", "api_generated_asset", "provided_asset"}
or item.get("type") in {"imagegen_asset", "api_generated_asset", "provided_asset"}
]
if manifest is not None:
for item in semantic_inventory_items:
item_id = item.get("id")
if item_id not in manifest_by_id:
errors.append(f"inventory semantic item missing manifest entry: {item_id}")
total_picture_objects = sum(row["picture_objects"] for row in slide_counts)
if manifest is not None and total_picture_objects < len(manifest):
errors.append("pptx picture object count is lower than manifest entries")
total_text_runs = sum(row["text_runs"] for row in slide_counts)
build_report = None
layout_qa = None
text_fit_rows = []
if args.build_report:
build_report = json.loads(Path(args.build_report).read_text(encoding="utf-8"))
layout_qa = build_report.get("layout_qa") if isinstance(build_report, dict) else None
if isinstance(layout_qa, dict) and layout_qa.get("error_count", 0):
errors.append("build report layout QA contains errors")
if isinstance(build_report, dict):
text_fit_rows = build_report.get("text_placements", [])[:]
status = "PASS"
if errors:
status = "FAIL"
elif inventory is None or manifest is None or not semantic_inventory_items or total_text_runs == 0:
status = "DRAFT_ONLY"
report = [
"# Semantic PPTX Validation Report",
"",
f"- PPTX: `{pptx}`",
f"- Zip integrity: {'PASS' if not any(e.startswith('zip test') for e in errors) else 'FAIL'}",
f"- Result status: {status}",
f"- Slide count: {len(slide_counts)}",
f"- Media object count: {len(media)}",
f"- Exact reference image hash check: {'PASS' if not exact_ref_hits else 'FAIL'}",
f"- Full-slide media check: {'PASS' if not full_slide_hits else 'FAIL'}",
f"- Near-full-slide media check: {'PASS' if not near_full_slide_hits else 'FAIL'}",
f"- SVG media check: {'PASS' if not svg_media else 'FAIL'}",
f"- Manifest entries: {len(manifest) if manifest is not None else 'not provided'}",
f"- Inventory provided: {'yes' if inventory is not None else 'no'}",
f"- Build report provided: {'yes' if build_report is not None else 'no'}",
f"- Inventory semantic asset items: {len(semantic_inventory_items)}",
f"- PPT picture objects: {total_picture_objects}",
f"- PPT text runs: {total_text_runs}",
"",
"## Slide Object Counts",
]
for row in slide_counts:
report.append(
f"- {row['slide_xml']}: pictures {row['picture_objects']}, shapes {row['shape_objects']}, text runs {row['text_runs']}"
)
report += ["", "## Largest Embedded Media"]
for item in sorted(media, key=lambda m: (m["size"] or [0, 0])[0] * (m["size"] or [0, 0])[1], reverse=True)[:10]:
report.append(f"- {item['name']}: size {item['size']}, bytes {item['bytes']}")
report += ["", "## Text Layout QA"]
if layout_qa:
report.append(f"- Status: {layout_qa.get('status')}")
report.append(f"- Errors: {layout_qa.get('error_count', 0)}")
report.append(f"- Warnings: {layout_qa.get('warning_count', 0)}")
for err in layout_qa.get("errors", [])[:20]:
report.append(f"- ERROR {err.get('type')}: {err}")
for warn in layout_qa.get("warnings", [])[:30]:
report.append(f"- WARN {warn.get('type')}: {warn}")
else:
report.append("- Not provided. Pass `--build-report` to validate text fitting, collisions, z-order, and layout QA.")
if text_fit_rows:
fitted = [row for row in text_fit_rows if row.get("effective_font_size") != row.get("font_size")]
min_font = min((float(row.get("effective_font_size", 999)) for row in text_fit_rows), default=0)
max_overflow = max((
max(float(row.get("overflow_height_ratio", 0)), float(row.get("overflow_width_ratio", 0)))
for row in text_fit_rows
), default=0)
report.append(f"- Native text objects in build report: {len(text_fit_rows)}")
report.append(f"- Auto-fitted text objects: {len(fitted)}")
report.append(f"- Minimum effective font size: {round(min_font, 2)} pt")
report.append(f"- Maximum estimated overflow ratio: {round(max_overflow, 4)}")
report += ["", "## Result"]
if status == "FAIL":
report.extend([f"- FAIL: {err}" for err in errors])
elif status == "DRAFT_ONLY":
report.append("- DRAFT_ONLY: no hard media violation found, but manifest, inventory, semantic assets, or native text evidence is incomplete.")
else:
report.append("- PASS: no reference image hash, rejected full-slide media, SVG media, invalid manifest entries, or manifest/object mismatch found.")
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(report) + "\n", encoding="utf-8")
print(json.dumps({"report": str(out), "status": status, "errors": errors}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Preflight semantic visual-replica inventory, manifest, and anchors."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ALLOWED_CLASSES = {
"text",
"layout_native",
"line_native",
"connector_native",
"imagegen_asset",
"api_generated_asset",
"provided_asset",
"unresolved",
}
SEMANTIC_CLASSES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
ALLOWED_SOURCE_TYPES = {"imagegen_asset", "api_generated_asset", "provided_asset"}
FORBIDDEN_SOURCE_TYPES = {"raw_crop", "reference_crop", "screenshot_crop", "placeholder", "prompt_only_asset"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inventory", required=True, help="visual_inventory.json path.")
parser.add_argument("--manifest", help="asset_manifest.json path.")
parser.add_argument("--anchors", help="asset_anchors.json path.")
parser.add_argument("--stage", choices=["plan", "build"], default="build", help="Validation strictness.")
parser.add_argument("--require-anchors", action="store_true", help="Require every semantic placement to have an anchor.")
parser.add_argument("--out", help="Optional JSON report path.")
return parser.parse_args()
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def as_items(inventory: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(inventory.get("slides"), list):
items: list[dict[str, Any]] = []
for idx, slide in enumerate(inventory["slides"], start=1):
if not isinstance(slide, dict):
continue
slide_no = slide.get("slide", idx)
for item in slide.get("items", []):
if isinstance(item, dict):
item = dict(item)
item.setdefault("slide", slide_no)
items.append(item)
return items
return [item for item in inventory.get("items", []) if isinstance(item, dict)]
def read_optional_list(path_value: str | None) -> list[dict[str, Any]] | None:
if not path_value:
return None
data = read_json(Path(path_value))
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict) and isinstance(data.get("items"), list):
return [item for item in data["items"] if isinstance(item, dict)]
if isinstance(data, dict) and isinstance(data.get("anchors"), list):
return [item for item in data["anchors"] if isinstance(item, dict)]
return []
def valid_bbox(value: Any, allow_zero_axis: bool = False) -> bool:
if not (
isinstance(value, list)
and len(value) == 4
and all(isinstance(v, (int, float)) for v in value)
):
return False
if allow_zero_axis:
return value[2] >= 0 and value[3] >= 0 and (value[2] > 0 or value[3] > 0)
return value[2] > 0 and value[3] > 0
def rel_path(base: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else base / path
def main() -> None:
args = parse_args()
inventory_path = Path(args.inventory)
inventory = read_json(inventory_path)
base_dir = inventory_path.parent
errors: list[str] = []
warnings: list[str] = []
slide_size = inventory.get("slide_size_px") or inventory.get("canvas_px")
if not (
isinstance(slide_size, list)
and len(slide_size) == 2
and all(isinstance(v, (int, float)) and v > 0 for v in slide_size)
):
errors.append("inventory missing valid slide_size_px")
slide_size = [1, 1]
items = as_items(inventory)
seen_ids: set[str] = set()
semantic_ids: list[str] = []
for idx, item in enumerate(items):
item_id = item.get("id")
if not item_id:
errors.append(f"inventory item {idx} missing id")
continue
if item_id in seen_ids:
errors.append(f"duplicate inventory item id: {item_id}")
seen_ids.add(str(item_id))
cls = item.get("class") or item.get("type")
if cls not in ALLOWED_CLASSES:
errors.append(f"{item_id} has invalid class: {cls}")
if cls == "unresolved" and args.stage == "build":
errors.append(f"{item_id} is unresolved during build stage")
bbox = item.get("bbox_px") or item.get("bbox")
if not valid_bbox(bbox, allow_zero_axis=cls in {"line_native", "connector_native"}):
errors.append(f"{item_id} missing valid bbox_px")
elif slide_size != [1, 1]:
x, y, w, h = bbox
if x < -2 or y < -2 or x + w > float(slide_size[0]) + 2 or y + h > float(slide_size[1]) + 2:
warnings.append(f"{item_id} bbox extends outside slide bounds")
if cls in SEMANTIC_CLASSES:
semantic_ids.append(str(item_id))
if item.get("semantic_unit_count") not in {None, 1}:
errors.append(f"{item_id} semantic_unit_count must be 1")
manifest_items = read_optional_list(args.manifest)
manifest_by_id: dict[str, dict[str, Any]] = {}
if manifest_items is None:
if args.stage == "build" and semantic_ids:
errors.append("manifest is required for build stage")
else:
manifest_base = Path(args.manifest).parent if args.manifest else base_dir
for idx, item in enumerate(manifest_items):
sid = item.get("semantic_unit_id")
if not sid:
errors.append(f"manifest item {idx} missing semantic_unit_id")
continue
if sid in manifest_by_id:
errors.append(f"duplicate manifest semantic_unit_id: {sid}")
manifest_by_id[str(sid)] = item
source_type = item.get("source_type")
if source_type in FORBIDDEN_SOURCE_TYPES:
errors.append(f"manifest item {sid} uses forbidden source_type: {source_type}")
if source_type not in ALLOWED_SOURCE_TYPES:
errors.append(f"manifest item {sid} has invalid source_type: {source_type}")
if item.get("semantic_unit_count") != 1:
errors.append(f"manifest item {sid} semantic_unit_count must be 1")
asset_path = item.get("asset_path")
if args.stage == "build":
if not asset_path:
errors.append(f"manifest item {sid} missing asset_path")
elif not rel_path(manifest_base, str(asset_path)).exists():
errors.append(f"manifest item {sid} asset file missing: {asset_path}")
if manifest_items is not None:
for sid in semantic_ids:
if sid not in manifest_by_id:
errors.append(f"semantic inventory item missing manifest entry: {sid}")
anchors = read_optional_list(args.anchors)
anchor_ids: set[str] = set()
if anchors is not None:
for idx, anchor in enumerate(anchors):
sid = anchor.get("semantic_unit_id") or anchor.get("id")
if not sid:
errors.append(f"anchor {idx} missing semantic_unit_id")
continue
anchor_ids.add(str(sid))
bbox = anchor.get("target_bbox_px") or anchor.get("bbox_px") or anchor.get("bbox")
if not valid_bbox(bbox):
errors.append(f"anchor {sid} missing valid target bbox")
if anchor.get("placement_rule") and "stretch" in str(anchor.get("placement_rule")).lower() and "no one-axis" not in str(anchor.get("placement_rule")).lower():
errors.append(f"anchor {sid} placement_rule appears to allow image stretch")
elif args.require_anchors:
errors.append("anchors file is required")
if args.require_anchors and anchors is not None:
for sid in semantic_ids:
if sid not in anchor_ids:
errors.append(f"semantic inventory item missing anchor: {sid}")
report = {
"status": "FAIL" if errors else "PASS",
"stage": args.stage,
"inventory_items": len(items),
"semantic_items": len(semantic_ids),
"manifest_entries": len(manifest_items) if manifest_items is not None else None,
"anchor_entries": len(anchors) if anchors is not None else None,
"errors": errors,
"warnings": warnings,
}
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if errors:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STYLES_ROOT = ROOT / "styles"
REQUIRED_LAYOUTS = {
"cover-hero",
"content-structured",
"process-flow",
"comparison-two-zone",
"data-callouts",
"closing-action",
}
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
def fail(message: str) -> None:
raise SystemExit(f"ERROR: {message}")
def iter_colors(value: object):
if isinstance(value, dict):
for key, child in value.items():
if key in {"background", "primary", "secondary", "text"}:
values = child if isinstance(child, list) else [child]
for item in values:
yield item
yield from iter_colors(child)
elif isinstance(value, list):
for child in value:
yield from iter_colors(child)
def main() -> None:
catalog_path = STYLES_ROOT / "catalog.json"
profiles_path = STYLES_ROOT / "typography-profiles.json"
if not catalog_path.is_file():
fail("missing styles/catalog.json")
if not profiles_path.is_file():
fail("missing styles/typography-profiles.json")
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
profiles = json.loads(profiles_path.read_text(encoding="utf-8"))
if catalog.get("schema_version") != 2:
fail("catalog schema_version must be 2")
entries = catalog.get("styles")
if not isinstance(entries, list) or not entries:
fail("catalog styles must be a non-empty list")
ids = [entry.get("id") for entry in entries]
if len(ids) != len(set(ids)):
fail("duplicate style ids in catalog")
typography_profiles = {row.get("id"): row for row in profiles.get("typography_profiles", [])}
table_profiles = {row.get("id"): row for row in profiles.get("table_profiles", [])}
if profiles.get("schema_version") != 1 or not typography_profiles or not table_profiles:
fail("typography profile catalog must contain typography_profiles and table_profiles")
required_tokens = {"hero", "section_title", "page_title", "subtitle", "minor_title", "body", "label", "caption", "table"}
for profile_id, profile in typography_profiles.items():
if not isinstance(profile_id, str) or not re.fullmatch(r"[a-z0-9-]+", profile_id):
fail(f"invalid typography profile id: {profile_id!r}")
missing_tokens = required_tokens - set(profile.get("tokens", {}))
if missing_tokens:
fail(f"{profile_id}: missing typography tokens {sorted(missing_tokens)}")
grid = profile.get("font_size_grid")
if not isinstance(grid, (int, float)) or grid <= 0:
fail(f"{profile_id}: font_size_grid must be positive")
for entry in entries:
style_id = entry.get("id")
if not isinstance(style_id, str) or not re.fullmatch(r"[a-z0-9-]+", style_id):
fail(f"invalid style id: {style_id!r}")
if entry.get("typography_profile") not in typography_profiles:
fail(f"{style_id}: unknown typography_profile {entry.get('typography_profile')!r}")
if entry.get("table_profile") not in table_profiles:
fail(f"{style_id}: unknown table_profile {entry.get('table_profile')!r}")
variants = entry.get("variants")
if not isinstance(variants, list) or len(variants) < 3:
fail(f"{style_id}: at least three visual variants are required")
variant_ids = [variant.get("id") for variant in variants]
if len(variant_ids) != len(set(variant_ids)):
fail(f"{style_id}: duplicate variant ids")
if entry.get("default_variant") not in variant_ids:
fail(f"{style_id}: default_variant must reference a variant")
for variant in variants:
variant_id = variant.get("id")
if not isinstance(variant_id, str) or not re.fullmatch(r"[a-z0-9-]+", variant_id):
fail(f"{style_id}: invalid variant id {variant_id!r}")
if not isinstance(variant.get("name"), str) or not variant["name"].strip():
fail(f"{style_id}/{variant_id}: name is required")
for color in iter_colors(variant.get("design_tokens")):
if not isinstance(color, str) or not HEX_COLOR.fullmatch(color):
fail(f"{style_id}/{variant_id}: invalid color token {color!r}")
style_dir = STYLES_ROOT / style_id
style_md = style_dir / "STYLE.md"
layouts_path = style_dir / "layouts.json"
if not style_md.is_file() or not layouts_path.is_file():
fail(f"{style_id}: STYLE.md or layouts.json missing")
data = json.loads(layouts_path.read_text(encoding="utf-8"))
required_top = {"schema_version", "style_id", "display_name", "global_prompt", "design_tokens", "layouts"}
missing_top = required_top - data.keys()
if missing_top:
fail(f"{style_id}: missing top-level keys {sorted(missing_top)}")
if data["schema_version"] != 1 or data["style_id"] != style_id:
fail(f"{style_id}: schema_version or style_id mismatch")
if data["display_name"] != entry.get("name"):
fail(f"{style_id}: display name differs from catalog")
layouts = data.get("layouts")
if not isinstance(layouts, list):
fail(f"{style_id}: layouts must be a list")
layout_ids = [layout.get("id") for layout in layouts]
if set(layout_ids) != REQUIRED_LAYOUTS or len(layout_ids) != len(REQUIRED_LAYOUTS):
fail(f"{style_id}: layout ids must equal {sorted(REQUIRED_LAYOUTS)}")
required_layout = {"id", "page_type", "summary", "content_capacity", "best_for", "avoid_for", "reuse_friendly", "composition"}
for layout in layouts:
missing_layout = required_layout - layout.keys()
if missing_layout:
fail(f"{style_id}/{layout.get('id')}: missing keys {sorted(missing_layout)}")
for color in iter_colors(data.get("design_tokens")):
if not isinstance(color, str) or not HEX_COLOR.fullmatch(color):
fail(f"{style_id}: invalid color token {color!r}")
preset_count = sum(len(entry.get("variants", [])) for entry in entries)
print(f"Style library is valid: {len(entries)} families, {preset_count} presets, {len(entries) * len(REQUIRED_LAYOUTS)} layouts")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Validate an SVG slide for portable, safe structural reuse."""
from __future__ import annotations
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
LENGTH_RE = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("svg", help="SVG file to validate.")
parser.add_argument("--out", help="Optional JSON report path.")
parser.add_argument("--min-font-size", type=float, default=16.0, help="Warn below this SVG font size.")
parser.add_argument("--aspect-ratio", type=float, default=16 / 9, help="Expected slide aspect ratio.")
parser.add_argument("--aspect-tolerance", type=float, default=0.03, help="Allowed ratio difference.")
return parser.parse_args()
def number(value: str | None) -> float | None:
if not value:
return None
match = LENGTH_RE.match(value)
return float(match.group(1)) if match else None
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def main() -> None:
args = parse_args()
path = Path(args.svg)
errors: list[str] = []
warnings: list[str] = []
facts: dict[str, object] = {"file": str(path)}
if not path.is_file():
raise SystemExit(f"SVG not found: {path}")
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
raise SystemExit(f"Invalid SVG XML: {exc}") from exc
if local_name(root.tag) != "svg":
errors.append("root element is not <svg>")
width = number(root.get("width"))
height = number(root.get("height"))
viewbox = root.get("viewBox", "").replace(",", " ").split()
if len(viewbox) == 4:
try:
width, height = float(viewbox[2]), float(viewbox[3])
except ValueError:
errors.append("viewBox contains non-numeric values")
elif width is None or height is None:
errors.append("SVG needs a numeric viewBox or width/height")
if width and height:
ratio = width / height
facts.update({"width": width, "height": height, "aspect_ratio": ratio})
if abs(ratio - args.aspect_ratio) > args.aspect_tolerance:
warnings.append(f"aspect ratio {ratio:.4f} differs from expected {args.aspect_ratio:.4f}")
counts: dict[str, int] = {}
for element in root.iter():
name = local_name(element.tag)
counts[name] = counts.get(name, 0) + 1
if name in {"script", "foreignObject"}:
errors.append(f"forbidden <{name}> element")
href = element.get("href") or element.get(XLINK_HREF)
if href and not href.startswith(("#", "data:")):
errors.append(f"external resource reference: {href}")
if name == "text":
font_size = number(element.get("font-size"))
style = element.get("style", "")
if font_size is None:
match = re.search(r"font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)", style)
font_size = float(match.group(1)) if match else None
if font_size is not None and font_size < args.min_font_size:
warnings.append(f"text font-size {font_size:g} is below {args.min_font_size:g}")
facts["elements"] = counts
report = {
"status": "FAIL" if errors else "PASS",
"errors": sorted(set(errors)),
"warnings": sorted(set(warnings)),
"facts": facts,
}
payload = json.dumps(report, ensure_ascii=False, indent=2)
if args.out:
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(payload + "\n", encoding="utf-8")
print(payload)
if errors:
sys.exit(1)
if __name__ == "__main__":
main()