diff --git a/README.md b/README.md index 6dfd92e..a3d5c95 100644 --- a/README.md +++ b/README.md @@ -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 배지)에 긴 텍스트를 넣어 오버플로 되는 것을 사전에 판단할 수 있습니다. diff --git a/document_adapter/xlsx_adapter.py b/document_adapter/xlsx_adapter.py index 74a7ab9..9c159a2 100644 --- a/document_adapter/xlsx_adapter.py +++ b/document_adapter/xlsx_adapter.py @@ -89,6 +89,9 @@ 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 @@ -96,6 +99,28 @@ def save(self, path: Path | str | None = None) -> Path: 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 @@ -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) @@ -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) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 4fa0a8d..a402498 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -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 저장본 시뮬레이션 — 캐시 주입 → 계산값 표시 + 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("A1+A2", "A1+A230")) + 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 셀 타입 처리: 날짜는 시간 제거, 금액은 숫자 보존, 전화/우편번호는 문자.