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)); 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"))); 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"))); 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++; 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++; } 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 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 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; }); }