Harden Office canvas sync and PPTX output
Sync document_artifact results into an already-open Office canvas without auto-opening a closed canvas. Generate PPTX artifacts through the Office plugin writer so PowerPoint decks open in Impress with visible multi-slide content. Add focused regression coverage for canvas sync behavior and PPTX slide creation.
Alessandro committed
May 2, 2026 at 15:28 UTC
3466160e4ce5c46b9a01a5f5796746746d02dd9a
6 files changed
+370
-32
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+121
-2
@@ -1,3 +1,122 @@
1
-export default async function autoOpenDocumentResults(_context) {
2
- return;
1
+const SYNC_WINDOW_MS = 10 * 60 * 1000;
2
+const syncedDocumentResults = new Set();
3
+
4
+export default async function syncDocumentResultsIntoOpenCanvas(context) {
5
+ if (!context?.results?.length || context.historyEmpty) return;
6
+ if (!isOfficeCanvasAlreadyOpen()) return;
7
+
8
+ for (const { args } of context.results) {
9
+ const payload = getDocumentPayload(args);
10
+ if (getToolName(payload) !== "document_artifact") continue;
11
+ if (!shouldSyncOpenOfficeCanvas(args, payload)) continue;
12
+
13
+ const document = payload.document && typeof payload.document === "object" ? payload.document : {};
14
+ const path = payload.path || document.path || "";
15
+ const fileId = payload.file_id || document.file_id || "";
16
+ if (!path && !fileId) continue;
17
+
18
+ const key = [
19
+ args?.id || "",
20
+ payload.action || "",
21
+ fileId || "",
22
+ path || "",
23
+ payload.version || document.version || "",
24
+ ].join(":");
25
+ if (syncedDocumentResults.has(key)) continue;
26
+ syncedDocumentResults.add(key);
27
+
28
+ globalThis.setTimeout(async () => {
29
+ if (!isOfficeCanvasAlreadyOpen()) return;
30
+ const office = globalThis.Alpine?.store?.("office");
31
+ if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
32
+ await office.openSession?.({
33
+ path,
34
+ file_id: fileId,
35
+ source: "tool-result-sync",
36
+ });
37
+ }, 0);
38
+ }
39
+}
40
+
41
+function getDocumentPayload(args = {}) {
42
+ const contentPayload = parseMaybeJson(args.content);
43
+ const kvpsPayload = args.kvps && typeof args.kvps === "object"
44
+ ? args.kvps
45
+ : parseMaybeJson(args.kvps);
46
+ return {
47
+ ...pickPayloadFields(args),
48
+ ...(contentPayload || {}),
49
+ ...(kvpsPayload || {}),
50
+ };
51
+}
52
+
53
+function pickPayloadFields(args = {}) {
54
+ const payload = {};
55
+ for (const key of [
56
+ "_tool_name",
57
+ "tool_name",
58
+ "action",
59
+ "file_id",
60
+ "path",
61
+ "version",
62
+ "last_modified",
63
+ ]) {
64
+ if (args[key] != null && args[key] !== "") payload[key] = args[key];
65
+ }
66
+ return payload;
67
+}
68
+
69
+function getToolName(payload = {}) {
70
+ return String(payload._tool_name || payload.tool_name || "").trim();
71
+}
72
+
73
+function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
74
+ if (!isFresh(args.timestamp, payload.last_modified || payload.document?.last_modified)) return false;
75
+ const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
76
+ return ["create", "open", "edit", "restore_version"].includes(action);
77
+}
78
+
79
+function isOfficeCanvasAlreadyOpen() {
80
+ const canvas = globalThis.Alpine?.store?.("rightCanvas");
81
+ return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
82
+}
83
+
84
+function isDirtySameDocument(office, document = {}) {
85
+ if (!office?.dirty || !office?.session) return false;
86
+ const path = String(document.path || "");
87
+ const fileId = String(document.file_id || "");
88
+ return Boolean(
89
+ (fileId && office.session.file_id === fileId)
90
+ || (path && office.session.path === path),
91
+ );
92
+}
93
+
94
+function isFresh(...timestamps) {
95
+ const now = Date.now();
96
+ for (const value of timestamps) {
97
+ const time = parseTimestamp(value);
98
+ if (time && now - time < SYNC_WINDOW_MS) return true;
99
+ }
100
+ return false;
101
+}
102
+
103
+function parseTimestamp(value) {
104
+ if (!value) return 0;
105
+ if (typeof value === "number") return value > 1e12 ? value : value * 1000;
106
+ const parsed = Date.parse(String(value));
107
+ return Number.isFinite(parsed) ? parsed : 0;
108
+}
109
+
110
+function parseMaybeJson(value) {
111
+ if (!value) return null;
112
+ if (typeof value === "object") return value;
113
+ if (typeof value !== "string") return null;
114
+ const trimmed = value.trim();
115
+ if (!trimmed.startsWith("{")) return null;
116
+ try {
117
+ const parsed = JSON.parse(trimmed);
118
+ return parsed && typeof parsed === "object" ? parsed : null;
119
+ } catch {
120
+ return null;
121
+ }
122
}
plugins/_office/helpers/artifact_editor.py
+2
-20
@@ -10,7 +10,7 @@ from typing import Any
10
from xml.sax.saxutils import escape
11
import xml.etree.ElementTree as ET
12
13
-from plugins._office.helpers import document_store
13
+from plugins._office.helpers import document_store, pptx_writer
14
15
16
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -1205,25 +1205,7 @@ def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
1205
1206
1207
def _pptx_from_slides(slides: list[dict[str, Any]]) -> bytes:
1208
- if not slides:
1209
- slides = [{"title": "Presentation", "bullets": []}]
1210
-
1211
- files: dict[str, str | bytes] = {
1212
- "[Content_Types].xml": _pptx_content_types(len(slides)),
1213
- "_rels/.rels": (
1214
- '<?xml version="1.0" encoding="UTF-8"?>'
1215
- f'<Relationships xmlns="{REL_NS}">'
1216
- '<Relationship Id="rId1" '
1217
- 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
1218
- 'Target="ppt/presentation.xml"/>'
1219
- "</Relationships>"
1220
- ),
1221
- "ppt/_rels/presentation.xml.rels": _pptx_presentation_rels(len(slides)),
1222
- "ppt/presentation.xml": _pptx_presentation_xml(len(slides)),
1223
- }
1224
- for index, slide in enumerate(slides, start=1):
1225
- files[f"ppt/slides/slide{index}.xml"] = _pptx_slide_xml(slide)
1226
- return _zip_map(files)
1208
+ return pptx_writer.pptx_from_slides(slides)
1209
1210
1211
def _pptx_content_types(count: int) -> str:
plugins/_office/helpers/document_store.py
+2
-8
@@ -17,6 +17,7 @@ from typing import Any
17
from xml.sax.saxutils import escape
18
19
from helpers import files
20
+from plugins._office.helpers import pptx_writer
21
22
23
PLUGIN_NAME = "_office"
@@ -829,11 +830,4 @@ def _column_name(index: int) -> str:
830
831
832
def _pptx(title: str, content: str) -> bytes:
832
- subtitle = content.splitlines()[0] if content.splitlines() else ""
833
- return _zip_bytes({
834
- "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>""",
835
- "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>""",
836
- "ppt/_rels/presentation.xml.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>""",
837
- "ppt/presentation.xml": """<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="5143500"/></p:presentation>""",
838
- "ppt/slides/slide1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{escape(title)}</a:t></a:r></a:p><a:p><a:r><a:t>{escape(subtitle)}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>""",
839
- })
833
+ return pptx_writer.pptx_from_text(title, content)
plugins/_office/helpers/pptx_writer.py
new
+225
@@ -0,0 +1,225 @@
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()
tests/test_office_canvas_setup.py
+5
-1
@@ -321,7 +321,11 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
321
assert "body.right-canvas-mobile-mode .right-canvas" in canvas_css
322
assert "display: none !important" in canvas_css
323
assert "autoOpenOfficeCanvas" not in handler
324
- assert "requestAnimationFrame" not in after_loop
324
+ assert "isOfficeCanvasAlreadyOpen" in after_loop
325
+ assert 'canvas?.isOpen && canvas?.activeSurfaceId === "office"' in after_loop
326
+ assert "office.openSession?.(" in after_loop
327
+ assert 'source: "tool-result-sync"' in after_loop
328
+ assert 'rightCanvas.open' not in after_loop
329
330
331
def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
tests/test_office_document_store.py
+15
-1
@@ -112,7 +112,21 @@ def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state):
112
assert rows[1][1] == 12500
113
assert rows[2][0] == "Research"
114
115
- deck = document_store.create_document("presentation", "Roadmap", "pptx", "Initial")
115
+ deck = document_store.create_document(
116
+ "presentation",
117
+ "Roadmap",
118
+ "pptx",
119
+ "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout",
120
+ )
121
+ created_deck_read = artifact_editor.read_artifact(deck)
122
+ with zipfile.ZipFile(deck["path"]) as archive:
123
+ created_slide_names = [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")]
124
+
125
+ assert created_deck_read["slide_count"] == 2
126
+ assert created_deck_read["slides"][0]["title"] == "Roadmap"
127
+ assert created_deck_read["slides"][1]["title"] == "Next"
128
+ assert len(created_slide_names) == 2
129
+
130
updated_deck, deck_payload = artifact_editor.edit_artifact(
131
deck,
132
operation="set_slides",