mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-09-05 21:23:57 +08:00
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:
112
skills/presentation-forge/scripts/audit_pptx_editability.py
Executable file
112
skills/presentation-forge/scripts/audit_pptx_editability.py
Executable 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()
|
||||
99
skills/presentation-forge/scripts/audit_public_skill.py
Executable file
99
skills/presentation-forge/scripts/audit_public_skill.py
Executable 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()
|
||||
702
skills/presentation-forge/scripts/build_native_editable_deck.py
Normal file
702
skills/presentation-forge/scripts/build_native_editable_deck.py
Normal 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()
|
||||
770
skills/presentation-forge/scripts/build_semantic_deck.py
Normal file
770
skills/presentation-forge/scripts/build_semantic_deck.py
Normal 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()
|
||||
139
skills/presentation-forge/scripts/clean_assets.py
Executable file
139
skills/presentation-forge/scripts/clean_assets.py
Executable 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()
|
||||
98
skills/presentation-forge/scripts/compare_render.py
Executable file
98
skills/presentation-forge/scripts/compare_render.py
Executable 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()
|
||||
177
skills/presentation-forge/scripts/compile_scenes.py
Normal file
177
skills/presentation-forge/scripts/compile_scenes.py
Normal 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()
|
||||
409
skills/presentation-forge/scripts/compile_slides_markdown.py
Normal file
409
skills/presentation-forge/scripts/compile_slides_markdown.py
Normal 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()
|
||||
549
skills/presentation-forge/scripts/editor_bridge.py
Normal file
549
skills/presentation-forge/scripts/editor_bridge.py
Normal 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()
|
||||
267
skills/presentation-forge/scripts/grid_cut.py
Executable file
267
skills/presentation-forge/scripts/grid_cut.py
Executable 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()
|
||||
174
skills/presentation-forge/scripts/init_deck_session.py
Normal file
174
skills/presentation-forge/scripts/init_deck_session.py
Normal 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()
|
||||
125
skills/presentation-forge/scripts/init_semantic_project.py
Executable file
125
skills/presentation-forge/scripts/init_semantic_project.py
Executable 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()
|
||||
451
skills/presentation-forge/scripts/native_template_fill.py
Normal file
451
skills/presentation-forge/scripts/native_template_fill.py
Normal 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()
|
||||
198
skills/presentation-forge/scripts/package_image_deck.py
Executable file
198
skills/presentation-forge/scripts/package_image_deck.py
Executable 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()
|
||||
162
skills/presentation-forge/scripts/preflight_ppt_environment.py
Normal file
162
skills/presentation-forge/scripts/preflight_ppt_environment.py
Normal 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()
|
||||
127
skills/presentation-forge/scripts/rebuild_slide.py
Normal file
127
skills/presentation-forge/scripts/rebuild_slide.py
Normal 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()
|
||||
24
skills/presentation-forge/scripts/render_editor_canvas.py
Normal file
24
skills/presentation-forge/scripts/render_editor_canvas.py
Normal 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()
|
||||
175
skills/presentation-forge/scripts/render_pptx.py
Normal file
175
skills/presentation-forge/scripts/render_pptx.py
Normal 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()
|
||||
93
skills/presentation-forge/scripts/revision_session.py
Normal file
93
skills/presentation-forge/scripts/revision_session.py
Normal 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()
|
||||
192
skills/presentation-forge/scripts/route_deck_workflow.py
Normal file
192
skills/presentation-forge/scripts/route_deck_workflow.py
Normal 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()
|
||||
303
skills/presentation-forge/scripts/run_quality_gate.py
Normal file
303
skills/presentation-forge/scripts/run_quality_gate.py
Normal 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()
|
||||
59
skills/presentation-forge/scripts/style_presets.py
Normal file
59
skills/presentation-forge/scripts/style_presets.py
Normal 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
|
||||
243
skills/presentation-forge/scripts/sync_canvas_scene.py
Normal file
243
skills/presentation-forge/scripts/sync_canvas_scene.py
Normal 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()
|
||||
158
skills/presentation-forge/scripts/validate_design_quality.py
Normal file
158
skills/presentation-forge/scripts/validate_design_quality.py
Normal 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()
|
||||
@@ -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()
|
||||
87
skills/presentation-forge/scripts/validate_pptx_fonts.py
Normal file
87
skills/presentation-forge/scripts/validate_pptx_fonts.py
Normal 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()
|
||||
249
skills/presentation-forge/scripts/validate_pptx_typography.py
Normal file
249
skills/presentation-forge/scripts/validate_pptx_typography.py
Normal 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()
|
||||
82
skills/presentation-forge/scripts/validate_scene.py
Normal file
82
skills/presentation-forge/scripts/validate_scene.py
Normal 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()
|
||||
256
skills/presentation-forge/scripts/validate_semantic_deck.py
Executable file
256
skills/presentation-forge/scripts/validate_semantic_deck.py
Executable 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()
|
||||
209
skills/presentation-forge/scripts/validate_semantic_inputs.py
Executable file
209
skills/presentation-forge/scripts/validate_semantic_inputs.py
Executable 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()
|
||||
135
skills/presentation-forge/scripts/validate_style_library.py
Normal file
135
skills/presentation-forge/scripts/validate_style_library.py
Normal 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()
|
||||
109
skills/presentation-forge/scripts/validate_svg_slide.py
Normal file
109
skills/presentation-forge/scripts/validate_svg_slide.py
Normal 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()
|
||||
Reference in New Issue
Block a user