using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// /// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구. /// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다. /// public class DocumentAssemblerTool : IAgentTool { private static string GetDefaultOutputFormat() { var app = System.Windows.Application.Current as App; return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto"; } private static string GetDefaultMood() { var app = System.Windows.Application.Current as App; return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern"; } public string Name => "document_assemble"; public string Description => "Assemble multiple individually-written sections into a single complete document. " + "Use this after writing each section separately with document_plan. " + "Supports HTML, DOCX, and Markdown output. " + "Automatically adds table of contents, cover page, and section numbering for HTML. " + "After assembly, the document is auto-validated for quality issues."; public ToolParameterSchema Parameters => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Output file path. Relative to work folder." }, ["title"] = new() { Type = "string", Description = "Document title" }, ["sections"] = new() { Type = "array", Description = "Array of section objects: [{\"heading\": \"1. 개요\", \"content\": \"HTML or markdown body...\", \"level\": 1}]. " + "content should be the detailed text for each section.", Items = new() { Type = "object" } }, ["format"] = new() { Type = "string", Description = "Output format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)", Enum = ["auto", "html", "docx", "markdown"] }, ["mood"] = new() { Type = "string", Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: inferred from settings and document intent" }, ["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" }, ["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." }, ["template_path"] = new() { Type = "string", Description = "Optional .docx template path for DOCX output. Reuses template styles and section setup." }, ["header"] = new() { Type = "string", Description = "Header text for DOCX output." }, ["footer"] = new() { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." }, ["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in DOCX footer. Default: true when header or footer is set." }, }, Required = ["path", "title", "sections"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var path = args.GetProperty("path").SafeGetString() ?? ""; var title = args.GetProperty("title").SafeGetString() ?? "Document"; var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat(); var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood(); var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null; var templatePath = args.SafeTryGetProperty("template_path", out var templateEl) ? templateEl.SafeGetString() : null; var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null; var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null; var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pageNumbersEl) ? pageNumbersEl.ValueKind == JsonValueKind.True : !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText); if (!args.SafeTryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array) return ToolResult.Fail("sections 배열이 필요합니다."); var sections = new List<(string Heading, string Content, int Level)>(); foreach (var sec in sectionsEl.EnumerateArray()) { var heading = sec.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : ""; var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : ""; var level = sec.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1; if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content)) sections.Add((heading, content, level)); } if (sections.Count == 0) return ToolResult.Fail("조립할 섹션이 없습니다."); var format = ResolveDocumentFormat(requestedFormat, title, sections); var mood = ResolveDocumentMood(requestedMood, title, sections); var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); // 확장자 자동 추가 var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" }; if (!fullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) fullPath += ext; if (!context.IsPathAllowed(fullPath)) return ToolResult.Fail($"경로 접근 차단: {fullPath}"); if (!await context.CheckWritePermissionAsync(Name, fullPath)) return ToolResult.Fail($"쓰기 권한 거부: {fullPath}"); var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); string? templateFullPath = null; if (!string.IsNullOrWhiteSpace(templatePath)) { templateFullPath = FileReadTool.ResolvePath(templatePath, context.WorkFolder); if (!templateFullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) templateFullPath += ".docx"; if (!context.IsPathAllowed(templateFullPath)) return ToolResult.Fail($"템플릿 경로 접근 차단: {templateFullPath}"); if (!File.Exists(templateFullPath)) return ToolResult.Fail($"DOCX 템플릿을 찾을 수 없습니다: {templateFullPath}"); if (string.Equals(Path.GetFullPath(templateFullPath), Path.GetFullPath(fullPath), StringComparison.OrdinalIgnoreCase)) return ToolResult.Fail("template_path와 path는 같은 파일일 수 없습니다."); } try { string resultMsg; switch (format) { case "docx": resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers); break; case "markdown": resultMsg = AssembleMarkdown(fullPath, title, sections); break; default: resultMsg = AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle); break; } // 품질 요약 통계 var totalChars = sections.Sum(s => s.Content.Length); var totalWords = sections.Sum(s => EstimateWordCount(s.Content)); var pageEstimate = Math.Max(1, totalWords / 500); return ToolResult.Ok( $"✅ 문서 조립 완료: {fullPath}\n" + $" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" + $"{resultMsg}", fullPath); } catch (Exception ex) { return ToolResult.Fail($"문서 조립 실패: {ex.Message}"); } } private string AssembleHtml(string path, string title, List<(string Heading, string Content, int Level)> sections, string mood, bool toc, string? coverSubtitle) { var sb = new StringBuilder(); var style = TemplateService.GetCss(mood); var moodInfo = TemplateService.GetMood(mood); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{Escape(title)}"); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); // 커버 페이지 if (!string.IsNullOrWhiteSpace(coverSubtitle)) { sb.AppendLine("
"); sb.AppendLine($"

{Escape(title)}

"); sb.AppendLine($"
{Escape(coverSubtitle)}
"); sb.AppendLine($"
{DateTime.Now:yyyy년 MM월 dd일}
"); sb.AppendLine("
"); } sb.AppendLine("
"); if (string.IsNullOrWhiteSpace(coverSubtitle)) sb.AppendLine($"

{Escape(title)}

"); // TOC 생성 if (toc && sections.Count > 1) { sb.AppendLine("
"); sb.AppendLine("

📋 목차

"); sb.AppendLine("
    "); for (int i = 0; i < sections.Count; i++) { var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : ""; sb.AppendLine($"{Escape(sections[i].Heading)}"); } sb.AppendLine("
"); sb.AppendLine("
"); } // 섹션 본문 for (int i = 0; i < sections.Count; i++) { var (heading, content, level) = sections[i]; var tag = level <= 1 ? "h2" : "h3"; sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}"); // LLM이 생성한 깨진 태그 자동 수정 var sanitized = HtmlSkill.SanitizeHtmlTagsPublic(content); sb.AppendLine($"
{sanitized}
"); } sb.AppendLine("
"); sb.AppendLine(""); sb.AppendLine(""); File.WriteAllText(path, sb.ToString(), Encoding.UTF8); var issues = ValidateBasic(sb.ToString()); var review = ArtifactQualityReviewService.ReviewHtml(title, sb.ToString(), !string.IsNullOrWhiteSpace(coverSubtitle), toc, printReady: true); var reviewSummary = $" HTML 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}"; return issues.Count > 0 ? $"{reviewSummary} | 기본 검토 이슈 {issues.Count}건: {string.Join("; ", issues)}" : reviewSummary; } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers) { var templateApplied = !string.IsNullOrWhiteSpace(templatePath); if (templateApplied) File.Copy(templatePath!, path, true); using var doc = templateApplied ? DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Open(path, true) : DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var mainPart = doc.MainDocumentPart ?? doc.AddMainDocumentPart(); EnsureAssemblerStyles(mainPart, templateApplied); var body = InitializeAssemblerBody(mainPart, templateApplied); DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new() { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕", ComplexScript = "맑은 고딕" }; string DecodeHtml(string text) => text.Replace(" ", " ") .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace(""", "\""); string ExtractStructuredText(string html) { if (string.IsNullOrWhiteSpace(html)) return ""; var text = Regex.Replace(html, @"", "\n", RegexOptions.IgnoreCase); text = Regex.Replace(text, @"", "\n", RegexOptions.IgnoreCase); text = Regex.Replace(text, @"<[^>]+>", " "); text = DecodeHtml(text); text = Regex.Replace(text, @"[ \t]+\n", "\n"); text = Regex.Replace(text, @"\n{3,}", "\n\n"); return text.Trim(); } DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateParagraph( string text, string fontSize = "22", bool bold = false, string? color = null, string? fill = null) { var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var props = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties(); props.SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160" }; if (!string.IsNullOrWhiteSpace(fill)) { props.ParagraphBorders = new DocumentFormat.OpenXml.Wordprocessing.ParagraphBorders( new DocumentFormat.OpenXml.Wordprocessing.LeftBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 10, Color = color ?? "2B579A" }); props.Shading = new DocumentFormat.OpenXml.Wordprocessing.Shading { Val = DocumentFormat.OpenXml.Wordprocessing.ShadingPatternValues.Clear, Fill = fill }; } para.AppendChild(props); var run = new DocumentFormat.OpenXml.Wordprocessing.Run(); var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties { RunFonts = KoreanFonts(), FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize } }; if (bold) runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(); if (!string.IsNullOrWhiteSpace(color)) runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color }; run.AppendChild(runProps); run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve }); para.AppendChild(run); return para; } DocumentFormat.OpenXml.Wordprocessing.Table CreateTableFromHtml(string tableHtml) { var table = new DocumentFormat.OpenXml.Wordprocessing.Table(); table.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableProperties( new DocumentFormat.OpenXml.Wordprocessing.TableBorders( new DocumentFormat.OpenXml.Wordprocessing.TopBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" }, new DocumentFormat.OpenXml.Wordprocessing.BottomBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" }, new DocumentFormat.OpenXml.Wordprocessing.LeftBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" }, new DocumentFormat.OpenXml.Wordprocessing.RightBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "CBD5E1" }, new DocumentFormat.OpenXml.Wordprocessing.InsideHorizontalBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "E2E8F0" }, new DocumentFormat.OpenXml.Wordprocessing.InsideVerticalBorder { Val = DocumentFormat.OpenXml.Wordprocessing.BorderValues.Single, Size = 8, Color = "E2E8F0" }))); var rowMatches = Regex.Matches(tableHtml, @"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); var rowIndex = 0; foreach (Match rowMatch in rowMatches) { var row = new DocumentFormat.OpenXml.Wordprocessing.TableRow(); var rowHtml = rowMatch.Groups[1].Value; var isHeader = rowIndex == 0 || Regex.IsMatch(rowHtml, "]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); foreach (Match cellMatch in cellMatches) { var cellText = ExtractStructuredText(cellMatch.Groups[1].Value); var cell = new DocumentFormat.OpenXml.Wordprocessing.TableCell(); if (isHeader) { cell.AppendChild(CreateParagraph(cellText, fontSize: "21", bold: true, color: "1F3A5F", fill: "E8EEF8")); } else { cell.AppendChild(CreateParagraph(cellText)); } cell.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableCellProperties( new DocumentFormat.OpenXml.Wordprocessing.TableCellWidth { Type = DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues.Auto })); row.AppendChild(cell); } if (cellMatches.Count > 0) table.AppendChild(row); rowIndex++; } return table; } void AppendPlainText(string plain) { foreach (var rawLine in plain.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { var line = rawLine.Trim(); if (string.IsNullOrWhiteSpace(line)) continue; if (Regex.IsMatch(line, @"^#{2,6}\s+")) { body.AppendChild(CreateParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), fontSize: "26", bold: true, color: "2B579A")); continue; } if (Regex.IsMatch(line, @"^[-*]\s+")) { body.AppendChild(CreateParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}")); continue; } if (Regex.IsMatch(line, @"^\d+\.\s+")) { body.AppendChild(CreateParagraph(line)); continue; } body.AppendChild(CreateParagraph(line)); } } void AppendListBlock(string listHtml, bool ordered) { var matches = Regex.Matches(listHtml, @"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); var number = 1; foreach (Match match in matches) { var text = ExtractStructuredText(match.Groups[1].Value); var prefix = ordered ? $"{number}. " : "• "; body.AppendChild(CreateParagraph(prefix + text)); number++; } } var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle); if (includeCover) AppendAssemblerCoverPage(body, title, coverSubtitle!); else body.AppendChild(CreateParagraph(title, fontSize: "48", bold: true, color: "1F3A5F")); if (!includeCover) body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); if (useToc && sections.Count > 3) AppendAssemblerTableOfContents(body); var tableCount = 0; var listCount = 0; var calloutCount = 0; var highlightCount = 0; var headings = new List(); foreach (var (heading, content, level) in sections) { var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties { RunFonts = KoreanFonts(), Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(), FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" }, Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" }, }); headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(heading)); headPara.AppendChild(headRun); body.AppendChild(headPara); headings.Add(heading); AppendStructuredContent(content); body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); } body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties( new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 }, new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 720, Footer = 720, Gutter = 0 } )); if (!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText) || showPageNumbers) AddAssemblerHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers); mainPart.Document.Save(); var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput( title, headings.Count, sections.Sum(section => StripHtmlTags(section.Content).Length), tableCount, listCount, calloutCount, highlightCount, 0, includeCover, useToc && sections.Count > 3, templateApplied, !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText), headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "executive summary", "summary", "요약")), headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "recommendation", "proposal", "next step", "action", "권고", "실행")), headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "appendix", "reference", "supplement", "부록", "참고")))); return $" DOCX 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}"; void AppendStructuredContent(string rawContent) { if (string.IsNullOrWhiteSpace(rawContent)) return; var normalized = rawContent.Replace("\r\n", "\n"); if (!Regex.IsMatch(normalized, @"<\s*(p|ul|ol|table|blockquote|div|h[1-6]|li)\b", RegexOptions.IgnoreCase)) { AppendPlainText(normalized); return; } normalized = Regex.Replace(normalized, @"", "\n", RegexOptions.IgnoreCase); var blockPattern = @"]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid|highlight-box)[^""]*""[^>]*>.*?|]*>.*?|]*>.*?

"; var matches = Regex.Matches(normalized, blockPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline); if (matches.Count == 0) { AppendPlainText(ExtractStructuredText(normalized)); return; } var cursor = 0; foreach (Match match in matches) { if (match.Index > cursor) { var leadingText = ExtractStructuredText(normalized.Substring(cursor, match.Index - cursor)); AppendPlainText(leadingText); } var block = match.Value; if (Regex.IsMatch(block, @"^DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함). private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles() { var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles(); // 문서 기본 글꼴 설정 var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults( new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault( new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle( new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕", ComplexScript = "맑은 고딕" }, new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }, new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" } ) ), new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault( new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle( new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto } ) ) ); styles.AppendChild(docDefaults); // Normal 스타일 var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style { Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph, StyleId = "Normal", Default = true }; normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" }); styles.AppendChild(normalStyle); return styles; } private static DocumentFormat.OpenXml.Wordprocessing.Body InitializeAssemblerBody( DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, bool preserveSectionSetup) { mainPart.Document ??= new DocumentFormat.OpenXml.Wordprocessing.Document(); DocumentFormat.OpenXml.Wordprocessing.SectionProperties? preservedSection = null; if (preserveSectionSetup) preservedSection = mainPart.Document.Body?.Elements().LastOrDefault()?.CloneNode(true) as DocumentFormat.OpenXml.Wordprocessing.SectionProperties; mainPart.Document.Body = new DocumentFormat.OpenXml.Wordprocessing.Body(); var body = mainPart.Document.Body; if (preservedSection != null) body.Append(preservedSection); return body; } private static void EnsureAssemblerStyles( DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, bool preserveExisting) { var stylesPart = mainPart.StyleDefinitionsPart; if (stylesPart == null) { stylesPart = mainPart.AddNewPart(); stylesPart.Styles = CreateDefaultDocxStyles(); stylesPart.Styles.Save(); return; } if (!preserveExisting || stylesPart.Styles == null) { stylesPart.Styles = CreateDefaultDocxStyles(); stylesPart.Styles.Save(); } } private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle) { body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center }, SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { Before = "1600", After = "120" } }, new DocumentFormat.OpenXml.Wordprocessing.Run( new DocumentFormat.OpenXml.Wordprocessing.RunProperties( new DocumentFormat.OpenXml.Wordprocessing.Bold(), new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "44" }, new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "1F3A5F" }, new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), new DocumentFormat.OpenXml.Wordprocessing.Text(title)))); body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center }, SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "260" } }, new DocumentFormat.OpenXml.Wordprocessing.Run( new DocumentFormat.OpenXml.Wordprocessing.RunProperties( new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "26" }, new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "5B6472" }, new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), new DocumentFormat.OpenXml.Wordprocessing.Text(subtitle)))); body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center } }, new DocumentFormat.OpenXml.Wordprocessing.Run( new DocumentFormat.OpenXml.Wordprocessing.RunProperties( new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" }, new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" }, new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), new DocumentFormat.OpenXml.Wordprocessing.Text(DateTime.Now.ToString("yyyy-MM-dd"))))); body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break { Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page }))); } private static void AppendAssemblerTableOfContents(DocumentFormat.OpenXml.Wordprocessing.Body body) { body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties( new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { Before = "120", After = "120" }), new DocumentFormat.OpenXml.Wordprocessing.Run( new DocumentFormat.OpenXml.Wordprocessing.RunProperties( new DocumentFormat.OpenXml.Wordprocessing.Bold(), new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "28" }, new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), new DocumentFormat.OpenXml.Wordprocessing.Text("목차")))); var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar { FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin })); paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ") { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve })); paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar { FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Separate })); paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Text("Word에서 필드 업데이트를 실행하면 목차가 새로 고쳐집니다."))); paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar { FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End })); body.Append(paragraph); body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break { Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page }))); } private static void AddAssemblerHeaderFooter( DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, DocumentFormat.OpenXml.Wordprocessing.Body body, string? headerText, string? footerText, bool showPageNumbers) { if (!string.IsNullOrWhiteSpace(headerText)) { var headerPart = mainPart.AddNewPart(); var header = new DocumentFormat.OpenXml.Wordprocessing.Header(); var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.Run( new DocumentFormat.OpenXml.Wordprocessing.RunProperties( new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" }, new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" }, new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), new DocumentFormat.OpenXml.Wordprocessing.Text(headerText))); paragraph.ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Right } }; header.Append(paragraph); headerPart.Header = header; var sectionProperties = body.Elements().LastOrDefault() ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties()); sectionProperties.Elements().ToList().ForEach(reference => reference.Remove()); sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.HeaderReference { Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default, Id = mainPart.GetIdOfPart(headerPart) }); } if (!string.IsNullOrWhiteSpace(footerText) || showPageNumbers) { var footerPart = mainPart.AddNewPart(); var footer = new DocumentFormat.OpenXml.Wordprocessing.Footer(); var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph { ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center } } }; var displayText = string.IsNullOrWhiteSpace(footerText) ? "AX Copilot" : footerText!; if (showPageNumbers) { if (displayText.Contains("{page}", StringComparison.Ordinal)) { var parts = displayText.Split("{page}", StringSplitOptions.None); paragraph.Append(CreateAssemblerFooterRun(parts[0])); paragraph.Append(CreateAssemblerPageNumberRun()); if (parts.Length > 1) paragraph.Append(CreateAssemblerFooterRun(parts[1])); } else { paragraph.Append(CreateAssemblerFooterRun(displayText + " · ")); paragraph.Append(CreateAssemblerPageNumberRun()); } } else { paragraph.Append(CreateAssemblerFooterRun(displayText)); } footer.Append(paragraph); footerPart.Footer = footer; var sectionProperties = body.Elements().LastOrDefault() ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties()); sectionProperties.Elements().ToList().ForEach(reference => reference.Remove()); sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.FooterReference { Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default, Id = mainPart.GetIdOfPart(footerPart) }); } } private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerFooterRun(string text) => new(new DocumentFormat.OpenXml.Wordprocessing.Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve }) { RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties { FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" }, Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" }, RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" } } }; private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerPageNumberRun() { var run = new DocumentFormat.OpenXml.Wordprocessing.Run { RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties { FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" }, Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" }, RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" } } }; run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar { FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin }); run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" PAGE ") { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve }); run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar { FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End }); return run; } private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections) { var sb = new StringBuilder(); sb.AppendLine($"# {title}"); sb.AppendLine(); sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*"); sb.AppendLine(); // TOC if (sections.Count > 1) { sb.AppendLine("## 목차"); sb.AppendLine(); foreach (var (heading, _, _) in sections) { var anchor = heading.Replace(" ", "-").ToLowerInvariant(); sb.AppendLine($"- [{heading}](#{anchor})"); } sb.AppendLine(); sb.AppendLine("---"); sb.AppendLine(); } foreach (var (heading, content, level) in sections) { var prefix = level <= 1 ? "##" : "###"; sb.AppendLine($"{prefix} {heading}"); sb.AppendLine(); sb.AppendLine(StripHtmlTags(content)); sb.AppendLine(); } File.WriteAllText(path, sb.ToString(), Encoding.UTF8); return " ✓ Markdown 조립 완료"; } private static string ResolveDocumentFormat(string? preferred, string title, List<(string Heading, string Content, int Level)> sections) { var normalized = (preferred ?? "auto").Trim().ToLowerInvariant(); if (normalized is "html" or "docx" or "markdown") return normalized; var intent = BuildIntentText(title, sections); if (ContainsAny(intent, "docx", "word", "워드")) return "docx"; if (ContainsAny(intent, "markdown", ".md", "마크다운", "readme", "가이드", "매뉴얼", "회의록")) return "markdown"; if (ContainsAny(intent, "html", "웹 문서", "웹페이지", "대시보드")) return "html"; if (ContainsAny(intent, "proposal", "제안", "제안서", "보고")) return "docx"; if (ContainsAny(intent, "analysis", "분석", "지표", "통계")) return "html"; return "docx"; } private static string ResolveDocumentMood(string? preferred, string title, List<(string Heading, string Content, int Level)> sections) { var normalized = (preferred ?? "modern").Trim().ToLowerInvariant(); if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern") return normalized; var intent = BuildIntentText(title, sections); if (ContainsAny(intent, "dashboard", "분석", "지표", "통계", "kpi")) return "dashboard"; if (ContainsAny(intent, "기획", "아이디어", "브레인스토밍", "creative")) return "creative"; if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내")) return "corporate"; if (ContainsAny(intent, "가이드", "매뉴얼", "manual", "guide")) return "minimal"; if (ContainsAny(intent, "회의록", "minutes")) return "professional"; return "professional"; } private static string BuildIntentText(string title, List<(string Heading, string Content, int Level)> sections) { var headingText = string.Join(" ", sections.Select(s => s.Heading)); return $"{title} {headingText}".ToLowerInvariant(); } private static bool ContainsAny(string text, params string[] keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); private static int EstimateWordCount(string text) { if (string.IsNullOrWhiteSpace(text)) return 0; var plain = StripHtmlTags(text); // 한국어: 글자 수 / 3 ≈ 단어 수, 영어: 공백 분리 var spaces = plain.Count(c => c == ' '); var koreanChars = plain.Count(c => c >= 0xAC00 && c <= 0xD7A3); return spaces + 1 + koreanChars / 3; } private static string StripHtmlTags(string html) { if (string.IsNullOrEmpty(html)) return ""; return System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ") .Replace(" ", " ") .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace(" ", " ") .Trim(); } private static List ValidateBasic(string html) { var issues = new List(); if (html.Length < 500) issues.Add("문서 내용이 매우 짧습니다 (500자 미만)"); // 빈 섹션 검사 var emptySectionPattern = new System.Text.RegularExpressions.Regex( @"]*>[^<]+\s*
\s*
", System.Text.RegularExpressions.RegexOptions.IgnoreCase); var emptyMatches = emptySectionPattern.Matches(html); if (emptyMatches.Count > 0) issues.Add($"빈 섹션 {emptyMatches.Count}개 발견"); // 플레이스홀더 검사 if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) || html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) || html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase)) issues.Add("플레이스홀더 텍스트가 남아있습니다"); return issues; } private static string Escape(string text) { return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } }