using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Markdig; namespace AxCopilot.Services.Agent; /// /// 문서 포맷 변환 도구. /// Markdown → HTML, HTML → 텍스트, CSV → HTML 테이블 등 경량 변환을 수행합니다. /// 복잡한 변환(DOCX↔HTML)은 원본 파일을 읽고 적절한 생성 스킬(docx_create, html_create)로 /// 재생성하도록 LLM에 안내합니다. /// public class FormatConvertTool : IAgentTool { public string Name => "format_convert"; public string Description => "Convert a document between formats. Supports: " + "md→html (Markdown to styled HTML with mood CSS), " + "html→text (strip HTML tags to plain text), " + "csv→html (CSV to HTML table). " + "For complex conversions (docx↔html, xlsx↔csv), read the source with document_read/file_read, " + "then use the appropriate creation skill (html_create, docx_create, etc.)."; public ToolParameterSchema Parameters => new() { Properties = new() { ["source"] = new() { Type = "string", Description = "Source file path to convert" }, ["target"] = new() { Type = "string", Description = "Target output file path (extension determines format)" }, ["mood"] = new() { Type = "string", Description = "Design mood for HTML output (default: modern). Only used for md→html conversion." }, }, Required = ["source", "target"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var source = args.GetProperty("source").GetString() ?? ""; var target = args.GetProperty("target").GetString() ?? ""; var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern"; var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder); var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder); if (context.ActiveTab == "Cowork") tgtPath = AgentContext.EnsureTimestampedPath(tgtPath); if (!context.IsPathAllowed(srcPath)) return ToolResult.Fail($"소스 경로 접근 차단: {srcPath}"); if (!context.IsPathAllowed(tgtPath)) return ToolResult.Fail($"대상 경로 접근 차단: {tgtPath}"); if (!File.Exists(srcPath)) return ToolResult.Fail($"소스 파일 없음: {srcPath}"); if (!await context.CheckWritePermissionAsync("format_convert", tgtPath)) return ToolResult.Fail("쓰기 권한이 거부되었습니다."); var srcExt = Path.GetExtension(srcPath).ToLowerInvariant(); var tgtExt = Path.GetExtension(tgtPath).ToLowerInvariant(); var convKey = $"{srcExt}→{tgtExt}"; try { var srcContent = (await TextFileCodec.ReadAllTextAsync(srcPath, ct)).Text; string result; switch (convKey) { case ".md→.html": var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); var bodyHtml = Markdown.ToHtml(srcContent, pipeline); var css = TemplateService.GetCss(mood); result = $"\n\n\n\n" + $"\n\n\n
\n{bodyHtml}\n
\n\n"; break; case ".html→.txt" or ".htm→.txt": result = StripHtmlTags(srcContent); break; case ".csv→.html": result = CsvToHtmlTable(srcContent, mood); break; case ".md→.txt": result = StripMarkdown(srcContent); break; default: return ToolResult.Fail( $"직접 변환 미지원: {convKey}\n" + "대안: source를 file_read/document_read로 읽은 뒤, " + "적절한 생성 스킬(html_create, docx_create, excel_create 등)을 사용하세요."); } await TextFileCodec.WriteAllTextAsync(tgtPath, result, TextFileCodec.Utf8NoBom, ct); var srcName = Path.GetFileName(srcPath); var tgtName = Path.GetFileName(tgtPath); return ToolResult.Ok( $"변환 완료: {srcName} → {tgtName}\n" + $"변환 유형: {convKey}\n" + $"출력 크기: {result.Length:N0}자", tgtPath); } catch (Exception ex) { return ToolResult.Fail($"변환 오류: {ex.Message}"); } } private static string StripHtmlTags(string html) { // script/style 제거 var cleaned = Regex.Replace(html, @"<(script|style)[^>]*>.*?", "", RegexOptions.Singleline | RegexOptions.IgnoreCase); // 줄바꿈 태그 처리 cleaned = Regex.Replace(cleaned, @"", "\n", RegexOptions.IgnoreCase); cleaned = Regex.Replace(cleaned, @"", "\n", RegexOptions.IgnoreCase); // 태그 제거 cleaned = Regex.Replace(cleaned, @"<[^>]+>", ""); // HTML 엔티티 디코딩 cleaned = System.Net.WebUtility.HtmlDecode(cleaned); // 연속 빈줄 정리 cleaned = Regex.Replace(cleaned, @"\n{3,}", "\n\n"); return cleaned.Trim(); } private static string StripMarkdown(string md) { var result = md; result = Regex.Replace(result, @"^#{1,6}\s+", "", RegexOptions.Multiline); // 헤딩 result = Regex.Replace(result, @"\*\*(.+?)\*\*", "$1"); // 볼드 result = Regex.Replace(result, @"\*(.+?)\*", "$1"); // 이탤릭 result = Regex.Replace(result, @"`(.+?)`", "$1"); // 인라인 코드 result = Regex.Replace(result, @"^\s*[-*+]\s+", "", RegexOptions.Multiline); // 리스트 result = Regex.Replace(result, @"^\s*\d+\.\s+", "", RegexOptions.Multiline); // 번호 리스트 result = Regex.Replace(result, @"\[(.+?)\]\(.+?\)", "$1"); // 링크 return result.Trim(); } private static string CsvToHtmlTable(string csv, string mood) { var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); if (lines.Length == 0) return "

빈 CSV 파일

"; var cssStr = TemplateService.GetCss(mood); var sb = new StringBuilder(); sb.AppendLine("\n\n\n"); sb.AppendLine($"\n\n\n
"); sb.AppendLine(""); var headers = ParseCsvLine(lines[0]); foreach (var h in headers) sb.Append($""); sb.AppendLine(""); for (int i = 1; i < Math.Min(lines.Length, 1001); i++) { var vals = ParseCsvLine(lines[i]); sb.Append(""); foreach (var v in vals) sb.Append($""); sb.AppendLine(""); } sb.AppendLine("
{System.Net.WebUtility.HtmlEncode(h)}
{System.Net.WebUtility.HtmlEncode(v)}
\n
\n\n"); return sb.ToString(); } private static string[] ParseCsvLine(string line) { var fields = new List(); var current = new StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { var c = line[i]; if (inQuotes) { if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } else if (c == '"') inQuotes = false; else current.Append(c); } else { if (c == '"') inQuotes = true; else if (c == ',') { fields.Add(current.ToString()); current.Clear(); } else current.Append(c); } } fields.Add(current.ToString()); return fields.ToArray(); } }