- HtmlSkill에 board_report와 strategy_brief 구조화 섹션 타입을 추가해 이사회 보고형/전략 요약형 HTML 산출물 표현력을 확장 - ArtifactQualityReviewService HTML 리뷰에 board-report-panel, strategy-brief-panel 인식과 보완 포인트 규칙을 추가 - Excel dashboard sheet에 KPI, highlights, actions를 함께 렌더링해 executive dashboard 시트 밀도를 강화 - PptxSkillGoldenDeckTests에 strategy deck 회귀 샘플을 추가해 strong 전략 덱 품질 기준을 고정 - README.md와 docs/DEVELOPMENT.md에 2026-04-14 23:32 (KST) 기준 이력과 검증 명령을 반영 검증 결과 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next5\\ -p:IntermediateOutputPath=obj\\verify_doc_next5\\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ArtifactQualityReviewServiceTests|ExcelSkillDashboardSummaryTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DeckQualityReviewServiceTests|PptxSkillGoldenDeckTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests -p:OutputPath=bin\\verify_doc_next5_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next5_tests\\ : 통과 14
1607 lines
77 KiB
C#
1607 lines
77 KiB
C#
using System.IO;
|
|
using System.Text.Json;
|
|
using DocumentFormat.OpenXml;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using DocumentFormat.OpenXml.Spreadsheet;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// Excel (.xlsx) 문서를 생성하는 내장 스킬.
|
|
/// 셀 서식(색상/테두리/볼드), 수식, 열 너비, 셀 병합, 틀 고정, 테마, 숫자 형식, 멀티시트, 정렬 등을 지원합니다.
|
|
/// </summary>
|
|
public class ExcelSkill : IAgentTool
|
|
{
|
|
public string Name => "excel_create";
|
|
public string Description => "Create a styled Excel (.xlsx) file. " +
|
|
"Supports: header styling (bold white text on 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<ToolResult> 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<WorkbookStylesPart>();
|
|
stylesPart.Stylesheet = CreateStylesheet(isStyled, theme, customFmts, numFmts, alignments);
|
|
stylesPart.Stylesheet.Save();
|
|
|
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
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,
|
|
false,
|
|
false,
|
|
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<WorkbookStylesPart>();
|
|
stylesPart.Stylesheet = CreateStylesheet(true, theme, customFmts, numFmts, alignments);
|
|
stylesPart.Stylesheet.Save();
|
|
|
|
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
|
|
var dashboardSheetName = GetDashboardSheetName(summarySheet);
|
|
var hasDashboardSheet = HasDashboardSheet(summarySheet);
|
|
var summaryLinkedSheets = hasDashboardSheet
|
|
? new List<string> { dashboardSheetName, sheetName }
|
|
: [sheetName];
|
|
|
|
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
|
|
? summaryNameEl.SafeGetString() ?? "Summary"
|
|
: "Summary";
|
|
var summaryPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
WriteExecutiveSummarySheet(summaryPart, summarySheet, summaryLinkedSheets);
|
|
wbSheets.Append(new Sheet
|
|
{
|
|
Id = workbookPart.GetIdOfPart(summaryPart),
|
|
SheetId = 1,
|
|
Name = summaryName,
|
|
});
|
|
|
|
uint nextSheetId = 2;
|
|
if (hasDashboardSheet)
|
|
{
|
|
var dashboardPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
WriteDashboardSheet(dashboardPart, summarySheet, [sheetName]);
|
|
wbSheets.Append(new Sheet
|
|
{
|
|
Id = workbookPart.GetIdOfPart(dashboardPart),
|
|
SheetId = nextSheetId++,
|
|
Name = dashboardSheetName,
|
|
});
|
|
}
|
|
|
|
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
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 = nextSheetId,
|
|
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,
|
|
hasDashboardSheet ? 3 : 2,
|
|
1,
|
|
rowCount,
|
|
CountFormulaCells(rows),
|
|
summaryLinkedSheets.Count,
|
|
validationCount,
|
|
conditionalFormattingCount,
|
|
true,
|
|
hasDashboardSheet,
|
|
HasSummaryItems(summarySheet, "highlights"),
|
|
HasSummaryItems(summarySheet, "actions"),
|
|
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"),
|
|
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<string?>();
|
|
var allAligns = new List<string?>();
|
|
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<WorkbookStylesPart>();
|
|
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<string>();
|
|
var dashboardSheetName = GetDashboardSheetName(summarySheet);
|
|
var hasDashboardSheet = HasDashboardSheet(summarySheet);
|
|
|
|
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<WorksheetPart>();
|
|
var summaryLinkedSheets = hasDashboardSheet
|
|
? new[] { dashboardSheetName }.Concat(detailSheetNames).ToList()
|
|
: detailSheetNames;
|
|
WriteExecutiveSummarySheet(summaryPart, summarySheet, summaryLinkedSheets);
|
|
wbSheets.Append(new Sheet
|
|
{
|
|
Id = workbookPart.GetIdOfPart(summaryPart),
|
|
SheetId = sheetId++,
|
|
Name = summaryName,
|
|
});
|
|
|
|
if (hasDashboardSheet)
|
|
{
|
|
var dashboardPart = workbookPart.AddNewPart<WorksheetPart>();
|
|
WriteDashboardSheet(dashboardPart, summarySheet, detailSheetNames);
|
|
wbSheets.Append(new Sheet
|
|
{
|
|
Id = workbookPart.GetIdOfPart(dashboardPart),
|
|
SheetId = sheetId++,
|
|
Name = dashboardSheetName,
|
|
});
|
|
}
|
|
}
|
|
|
|
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>();
|
|
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) + (hasDashboardSheet ? 1 : 0),
|
|
totalSheets,
|
|
totalRows,
|
|
totalFormulaCount,
|
|
summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count + (hasDashboardSheet ? 1 : 0) : 0,
|
|
totalValidationCount,
|
|
totalConditionalFormattingCount,
|
|
summarySheet.ValueKind == JsonValueKind.Object,
|
|
hasDashboardSheet,
|
|
HasSummaryItems(summarySheet, "highlights"),
|
|
HasSummaryItems(summarySheet, "actions"),
|
|
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"),
|
|
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<string> 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);
|
|
AppendTrendSection(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 WriteDashboardSheet(WorksheetPart worksheetPart, JsonElement summarySheet, IReadOnlyList<string> 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 = 22, 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 baseTitle = summarySheet.SafeTryGetProperty("title", out var titleEl)
|
|
? titleEl.SafeGetString() ?? "Executive Summary"
|
|
: "Executive Summary";
|
|
var subtitle = summarySheet.SafeTryGetProperty("subtitle", out var subtitleEl)
|
|
? subtitleEl.SafeGetString() ?? string.Empty
|
|
: string.Empty;
|
|
|
|
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"{baseTitle} Dashboard", 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);
|
|
AppendTrendSection(summarySheet, sheetData, ref rowIndex);
|
|
AppendKpiSection(summarySheet, sheetData, merges, ref rowIndex);
|
|
AppendSheetSummarySection(summarySheet, sheetData, ref rowIndex);
|
|
AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex);
|
|
AppendSummaryTextSection(summarySheet, "actions", "Priority Actions", sheetData, merges, ref rowIndex);
|
|
|
|
if (detailSheetNames.Count > 0)
|
|
{
|
|
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Linked Detail Sheets", 1);
|
|
var startRow = rowIndex;
|
|
foreach (var detailSheetName in detailSheetNames)
|
|
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"Open: {detailSheetName}", 0);
|
|
|
|
for (var i = 0; i < detailSheetNames.Count; i++)
|
|
{
|
|
hyperlinks.Append(new Hyperlink
|
|
{
|
|
Reference = $"A{startRow + (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 AppendTrendSection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
|
|
{
|
|
if (!summarySheet.SafeTryGetProperty("trend_series", out var trendSeries)
|
|
|| trendSeries.ValueKind != JsonValueKind.Array
|
|
|| trendSeries.GetArrayLength() == 0)
|
|
return;
|
|
|
|
var headerRow = new Row { RowIndex = rowIndex++ };
|
|
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Trend Dashboard", 1));
|
|
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Current", 1));
|
|
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Target", 1));
|
|
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Delta", 1));
|
|
headerRow.Append(CreateSummaryCell("E", headerRow.RowIndex!.Value, "Status", 1));
|
|
sheetData.Append(headerRow);
|
|
|
|
var trendIndex = 0;
|
|
foreach (var trend in trendSeries.EnumerateArray())
|
|
{
|
|
var row = new Row { RowIndex = rowIndex++ };
|
|
var stripeStyle = trendIndex % 2 == 0 ? (uint)0 : (uint)2;
|
|
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, trend.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", 1));
|
|
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, trend.SafeTryGetProperty("current", out var currentEl) ? currentEl.SafeGetString() ?? currentEl.ToString() : "", 3));
|
|
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, trend.SafeTryGetProperty("target", out var targetEl) ? targetEl.SafeGetString() ?? targetEl.ToString() : "", stripeStyle));
|
|
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, trend.SafeTryGetProperty("delta", out var deltaEl) ? deltaEl.SafeGetString() ?? deltaEl.ToString() : "", stripeStyle));
|
|
row.Append(CreateSummaryCell("E", row.RowIndex!.Value, trend.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", stripeStyle));
|
|
sheetData.Append(row);
|
|
trendIndex++;
|
|
}
|
|
|
|
rowIndex++;
|
|
}
|
|
|
|
private static void AppendKpiSection(JsonElement summarySheet, SheetData sheetData, MergeCells merges, ref uint rowIndex)
|
|
{
|
|
if (!summarySheet.SafeTryGetProperty("kpis", out var kpis) || kpis.ValueKind != JsonValueKind.Array || kpis.GetArrayLength() == 0)
|
|
return;
|
|
|
|
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 index = 0;
|
|
foreach (var kpi in kpis.EnumerateArray())
|
|
{
|
|
var stripeStyle = index % 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() ?? string.Empty : string.Empty, 1));
|
|
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, kpi.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? string.Empty : string.Empty, 3));
|
|
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, kpi.SafeTryGetProperty("trend", out var trendEl) ? trendEl.SafeGetString() ?? string.Empty : string.Empty, stripeStyle));
|
|
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, kpi.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? string.Empty : string.Empty, stripeStyle));
|
|
sheetData.Append(row);
|
|
index++;
|
|
}
|
|
|
|
rowIndex++;
|
|
}
|
|
|
|
private static bool HasDashboardSheet(JsonElement summarySheet)
|
|
{
|
|
if (summarySheet.ValueKind != JsonValueKind.Object)
|
|
return false;
|
|
|
|
if (summarySheet.SafeTryGetProperty("dashboard_sheet_name", out var dashboardNameEl)
|
|
&& dashboardNameEl.ValueKind == JsonValueKind.String
|
|
&& !string.IsNullOrWhiteSpace(dashboardNameEl.SafeGetString()))
|
|
return true;
|
|
|
|
return summarySheet.SafeTryGetProperty("trend_series", out var trendSeries)
|
|
&& trendSeries.ValueKind == JsonValueKind.Array
|
|
&& trendSeries.GetArrayLength() > 0;
|
|
}
|
|
|
|
private static string GetDashboardSheetName(JsonElement summarySheet)
|
|
{
|
|
if (summarySheet.SafeTryGetProperty("dashboard_sheet_name", out var dashboardNameEl)
|
|
&& dashboardNameEl.ValueKind == JsonValueKind.String
|
|
&& !string.IsNullOrWhiteSpace(dashboardNameEl.SafeGetString()))
|
|
return dashboardNameEl.SafeGetString()!;
|
|
|
|
return "Dashboard";
|
|
}
|
|
|
|
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<string?> numFmts,
|
|
List<string?> 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<StringValue> { InnerText = "A2" }
|
|
})
|
|
{ TabSelected = true, WorkbookViewId = 0 });
|
|
|
|
var insertBefore = (OpenXmlElement?)worksheetPart.Worksheet.GetFirstChild<Columns>()
|
|
?? worksheetPart.Worksheet.GetFirstChild<SheetData>();
|
|
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<StringValue> { 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<SheetData>().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<StringValue> { 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<StringValue> { 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<string?> numFmts,
|
|
List<string?> 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<string?> allNumFmts,
|
|
List<string?>? 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<string?> 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<string?> ParseNumberFormats(JsonElement args, string key)
|
|
{
|
|
var result = new List<string?>();
|
|
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<string?> ParseAlignments(JsonElement args, string key)
|
|
{
|
|
var result = new List<string?>();
|
|
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<string>();
|
|
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}";
|
|
|
|
/// <summary>{icon:name} 패턴을 유니코드 심볼로 치환합니다.</summary>
|
|
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;
|
|
});
|
|
}
|