Skip to content

Commit b7982f0

Browse files
authored
Add guarded OOXML Office artifact generation
Adds minimal DOCX/XLSX/PPTX generation using standard-library OOXML package builders with evidence and tests.
1 parent 96f519f commit b7982f0

4 files changed

Lines changed: 316 additions & 14 deletions

File tree

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ sourceosctl [--version] <command> [<subcommand>] [options]
6969
| `sourceosctl office plan` | Render an OfficeArtifact-compatible workroom artifact plan |
7070
| `sourceosctl office generate --dry-run` | Render an Office generation plan without writing files |
7171
| `sourceosctl office generate --execute --policy-ok --format md|txt|json` | Write a guarded text/Markdown/JSON artifact and emit OfficeArtifactEvidence |
72+
| `sourceosctl office generate --execute --policy-ok --format docx|xlsx|pptx` | Write a guarded minimal OOXML artifact and emit OfficeArtifactEvidence |
7273
| `sourceosctl office convert <path> --to <format> --dry-run` | Render a LibreOffice-style conversion plan without writing files |
7374
| `sourceosctl office convert <path> --to <format> --execute --policy-ok` | Run guarded local LibreOffice conversion and emit OfficeArtifactEvidence |
7475
| `sourceosctl office inspect <path>` | Inspect a local office artifact file and hash it |
@@ -96,6 +97,9 @@ python3 bin/sourceosctl office doctor
9697
python3 bin/sourceosctl office plan --artifact-type slide-deck --format pptx --title "Demo Deck"
9798
python3 bin/sourceosctl office generate --dry-run --artifact-type document --format docx --title "Demo Report"
9899
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type document --format md --title "Demo Report" --evidence-out ./office-evidence.json
100+
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type document --format docx --title "Demo Report" --evidence-out ./office-docx-evidence.json
101+
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type spreadsheet --format xlsx --title "Demo Workbook" --evidence-out ./office-xlsx-evidence.json
102+
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type slide-deck --format pptx --title "Demo Deck" --evidence-out ./office-pptx-evidence.json
99103
python3 bin/sourceosctl office convert ./example.docx --to pdf --dry-run
100104
python3 bin/sourceosctl office convert ./example.docx --to pdf --execute --policy-ok --evidence-out ./office-convert-evidence.json
101105
```
@@ -145,10 +149,11 @@ Backends are modeled as an abstraction:
145149
- Microsoft Graph / Office 365 and Google Workspace: compatibility adapters, not core authority.
146150
- SourceOS-native: future native document surfaces.
147151

148-
Guarded Office execution is intentionally narrow:
152+
Guarded Office execution is intentionally bounded:
149153

150-
- `office generate --execute --policy-ok` currently writes only `txt`, `md`, or `json` artifacts.
151-
- Office binary generation (`docx`, `xlsx`, `pptx`, `odt`, `ods`, `odp`) remains disabled until template/render backends are hardened.
154+
- `office generate --execute --policy-ok` writes `txt`, `md`, `json`, `docx`, `xlsx`, or `pptx` artifacts.
155+
- DOCX/XLSX/PPTX generation uses a minimal dependency-light OOXML bootstrap builder, not a full template or collaboration engine.
156+
- ODT/ODS/ODP and other binary formats remain conversion/backend territory until LibreOffice/Collabora/ONLYOFFICE template backends are hardened.
152157
- `office convert --execute --policy-ok` uses local LibreOffice/`soffice` when available.
153158
- All guarded Office execution emits or writes `OfficeArtifactEvidence`.
154159
- Email sending, external publishing, and calendar modification remain policy-gated side effects and are not enabled here.

sourceosctl/commands/office.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""office command helpers.
22
3-
This module implements SourceOS Office Plane planning plus the first guarded
4-
local execution slice. Dry-run remains the default. File-writing behavior is
5-
available only behind --execute --policy-ok, writes only to explicit output
6-
roots, and emits OfficeArtifactEvidence-compatible JSON.
3+
This module implements SourceOS Office Plane planning plus guarded local
4+
execution. Dry-run remains the default. File-writing behavior is available
5+
only behind --execute --policy-ok, writes only to explicit output roots, and
6+
emits OfficeArtifactEvidence-compatible JSON.
77
"""
88

99
from __future__ import annotations
@@ -20,6 +20,8 @@
2020
from pathlib import Path
2121
from typing import Any, Dict, Optional
2222

23+
from sourceosctl.commands.ooxml import OOXML_GENERATION_FORMATS, write_ooxml_artifact
24+
2325

2426
DEFAULT_WORKROOM_ID = "workroom-local-default"
2527
DEFAULT_OUTPUT_ROOT = "~/Documents/SourceOS/agent-output"
@@ -63,6 +65,7 @@
6365
]
6466

6567
TEXT_GENERATION_FORMATS = {"txt", "md", "json"}
68+
GUARDED_GENERATION_FORMATS = TEXT_GENERATION_FORMATS | OOXML_GENERATION_FORMATS
6669

6770
DEFAULT_BACKEND_BY_MODE = {
6871
"local-headless": "libreoffice",
@@ -344,7 +347,7 @@ def plan(args) -> int:
344347

345348

346349
def generate(args) -> int:
347-
"""Render or execute a guarded text/json/markdown generation plan."""
350+
"""Render or execute guarded text/json/OOXML generation."""
348351
execute = bool(getattr(args, "execute", False))
349352
payload = _artifact_plan(args, "generate")
350353
payload["templateRef"] = getattr(args, "template", None)
@@ -361,9 +364,9 @@ def generate(args) -> int:
361364
return 1
362365

363366
fmt = payload["officeArtifact"]["format"]
364-
if fmt not in TEXT_GENERATION_FORMATS:
367+
if fmt not in GUARDED_GENERATION_FORMATS:
365368
print(
366-
"error: guarded generation currently supports only txt, md, or json; use convert for Office binary formats",
369+
"error: guarded generation currently supports txt, md, json, docx, xlsx, and pptx; use convert or backend adapters for other formats",
367370
file=sys.stderr,
368371
)
369372
return 1
@@ -372,16 +375,33 @@ def generate(args) -> int:
372375
output_path.parent.mkdir(parents=True, exist_ok=True)
373376
if fmt == "json":
374377
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
375-
else:
378+
notes = "sourceosctl guarded JSON Office Plane artifact generation"
379+
elif fmt in TEXT_GENERATION_FORMATS:
376380
output_path.write_text(
377381
f"# {payload['officeArtifact']['title']}\n\n"
378382
"Generated by sourceosctl Office Plane guarded execution.\n\n"
379383
f"Workroom: {payload['officeArtifact']['workroomId']}\n"
380384
f"Artifact: {payload['officeArtifact']['artifactId']}\n",
381385
encoding="utf-8",
382386
)
387+
notes = "sourceosctl guarded text/Markdown Office Plane artifact generation"
388+
else:
389+
write_ooxml_artifact(
390+
fmt=fmt,
391+
path=output_path,
392+
title=payload["officeArtifact"]["title"],
393+
workroom_id=payload["officeArtifact"]["workroomId"],
394+
artifact_id=payload["officeArtifact"]["artifactId"],
395+
)
396+
notes = "sourceosctl guarded minimal OOXML Office Plane artifact generation"
383397

384-
evidence = _build_evidence(plan=payload, operation="generate", status="requires-review", output_path=output_path)
398+
evidence = _build_evidence(
399+
plan=payload,
400+
operation="generate",
401+
status="requires-review",
402+
output_path=output_path,
403+
notes=notes,
404+
)
385405
evidence_out = getattr(args, "evidence_out", None)
386406
if evidence_out:
387407
_write_json(evidence_out, evidence)

sourceosctl/commands/ooxml.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Minimal OOXML artifact builders for SourceOS Office Plane.
2+
3+
These helpers intentionally use only Python's standard library. They create
4+
small, deterministic-enough DOCX/XLSX/PPTX ZIP packages for guarded local
5+
artifact generation. They are not a replacement for LibreOffice, Collabora,
6+
ONLYOFFICE, or a full template engine; they are the safe local bootstrap path
7+
for simple agent-authored workroom artifacts.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from html import escape
13+
from pathlib import Path
14+
from zipfile import ZIP_DEFLATED, ZipFile
15+
16+
17+
OOXML_GENERATION_FORMATS = {"docx", "xlsx", "pptx"}
18+
19+
20+
def _xml(text: str) -> str:
21+
return escape(text, quote=True)
22+
23+
24+
def _write_zip(path: Path, files: dict[str, str]) -> None:
25+
path.parent.mkdir(parents=True, exist_ok=True)
26+
with ZipFile(path, "w", ZIP_DEFLATED) as zf:
27+
for name in sorted(files):
28+
zf.writestr(name, files[name])
29+
30+
31+
def write_ooxml_artifact(
32+
*,
33+
fmt: str,
34+
path: Path,
35+
title: str,
36+
workroom_id: str,
37+
artifact_id: str,
38+
) -> None:
39+
"""Write a minimal OOXML artifact to path.
40+
41+
Args:
42+
fmt: one of docx, xlsx, pptx.
43+
path: output path.
44+
title: human-readable artifact title.
45+
workroom_id: Professional Workroom id.
46+
artifact_id: OfficeArtifact id.
47+
"""
48+
if fmt == "docx":
49+
_write_docx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
50+
return
51+
if fmt == "xlsx":
52+
_write_xlsx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
53+
return
54+
if fmt == "pptx":
55+
_write_pptx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
56+
return
57+
raise ValueError(f"unsupported OOXML generation format: {fmt}")
58+
59+
60+
def _write_docx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
61+
document = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
62+
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
63+
<w:body>
64+
<w:p><w:r><w:t>{_xml(title)}</w:t></w:r></w:p>
65+
<w:p><w:r><w:t>Generated by SourceOS Office Plane guarded OOXML generation.</w:t></w:r></w:p>
66+
<w:p><w:r><w:t>Workroom: {_xml(workroom_id)}</w:t></w:r></w:p>
67+
<w:p><w:r><w:t>Artifact: {_xml(artifact_id)}</w:t></w:r></w:p>
68+
<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>
69+
</w:body>
70+
</w:document>
71+
'''
72+
files = {
73+
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
74+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
75+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
76+
<Default Extension="xml" ContentType="application/xml"/>
77+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
78+
</Types>
79+
''',
80+
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
81+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
82+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
83+
</Relationships>
84+
''',
85+
"word/document.xml": document,
86+
}
87+
_write_zip(path, files)
88+
89+
90+
def _write_xlsx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
91+
rows = [
92+
("Title", title),
93+
("Generated By", "SourceOS Office Plane guarded OOXML generation"),
94+
("Workroom", workroom_id),
95+
("Artifact", artifact_id),
96+
]
97+
row_xml = []
98+
for idx, (key, value) in enumerate(rows, start=1):
99+
row_xml.append(
100+
f'''<row r="{idx}"><c r="A{idx}" t="inlineStr"><is><t>{_xml(key)}</t></is></c><c r="B{idx}" t="inlineStr"><is><t>{_xml(value)}</t></is></c></row>'''
101+
)
102+
sheet = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
103+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
104+
<sheetData>
105+
{''.join(row_xml)}
106+
</sheetData>
107+
</worksheet>
108+
'''
109+
files = {
110+
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
111+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
112+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
113+
<Default Extension="xml" ContentType="application/xml"/>
114+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
115+
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
116+
</Types>
117+
''',
118+
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
119+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
120+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
121+
</Relationships>
122+
''',
123+
"xl/workbook.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
124+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
125+
<sheets><sheet name="SourceOS" sheetId="1" r:id="rId1"/></sheets>
126+
</workbook>
127+
''',
128+
"xl/_rels/workbook.xml.rels": '''<?xml version="1.0" encoding="UTF-8"?>
129+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
130+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
131+
</Relationships>
132+
''',
133+
"xl/worksheets/sheet1.xml": sheet,
134+
}
135+
_write_zip(path, files)
136+
137+
138+
def _write_pptx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
139+
slide = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
140+
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
141+
<p:cSld>
142+
<p:spTree>
143+
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
144+
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
145+
<p:sp>
146+
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
147+
<p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="7315200" cy="914400"/></a:xfrm></p:spPr>
148+
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{_xml(title)}</a:t></a:r></a:p></p:txBody>
149+
</p:sp>
150+
<p:sp>
151+
<p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
152+
<p:spPr><a:xfrm><a:off x="914400" y="2133600"/><a:ext cx="7315200" cy="2743200"/></a:xfrm></p:spPr>
153+
<p:txBody><a:bodyPr/><a:lstStyle/>
154+
<a:p><a:r><a:t>Generated by SourceOS Office Plane guarded OOXML generation.</a:t></a:r></a:p>
155+
<a:p><a:r><a:t>Workroom: {_xml(workroom_id)}</a:t></a:r></a:p>
156+
<a:p><a:r><a:t>Artifact: {_xml(artifact_id)}</a:t></a:r></a:p>
157+
</p:txBody>
158+
</p:sp>
159+
</p:spTree>
160+
</p:cSld>
161+
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
162+
</p:sld>
163+
'''
164+
files = {
165+
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
166+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
167+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
168+
<Default Extension="xml" ContentType="application/xml"/>
169+
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
170+
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
171+
</Types>
172+
''',
173+
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
174+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
175+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
176+
</Relationships>
177+
''',
178+
"ppt/presentation.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
179+
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
180+
<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>
181+
<p:sldSz cx="9144000" cy="6858000" type="screen4x3"/>
182+
<p:notesSz cx="6858000" cy="9144000"/>
183+
</p:presentation>
184+
''',
185+
"ppt/_rels/presentation.xml.rels": '''<?xml version="1.0" encoding="UTF-8"?>
186+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
187+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
188+
</Relationships>
189+
''',
190+
"ppt/slides/slide1.xml": slide,
191+
}
192+
_write_zip(path, files)

0 commit comments

Comments
 (0)