using System.IO; using System.Text.Json; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace AxCopilot.Services.Agent; /// /// Word (.docx) 문서를 생성하는 내장 스킬. /// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다. /// public class DocxSkill : IAgentTool { public string Name => "docx_create"; public string Description => "Create a rich Word (.docx) document. " + "Supports: sections with heading+body, tables with optional header styling, " + "text formatting (bold, italic, color, highlight, shading), " + "headers/footers with page numbers, page breaks between sections, " + "and numbered/bulleted lists."; public ToolParameterSchema Parameters => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Output file path (.docx). Relative to work folder." }, ["title"] = new() { Type = "string", Description = "Document title (optional)." }, ["sections"] = new() { Type = "array", Description = "Array of content blocks. Each block is one of:\n" + "• Section: {\"heading\": \"...\", \"body\": \"...\", \"level\": 1|2}\n" + "• Table: {\"type\": \"table\", \"headers\": [\"A\",\"B\"], \"rows\": [[\"1\",\"2\"]], \"style\": \"striped|plain\"}\n" + "• PageBreak: {\"type\": \"pagebreak\"}\n" + "• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \"item2\"]}\n" + "Body text supports inline formatting: **bold**, *italic*, `code`.", Items = new() { Type = "object" } }, ["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." }, ["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." }, ["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." }, }, Required = ["path", "sections"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var path = args.GetProperty("path").GetString() ?? ""; var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null; var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null; var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() : (headerText != null || footerText != null); var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); if (!fullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) fullPath += ".docx"; if (!context.IsPathAllowed(fullPath)) return ToolResult.Fail($"경로 접근 차단: {fullPath}"); if (!await context.CheckWritePermissionAsync(Name, fullPath)) return ToolResult.Fail($"쓰기 권한 거부: {fullPath}"); try { var sections = args.GetProperty("sections"); var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document); var mainPart = doc.AddMainDocumentPart(); mainPart.Document = new Document(); var body = mainPart.Document.AppendChild(new Body()); // 머리글/바닥글 설정 if (headerText != null || footerText != null || showPageNumbers) AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers); // 제목 if (!string.IsNullOrEmpty(title)) { body.Append(CreateTitleParagraph(title)); // 제목 아래 구분선 body.Append(new Paragraph( new ParagraphProperties { ParagraphBorders = new ParagraphBorders( new BottomBorder { Val = BorderValues.Single, Size = 6, Color = "4472C4", Space = 1 }), SpacingBetweenLines = new SpacingBetweenLines { After = "300" }, })); } int sectionCount = 0; int tableCount = 0; foreach (var section in sections.EnumerateArray()) { var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null; if (blockType == "pagebreak") { body.Append(CreatePageBreak()); continue; } if (blockType == "table") { body.Append(CreateTable(section)); tableCount++; continue; } if (blockType == "list") { AppendList(body, section); continue; } // 일반 섹션 (heading + body) var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : ""; var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : ""; var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1; if (!string.IsNullOrEmpty(heading)) body.Append(CreateHeadingParagraph(heading, level)); if (!string.IsNullOrEmpty(bodyText)) { foreach (var line in bodyText.Split('\n')) { body.Append(CreateBodyParagraph(line)); } } sectionCount++; } mainPart.Document.Save(); var parts = new List(); if (!string.IsNullOrEmpty(title)) parts.Add($"제목: {title}"); if (sectionCount > 0) parts.Add($"섹션: {sectionCount}개"); if (tableCount > 0) parts.Add($"테이블: {tableCount}개"); if (headerText != null) parts.Add("머리글"); if (showPageNumbers) parts.Add("페이지번호"); return ToolResult.Ok( $"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}", fullPath); } catch (Exception ex) { return ToolResult.Fail($"Word 문서 생성 실패: {ex.Message}"); } } // ═══════════════════════════════════════════════════ // 제목/소제목/본문 단락 생성 // ═══════════════════════════════════════════════════ private static Paragraph CreateTitleParagraph(string text) { var para = new Paragraph(); para.ParagraphProperties = new ParagraphProperties { Justification = new Justification { Val = JustificationValues.Center }, SpacingBetweenLines = new SpacingBetweenLines { After = "100" }, }; var run = new Run(new Text(text)); run.RunProperties = new RunProperties { Bold = new Bold(), FontSize = new FontSize { Val = "44" }, // 22pt Color = new Color { Val = "1F3864" }, }; para.Append(run); return para; } private static Paragraph CreateHeadingParagraph(string text, int level) { var para = new Paragraph(); var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt var color = level <= 1 ? "2E74B5" : "404040"; para.ParagraphProperties = new ParagraphProperties { SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" }, }; // 레벨1 소제목에 하단 테두리 추가 if (level <= 1) { para.ParagraphProperties.ParagraphBorders = new ParagraphBorders( new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 }); } var run = new Run(new Text(text)); run.RunProperties = new RunProperties { Bold = new Bold(), FontSize = new FontSize { Val = fontSize }, Color = new Color { Val = color }, }; para.Append(run); return para; } private static Paragraph CreateBodyParagraph(string text) { var para = new Paragraph(); para.ParagraphProperties = new ParagraphProperties { SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격 }; // 인라인 서식 파싱: **bold**, *italic*, `code` AppendFormattedRuns(para, text); return para; } /// **bold**, *italic*, `code` 인라인 서식을 Run으로 변환 private static void AppendFormattedRuns(Paragraph para, string text) { // 패턴: **bold** | *italic* | `code` | 일반텍스트 var regex = new System.Text.RegularExpressions.Regex( @"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`"); int lastIndex = 0; foreach (System.Text.RegularExpressions.Match match in regex.Matches(text)) { // 매치 전 일반 텍스트 if (match.Index > lastIndex) para.Append(CreateRun(text[lastIndex..match.Index])); if (match.Groups[1].Success) // **bold** { var run = CreateRun(match.Groups[1].Value); run.RunProperties ??= new RunProperties(); run.RunProperties.Bold = new Bold(); para.Append(run); } else if (match.Groups[2].Success) // *italic* { var run = CreateRun(match.Groups[2].Value); run.RunProperties ??= new RunProperties(); run.RunProperties.Italic = new Italic(); para.Append(run); } else if (match.Groups[3].Success) // `code` { var run = CreateRun(match.Groups[3].Value); run.RunProperties ??= new RunProperties(); run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" }; run.RunProperties.FontSize = new FontSize { Val = "20" }; run.RunProperties.Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F2F2", Color = "auto" }; para.Append(run); } lastIndex = match.Index + match.Length; } // 나머지 텍스트 if (lastIndex < text.Length) para.Append(CreateRun(text[lastIndex..])); // 빈 텍스트인 경우 빈 Run 추가 if (lastIndex == 0 && text.Length == 0) para.Append(CreateRun("")); } private static Run CreateRun(string text) { var run = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); run.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" }, // 11pt }; return run; } // ═══════════════════════════════════════════════════ // 테이블 생성 // ═══════════════════════════════════════════════════ private static Table CreateTable(JsonElement section) { var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default; var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default; var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped"; var table = new Table(); // 테이블 속성 — 테두리 + 전체 너비 var tblProps = new TableProperties( new TableBorders( new TopBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, new RightBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, new InsideVerticalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" } ), new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct } ); table.AppendChild(tblProps); // 헤더 행 if (headers.ValueKind == JsonValueKind.Array) { var headerRow = new TableRow(); foreach (var h in headers.EnumerateArray()) { var cell = new TableCell(); cell.TableCellProperties = new TableCellProperties { Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" }, TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center }, }; var para = new Paragraph(new Run(new Text(h.GetString() ?? "")) { RunProperties = new RunProperties { Bold = new Bold(), FontSize = new FontSize { Val = "20" }, Color = new Color { Val = "FFFFFF" }, } }); para.ParagraphProperties = new ParagraphProperties { SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" }, }; cell.Append(para); headerRow.Append(cell); } table.Append(headerRow); } // 데이터 행 if (rows.ValueKind == JsonValueKind.Array) { int rowIdx = 0; foreach (var row in rows.EnumerateArray()) { var dataRow = new TableRow(); foreach (var cellVal in row.EnumerateArray()) { var cell = new TableCell(); // striped 스타일: 짝수행에 배경색 if (tableStyle == "striped" && rowIdx % 2 == 0) { cell.TableCellProperties = new TableCellProperties { Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F7FB", Color = "auto" }, }; } var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve }) { RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } } }); para.ParagraphProperties = new ParagraphProperties { SpacingBetweenLines = new SpacingBetweenLines { Before = "20", After = "20" }, }; cell.Append(para); dataRow.Append(cell); } table.Append(dataRow); rowIdx++; } } return table; } // ═══════════════════════════════════════════════════ // 리스트 (번호/불릿) // ═══════════════════════════════════════════════════ private static void AppendList(Body body, JsonElement section) { var items = section.TryGetProperty("items", out var arr) ? arr : default; var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet"; if (items.ValueKind != JsonValueKind.Array) return; int idx = 1; foreach (var item in items.EnumerateArray()) { var text = item.GetString() ?? item.ToString(); var prefix = listStyle == "number" ? $"{idx}. " : "• "; var para = new Paragraph(); para.ParagraphProperties = new ParagraphProperties { Indentation = new Indentation { Left = "720" }, // 0.5 inch SpacingBetweenLines = new SpacingBetweenLines { Line = "320" }, }; var prefixRun = new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve }); prefixRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" }, Bold = listStyle == "number" ? new Bold() : null, }; para.Append(prefixRun); var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } }; para.Append(textRun); body.Append(para); idx++; } } // ═══════════════════════════════════════════════════ // 페이지 나누기 // ═══════════════════════════════════════════════════ private static Paragraph CreatePageBreak() { var para = new Paragraph(); var run = new Run(new Break { Type = BreakValues.Page }); para.Append(run); return para; } // ═══════════════════════════════════════════════════ // 머리글/바닥글 // ═══════════════════════════════════════════════════ private static void AddHeaderFooter(MainDocumentPart mainPart, Body body, string? headerText, string? footerText, bool showPageNumbers) { // 머리글 if (!string.IsNullOrEmpty(headerText)) { var headerPart = mainPart.AddNewPart(); var header = new Header(); var para = new Paragraph(new Run(new Text(headerText)) { RunProperties = new RunProperties { FontSize = new FontSize { Val = "18" }, // 9pt Color = new Color { Val = "808080" }, } }); para.ParagraphProperties = new ParagraphProperties { Justification = new Justification { Val = JustificationValues.Right }, }; header.Append(para); headerPart.Header = header; // SectionProperties에 머리글 연결 var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); secProps.Append(new HeaderReference { Type = HeaderFooterValues.Default, Id = mainPart.GetIdOfPart(headerPart) }); } // 바닥글 if (!string.IsNullOrEmpty(footerText) || showPageNumbers) { var footerPart = mainPart.AddNewPart(); var footer = new Footer(); var para = new Paragraph(); para.ParagraphProperties = new ParagraphProperties { Justification = new Justification { Val = JustificationValues.Center }, }; var displayText = footerText ?? "AX Copilot"; if (showPageNumbers) { // 바닥글 텍스트 + 페이지 번호 if (displayText.Contains("{page}")) { var parts = displayText.Split("{page}"); para.Append(CreateFooterRun(parts[0])); para.Append(CreatePageNumberRun()); if (parts.Length > 1) para.Append(CreateFooterRun(parts[1])); } else { para.Append(CreateFooterRun(displayText + " · ")); para.Append(CreatePageNumberRun()); } } else { para.Append(CreateFooterRun(displayText)); } footer.Append(para); footerPart.Footer = footer; var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); secProps.Append(new FooterReference { Type = HeaderFooterValues.Default, Id = mainPart.GetIdOfPart(footerPart) }); } } private static Run CreateFooterRun(string text) => new(new Text(text) { Space = SpaceProcessingModeValues.Preserve }) { RunProperties = new RunProperties { FontSize = new FontSize { Val = "16" }, Color = new Color { Val = "999999" }, } }; private static Run CreatePageNumberRun() { var run = new Run(); run.RunProperties = new RunProperties { FontSize = new FontSize { Val = "16" }, Color = new Color { Val = "999999" }, }; run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin }); run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }); run.Append(new FieldChar { FieldCharType = FieldCharValues.End }); return run; } }