Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user