返回 ppt-master
validator.py
1 """validate: read back the latest template-fill export and check core contract."""
2
3 from __future__ import annotations
4
5 import re
6 import subprocess
7 import sys
8 from pathlib import Path
9 from typing import Any, Callable
10
11 from .checker import _chart_lookup, _slot_lookup, _table_lookup
12 from .ooxml import _load_json, _write_json
13 from .selectors import (
14 _chart_selectors,
15 _replacement_selectors,
16 _replacement_text,
17 _table_cell_text,
18 _table_selectors,
19 )
20
21
22 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
23 _SLIDE_HEADING_RE = re.compile(r"^## Slide\s+(\d+)\s*$", re.MULTILINE)
24 _SPEAKER_NOTES_RE = re.compile(r"^### Speaker Notes\s*$", re.MULTILINE)
25 _MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\([^)]+\)")
26 _LIST_PREFIX_RE = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+")
27 _TABLE_BREAK_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
28 _TABLE_SEPARATOR_RE = re.compile(r"^:?-{3,}:?$")
29 _ESCAPED_READBACK_CONTROL_RE = re.compile(
30 r"^\\(## Slide\s+\d+|### Speaker Notes)$"
31 )
32
33
34 def _latest_export(project_path: Path) -> Path:
35 exports_dir = project_path / "exports"
36 candidates = [path for path in exports_dir.glob("*.pptx") if path.is_file()]
37 if not candidates:
38 raise RuntimeError(f"No PPTX exports found in: {exports_dir}")
39 return max(candidates, key=lambda path: path.stat().st_mtime)
40
41
42 def _readback_slide_count(markdown: str) -> int:
43 return len(_SLIDE_HEADING_RE.findall(markdown))
44
45
46 def _slide_sections(markdown: str) -> dict[int, str]:
47 matches = list(_SLIDE_HEADING_RE.finditer(markdown))
48 sections: dict[int, str] = {}
49 for index, match in enumerate(matches):
50 end = (
51 matches[index + 1].start()
52 if index + 1 < len(matches)
53 else len(markdown)
54 )
55 sections[int(match.group(1))] = markdown[match.end():end].strip()
56 return sections
57
58
59 def _split_notes(section: str) -> tuple[str, str]:
60 match = _SPEAKER_NOTES_RE.search(section)
61 if match is None:
62 return section, ""
63 return section[:match.start()].strip(), section[match.end():].strip()
64
65
66 def _normalize_line(value: str) -> str:
67 return re.sub(r"\s+", " ", value).strip()
68
69
70 def _unescape_readback_control_line(value: str) -> str:
71 """Restore the display text of an escaped read-back section marker."""
72 match = _ESCAPED_READBACK_CONTROL_RE.fullmatch(value)
73 return match.group(1) if match else value
74
75
76 def _paragraph_lines(value: object) -> list[str]:
77 text = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
78 return [
79 normalized
80 for line in text.split("\n")
81 if (normalized := _normalize_line(line))
82 ]
83
84
85 def _markdown_line_variants(markdown: str) -> list[set[str] | None]:
86 variants: list[set[str] | None] = []
87 for raw_line in markdown.splitlines():
88 stripped = raw_line.strip()
89 if (
90 not stripped
91 or stripped.startswith("|")
92 or stripped.startswith("![")
93 or stripped.startswith("> [Chart")
94 or stripped == "_No extractable text content._"
95 ):
96 variants.append(None)
97 continue
98 display = _MARKDOWN_LINK_RE.sub(r"\1", stripped)
99 without_prefix = _LIST_PREFIX_RE.sub("", display, count=1)
100 line_variants = {
101 _unescape_readback_control_line(_normalize_line(candidate))
102 for candidate in (stripped, display, without_prefix)
103 }
104 line_variants.discard("")
105 variants.append(line_variants)
106 return variants
107
108
109 def _contains_paragraphs(markdown: str, value: str) -> bool:
110 expected = _paragraph_lines(value)
111 if not expected:
112 return True
113 actual = _markdown_line_variants(markdown)
114 if len(expected) > len(actual):
115 return False
116 for start in range(len(actual) - len(expected) + 1):
117 if all(
118 actual[start + offset] is not None
119 and expected[offset] in actual[start + offset]
120 for offset in range(len(expected))
121 ):
122 return True
123 return False
124
125
126 def _table_cells(markdown: str) -> list[list[str]]:
127 cells: list[list[str]] = []
128 for raw_line in markdown.splitlines():
129 stripped = raw_line.strip()
130 if not (stripped.startswith("|") and stripped.endswith("|")):
131 continue
132 values = re.split(r"(?<!\\)\|", stripped[1:-1])
133 normalized_values = [
134 value.strip().replace(r"\|", "|")
135 for value in values
136 ]
137 if normalized_values and all(
138 _TABLE_SEPARATOR_RE.fullmatch(value) is not None
139 for value in normalized_values
140 ):
141 continue
142 for value in normalized_values:
143 cells.append(_paragraph_lines(_TABLE_BREAK_RE.sub("\n", value)))
144 return cells
145
146
147 def _contains_table_cell(markdown: str, value: str) -> bool:
148 expected = _paragraph_lines(value)
149 if not expected:
150 return True
151 return expected in _table_cells(markdown)
152
153
154 def _contains_chart_text(markdown: str, value: str) -> bool:
155 return (
156 _contains_paragraphs(markdown, value)
157 or _contains_table_cell(markdown, value)
158 )
159
160
161 def _first_library(project_path: Path) -> dict[str, Any] | None:
162 libraries = sorted((project_path / "analysis").glob("*.slide_library.json"))
163 if not libraries:
164 return None
165 return _load_json(libraries[0])
166
167
168 def _matched_library_target(
169 lookup: dict[tuple[int, str], dict[str, Any]] | None,
170 source_slide: int,
171 selectors: list[str],
172 ) -> dict[str, Any] | None:
173 """Return the first library target matched by the runtime selector order."""
174 if lookup is None:
175 return None
176 return next(
177 (
178 target
179 for selector in selectors
180 if (target := lookup.get((source_slide, selector))) is not None
181 ),
182 None,
183 )
184
185
186 def _replacement_tokens(
187 plan: dict[str, Any],
188 library: dict[str, Any] | None,
189 ) -> list[tuple[int, str, str, str]]:
190 tokens: list[tuple[int, str, str, str]] = []
191 slot_lookup = _slot_lookup(library) if library is not None else None
192 for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
193 source_slide = int(slide.get("source_slide", 0))
194 replacements = slide.get("replacements", [])
195 if not isinstance(replacements, list):
196 continue
197 slide_tokens: list[tuple[str, bool]] = []
198 for replacement in replacements:
199 if not isinstance(replacement, dict):
200 continue
201 text = _replacement_text(replacement)
202 if not _paragraph_lines(text):
203 continue
204 slot = _matched_library_target(
205 slot_lookup,
206 source_slide,
207 _replacement_selectors(replacement),
208 )
209 if (
210 library is not None
211 and replacement.get("optional")
212 and slot is None
213 ):
214 continue
215 is_title = (
216 slot is not None
217 and str(slot.get("role") or "") == "title_candidate"
218 )
219 slide_tokens.append((text, is_title))
220 if (
221 library is None
222 and slide_tokens
223 and not any(is_title for _, is_title in slide_tokens)
224 ):
225 first_text, _ = slide_tokens[0]
226 slide_tokens[0] = (first_text, True)
227 for text, is_title in slide_tokens:
228 tokens.append(
229 (
230 plan_slide,
231 text,
232 (
233 "title_missing_in_readback"
234 if is_title
235 else "body_text_missing_in_readback"
236 ),
237 "title" if is_title else "replacement text",
238 )
239 )
240 return tokens
241
242
243 def _table_tokens(
244 plan: dict[str, Any],
245 library: dict[str, Any] | None,
246 ) -> list[tuple[int, str]]:
247 tokens: list[tuple[int, str]] = []
248 table_lookup = _table_lookup(library) if library is not None else None
249 for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
250 source_slide = int(slide.get("source_slide", 0))
251 for table_edit in slide.get("table_edits", []) or []:
252 table = _matched_library_target(
253 table_lookup,
254 source_slide,
255 _table_selectors(table_edit),
256 )
257 if (
258 library is not None
259 and table_edit.get("optional")
260 and table is None
261 ):
262 continue
263 for cell in table_edit.get("cells", []) or []:
264 text = _table_cell_text(cell)
265 if _paragraph_lines(text):
266 tokens.append((plan_slide, text))
267 return tokens
268
269
270 def _format_chart_series_value(value: object) -> str:
271 """Match ppt_to_md's display formatting for chart series values."""
272 if value is None:
273 return ""
274 if isinstance(value, float) and value.is_integer():
275 return str(int(value))
276 return str(value)
277
278
279 def _chart_tokens(
280 plan: dict[str, Any],
281 library: dict[str, Any] | None,
282 ) -> list[tuple[int, str]]:
283 tokens: list[tuple[int, str]] = []
284 chart_lookup = _chart_lookup(library) if library is not None else None
285 for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
286 source_slide = int(slide.get("source_slide", 0))
287 for chart_edit in slide.get("chart_edits", []) or []:
288 chart = _matched_library_target(
289 chart_lookup,
290 source_slide,
291 _chart_selectors(chart_edit),
292 )
293 if (
294 library is not None
295 and chart_edit.get("optional")
296 and chart is None
297 ):
298 continue
299 for category in chart_edit.get("categories", []) or []:
300 if str(category).strip():
301 tokens.append((plan_slide, str(category)))
302 for series in chart_edit.get("series", []) or []:
303 name = str(series.get("name") or "").strip()
304 if name:
305 tokens.append((plan_slide, name))
306 for value in series.get("values", []) or []:
307 text = _format_chart_series_value(value)
308 if text:
309 tokens.append((plan_slide, text))
310 return tokens
311
312
313 def _append_page_token_checks(
314 *,
315 results: list[dict[str, Any]],
316 summary: dict[str, int],
317 sections: dict[int, str],
318 tokens: list[tuple[int, str, str, str]],
319 status: str,
320 matcher: Callable[[str, str], bool],
321 ) -> None:
322 summary_key = status.lower()
323 for plan_slide, text, code, label in tokens:
324 body, _ = _split_notes(sections.get(plan_slide, ""))
325 if matcher(body, text):
326 summary["ok"] += 1
327 continue
328 summary[summary_key] += 1
329 results.append(
330 {
331 "status": status,
332 "code": code,
333 "plan_slide": plan_slide,
334 "message": f"{label} not found on the corresponding read-back slide",
335 "text": text,
336 }
337 )
338
339
340 def validate_project(project_path: Path) -> dict[str, Any]:
341 """Run read-back validation for a template-fill project."""
342 project_path = project_path.expanduser().resolve()
343 plan_path = project_path / "analysis" / "fill_plan.json"
344 if not plan_path.is_file():
345 raise RuntimeError(f"Missing fill plan: {plan_path}")
346
347 plan = _load_json(plan_path)
348 output_path = _latest_export(project_path)
349 validation_dir = project_path / "validation"
350 validation_dir.mkdir(parents=True, exist_ok=True)
351 readback_path = validation_dir / "readback.md"
352
353 ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py"
354 try:
355 subprocess.run(
356 [sys.executable, str(ppt_to_md), str(output_path), "-o", str(readback_path)],
357 cwd=_SCRIPTS_DIR.parents[2],
358 check=True,
359 capture_output=True,
360 text=True,
361 encoding="utf-8",
362 errors="replace",
363 )
364 except FileNotFoundError as exc:
365 raise RuntimeError(f"Missing executable: {sys.executable}") from exc
366 except subprocess.CalledProcessError as exc:
367 details = (exc.stderr or exc.stdout or "").strip()
368 raise RuntimeError(details or "ppt_to_md read-back failed") from exc
369
370 markdown = readback_path.read_text(encoding="utf-8", errors="replace")
371 results: list[dict[str, Any]] = []
372 summary = {"ok": 0, "warn": 0, "error": 0}
373 sections = _slide_sections(markdown)
374
375 expected_slides = len(plan.get("slides", []) or [])
376 actual_slides = _readback_slide_count(markdown)
377 if expected_slides == actual_slides:
378 summary["ok"] += 1
379 else:
380 summary["error"] += 1
381 results.append(
382 {
383 "status": "ERROR",
384 "code": "slide_count_mismatch",
385 "expected": expected_slides,
386 "actual": actual_slides,
387 "message": "read-back slide count does not match fill_plan.slides",
388 }
389 )
390
391 for plan_slide in range(1, expected_slides + 1):
392 if plan_slide in sections:
393 continue
394 summary["error"] += 1
395 results.append(
396 {
397 "status": "ERROR",
398 "code": "slide_missing_in_readback",
399 "plan_slide": plan_slide,
400 "message": "planned slide section is missing from read-back Markdown",
401 }
402 )
403
404 library = _first_library(project_path)
405 _append_page_token_checks(
406 results=results,
407 summary=summary,
408 sections=sections,
409 tokens=_replacement_tokens(plan, library),
410 status="ERROR",
411 matcher=_contains_paragraphs,
412 )
413 _append_page_token_checks(
414 results=results,
415 summary=summary,
416 sections=sections,
417 tokens=[
418 (plan_slide, text, "table_text_missing_in_readback", "table cell text")
419 for plan_slide, text in _table_tokens(plan, library)
420 ],
421 status="ERROR",
422 matcher=_contains_table_cell,
423 )
424 _append_page_token_checks(
425 results=results,
426 summary=summary,
427 sections=sections,
428 tokens=[
429 (plan_slide, text, "chart_text_missing_in_readback", "chart text")
430 for plan_slide, text in _chart_tokens(plan, library)
431 ],
432 status="WARN",
433 matcher=_contains_chart_text,
434 )
435
436 for plan_slide, slide in enumerate(plan.get("slides", []) or [], start=1):
437 _, notes = _split_notes(sections.get(plan_slide, ""))
438 planned_notes = str(slide.get("notes") or slide.get("speaker_notes") or "")
439 if _paragraph_lines(planned_notes):
440 if _contains_paragraphs(notes, planned_notes):
441 summary["ok"] += 1
442 continue
443 summary["error"] += 1
444 results.append(
445 {
446 "status": "ERROR",
447 "code": "notes_missing_in_readback",
448 "plan_slide": plan_slide,
449 "message": (
450 "planned speaker notes not found on the corresponding "
451 "read-back slide"
452 ),
453 "text": planned_notes,
454 }
455 )
456 elif notes.strip():
457 summary["warn"] += 1
458 results.append(
459 {
460 "status": "WARN",
461 "code": "unexpected_notes_in_readback",
462 "plan_slide": plan_slide,
463 "message": (
464 "read-back slide contains speaker notes although the "
465 "plan slide has no notes field"
466 ),
467 }
468 )
469
470 report = {
471 "schema": "template_fill_pptx_validate.v1",
472 "project": str(project_path),
473 "export": str(output_path),
474 "readback": str(readback_path),
475 "summary": summary,
476 "results": results,
477 }
478 _write_json(validation_dir / "validate_report.json", report)
479 return report
480
481
482 def print_validate_report(report: dict[str, Any]) -> None:
483 """Print a compact validation report."""
484 summary = report["summary"]
485 print(f"validate: ok={summary['ok']} warn={summary['warn']} error={summary['error']}")
486 print(f"export: {report['export']}")
487 print(f"readback: {report['readback']}")
488 for item in report["results"]:
489 text = item.get("text")
490 suffix = f" text={text!r}" if text else ""
491 print(f"{item['status']} {item['code']}: {item['message']}{suffix}")
492
492 lines PYTHON