Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
- HWPX는 한컴오피스 설치가 **불필요**합니다 (macOS/Linux 서버에서 그대로 동작).
- 구버전 `.hwp`(바이너리 포맷)는 지원하지 않습니다 — `.hwpx`로 변환 후 사용하세요.
- **XLSX (v0.10+)**: 각 워크시트를 하나의 표로 매핑(`table_index` = 시트 인덱스, `location` = 시트명). 병합 셀·`fill_form`·`render_template` 동일 인터페이스로 동작.
- 셀 타입: 날짜는 날짜만 표시, `set_cell` 은 깔끔한 숫자(금액)를 숫자형으로 기록(전화·우편번호는 문자 유지).
- **수식 셀**: 캐시된 계산값이 있으면(Excel 저장본) 그 값을, 없으면(openpyxl 생성본 등) 수식 문자열(`=A1+B1`)을 표시. 수식 자체는 편집/저장 시 보존됩니다.
- 병합 셀: 3개 포맷 모두 preview에 `null` 슬롯 + `merges` 메타로 구조 노출. non-anchor 좌표에 쓰기는 `MergedCellWriteError`로 거부.
- **셀 크기 메타 (v0.6+)**: `get_tables`는 `column_widths_cm` / `row_heights_cm`, `get_cell`은 `width_cm` / `height_cm` / `char_count`를 반환합니다. LLM이 좁은 셀(예: 1.7×0.7cm 배지)에 긴 텍스트를 넣어 오버플로 되는 것을 사전에 판단할 수 있습니다.

Expand Down
29 changes: 27 additions & 2 deletions document_adapter/xlsx_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,38 @@ class XlsxAdapter(DocumentAdapter):

def _open(self) -> None:
self._wb = load_workbook(str(self.path))
# 수식의 *캐시된 계산값* 읽기용(data_only). Excel 이 저장한 파일에만 캐시가
# 있으므로 lazy 로 로드하고, 없거나 실패하면 False 로 표시.
self._values_wb: Any = None

def save(self, path: Path | str | None = None) -> Path:
target = Path(path) if path else self.path
self._wb.save(str(target))
self.path = target
return target

def _cached_value(self, ws_title: str, row1: int, col1: int) -> Any:
"""수식 셀의 캐시된 계산값(없으면 None). Excel 저장본에서만 존재."""
if self._values_wb is None:
try:
self._values_wb = load_workbook(str(self.path), data_only=True)
except Exception:
self._values_wb = False
if not self._values_wb:
return None
try:
return self._values_wb[ws_title].cell(row=row1, column=col1).value
except Exception:
return None

def _display_text(self, ws, row0: int, col0: int, raw: Any) -> str:
"""표시 텍스트: 수식이고 캐시 계산값이 있으면 그 값을, 없으면 raw."""
if isinstance(raw, str) and raw.startswith("="):
cached = self._cached_value(ws.title, row0 + 1, col0 + 1)
if cached is not None:
return _cell_text(cached)
return _cell_text(raw)

# ---- helpers ----
def _ws(self, table_index: int):
sheets = self._wb.worksheets
Expand Down Expand Up @@ -149,7 +174,7 @@ def get_tables(self, min_rows: int = 1, min_cols: int = 1,
if (r, c) in covered:
continue
v = ws.cell(row=r + 1, column=c + 1).value
preview[r][c] = _cell_text(v)[:max_cell_len]
preview[r][c] = self._display_text(ws, r, c, v)[:max_cell_len]
merges = [MergeInfo(anchor=a, span=s) for a, s in anchors.items()]
col_widths = [
_colwidth_to_cm(ws.column_dimensions[get_column_letter(c + 1)].width)
Expand Down Expand Up @@ -182,7 +207,7 @@ def get_cell(self, table_index: int, row: int, col: int) -> CellContent:
is_anchor, anchor = True, (row, col)
span = anchors.get((row, col), (1, 1))
v = ws.cell(row=row + 1, column=col + 1).value
text = _cell_text(v)
text = self._display_text(ws, anchor[0], anchor[1], v)
width_cm = _colwidth_to_cm(
ws.column_dimensions[get_column_letter(anchor[1] + 1)].width)
height_cm = _rowheight_to_cm(ws.row_dimensions[anchor[0] + 1].height)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,38 @@ def test_xlsx_inspect_fill_render_roundtrip(tmp_path: Path) -> None:
ad2.close()


def test_xlsx_formula_cached_value(tmp_path: Path) -> None:
"""수식 셀: 캐시된 계산값이 있으면 그 값을, 없으면 수식 문자열을 표시."""
from openpyxl import Workbook

# (1) openpyxl 생성 — 캐시 없음 → 수식 문자열 폴백
nocache = tmp_path / "nocache.xlsx"
wb = Workbook()
ws = wb.active
ws["A1"] = 10
ws["A2"] = 20
ws["A3"] = "=A1+A2"
wb.save(str(nocache))
ad = load(nocache)
assert ad.get_cell(0, 2, 0).text == "=A1+A2"
ad.close()

# (2) Excel 저장본 시뮬레이션 — <v> 캐시 주입 → 계산값 표시
work = tmp_path / "x"
with zipfile.ZipFile(nocache) as z:
z.extractall(work)
sx = work / "xl" / "worksheets" / "sheet1.xml"
sx.write_text(sx.read_text().replace("<f>A1+A2</f>", "<f>A1+A2</f><v>30</v>"))
cached = tmp_path / "cached.xlsx"
with zipfile.ZipFile(cached, "w", zipfile.ZIP_DEFLATED) as z:
for f in work.rglob("*"):
if f.is_file():
z.write(f, f.relative_to(work).as_posix())
ad2 = load(cached)
assert ad2.get_cell(0, 2, 0).text == "30" # 수식 아닌 계산값
ad2.close()


def test_xlsx_value_typing(tmp_path: Path) -> None:
"""xlsx 셀 타입 처리: 날짜는 시간 제거, 금액은 숫자 보존, 전화/우편번호는 문자.

Expand Down
Loading