- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함 - FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함 - AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함 - AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함 - DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함 - README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
233
src/AxCopilot/Services/Agent/XlsxToHtmlConverter.cs
Normal file
233
src/AxCopilot/Services/Agent/XlsxToHtmlConverter.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
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: 'Malgun Gothic', '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>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user