407 lines
18 KiB
C#
407 lines
18 KiB
C#
using System.IO;
|
|
using System.Text.Json;
|
|
using DocumentFormat.OpenXml;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using DocumentFormat.OpenXml.Spreadsheet;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// Excel (.xlsx) 문서를 생성하는 내장 스킬.
|
|
/// 셀 서식(색상/테두리/볼드), 수식, 열 너비, 셀 병합, 틀 고정 등을 지원합니다.
|
|
/// </summary>
|
|
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<ToolResult> 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<WorkbookStylesPart>();
|
|
stylesPart.Stylesheet = CreateStylesheet(isStyled);
|
|
stylesPart.Stylesheet.Save();
|
|
|
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
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<StringValue> { InnerText = "A2" }
|
|
})
|
|
{ TabSelected = true, WorkbookViewId = 0 });
|
|
|
|
var insertBefore = (OpenXmlElement?)worksheetPart.Worksheet.GetFirstChild<Columns>()
|
|
?? worksheetPart.Worksheet.GetFirstChild<SheetData>();
|
|
worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore);
|
|
}
|
|
|
|
workbookPart.Workbook.Save();
|
|
|
|
var features = new List<string>();
|
|
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}";
|
|
}
|