main
py 225 lines 8.79 KB
Raw
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()