| 1 | from __future__ import annotations |
| 2 | |
| 3 | import io |
| 4 | import json |
| 5 | import re |
| 6 | import zipfile |
| 7 | from typing import Any |
| 8 | from xml.sax.saxutils import escape |
| 9 | |
| 10 | |
| 11 | A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 12 | P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 13 | R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" |
| 14 | CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" |
| 15 | REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" |
| 16 | |
| 17 | |
| 18 | def pptx_from_text(title: str, content: str) -> bytes: |
| 19 | return pptx_from_slides(slides_from_text(title, content)) |
| 20 | |
| 21 | |
| 22 | def slides_from_text(title: str, content: str) -> list[dict[str, Any]]: |
| 23 | normalized = normalize_slides(content) |
| 24 | document_title = str(title or "Presentation").strip() or "Presentation" |
| 25 | if not normalized: |
| 26 | return [{"title": document_title, "bullets": []}] |
| 27 | |
| 28 | text = str(content or "") |
| 29 | if len(normalized) == 1 and "---" not in text: |
| 30 | slide = normalized[0] |
| 31 | slide_title = str(slide.get("title") or "").strip() |
| 32 | if slide_title and slide_title.casefold() != document_title.casefold(): |
| 33 | return [{"title": document_title, "bullets": [slide_title, *slide.get("bullets", [])]}] |
| 34 | return normalized |
| 35 | |
| 36 | |
| 37 | def normalize_slides(value: Any) -> list[dict[str, Any]]: |
| 38 | if value is None: |
| 39 | return [] |
| 40 | if isinstance(value, str): |
| 41 | stripped = value.strip() |
| 42 | if not stripped: |
| 43 | return [] |
| 44 | if stripped.startswith("[") or stripped.startswith("{"): |
| 45 | return normalize_slides(json.loads(stripped)) |
| 46 | chunks = re.split(r"(?m)^\s*---+\s*$", stripped) |
| 47 | result = [] |
| 48 | for chunk in chunks: |
| 49 | lines = [_clean_slide_line(line) for line in chunk.splitlines() if line.strip()] |
| 50 | lines = [line for line in lines if line] |
| 51 | if lines: |
| 52 | result.append({"title": lines[0], "bullets": lines[1:]}) |
| 53 | return result |
| 54 | if isinstance(value, dict): |
| 55 | return [_slide_from_mapping(value)] |
| 56 | if isinstance(value, list): |
| 57 | result = [] |
| 58 | for item in value: |
| 59 | if isinstance(item, dict): |
| 60 | result.append(_slide_from_mapping(item)) |
| 61 | elif isinstance(item, str): |
| 62 | result.extend(normalize_slides(item)) |
| 63 | elif isinstance(item, (list, tuple)): |
| 64 | lines = [_clean_slide_line(part) for part in item if str(part).strip()] |
| 65 | if lines: |
| 66 | result.append({"title": lines[0], "bullets": lines[1:]}) |
| 67 | else: |
| 68 | result.append({"title": str(item), "bullets": []}) |
| 69 | return result |
| 70 | return [{"title": str(value), "bullets": []}] |
| 71 | |
| 72 | |
| 73 | def pptx_from_slides(slides: list[dict[str, Any]]) -> bytes: |
| 74 | normalized = normalize_slides(slides) |
| 75 | if not normalized: |
| 76 | normalized = [{"title": "Presentation", "bullets": []}] |
| 77 | try: |
| 78 | return _pptx_from_slides_with_python_pptx(normalized) |
| 79 | except Exception: |
| 80 | return _pptx_from_slides_ooxml(normalized) |
| 81 | |
| 82 | |
| 83 | def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]: |
| 84 | title = _clean_slide_line(value.get("title") or value.get("heading") or "Slide") |
| 85 | bullets = value.get("bullets") |
| 86 | if bullets is None: |
| 87 | body = value.get("body") or value.get("content") or "" |
| 88 | bullets = [_clean_slide_line(line) for line in str(body).splitlines() if line.strip()] |
| 89 | elif isinstance(bullets, str): |
| 90 | bullets = [_clean_slide_line(line) for line in bullets.splitlines() if line.strip()] |
| 91 | else: |
| 92 | bullets = [_clean_slide_line(item) for item in bullets] |
| 93 | return {"title": title or "Slide", "bullets": [bullet for bullet in bullets if bullet]} |
| 94 | |
| 95 | |
| 96 | def _clean_slide_line(value: Any) -> str: |
| 97 | line = str(value or "").strip() |
| 98 | line = re.sub(r"^\s{0,3}#{1,6}\s+", "", line) |
| 99 | line = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s+", "", line) |
| 100 | return line.strip() |
| 101 | |
| 102 | |
| 103 | def _pptx_from_slides_with_python_pptx(slides: list[dict[str, Any]]) -> bytes: |
| 104 | from pptx import Presentation # type: ignore |
| 105 | from pptx.util import Inches # type: ignore |
| 106 | |
| 107 | presentation = Presentation() |
| 108 | for slide_spec in slides: |
| 109 | layout = presentation.slide_layouts[1] if len(presentation.slide_layouts) > 1 else presentation.slide_layouts[0] |
| 110 | slide = presentation.slides.add_slide(layout) |
| 111 | title = str(slide_spec.get("title") or "Slide") |
| 112 | bullets = [str(item) for item in slide_spec.get("bullets") or []] |
| 113 | |
| 114 | if slide.shapes.title: |
| 115 | slide.shapes.title.text = title |
| 116 | else: |
| 117 | title_box = slide.shapes.add_textbox(Inches(0.6), Inches(0.35), Inches(8.8), Inches(0.8)) |
| 118 | title_box.text_frame.text = title |
| 119 | |
| 120 | body_shape = slide.placeholders[1] if len(slide.placeholders) > 1 else None |
| 121 | if body_shape is None: |
| 122 | body_shape = slide.shapes.add_textbox(Inches(0.85), Inches(1.45), Inches(8.35), Inches(4.55)) |
| 123 | |
| 124 | text_frame = body_shape.text_frame |
| 125 | text_frame.clear() |
| 126 | if not bullets: |
| 127 | text_frame.text = "" |
| 128 | continue |
| 129 | |
| 130 | for index, bullet in enumerate(bullets): |
| 131 | paragraph = text_frame.paragraphs[0] if index == 0 else text_frame.add_paragraph() |
| 132 | paragraph.text = bullet |
| 133 | paragraph.level = 0 |
| 134 | |
| 135 | buffer = io.BytesIO() |
| 136 | presentation.save(buffer) |
| 137 | return buffer.getvalue() |
| 138 | |
| 139 | |
| 140 | def _pptx_from_slides_ooxml(slides: list[dict[str, Any]]) -> bytes: |
| 141 | files: dict[str, str | bytes] = { |
| 142 | "[Content_Types].xml": _pptx_content_types(len(slides)), |
| 143 | "_rels/.rels": ( |
| 144 | '<?xml version="1.0" encoding="UTF-8"?>' |
| 145 | f'<Relationships xmlns="{REL_NS}">' |
| 146 | '<Relationship Id="rId1" ' |
| 147 | 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' |
| 148 | 'Target="ppt/presentation.xml"/>' |
| 149 | "</Relationships>" |
| 150 | ), |
| 151 | "ppt/_rels/presentation.xml.rels": _pptx_presentation_rels(len(slides)), |
| 152 | "ppt/presentation.xml": _pptx_presentation_xml(len(slides)), |
| 153 | } |
| 154 | for index, slide in enumerate(slides, start=1): |
| 155 | files[f"ppt/slides/slide{index}.xml"] = _pptx_slide_xml(slide) |
| 156 | return _zip_map(files) |
| 157 | |
| 158 | |
| 159 | def _pptx_content_types(count: int) -> str: |
| 160 | overrides = [ |
| 161 | '<Override PartName="/ppt/presentation.xml" ' |
| 162 | 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>' |
| 163 | ] |
| 164 | for index in range(1, count + 1): |
| 165 | overrides.append( |
| 166 | f'<Override PartName="/ppt/slides/slide{index}.xml" ' |
| 167 | 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>' |
| 168 | ) |
| 169 | return ( |
| 170 | '<?xml version="1.0" encoding="UTF-8"?>' |
| 171 | f'<Types xmlns="{CT_NS}">' |
| 172 | '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' |
| 173 | '<Default Extension="xml" ContentType="application/xml"/>' |
| 174 | + "".join(overrides) |
| 175 | + "</Types>" |
| 176 | ) |
| 177 | |
| 178 | |
| 179 | def _pptx_presentation_rels(count: int) -> str: |
| 180 | rels = [] |
| 181 | for index in range(1, count + 1): |
| 182 | rels.append( |
| 183 | f'<Relationship Id="rId{index}" ' |
| 184 | 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" ' |
| 185 | f'Target="slides/slide{index}.xml"/>' |
| 186 | ) |
| 187 | return '<?xml version="1.0" encoding="UTF-8"?>' + f'<Relationships xmlns="{REL_NS}">' + "".join(rels) + "</Relationships>" |
| 188 | |
| 189 | |
| 190 | def _pptx_presentation_xml(count: int) -> str: |
| 191 | slide_ids = "".join(f'<p:sldId id="{255 + index}" r:id="rId{index}"/>' for index in range(1, count + 1)) |
| 192 | return ( |
| 193 | '<?xml version="1.0" encoding="UTF-8"?>' |
| 194 | f'<p:presentation xmlns:p="{P_NS}" xmlns:r="{R_NS}">' |
| 195 | f"<p:sldIdLst>{slide_ids}</p:sldIdLst>" |
| 196 | '<p:sldSz cx="9144000" cy="5143500"/>' |
| 197 | "</p:presentation>" |
| 198 | ) |
| 199 | |
| 200 | |
| 201 | def _pptx_slide_xml(slide: dict[str, Any]) -> str: |
| 202 | title = str(slide.get("title") or "Slide") |
| 203 | bullets = [str(item) for item in slide.get("bullets") or []] |
| 204 | paragraphs = [title, *bullets] |
| 205 | text = "".join(f"<a:p><a:r><a:t>{escape(item)}</a:t></a:r></a:p>" for item in paragraphs) |
| 206 | return ( |
| 207 | '<?xml version="1.0" encoding="UTF-8"?>' |
| 208 | f'<p:sld xmlns:a="{A_NS}" xmlns:p="{P_NS}">' |
| 209 | "<p:cSld><p:spTree>" |
| 210 | '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' |
| 211 | "<p:grpSpPr/>" |
| 212 | '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Content"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>' |
| 213 | f"<p:txBody><a:bodyPr/><a:lstStyle/>{text}</p:txBody>" |
| 214 | "</p:sp>" |
| 215 | "</p:spTree></p:cSld>" |
| 216 | "</p:sld>" |
| 217 | ) |
| 218 | |
| 219 | |
| 220 | def _zip_map(files_map: dict[str, str | bytes]) -> bytes: |
| 221 | buffer = io.BytesIO() |
| 222 | with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: |
| 223 | for name, value in files_map.items(): |
| 224 | archive.writestr(name, value.encode("utf-8") if isinstance(value, str) else value) |
| 225 | return buffer.getvalue() |