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 colored background), " +
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
"cell merge, freeze panes (freeze header row), number formatting, " +
"themes (professional/modern/dark/minimal), column alignments, executive summary sheets, and multi-sheet workbooks. " +
"Inline icons: use {icon:name} in cell text (e.g. '{icon:checkmark} 완료', '{icon:warning} 주의'). " +
"170+ built-in icons: checkmark, warning, star, rocket, chart_up, chart_down, etc.";
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' (colored header, striped rows, borders) or 'plain'. Default: 'styled'" },
["theme"] = new() { Type = "string", Description = "Color theme: 'professional' (blue), 'modern' (green), 'dark' (slate), 'minimal' (gray). Default: 'professional'." },
["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." },
["summary_sheet"] = new()
{
Type = "object",
Description = "Optional executive summary sheet inserted before detail sheets. " +
"Example: {\"name\":\"Summary\",\"title\":\"2026 운영 리뷰\",\"subtitle\":\"핵심 KPI와 후속 과제\",\"kpis\":[{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Strong\"}],\"highlights\":[\"핵심 인사이트 1\",\"핵심 인사이트 2\"],\"actions\":[\"즉시 과제 1\",\"즉시 과제 2\"]}"
},
["number_formats"] = new() { Type = "array", Description = "Number format per column index. Supported: 'currency' (#,##0\"원\"), 'percent' (0.00%), 'decimal' (#,##0.00), 'integer' (#,##0), 'date' (yyyy-mm-dd), or any custom Excel format string. e.g. [\"text\",\"integer\",\"currency\",\"percent\"]", Items = new() { Type = "string" } },
["data_validations"] = new() { Type = "array", Description = "Validation rules such as [{\"range\":\"E2:E100\",\"type\":\"list\",\"formula1\":\"\\\"Open,In Progress,Done\\\"\",\"allow_blank\":true,\"prompt\":\"Select status\"}].", Items = new() { Type = "object" } },
["conditional_formats"] = new() { Type = "array", Description = "Conditional formatting rules such as [{\"range\":\"C2:C20\",\"type\":\"color_scale\",\"low\":\"FEE2E2\",\"mid\":\"FEF3C7\",\"high\":\"DCFCE7\"},{\"range\":\"D2:D20\",\"type\":\"data_bar\",\"color\":\"2563EB\"}].", Items = new() { Type = "object" } },
["col_alignments"] = new() { Type = "array", Description = "Horizontal alignment per column: 'left', 'center', 'right'. Headers always center-aligned.", Items = new() { Type = "string" } },
["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?, data_validations?, conditional_formats?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
},
Required = []
};
// ═══════════════════════════════════════════════════
// Theme definitions
// ═══════════════════════════════════════════════════
private record ThemeColors(string HeaderBg, string HeaderFg, string StripeBg);
private static ThemeColors GetTheme(string? theme) => theme?.ToLower() switch
{
"modern" => new("FF065F46", "FFFFFFFF", "FFF0FDF4"),
"dark" => new("FF1E293B", "FFFFFFFF", "FFF1F5F9"),
"minimal"=> new("FF374151", "FFFFFFFF", "FFF9FAFB"),
_ => new("FF1F4E79", "FFFFFFFF", "FFF2F7FC"), // professional (default)
};
// ═══════════════════════════════════════════════════
// Number format resolution
// ═══════════════════════════════════════════════════
// Returns (isBuiltIn, numFmtId, formatString)
// Built-in IDs we use: 0=General, 164+ = custom
private static (bool isBuiltIn, uint numFmtId, string? formatCode) ResolveNumberFormat(string? fmt)
{
if (string.IsNullOrEmpty(fmt) || fmt == "text")
return (true, 0, null); // General / no special format
return fmt.ToLower() switch
{
"percent" => (true, 10, null), // 0.00% (built-in id 10)
"decimal" => (true, 4, null), // #,##0.00 (built-in id 4)
"integer" => (true, 3, null), // #,##0 (built-in id 3)
"date" => (true, 14, null), // m/d/yyyy (built-in id 14)
"currency" => (false, 0, "#,##0\"원\""), // custom
_ => (false, 0, fmt), // user-supplied custom string
};
}
// ═══════════════════════════════════════════════════
// Execute
// ═══════════════════════════════════════════════════
public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// path 미제공 시 title 또는 sheet_name에서 자동 생성
string path;
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.SafeGetString()!;
}
else
{
var hint = (args.SafeTryGetProperty("title", out var tEl) ? tEl.SafeGetString() : null)
?? (args.SafeTryGetProperty("sheet_name", out var snEl) ? snEl.SafeGetString() : null)
?? "workbook";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "workbook" : safe) + ".xlsx";
}
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 dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var hasSummarySheet = args.SafeTryGetProperty("summary_sheet", out var summarySheet)
&& summarySheet.ValueKind == JsonValueKind.Object;
// Determine if we are in multi-sheet mode
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
&& sheetsArr.ValueKind == JsonValueKind.Array
&& sheetsArr.GetArrayLength() > 0;
if (multiSheetMode)
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath, hasSummarySheet ? summarySheet : default);
else if (hasSummarySheet)
return GenerateSingleSheetWorkbookWithSummary(args, summarySheet, fullPath);
else
return GenerateSingleSheetWorkbook(args, fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Excel 생성 실패: {ex.Message}");
}
}
// ═══════════════════════════════════════════════════
// Single-sheet workbook (backward-compatible path)
// ═══════════════════════════════════════════════════
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
{
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers'");
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
var isStyled = tableStyle != "plain";
var freezeHeader= args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(args, "number_formats");
var alignments = ParseAlignments(args, "col_alignments");
// Collect custom number formats for stylesheet
var customFmts = CollectCustomFormats(numFmts);
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
var stylesPart = workbookPart.AddNewPart();
stylesPart.Stylesheet = CreateStylesheet(isStyled, theme, customFmts, numFmts, alignments);
stylesPart.Stylesheet.Save();
var worksheetPart = workbookPart.AddNewPart();
worksheetPart.Worksheet = new Worksheet();
var colCount = headers.GetArrayLength();
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
var conditionalFormatsArg = args.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = 1,
Name = sheetName,
});
workbookPart.Workbook.Save();
var features = BuildFeatureList(isStyled, freezeHeader,
mergesArg.ValueKind == JsonValueKind.Array,
summaryArg.ValueKind == JsonValueKind.Object,
numFmts.Count > 0, alignments.Count > 0, themeName);
var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput(
sheetName,
1,
1,
rowCount,
CountFormulaCells(rows),
0,
validationCount,
conditionalFormattingCount,
false,
false,
summaryArg.ValueKind == JsonValueKind.Object,
false,
false,
false));
features += $"\n{review.ToToolSummary()}";
return ToolResult.Ok(
$"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{features}",
fullPath);
}
// ═══════════════════════════════════════════════════
// Multi-sheet workbook
// ═══════════════════════════════════════════════════
private static ToolResult GenerateSingleSheetWorkbookWithSummary(JsonElement args, JsonElement summarySheet, string fullPath)
{
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'headers'");
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'rows'");
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
var isStyled = tableStyle != "plain";
var freezeHeader = args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(args, "number_formats");
var alignments = ParseAlignments(args, "col_alignments");
var customFmts = CollectCustomFormats(numFmts);
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
var stylesPart = workbookPart.AddNewPart();
stylesPart.Stylesheet = CreateStylesheet(true, theme, customFmts, numFmts, alignments);
stylesPart.Stylesheet.Save();
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
? summaryNameEl.SafeGetString() ?? "Summary"
: "Summary";
var summaryPart = workbookPart.AddNewPart();
WriteExecutiveSummarySheet(summaryPart, summarySheet, [sheetName]);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(summaryPart),
SheetId = 1,
Name = summaryName,
});
var worksheetPart = workbookPart.AddNewPart();
worksheetPart.Worksheet = new Worksheet();
var colCount = headers.GetArrayLength();
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
var conditionalFormatsArg = args.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = 2,
Name = sheetName,
});
workbookPart.Workbook.Save();
var features = BuildFeatureList(isStyled, freezeHeader,
mergesArg.ValueKind == JsonValueKind.Array,
summaryArg.ValueKind == JsonValueKind.Object,
numFmts.Count > 0, alignments.Count > 0, themeName);
var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput(
summaryName,
2,
1,
rowCount,
CountFormulaCells(rows),
1,
validationCount,
conditionalFormattingCount,
true,
HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"),
HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries")));
features += $"\n{review.ToToolSummary()}";
return ToolResult.Ok(
$"Excel ?뚯씪 ?앹꽦 ?꾨즺: {fullPath}\n?쒗듃: {summaryName}, {sheetName}, ?? {colCount}, ?? {rowCount}{features} [?붿빟 ?쒗듃]",
fullPath);
}
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath, JsonElement summarySheet)
{
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
// Build a combined set of custom formats and alignments across all sheets for the stylesheet.
// We create one shared stylesheet (using the first sheet's theme if not overridden).
var allNumFmts = new List();
var allAligns = new List();
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var nf = ParseNumberFormats(sheetDef, "number_formats");
var al = ParseAlignments(sheetDef, "col_alignments");
allNumFmts.AddRange(nf);
allAligns.AddRange(al);
}
// Use first sheet's theme for the shared stylesheet
var firstThemeName = sheetsArr[0].SafeTryGetProperty("theme", out var ft) ? ft.SafeGetString() : null;
var firstTheme = GetTheme(firstThemeName);
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
var stylesPart = workbookPart.AddNewPart();
stylesPart.Stylesheet = CreateStylesheet(true, firstTheme, combinedCustomFmts, allNumFmts, allAligns);
stylesPart.Stylesheet.Save();
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
uint sheetId = 1;
var totalSheets = 0;
var totalRows = 0;
var totalFormulaCount = 0;
var totalValidationCount = 0;
var totalConditionalFormattingCount = 0;
var detailSheetNames = new List();
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var detailSheetName = sheetDef.SafeTryGetProperty("name", out var nameEl)
? nameEl.SafeGetString() ?? $"Sheet{detailSheetNames.Count + 1}"
: $"Sheet{detailSheetNames.Count + 1}";
detailSheetNames.Add(detailSheetName);
}
if (summarySheet.ValueKind == JsonValueKind.Object)
{
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
? summaryNameEl.SafeGetString() ?? "Summary"
: "Summary";
var summaryPart = workbookPart.AddNewPart();
WriteExecutiveSummarySheet(summaryPart, summarySheet, detailSheetNames);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(summaryPart),
SheetId = sheetId++,
Name = summaryName,
});
}
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var sheetName = sheetDef.SafeTryGetProperty("name", out var snEl) ? snEl.SafeGetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
var tableStyle = sheetDef.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "styled" : "styled";
var themeName = sheetDef.SafeTryGetProperty("theme", out var thEl) ? thEl.SafeGetString() : firstThemeName;
var isStyled = tableStyle != "plain";
var freezeHeader = sheetDef.SafeTryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
var alignments = ParseAlignments(sheetDef, "col_alignments");
if (!sheetDef.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
if (!sheetDef.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
var colCount = headers.GetArrayLength();
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var validationsArg = sheetDef.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default;
var conditionalFormatsArg = sheetDef.SafeTryGetProperty("conditional_formats", out var conditionalFormatsEl) ? conditionalFormatsEl : default;
var worksheetPart = workbookPart.AddNewPart();
worksheetPart.Worksheet = new Worksheet();
var (rowCount, validationCount, conditionalFormattingCount) = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, combinedCustomFmts,
summaryArg, mergesArg, validationsArg, conditionalFormatsArg, colCount);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = sheetId,
Name = sheetName,
});
sheetId++;
totalSheets++;
totalRows += rowCount;
totalFormulaCount += CountFormulaCells(rows);
totalValidationCount += validationCount;
totalConditionalFormattingCount += conditionalFormattingCount;
}
workbookPart.Workbook.Save();
var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput(
fullPath,
totalSheets + (summarySheet.ValueKind == JsonValueKind.Object ? 1 : 0),
totalSheets,
totalRows,
totalFormulaCount,
summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count : 0,
totalValidationCount,
totalConditionalFormattingCount,
summarySheet.ValueKind == JsonValueKind.Object,
HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"),
HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries")));
return ToolResult.Ok(
$"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}\n{review.ToToolSummary()}",
fullPath);
}
// ═══════════════════════════════════════════════════
// Core sheet writer (shared by single + multi)
// ═══════════════════════════════════════════════════
private static void WriteExecutiveSummarySheet(WorksheetPart worksheetPart, JsonElement summarySheet, IReadOnlyList detailSheetNames)
{
worksheetPart.Worksheet = new Worksheet();
var columns = new Columns(
new Column { Min = 1, Max = 1, Width = 22, CustomWidth = true },
new Column { Min = 2, Max = 2, Width = 18, CustomWidth = true },
new Column { Min = 3, Max = 3, Width = 18, CustomWidth = true },
new Column { Min = 4, Max = 4, Width = 28, CustomWidth = true },
new Column { Min = 5, Max = 5, Width = 18, CustomWidth = true },
new Column { Min = 6, Max = 6, Width = 30, CustomWidth = true });
worksheetPart.Worksheet.Append(columns);
var sheetData = new SheetData();
worksheetPart.Worksheet.Append(sheetData);
var merges = new MergeCells();
var hyperlinks = new Hyperlinks();
uint rowIndex = 1;
var title = summarySheet.SafeTryGetProperty("title", out var titleEl)
? titleEl.SafeGetString() ?? "Executive Summary"
: "Executive Summary";
var subtitle = summarySheet.SafeTryGetProperty("subtitle", out var subtitleEl)
? subtitleEl.SafeGetString() ?? ""
: "";
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", title, 1);
if (!string.IsNullOrWhiteSpace(subtitle))
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", subtitle, 3);
rowIndex++;
AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex);
AppendScorecardSection(summarySheet, sheetData, ref rowIndex);
if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Key KPIs", 1);
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Metric", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Trend", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1));
sheetData.Append(headerRow);
var kpiIndex = 0;
foreach (var kpi in kpis.EnumerateArray())
{
var styleIndex = kpiIndex % 2 == 0 ? (uint)0 : (uint)2;
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, kpi.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, kpi.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, kpi.SafeTryGetProperty("trend", out var trendEl) ? trendEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, kpi.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", styleIndex));
sheetData.Append(row);
kpiIndex++;
}
rowIndex++;
}
AppendSheetSummarySection(summarySheet, sheetData, ref rowIndex);
AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex);
AppendSummaryTextSection(summarySheet, "actions", "Next Actions", sheetData, merges, ref rowIndex);
if (detailSheetNames.Count > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Detail Sheets", 1);
foreach (var detailSheetName in detailSheetNames)
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {detailSheetName}", 0);
}
if (detailSheetNames.Count > 0)
{
var hyperlinkStartRow = rowIndex - (uint)detailSheetNames.Count;
for (var i = 0; i < detailSheetNames.Count; i++)
{
hyperlinks.Append(new Hyperlink
{
Reference = $"A{hyperlinkStartRow + (uint)i}",
Location = $"'{detailSheetNames[i]}'!A1",
Display = detailSheetNames[i]
});
}
}
if (merges.HasChildren)
worksheetPart.Worksheet.InsertAfter(merges, sheetData);
if (hyperlinks.HasChildren)
{
var anchor = merges.HasChildren ? (OpenXmlElement)merges : sheetData;
worksheetPart.Worksheet.InsertAfter(hyperlinks, anchor);
}
}
private static void AppendDecisionSummarySection(JsonElement summarySheet, SheetData sheetData, MergeCells merges, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty("decision_summary", out var decisionSummary))
return;
var appended = false;
if (decisionSummary.ValueKind == JsonValueKind.Array && decisionSummary.GetArrayLength() > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1);
foreach (var item in decisionSummary.EnumerateArray())
{
var label = item.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "";
var owner = item.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : "";
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, label, 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, value, 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, owner, 0));
sheetData.Append(row);
appended = true;
}
}
else if (decisionSummary.ValueKind == JsonValueKind.Object)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1);
foreach (var property in decisionSummary.EnumerateObject())
{
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, property.Name, 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, property.Value.SafeGetString() ?? property.Value.ToString(), 3));
sheetData.Append(row);
appended = true;
}
}
if (appended)
rowIndex++;
}
private static void AppendScorecardSection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
{
JsonElement scorecards;
var hasScorecards = summarySheet.SafeTryGetProperty("scorecards", out scorecards)
&& scorecards.ValueKind == JsonValueKind.Array
&& scorecards.GetArrayLength() > 0;
if (!hasScorecards)
{
hasScorecards = summarySheet.SafeTryGetProperty("cards", out scorecards)
&& scorecards.ValueKind == JsonValueKind.Array
&& scorecards.GetArrayLength() > 0;
}
if (!hasScorecards)
return;
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Scorecards", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Status", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1));
sheetData.Append(headerRow);
var cardIndex = 0;
foreach (var card in scorecards.EnumerateArray())
{
var row = new Row { RowIndex = rowIndex++ };
var stripeStyle = cardIndex % 2 == 0 ? (uint)0 : (uint)2;
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, card.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, card.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, card.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", stripeStyle));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, card.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", stripeStyle));
sheetData.Append(row);
cardIndex++;
}
rowIndex++;
}
private static void AppendSheetSummarySection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty("sheet_summaries", out var sheetSummaries)
|| sheetSummaries.ValueKind != JsonValueKind.Array
|| sheetSummaries.GetArrayLength() == 0)
return;
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Sheet", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Status", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Summary", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Owner", 1));
sheetData.Append(headerRow);
var summaryIndex = 0;
foreach (var sheetSummary in sheetSummaries.EnumerateArray())
{
var row = new Row { RowIndex = rowIndex++ };
var stripeStyle = summaryIndex % 2 == 0 ? (uint)0 : (uint)2;
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("sheet", out var sheetEl) ? sheetEl.SafeGetString() ?? "" : "", 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("summary", out var summaryEl) ? summaryEl.SafeGetString() ?? "" : "", stripeStyle));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : "", stripeStyle));
sheetData.Append(row);
summaryIndex++;
}
rowIndex++;
}
private static void AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty(key, out var items) || items.ValueKind != JsonValueKind.Array || items.GetArrayLength() == 0)
return;
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", heading, 1);
foreach (var item in items.EnumerateArray())
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {item.SafeGetString() ?? ""}", 0);
rowIndex++;
}
private static void AppendMergedTextRow(SheetData sheetData, MergeCells merges, uint rowIndex, string startColumn, string endColumn, string text, uint styleIndex)
{
var row = new Row { RowIndex = rowIndex };
row.Append(CreateSummaryCell(startColumn, rowIndex, text, styleIndex));
sheetData.Append(row);
merges.Append(new MergeCell { Reference = $"{startColumn}{rowIndex}:{endColumn}{rowIndex}" });
}
private static Cell CreateSummaryCell(string column, uint rowIndex, string text, uint styleIndex)
{
return new Cell
{
CellReference = $"{column}{rowIndex}",
DataType = CellValues.String,
CellValue = new CellValue(text),
StyleIndex = styleIndex,
};
}
private static (int RowCount, int ValidationCount, int ConditionalFormattingCount) WriteSheetContent(
WorksheetPart worksheetPart,
JsonElement args,
string sheetName,
JsonElement headers,
JsonElement rows,
bool isStyled,
bool freezeHeader,
ThemeColors theme,
List numFmts,
List alignments,
List<(string code, uint id)> customFmtRegistry,
JsonElement summaryArg,
JsonElement mergesArg,
JsonElement validationsArg,
JsonElement conditionalFormatsArg,
int colCount)
{
// Column widths
var columns = CreateColumns(args, colCount);
if (columns != null)
worksheetPart.Worksheet.Append(columns);
var sheetData = new SheetData();
worksheetPart.Worksheet.Append(sheetData);
// Header row
var headerRow = new Row { RowIndex = 1 };
for (int ci = 0; ci < colCount; ci++)
{
var h = headers.EnumerateArray().ElementAtOrDefault(ci);
var cellRef = GetCellReference(ci, 0);
var cell = new Cell
{
CellReference = cellRef,
DataType = CellValues.String,
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.SafeGetString() ?? "" : ""),
StyleIndex = isStyled ? (uint)1 : 0,
};
headerRow.Append(cell);
}
sheetData.Append(headerRow);
// Data rows
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 };
// Determine style index
cell.StyleIndex = ResolveDataStyleIndex(
ci, rowCount, isStyled, numFmts, alignments, customFmtRegistry);
var strVal = cellVal.ToString();
// {icon:name} 인라인 아이콘 → 유니코드 심볼로 치환
if (strVal.Contains("{icon:"))
strVal = ResolveInlineIcons(strVal);
if (strVal.StartsWith('='))
{
cell.CellFormula = new CellFormula(strVal);
cell.DataType = null;
}
else if (cellVal.ValueKind == JsonValueKind.Number && !strVal.Contains("{icon:"))
{
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 (summaryArg.ValueKind == JsonValueKind.Object)
AddSummaryRow(sheetData, summaryArg, rowNum, colCount, rowCount, isStyled);
// Cell merges
MergeCells? mergeCellsSection = null;
if (mergesArg.ValueKind == JsonValueKind.Array)
{
mergeCellsSection = new MergeCells();
foreach (var merge in mergesArg.EnumerateArray())
{
var range = merge.SafeGetString();
if (!string.IsNullOrEmpty(range))
mergeCellsSection.Append(new MergeCell { Reference = range });
}
if (mergeCellsSection.HasChildren)
worksheetPart.Worksheet.InsertAfter(mergeCellsSection, sheetData);
}
var validationCount = AddDataValidations(worksheetPart, sheetData, mergeCellsSection, validationsArg);
var conditionalFormattingCount = AddConditionalFormats(worksheetPart, mergeCellsSection, conditionalFormatsArg);
// Freeze header
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);
}
return (rowCount, validationCount, conditionalFormattingCount);
}
private static int AddDataValidations(WorksheetPart worksheetPart, SheetData sheetData, MergeCells? mergeCells, JsonElement validationsArg)
{
if (validationsArg.ValueKind != JsonValueKind.Array || validationsArg.GetArrayLength() == 0)
return 0;
var dataValidations = new DataValidations();
var count = 0;
foreach (var rule in validationsArg.EnumerateArray())
{
var range = rule.SafeTryGetProperty("range", out var rangeEl) ? rangeEl.SafeGetString() : null;
var typeText = rule.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() : null;
var formula1 = rule.SafeTryGetProperty("formula1", out var formulaEl) ? formulaEl.SafeGetString() : null;
if (string.IsNullOrWhiteSpace(range) || string.IsNullOrWhiteSpace(typeText) || string.IsNullOrWhiteSpace(formula1))
continue;
var validation = new DataValidation
{
Type = ResolveValidationType(typeText),
AllowBlank = !(rule.SafeTryGetProperty("allow_blank", out var allowBlankEl) && allowBlankEl.ValueKind == JsonValueKind.False),
ShowInputMessage = rule.SafeTryGetProperty("prompt", out _),
SequenceOfReferences = new ListValue { InnerText = range }
};
if (rule.SafeTryGetProperty("prompt", out var promptEl) && !string.IsNullOrWhiteSpace(promptEl.SafeGetString()))
{
validation.PromptTitle = "Input";
validation.Prompt = promptEl.SafeGetString();
}
validation.Append(new Formula1(formula1));
dataValidations.Append(validation);
count++;
}
if (count == 0)
return 0;
dataValidations.Count = (uint)count;
var anchor = (OpenXmlElement?)mergeCells ?? sheetData;
worksheetPart.Worksheet.InsertAfter(dataValidations, anchor);
return count;
}
private static int AddConditionalFormats(WorksheetPart worksheetPart, MergeCells? mergeCells, JsonElement conditionalFormatsArg)
{
if (conditionalFormatsArg.ValueKind != JsonValueKind.Array || conditionalFormatsArg.GetArrayLength() == 0)
return 0;
var insertedCount = 0;
OpenXmlElement anchor = mergeCells is not null
? mergeCells
: worksheetPart.Worksheet.Elements().First();
foreach (var rule in conditionalFormatsArg.EnumerateArray())
{
var range = rule.SafeTryGetProperty("range", out var rangeEl) ? rangeEl.SafeGetString() : null;
var type = rule.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString()?.Trim().ToLowerInvariant() : null;
if (string.IsNullOrWhiteSpace(range) || string.IsNullOrWhiteSpace(type))
continue;
ConditionalFormatting? conditionalFormatting = type switch
{
"color_scale" => BuildColorScaleConditionalFormatting(range, rule, (uint)(insertedCount + 1)),
"data_bar" => BuildDataBarConditionalFormatting(range, rule, (uint)(insertedCount + 1)),
_ => null,
};
if (conditionalFormatting == null)
continue;
worksheetPart.Worksheet.InsertAfter(conditionalFormatting, anchor);
anchor = conditionalFormatting;
insertedCount++;
}
return insertedCount;
}
private static ConditionalFormatting? BuildColorScaleConditionalFormatting(string range, JsonElement rule, uint priority)
{
var low = rule.SafeTryGetProperty("low", out var lowEl) ? NormalizeHex(lowEl.SafeGetString(), "FEE2E2") : "FEE2E2";
var mid = rule.SafeTryGetProperty("mid", out var midEl) ? NormalizeHex(midEl.SafeGetString(), "FEF3C7") : "FEF3C7";
var high = rule.SafeTryGetProperty("high", out var highEl) ? NormalizeHex(highEl.SafeGetString(), "DCFCE7") : "DCFCE7";
var ruleElement = new ConditionalFormattingRule
{
Type = ConditionalFormatValues.ColorScale,
Priority = (int)priority,
};
ruleElement.Append(new ColorScale(
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Min },
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Percentile, Val = "50" },
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Max },
new Color { Rgb = low },
new Color { Rgb = mid },
new Color { Rgb = high }));
return new ConditionalFormatting(ruleElement)
{
SequenceOfReferences = new ListValue { InnerText = range }
};
}
private static ConditionalFormatting? BuildDataBarConditionalFormatting(string range, JsonElement rule, uint priority)
{
var color = rule.SafeTryGetProperty("color", out var colorEl) ? NormalizeHex(colorEl.SafeGetString(), "2563EB") : "2563EB";
var ruleElement = new ConditionalFormattingRule
{
Type = ConditionalFormatValues.DataBar,
Priority = (int)priority,
};
ruleElement.Append(new DataBar(
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Min },
new ConditionalFormatValueObject { Type = ConditionalFormatValueObjectValues.Max },
new Color { Rgb = color }));
return new ConditionalFormatting(ruleElement)
{
SequenceOfReferences = new ListValue { InnerText = range }
};
}
private static string NormalizeHex(string? color, string fallback)
{
var value = string.IsNullOrWhiteSpace(color) ? fallback : color.Trim().TrimStart('#');
return value.Length switch
{
6 => "FF" + value.ToUpperInvariant(),
8 => value.ToUpperInvariant(),
_ => fallback,
};
}
private static DataValidationValues ResolveValidationType(string? type)
{
return (type ?? "list").Trim().ToLowerInvariant() switch
{
"whole" or "whole_number" => DataValidationValues.Whole,
"decimal" => DataValidationValues.Decimal,
"date" => DataValidationValues.Date,
"text_length" => DataValidationValues.TextLength,
"custom" => DataValidationValues.Custom,
_ => DataValidationValues.List,
};
}
// ═══════════════════════════════════════════════════
// Style index resolution for data cells
// ═══════════════════════════════════════════════════
// StyleIndex layout (base indices built by CreateStylesheet):
// 0 = default (no style)
// 1 = header (bold, theme header bg)
// 2 = stripe (theme stripe bg)
// 3 = summary (bold, gray bg)
// 4..3+N = number-format variants of default (plain, even-row): base=4
//
// For each unique (numFmt, isStripe, alignment) we build extra CellFormats.
// To keep it simple, we encode them linearly after index 3.
// BuildExtraFormats returns a mapping used both in stylesheet creation and here.
//
// ExtraFormat key: (colIndex, isStripe)
// We pre-compute these in CreateStylesheet and expose via static registry pattern.
//
// To avoid complex cross-method state, we compute style index inline using the
// same deterministic formula used when building CellFormats in CreateStylesheet.
private static uint ResolveDataStyleIndex(
int colIndex,
int dataRowIndex,
bool isStyled,
List numFmts,
List alignments,
List<(string code, uint id)> customFmtRegistry)
{
if (!isStyled && numFmts.Count == 0 && alignments.Count == 0)
return 0;
// Base: 4 reserved indices (0=default, 1=header, 2=stripe, 3=summary)
// Extra formats start at index 4.
// Layout: for each col (0..maxCol-1), two entries: [plain, stripe]
// combined (colIndex, isStripe) into a single ordinal.
bool isStripe = isStyled && dataRowIndex % 2 == 0;
var maxCol = Math.Max(numFmts.Count, alignments.Count);
if (maxCol == 0)
{
// No per-column formats, just use built-in stripes
if (!isStyled) return 0;
return isStripe ? (uint)2 : (uint)0;
}
if (colIndex >= maxCol)
{
// Column beyond per-column format definitions — fall back to default/stripe
if (!isStyled) return 0;
return isStripe ? (uint)2 : (uint)0;
}
// Extra style index = 4 + colIndex * 2 + (isStripe ? 1 : 0)
return (uint)(4 + colIndex * 2 + (isStripe ? 1 : 0));
}
// ═══════════════════════════════════════════════════
// Stylesheet
// ═══════════════════════════════════════════════════
private static Stylesheet CreateStylesheet(
bool isStyled,
ThemeColors theme,
List<(string code, uint id)> customFmtRegistry,
List allNumFmts,
List? allAlignments = null)
{
var stylesheet = new Stylesheet();
// ── NumberingFormats (must come first) ───────────────────
if (customFmtRegistry.Count > 0)
{
var nfSection = new NumberingFormats();
foreach (var (code, id) in customFmtRegistry)
{
nfSection.Append(new NumberingFormat
{
NumberFormatId = id,
FormatCode = code,
});
}
nfSection.Count = (uint)customFmtRegistry.Count;
stylesheet.Append(nfSection);
}
// ── Fonts ─────────────────────────────────────────────────
var fonts = new Fonts(
new Font( // 0: default
new FontSize { Val = 11 },
new FontName { Val = "Noto Sans KR" }
),
new Font( // 1: bold white (header)
new Bold(),
new FontSize { Val = 11 },
new Color { Rgb = theme.HeaderFg },
new FontName { Val = "Noto Sans KR" }
),
new Font( // 2: bold (summary row)
new Bold(),
new FontSize { Val = 11 },
new FontName { Val = "Noto Sans KR" }
)
);
stylesheet.Append(fonts);
// ── Fills ─────────────────────────────────────────────────
var fills = new Fills(
new Fill(new PatternFill { PatternType = PatternValues.None }), // 0: none (required)
new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), // 1: gray125 (required)
new Fill(new PatternFill // 2: theme header bg
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = theme.HeaderBg },
BackgroundColor = new BackgroundColor { Indexed = 64 }
}),
new Fill(new PatternFill // 3: theme stripe bg
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = theme.StripeBg },
BackgroundColor = new BackgroundColor { Indexed = 64 }
}),
new Fill(new PatternFill // 4: light gray (summary)
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" },
BackgroundColor = new BackgroundColor { Indexed = 64 }
})
);
stylesheet.Append(fills);
// ── Borders ───────────────────────────────────────────────
var borders = new Borders(
new Border( // 0: no border
new LeftBorder(), new RightBorder(),
new TopBorder(), new BottomBorder(), new DiagonalBorder()
),
new Border( // 1: thin 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 ───────────────────────────────────────────
// Indices 0-3: reserved base formats (same as before)
// Indices 4+: per-column extra formats (plain + stripe per column)
var cellFormats = new CellFormats();
// 0: default
cellFormats.Append(new CellFormat
{
FontId = 0, FillId = 0, BorderId = isStyled ? (uint)1 : 0,
ApplyBorder = isStyled
});
// 1: header (bold, theme header bg, center)
cellFormats.Append(new CellFormat
{
FontId = 1, FillId = 2, BorderId = 1,
ApplyFont = true, ApplyFill = true, ApplyBorder = true,
Alignment = new Alignment
{
Horizontal = HorizontalAlignmentValues.Center,
Vertical = VerticalAlignmentValues.Center
}
});
// 2: stripe row (theme stripe bg)
cellFormats.Append(new CellFormat
{
FontId = 0, FillId = 3, BorderId = 1,
ApplyFill = true, ApplyBorder = true
});
// 3: summary row (bold + gray bg)
cellFormats.Append(new CellFormat
{
FontId = 2, FillId = 4, BorderId = 1,
ApplyFont = true, ApplyFill = true, ApplyBorder = true
});
// 4+: per-column formats (plain + stripe), two entries per column
int maxCol = Math.Max(allNumFmts.Count, allAlignments?.Count ?? 0);
for (int ci = 0; ci < maxCol; ci++)
{
var fmtStr = ci < allNumFmts.Count ? allNumFmts[ci] : null;
var (isBuiltIn, builtInId, customCode) = ResolveNumberFormat(fmtStr);
uint numFmtId = isBuiltIn ? builtInId : GetCustomFmtId(customCode!, customFmtRegistry);
bool applyNumFmt = numFmtId != 0;
var alignStr = (allAlignments != null && ci < allAlignments.Count) ? allAlignments[ci] : null;
var horizAlign = alignStr switch
{
"right" => HorizontalAlignmentValues.Right,
"center" => HorizontalAlignmentValues.Center,
_ => HorizontalAlignmentValues.Left,
};
bool applyAlign = !string.IsNullOrEmpty(alignStr);
// plain (odd rows)
var plainFmt = new CellFormat
{
FontId = 0, FillId = 0,
BorderId = isStyled ? (uint)1 : 0,
NumberFormatId = numFmtId,
ApplyBorder = isStyled,
ApplyNumberFormat = applyNumFmt,
ApplyAlignment = applyAlign,
};
if (applyAlign)
plainFmt.Alignment = new Alignment { Horizontal = horizAlign };
cellFormats.Append(plainFmt);
// stripe (even rows)
var stripeFmt = new CellFormat
{
FontId = 0, FillId = isStyled ? (uint)3 : 0,
BorderId = isStyled ? (uint)1 : 0,
NumberFormatId = numFmtId,
ApplyFill = isStyled,
ApplyBorder = isStyled,
ApplyNumberFormat = applyNumFmt,
ApplyAlignment = applyAlign,
};
if (applyAlign)
stripeFmt.Alignment = new Alignment { Horizontal = horizAlign };
cellFormats.Append(stripeFmt);
}
stylesheet.Append(cellFormats);
return stylesheet;
}
private static uint GetCustomFmtId(string code, List<(string code, uint id)> registry)
{
foreach (var (c, id) in registry)
if (c == code) return id;
return 0;
}
// Build registry of custom number formats (deduplicated), assigning IDs from 164+
private static List<(string code, uint id)> CollectCustomFormats(IEnumerable numFmts)
{
var registry = new List<(string code, uint id)>();
uint nextId = 164;
foreach (var fmt in numFmts)
{
if (string.IsNullOrEmpty(fmt) || fmt == "text") continue;
var (isBuiltIn, _, customCode) = ResolveNumberFormat(fmt);
if (isBuiltIn || customCode == null) continue;
if (registry.Any(r => r.code == customCode)) continue;
registry.Add((customCode, nextId++));
}
return registry;
}
// ═══════════════════════════════════════════════════
// Column widths
// ═══════════════════════════════════════════════════
private static Columns? CreateColumns(JsonElement args, int colCount)
{
var hasWidths = args.SafeTryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
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;
}
// ═══════════════════════════════════════════════════
// Summary row
// ═══════════════════════════════════════════════════
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
uint rowNum, int colCount, int dataRowCount, bool isStyled)
{
var label = summary.SafeTryGetProperty("label", out var lbl) ? lbl.SafeGetString() ?? "합계" : "합계";
var colFormulas = summary.SafeTryGetProperty("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.SafeTryGetProperty(colLetter, out var funcName))
{
var func = funcName.SafeGetString()?.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);
}
// ═══════════════════════════════════════════════════
// Parameter parsing helpers
// ═══════════════════════════════════════════════════
private static List ParseNumberFormats(JsonElement args, string key)
{
var result = new List();
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString());
return result;
}
private static List ParseAlignments(JsonElement args, string key)
{
var result = new List();
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
foreach (var el in arr.EnumerateArray())
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString()?.ToLower());
return result;
}
private static int CountFormulaCells(JsonElement rows)
{
if (rows.ValueKind != JsonValueKind.Array)
return 0;
var count = 0;
foreach (var row in rows.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Array)
continue;
foreach (var cell in row.EnumerateArray())
{
var text = cell.ToString();
if (!string.IsNullOrWhiteSpace(text) && text.StartsWith('='))
count++;
}
}
return count;
}
private static bool HasSummaryItems(JsonElement summarySheet, string propertyName)
{
return summarySheet.ValueKind == JsonValueKind.Object
&& summarySheet.SafeTryGetProperty(propertyName, out var items)
&& items.ValueKind == JsonValueKind.Array
&& items.GetArrayLength() > 0;
}
private static bool HasStructuredSummaryContent(JsonElement summarySheet, string propertyName)
{
if (summarySheet.ValueKind != JsonValueKind.Object
|| !summarySheet.SafeTryGetProperty(propertyName, out var value))
return false;
return value.ValueKind switch
{
JsonValueKind.Array => value.GetArrayLength() > 0,
JsonValueKind.Object => value.EnumerateObject().Any(),
_ => false,
};
}
private static string BuildFeatureList(bool isStyled, bool freezeHeader,
bool hasMerges, bool hasSummary, bool hasNumFmts, bool hasAlignments, string? theme)
{
var features = new List();
if (isStyled) features.Add("스타일 적용");
if (theme != null) features.Add($"테마:{theme}");
if (freezeHeader) features.Add("틀 고정");
if (hasMerges) features.Add("셀 병합");
if (hasSummary) features.Add("요약행");
if (hasNumFmts) features.Add("숫자형식");
if (hasAlignments) features.Add("정렬");
return features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
}
// ═══════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════
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}";
/// {icon:name} 패턴을 유니코드 심볼로 치환합니다.
private static string ResolveInlineIcons(string text)
=> System.Text.RegularExpressions.Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
return IconLibrary.Contains(name) ? IconLibrary.Resolve(name) : m.Value;
});
}