"""내장 엑셀 통합문서(Workbook JSON) → xlsx 바이트(요구 347-5 · PLAN 534-2). 계약 = `A00_Common/spreadsheet/spreadsheet_types.ts` — 서식 이름이 엑셀 이름 그대로라 1:1 로 옮김. 값 · 식(`=` 붙여 살아 있는 수식 · 열 때 엑셀이 다시 셈) · 서식(글꼴 · 채움 · 맞춤 · 줄바꿈 · 테두리 · 숫자 형식) · 병합 · 열 폭(글자 단위) · 행 높이(pt) · 숨김 · 틀 고정 · 눈금선 · 시트 숨김 · 탭 색 · 인쇄(용지 · 방향 · 배율/맞춤 · 여백 · 머리 줄 반복 · 쪽 나눔) · 그림(범위 두 칸 닻). 서식 차례는 화면과 같음(칸 → 행 → 열 → 시트 기본 → 0 · `spreadsheet_cellstyle.styleIndexAt`) · 안 준 맞춤 = 가운데 · 가운데(237-1, 화면 · 붙여넣기 · 인쇄 공통). 그림 = `figures` {열쇠: PNG 바이트} · 없는 열쇠는 건너뜀. 값 셈 없음(서버 재계산은 부른 쪽 `with_server_values`). """ from __future__ import annotations import io import re from typing import Any from openpyxl import Workbook as XlBook from openpyxl.drawing.image import Image from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import column_index_from_string, get_column_letter from openpyxl.worksheet.page import PageMargins from openpyxl.worksheet.pagebreak import Break from common_util.common_util_print_pdf import sheet_title A1 = re.compile(r"^([A-Z]+)(\d+)$") PAPER = {"A4": 9, "A3": 8} MM_PER_INCH = 25.4 def _rc(a1: str) -> tuple[int, int]: """A1 → (행, 열) 1 부터.""" m = A1.match(a1.upper()) if not m: raise ValueError(f"칸 주소 꼴 아님 「{a1}」") return int(m.group(2)), column_index_from_string(m.group(1)) def _span(text: str) -> tuple[int, int, int, int]: """`B5:H20` · `B5` → (r0, c0, r1, c1) 1 부터.""" a, _, b = text.partition(":") r0, c0 = _rc(a) r1, c1 = _rc(b) if b else (r0, c0) return min(r0, r1), min(c0, c1), max(r0, r1), max(c0, c1) def _color(hex_: str | None) -> str | None: return hex_.lstrip("#").upper() if hex_ else None def _style(st: dict[str, Any]) -> dict[str, Any]: """서식 한 벌 → openpyxl 속성(font · fill · alignment · border · number_format).""" h = st.get("가로") side = { k: Side(style=v["선"], color=_color(v.get("색")) or "000000") for k, v in (st.get("테두리") or {}).items() } out: dict[str, Any] = { "font": Font( name=st.get("글꼴"), size=st.get("크기"), bold=st.get("굵게"), italic=st.get("기울임"), underline="single" if st.get("밑줄") else None, strike=st.get("취소선"), color=_color(st.get("글자색")), ), "alignment": Alignment( horizontal="center" if not h or h == "general" else h, vertical=st.get("세로") or "center", wrap_text=st.get("줄바꿈"), ), "border": Border( top=side.get("위"), bottom=side.get("아래"), left=side.get("왼"), right=side.get("오른") ), } if st.get("채움"): out["fill"] = PatternFill("solid", fgColor=_color(st["채움"])) if st.get("형식"): out["number_format"] = st["형식"] return out def _extent(sheet: dict[str, Any]) -> tuple[int, int]: """쓰는 마지막 행 · 열(1 부터) — 칸 · 병합 · 그림 닻.""" spans = [_span(a1) for a1 in sheet.get("칸", {})] spans += [_span(m) for m in sheet.get("병합") or []] spans += [_span(f["닻"]) for f in sheet.get("그림") or []] return max((s[2] for s in spans), default=0), max((s[3] for s in spans), default=0) def _print(ws: Any, pr: dict[str, Any]) -> None: ws.page_setup.paperSize = PAPER.get(pr.get("용지") or "A4", 9) ws.page_setup.orientation = pr.get("방향") or "portrait" fit = pr.get("맞춤") if fit: ws.sheet_properties.pageSetUpPr.fitToPage = True ws.page_setup.fitToWidth = fit.get("폭", 0) ws.page_setup.fitToHeight = fit.get("높이", 0) elif pr.get("배율"): ws.page_setup.scale = pr["배율"] m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})} ws.page_margins = PageMargins( top=m["위"] / MM_PER_INCH, bottom=m["아래"] / MM_PER_INCH, left=m["왼"] / MM_PER_INCH, right=m["오른"] / MM_PER_INCH, ) if pr.get("머리행"): a, b = pr["머리행"] ws.print_title_rows = f"{a}:{b}" for k in sorted(set(pr.get("쪽나눔") or [])): ws.row_breaks.append(Break(id=k)) def _sheet(ws: Any, book: dict[str, Any], sheet: dict[str, Any], styles: list, figures) -> None: cells = sheet.get("칸", {}) rows_info = sheet.get("행") or {} cols_info = sheet.get("열") or {} base = sheet.get("기본") or {} n_rows, n_cols = _extent(sheet) for r in range(1, n_rows + 1): for c in range(1, n_cols + 1): letter = get_column_letter(c) cell = cells.get(f"{letter}{r}") idx = next( ( i for i in ( (cell or {}).get("서식"), rows_info.get(str(r), {}).get("서식"), cols_info.get(letter, {}).get("서식"), base.get("서식"), ) if i is not None ), 0, ) if cell is None and not idx: continue xl = ws.cell(row=r, column=c) if cell and cell.get("식") is not None: xl.value = "=" + cell["식"] elif cell and "값" in cell: xl.value = cell["값"] if isinstance(cell["값"], str) and cell["값"].startswith("="): xl.data_type = "s" # 글로 친 「=…」 — 수식 아님 for attr, value in (styles[idx] if 0 <= idx < len(styles) else {}).items(): setattr(xl, attr, value) for m in sheet.get("병합") or []: ws.merge_cells(m) width = base.get("열폭") or (book.get("기본") or {}).get("열폭") if width: ws.sheet_format.defaultColWidth = width height = base.get("행높이") or (book.get("기본") or {}).get("행높이") if height: ws.sheet_format.defaultRowHeight = height ws.sheet_format.customHeight = True for letter, info in cols_info.items(): dim = ws.column_dimensions[letter] if info.get("폭") is not None: dim.width = info["폭"] dim.hidden = bool(info.get("숨김")) for key, info in rows_info.items(): dim = ws.row_dimensions[int(key)] if info.get("높이") is not None: dim.height = info["높이"] dim.hidden = bool(info.get("숨김") or info.get("걸러짐")) frozen = sheet.get("틀고정") or {} if frozen.get("행") or frozen.get("열"): ws.freeze_panes = ws.cell( row=(frozen.get("행") or 0) + 1, column=(frozen.get("열") or 0) + 1 ) if (sheet.get("보기") or {}).get("눈금선") is False: ws.sheet_view.showGridLines = False if sheet.get("숨김"): ws.sheet_state = "hidden" if sheet.get("탭색"): ws.sheet_properties.tabColor = _color(sheet["탭색"]) _print(ws, sheet.get("인쇄") or {}) for fig in sheet.get("그림") or []: png = figures.get(fig["열쇠"]) if not png: continue r0, c0, r1, c1 = _span(fig["닻"]) img = Image(io.BytesIO(png)) img.anchor = TwoCellAnchor( editAs="twoCell", _from=AnchorMarker(col=c0 - 1, row=r0 - 1), to=AnchorMarker(col=c1, row=r1), ) ws.add_image(img) def workbook_to_xlsx(book: dict[str, Any], figures: dict[str, bytes] | None = None) -> bytes: """통합문서 → xlsx 바이트 — 시트 차례 그대로 · 열 때 활성 시트 = `활성`.""" xl = XlBook() xl.remove(xl.active) styles = [_style(st or {}) for st in book.get("서식") or [{}]] used: set[str] = set() active = 0 for i, sheet in enumerate(book.get("시트") or []): ws = xl.create_sheet(sheet_title(sheet.get("이름") or "시트", used)) _sheet(ws, book, sheet, styles, figures or {}) if sheet.get("id") == book.get("활성"): active = i if not xl.worksheets: xl.create_sheet("시트") xl.active = active xl.calculation.fullCalcOnLoad = True # 식 값은 엑셀이 열 때 셈(캐시 값 없음) out = io.BytesIO() xl.save(out) return out.getvalue()