Skip to content

Commit cff21bd

Browse files
committed
feat: export a report as Word (.docx)
Add a to_docx export that walks the report views and writes headings, markdown, native tables and chart images into a Word document, wired into the export menu and the mobile SpeedDial. python-docx is an optional dependency: to_docx checks for it via try_import and raises a clear error if it is missing, and the module no longer imports docx at load time.
1 parent 90e6a80 commit cff21bd

2 files changed

Lines changed: 128 additions & 2 deletions

File tree

lumen/ai/export.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,58 @@ def export_notebook(outputs: list[Viewable], preamble: str = ""):
133133
cells, extensions = render_cells(outputs)
134134
cells = make_preamble(preamble, extensions=extensions) + cells
135135
return write_notebook(cells)
136+
137+
138+
def _docx_add_runs(paragraph, text: str):
139+
"""Add text to a paragraph, rendering ``**bold**`` spans as bold runs."""
140+
for i, part in enumerate(re.split(r'\*\*(.+?)\*\*', text)):
141+
if not part:
142+
continue
143+
run = paragraph.add_run(part)
144+
if i % 2 == 1:
145+
run.bold = True
146+
147+
148+
def docx_add_markdown(doc, text: str):
149+
"""Render basic markdown (headings, bullets, bold) into a docx document."""
150+
for line in text.splitlines():
151+
stripped = line.strip()
152+
if not stripped:
153+
continue
154+
if stripped.startswith('#'):
155+
level = len(stripped) - len(stripped.lstrip('#'))
156+
doc.add_heading(stripped.lstrip('# ').strip(), level=min(level, 4))
157+
elif stripped.startswith(('- ', '* ')):
158+
_docx_add_runs(doc.add_paragraph(style='List Bullet'), stripped[2:])
159+
else:
160+
_docx_add_runs(doc.add_paragraph(), stripped)
161+
162+
163+
def docx_add_table(doc, df, max_rows: int = 50):
164+
"""Render a DataFrame as a native Word table (header + capped rows)."""
165+
table = doc.add_table(rows=1, cols=len(df.columns))
166+
table.style = 'Table Grid'
167+
for cell, col in zip(table.rows[0].cells, df.columns, strict=False):
168+
cell.text = str(col)
169+
for _, record in df.head(max_rows).iterrows():
170+
cells = table.add_row().cells
171+
for cell, value in zip(cells, record, strict=False):
172+
cell.text = '' if value is None else str(value)
173+
if len(df) > max_rows:
174+
doc.add_paragraph(f'... {len(df) - max_rows} more rows')
175+
176+
177+
def docx_add_chart(doc, editor) -> bool:
178+
"""Embed a chart editor as a PNG image; return True if one was added."""
179+
from docx.shared import Inches
180+
181+
if 'png' not in editor.export_formats:
182+
return False
183+
try:
184+
png = editor.export('png')
185+
except Exception:
186+
# Best-effort: a chart we cannot render should not abort the document.
187+
return False
188+
png.seek(0)
189+
doc.add_picture(png, width=Inches(6))
190+
return True

lumen/ai/report.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from ..config import config
3131
from ..pipeline import Pipeline
32+
from ..util import try_import
3233
from ..views.base import Panel, View
3334
from .actor import (
3435
Actor, ContextProvider, NullStep, TContext,
@@ -939,6 +940,7 @@ def _init_view(self):
939940
items=[
940941
{"label": "Notebook (.ipynb)", "format": "ipynb", "icon": "description"},
941942
{"label": "HTML (.html)", "format": "html", "icon": "language"},
943+
{"label": "Word (.docx)", "format": "docx", "icon": "article"},
942944
],
943945
on_click=lambda _: self._download._transfer(),
944946
icon_size="36px",
@@ -1030,6 +1032,7 @@ def _init_view(self):
10301032
{"label": "Switch Report/Story View", "icon": "swap_horiz"},
10311033
{"label": "Export as Notebook", "icon": "description", "format": "ipynb"},
10321034
{"label": "Export as HTML", "icon": "language", "format": "html"},
1035+
{"label": "Export as Word", "icon": "article", "format": "docx"},
10331036
{"label": "Configure Report", "icon": "settings"}
10341037
],
10351038
color="default",
@@ -1081,7 +1084,7 @@ async def _trigger_event(self, item: dict):
10811084
self._open_arrange_dialog()
10821085
elif icon == "swap_horiz":
10831086
self._toggle_view()
1084-
elif icon in ("description", "language"):
1087+
elif icon in ("description", "language", "article"):
10851088
fmt = item.get("format", "ipynb")
10861089
self._export.value = {"format": fmt}
10871090
self._download._transfer()
@@ -1261,15 +1264,83 @@ def to_html(self):
12611264
self._export_view().save(buf, title=self.title or "Report")
12621265
return buf.getvalue()
12631266

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+
12641333
async def _export_report(self, item=None):
12651334
if len(self) and self.status not in ("success", "cancelled", "error"):
12661335
await self.execute()
12671336
fmt = item.get("format", "ipynb") if isinstance(item, dict) else "ipynb"
12681337
title = self.title or "Report"
1269-
ext = "html" if fmt == "html" else "ipynb"
1338+
ext = {"html": "html", "docx": "docx"}.get(fmt, "ipynb")
12701339
self._download.filename = f"{title}.{ext}"
12711340
if fmt == "html":
12721341
return io.StringIO(self.to_html())
1342+
if fmt == "docx":
1343+
return io.BytesIO(self.to_docx())
12731344
return io.StringIO(self.to_notebook())
12741345

12751346
def _expand_all(self, event=None):

0 commit comments

Comments
 (0)