- 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>
218 lines
8.7 KiB
Python
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()
|