using System.IO; using System.Text.Json; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; namespace AxCopilot.Services.Agent; /// /// Excel (.xlsx) 문서를 생성하는 내장 스킬. /// 셀 서식(색상/테두리/볼드), 수식, 열 너비, 셀 병합, 틀 고정 등을 지원합니다. /// 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 => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Output file path (.xlsx). Relative to work folder." }, ["sheet_name"] = new() { Type = "string", Description = "Sheet name. Default: 'Sheet1'." }, ["headers"] = new() { Type = "array", Description = "Column headers as JSON array of strings.", Items = new() { Type = "string" } }, ["rows"] = new() { Type = "array", Description = "Data rows as JSON array of arrays. Use string starting with '=' for formulas (e.g. '=SUM(A2:A10)').", Items = new() { Type = "array", Items = new() { Type = "string" } } }, ["style"] = new() { Type = "string", Description = "Table style: 'styled' (blue header, striped rows, borders) or 'plain'. Default: 'styled'" }, ["col_widths"] = new() { Type = "array", Description = "Column widths as JSON array of numbers (in characters). e.g. [15, 10, 20]. Auto-fit if omitted.", Items = new() { Type = "number" } }, ["freeze_header"] = new() { Type = "boolean", Description = "Freeze the header row. Default: true for styled." }, ["merges"] = new() { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new() { Type = "string" } }, ["summary_row"] = new() { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." }, }, Required = ["path", "headers", "rows"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var path = args.GetProperty("path").GetString() ?? ""; var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1"; var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled"; var isStyled = tableStyle != "plain"; var freezeHeader = args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled; var 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 { var headers = args.GetProperty("headers"); var rows = args.GetProperty("rows"); var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook); var workbookPart = spreadsheet.AddWorkbookPart(); workbookPart.Workbook = new Workbook(); // Stylesheet 추가 (서식용) var stylesPart = workbookPart.AddNewPart(); stylesPart.Stylesheet = CreateStylesheet(isStyled); stylesPart.Stylesheet.Save(); var worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(); // 열 너비 설정 var colCount = headers.GetArrayLength(); var columns = CreateColumns(args, colCount); if (columns != null) worksheetPart.Worksheet.Append(columns); var sheetData = new SheetData(); worksheetPart.Worksheet.Append(sheetData); var sheets = workbookPart.Workbook.AppendChild(new Sheets()); sheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(worksheetPart), SheetId = 1, Name = sheetName, }); // 헤더 행 (styleIndex 1 = 볼드 흰색 + 파란배경) var headerRow = new Row { RowIndex = 1 }; int colIdx = 0; foreach (var h in headers.EnumerateArray()) { var cellRef = GetCellReference(colIdx, 0); var cell = new Cell { CellReference = cellRef, DataType = CellValues.String, CellValue = new CellValue(h.GetString() ?? ""), StyleIndex = isStyled ? (uint)1 : 0, }; headerRow.Append(cell); colIdx++; } sheetData.Append(headerRow); // 데이터 행 int rowCount = 0; uint rowNum = 2; foreach (var row in rows.EnumerateArray()) { var dataRow = new Row { RowIndex = rowNum }; int ci = 0; foreach (var cellVal in row.EnumerateArray()) { var cellRef = GetCellReference(ci, (int)rowNum - 1); var cell = new Cell { CellReference = cellRef }; // striped 스타일: 짝수행 if (isStyled && rowCount % 2 == 0) cell.StyleIndex = 2; // 연한 파란 배경 var strVal = cellVal.ToString(); // 수식 (=으로 시작) if (strVal.StartsWith('=')) { cell.CellFormula = new CellFormula(strVal); cell.DataType = null; // 수식은 DataType 없음 } else if (cellVal.ValueKind == JsonValueKind.Number) { cell.DataType = CellValues.Number; cell.CellValue = new CellValue(cellVal.GetDouble().ToString()); } else { cell.DataType = CellValues.String; cell.CellValue = new CellValue(strVal); } dataRow.Append(cell); ci++; } sheetData.Append(dataRow); rowCount++; rowNum++; } // 요약 행 (summary_row) 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) { var mergeCells = new MergeCells(); foreach (var merge in merges.EnumerateArray()) { var range = merge.GetString(); if (!string.IsNullOrEmpty(range)) mergeCells.Append(new MergeCell { Reference = range }); } if (mergeCells.HasChildren) worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData); } // 틀 고정 (헤더 행) if (freezeHeader) { var sheetViews = new SheetViews(new SheetView( new Pane { VerticalSplit = 1, TopLeftCell = "A2", ActivePane = PaneValues.BottomLeft, State = PaneStateValues.Frozen }, new Selection { Pane = PaneValues.BottomLeft, ActiveCell = "A2", SequenceOfReferences = new ListValue { InnerText = "A2" } }) { TabSelected = true, WorkbookViewId = 0 }); var insertBefore = (OpenXmlElement?)worksheetPart.Worksheet.GetFirstChild() ?? worksheetPart.Worksheet.GetFirstChild(); worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore); } workbookPart.Workbook.Save(); var features = new List(); if (isStyled) features.Add("스타일 적용"); if (freezeHeader) features.Add("틀 고정"); if (args.TryGetProperty("merges", out _)) features.Add("셀 병합"); if (args.TryGetProperty("summary_row", out _)) features.Add("요약행"); var 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}"); } } // ═══════════════════════════════════════════════════ // Stylesheet (셀 서식) // ═══════════════════════════════════════════════════ private static Stylesheet CreateStylesheet(bool isStyled) { var stylesheet = new Stylesheet(); // Fonts var fonts = new Fonts( new Font( // 0: 기본 new FontSize { Val = 11 }, new FontName { Val = "맑은 고딕" } ), new Font( // 1: 볼드 흰색 (헤더용) new Bold(), new FontSize { Val = 11 }, new Color { Rgb = "FFFFFFFF" }, new FontName { Val = "맑은 고딕" } ), new Font( // 2: 볼드 (요약행용) new Bold(), new FontSize { Val = 11 }, new FontName { Val = "맑은 고딕" } ) ); stylesheet.Append(fonts); // Fills var fills = new Fills( new Fill(new PatternFill { PatternType = PatternValues.None }), // 0: none (필수) new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), // 1: gray125 (필수) new Fill(new PatternFill // 2: 파란 헤더 배경 { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FF2E74B5" }, BackgroundColor = new BackgroundColor { Indexed = 64 } }), new Fill(new PatternFill // 3: 연한 파란 (striped) { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FFF2F7FB" }, BackgroundColor = new BackgroundColor { Indexed = 64 } }), new Fill(new PatternFill // 4: 연한 회색 (요약행) { PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" }, BackgroundColor = new BackgroundColor { Indexed = 64 } }) ); stylesheet.Append(fills); // Borders var borders = new Borders( new Border( // 0: 테두리 없음 new LeftBorder(), new RightBorder(), new TopBorder(), new BottomBorder(), new DiagonalBorder() ), new Border( // 1: 얇은 테두리 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 var cellFormats = new CellFormats( new CellFormat // 0: 기본 { FontId = 0, FillId = 0, BorderId = isStyled ? (uint)1 : 0, ApplyBorder = isStyled }, new CellFormat // 1: 헤더 (볼드 흰색 + 파란배경 + 테두리) { FontId = 1, FillId = 2, BorderId = 1, ApplyFont = true, ApplyFill = true, ApplyBorder = true, Alignment = new Alignment { Horizontal = HorizontalAlignmentValues.Center, Vertical = VerticalAlignmentValues.Center } }, new CellFormat // 2: striped 행 (연한 파란 배경) { FontId = 0, FillId = 3, BorderId = 1, ApplyFill = true, ApplyBorder = true }, new CellFormat // 3: 요약행 (볼드 + 회색 배경) { FontId = 2, FillId = 4, BorderId = 1, ApplyFont = true, ApplyFill = true, ApplyBorder = true } ); stylesheet.Append(cellFormats); return stylesheet; } // ═══════════════════════════════════════════════════ // 열 너비 // ═══════════════════════════════════════════════════ private static Columns? CreateColumns(JsonElement args, int colCount) { var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array; // col_widths가 없으면 기본 너비 15 적용 var columns = new Columns(); for (int i = 0; i < colCount; i++) { double width = 15; // 기본 너비 if (hasWidths && i < widthsArr.GetArrayLength()) width = widthsArr[i].GetDouble(); columns.Append(new Column { Min = (uint)(i + 1), Max = (uint)(i + 1), Width = width, CustomWidth = true, }); } return columns; } // ═══════════════════════════════════════════════════ // 요약 행 // ═══════════════════════════════════════════════════ private static void AddSummaryRow(SheetData sheetData, JsonElement summary, uint rowNum, int colCount, int dataRowCount, bool isStyled) { var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계"; var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default; var summaryRow = new Row { RowIndex = rowNum }; // 첫 번째 열에 라벨 var labelCell = new Cell { CellReference = GetCellReference(0, (int)rowNum - 1), DataType = CellValues.String, CellValue = new CellValue(label), StyleIndex = isStyled ? (uint)3 : 0, }; summaryRow.Append(labelCell); // 나머지 열에 수식 또는 빈 셀 for (int ci = 1; ci < colCount; ci++) { var colLetter = GetColumnLetter(ci); var cell = new Cell { CellReference = GetCellReference(ci, (int)rowNum - 1), StyleIndex = isStyled ? (uint)3 : 0, }; if (colFormulas.ValueKind == JsonValueKind.Object && colFormulas.TryGetProperty(colLetter, out var funcName)) { var func = funcName.GetString()?.ToUpper() ?? "SUM"; var startRow = 2; var endRow = startRow + dataRowCount - 1; cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})"); } summaryRow.Append(cell); } sheetData.Append(summaryRow); } // ═══════════════════════════════════════════════════ // 유틸리티 // ═══════════════════════════════════════════════════ private static string GetColumnLetter(int colIndex) { var result = ""; while (colIndex >= 0) { result = (char)('A' + colIndex % 26) + result; colIndex = colIndex / 26 - 1; } return result; } private static string GetCellReference(int colIndex, int rowIndex) => $"{GetColumnLetter(colIndex)}{rowIndex + 1}"; }