Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""validate: read back the latest template-fill export and check core contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .checker import _chart_lookup, _slot_lookup, _table_lookup
|
||||
from .ooxml import _load_json, _write_json
|
||||
from .selectors import (
|
||||
_chart_selectors,
|
||||
_replacement_selectors,
|
||||
_replacement_text,
|
||||
_table_cell_text,
|
||||
_table_selectors,
|
||||
)
|
||||
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+(\d+)\s*$", re.MULTILINE)
|
||||
_SPEAKER_NOTES_RE = re.compile(r"^### Speaker Notes\s*$", re.MULTILINE)
|
||||
_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\([^)]+\)")
|
||||
_LIST_PREFIX_RE = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+")
|
||||
_TABLE_BREAK_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^:?-{3,}:?$")
|
||||
_ESCAPED_READBACK_CONTROL_RE = re.compile(
|
||||
r"^\\(## Slide\s+\d+|### Speaker Notes)$"
|
||||
)
|
||||
|
||||
|
||||
def _latest_export(project_path: Path) -> Path:
|
||||
exports_dir = project_path / "exports"
|
||||
candidates = [path for path in exports_dir.glob("*.pptx") if path.is_file()]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"No PPTX exports found in: {exports_dir}")
|
||||
return max(candidates, key=lambda path: path.stat().st_mtime)
|
||||
|
||||
|
||||
def _readback_slide_count(markdown: str) -> int:
|
||||
return len(_SLIDE_HEADING_RE.findall(markdown))
|
||||
|
||||
|
||||
def _slide_sections(markdown: str) -> dict[int, str]:
|
||||
matches = list(_SLIDE_HEADING_RE.finditer(markdown))
|
||||
sections: dict[int, str] = {}
|
||||
for index, match in enumerate(matches):
|
||||
end = (
|
||||
matches[index + 1].start()
|
||||
if index + 1 < len(matches)
|
||||
else len(markdown)
|
||||
)
|
||||
sections[int(match.group(1))] = markdown[match.end():end].strip()
|
||||
return sections
|
||||
|
||||
|
||||
def _split_notes(section: str) -> tuple[str, str]:
|
||||
match = _SPEAKER_NOTES_RE.search(section)
|
||||
if match is None:
|
||||
return section, ""
|
||||
return section[:match.start()].strip(), section[match.end():].strip()
|
||||
|
||||
|
||||
def _normalize_line(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _unescape_readback_control_line(value: str) -> str:
|
||||
"""Restore the display text of an escaped read-back section marker."""
|
||||
match = _ESCAPED_READBACK_CONTROL_RE.fullmatch(value)
|
||||
return match.group(1) if match else value
|
||||
|
||||
|
||||
def _paragraph_lines(value: object) -> list[str]:
|
||||
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
return [
|
||||
normalized
|
||||
for line in text.split("\n")
|
||||
if (normalized := _normalize_line(line))
|
||||
]
|
||||
|
||||
|
||||
def _markdown_line_variants(markdown: str) -> list[set[str] | None]:
|
||||
variants: list[set[str] | None] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if (
|
||||
not stripped
|
||||
or stripped.startswith("|")
|
||||
or stripped.startswith("![")
|
||||
or stripped.startswith("> [Chart")
|
||||
or stripped == "_No extractable text content._"
|
||||
):
|
||||
variants.append(None)
|
||||
continue
|
||||
display = _MARKDOWN_LINK_RE.sub(r"\1", stripped)
|
||||
without_prefix = _LIST_PREFIX_RE.sub("", display, count=1)
|
||||
line_variants = {
|
||||
_unescape_readback_control_line(_normalize_line(candidate))
|
||||
for candidate in (stripped, display, without_prefix)
|
||||
}
|
||||
line_variants.discard("")
|
||||
variants.append(line_variants)
|
||||
return variants
|
||||
|
||||
|
||||
def _contains_paragraphs(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
actual = _markdown_line_variants(markdown)
|
||||
if len(expected) > len(actual):
|
||||
return False
|
||||
for start in range(len(actual) - len(expected) + 1):
|
||||
if all(
|
||||
actual[start + offset] is not None
|
||||
and expected[offset] in actual[start + offset]
|
||||
for offset in range(len(expected))
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _table_cells(markdown: str) -> list[list[str]]:
|
||||
cells: list[list[str]] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not (stripped.startswith("|") and stripped.endswith("|")):
|
||||
continue
|
||||
values = re.split(r"(?<!\\)\|", stripped[1:-1])
|
||||
normalized_values = [
|
||||
value.strip().replace(r"\|", "|")
|
||||
for value in values
|
||||
]
|
||||
if normalized_values and all(
|
||||
_TABLE_SEPARATOR_RE.fullmatch(value) is not None
|
||||
for value in normalized_values
|
||||
):
|
||||
continue
|
||||
for value in normalized_values:
|
||||
cells.append(_paragraph_lines(_TABLE_BREAK_RE.sub("\n", value)))
|
||||
return cells
|
||||
|
||||
|
||||
def _contains_table_cell(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
return expected in _table_cells(markdown)
|
||||
|
||||
|
||||
def _contains_chart_text(markdown: str, value: str) -> bool:
|
||||
return (
|
||||
_contains_paragraphs(markdown, value)
|
||||
or _contains_table_cell(markdown, value)
|
||||
)
|
||||
|
||||
|
||||
def _first_library(project_path: Path) -> dict[str, Any] | None:
|
||||
libraries = sorted((project_path / "analysis").glob("*.slide_library.json"))
|
||||
if not libraries:
|
||||
return None
|
||||
return _load_json(libraries[0])
|
||||
|
||||
|
||||
def _matched_library_target(
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] | None,
|
||||
source_slide: int,
|
||||
selectors: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the first library target matched by the runtime selector order."""
|
||||
if lookup is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
target
|
||||
for selector in selectors
|
||||
if (target := lookup.get((source_slide, selector))) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _replacement_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str, str, str]]:
|
||||
tokens: list[tuple[int, str, str, str]] = []
|
||||
slot_lookup = _slot_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
replacements = slide.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
continue
|
||||
slide_tokens: list[tuple[str, bool]] = []
|
||||
for replacement in replacements:
|
||||
if not isinstance(replacement, dict):
|
||||
continue
|
||||
text = _replacement_text(replacement)
|
||||
if not _paragraph_lines(text):
|
||||
continue
|
||||
slot = _matched_library_target(
|
||||
slot_lookup,
|
||||
source_slide,
|
||||
_replacement_selectors(replacement),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and replacement.get("optional")
|
||||
and slot is None
|
||||
):
|
||||
continue
|
||||
is_title = (
|
||||
slot is not None
|
||||
and str(slot.get("role") or "") == "title_candidate"
|
||||
)
|
||||
slide_tokens.append((text, is_title))
|
||||
if (
|
||||
library is None
|
||||
and slide_tokens
|
||||
and not any(is_title for _, is_title in slide_tokens)
|
||||
):
|
||||
first_text, _ = slide_tokens[0]
|
||||
slide_tokens[0] = (first_text, True)
|
||||
for text, is_title in slide_tokens:
|
||||
tokens.append(
|
||||
(
|
||||
plan_slide,
|
||||
text,
|
||||
(
|
||||
"title_missing_in_readback"
|
||||
if is_title
|
||||
else "body_text_missing_in_readback"
|
||||
),
|
||||
"title" if is_title else "replacement text",
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def _table_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
table_lookup = _table_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for table_edit in slide.get("table_edits", []) or []:
|
||||
table = _matched_library_target(
|
||||
table_lookup,
|
||||
source_slide,
|
||||
_table_selectors(table_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and table_edit.get("optional")
|
||||
and table is None
|
||||
):
|
||||
continue
|
||||
for cell in table_edit.get("cells", []) or []:
|
||||
text = _table_cell_text(cell)
|
||||
if _paragraph_lines(text):
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _format_chart_series_value(value: object) -> str:
|
||||
"""Match ppt_to_md's display formatting for chart series values."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _chart_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
chart_lookup = _chart_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for chart_edit in slide.get("chart_edits", []) or []:
|
||||
chart = _matched_library_target(
|
||||
chart_lookup,
|
||||
source_slide,
|
||||
_chart_selectors(chart_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and chart_edit.get("optional")
|
||||
and chart is None
|
||||
):
|
||||
continue
|
||||
for category in chart_edit.get("categories", []) or []:
|
||||
if str(category).strip():
|
||||
tokens.append((plan_slide, str(category)))
|
||||
for series in chart_edit.get("series", []) or []:
|
||||
name = str(series.get("name") or "").strip()
|
||||
if name:
|
||||
tokens.append((plan_slide, name))
|
||||
for value in series.get("values", []) or []:
|
||||
text = _format_chart_series_value(value)
|
||||
if text:
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _append_page_token_checks(
|
||||
*,
|
||||
results: list[dict[str, Any]],
|
||||
summary: dict[str, int],
|
||||
sections: dict[int, str],
|
||||
tokens: list[tuple[int, str, str, str]],
|
||||
status: str,
|
||||
matcher: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
summary_key = status.lower()
|
||||
for plan_slide, text, code, label in tokens:
|
||||
body, _ = _split_notes(sections.get(plan_slide, ""))
|
||||
if matcher(body, text):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary[summary_key] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": status,
|
||||
"code": code,
|
||||
"plan_slide": plan_slide,
|
||||
"message": f"{label} not found on the corresponding read-back slide",
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_project(project_path: Path) -> dict[str, Any]:
|
||||
"""Run read-back validation for a template-fill project."""
|
||||
project_path = project_path.expanduser().resolve()
|
||||
plan_path = project_path / "analysis" / "fill_plan.json"
|
||||
if not plan_path.is_file():
|
||||
raise RuntimeError(f"Missing fill plan: {plan_path}")
|
||||
|
||||
plan = _load_json(plan_path)
|
||||
output_path = _latest_export(project_path)
|
||||
validation_dir = project_path / "validation"
|
||||
validation_dir.mkdir(parents=True, exist_ok=True)
|
||||
readback_path = validation_dir / "readback.md"
|
||||
|
||||
ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py"
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, str(ppt_to_md), str(output_path), "-o", str(readback_path)],
|
||||
cwd=_SCRIPTS_DIR.parents[2],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f"Missing executable: {sys.executable}") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
details = (exc.stderr or exc.stdout or "").strip()
|
||||
raise RuntimeError(details or "ppt_to_md read-back failed") from exc
|
||||
|
||||
markdown = readback_path.read_text(encoding="utf-8", errors="replace")
|
||||
results: list[dict[str, Any]] = []
|
||||
summary = {"ok": 0, "warn": 0, "error": 0}
|
||||
sections = _slide_sections(markdown)
|
||||
|
||||
expected_slides = len(plan.get("slides", []) or [])
|
||||
actual_slides = _readback_slide_count(markdown)
|
||||
if expected_slides == actual_slides:
|
||||
summary["ok"] += 1
|
||||
else:
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "slide_count_mismatch",
|
||||
"expected": expected_slides,
|
||||
"actual": actual_slides,
|
||||
"message": "read-back slide count does not match fill_plan.slides",
|
||||
}
|
||||
)
|
||||
|
||||
for plan_slide in range(1, expected_slides + 1):
|
||||
if plan_slide in sections:
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "slide_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": "planned slide section is missing from read-back Markdown",
|
||||
}
|
||||
)
|
||||
|
||||
library = _first_library(project_path)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=_replacement_tokens(plan, library),
|
||||
status="ERROR",
|
||||
matcher=_contains_paragraphs,
|
||||
)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "table_text_missing_in_readback", "table cell text")
|
||||
for plan_slide, text in _table_tokens(plan, library)
|
||||
],
|
||||
status="ERROR",
|
||||
matcher=_contains_table_cell,
|
||||
)
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "chart_text_missing_in_readback", "chart text")
|
||||
for plan_slide, text in _chart_tokens(plan, library)
|
||||
],
|
||||
status="WARN",
|
||||
matcher=_contains_chart_text,
|
||||
)
|
||||
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []) or [], start=1):
|
||||
_, notes = _split_notes(sections.get(plan_slide, ""))
|
||||
planned_notes = str(slide.get("notes") or slide.get("speaker_notes") or "")
|
||||
if _paragraph_lines(planned_notes):
|
||||
if _contains_paragraphs(notes, planned_notes):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "notes_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"planned speaker notes not found on the corresponding "
|
||||
"read-back slide"
|
||||
),
|
||||
"text": planned_notes,
|
||||
}
|
||||
)
|
||||
elif notes.strip():
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "unexpected_notes_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"read-back slide contains speaker notes although the "
|
||||
"plan slide has no notes field"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema": "template_fill_pptx_validate.v1",
|
||||
"project": str(project_path),
|
||||
"export": str(output_path),
|
||||
"readback": str(readback_path),
|
||||
"summary": summary,
|
||||
"results": results,
|
||||
}
|
||||
_write_json(validation_dir / "validate_report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def print_validate_report(report: dict[str, Any]) -> None:
|
||||
"""Print a compact validation report."""
|
||||
summary = report["summary"]
|
||||
print(f"validate: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
|
||||
print(f"export: {report['export']}")
|
||||
print(f"readback: {report['readback']}")
|
||||
for item in report["results"]:
|
||||
text = item.get("text")
|
||||
suffix = f" text={text!r}" if text else ""
|
||||
print(f"{item['status']} {item['code']}: {item['message']}{suffix}")
|
||||
Reference in New Issue
Block a user