From 4f95250d0b38cc5a3a15ad72fffc54a839de8dee Mon Sep 17 00:00:00 2001
From: Alessandro <155005371+3clyp50@users.noreply.github.com>
Date: Tue, 28 Apr 2026 03:04:52 +0200
Subject: [PATCH] Add native XLSX chart support
Teach document_artifact to create embedded spreadsheet charts through a native create_chart operation, including generic line/bar/column/pie/area/scatter support and stock-style OHLC charts.
Parse CSV, TSV, and Markdown table content into real XLSX cells during spreadsheet creation so chart ranges bind to typed data instead of row text blobs.
Update the Office artifact skill and tool prompt to prefer native chart creation over Python fallback, and cover the workflow with regression tests.
---
plugins/_office/helpers/artifact_editor.py | 542 +++++++++++++++++-
plugins/_office/helpers/wopi_store.py | 90 ++-
.../agent.system.tool.document_artifact.md | 3 +
.../_office/skills/office-artifacts/SKILL.md | 29 +-
plugins/_office/tools/document_artifact.py | 2 +
tests/test_office_wopi_store.py | 79 +++
6 files changed, 735 insertions(+), 10 deletions(-)
diff --git a/plugins/_office/helpers/artifact_editor.py b/plugins/_office/helpers/artifact_editor.py
index 65d7df8d7..3b949f6d8 100644
--- a/plugins/_office/helpers/artifact_editor.py
+++ b/plugins/_office/helpers/artifact_editor.py
@@ -61,19 +61,20 @@ def edit_artifact(
sheet: str = "",
cells: Any = None,
rows: Any = None,
+ chart: Any = None,
slides: Any = None,
**kwargs: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Apply a direct saved edit to an Office artifact and return updated metadata."""
path = Path(doc["path"])
ext = str(doc["extension"]).lower()
- op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, slides=slides)
+ op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, chart=chart, slides=slides)
before = path.read_bytes()
if ext == "docx":
updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
elif ext == "xlsx":
- updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
+ updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, chart=chart, **kwargs)
elif ext == "pptx":
updated, details = _edit_pptx(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
else:
@@ -104,6 +105,7 @@ def normalize_operation(
find: str = "",
cells: Any = None,
rows: Any = None,
+ chart: Any = None,
slides: Any = None,
) -> str:
op = str(operation or "").strip().lower().replace("-", "_")
@@ -119,6 +121,10 @@ def normalize_operation(
"set_sheet": "set_rows",
"write_sheet": "set_rows",
"add_rows": "append_rows",
+ "add_chart": "create_chart",
+ "chart": "create_chart",
+ "insert_chart": "create_chart",
+ "set_chart": "create_chart",
"add_slide": "append_slide",
"set_deck": "set_slides",
}
@@ -129,6 +135,8 @@ def normalize_operation(
return "set_cells"
if rows:
return "append_rows"
+ if chart:
+ return "create_chart"
if slides:
return "set_slides"
if find:
@@ -167,10 +175,13 @@ def _read_xlsx(path: Path) -> dict[str, Any]:
values = ["" if value is None else value for value in row]
if any(str(value).strip() for value in values):
rows.append(values)
+ charts = [_chart_summary(chart) for chart in getattr(worksheet, "_charts", [])[:20]]
sheets.append({
"name": worksheet.title,
"max_row": worksheet.max_row,
"max_column": worksheet.max_column,
+ "chart_count": len(getattr(worksheet, "_charts", [])),
+ "charts": charts,
"preview_rows": rows,
})
return {
@@ -278,9 +289,10 @@ def _edit_xlsx(
sheet: str = "",
cells: Any = None,
rows: Any = None,
+ chart: Any = None,
**kwargs: Any,
) -> tuple[bytes, dict[str, Any]]:
- if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
+ if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text", "create_chart"}:
raise ValueError(f"Unsupported XLSX operation: {op}")
openpyxl = _require_openpyxl()
workbook = openpyxl.load_workbook(path)
@@ -327,12 +339,536 @@ def _edit_xlsx(
details["replacements"] = count
if count == 0:
return path.read_bytes(), details
+ elif op == "create_chart":
+ chart_details = []
+ for chart_spec in _normalize_chart_specs(chart, kwargs):
+ chart_details.append(_create_xlsx_chart(workbook, worksheet, chart_spec))
+ details["charts_created"] = len(chart_details)
+ details["charts"] = chart_details
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue(), details
+_CHART_SPEC_KEYS = {
+ "anchor",
+ "categories",
+ "chart_type",
+ "close",
+ "data_range",
+ "fields",
+ "from_rows",
+ "height",
+ "high",
+ "include_headers",
+ "labels",
+ "legend",
+ "low",
+ "open",
+ "position",
+ "replace_existing",
+ "series",
+ "sheet",
+ "style",
+ "title",
+ "titles_from_data",
+ "type",
+ "values",
+ "width",
+ "x_axis_title",
+ "xvalues",
+ "y_axis_title",
+ "yvalues",
+}
+
+_CHART_TYPE_ALIASES = {
+ "area": "area",
+ "bar": "bar",
+ "candlestick": "stock",
+ "col": "column",
+ "column": "column",
+ "columns": "column",
+ "line": "line",
+ "ohlc": "stock",
+ "pie": "pie",
+ "scatter": "scatter",
+ "stock": "stock",
+}
+
+
+def _normalize_chart_specs(chart: Any, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
+ parsed = _parse_chart_value(chart)
+ if isinstance(parsed, list):
+ if not parsed:
+ raise ValueError("chart list must include at least one chart spec")
+ return [_normalize_chart_spec(item, {}) for item in parsed]
+ if parsed is None:
+ parsed = {}
+ if not isinstance(parsed, dict):
+ raise ValueError("chart must be an object, JSON object, or list of chart objects")
+ return [_normalize_chart_spec(parsed, kwargs)]
+
+
+def _parse_chart_value(value: Any) -> Any:
+ if value is None or value == "":
+ return None
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return None
+ if stripped.startswith("{") or stripped.startswith("["):
+ return json.loads(stripped)
+ return {"type": stripped}
+ return value
+
+
+def _normalize_chart_spec(value: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
+ if isinstance(value, str):
+ value = _parse_chart_value(value)
+ if value is None:
+ value = {}
+ if not isinstance(value, dict):
+ raise ValueError("each chart spec must be an object")
+
+ spec = dict(value)
+ explicit_include_headers = "include_headers" in spec or "titles_from_data" in spec
+ for key in _CHART_SPEC_KEYS:
+ if key in kwargs and kwargs[key] not in (None, ""):
+ spec[key] = kwargs[key]
+ if key in {"include_headers", "titles_from_data"}:
+ explicit_include_headers = True
+
+ explicit_type = bool(spec.get("type") or spec.get("chart_type"))
+ chart_type = str(spec.get("type") or spec.get("chart_type") or "").strip().lower().replace("-", "_")
+ if chart_type:
+ chart_type = _CHART_TYPE_ALIASES.get(chart_type, chart_type)
+ spec["type"] = chart_type
+ spec["_explicit_type"] = explicit_type
+ spec["position"] = str(spec.get("position") or spec.get("anchor") or "H2")
+ spec["include_headers"] = _bool_value(spec.get("include_headers", spec.get("titles_from_data")), default=True)
+ spec["_include_headers_explicit"] = explicit_include_headers
+ spec["from_rows"] = _bool_value(spec.get("from_rows"), default=False)
+ spec["replace_existing"] = _bool_value(spec.get("replace_existing"), default=False)
+ spec["width"] = _float_or_default(spec.get("width"), 18.0)
+ spec["height"] = _float_or_default(spec.get("height"), 10.0)
+ return spec
+
+
+def _create_xlsx_chart(workbook: Any, default_worksheet: Any, spec: dict[str, Any]) -> dict[str, Any]:
+ openpyxl = _require_openpyxl()
+ worksheet = _worksheet(workbook, str(spec.get("sheet") or default_worksheet.title))
+ chart_type = spec["type"] or _infer_default_chart_type(worksheet)
+ if chart_type not in _CHART_TYPE_ALIASES.values():
+ raise ValueError(f"Unsupported XLSX chart type: {chart_type}")
+
+ if spec["replace_existing"]:
+ charts_removed = len(getattr(worksheet, "_charts", []))
+ worksheet._charts = []
+ else:
+ charts_removed = 0
+
+ if chart_type == "stock":
+ chart, data_range, categories = _stock_chart(openpyxl, workbook, worksheet, spec)
+ elif chart_type == "scatter":
+ chart, data_range, categories = _scatter_chart(openpyxl, workbook, worksheet, spec)
+ else:
+ chart, data_range, categories = _standard_chart(openpyxl, workbook, worksheet, spec, chart_type)
+
+ _apply_chart_options(chart, spec)
+ worksheet.add_chart(chart, spec["position"])
+ return {
+ "type": chart_type,
+ "title": str(spec.get("title") or ""),
+ "sheet": worksheet.title,
+ "position": spec["position"],
+ "data_range": data_range,
+ "categories": categories,
+ "series_count": len(getattr(chart, "series", [])),
+ "charts_removed": charts_removed,
+ }
+
+
+def _standard_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any], chart_type: str) -> tuple[Any, str, str]:
+ chart_classes = {
+ "area": openpyxl.chart.AreaChart,
+ "bar": openpyxl.chart.BarChart,
+ "column": openpyxl.chart.BarChart,
+ "line": openpyxl.chart.LineChart,
+ "pie": openpyxl.chart.PieChart,
+ }
+ chart = chart_classes[chart_type]()
+ if chart_type == "bar":
+ chart.type = "bar"
+ elif chart_type == "column":
+ chart.type = "col"
+
+ include_headers = bool(spec["include_headers"])
+ categories = str(spec.get("categories") or spec.get("labels") or "")
+ if spec.get("series"):
+ data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, validate_numeric=True)
+ else:
+ range_value = spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, chart_type, include_headers)
+ include_headers = _include_headers_for_range(spec, range_value)
+ data_ref, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
+ _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=data_range)
+ chart.add_data(data_ref, titles_from_data=include_headers, from_rows=bool(spec["from_rows"]))
+
+ if not categories:
+ categories = _default_category_range(worksheet, include_headers=include_headers)
+ if categories:
+ categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
+ chart.set_categories(categories_ref)
+ return chart, data_range, categories
+
+
+def _stock_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
+ chart = openpyxl.chart.StockChart()
+ field_ranges = _stock_field_ranges(spec)
+
+ if field_ranges:
+ data_labels = []
+ for label in ("open", "high", "low", "close"):
+ include_headers = _include_headers_for_range(spec, field_ranges[label])
+ series_ref, data_sheet, bounds, label_range = _reference_from_range(openpyxl, workbook, worksheet, field_ranges[label])
+ _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=label)
+ chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
+ data_labels.append(label_range)
+ data_range = ", ".join(data_labels)
+ elif spec.get("series"):
+ include_headers = bool(spec["include_headers"])
+ data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, expected_count=4, validate_numeric=True)
+ else:
+ include_headers = bool(spec["include_headers"])
+ range_value = spec.get("data_range") or _default_data_range(worksheet, "stock", include_headers)
+ include_headers = _include_headers_for_range(spec, range_value)
+ _, data_sheet, bounds, range_label = _reference_from_range(openpyxl, workbook, worksheet, range_value)
+ min_col, min_row, max_col, max_row = bounds
+ columns = list(range(min_col, max_col + 1))
+ if len(columns) > 4 and _looks_like_category_header(data_sheet.cell(row=min_row, column=min_col).value):
+ columns = columns[1:5]
+ else:
+ columns = columns[:4]
+ if len(columns) != 4:
+ raise ValueError("stock charts require exactly four Open, High, Low, Close data series")
+ _validate_stock_headers(data_sheet, columns, min_row, include_headers=include_headers)
+ for column in columns:
+ _validate_numeric_series(data_sheet, (column, min_row, column, max_row), include_headers=include_headers, label=data_sheet.cell(row=min_row, column=column).value or _column_letter(column))
+ series_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=min_row, max_col=column, max_row=max_row)
+ chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
+ data_range = range_label
+
+ categories = str(spec.get("categories") or spec.get("labels") or _default_category_range(worksheet, include_headers=bool(spec["include_headers"])))
+ if categories:
+ categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
+ chart.set_categories(categories_ref)
+ chart.hiLowLines = openpyxl.chart.axis.ChartLines()
+ chart.upDownBars = openpyxl.chart.updown_bars.UpDownBars()
+ return chart, data_range, categories
+
+
+def _scatter_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
+ chart = openpyxl.chart.ScatterChart()
+ include_headers = bool(spec["include_headers"])
+ categories = str(spec.get("xvalues") or spec.get("categories") or _default_category_range(worksheet, include_headers=include_headers))
+ x_ref, x_sheet, x_bounds, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
+ if include_headers and x_bounds[1] == 1 and x_bounds[3] > 1:
+ x_ref = openpyxl.chart.Reference(x_sheet, min_col=x_bounds[0], min_row=2, max_col=x_bounds[2], max_row=x_bounds[3])
+
+ data_ranges = []
+ series_items = _series_items(spec)
+ if series_items:
+ for item in series_items:
+ values_ref, title, data_range = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=True)
+ xvalues = item.get("xvalues") or item.get("x") or item.get("categories")
+ if xvalues:
+ item_x_ref, item_x_sheet, item_x_bounds, _ = _reference_from_range(openpyxl, workbook, worksheet, xvalues)
+ if include_headers and item_x_bounds[1] == 1 and item_x_bounds[3] > 1:
+ item_x_ref = openpyxl.chart.Reference(item_x_sheet, min_col=item_x_bounds[0], min_row=2, max_col=item_x_bounds[2], max_row=item_x_bounds[3])
+ else:
+ item_x_ref = x_ref
+ chart.series.append(openpyxl.chart.Series(values_ref, xvalues=item_x_ref, title=title))
+ data_ranges.append(data_range)
+ else:
+ range_value = spec.get("yvalues") or spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, "scatter", include_headers)
+ _, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
+ min_col, min_row, max_col, max_row = bounds
+ first_row = min_row + 1 if include_headers and min_row == 1 and max_row > 1 else min_row
+ for column in range(min_col, max_col + 1):
+ title = data_sheet.cell(row=min_row, column=column).value if first_row > min_row else None
+ _validate_numeric_series(data_sheet, (column, min_row, column, max_row), include_headers=include_headers, label=title or _column_letter(column))
+ y_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=first_row, max_col=column, max_row=max_row)
+ chart.series.append(openpyxl.chart.Series(y_ref, xvalues=x_ref, title=str(title) if title is not None else None))
+ return chart, ", ".join(data_ranges) if data_ranges else data_range, categories
+
+
+def _add_explicit_series(
+ openpyxl: Any,
+ chart: Any,
+ workbook: Any,
+ worksheet: Any,
+ spec: dict[str, Any],
+ expected_count: int | None = None,
+ validate_numeric: bool = False,
+) -> str:
+ include_headers = bool(spec["include_headers"])
+ ranges = []
+ for item in _series_items(spec):
+ values_ref, title, label = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=validate_numeric)
+ if title:
+ chart.series.append(openpyxl.chart.Series(values_ref, title=title))
+ else:
+ chart.series.append(openpyxl.chart.Series(values_ref, title_from_data=include_headers))
+ ranges.append(label)
+ if expected_count is not None and len(ranges) != expected_count:
+ raise ValueError(f"chart requires exactly {expected_count} series")
+ return ", ".join(ranges)
+
+
+def _series_items(spec: dict[str, Any]) -> list[dict[str, Any]]:
+ raw = spec.get("series") or []
+ if isinstance(raw, str):
+ raw = json.loads(raw)
+ if not isinstance(raw, list):
+ raise ValueError("chart series must be a list")
+ items = []
+ for item in raw:
+ if isinstance(item, str):
+ items.append({"values": item})
+ elif isinstance(item, dict):
+ items.append(item)
+ else:
+ raise ValueError("chart series entries must be objects or range strings")
+ return items
+
+
+def _series_values_reference(
+ openpyxl: Any,
+ workbook: Any,
+ worksheet: Any,
+ item: dict[str, Any],
+ *,
+ include_headers: bool,
+ validate_numeric: bool = False,
+) -> tuple[Any, str | None, str]:
+ values = item.get("values") or item.get("range") or item.get("yvalues") or item.get("y")
+ if not values:
+ raise ValueError("chart series entries require values or range")
+ ref, data_sheet, bounds, label = _reference_from_range(openpyxl, workbook, worksheet, values)
+ title = item.get("title") or item.get("name")
+ min_col, min_row, max_col, max_row = bounds
+ if include_headers and min_row == 1 and max_col == min_col and max_row > min_row:
+ title = title if title is not None else data_sheet.cell(row=min_row, column=min_col).value
+ ref = openpyxl.chart.Reference(data_sheet, min_col=min_col, min_row=min_row + 1, max_col=max_col, max_row=max_row)
+ if validate_numeric:
+ _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=title or label)
+ return ref, str(title) if title is not None else None, label
+
+
+def _stock_field_ranges(spec: dict[str, Any]) -> dict[str, Any]:
+ fields = spec.get("fields") or {}
+ if isinstance(fields, str):
+ fields = json.loads(fields)
+ if not isinstance(fields, dict):
+ raise ValueError("stock chart fields must be an object")
+ result = {}
+ for label in ("open", "high", "low", "close"):
+ value = spec.get(label) or fields.get(label)
+ if value:
+ result[label] = value
+ if result and set(result) != {"open", "high", "low", "close"}:
+ raise ValueError("stock chart fields must include open, high, low, and close")
+ return result
+
+
+def _reference_from_range(openpyxl: Any, workbook: Any, default_worksheet: Any, value: Any) -> tuple[Any, Any, tuple[int, int, int, int], str]:
+ sheet_name, cell_range = _split_range_ref(value, default_worksheet.title)
+ worksheet = _worksheet(workbook, sheet_name)
+ min_col, min_row, max_col, max_row = openpyxl.utils.cell.range_boundaries(cell_range)
+ reference = openpyxl.chart.Reference(worksheet, min_col=min_col, min_row=min_row, max_col=max_col, max_row=max_row)
+ return reference, worksheet, (min_col, min_row, max_col, max_row), _range_label(openpyxl, worksheet.title, min_col, min_row, max_col, max_row)
+
+
+def _split_range_ref(value: Any, default_sheet: str) -> tuple[str, str]:
+ if isinstance(value, dict):
+ sheet = str(value.get("sheet") or default_sheet)
+ min_cell = value.get("range") or value.get("ref")
+ if min_cell:
+ return _split_range_ref(str(min_cell), sheet)
+ min_col = value.get("min_col")
+ min_row = value.get("min_row")
+ max_col = value.get("max_col", min_col)
+ max_row = value.get("max_row", min_row)
+ if min_col is None or min_row is None:
+ raise ValueError("range objects require range/ref or min_col and min_row")
+ return sheet, f"{_cell_ref(min_col, min_row)}:{_cell_ref(max_col, max_row)}"
+ ref = str(value or "").strip()
+ if not ref:
+ raise ValueError("chart range is required")
+ if "!" not in ref:
+ return default_sheet, ref
+ sheet, cell_range = ref.rsplit("!", 1)
+ return sheet.strip().strip("'") or default_sheet, cell_range
+
+
+def _range_label(openpyxl: Any, sheet_title: str, min_col: int, min_row: int, max_col: int, max_row: int) -> str:
+ start = f"{openpyxl.utils.cell.get_column_letter(min_col)}{min_row}"
+ end = f"{openpyxl.utils.cell.get_column_letter(max_col)}{max_row}"
+ sheet = sheet_title if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", sheet_title) else f"'{sheet_title}'"
+ return f"{sheet}!{start}:{end}"
+
+
+def _include_headers_for_range(spec: dict[str, Any], range_value: Any) -> bool:
+ include_headers = bool(spec["include_headers"])
+ if spec.get("_include_headers_explicit"):
+ return include_headers
+ try:
+ _, cell_range = _split_range_ref(range_value, "")
+ _, min_row, _, _ = __import__("openpyxl").utils.cell.range_boundaries(cell_range)
+ except Exception:
+ return include_headers
+ return include_headers and min_row == 1
+
+
+def _validate_stock_headers(data_sheet: Any, columns: list[int], min_row: int, *, include_headers: bool) -> None:
+ if not include_headers or min_row != 1:
+ return
+ expected = ["open", "high", "low", "close"]
+ found = [str(data_sheet.cell(row=min_row, column=column).value or "").strip().lower() for column in columns]
+ if found != expected:
+ raise ValueError(f"stock charts require Open, High, Low, Close columns in order; found {found}")
+
+
+def _validate_numeric_series(data_sheet: Any, bounds: tuple[int, int, int, int], *, include_headers: bool, label: Any) -> None:
+ min_col, min_row, max_col, max_row = bounds
+ start_row = min_row + 1 if include_headers and min_row == 1 and max_row > min_row else min_row
+ values = [
+ data_sheet.cell(row=row, column=column).value
+ for column in range(min_col, max_col + 1)
+ for row in range(start_row, max_row + 1)
+ ]
+ numeric_count = sum(1 for value in values if isinstance(value, (int, float)) and not isinstance(value, bool))
+ if numeric_count == 0:
+ name = str(label or _range_label(__import__("openpyxl"), data_sheet.title, min_col, min_row, max_col, max_row))
+ raise ValueError(f"chart series '{name}' has no numeric data")
+
+
+def _default_category_range(worksheet: Any, *, include_headers: bool) -> str:
+ if (worksheet.max_column or 0) < 2 or (worksheet.max_row or 0) < 2:
+ return ""
+ first_row_is_header = _looks_like_category_header(worksheet.cell(row=1, column=1).value)
+ return f"A{2 if include_headers or first_row_is_header else 1}:A{worksheet.max_row}"
+
+
+def _default_data_range(worksheet: Any, chart_type: str, include_headers: bool) -> str:
+ max_row = worksheet.max_row or 1
+ max_col = worksheet.max_column or 1
+ if chart_type == "stock":
+ if max_col < 5 or max_row < 2:
+ raise ValueError("stock charts need Date, Open, High, Low, Close columns or explicit ranges")
+ return f"B{1 if include_headers else 2}:E{max_row}"
+ if chart_type == "pie" and max_col >= 2:
+ return f"B{1 if include_headers else 2}:B{max_row}"
+ start_col = 2 if max_col >= 2 else 1
+ return f"{_column_letter(start_col)}{1 if include_headers else 2}:{_column_letter(max_col)}{max_row}"
+
+
+def _cell_ref(column: Any, row: Any) -> str:
+ return f"{_column_letter(column)}{int(row)}"
+
+
+def _column_letter(column: Any) -> str:
+ if isinstance(column, str) and column.isalpha():
+ return column.upper()
+ return __import__("openpyxl").utils.cell.get_column_letter(int(column))
+
+
+def _infer_default_chart_type(worksheet: Any) -> str:
+ headers = [str(worksheet.cell(row=1, column=column).value or "").strip().lower() for column in range(1, (worksheet.max_column or 0) + 1)]
+ if {"open", "high", "low", "close"}.issubset(set(headers)):
+ return "stock"
+ return "line"
+
+
+def _looks_like_category_header(value: Any) -> bool:
+ return str(value or "").strip().lower() in {"date", "time", "category", "label", "month", "year"}
+
+
+def _apply_chart_options(chart: Any, spec: dict[str, Any]) -> None:
+ if spec.get("title"):
+ chart.title = str(spec["title"])
+ if spec.get("style") not in (None, ""):
+ chart.style = int(spec["style"])
+ chart.width = spec["width"]
+ chart.height = spec["height"]
+ if _bool_value(spec.get("legend"), default=True) is False:
+ chart.legend = None
+ if spec.get("x_axis_title") and hasattr(chart, "x_axis"):
+ chart.x_axis.title = str(spec["x_axis_title"])
+ if spec.get("y_axis_title") and hasattr(chart, "y_axis"):
+ chart.y_axis.title = str(spec["y_axis_title"])
+
+
+def _chart_summary(chart: Any) -> dict[str, Any]:
+ return {
+ "type": _chart_kind(chart),
+ "title": _chart_title(chart),
+ "anchor": _chart_anchor(chart),
+ "series_count": len(getattr(chart, "series", [])),
+ }
+
+
+def _chart_kind(chart: Any) -> str:
+ name = chart.__class__.__name__.replace("Chart", "").lower()
+ return {"bar": "bar_or_column", "stock": "stock"}.get(name, name)
+
+
+def _chart_title(chart: Any) -> str:
+ title = getattr(chart, "title", None)
+ if title is None or isinstance(title, str):
+ return title or ""
+ try:
+ paragraphs = title.tx.rich.p
+ parts = []
+ for paragraph in paragraphs:
+ for run in paragraph.r:
+ if run.t:
+ parts.append(run.t)
+ return "".join(parts)
+ except Exception:
+ return ""
+
+
+def _chart_anchor(chart: Any) -> str:
+ openpyxl = _require_openpyxl()
+ anchor = getattr(chart, "anchor", "")
+ if isinstance(anchor, str):
+ return anchor
+ marker = getattr(anchor, "_from", None)
+ if marker is None:
+ return ""
+ return f"{openpyxl.utils.cell.get_column_letter(marker.col + 1)}{marker.row + 1}"
+
+
+def _bool_value(value: Any, default: bool = False) -> bool:
+ if value in (None, ""):
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ return str(value).strip().lower() not in {"0", "false", "no", "off", "none"}
+
+
+def _float_or_default(value: Any, default: float) -> float:
+ if value in (None, ""):
+ return default
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+
def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
raise ValueError(f"Unsupported PPTX operation: {op}")
diff --git a/plugins/_office/helpers/wopi_store.py b/plugins/_office/helpers/wopi_store.py
index a3c0da6ec..d7a738727 100644
--- a/plugins/_office/helpers/wopi_store.py
+++ b/plugins/_office/helpers/wopi_store.py
@@ -1,8 +1,11 @@
from __future__ import annotations
+import csv
import hashlib
+import io
import json
import os
+import re
import secrets
import shutil
import sqlite3
@@ -564,8 +567,6 @@ def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
def _zip_bytes(files_map: dict[str, str | bytes], stored: set[str] | None = None) -> bytes:
- import io
-
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name, value in files_map.items():
@@ -587,10 +588,10 @@ def _docx(title: str, content: str) -> bytes:
def _xlsx(title: str, content: str) -> bytes:
- rows = [title] + [line for line in content.splitlines() if line.strip()]
+ rows = _xlsx_rows(title, content)
sheet_rows = "".join(
- f'{escape(line)}
'
- for idx, line in enumerate(rows, start=1)
+ f'{"".join(_xlsx_cell(row_idx, col_idx, value) for col_idx, value in enumerate(row, start=1))}
'
+ for row_idx, row in enumerate(rows, start=1)
)
return _zip_bytes({
"[Content_Types].xml": """""",
@@ -601,6 +602,85 @@ def _xlsx(title: str, content: str) -> bytes:
})
+def _xlsx_rows(title: str, content: str) -> list[list[Any]]:
+ parsed = _tabular_rows(content)
+ if parsed:
+ return parsed
+ lines = [line for line in str(content or "").splitlines() if line.strip()]
+ if lines:
+ return [[title], *[[line] for line in lines]]
+ return [[title]]
+
+
+def _tabular_rows(content: str) -> list[list[Any]]:
+ text = str(content or "").strip("\n")
+ if not text.strip():
+ return []
+ lines = [line for line in text.splitlines() if line.strip()]
+ markdown_rows = _markdown_table_rows(lines)
+ if markdown_rows:
+ return markdown_rows
+
+ delimiter = "\t" if any("\t" in line for line in lines) else ("," if any("," in line for line in lines) else None)
+ if not delimiter:
+ return []
+ return [[_xlsx_value(cell) for cell in row] for row in csv.reader(io.StringIO("\n".join(lines)), delimiter=delimiter)]
+
+
+def _markdown_table_rows(lines: list[str]) -> list[list[Any]]:
+ table_lines = [line.strip() for line in lines if line.strip().startswith("|") and line.strip().endswith("|")]
+ if len(table_lines) < 2:
+ return []
+ rows = []
+ for line in table_lines:
+ cells = [cell.strip() for cell in line.strip("|").split("|")]
+ if all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
+ continue
+ rows.append([_xlsx_value(cell) for cell in cells])
+ return rows
+
+
+def _xlsx_cell(row_idx: int, col_idx: int, value: Any) -> str:
+ ref = f"{_column_name(col_idx)}{row_idx}"
+ value = _xlsx_value(value)
+ if value in (None, ""):
+ return f''
+ if isinstance(value, bool):
+ return f'{1 if value else 0}'
+ if isinstance(value, (int, float)):
+ return f'{value}'
+ return f'{escape(str(value))}'
+
+
+def _xlsx_value(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ stripped = value.strip()
+ if not stripped:
+ return ""
+ if stripped.lower() in {"true", "false"}:
+ return stripped.lower() == "true"
+ if re.fullmatch(r"[+-]?\d+", stripped) and not (len(stripped.lstrip("+-")) > 1 and stripped.lstrip("+-").startswith("0")):
+ try:
+ return int(stripped)
+ except ValueError:
+ return stripped
+ if re.fullmatch(r"[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?", stripped) or re.fullmatch(r"[+-]?\d+[eE][+-]?\d+", stripped):
+ try:
+ return float(stripped)
+ except ValueError:
+ return stripped
+ return stripped
+
+
+def _column_name(index: int) -> str:
+ name = ""
+ while index:
+ index, remainder = divmod(index - 1, 26)
+ name = chr(65 + remainder) + name
+ return name
+
+
def _pptx(title: str, content: str) -> bytes:
subtitle = content.splitlines()[0] if content.splitlines() else ""
return _zip_bytes({
diff --git a/plugins/_office/prompts/agent.system.tool.document_artifact.md b/plugins/_office/prompts/agent.system.tool.document_artifact.md
index 4e255f3fb..96786b1b6 100644
--- a/plugins/_office/prompts/agent.system.tool.document_artifact.md
+++ b/plugins/_office/prompts/agent.system.tool.document_artifact.md
@@ -3,4 +3,7 @@ create/open/read/edit reusable Office artifacts in the Agent Zero canvas
formats: docx xlsx pptx odt ods odp
methods: create open read edit inspect export version_history restore_version status
common args: kind title format content path file_id
+XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
+chart types: line bar column pie area scatter stock ohlc candlestick
+XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
for nontrivial Office artifact work, load skill `office-artifacts` first
diff --git a/plugins/_office/skills/office-artifacts/SKILL.md b/plugins/_office/skills/office-artifacts/SKILL.md
index 535ddafa3..b241cbafa 100644
--- a/plugins/_office/skills/office-artifacts/SKILL.md
+++ b/plugins/_office/skills/office-artifacts/SKILL.md
@@ -1,7 +1,7 @@
---
name: office-artifacts
description: Use when creating, opening, reading, or editing editable Office canvas artifacts such as DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
-version: "1.0.0"
+version: "1.1.0"
author: "Agent Zero Core Team"
tags: ["office", "docx", "xlsx", "pptx", "canvas", "documents", "spreadsheets", "presentations"]
triggers:
@@ -44,6 +44,8 @@ Create:
}
```
+For spreadsheets, `content` can be CSV, TSV, or a Markdown table; the tool writes real cells, not one text blob per row.
+
Read:
```json
{
@@ -82,10 +84,31 @@ Set spreadsheet cells:
}
```
+Create an embedded spreadsheet chart:
+```json
+{
+ "tool_name": "document_artifact:edit",
+ "tool_args": {
+ "file_id": "abc123",
+ "operation": "create_chart",
+ "sheet": "Sheet1",
+ "chart": {
+ "type": "line",
+ "title": "Monthly Revenue",
+ "data_range": "B1:C13",
+ "categories": "A2:A13",
+ "position": "E1",
+ "width": 18,
+ "height": 10
+ }
+ }
+}
+```
+
## Edit Operations
- DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
-- XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
+- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
Arguments:
@@ -93,6 +116,7 @@ Arguments:
- `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
- `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
- `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
+- `create_chart` accepts `chart` as an object or JSON string. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`.
- `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
- `count` limits text replacements.
@@ -100,5 +124,6 @@ Arguments:
- Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
- Use `read` before editing unless the current saved content is already known.
+- Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
- Use `edit` for precise saved changes; use the visual Office canvas for human/manual layout polish.
- Direct edits update version history and refresh the canvas on edit/open results.
diff --git a/plugins/_office/tools/document_artifact.py b/plugins/_office/tools/document_artifact.py
index 5a3367b3b..5d5656599 100644
--- a/plugins/_office/tools/document_artifact.py
+++ b/plugins/_office/tools/document_artifact.py
@@ -25,6 +25,7 @@ class DocumentArtifact(Tool):
sheet: str = "",
cells: Any = None,
rows: Any = None,
+ chart: Any = None,
slides: Any = None,
max_chars: int | str = 12000,
**kwargs: Any,
@@ -57,6 +58,7 @@ class DocumentArtifact(Tool):
sheet=sheet,
cells=cells,
rows=rows,
+ chart=chart,
slides=slides,
**kwargs,
)
diff --git a/tests/test_office_wopi_store.py b/tests/test_office_wopi_store.py
index 53b7df9f8..279d16e49 100644
--- a/tests/test_office_wopi_store.py
+++ b/tests/test_office_wopi_store.py
@@ -201,6 +201,85 @@ def test_document_artifact_xlsx_edit_sets_cells_and_appends_rows(office_state):
assert ["Research", 9800] in rows
+def test_document_artifact_xlsx_create_parses_csv_content_for_charting(office_state):
+ doc = wopi_store.create_document(
+ "spreadsheet",
+ "Revenue Demo",
+ "xlsx",
+ "\n".join([
+ "Month,Revenue,Costs",
+ "Jan,120,80",
+ "Feb,135,92",
+ "Mar,150,96",
+ ]),
+ )
+ content = artifact_editor.read_artifact(doc)
+ rows = content["sheets"][0]["preview_rows"]
+
+ assert rows[0] == ["Month", "Revenue", "Costs"]
+ assert rows[1] == ["Jan", 120, 80]
+
+ updated, payload = artifact_editor.edit_artifact(
+ doc,
+ operation="create_chart",
+ chart={"type": "line", "position": "E1"},
+ )
+
+ assert payload["changed"] is True
+ assert payload["charts"][0]["type"] == "line"
+ assert payload["charts"][0]["position"] == "E1"
+ assert artifact_editor.read_artifact(updated)["sheets"][0]["chart_count"] == 1
+
+
+def test_document_artifact_xlsx_stock_chart_rejects_non_numeric_ohlc_data(office_state):
+ doc = wopi_store.create_document(
+ "spreadsheet",
+ "Broken Trading Demo",
+ "xlsx",
+ "\n".join([
+ "Date,Open,High,Low,Close",
+ "2026-04-24,open,high,low,close",
+ "2026-04-25,still,not,real,numbers",
+ ]),
+ )
+
+ with pytest.raises(ValueError, match="no numeric data"):
+ artifact_editor.edit_artifact(doc, operation="create_chart", chart={"type": "candlestick"})
+
+
+def test_document_artifact_xlsx_edit_creates_stock_chart(office_state):
+ doc = wopi_store.create_document("spreadsheet", "Trading Demo", "xlsx", "")
+ rows = [
+ ["Date", "Open", "High", "Low", "Close", "Volume"],
+ ["2026-04-24", 100, 105, 99, 104, 1000],
+ ["2026-04-25", 104, 106, 102, 103, 1200],
+ ["2026-04-28", 103, 108, 101, 107, 1800],
+ ]
+ updated, _ = artifact_editor.edit_artifact(doc, operation="set_rows", rows=rows)
+
+ updated, payload = artifact_editor.edit_artifact(
+ updated,
+ operation="create_chart",
+ chart={
+ "type": "candlestick",
+ "title": "DEMO Stock Price (OHLC)",
+ "position": "A8",
+ "width": 16,
+ "height": 8,
+ },
+ )
+ content = artifact_editor.read_artifact(updated)
+ sheet = content["sheets"][0]
+
+ assert payload["changed"] is True
+ assert payload["charts_created"] == 1
+ assert payload["charts"][0]["type"] == "stock"
+ assert payload["charts"][0]["series_count"] == 4
+ assert sheet["chart_count"] == 1
+ assert sheet["charts"][0]["type"] == "stock"
+ assert sheet["charts"][0]["title"] == "DEMO Stock Price (OHLC)"
+
+
def test_document_artifact_pptx_edit_sets_slides(office_state):
doc = wopi_store.create_document("presentation", "Roadmap", "pptx", "Initial")