Skills › Scientific Agent Skills › pptx-posters › generate_poster.py
generate_poster.py
432 lines · skills/pptx-posters/scripts/generate_poster.py at 5f2bc03
1#!/usr/bin/env python3
2"""Generate one macro-free PPTX poster from approved, strictly local JSON."""
4from __future__ import annotations
6import argparse
7import os
8import sys
9from datetime import datetime, timezone
10from importlib.metadata import PackageNotFoundError, version
11from pathlib import Path
12from typing import Any
14from _common import (
15 CliError,
16 checked_output_file,
17 commit_temp_file,
18 emit_json,
19 private_temp_file,
20 sha256_file,
21)
22from _manifest import load_and_validate_manifest
23from _pptx import (
24 analyze_layout,
25 patch_accessibility_metadata,
26 require_safe_pptx,
27 strip_generated_printer_settings,
28)
29from inventory_images import build_inventory
31EXPECTED_VERSIONS = {
32 "python-pptx": "1.0.2",
33 "Pillow": "12.3.0",
34 "lxml": "6.1.3",
35}
38def _require_exact_dependencies() -> dict[str, str]:
39 installed: dict[str, str] = {}
40 for distribution, expected in EXPECTED_VERSIONS.items():
41 try:
42 actual = version(distribution)
43 except PackageNotFoundError as exc:
44 raise CliError(
45 f"{distribution}=={expected} is required; install exact pins with "
46 '`uv pip install "python-pptx==1.0.2" "Pillow==12.3.0" '
47 '"lxml==6.1.3"`'
48 ) from exc
49 if actual != expected:
50 raise CliError(
51 f"{distribution}=={expected} is required for reproducible "
52 f"generation, but {actual} is installed"
53 )
54 installed[distribution] = actual
55 return installed
58def _rgb(value: str, rgb_class: Any) -> Any:
59 return rgb_class(
60 int(value[1:3], 16),
61 int(value[3:5], 16),
62 int(value[5:7], 16),
63 )
66def _set_text_shape(
67 slide: Any,
68 element: dict[str, Any],
69 *,
70 order: int,
71 colors: dict[str, str],
72 contrast_pairs: dict[str, dict[str, Any]],
73 Inches: Any,
74 Pt: Any,
75 RGBColor: Any,
76 PP_ALIGN: Any,
77 MSO_ANCHOR: Any,
78 MSO_AUTO_SIZE: Any,
79 existing_shape: Any | None = None,
80) -> None:
81 if existing_shape is None:
82 shape = slide.shapes.add_textbox(
83 Inches(float(element["x_in"])),
84 Inches(float(element["y_in"])),
85 Inches(float(element["width_in"])),
86 Inches(float(element["height_in"])),
87 )
88 else:
89 shape = existing_shape
90 shape.left = Inches(float(element["x_in"]))
91 shape.top = Inches(float(element["y_in"]))
92 shape.width = Inches(float(element["width_in"]))
93 shape.height = Inches(float(element["height_in"]))
94 shape.name = f"R{order:03d}_TEXT_{element['id']}"
95 text_frame = shape.text_frame
96 text_frame.clear()
97 text_frame.text = element["text"]
98 text_frame.word_wrap = True
99 text_frame.auto_size = MSO_AUTO_SIZE.NONE
100 margin = Inches(float(element["margin_in"]))
101 text_frame.margin_left = margin
102 text_frame.margin_right = margin
103 text_frame.margin_top = margin
104 text_frame.margin_bottom = margin
105 text_frame.vertical_anchor = {
106 "top": MSO_ANCHOR.TOP,
107 "middle": MSO_ANCHOR.MIDDLE,
108 "bottom": MSO_ANCHOR.BOTTOM,
109 }[element["vertical_align"]]
111 pair = contrast_pairs[element["contrast_pair_id"]]
112 foreground = colors[pair["foreground_color_id"]]
113 background = colors[pair["background_color_id"]]
114 shape.fill.solid()
115 shape.fill.fore_color.rgb = _rgb(background, RGBColor)
116 line_color_id = element["line_color_id"]
117 if line_color_id is None:
118 shape.line.fill.background()
119 else:
120 shape.line.color.rgb = _rgb(colors[line_color_id], RGBColor)
121 shape.line.width = Pt(float(element["line_width_pt"]))
123 alignment = {
124 "left": PP_ALIGN.LEFT,
125 "center": PP_ALIGN.CENTER,
126 "right": PP_ALIGN.RIGHT,
127 }[element["align"]]
128 for paragraph in text_frame.paragraphs:
129 paragraph.alignment = alignment
130 for run in paragraph.runs:
131 run.font.name = element["font_face"]
132 run.font.size = Pt(float(element["font_size_pt_design"]))
133 run.font.bold = bool(element["bold"])
134 run.font.color.rgb = _rgb(foreground, RGBColor)
137def _add_picture(
138 slide: Any,
139 element: dict[str, Any],
140 *,
141 order: int,
142 asset: dict[str, Any],
143 asset_path: Path,
144 metadata: dict[str, Any],
145 Inches: Any,
146) -> tuple[str, str]:
147 width_px = int(metadata["width_px"])
148 height_px = int(metadata["height_px"])
149 box_width = float(element["width_in"])
150 box_height = float(element["height_in"])
151 fit = min(box_width / width_px, box_height / height_px)
152 placed_width = width_px * fit
153 placed_height = height_px * fit
154 left = float(element["x_in"]) + (box_width - placed_width) / 2.0
155 top = float(element["y_in"]) + (box_height - placed_height) / 2.0
156 picture = slide.shapes.add_picture(
157 str(asset_path),
158 Inches(left),
159 Inches(top),
160 width=Inches(placed_width),
161 height=Inches(placed_height),
162 )
163 shape_name = f"R{order:03d}_IMAGE_{element['id']}"
164 picture.name = shape_name
165 return shape_name, asset["alt_text"]
168def _build_presentation(
169 document: dict[str, Any],
170 validation: dict[str, Any],
171 *,
172 image_inventory: dict[str, Any],
173 output_path: Path,
174) -> dict[str, str]:
175 try:
176 from pptx import Presentation
177 from pptx.dml.color import RGBColor
178 from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN
179 from pptx.util import Inches, Pt
180 except ImportError as exc:
181 raise CliError(
182 "python-pptx is required; install the exact pins with "
183 '`uv pip install "python-pptx==1.0.2" "Pillow==12.3.0" '
184 '"lxml==6.1.3"`'
185 ) from exc
187 presentation = Presentation()
188 presentation.slide_width = Inches(float(document["canvas"]["width_in"]))
189 presentation.slide_height = Inches(float(document["canvas"]["height_in"]))
190 slide = presentation.slides.add_slide(presentation.slide_layouts[5])
191 title_placeholder = slide.shapes.title
192 if title_placeholder is None:
193 raise CliError("built-in title-only layout did not provide a title placeholder")
194 slide.background.fill.solid()
195 slide.background.fill.fore_color.rgb = _rgb(
196 document["canvas"]["background_color"], RGBColor
197 )
199 core = presentation.core_properties
200 core.title = document["document"]["title"]
201 core.subject = document["document"]["subject"]
202 core.author = "; ".join(document["document"]["authors"])
203 core.language = document["document"]["language"]
204 approved_at = document["approval"]["approved_at"]
205 normalized_time = (
206 approved_at[:-1] + "+00:00" if approved_at.endswith("Z") else approved_at
207 )
208 # python-pptx serializes core dates as UTC without converting an offset.
209 approval_time = (
210 datetime.fromisoformat(normalized_time)
211 .astimezone(timezone.utc)
212 .replace(tzinfo=None)
213 )
214 core.created = approval_time
215 core.modified = approval_time
217 colors = {
218 color_id: value.upper()
219 for color_id, value in document["palette"]["colors"].items()
220 }
221 pairs = {
222 pair["id"]: pair for pair in document["palette"]["contrast_pairs"]
223 }
224 assets = {asset["id"]: asset for asset in document["assets"]}
225 paths = {
226 asset_id: Path(path)
227 for asset_id, path in validation["asset_paths"].items()
228 }
229 metadata = {
230 record["id"]: record["metadata"] for record in image_inventory["images"]
231 }
232 alt_text_by_shape_name: dict[str, str] = {}
233 for element in document["elements"]:
234 order = int(element["reading_order"])
235 if element["type"] == "text":
236 _set_text_shape(
237 slide,
238 element,
239 order=order,
240 colors=colors,
241 contrast_pairs=pairs,
242 Inches=Inches,
243 Pt=Pt,
244 RGBColor=RGBColor,
245 PP_ALIGN=PP_ALIGN,
246 MSO_ANCHOR=MSO_ANCHOR,
247 MSO_AUTO_SIZE=MSO_AUTO_SIZE,
248 existing_shape=(
249 title_placeholder if element["role"] == "title" else None
250 ),
251 )
252 else:
253 asset = assets[element["asset_id"]]
254 shape_name, alt_text = _add_picture(
255 slide,
256 element,
257 order=order,
258 asset=asset,
259 asset_path=paths[element["asset_id"]],
260 metadata=metadata[element["asset_id"]],
261 Inches=Inches,
262 )
263 alt_text_by_shape_name[shape_name] = alt_text
264 presentation.save(output_path)
265 return alt_text_by_shape_name
268def generate_poster(
269 manifest_value: str | Path,
270 output_value: str | Path,
271) -> dict[str, Any]:
272 """Generate, patch, technically inspect, and publish a new PPTX poster."""
273 dependency_versions = _require_exact_dependencies()
274 destination = checked_output_file(output_value, suffix=".pptx")
275 manifest_path, document, validation = load_and_validate_manifest(
276 manifest_value,
277 verify_assets=True,
278 require_approval=True,
279 )
280 image_inventory = build_inventory(manifest_path, document, validation)
281 if not image_inventory["pass"]:
282 messages = "; ".join(issue["message"] for issue in image_inventory["issues"])
283 raise CliError(f"image inventory failed: {messages}")
285 source_descriptor, source_temp = private_temp_file(
286 destination, suffix=".pptx"
287 )
288 sanitized_descriptor, sanitized_temp = private_temp_file(
289 destination, suffix=".pptx"
290 )
291 patched_descriptor, patched_temp = private_temp_file(
292 destination, suffix=".pptx"
293 )
294 os.close(source_descriptor)
295 os.close(sanitized_descriptor)
296 os.close(patched_descriptor)
297 try:
298 alt_texts = _build_presentation(
299 document,
300 validation,
301 image_inventory=image_inventory,
302 output_path=source_temp,
303 )
304 strip_generated_printer_settings(source_temp, sanitized_temp)
305 require_safe_pptx(sanitized_temp)
306 patch_accessibility_metadata(
307 sanitized_temp,
308 patched_temp,
309 alt_text_by_shape_name=alt_texts,
310 language=document["document"]["language"],
311 )
312 package_report = require_safe_pptx(patched_temp)
313 accessibility = package_report["accessibility"]
314 if accessibility["pictures_missing_alt_text"]:
315 raise CliError("generated PPTX contains a picture without alt text")
316 if accessibility["slide_title_count"] != 1:
317 raise CliError(
318 "generated PPTX must contain exactly one native slide-title placeholder"
319 )
320 if accessibility["text_runs_missing_language"]:
321 raise CliError("generated PPTX contains text without explicit language")
322 layout_report = analyze_layout(
323 patched_temp,
324 print_scale=float(validation["physical_output"]["print_scale"]),
325 minimum_font_pt_final=float(
326 document["quality"]["minimum_font_pt_final"]
327 ),
328 )
329 expected_order = [
330 (
331 f"R{int(element['reading_order']):03d}_"
332 f"{'TEXT' if element['type'] == 'text' else 'IMAGE'}_"
333 f"{element['id']}"
334 )
335 for element in document["elements"]
336 ]
337 actual_order = [
338 item["name"]
339 for item in layout_report["slides"][0]["reading_order"]
340 ]
341 if actual_order != expected_order:
342 raise CliError(
343 "generated PPTX direct shape order does not match approved reading order"
344 )
345 if not layout_report["pass"]:
346 codes = ", ".join(issue["code"] for issue in layout_report["issues"])
347 raise CliError(f"generated PPTX failed layout checks: {codes}")
348 commit_temp_file(patched_temp, destination)
349 finally:
350 for temporary in (source_temp, sanitized_temp, patched_temp):
351 try:
352 temporary.unlink()
353 except FileNotFoundError:
354 pass
356 return {
357 "schema_version": "1.0",
358 "generated": True,
359 "output": {
360 "path": str(destination),
361 "sha256": sha256_file(destination),
362 "size_bytes": destination.stat().st_size,
363 },
364 "manifest": {
365 "path": str(manifest_path),
366 "content_sha256": validation["content_sha256"],
367 "approved_by": document["approval"]["approved_by"],
368 "approved_at": document["approval"]["approved_at"],
369 },
370 "dependencies": dependency_versions,
371 "package_security": {
372 "safe": package_report["safe"],
373 "member_count": package_report["package"]["member_count"],
374 "external_relationships": 0,
375 "macros_or_embedded_objects": 0,
376 },
377 "technical_checks": {
378 "layout_pass": layout_report["pass"],
379 "image_inventory_pass": image_inventory["pass"],
380 "pictures_with_alt_text": accessibility["pictures_with_alt_text"],
381 "picture_count": accessibility["picture_count"],
382 "native_slide_title_count": accessibility["slide_title_count"],
383 "text_runs_with_language": accessibility["text_runs_with_language"],
384 "text_run_count": accessibility["text_run_count"],
385 },
386 "manual_checks_required": [
387 "PowerPoint Accessibility Checker and Reading Order pane",
388 "alt-text and native long-description completeness",
389 "screen-reader and keyboard navigation",
390 "rendered text overflow, font substitution, equations, and glyphs",
391 "PDF page size, tags, font rendering, and image quality after export",
392 "physical color proof, trim, bleed, safe margin, and final scaling",
393 "QR scan test plus visible fallback URL/text",
394 "author sign-off on every claim, number, citation, and source",
395 ],
396 }
399def build_parser() -> argparse.ArgumentParser:
400 parser = argparse.ArgumentParser(
401 description=(
402 "Generate one editable, macro-free PPTX poster from strict, approved "
403 "local JSON and local PNG/JPEG assets. The command refuses placeholders, "
404 "unknown sources, unapproved content, remote assets, unsafe package "
405 "features, low effective DPI, overlap, bounds errors, and overwrites."
406 )
407 )
408 parser.add_argument("manifest", help="approved local poster manifest JSON")
409 parser.add_argument(
410 "--output",
411 required=True,
412 help="new .pptx path; an existing destination is never overwritten",
413 )
414 parser.add_argument("--report", help="optional new JSON generation report path")
415 return parser
418def main(argv: list[str] | None = None) -> int:
419 parser = build_parser()
420 try:
421 args = parser.parse_args(argv)
422 if args.report is not None:
423 checked_output_file(args.report, suffix=".json")
424 report = generate_poster(args.manifest, args.output)
425 emit_json(report, output=args.report)
426 return 0
427 except CliError as exc:
428 parser.exit(2, f"error: {exc}\n")
431if __name__ == "__main__":
432 sys.exit(main())