Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/DocxSkill.cs

544 lines
22 KiB
C#

using System.IO;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Word (.docx) 문서를 생성하는 내장 스킬.
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
/// </summary>
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<ToolResult> 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<string>();
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;
}
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
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<HeaderPart>();
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<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(headerPart)
});
}
// 바닥글
if (!string.IsNullOrEmpty(footerText) || showPageNumbers)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
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<SectionProperties>() ?? 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;
}
}