Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import re | |
| import tempfile | |
| from pathlib import Path | |
| from pptx import Presentation | |
| from pptx.dml.color import RGBColor | |
| from pptx.util import Pt | |
| from src.ppt.placeholder_mapper import choose_layout_index | |
| from src.utils.validators import validate_slides, validate_template | |
| def _extract_json_array_block(raw_text: str) -> str: | |
| text = raw_text.strip() | |
| # Remove fenced markdown wrappers if present. | |
| text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip() | |
| text = re.sub(r"```$", "", text).strip() | |
| if text.startswith("[") and text.endswith("]"): | |
| return text | |
| match = re.search(r"\[[\s\S]*\]", text) | |
| if match: | |
| return match.group(0) | |
| raise ValueError("No JSON array block found in model output.") | |
| def parse_slides_payload(slides_payload: str | list[dict]) -> list[dict]: | |
| if isinstance(slides_payload, list): | |
| parsed = slides_payload | |
| elif isinstance(slides_payload, str): | |
| block = _extract_json_array_block(slides_payload) | |
| try: | |
| parsed = json.loads(block) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError(f"Malformed slide JSON: {exc}") from exc | |
| else: | |
| raise ValueError("slides_payload must be a JSON string or list of dict objects.") | |
| normalized = [] | |
| for item in parsed: | |
| if not isinstance(item, dict): | |
| continue | |
| title = str(item.get("title", "Untitled Slide")).strip() or "Untitled Slide" | |
| bullets = item.get("bullets", item.get("content", [])) | |
| if isinstance(bullets, str): | |
| bullets = [bullets] | |
| if not isinstance(bullets, list): | |
| bullets = [] | |
| bullets = [str(b).strip() for b in bullets if str(b).strip()] | |
| layout_type = str(item.get("layout_type", "content_slide")) | |
| normalized.append( | |
| { | |
| "title": title, | |
| "bullets": bullets, | |
| "layout_type": layout_type, | |
| } | |
| ) | |
| validate_slides(normalized) | |
| return normalized | |
| def _apply_run_style(run, style_profile: dict): | |
| font_style = (style_profile or {}).get("font", {}) | |
| if not font_style: | |
| return | |
| if font_style.get("name"): | |
| run.font.name = font_style["name"] | |
| if font_style.get("size_pt"): | |
| run.font.size = Pt(font_style["size_pt"]) | |
| if font_style.get("bold") is not None: | |
| run.font.bold = bool(font_style["bold"]) | |
| if font_style.get("italic") is not None: | |
| run.font.italic = bool(font_style["italic"]) | |
| color = font_style.get("color_rgb") or (style_profile or {}).get("dominant_text_color") | |
| if color and isinstance(color, (list, tuple)) and len(color) == 3: | |
| run.font.color.rgb = RGBColor(int(color[0]), int(color[1]), int(color[2])) | |
| def _remove_all_slides(prs: Presentation): | |
| # Remove slide relationship + slide ID to avoid duplicate part warnings on save. | |
| for idx in range(len(prs.slides) - 1, -1, -1): | |
| slide_id = prs.slides._sldIdLst[idx] # pylint: disable=protected-access | |
| rel_id = slide_id.rId | |
| prs.part.drop_rel(rel_id) | |
| del prs.slides._sldIdLst[idx] # pylint: disable=protected-access | |
| def generate_presentation( | |
| slides_payload: str | list[dict], | |
| template_path: str | Path, | |
| output_path: str | Path | None = None, | |
| style_profile: dict | None = None, | |
| ) -> Presentation: | |
| validated_template = validate_template(template_path) | |
| slides = parse_slides_payload(slides_payload) | |
| prs = Presentation(str(validated_template)) | |
| _remove_all_slides(prs) | |
| for slide_data in slides: | |
| layout_idx = choose_layout_index( | |
| slide_data.get("layout_type", "content_slide"), | |
| style_profile or {}, | |
| len(prs.slide_layouts), | |
| ) | |
| slide = prs.slides.add_slide(prs.slide_layouts[layout_idx]) | |
| title_shape = slide.shapes.title | |
| if title_shape and title_shape.text_frame: | |
| title_shape.text_frame.clear() | |
| run = title_shape.text_frame.paragraphs[0].add_run() | |
| run.text = slide_data["title"] | |
| _apply_run_style(run, style_profile or {}) | |
| body_shape = None | |
| for shape in slide.placeholders: | |
| if shape is not title_shape and getattr(shape, "has_text_frame", False): | |
| body_shape = shape | |
| break | |
| if body_shape and body_shape.text_frame: | |
| body_shape.text_frame.clear() | |
| for idx, bullet in enumerate(slide_data["bullets"]): | |
| paragraph = ( | |
| body_shape.text_frame.paragraphs[0] | |
| if idx == 0 | |
| else body_shape.text_frame.add_paragraph() | |
| ) | |
| run = paragraph.add_run() | |
| run.text = bullet | |
| _apply_run_style(run, style_profile or {}) | |
| if output_path: | |
| prs.save(str(output_path)) | |
| return prs | |
| def compile_presentation( | |
| slides_payload: str | list[dict], | |
| template_path: str | Path, | |
| style_profile: dict | None = None, | |
| output_path: str | Path | None = None, | |
| ) -> str: | |
| destination = Path(output_path) if output_path else Path(tempfile.NamedTemporaryFile(suffix=".pptx", delete=False).name) | |
| generate_presentation( | |
| slides_payload=slides_payload, | |
| template_path=template_path, | |
| output_path=destination, | |
| style_profile=style_profile, | |
| ) | |
| return str(destination) | |
| def create_deck(template_data: dict, slide_data: str | list[dict], template_path: str | Path, output_path: str | Path | None = None): | |
| """Backward-compatible wrapper used by service layer.""" | |
| return compile_presentation( | |
| slides_payload=slide_data, | |
| template_path=template_path, | |
| style_profile=template_data, | |
| output_path=output_path, | |
| ) |