|
29 | 29 |
|
30 | 30 | from ..config import config |
31 | 31 | from ..pipeline import Pipeline |
| 32 | +from ..util import try_import |
32 | 33 | from ..views.base import Panel, View |
33 | 34 | from .actor import ( |
34 | 35 | Actor, ContextProvider, NullStep, TContext, |
@@ -939,6 +940,7 @@ def _init_view(self): |
939 | 940 | items=[ |
940 | 941 | {"label": "Notebook (.ipynb)", "format": "ipynb", "icon": "description"}, |
941 | 942 | {"label": "HTML (.html)", "format": "html", "icon": "language"}, |
| 943 | + {"label": "Word (.docx)", "format": "docx", "icon": "article"}, |
942 | 944 | ], |
943 | 945 | on_click=lambda _: self._download._transfer(), |
944 | 946 | icon_size="36px", |
@@ -1030,6 +1032,7 @@ def _init_view(self): |
1030 | 1032 | {"label": "Switch Report/Story View", "icon": "swap_horiz"}, |
1031 | 1033 | {"label": "Export as Notebook", "icon": "description", "format": "ipynb"}, |
1032 | 1034 | {"label": "Export as HTML", "icon": "language", "format": "html"}, |
| 1035 | + {"label": "Export as Word", "icon": "article", "format": "docx"}, |
1033 | 1036 | {"label": "Configure Report", "icon": "settings"} |
1034 | 1037 | ], |
1035 | 1038 | color="default", |
@@ -1081,7 +1084,7 @@ async def _trigger_event(self, item: dict): |
1081 | 1084 | self._open_arrange_dialog() |
1082 | 1085 | elif icon == "swap_horiz": |
1083 | 1086 | self._toggle_view() |
1084 | | - elif icon in ("description", "language"): |
| 1087 | + elif icon in ("description", "language", "article"): |
1085 | 1088 | fmt = item.get("format", "ipynb") |
1086 | 1089 | self._export.value = {"format": fmt} |
1087 | 1090 | self._download._transfer() |
@@ -1261,15 +1264,83 @@ def to_html(self): |
1261 | 1264 | self._export_view().save(buf, title=self.title or "Report") |
1262 | 1265 | return buf.getvalue() |
1263 | 1266 |
|
| 1267 | + def to_docx(self) -> bytes: |
| 1268 | + """ |
| 1269 | + Returns the Word (.docx) representation of the report as bytes. |
| 1270 | + """ |
| 1271 | + if len(self) and self.status != "success": |
| 1272 | + raise RuntimeError( |
| 1273 | + "Report has not been executed, run report before exporting to docx." |
| 1274 | + ) |
| 1275 | + if try_import("docx") is None: |
| 1276 | + raise ImportError( |
| 1277 | + "Exporting a report to Word requires python-docx; install it with " |
| 1278 | + "`pip install python-docx`." |
| 1279 | + ) |
| 1280 | + from docx import Document |
| 1281 | + |
| 1282 | + from .export import docx_add_chart, docx_add_markdown, docx_add_table |
| 1283 | + |
| 1284 | + doc = Document() |
| 1285 | + pending: list[tuple[int, str]] = [] |
| 1286 | + |
| 1287 | + def flush_headers(): |
| 1288 | + for level, heading in pending: |
| 1289 | + doc.add_heading(heading, level=level or 1) |
| 1290 | + pending.clear() |
| 1291 | + |
| 1292 | + for out in self._export_views: |
| 1293 | + if isinstance(out, Typography): |
| 1294 | + obj = out.object or "" |
| 1295 | + if out.variant and out.variant.startswith('h'): |
| 1296 | + level, heading = int(out.variant[1:]), obj |
| 1297 | + elif obj.startswith('#'): |
| 1298 | + level = len(obj) - len(obj.lstrip('#')) |
| 1299 | + heading = obj.lstrip('# ').strip() |
| 1300 | + else: |
| 1301 | + level, heading = 1, obj |
| 1302 | + pending.append((min(level, 4), heading)) |
| 1303 | + continue |
| 1304 | + if isinstance(out, LumenEditor): |
| 1305 | + has_chart = 'png' in out.export_formats |
| 1306 | + data = getattr(out.component, 'data', None) |
| 1307 | + if not has_chart and data is None: |
| 1308 | + continue |
| 1309 | + flush_headers() |
| 1310 | + # Prefer the chart as an image; fall back to a data table when |
| 1311 | + # there is no renderable chart. |
| 1312 | + if not (has_chart and docx_add_chart(doc, out)) and data is not None: |
| 1313 | + docx_add_table(doc, data) |
| 1314 | + continue |
| 1315 | + if isinstance(out, Markdown): |
| 1316 | + text = out.object |
| 1317 | + elif isinstance(out, str): |
| 1318 | + text = out |
| 1319 | + elif isinstance(out, ChatMessage): |
| 1320 | + inner = out.object |
| 1321 | + text = inner.object if isinstance(inner, Markdown) else (inner if isinstance(inner, str) else None) |
| 1322 | + else: |
| 1323 | + text = None |
| 1324 | + if text is None: |
| 1325 | + continue |
| 1326 | + flush_headers() |
| 1327 | + docx_add_markdown(doc, text) |
| 1328 | + |
| 1329 | + buf = io.BytesIO() |
| 1330 | + doc.save(buf) |
| 1331 | + return buf.getvalue() |
| 1332 | + |
1264 | 1333 | async def _export_report(self, item=None): |
1265 | 1334 | if len(self) and self.status not in ("success", "cancelled", "error"): |
1266 | 1335 | await self.execute() |
1267 | 1336 | fmt = item.get("format", "ipynb") if isinstance(item, dict) else "ipynb" |
1268 | 1337 | title = self.title or "Report" |
1269 | | - ext = "html" if fmt == "html" else "ipynb" |
| 1338 | + ext = {"html": "html", "docx": "docx"}.get(fmt, "ipynb") |
1270 | 1339 | self._download.filename = f"{title}.{ext}" |
1271 | 1340 | if fmt == "html": |
1272 | 1341 | return io.StringIO(self.to_html()) |
| 1342 | + if fmt == "docx": |
| 1343 | + return io.BytesIO(self.to_docx()) |
1273 | 1344 | return io.StringIO(self.to_notebook()) |
1274 | 1345 |
|
1275 | 1346 | def _expand_all(self, event=None): |
|
0 commit comments