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.

Alessandro committed Apr 28, 2026 at 03:04 UTC 4f95250d0b38cc5a3a15ad72fffc54a839de8dee
6 files changed +735 -10
plugins/_office/helpers/artifact_editor.py
+539 -3
@@ -61,19 +61,20 @@ def edit_artifact(
61 sheet: str = "",
62 cells: Any = None,
63 rows: Any = None,
64 + chart: Any = None,
65 slides: Any = None,
66 **kwargs: Any,
67 ) -> tuple[dict[str, Any], dict[str, Any]]:
68 """Apply a direct saved edit to an Office artifact and return updated metadata."""
69 path = Path(doc["path"])
70 ext = str(doc["extension"]).lower()
70 - op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, slides=slides)
71 + op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, chart=chart, slides=slides)
72 before = path.read_bytes()
73
74 if ext == "docx":
75 updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
76 elif ext == "xlsx":
76 - updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
77 + updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, chart=chart, **kwargs)
78 elif ext == "pptx":
79 updated, details = _edit_pptx(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
80 else:
@@ -104,6 +105,7 @@ def normalize_operation(
105 find: str = "",
106 cells: Any = None,
107 rows: Any = None,
108 + chart: Any = None,
109 slides: Any = None,
110 ) -> str:
111 op = str(operation or "").strip().lower().replace("-", "_")
@@ -119,6 +121,10 @@ def normalize_operation(
121 "set_sheet": "set_rows",
122 "write_sheet": "set_rows",
123 "add_rows": "append_rows",
124 + "add_chart": "create_chart",
125 + "chart": "create_chart",
126 + "insert_chart": "create_chart",
127 + "set_chart": "create_chart",
128 "add_slide": "append_slide",
129 "set_deck": "set_slides",
130 }
@@ -129,6 +135,8 @@ def normalize_operation(
135 return "set_cells"
136 if rows:
137 return "append_rows"
138 + if chart:
139 + return "create_chart"
140 if slides:
141 return "set_slides"
142 if find:
@@ -167,10 +175,13 @@ def _read_xlsx(path: Path) -> dict[str, Any]:
175 values = ["" if value is None else value for value in row]
176 if any(str(value).strip() for value in values):
177 rows.append(values)
178 + charts = [_chart_summary(chart) for chart in getattr(worksheet, "_charts", [])[:20]]
179 sheets.append({
180 "name": worksheet.title,
181 "max_row": worksheet.max_row,
182 "max_column": worksheet.max_column,
183 + "chart_count": len(getattr(worksheet, "_charts", [])),
184 + "charts": charts,
185 "preview_rows": rows,
186 })
187 return {
@@ -278,9 +289,10 @@ def _edit_xlsx(
289 sheet: str = "",
290 cells: Any = None,
291 rows: Any = None,
292 + chart: Any = None,
293 **kwargs: Any,
294 ) -> tuple[bytes, dict[str, Any]]:
283 - if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
295 + if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text", "create_chart"}:
296 raise ValueError(f"Unsupported XLSX operation: {op}")
297 openpyxl = _require_openpyxl()
298 workbook = openpyxl.load_workbook(path)
@@ -327,12 +339,536 @@ def _edit_xlsx(
339 details["replacements"] = count
340 if count == 0:
341 return path.read_bytes(), details
342 + elif op == "create_chart":
343 + chart_details = []
344 + for chart_spec in _normalize_chart_specs(chart, kwargs):
345 + chart_details.append(_create_xlsx_chart(workbook, worksheet, chart_spec))
346 + details["charts_created"] = len(chart_details)
347 + details["charts"] = chart_details
348
349 buffer = io.BytesIO()
350 workbook.save(buffer)
351 return buffer.getvalue(), details
352
353
354 +_CHART_SPEC_KEYS = {
355 + "anchor",
356 + "categories",
357 + "chart_type",
358 + "close",
359 + "data_range",
360 + "fields",
361 + "from_rows",
362 + "height",
363 + "high",
364 + "include_headers",
365 + "labels",
366 + "legend",
367 + "low",
368 + "open",
369 + "position",
370 + "replace_existing",
371 + "series",
372 + "sheet",
373 + "style",
374 + "title",
375 + "titles_from_data",
376 + "type",
377 + "values",
378 + "width",
379 + "x_axis_title",
380 + "xvalues",
381 + "y_axis_title",
382 + "yvalues",
383 +}
384 +
385 +_CHART_TYPE_ALIASES = {
386 + "area": "area",
387 + "bar": "bar",
388 + "candlestick": "stock",
389 + "col": "column",
390 + "column": "column",
391 + "columns": "column",
392 + "line": "line",
393 + "ohlc": "stock",
394 + "pie": "pie",
395 + "scatter": "scatter",
396 + "stock": "stock",
397 +}
398 +
399 +
400 +def _normalize_chart_specs(chart: Any, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
401 + parsed = _parse_chart_value(chart)
402 + if isinstance(parsed, list):
403 + if not parsed:
404 + raise ValueError("chart list must include at least one chart spec")
405 + return [_normalize_chart_spec(item, {}) for item in parsed]
406 + if parsed is None:
407 + parsed = {}
408 + if not isinstance(parsed, dict):
409 + raise ValueError("chart must be an object, JSON object, or list of chart objects")
410 + return [_normalize_chart_spec(parsed, kwargs)]
411 +
412 +
413 +def _parse_chart_value(value: Any) -> Any:
414 + if value is None or value == "":
415 + return None
416 + if isinstance(value, str):
417 + stripped = value.strip()
418 + if not stripped:
419 + return None
420 + if stripped.startswith("{") or stripped.startswith("["):
421 + return json.loads(stripped)
422 + return {"type": stripped}
423 + return value
424 +
425 +
426 +def _normalize_chart_spec(value: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
427 + if isinstance(value, str):
428 + value = _parse_chart_value(value)
429 + if value is None:
430 + value = {}
431 + if not isinstance(value, dict):
432 + raise ValueError("each chart spec must be an object")
433 +
434 + spec = dict(value)
435 + explicit_include_headers = "include_headers" in spec or "titles_from_data" in spec
436 + for key in _CHART_SPEC_KEYS:
437 + if key in kwargs and kwargs[key] not in (None, ""):
438 + spec[key] = kwargs[key]
439 + if key in {"include_headers", "titles_from_data"}:
440 + explicit_include_headers = True
441 +
442 + explicit_type = bool(spec.get("type") or spec.get("chart_type"))
443 + chart_type = str(spec.get("type") or spec.get("chart_type") or "").strip().lower().replace("-", "_")
444 + if chart_type:
445 + chart_type = _CHART_TYPE_ALIASES.get(chart_type, chart_type)
446 + spec["type"] = chart_type
447 + spec["_explicit_type"] = explicit_type
448 + spec["position"] = str(spec.get("position") or spec.get("anchor") or "H2")
449 + spec["include_headers"] = _bool_value(spec.get("include_headers", spec.get("titles_from_data")), default=True)
450 + spec["_include_headers_explicit"] = explicit_include_headers
451 + spec["from_rows"] = _bool_value(spec.get("from_rows"), default=False)
452 + spec["replace_existing"] = _bool_value(spec.get("replace_existing"), default=False)
453 + spec["width"] = _float_or_default(spec.get("width"), 18.0)
454 + spec["height"] = _float_or_default(spec.get("height"), 10.0)
455 + return spec
456 +
457 +
458 +def _create_xlsx_chart(workbook: Any, default_worksheet: Any, spec: dict[str, Any]) -> dict[str, Any]:
459 + openpyxl = _require_openpyxl()
460 + worksheet = _worksheet(workbook, str(spec.get("sheet") or default_worksheet.title))
461 + chart_type = spec["type"] or _infer_default_chart_type(worksheet)
462 + if chart_type not in _CHART_TYPE_ALIASES.values():
463 + raise ValueError(f"Unsupported XLSX chart type: {chart_type}")
464 +
465 + if spec["replace_existing"]:
466 + charts_removed = len(getattr(worksheet, "_charts", []))
467 + worksheet._charts = []
468 + else:
469 + charts_removed = 0
470 +
471 + if chart_type == "stock":
472 + chart, data_range, categories = _stock_chart(openpyxl, workbook, worksheet, spec)
473 + elif chart_type == "scatter":
474 + chart, data_range, categories = _scatter_chart(openpyxl, workbook, worksheet, spec)
475 + else:
476 + chart, data_range, categories = _standard_chart(openpyxl, workbook, worksheet, spec, chart_type)
477 +
478 + _apply_chart_options(chart, spec)
479 + worksheet.add_chart(chart, spec["position"])
480 + return {
481 + "type": chart_type,
482 + "title": str(spec.get("title") or ""),
483 + "sheet": worksheet.title,
484 + "position": spec["position"],
485 + "data_range": data_range,
486 + "categories": categories,
487 + "series_count": len(getattr(chart, "series", [])),
488 + "charts_removed": charts_removed,
489 + }
490 +
491 +
492 +def _standard_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any], chart_type: str) -> tuple[Any, str, str]:
493 + chart_classes = {
494 + "area": openpyxl.chart.AreaChart,
495 + "bar": openpyxl.chart.BarChart,
496 + "column": openpyxl.chart.BarChart,
497 + "line": openpyxl.chart.LineChart,
498 + "pie": openpyxl.chart.PieChart,
499 + }
500 + chart = chart_classes[chart_type]()
501 + if chart_type == "bar":
502 + chart.type = "bar"
503 + elif chart_type == "column":
504 + chart.type = "col"
505 +
506 + include_headers = bool(spec["include_headers"])
507 + categories = str(spec.get("categories") or spec.get("labels") or "")
508 + if spec.get("series"):
509 + data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, validate_numeric=True)
510 + else:
511 + range_value = spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, chart_type, include_headers)
512 + include_headers = _include_headers_for_range(spec, range_value)
513 + data_ref, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
514 + _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=data_range)
515 + chart.add_data(data_ref, titles_from_data=include_headers, from_rows=bool(spec["from_rows"]))
516 +
517 + if not categories:
518 + categories = _default_category_range(worksheet, include_headers=include_headers)
519 + if categories:
520 + categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
521 + chart.set_categories(categories_ref)
522 + return chart, data_range, categories
523 +
524 +
525 +def _stock_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
526 + chart = openpyxl.chart.StockChart()
527 + field_ranges = _stock_field_ranges(spec)
528 +
529 + if field_ranges:
530 + data_labels = []
531 + for label in ("open", "high", "low", "close"):
532 + include_headers = _include_headers_for_range(spec, field_ranges[label])
533 + series_ref, data_sheet, bounds, label_range = _reference_from_range(openpyxl, workbook, worksheet, field_ranges[label])
534 + _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=label)
535 + chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
536 + data_labels.append(label_range)
537 + data_range = ", ".join(data_labels)
538 + elif spec.get("series"):
539 + include_headers = bool(spec["include_headers"])
540 + data_range = _add_explicit_series(openpyxl, chart, workbook, worksheet, spec, expected_count=4, validate_numeric=True)
541 + else:
542 + include_headers = bool(spec["include_headers"])
543 + range_value = spec.get("data_range") or _default_data_range(worksheet, "stock", include_headers)
544 + include_headers = _include_headers_for_range(spec, range_value)
545 + _, data_sheet, bounds, range_label = _reference_from_range(openpyxl, workbook, worksheet, range_value)
546 + min_col, min_row, max_col, max_row = bounds
547 + columns = list(range(min_col, max_col + 1))
548 + if len(columns) > 4 and _looks_like_category_header(data_sheet.cell(row=min_row, column=min_col).value):
549 + columns = columns[1:5]
550 + else:
551 + columns = columns[:4]
552 + if len(columns) != 4:
553 + raise ValueError("stock charts require exactly four Open, High, Low, Close data series")
554 + _validate_stock_headers(data_sheet, columns, min_row, include_headers=include_headers)
555 + for column in columns:
556 + _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))
557 + series_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=min_row, max_col=column, max_row=max_row)
558 + chart.series.append(openpyxl.chart.Series(series_ref, title_from_data=include_headers))
559 + data_range = range_label
560 +
561 + categories = str(spec.get("categories") or spec.get("labels") or _default_category_range(worksheet, include_headers=bool(spec["include_headers"])))
562 + if categories:
563 + categories_ref, _, _, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
564 + chart.set_categories(categories_ref)
565 + chart.hiLowLines = openpyxl.chart.axis.ChartLines()
566 + chart.upDownBars = openpyxl.chart.updown_bars.UpDownBars()
567 + return chart, data_range, categories
568 +
569 +
570 +def _scatter_chart(openpyxl: Any, workbook: Any, worksheet: Any, spec: dict[str, Any]) -> tuple[Any, str, str]:
571 + chart = openpyxl.chart.ScatterChart()
572 + include_headers = bool(spec["include_headers"])
573 + categories = str(spec.get("xvalues") or spec.get("categories") or _default_category_range(worksheet, include_headers=include_headers))
574 + x_ref, x_sheet, x_bounds, categories = _reference_from_range(openpyxl, workbook, worksheet, categories)
575 + if include_headers and x_bounds[1] == 1 and x_bounds[3] > 1:
576 + 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])
577 +
578 + data_ranges = []
579 + series_items = _series_items(spec)
580 + if series_items:
581 + for item in series_items:
582 + values_ref, title, data_range = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=True)
583 + xvalues = item.get("xvalues") or item.get("x") or item.get("categories")
584 + if xvalues:
585 + item_x_ref, item_x_sheet, item_x_bounds, _ = _reference_from_range(openpyxl, workbook, worksheet, xvalues)
586 + if include_headers and item_x_bounds[1] == 1 and item_x_bounds[3] > 1:
587 + 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])
588 + else:
589 + item_x_ref = x_ref
590 + chart.series.append(openpyxl.chart.Series(values_ref, xvalues=item_x_ref, title=title))
591 + data_ranges.append(data_range)
592 + else:
593 + range_value = spec.get("yvalues") or spec.get("values") or spec.get("data_range") or _default_data_range(worksheet, "scatter", include_headers)
594 + _, data_sheet, bounds, data_range = _reference_from_range(openpyxl, workbook, worksheet, range_value)
595 + min_col, min_row, max_col, max_row = bounds
596 + first_row = min_row + 1 if include_headers and min_row == 1 and max_row > 1 else min_row
597 + for column in range(min_col, max_col + 1):
598 + title = data_sheet.cell(row=min_row, column=column).value if first_row > min_row else None
599 + _validate_numeric_series(data_sheet, (column, min_row, column, max_row), include_headers=include_headers, label=title or _column_letter(column))
600 + y_ref = openpyxl.chart.Reference(data_sheet, min_col=column, min_row=first_row, max_col=column, max_row=max_row)
601 + chart.series.append(openpyxl.chart.Series(y_ref, xvalues=x_ref, title=str(title) if title is not None else None))
602 + return chart, ", ".join(data_ranges) if data_ranges else data_range, categories
603 +
604 +
605 +def _add_explicit_series(
606 + openpyxl: Any,
607 + chart: Any,
608 + workbook: Any,
609 + worksheet: Any,
610 + spec: dict[str, Any],
611 + expected_count: int | None = None,
612 + validate_numeric: bool = False,
613 +) -> str:
614 + include_headers = bool(spec["include_headers"])
615 + ranges = []
616 + for item in _series_items(spec):
617 + values_ref, title, label = _series_values_reference(openpyxl, workbook, worksheet, item, include_headers=include_headers, validate_numeric=validate_numeric)
618 + if title:
619 + chart.series.append(openpyxl.chart.Series(values_ref, title=title))
620 + else:
621 + chart.series.append(openpyxl.chart.Series(values_ref, title_from_data=include_headers))
622 + ranges.append(label)
623 + if expected_count is not None and len(ranges) != expected_count:
624 + raise ValueError(f"chart requires exactly {expected_count} series")
625 + return ", ".join(ranges)
626 +
627 +
628 +def _series_items(spec: dict[str, Any]) -> list[dict[str, Any]]:
629 + raw = spec.get("series") or []
630 + if isinstance(raw, str):
631 + raw = json.loads(raw)
632 + if not isinstance(raw, list):
633 + raise ValueError("chart series must be a list")
634 + items = []
635 + for item in raw:
636 + if isinstance(item, str):
637 + items.append({"values": item})
638 + elif isinstance(item, dict):
639 + items.append(item)
640 + else:
641 + raise ValueError("chart series entries must be objects or range strings")
642 + return items
643 +
644 +
645 +def _series_values_reference(
646 + openpyxl: Any,
647 + workbook: Any,
648 + worksheet: Any,
649 + item: dict[str, Any],
650 + *,
651 + include_headers: bool,
652 + validate_numeric: bool = False,
653 +) -> tuple[Any, str | None, str]:
654 + values = item.get("values") or item.get("range") or item.get("yvalues") or item.get("y")
655 + if not values:
656 + raise ValueError("chart series entries require values or range")
657 + ref, data_sheet, bounds, label = _reference_from_range(openpyxl, workbook, worksheet, values)
658 + title = item.get("title") or item.get("name")
659 + min_col, min_row, max_col, max_row = bounds
660 + if include_headers and min_row == 1 and max_col == min_col and max_row > min_row:
661 + title = title if title is not None else data_sheet.cell(row=min_row, column=min_col).value
662 + ref = openpyxl.chart.Reference(data_sheet, min_col=min_col, min_row=min_row + 1, max_col=max_col, max_row=max_row)
663 + if validate_numeric:
664 + _validate_numeric_series(data_sheet, bounds, include_headers=include_headers, label=title or label)
665 + return ref, str(title) if title is not None else None, label
666 +
667 +
668 +def _stock_field_ranges(spec: dict[str, Any]) -> dict[str, Any]:
669 + fields = spec.get("fields") or {}
670 + if isinstance(fields, str):
671 + fields = json.loads(fields)
672 + if not isinstance(fields, dict):
673 + raise ValueError("stock chart fields must be an object")
674 + result = {}
675 + for label in ("open", "high", "low", "close"):
676 + value = spec.get(label) or fields.get(label)
677 + if value:
678 + result[label] = value
679 + if result and set(result) != {"open", "high", "low", "close"}:
680 + raise ValueError("stock chart fields must include open, high, low, and close")
681 + return result
682 +
683 +
684 +def _reference_from_range(openpyxl: Any, workbook: Any, default_worksheet: Any, value: Any) -> tuple[Any, Any, tuple[int, int, int, int], str]:
685 + sheet_name, cell_range = _split_range_ref(value, default_worksheet.title)
686 + worksheet = _worksheet(workbook, sheet_name)
687 + min_col, min_row, max_col, max_row = openpyxl.utils.cell.range_boundaries(cell_range)
688 + reference = openpyxl.chart.Reference(worksheet, min_col=min_col, min_row=min_row, max_col=max_col, max_row=max_row)
689 + return reference, worksheet, (min_col, min_row, max_col, max_row), _range_label(openpyxl, worksheet.title, min_col, min_row, max_col, max_row)
690 +
691 +
692 +def _split_range_ref(value: Any, default_sheet: str) -> tuple[str, str]:
693 + if isinstance(value, dict):
694 + sheet = str(value.get("sheet") or default_sheet)
695 + min_cell = value.get("range") or value.get("ref")
696 + if min_cell:
697 + return _split_range_ref(str(min_cell), sheet)
698 + min_col = value.get("min_col")
699 + min_row = value.get("min_row")
700 + max_col = value.get("max_col", min_col)
701 + max_row = value.get("max_row", min_row)
702 + if min_col is None or min_row is None:
703 + raise ValueError("range objects require range/ref or min_col and min_row")
704 + return sheet, f"{_cell_ref(min_col, min_row)}:{_cell_ref(max_col, max_row)}"
705 + ref = str(value or "").strip()
706 + if not ref:
707 + raise ValueError("chart range is required")
708 + if "!" not in ref:
709 + return default_sheet, ref
710 + sheet, cell_range = ref.rsplit("!", 1)
711 + return sheet.strip().strip("'") or default_sheet, cell_range
712 +
713 +
714 +def _range_label(openpyxl: Any, sheet_title: str, min_col: int, min_row: int, max_col: int, max_row: int) -> str:
715 + start = f"{openpyxl.utils.cell.get_column_letter(min_col)}{min_row}"
716 + end = f"{openpyxl.utils.cell.get_column_letter(max_col)}{max_row}"
717 + sheet = sheet_title if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", sheet_title) else f"'{sheet_title}'"
718 + return f"{sheet}!{start}:{end}"
719 +
720 +
721 +def _include_headers_for_range(spec: dict[str, Any], range_value: Any) -> bool:
722 + include_headers = bool(spec["include_headers"])
723 + if spec.get("_include_headers_explicit"):
724 + return include_headers
725 + try:
726 + _, cell_range = _split_range_ref(range_value, "")
727 + _, min_row, _, _ = __import__("openpyxl").utils.cell.range_boundaries(cell_range)
728 + except Exception:
729 + return include_headers
730 + return include_headers and min_row == 1
731 +
732 +
733 +def _validate_stock_headers(data_sheet: Any, columns: list[int], min_row: int, *, include_headers: bool) -> None:
734 + if not include_headers or min_row != 1:
735 + return
736 + expected = ["open", "high", "low", "close"]
737 + found = [str(data_sheet.cell(row=min_row, column=column).value or "").strip().lower() for column in columns]
738 + if found != expected:
739 + raise ValueError(f"stock charts require Open, High, Low, Close columns in order; found {found}")
740 +
741 +
742 +def _validate_numeric_series(data_sheet: Any, bounds: tuple[int, int, int, int], *, include_headers: bool, label: Any) -> None:
743 + min_col, min_row, max_col, max_row = bounds
744 + start_row = min_row + 1 if include_headers and min_row == 1 and max_row > min_row else min_row
745 + values = [
746 + data_sheet.cell(row=row, column=column).value
747 + for column in range(min_col, max_col + 1)
748 + for row in range(start_row, max_row + 1)
749 + ]
750 + numeric_count = sum(1 for value in values if isinstance(value, (int, float)) and not isinstance(value, bool))
751 + if numeric_count == 0:
752 + name = str(label or _range_label(__import__("openpyxl"), data_sheet.title, min_col, min_row, max_col, max_row))
753 + raise ValueError(f"chart series '{name}' has no numeric data")
754 +
755 +
756 +def _default_category_range(worksheet: Any, *, include_headers: bool) -> str:
757 + if (worksheet.max_column or 0) < 2 or (worksheet.max_row or 0) < 2:
758 + return ""
759 + first_row_is_header = _looks_like_category_header(worksheet.cell(row=1, column=1).value)
760 + return f"A{2 if include_headers or first_row_is_header else 1}:A{worksheet.max_row}"
761 +
762 +
763 +def _default_data_range(worksheet: Any, chart_type: str, include_headers: bool) -> str:
764 + max_row = worksheet.max_row or 1
765 + max_col = worksheet.max_column or 1
766 + if chart_type == "stock":
767 + if max_col < 5 or max_row < 2:
768 + raise ValueError("stock charts need Date, Open, High, Low, Close columns or explicit ranges")
769 + return f"B{1 if include_headers else 2}:E{max_row}"
770 + if chart_type == "pie" and max_col >= 2:
771 + return f"B{1 if include_headers else 2}:B{max_row}"
772 + start_col = 2 if max_col >= 2 else 1
773 + return f"{_column_letter(start_col)}{1 if include_headers else 2}:{_column_letter(max_col)}{max_row}"
774 +
775 +
776 +def _cell_ref(column: Any, row: Any) -> str:
777 + return f"{_column_letter(column)}{int(row)}"
778 +
779 +
780 +def _column_letter(column: Any) -> str:
781 + if isinstance(column, str) and column.isalpha():
782 + return column.upper()
783 + return __import__("openpyxl").utils.cell.get_column_letter(int(column))
784 +
785 +
786 +def _infer_default_chart_type(worksheet: Any) -> str:
787 + headers = [str(worksheet.cell(row=1, column=column).value or "").strip().lower() for column in range(1, (worksheet.max_column or 0) + 1)]
788 + if {"open", "high", "low", "close"}.issubset(set(headers)):
789 + return "stock"
790 + return "line"
791 +
792 +
793 +def _looks_like_category_header(value: Any) -> bool:
794 + return str(value or "").strip().lower() in {"date", "time", "category", "label", "month", "year"}
795 +
796 +
797 +def _apply_chart_options(chart: Any, spec: dict[str, Any]) -> None:
798 + if spec.get("title"):
799 + chart.title = str(spec["title"])
800 + if spec.get("style") not in (None, ""):
801 + chart.style = int(spec["style"])
802 + chart.width = spec["width"]
803 + chart.height = spec["height"]
804 + if _bool_value(spec.get("legend"), default=True) is False:
805 + chart.legend = None
806 + if spec.get("x_axis_title") and hasattr(chart, "x_axis"):
807 + chart.x_axis.title = str(spec["x_axis_title"])
808 + if spec.get("y_axis_title") and hasattr(chart, "y_axis"):
809 + chart.y_axis.title = str(spec["y_axis_title"])
810 +
811 +
812 +def _chart_summary(chart: Any) -> dict[str, Any]:
813 + return {
814 + "type": _chart_kind(chart),
815 + "title": _chart_title(chart),
816 + "anchor": _chart_anchor(chart),
817 + "series_count": len(getattr(chart, "series", [])),
818 + }
819 +
820 +
821 +def _chart_kind(chart: Any) -> str:
822 + name = chart.__class__.__name__.replace("Chart", "").lower()
823 + return {"bar": "bar_or_column", "stock": "stock"}.get(name, name)
824 +
825 +
826 +def _chart_title(chart: Any) -> str:
827 + title = getattr(chart, "title", None)
828 + if title is None or isinstance(title, str):
829 + return title or ""
830 + try:
831 + paragraphs = title.tx.rich.p
832 + parts = []
833 + for paragraph in paragraphs:
834 + for run in paragraph.r:
835 + if run.t:
836 + parts.append(run.t)
837 + return "".join(parts)
838 + except Exception:
839 + return ""
840 +
841 +
842 +def _chart_anchor(chart: Any) -> str:
843 + openpyxl = _require_openpyxl()
844 + anchor = getattr(chart, "anchor", "")
845 + if isinstance(anchor, str):
846 + return anchor
847 + marker = getattr(anchor, "_from", None)
848 + if marker is None:
849 + return ""
850 + return f"{openpyxl.utils.cell.get_column_letter(marker.col + 1)}{marker.row + 1}"
851 +
852 +
853 +def _bool_value(value: Any, default: bool = False) -> bool:
854 + if value in (None, ""):
855 + return default
856 + if isinstance(value, bool):
857 + return value
858 + if isinstance(value, (int, float)):
859 + return bool(value)
860 + return str(value).strip().lower() not in {"0", "false", "no", "off", "none"}
861 +
862 +
863 +def _float_or_default(value: Any, default: float) -> float:
864 + if value in (None, ""):
865 + return default
866 + try:
867 + return float(value)
868 + except (TypeError, ValueError):
869 + return default
870 +
871 +
872 def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
873 if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
874 raise ValueError(f"Unsupported PPTX operation: {op}")
plugins/_office/helpers/wopi_store.py
+85 -5
@@ -1,8 +1,11 @@
1 from __future__ import annotations
2
3 +import csv
4 import hashlib
5 +import io
6 import json
7 import os
8 +import re
9 import secrets
10 import shutil
11 import sqlite3
@@ -564,8 +567,6 @@ def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
567
568
569 def _zip_bytes(files_map: dict[str, str | bytes], stored: set[str] | None = None) -> bytes:
567 - import io
568 -
570 buffer = io.BytesIO()
571 with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
572 for name, value in files_map.items():
@@ -587,10 +588,10 @@ def _docx(title: str, content: str) -> bytes:
588
589
590 def _xlsx(title: str, content: str) -> bytes:
590 - rows = [title] + [line for line in content.splitlines() if line.strip()]
591 + rows = _xlsx_rows(title, content)
592 sheet_rows = "".join(
592 - f'<row r="{idx}"><c r="A{idx}" t="inlineStr"><is><t>{escape(line)}</t></is></c></row>'
593 - for idx, line in enumerate(rows, start=1)
593 + f'<row r="{row_idx}">{"".join(_xlsx_cell(row_idx, col_idx, value) for col_idx, value in enumerate(row, start=1))}</row>'
594 + for row_idx, row in enumerate(rows, start=1)
595 )
596 return _zip_bytes({
597 "[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="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>""",
@@ -601,6 +602,85 @@ def _xlsx(title: str, content: str) -> bytes:
602 })
603
604
605 +def _xlsx_rows(title: str, content: str) -> list[list[Any]]:
606 + parsed = _tabular_rows(content)
607 + if parsed:
608 + return parsed
609 + lines = [line for line in str(content or "").splitlines() if line.strip()]
610 + if lines:
611 + return [[title], *[[line] for line in lines]]
612 + return [[title]]
613 +
614 +
615 +def _tabular_rows(content: str) -> list[list[Any]]:
616 + text = str(content or "").strip("\n")
617 + if not text.strip():
618 + return []
619 + lines = [line for line in text.splitlines() if line.strip()]
620 + markdown_rows = _markdown_table_rows(lines)
621 + if markdown_rows:
622 + return markdown_rows
623 +
624 + delimiter = "\t" if any("\t" in line for line in lines) else ("," if any("," in line for line in lines) else None)
625 + if not delimiter:
626 + return []
627 + return [[_xlsx_value(cell) for cell in row] for row in csv.reader(io.StringIO("\n".join(lines)), delimiter=delimiter)]
628 +
629 +
630 +def _markdown_table_rows(lines: list[str]) -> list[list[Any]]:
631 + table_lines = [line.strip() for line in lines if line.strip().startswith("|") and line.strip().endswith("|")]
632 + if len(table_lines) < 2:
633 + return []
634 + rows = []
635 + for line in table_lines:
636 + cells = [cell.strip() for cell in line.strip("|").split("|")]
637 + if all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
638 + continue
639 + rows.append([_xlsx_value(cell) for cell in cells])
640 + return rows
641 +
642 +
643 +def _xlsx_cell(row_idx: int, col_idx: int, value: Any) -> str:
644 + ref = f"{_column_name(col_idx)}{row_idx}"
645 + value = _xlsx_value(value)
646 + if value in (None, ""):
647 + return f'<c r="{ref}"/>'
648 + if isinstance(value, bool):
649 + return f'<c r="{ref}" t="b"><v>{1 if value else 0}</v></c>'
650 + if isinstance(value, (int, float)):
651 + return f'<c r="{ref}"><v>{value}</v></c>'
652 + return f'<c r="{ref}" t="inlineStr"><is><t>{escape(str(value))}</t></is></c>'
653 +
654 +
655 +def _xlsx_value(value: Any) -> Any:
656 + if not isinstance(value, str):
657 + return value
658 + stripped = value.strip()
659 + if not stripped:
660 + return ""
661 + if stripped.lower() in {"true", "false"}:
662 + return stripped.lower() == "true"
663 + if re.fullmatch(r"[+-]?\d+", stripped) and not (len(stripped.lstrip("+-")) > 1 and stripped.lstrip("+-").startswith("0")):
664 + try:
665 + return int(stripped)
666 + except ValueError:
667 + return stripped
668 + if re.fullmatch(r"[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?", stripped) or re.fullmatch(r"[+-]?\d+[eE][+-]?\d+", stripped):
669 + try:
670 + return float(stripped)
671 + except ValueError:
672 + return stripped
673 + return stripped
674 +
675 +
676 +def _column_name(index: int) -> str:
677 + name = ""
678 + while index:
679 + index, remainder = divmod(index - 1, 26)
680 + name = chr(65 + remainder) + name
681 + return name
682 +
683 +
684 def _pptx(title: str, content: str) -> bytes:
685 subtitle = content.splitlines()[0] if content.splitlines() else ""
686 return _zip_bytes({
plugins/_office/prompts/agent.system.tool.document_artifact.md
+3
@@ -3,4 +3,7 @@ create/open/read/edit reusable Office artifacts in the Agent Zero canvas
3 formats: docx xlsx pptx odt ods odp
4 methods: create open read edit inspect export version_history restore_version status
5 common args: kind title format content path file_id
6 +XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
7 +chart types: line bar column pie area scatter stock ohlc candlestick
8 +XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
9 for nontrivial Office artifact work, load skill `office-artifacts` first
plugins/_office/skills/office-artifacts/SKILL.md
+27 -2
@@ -1,7 +1,7 @@
1 ---
2 name: office-artifacts
3 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.
4 -version: "1.0.0"
4 +version: "1.1.0"
5 author: "Agent Zero Core Team"
6 tags: ["office", "docx", "xlsx", "pptx", "canvas", "documents", "spreadsheets", "presentations"]
7 triggers:
@@ -44,6 +44,8 @@ Create:
44 }
45 ```
46
47 +For spreadsheets, `content` can be CSV, TSV, or a Markdown table; the tool writes real cells, not one text blob per row.
48 +
49 Read:
50 ```json
51 {
@@ -82,10 +84,31 @@ Set spreadsheet cells:
84 }
85 ```
86
87 +Create an embedded spreadsheet chart:
88 +```json
89 +{
90 + "tool_name": "document_artifact:edit",
91 + "tool_args": {
92 + "file_id": "abc123",
93 + "operation": "create_chart",
94 + "sheet": "Sheet1",
95 + "chart": {
96 + "type": "line",
97 + "title": "Monthly Revenue",
98 + "data_range": "B1:C13",
99 + "categories": "A2:A13",
100 + "position": "E1",
101 + "width": 18,
102 + "height": 10
103 + }
104 + }
105 +}
106 +```
107 +
108 ## Edit Operations
109
110 - DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
88 -- XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
111 +- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
112 - PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
113
114 Arguments:
@@ -93,6 +116,7 @@ Arguments:
116 - `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
117 - `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
118 - `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
119 +- `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`.
120 - `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
121 - `count` limits text replacements.
122
@@ -100,5 +124,6 @@ Arguments:
124
125 - Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
126 - Use `read` before editing unless the current saved content is already known.
127 +- 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.
128 - Use `edit` for precise saved changes; use the visual Office canvas for human/manual layout polish.
129 - Direct edits update version history and refresh the canvas on edit/open results.
plugins/_office/tools/document_artifact.py
+2
@@ -25,6 +25,7 @@ class DocumentArtifact(Tool):
25 sheet: str = "",
26 cells: Any = None,
27 rows: Any = None,
28 + chart: Any = None,
29 slides: Any = None,
30 max_chars: int | str = 12000,
31 **kwargs: Any,
@@ -57,6 +58,7 @@ class DocumentArtifact(Tool):
58 sheet=sheet,
59 cells=cells,
60 rows=rows,
61 + chart=chart,
62 slides=slides,
63 **kwargs,
64 )
tests/test_office_wopi_store.py
+79
@@ -201,6 +201,85 @@ def test_document_artifact_xlsx_edit_sets_cells_and_appends_rows(office_state):
201 assert ["Research", 9800] in rows
202
203
204 +def test_document_artifact_xlsx_create_parses_csv_content_for_charting(office_state):
205 + doc = wopi_store.create_document(
206 + "spreadsheet",
207 + "Revenue Demo",
208 + "xlsx",
209 + "\n".join([
210 + "Month,Revenue,Costs",
211 + "Jan,120,80",
212 + "Feb,135,92",
213 + "Mar,150,96",
214 + ]),
215 + )
216 + content = artifact_editor.read_artifact(doc)
217 + rows = content["sheets"][0]["preview_rows"]
218 +
219 + assert rows[0] == ["Month", "Revenue", "Costs"]
220 + assert rows[1] == ["Jan", 120, 80]
221 +
222 + updated, payload = artifact_editor.edit_artifact(
223 + doc,
224 + operation="create_chart",
225 + chart={"type": "line", "position": "E1"},
226 + )
227 +
228 + assert payload["changed"] is True
229 + assert payload["charts"][0]["type"] == "line"
230 + assert payload["charts"][0]["position"] == "E1"
231 + assert artifact_editor.read_artifact(updated)["sheets"][0]["chart_count"] == 1
232 +
233 +
234 +def test_document_artifact_xlsx_stock_chart_rejects_non_numeric_ohlc_data(office_state):
235 + doc = wopi_store.create_document(
236 + "spreadsheet",
237 + "Broken Trading Demo",
238 + "xlsx",
239 + "\n".join([
240 + "Date,Open,High,Low,Close",
241 + "2026-04-24,open,high,low,close",
242 + "2026-04-25,still,not,real,numbers",
243 + ]),
244 + )
245 +
246 + with pytest.raises(ValueError, match="no numeric data"):
247 + artifact_editor.edit_artifact(doc, operation="create_chart", chart={"type": "candlestick"})
248 +
249 +
250 +def test_document_artifact_xlsx_edit_creates_stock_chart(office_state):
251 + doc = wopi_store.create_document("spreadsheet", "Trading Demo", "xlsx", "")
252 + rows = [
253 + ["Date", "Open", "High", "Low", "Close", "Volume"],
254 + ["2026-04-24", 100, 105, 99, 104, 1000],
255 + ["2026-04-25", 104, 106, 102, 103, 1200],
256 + ["2026-04-28", 103, 108, 101, 107, 1800],
257 + ]
258 + updated, _ = artifact_editor.edit_artifact(doc, operation="set_rows", rows=rows)
259 +
260 + updated, payload = artifact_editor.edit_artifact(
261 + updated,
262 + operation="create_chart",
263 + chart={
264 + "type": "candlestick",
265 + "title": "DEMO Stock Price (OHLC)",
266 + "position": "A8",
267 + "width": 16,
268 + "height": 8,
269 + },
270 + )
271 + content = artifact_editor.read_artifact(updated)
272 + sheet = content["sheets"][0]
273 +
274 + assert payload["changed"] is True
275 + assert payload["charts_created"] == 1
276 + assert payload["charts"][0]["type"] == "stock"
277 + assert payload["charts"][0]["series_count"] == 4
278 + assert sheet["chart_count"] == 1
279 + assert sheet["charts"][0]["type"] == "stock"
280 + assert sheet["charts"][0]["title"] == "DEMO Stock Price (OHLC)"
281 +
282 +
283 def test_document_artifact_pptx_edit_sets_slides(office_state):
284 doc = wopi_store.create_document("presentation", "Roadmap", "pptx", "Initial")
285