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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 에서
Expand Down
50 changes: 46 additions & 4 deletions document_adapter/xlsx_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""
from __future__ import annotations

import datetime
import re
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading