- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 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)
234 lines
8.8 KiB
C#
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>";
|
|
}
|
|
}
|