Files
Aislo/common_util/common_util_workbook_xlsx.py
T
eomsangdonandClaude Opus 5.5 f20aea412a feat(534-2): 내장 엑셀 내보내기 — 인쇄 HTML(Node print) · xlsx · SVG→PNG · PDF
- spreadsheet_print_html: 통합문서 → 인쇄 HTML(DOM 없음 · 화면 엔진 값 · formatValue · 병합 · colgroup 폭 · 쪽 나눔 덩이 · thead 머리행 반복 · 그림 img · 시트마다 @page)
- 서식 → CSS 를 클립보드에서 떼어 공용(medium · thick · double 굵기 반영)
- 서버 Node 진입 prints · 파이썬 print_htmls · workbook_pdf(공용 PDF 엔진 page_size=None)
- common_util_workbook_xlsx: openpyxl 로 값 · 식 · 서식 · 병합 · 폭 · 높이 · 틀 고정 · 인쇄 설정 · 쪽 나눔 · 그림(두 칸 닻)
- common_util_svg_png: Playwright 로 SVG → PNG(PyMuPDF 안 씀)
- 시험 test_spreadsheet_print(인쇄 HTML · xlsx 되읽기 · PNG 크기 · PDF 쪽 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:33:57 +09:00

218 lines
8.7 KiB
Python

"""내장 엑셀 통합문서(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()