Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-25 19:41:43 +09:00
9 changed files with 850 additions and 50 deletions
+37
View File
@@ -9,6 +9,7 @@ CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는
from __future__ import annotations
import base64
import copy
from typing import Any
@@ -77,3 +78,39 @@ def define_names(asks: list[dict[str, Any]]) -> list[dict[str, Any]]:
if out is None:
raise RecalcError("스프레드시트 계산 번들 실행 실패")
return out["names"]
def print_htmls(asks: list[dict[str, Any]]) -> list[str]:
"""통합문서 → 인쇄 HTML(534-2 · 번들 `printHtml` 한 벌) —
ask = `{book, figures?: {열쇠: PNG 바이트}}`.
물음 차례 그대로 HTML. 하나라도 못 만들면 `RecalcError`."""
if _stale() and not build_bundle(NPM_SCRIPT):
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
prints = [
{
"book": ask["book"],
"figures": {
key: "data:image/png;base64," + base64.b64encode(png).decode()
for key, png in (ask.get("figures") or {}).items()
},
}
for ask in asks
]
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"prints": prints})
if out is None:
raise RecalcError("스프레드시트 계산 번들 실행 실패")
htmls: list[str] = []
for result in out["prints"]:
if not result.get("ok"):
raise RecalcError(f"인쇄 HTML 실패 — {result.get('까닭')}")
htmls.append(result["html"])
return htmls
def workbook_pdf(book: dict[str, Any], figures: dict[str, bytes] | None = None) -> bytes:
"""통합문서 → PDF(요구 347-5) — 인쇄 HTML → 공용 PDF 엔진(쪽 크기 · 여백 = 시트마다 CSS @page).
실패 = `RecalcError` 또는 `common_util_print_pdf.PrintError`."""
from common_util.common_util_print_pdf import render_html_to_pdf
html = print_htmls([{"book": book, "figures": figures}])[0]
return render_html_to_pdf(html, page_size=None)
+12 -1
View File
@@ -11,6 +11,8 @@
* tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 풀이는 `A00_Common/spreadsheet/spreadsheet_area_table.ts`
* (화면 도면 패널의 저장 전 실시간 표 값과 같은 코드 · 112-1) · 저장 안 함.
* 인쇄(534-2 · 요구 347-5) = 입력 `prints: {book, figures?}[]` → 출력 `prints: ({ ok: true, html } | { ok: false, 까닭 })[]` —
* `A00_Common/spreadsheet/spreadsheet_print_html.ts` 한 벌 · figures = 그림 열쇠 → data: 주소 · PDF 는 파이썬이 만듦.
* 끝 코드: 0 성공 / 2 인자 오류
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
* ========================================================================== */
@@ -18,6 +20,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { parseRange } from "../A00_Common/spreadsheet/spreadsheet_address";
import { areaTable, type TableAsk } from "../A00_Common/spreadsheet/spreadsheet_area_table";
import { printHtml } from "../A00_Common/spreadsheet/spreadsheet_print_html";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
@@ -52,6 +55,7 @@ const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
books?: Workbook[];
tables?: TableAsk[];
names?: NameAsk[];
prints?: { book: Workbook; figures?: Record<string, string> }[];
};
const results = (input.books ?? []).map((book) => {
try {
@@ -74,4 +78,11 @@ const names = (input.names ?? []).map((ask) => {
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
}
});
writeFileSync(outputPath, JSON.stringify({ results, tables, names }));
const prints = (input.prints ?? []).map((ask) => {
try {
return { ok: true, html: printHtml(ask.book, ask.figures) };
} catch (error) {
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
}
});
writeFileSync(outputPath, JSON.stringify({ results, tables, names, prints }));
+95
View File
@@ -0,0 +1,95 @@
"""SVG → PNG(요구 347-5 · 요구 350 — PyMuPDF 안 씀) — 헤드리스 Edge(없으면 Chrome) Playwright 찍기.
svgs_to_pngs([svg 글], scale=2) → [PNG 바이트] 브라우저 한 번으로 여러 장(차례 그대로)
크기 = SVG 의 width · height(없으면 viewBox) × scale. 보고서 그림(단면 · 평면 위험 띠)을
내장 엑셀 그림(xlsx · 화면)으로 넣을 때 씀 — PDF 는 인쇄 HTML 이 data: 주소로 그대로 씀.
브라우저 띄우는 규칙 · 잠금 · 까닭 글은 공용 PDF 엔진(`common_util_print_pdf`)과 한 벌 —
자식 파이썬 프로세스(서버 스레드에선 Playwright 드라이버가 못 뜸) · 스크립트 끔 · 바깥 요청 막음.
"""
from __future__ import annotations
import base64
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
from common_util.common_util_print_pdf import CHANNELS, NO_BROWSER, TIMEOUT_S, PrintError, _lock
ROOT = Path(__file__).resolve().parents[1]
def _shoot(svgs: list[str], scale: float) -> list[bytes]:
"""자식 프로세스 안 — SVG 마다 <svg> 요소만 찍음. 실패 = PrintError."""
try:
from playwright.sync_api import Error as PwError
from playwright.sync_api import sync_playwright
except ImportError as error:
raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error
try:
with sync_playwright() as p:
browser = None
for channel in CHANNELS:
try:
browser = p.chromium.launch(channel=channel, headless=True)
break
except PwError:
continue
if browser is None:
raise PrintError(NO_BROWSER)
try:
context = browser.new_context(java_script_enabled=False, device_scale_factor=scale)
context.route("**/*", lambda route: route.abort())
page = context.new_page()
out = []
for svg in svgs:
page.set_content(
f'<html><body style="margin:0;background:#fff">{svg}</body></html>'
)
out.append(page.locator("svg").first.screenshot())
return out
finally:
browser.close()
except PrintError:
raise
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
raise PrintError(NO_BROWSER) from error
def svgs_to_pngs(svgs: list[str], scale: float = 2, timeout_s: float = TIMEOUT_S) -> list[bytes]:
"""SVG 여러 장 → PNG 여러 장(자식 프로세스 한 번)."""
if not svgs:
return []
with _lock:
try:
done = subprocess.run(
[sys.executable, "-m", "common_util.common_util_svg_png"], # 묶음 import 되게
input=json.dumps({"svgs": svgs, "scale": scale}).encode(),
cwd=ROOT,
capture_output=True,
timeout=timeout_s,
)
except subprocess.TimeoutExpired as error:
raise PrintError(f"그림 만들기가 {timeout_s:.0f}초 안에 안 끝남") from error
except OSError as error:
raise PrintError(NO_BROWSER) from error
try:
result: dict[str, Any] = json.loads(done.stdout.decode().strip().splitlines()[-1])
except (ValueError, IndexError): # 자식이 죽음 — stderr 는 내지 않음
result = {"reason": NO_BROWSER}
if result.get("reason"):
raise PrintError(result["reason"])
return [base64.b64decode(png) for png in result["pngs"]]
if __name__ == "__main__": # 자식 프로세스 몸통
job = json.loads(sys.stdin.buffer.read())
try:
pngs = _shoot(job["svgs"], job["scale"])
print(json.dumps({"reason": None, "pngs": [base64.b64encode(p).decode() for p in pngs]}))
except PrintError as error:
print(json.dumps({"reason": str(error)}, ensure_ascii=False))
sys.exit(0)
+217
View File
@@ -0,0 +1,217 @@
"""내장 엑셀 통합문서(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()