Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs
lacvet 5607f6391e ?? ??? 3?? DOCX ????XLSX ?? ???HTML ???? ?? ??
- DocumentAssemblerTool? template_path/page_numbers? ??? DOCX ??? ??, ??????????? ?? ??? ??
- ExcelSkill? data_validations? ???? ??/??/?? ?? ??? ?? ??? ?? ?? ?? ??
- HtmlSkill? ArtifactQualityReviewService? decision_summary/evidence_cards ??? ?? ?? ?? ??? ??
- DocumentAssemblerDocxFeaturesTests, HtmlSkillConsultingSectionsTests, ExcelSkillDataValidationTests? ?? ??? ??
- README.md? docs/DEVELOPMENT.md? 2026-04-14 22:28 (KST) ???? ??

??:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\ (?? 0 / ?? 0)
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\ (?? 9)
2026-04-14 22:31:15 +09:00

1038 lines
49 KiB
C#

using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구.
/// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다.
/// </summary>
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<ToolResult> 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("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine(style);
// 추가 조립용 스타일
sb.AppendLine(@"
.assembled-doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
.assembled-doc h1 { font-size: 28px; margin-bottom: 8px; }
.assembled-doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
.assembled-doc h3 { font-size: 18px; margin-top: 24px; margin-bottom: 8px; }
.assembled-doc .section-content { line-height: 1.8; margin-bottom: 20px; }
.assembled-doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
.assembled-doc .toc h3 { margin-top: 0; }
.assembled-doc .toc a { color: inherit; text-decoration: none; }
.assembled-doc .toc a:hover { text-decoration: underline; }
.assembled-doc .toc ul { list-style: none; padding-left: 0; }
.assembled-doc .toc li { padding: 4px 0; }
.cover-page { text-align: center; padding: 120px 40px 80px; page-break-after: always; }
.cover-page h1 { font-size: 36px; margin-bottom: 16px; }
.cover-page .subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }
.cover-page .date { font-size: 14px; color: #999; }
@media print { .cover-page { page-break-after: always; } .assembled-doc h2 { page-break-before: auto; } }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
// 커버 페이지
if (!string.IsNullOrWhiteSpace(coverSubtitle))
{
sb.AppendLine("<div class=\"cover-page\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"subtitle\">{Escape(coverSubtitle)}</div>");
sb.AppendLine($"<div class=\"date\">{DateTime.Now:yyyy년 MM월 dd일}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("<div class=\"assembled-doc\">");
if (string.IsNullOrWhiteSpace(coverSubtitle))
sb.AppendLine($"<h1>{Escape(title)}</h1>");
// TOC 생성
if (toc && sections.Count > 1)
{
sb.AppendLine("<div class=\"toc\">");
sb.AppendLine("<h3>📋 목차</h3>");
sb.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
{
var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : "";
sb.AppendLine($"<li{indent}><a href=\"#section-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
}
sb.AppendLine("</ul>");
sb.AppendLine("</div>");
}
// 섹션 본문
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)}</{tag}>");
// LLM이 생성한 깨진 태그 자동 수정
var sanitized = HtmlSkill.SanitizeHtmlTagsPublic(content);
sb.AppendLine($"<div class=\"section-content\">{sanitized}</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
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("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace("&quot;", "\"");
string ExtractStructuredText(string html)
{
if (string.IsNullOrWhiteSpace(html)) return "";
var text = Regex.Replace(html, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, @"</(p|div|li|tr|h[1-6]|blockquote|table|thead|tbody|ul|ol)>", "\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, @"<tr\b[^>]*>(.*?)</tr>", 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, "<th", RegexOptions.IgnoreCase);
var cellMatches = Regex.Matches(rowHtml, @"<t[hd]\b[^>]*>(.*?)</t[hd]>", 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, @"<li\b[^>]*>(.*?)</li>", 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<string>();
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, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
var blockPattern = @"<table\b[^>]*>.*?</table>|<ul\b[^>]*>.*?</ul>|<ol\b[^>]*>.*?</ol>|<blockquote\b[^>]*>.*?</blockquote>|<div\b[^>]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid|highlight-box)[^""]*""[^>]*>.*?</div>|<h[2-6]\b[^>]*>.*?</h[2-6]>|<p\b[^>]*>.*?</p>";
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, @"^<table", RegexOptions.IgnoreCase))
{
body.AppendChild(CreateTableFromHtml(block));
tableCount++;
}
else if (Regex.IsMatch(block, @"^<ul", RegexOptions.IgnoreCase))
{
AppendListBlock(block, ordered: false);
listCount++;
}
else if (Regex.IsMatch(block, @"^<ol", RegexOptions.IgnoreCase))
{
AppendListBlock(block, ordered: true);
listCount++;
}
else if (Regex.IsMatch(block, @"^<h", RegexOptions.IgnoreCase))
{
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "26", bold: true, color: "2B579A"));
}
else if (Regex.IsMatch(block, @"^<(blockquote|div)", RegexOptions.IgnoreCase))
{
var fill = Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase) ? "FFF7DA" : "EDF4FF";
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "21", bold: true, color: "1F3A5F", fill: fill));
if (Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase))
highlightCount++;
else
calloutCount++;
}
else
{
AppendPlainText(ExtractStructuredText(block));
}
cursor = match.Index + match.Length;
}
if (cursor < normalized.Length)
AppendPlainText(ExtractStructuredText(normalized[cursor..]));
}
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
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<DocumentFormat.OpenXml.Wordprocessing.SectionProperties>().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<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
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<DocumentFormat.OpenXml.Packaging.HeaderPart>();
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<DocumentFormat.OpenXml.Wordprocessing.SectionProperties>().LastOrDefault()
?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
sectionProperties.Elements<DocumentFormat.OpenXml.Wordprocessing.HeaderReference>().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<DocumentFormat.OpenXml.Packaging.FooterPart>();
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<DocumentFormat.OpenXml.Wordprocessing.SectionProperties>().LastOrDefault()
?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
sectionProperties.Elements<DocumentFormat.OpenXml.Wordprocessing.FooterReference>().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("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace(" ", " ")
.Trim();
}
private static List<string> ValidateBasic(string html)
{
var issues = new List<string>();
if (html.Length < 500)
issues.Add("문서 내용이 매우 짧습니다 (500자 미만)");
// 빈 섹션 검사
var emptySectionPattern = new System.Text.RegularExpressions.Regex(
@"<h[23][^>]*>[^<]+</h[23]>\s*<div class=""section-content"">\s*</div>",
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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
}