using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; namespace AxCopilot.Services.Agent; public class ExcelSkill : IAgentTool { public string Name => "excel_create"; public string Description => "Create a styled Excel (.xlsx) file. Supports: header styling (bold white text on blue background), striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), cell merge, freeze panes (freeze header row), and number formatting."; public ToolParameterSchema Parameters { get { ToolParameterSchema obj = new ToolParameterSchema { Properties = new Dictionary { ["path"] = new ToolProperty { Type = "string", Description = "Output file path (.xlsx). Relative to work folder." }, ["sheet_name"] = new ToolProperty { Type = "string", Description = "Sheet name. Default: 'Sheet1'." }, ["headers"] = new ToolProperty { Type = "array", Description = "Column headers as JSON array of strings.", Items = new ToolProperty { Type = "string" } }, ["rows"] = new ToolProperty { Type = "array", Description = "Data rows as JSON array of arrays. Use string starting with '=' for formulas (e.g. '=SUM(A2:A10)').", Items = new ToolProperty { Type = "array", Items = new ToolProperty { Type = "string" } } }, ["style"] = new ToolProperty { Type = "string", Description = "Table style: 'styled' (blue header, striped rows, borders) or 'plain'. Default: 'styled'" }, ["col_widths"] = new ToolProperty { Type = "array", Description = "Column widths as JSON array of numbers (in characters). e.g. [15, 10, 20]. Auto-fit if omitted.", Items = new ToolProperty { Type = "number" } }, ["freeze_header"] = new ToolProperty { Type = "boolean", Description = "Freeze the header row. Default: true for styled." }, ["merges"] = new ToolProperty { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new ToolProperty { Type = "string" } }, ["summary_row"] = new ToolProperty { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." } } }; int num = 3; List list = new List(num); CollectionsMarshal.SetCount(list, num); Span span = CollectionsMarshal.AsSpan(list); span[0] = "path"; span[1] = "headers"; span[2] = "rows"; obj.Required = list; return obj; } } public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { JsonElement value = args.GetProperty("path"); string path = value.GetString() ?? ""; JsonElement sn; string sheetName = (args.TryGetProperty("sheet_name", out sn) ? (sn.GetString() ?? "Sheet1") : "Sheet1"); JsonElement st; string tableStyle = (args.TryGetProperty("style", out st) ? (st.GetString() ?? "styled") : "styled"); bool isStyled = tableStyle != "plain"; JsonElement fh; bool freezeHeader = (args.TryGetProperty("freeze_header", out fh) ? fh.GetBoolean() : isStyled); string fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") { fullPath = AgentContext.EnsureTimestampedPath(fullPath); } if (!fullPath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)) { fullPath += ".xlsx"; } if (!context.IsPathAllowed(fullPath)) { return ToolResult.Fail("경로 접근 차단: " + fullPath); } if (!(await context.CheckWritePermissionAsync(Name, fullPath))) { return ToolResult.Fail("쓰기 권한 거부: " + fullPath); } try { JsonElement headers = args.GetProperty("headers"); JsonElement rows = args.GetProperty("rows"); string dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } using SpreadsheetDocument spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook); WorkbookPart workbookPart = spreadsheet.AddWorkbookPart(); workbookPart.Workbook = new Workbook(); WorkbookStylesPart stylesPart = workbookPart.AddNewPart(); stylesPart.Stylesheet = CreateStylesheet(isStyled); stylesPart.Stylesheet.Save(); WorksheetPart worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(); int colCount = headers.GetArrayLength(); Columns columns = CreateColumns(args, colCount); if (columns != null) { worksheetPart.Worksheet.Append(columns); } SheetData sheetData = new SheetData(); worksheetPart.Worksheet.Append(sheetData); Sheets sheets = workbookPart.Workbook.AppendChild(new Sheets()); sheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(worksheetPart), SheetId = 1u, Name = sheetName }); Row headerRow = new Row { RowIndex = 1u }; int colIdx = 0; foreach (JsonElement h in headers.EnumerateArray()) { string cellRef = GetCellReference(colIdx, 0); Cell cell = new Cell { CellReference = cellRef, DataType = CellValues.String, CellValue = new CellValue(h.GetString() ?? ""), StyleIndex = (isStyled ? 1u : 0u) }; headerRow.Append(cell); colIdx++; } sheetData.Append(headerRow); int rowCount = 0; uint rowNum = 2u; foreach (JsonElement row in rows.EnumerateArray()) { Row dataRow = new Row { RowIndex = rowNum }; int ci = 0; foreach (JsonElement cellVal in row.EnumerateArray()) { string cellRef2 = GetCellReference(ci, (int)(rowNum - 1)); Cell cell2 = new Cell { CellReference = cellRef2 }; if (isStyled && rowCount % 2 == 0) { cell2.StyleIndex = 2u; } string strVal = cellVal.ToString(); if (strVal.StartsWith('=')) { cell2.CellFormula = new CellFormula(strVal); cell2.DataType = null; } else if (cellVal.ValueKind == JsonValueKind.Number) { cell2.DataType = CellValues.Number; cell2.CellValue = new CellValue(cellVal.GetDouble().ToString()); } else { cell2.DataType = CellValues.String; cell2.CellValue = new CellValue(strVal); } dataRow.Append(cell2); ci++; } sheetData.Append(dataRow); rowCount++; rowNum++; } if (args.TryGetProperty("summary_row", out var summary)) { AddSummaryRow(sheetData, summary, rowNum, colCount, rowCount, isStyled); } if (args.TryGetProperty("merges", out var merges) && merges.ValueKind == JsonValueKind.Array) { MergeCells mergeCells = new MergeCells(); foreach (JsonElement item in merges.EnumerateArray()) { string range = item.GetString(); if (!string.IsNullOrEmpty(range)) { mergeCells.Append(new MergeCell { Reference = range }); } } if (mergeCells.HasChildren) { worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData); } } if (freezeHeader) { SheetViews sheetViews = new SheetViews(new SheetView(new Pane { VerticalSplit = 1.0, TopLeftCell = "A2", ActivePane = PaneValues.BottomLeft, State = PaneStateValues.Frozen }, new Selection { Pane = PaneValues.BottomLeft, ActiveCell = "A2", SequenceOfReferences = new ListValue { InnerText = "A2" } }) { TabSelected = true, WorkbookViewId = 0u }); OpenXmlElement insertBefore = (OpenXmlElement)(((object)worksheetPart.Worksheet.GetFirstChild()) ?? ((object)worksheetPart.Worksheet.GetFirstChild())); worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore); } workbookPart.Workbook.Save(); List features = new List(); if (isStyled) { features.Add("스타일 적용"); } if (freezeHeader) { features.Add("틀 고정"); } if (args.TryGetProperty("merges", out value)) { features.Add("셀 병합"); } if (args.TryGetProperty("summary_row", out value)) { features.Add("요약행"); } string featureStr = ((features.Count > 0) ? (" [" + string.Join(", ", features) + "]") : ""); return ToolResult.Ok($"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{featureStr}", fullPath); } catch (Exception ex) { return ToolResult.Fail("Excel 생성 실패: " + ex.Message); } } private static Stylesheet CreateStylesheet(bool isStyled) { Stylesheet stylesheet = new Stylesheet(); Fonts fonts = new Fonts(new Font(new FontSize { Val = 11.0 }, new FontName { Val = "맑은 고딕" }), new Font(new Bold(), new FontSize { Val = 11.0 }, new Color { Rgb = "FFFFFFFF" }, new FontName { Val = "맑은 고딕" }), new Font(new Bold(), new FontSize { Val = 11.0 }, new FontName { Val = "맑은 고딕" })); stylesheet.Append(fonts); Fills fills = new Fills(new Fill(new PatternFill { PatternType = PatternValues.None }), new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), new Fill(new PatternFill { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FF2E74B5" }, BackgroundColor = new BackgroundColor { Indexed = 64u } }), new Fill(new PatternFill { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FFF2F7FB" }, BackgroundColor = new BackgroundColor { Indexed = 64u } }), new Fill(new PatternFill { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" }, BackgroundColor = new BackgroundColor { Indexed = 64u } })); stylesheet.Append(fills); Borders borders = new Borders(new Border(new LeftBorder(), new RightBorder(), new TopBorder(), new BottomBorder(), new DiagonalBorder()), new Border(new LeftBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin }, new RightBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin }, new TopBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin }, new BottomBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin }, new DiagonalBorder())); stylesheet.Append(borders); CellFormats cellFormats = new CellFormats(new CellFormat { FontId = 0u, FillId = 0u, BorderId = (isStyled ? 1u : 0u), ApplyBorder = isStyled }, new CellFormat { FontId = 1u, FillId = 2u, BorderId = 1u, ApplyFont = true, ApplyFill = true, ApplyBorder = true, Alignment = new Alignment { Horizontal = HorizontalAlignmentValues.Center, Vertical = VerticalAlignmentValues.Center } }, new CellFormat { FontId = 0u, FillId = 3u, BorderId = 1u, ApplyFill = true, ApplyBorder = true }, new CellFormat { FontId = 2u, FillId = 4u, BorderId = 1u, ApplyFont = true, ApplyFill = true, ApplyBorder = true }); stylesheet.Append(cellFormats); return stylesheet; } private static Columns? CreateColumns(JsonElement args, int colCount) { JsonElement value; bool flag = args.TryGetProperty("col_widths", out value) && value.ValueKind == JsonValueKind.Array; Columns columns = new Columns(); for (int i = 0; i < colCount; i++) { double num = 15.0; if (flag && i < value.GetArrayLength()) { num = value[i].GetDouble(); } columns.Append(new Column { Min = (uint)(i + 1), Max = (uint)(i + 1), Width = num, CustomWidth = true }); } return columns; } private static void AddSummaryRow(SheetData sheetData, JsonElement summary, uint rowNum, int colCount, int dataRowCount, bool isStyled) { JsonElement value; string text = (summary.TryGetProperty("label", out value) ? (value.GetString() ?? "합계") : "합계"); JsonElement value2; JsonElement jsonElement = (summary.TryGetProperty("columns", out value2) ? value2 : default(JsonElement)); Row row = new Row { RowIndex = rowNum }; Cell cell = new Cell { CellReference = GetCellReference(0, (int)(rowNum - 1)), DataType = CellValues.String, CellValue = new CellValue(text), StyleIndex = (isStyled ? 3u : 0u) }; row.Append(cell); for (int i = 1; i < colCount; i++) { string columnLetter = GetColumnLetter(i); Cell cell2 = new Cell { CellReference = GetCellReference(i, (int)(rowNum - 1)), StyleIndex = (isStyled ? 3u : 0u) }; if (jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(columnLetter, out var value3)) { string value4 = value3.GetString()?.ToUpper() ?? "SUM"; int num = 2; int value5 = num + dataRowCount - 1; cell2.CellFormula = new CellFormula($"={value4}({columnLetter}{num}:{columnLetter}{value5})"); } row.Append(cell2); } sheetData.Append(row); } private static string GetColumnLetter(int colIndex) { string text = ""; while (colIndex >= 0) { text = (char)(65 + colIndex % 26) + text; colIndex = colIndex / 26 - 1; } return text; } private static string GetCellReference(int colIndex, int rowIndex) { return $"{GetColumnLetter(colIndex)}{rowIndex + 1}"; } }