From b913f520393ee26981a0b516f6f11d6239caa4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Mon, 1 Jun 2026 19:05:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(xlsx):=20=EC=85=80=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(=EB=82=A0=EC=A7=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=C2=B7=EC=88=AB=EC=9E=90=20=EB=B3=B4=EC=A1=B4)=20+=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "제대로 검증했냐" 지적에 따라 데이터 타입 엣지케이스와 실제 xlsx 파일로 재검증: - 날짜: '2026-06-01 00:00:00' → '2026-06-01' (시간 0 이면 날짜만). - set_cell: 깔끔한 숫자(금액 '3,000,000'→3000000)는 숫자형으로 기록해 Excel 수식/합계가 살아있게. 전화·사번·우편번호(대시·선행 0)는 문자로 보존(_maybe_number 가드). 기존엔 항상 문자로 써서 금액 셀이 텍스트가 됐음. - 실제 xlsx 5종(calamine 테스트셋, 다중 시트 6개 포함) load/inspect/편집/round-trip 전부 무크래시 확인. - 회귀 테스트(test_xlsx_value_typing) 추가. 78개 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 ++ document_adapter/xlsx_adapter.py | 50 +++++++++++++++++++++++++++++--- tests/test_scenarios.py | 34 ++++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b35ff5c..132cbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ inspect 가 병합 docx 에서 깨지던 버그와 머리말/꼬리말·노트 base 구현으로 자동 동작. 병합 셀 anchor/span 인지(비-anchor 쓰기는 `MergedCellWriteError`), 셀 크기(cm) 메타 제공. `load("*.xlsx")` 자동 디스패치. MCP 도구는 확장자 디스패치로 그대로 동작. + - **셀 타입 처리**: 날짜는 시간 없이 표시(`2026-06-01`), `set_cell` 은 깔끔한 + 숫자(금액 등)를 숫자형으로 기록해 Excel 수식/합계를 유지하되, 전화·사번· + 우편번호(대시·선행 0)는 문자로 보존. 실제 xlsx 파일 5종(다중 시트 포함)으로 검증. ### Fixed - **docx `get_placeholders` 병합표 크래시**: `row.cells` 가 가로+세로 병합 docx 에서 diff --git a/document_adapter/xlsx_adapter.py b/document_adapter/xlsx_adapter.py index 77731a3..74a7ab9 100644 --- a/document_adapter/xlsx_adapter.py +++ b/document_adapter/xlsx_adapter.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import datetime import re from pathlib import Path from typing import Any @@ -45,6 +46,44 @@ def _rowheight_to_cm(points: float | None) -> float | None: return round(points / 72 * 2.54, 1) +def _cell_text(v: Any) -> str: + """셀 값을 사람이 읽는 텍스트로. 날짜는 시간 0 이면 날짜만 표시.""" + if v is None: + return "" + if isinstance(v, datetime.datetime): + if (v.hour, v.minute, v.second, v.microsecond) == (0, 0, 0, 0): + return v.date().isoformat() + return v.isoformat(sep=" ") + if isinstance(v, datetime.date): + return v.isoformat() + return str(v) + + +# 깔끔한 숫자 문자열만 매칭 (정수 / 천단위콤마 / 소수). 전화·ID·날짜(대시) 제외. +_NUM_RE = re.compile( + r"^-?\d+$|^-?\d{1,3}(?:,\d{3})+$|^-?\d+\.\d+$|^-?\d{1,3}(?:,\d{3})+\.\d+$" +) + + +def _maybe_number(s: str) -> int | float | None: + """금액 등 깔끔한 숫자 문자열을 int/float 로. 아니면 None(문자 유지). + + 선행 0 정수(우편번호·사번 등)와 대시 포함(전화·날짜)은 문자로 둔다. + """ + t = s.strip() + if not _NUM_RE.match(t): + return None + plain = t.replace(",", "") + intpart = plain.lstrip("-").split(".")[0] + if len(intpart) > 1 and intpart.startswith("0"): + return None + try: + f = float(plain) + return int(f) if "." not in plain else f + except ValueError: + return None + + class XlsxAdapter(DocumentAdapter): format = "xlsx" @@ -110,7 +149,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] = ("" if v is None else str(v))[:max_cell_len] + preview[r][c] = _cell_text(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) @@ -143,7 +182,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 = "" if v is None else str(v) + text = _cell_text(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) @@ -193,8 +232,11 @@ def set_cell(self, table_index: int, row: int, col: int, value: str, f"cell ({row},{col}) out of bounds ({rows}x{cols})") wr, wc = self._resolve_writable(ws, row, col, allow_merge_redirect) cell = ws.cell(row=wr + 1, column=wc + 1) - old = "" if cell.value is None else str(cell.value) - cell.value = value + old = _cell_text(cell.value) + # 깔끔한 숫자(금액 등)는 숫자로 기록해 Excel 수식/합계가 살아있게. + # 전화·ID·날짜 등은 _maybe_number 가 None → 문자 유지. + num = _maybe_number(value) + cell.value = num if num is not None else value return old def append_to_cell(self, table_index: int, row: int, col: int, value: str, diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index bfb4a1a..0ec3562 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -540,6 +540,40 @@ def test_xlsx_inspect_fill_render_roundtrip(tmp_path: Path) -> None: ad2.close() +def test_xlsx_value_typing(tmp_path: Path) -> None: + """xlsx 셀 타입 처리: 날짜는 시간 제거, 금액은 숫자 보존, 전화/우편번호는 문자. + + 회귀: 초기 구현은 set_cell 이 항상 문자로 써서 금액 셀이 텍스트가 되고 + (수식·합계 깨짐), 날짜를 '...00:00:00' 으로 표시했다. + """ + import datetime + from openpyxl import Workbook, load_workbook + + src = tmp_path / "t.xlsx" + wb = Workbook() + ws = wb.active + ws["A1"] = "날짜" + ws["B1"] = datetime.date(2026, 6, 1) + ws["A2"] = "금액" + ws["A3"] = "전화" + ws["A4"] = "우편" + wb.save(str(src)) + + ad = load(src) + assert ad.get_cell(0, 0, 1).text == "2026-06-01" # 시간 없음 + ad.set_cell(0, 1, 1, "3,000,000") # 금액 → 숫자 + ad.set_cell(0, 2, 1, "010-1234-5678") # 전화 → 문자 + ad.set_cell(0, 3, 1, "00100") # 우편(선행0) → 문자 + ad.save(src) + ad.close() + + wb2 = load_workbook(str(src)) + assert wb2.active["B2"].value == 3000000 + assert isinstance(wb2.active["B2"].value, int) + assert wb2.active["B3"].value == "010-1234-5678" + assert wb2.active["B4"].value == "00100" # 선행 0 보존 + + def test_xlsx_merged_cell_write_rejected(tmp_path: Path) -> None: """병합 non-anchor 좌표 쓰기는 MergedCellWriteError (allow_merge_redirect로 우회).""" from document_adapter.base import MergedCellWriteError