188 lines
8.3 KiB
Python
188 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""보고서 시트 모형 → 엑셀(.xlsx) 쓰기 (report_xlsx.py).
|
|
|
|
시트 모형 = report_*.js 가 만든 { name, title, rows:[{A:값,…,_k:종류,_link:시트}] } (rows[i] = i+1 행).
|
|
근거: 지식DB STmate/old/20_분석 19 §3(목차·안내 시트) · 20 §3(SET_REP 서식) · 06 §3(출력별 반올림).
|
|
쓰는 것: 값 · 글꼴(제목·머리·묶음 줄 굵게) · 테두리 · 숫자 서식(정수 #,##0 · 소수 자리 그대로) ·
|
|
열 폭 · 인쇄 설정(A4 · 폭 맞춤 · 머리 행 반복 · 쪽 넘김) · 목차 바로가기 하이퍼링크.
|
|
수식(formulas=True) — 행의 _f {열: '=…{r}…'} 를 수식으로(ST_P_XLS Prn_FMLA_INFO) · 값은 엑셀이 다시 셈 ·
|
|
= 로 시작하지 않는 _f 값은 숨은 열쇠 글(SUMIF 묶음 열쇠) 그대로.
|
|
바로가기 — 행의 _links {열: '시트!칸'} = 하이퍼링크(수식 모드면 HYPERLINK 수식이 대신) · 칸별 글꼴 _sz · _bold.
|
|
원본 꼴 — 이름표 열 숨김 · 인쇄 영역 A~이름표 앞 열 · 시트 cols 고정 폭 · tab 색.
|
|
인쇄 설정(print) — 방향(landscape·portrait) · 글꼴 이름 · 크기(SET_REP 인쇄 메뉴 옵션).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from io import BytesIO
|
|
|
|
import openpyxl
|
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
from openpyxl.utils import column_index_from_string, get_column_letter
|
|
from openpyxl.worksheet.pagebreak import Break
|
|
|
|
COL_RE = re.compile(r"^[A-Z]{1,2}$")
|
|
BAD_TITLE = re.compile(r"[\[\]:*?/\\]")
|
|
THIN = Side(style="thin", color="808080")
|
|
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
|
HEAD_FILL = PatternFill("solid", start_color="E8EEF5", end_color="E8EEF5")
|
|
FONT = "맑은 고딕"
|
|
BOLD_KINDS = {"head", "T1", "T2", "T3", "T4", "T5", "sub", "total", "final", "hopyo", "parent"}
|
|
|
|
|
|
def _cells(row: dict) -> dict:
|
|
return {k: v for k, v in row.items() if COL_RE.match(k) and v not in (None, "")}
|
|
|
|
|
|
def _num_format(v: float) -> str:
|
|
if float(v).is_integer():
|
|
return "#,##0"
|
|
digits = len(repr(float(v)).split(".")[1].rstrip("0"))
|
|
return "#,##0." + "0" * min(max(digits, 1), 6)
|
|
|
|
|
|
def sheet_title(name: str, used: set[str]) -> str:
|
|
"""엑셀 시트 이름 — 금지 글자 걷고 31자 · 겹치면 번호."""
|
|
base = BAD_TITLE.sub("", str(name or "Sheet"))[:31] or "Sheet"
|
|
title, i = base, 2
|
|
while title in used:
|
|
title = f"{base[:28]}({i})"
|
|
i += 1
|
|
used.add(title)
|
|
return title
|
|
|
|
|
|
def _tag_col(sheet: dict) -> str | None:
|
|
"""시트 이름표 열 — 2행에서 시트 이름이 적힌 칸(원본 숨은 열 · 인쇄 영역 끝 다음)."""
|
|
rows = sheet.get("rows") or []
|
|
if len(rows) < 2:
|
|
return None
|
|
return next((k for k, v in rows[1].items() if COL_RE.match(k) and v == sheet.get("name")), None)
|
|
|
|
|
|
def write_sheet(ws, sheet: dict, formulas: bool = False, prn: dict | None = None) -> None:
|
|
prn = prn or {}
|
|
font_name = prn.get("font") or FONT
|
|
font_size = float(prn.get("size") or 10)
|
|
rows = sheet.get("rows") or []
|
|
width: dict[int, int] = {}
|
|
max_col = 1
|
|
head_rows = 0
|
|
for r_idx, row in enumerate(rows, start=1):
|
|
kind = row.get("_k", "")
|
|
if kind == "head":
|
|
head_rows = r_idx
|
|
if kind == "page" and r_idx > 1:
|
|
ws.row_breaks.append(Break(id=r_idx - 1))
|
|
fmls = (row.get("_f") or {}) if formulas else {}
|
|
sizes = row.get("_sz") or {}
|
|
bolds = row.get("_bold") or {}
|
|
links = dict(row.get("_links") or {})
|
|
if row.get("_link") and not links: # 옛 꼴 — D(없으면 A) 칸 바로가기
|
|
at = next((c for c in ("D", "A") if c in row), None)
|
|
if at:
|
|
links[at] = f"'{row['_link']}'!A1"
|
|
cells = _cells(row)
|
|
for col in fmls:
|
|
if COL_RE.match(col) and col not in cells:
|
|
cells[col] = None
|
|
for col, val in cells.items():
|
|
c_idx = column_index_from_string(col)
|
|
max_col = max(max_col, c_idx)
|
|
fml = fmls.get(col)
|
|
out = fml.replace("{r}", str(r_idx)) if isinstance(fml, str) else val
|
|
cell = ws.cell(row=r_idx, column=c_idx, value=out)
|
|
bold = bolds.get(col, kind in BOLD_KINDS or kind == "title")
|
|
size = sizes.get(col) or (16 if kind == "title" else font_size)
|
|
cell.font = Font(name=font_name, size=size, bold=bold)
|
|
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
cell.number_format = _num_format(val)
|
|
cell.alignment = Alignment(horizontal="right", vertical="center")
|
|
elif val is not None:
|
|
text = str(val)
|
|
cell.alignment = Alignment(
|
|
horizontal="center" if kind == "head" else "left",
|
|
vertical="center",
|
|
wrap_text="\n" in text,
|
|
)
|
|
if kind == "head":
|
|
cell.fill = HEAD_FILL
|
|
if kind not in ("title", "pname") and r_idx > 2 and val is not None:
|
|
cell.border = BOX
|
|
if val is not None:
|
|
n = max(len(s) for s in str(val).split("\n")) if not isinstance(val, float) else 12
|
|
width[c_idx] = max(width.get(c_idx, 0), n)
|
|
target = links.get(col)
|
|
if target and not (isinstance(fml, str) and fml.startswith("=HYPERLINK")):
|
|
cell.hyperlink = f"#{target}"
|
|
cell.font = Font(name=font_name, size=size, color="1D4ED8", underline="single")
|
|
if rows and rows[0].get("_k") == "title" and max_col > 1:
|
|
tag = _tag_col(sheet)
|
|
end = column_index_from_string(tag) - 1 if tag else max_col
|
|
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(end, 1))
|
|
ws.cell(row=1, column=1).alignment = Alignment(horizontal="center", vertical="center")
|
|
fixed = sheet.get("cols") or {}
|
|
for c_idx in range(1, max_col + 1):
|
|
letter = get_column_letter(c_idx)
|
|
w = fixed.get(letter)
|
|
if not isinstance(w, (int, float)):
|
|
w = min(max(width.get(c_idx, 4) * 1.6 + 2, 6), 48)
|
|
ws.column_dimensions[letter].width = w
|
|
# 원본 꼴 — 이름표 열 숨김 · 인쇄 영역 = A ~ 이름표 앞 열 · 탭 색
|
|
tag = _tag_col(sheet)
|
|
if tag:
|
|
ws.column_dimensions[tag].hidden = True
|
|
last = get_column_letter(max(column_index_from_string(tag) - 1, 1))
|
|
ws.print_area = f"A1:{last}{max(len(rows), 1)}"
|
|
elif sheet.get("print_cols"):
|
|
ws.print_area = f"A1:{sheet['print_cols']}{max(len(rows), 1)}"
|
|
if sheet.get("tab"):
|
|
ws.sheet_properties.tabColor = sheet["tab"]
|
|
ws.page_setup.paperSize = ws.PAPERSIZE_A4
|
|
auto = "landscape" if max_col > 8 else "portrait"
|
|
ws.page_setup.orientation = (
|
|
prn.get("orient") if prn.get("orient") in ("landscape", "portrait") else auto
|
|
)
|
|
ws.page_setup.fitToWidth = 1
|
|
ws.page_setup.fitToHeight = 0
|
|
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
|
if head_rows:
|
|
ws.print_title_rows = f"1:{head_rows}"
|
|
ws.freeze_panes = ws.cell(row=head_rows + 1, column=1) if head_rows else None
|
|
|
|
|
|
def build_report_xlsx(sheets: list[dict], formulas: bool = False, prn: dict | None = None) -> bytes:
|
|
"""시트 모형 여럿 → 한 통 엑셀."""
|
|
wb = openpyxl.Workbook()
|
|
wb.remove(wb.active)
|
|
used: set[str] = set()
|
|
for sheet in sheets:
|
|
ws = wb.create_sheet(title=sheet_title(sheet.get("name", ""), used))
|
|
write_sheet(ws, sheet, formulas, prn)
|
|
if not wb.worksheets:
|
|
wb.create_sheet("빈 보고서")
|
|
if formulas:
|
|
wb.calculation.fullCalcOnLoad = True # 수식 값은 엑셀이 열 때 셈
|
|
buf = BytesIO()
|
|
wb.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def build_csv(sheet: dict) -> str:
|
|
"""시트 하나 → CSV(쉼표 · 큰따옴표 감쌈 · 엑셀 한글용 BOM 은 호출 쪽)."""
|
|
rows = sheet.get("rows") or []
|
|
max_col = max(
|
|
(column_index_from_string(k) for r in rows for k in _cells(r)),
|
|
default=1,
|
|
)
|
|
out = []
|
|
for row in rows:
|
|
vals = []
|
|
for c_idx in range(1, max_col + 1):
|
|
v = row.get(get_column_letter(c_idx))
|
|
s = "" if v is None else str(v)
|
|
vals.append('"' + s.replace('"', '""') + '"' if re.search(r'[",\n]', s) else s)
|
|
out.append(",".join(vals))
|
|
return "\r\n".join(out) + "\r\n"
|