Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/XlsxToHtmlConverter.cs
lacvet fb0bea41f7 AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리
- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:02:14 +09:00

234 lines
8.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace AxCopilot.Services.Agent;
/// <summary>XLSX 파일을 HTML 테이블로 변환하여 WebView2에서 미리보기합니다.</summary>
internal static class XlsxToHtmlConverter
{
private const int MaxRows = 500;
private const int MaxCols = 50;
public static string Convert(string xlsxPath)
{
try
{
using var doc = SpreadsheetDocument.Open(xlsxPath, false);
var workbookPart = doc.WorkbookPart;
if (workbookPart == null)
return WrapHtml("<p style='color:#888'>스프레드시트 내용이 없습니다.</p>");
var sheets = workbookPart.Workbook?.Sheets?.Elements<Sheet>().ToList();
if (sheets == null || sheets.Count == 0)
return WrapHtml("<p style='color:#888'>시트가 없습니다.</p>");
var sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable;
var sb = new StringBuilder();
// 시트 탭 UI
if (sheets.Count > 1)
{
sb.AppendLine("<div class='sheet-tabs'>");
for (int i = 0; i < sheets.Count; i++)
{
var name = sheets[i].Name?.Value ?? $"Sheet{i + 1}";
var activeClass = i == 0 ? " active" : "";
sb.AppendLine($"<button class='sheet-tab{activeClass}' onclick='showSheet({i})'>{Escape(name)}</button>");
}
sb.AppendLine("</div>");
}
// 시트별 테이블
for (int si = 0; si < sheets.Count; si++)
{
var sheet = sheets[si];
var partId = sheet.Id?.Value;
if (string.IsNullOrEmpty(partId)) continue;
WorksheetPart sheetPart;
try { sheetPart = (WorksheetPart)workbookPart.GetPartById(partId); }
catch { continue; }
var display = si == 0 ? "block" : "none";
sb.AppendLine($"<div class='sheet-content' id='sheet-{si}' style='display:{display}'>");
sb.AppendLine(RenderSheet(sheetPart, sharedStrings));
sb.AppendLine("</div>");
}
return WrapHtml(sb.ToString());
}
catch (Exception ex)
{
return WrapHtml($"<p style='color:#e53e3e'>XLSX 파싱 오류: {Escape(ex.Message)}</p>");
}
}
private static string RenderSheet(WorksheetPart sheetPart, SharedStringTable? sharedStrings)
{
var sheetData = sheetPart.Worksheet?.GetFirstChild<SheetData>();
if (sheetData == null)
return "<p style='color:#888'>시트 데이터가 없습니다.</p>";
var rows = sheetData.Elements<Row>().Take(MaxRows + 1).ToList();
if (rows.Count == 0)
return "<p style='color:#888'>데이터가 없습니다.</p>";
// 병합 셀 정보
var mergeCells = sheetPart.Worksheet?.GetFirstChild<MergeCells>()
?.Elements<MergeCell>().ToList() ?? new List<MergeCell>();
var mergeMap = BuildMergeMap(mergeCells);
var sb = new StringBuilder();
sb.AppendLine("<div style='overflow-x:auto'><table>");
var rowIndex = 0;
foreach (var row in rows.Take(MaxRows))
{
rowIndex++;
sb.Append(rowIndex == 1 ? "<tr class='header'>" : "<tr>");
var cells = row.Elements<Cell>().Take(MaxCols).ToList();
foreach (var cell in cells)
{
var cellRef = cell.CellReference?.Value ?? "";
if (mergeMap.TryGetValue(cellRef, out var merge))
{
if (merge.IsHidden) continue; // 병합된 셀 건너뛰기
var spanAttrs = "";
if (merge.ColSpan > 1) spanAttrs += $" colspan='{merge.ColSpan}'";
if (merge.RowSpan > 1) spanAttrs += $" rowspan='{merge.RowSpan}'";
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}{spanAttrs}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
else
{
var tag = rowIndex == 1 ? "th" : "td";
sb.Append($"<{tag}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
}
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table></div>");
if (rows.Count > MaxRows)
sb.AppendLine($"<p class='truncated'>... {MaxRows}행까지 표시 (전체 데이터는 Excel에서 확인)</p>");
return sb.ToString();
}
private static string GetCellValue(Cell cell, SharedStringTable? sharedStrings)
{
var value = cell.CellValue?.Text ?? cell.InnerText ?? "";
if (string.IsNullOrEmpty(value)) return "";
if (cell.DataType?.Value == CellValues.SharedString && sharedStrings != null)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count())
return sharedStrings.ElementAt(idx).InnerText;
}
return value;
}
private sealed record MergeCellInfo(int ColSpan, int RowSpan, bool IsHidden);
private static Dictionary<string, MergeCellInfo> BuildMergeMap(List<MergeCell> mergeCells)
{
var map = new Dictionary<string, MergeCellInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var mc in mergeCells)
{
var range = mc.Reference?.Value;
if (string.IsNullOrEmpty(range)) continue;
var parts = range.Split(':');
if (parts.Length != 2) continue;
var (startCol, startRow) = ParseCellRef(parts[0]);
var (endCol, endRow) = ParseCellRef(parts[1]);
var colSpan = endCol - startCol + 1;
var rowSpan = endRow - startRow + 1;
// 시작 셀: span 정보 포함
map[parts[0]] = new MergeCellInfo(colSpan, rowSpan, false);
// 나머지 셀: 숨김
for (int r = startRow; r <= endRow; r++)
for (int c = startCol; c <= endCol; c++)
{
var cellRef = ColumnIndexToLetter(c) + r;
if (!string.Equals(cellRef, parts[0], StringComparison.OrdinalIgnoreCase))
map[cellRef] = new MergeCellInfo(1, 1, true);
}
}
return map;
}
private static (int Col, int Row) ParseCellRef(string cellRef)
{
int col = 0, i = 0;
while (i < cellRef.Length && char.IsLetter(cellRef[i]))
{
col = col * 26 + (char.ToUpper(cellRef[i]) - 'A' + 1);
i++;
}
int.TryParse(cellRef[i..], out var row);
return (col, row);
}
private static string ColumnIndexToLetter(int col)
{
var sb = new StringBuilder();
while (col > 0)
{
col--;
sb.Insert(0, (char)('A' + col % 26));
col /= 26;
}
return sb.ToString();
}
private static string Escape(string text)
=> System.Net.WebUtility.HtmlEncode(text);
private static string WrapHtml(string body)
{
return $@"<!DOCTYPE html>
<html lang='ko'>
<head>
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.5; padding: 16px; font-size: 12.5px; }}
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;
font-size: 11.5px; border-radius: 6px 6px 0 0; color: #6b7280; }}
.sheet-tab.active {{ background: #fff; color: #2563eb; font-weight: 600;
border-bottom: 2px solid #2563eb; margin-bottom: -2px; }}
.sheet-tab:hover {{ background: #e5e7eb; }}
table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
th, td {{ border: 1px solid #d1d5db; padding: 5px 8px; text-align: left; white-space: nowrap; }}
th, .header td {{ background: #f3f4f6; font-weight: 600; position: sticky; top: 0; }}
tr:nth-child(even) {{ background: #f9fafb; }}
.truncated {{ color: #9ca3af; font-size: 11px; margin-top: 8px; font-style: italic; }}
</style>
<script>
function showSheet(idx) {{
document.querySelectorAll('.sheet-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.sheet-tab').forEach(el => el.classList.remove('active'));
document.getElementById('sheet-' + idx).style.display = 'block';
document.querySelectorAll('.sheet-tab')[idx].classList.add('active');
}}
</script>
</head>
<body>
{body}
</body>
</html>";
}
}