diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 6d7fefd78..ef629b80f 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -24,6 +24,7 @@ import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; import type { ConditionOptions } from "./spreadsheet_condition"; +import type { FigureSrc } from "./spreadsheet_figures"; import type { LinkOptions, Links } from "./spreadsheet_links"; import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; @@ -69,6 +70,8 @@ export interface SpreadsheetOptions { preview?: boolean; /** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */ conditions?: ConditionOptions; + /** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */ + figureSrc?: FigureSrc; } export interface SpreadsheetHandle { @@ -185,9 +188,21 @@ export function createSpreadsheet( }); } + /** 그림 겹판 · 쪽 나눔 점선 — 문서에 그림 · 쪽 나눔이 있을 때만 불러옴(크기 예산) */ + let figuring = false; + function loadFigures(): void { + const any = ctx.book.시트.some((s) => s.그림?.length || s.인쇄?.쪽나눔?.length); + if (figuring || !any) return; + figuring = true; + void import("./spreadsheet_figures").then((m) => { + if (root.isConnected) others.push(m.attachFigures(ctx, opts.figureSrc)); + }); + } + grid.render(); select(ctx.selection, false, true); loadLinks(); + loadFigures(); if (opts.conditions && !readOnly) void import("./spreadsheet_condition").then((m) => { if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!)); @@ -292,6 +307,7 @@ export function createSpreadsheet( grid.render(); select(ctx.selection, false, true); loadLinks(); + loadFigures(); }, recalc() { ctx.engine.rebuild(ctx.book); diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 9164ce8c4..89517185c 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -14,9 +14,9 @@ import { showToast } from "@ui/ui_template_elements"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; -import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { formatValue } from "./spreadsheet_numfmt"; +import { escapeHtml, styleToCss } from "./spreadsheet_print_html"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; import { st } from "./spreadsheet_text"; import type { @@ -38,16 +38,6 @@ import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; /* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */ -const BORDER_CSS: Record = { - hair: "dotted", - thin: "solid", - medium: "solid", - thick: "solid", - double: "double", - dotted: "dotted", - dashed: "dashed", -}; - function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle { const cell = sheet.칸[toA1(r, c)]; const idx = @@ -59,44 +49,6 @@ function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle return book.서식[idx] ?? {}; } -function styleToCss(style: CellStyle): string { - const parts: string[] = []; - if (style.글꼴) parts.push(`font-family:${style.글꼴}`); - if (style.크기) parts.push(`font-size:${style.크기}pt`); - if (style.굵게) parts.push("font-weight:bold"); - if (style.기울임) parts.push("font-style:italic"); - const deco = [style.밑줄 && "underline", style.취소선 && "line-through"] - .filter(Boolean) - .join(" "); - if (deco) parts.push(`text-decoration:${deco}`); - if (style.글자색) parts.push(`color:${style.글자색}`); - if (style.채움) parts.push(`background-color:${style.채움}`); - // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양 - const h = shownHAlign(style.가로); - parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`); - const v = shownVAlign(style.세로); - parts.push(`vertical-align:${v === "center" ? "middle" : v}`); - if (style.줄바꿈) parts.push("white-space:pre-wrap"); - if (style.테두리) { - (["위", "아래", "왼", "오른"] as const).forEach((side) => { - const s = style.테두리?.[side]; - if (!s) return; - const prop = { - 위: "border-top", - 아래: "border-bottom", - 왼: "border-left", - 오른: "border-right", - }[side]; - parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`); - }); - } - return parts.join(";"); -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); -} - /** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */ function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock { const { r0, c0, r1, c1 } = range; diff --git a/A00_Common/spreadsheet/spreadsheet_figures.ts b/A00_Common/spreadsheet/spreadsheet_figures.ts new file mode 100644 index 000000000..fedbc7043 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_figures.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * spreadsheet_figures.ts (주인 P · 534 sub6) + * 그림 겹판 · 쪽 나눔 점선(요구 347-5) — 시트 `그림`(범위 닻)을 칸 위에 얹고 `인쇄.쪽나눔` 행 아래에 점선. + * 격자 overlay 맨 아래 층(고름 테두리가 위) · 누름은 칸으로 통과 · 스크롤 · 확대 · 다시 그림마다 자리를 다시 셈. + * 그림 주소 = 부른 쪽 `figureSrc(열쇠)` · 없으면 이름 적힌 빈 상자. 그림 옮기기 · 나눔 고치기 화면은 없음(1차). + * `createSpreadsheet` 가 그림 · 쪽 나눔이 있는 문서일 때만 불러옴(크기 예산). + * ========================================================================== */ + +import { parseRange } from "./spreadsheet_address"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +export type FigureSrc = (열쇠: string) => string | undefined; + +const css = (node: HTMLElement, style: Partial) => + Object.assign(node.style, style); + +export function attachFigures(ctx: SpreadsheetContext, figureSrc?: FigureSrc): PartHandle { + const layer = document.createElement("div"); + layer.className = "ss-figures"; + css(layer, { position: "absolute", inset: "0", overflow: "hidden", pointerEvents: "none" }); + const imgs = new Map(); // 열쇠 → 그림(다시 그려도 다시 안 받음) + + function draw(): void { + const overlay = ctx.grid.root.querySelector(".aislo-grid__overlay"); + if (!overlay) return; + if (overlay.firstChild !== layer) overlay.prepend(layer); + // editorSlot 상자 = 격자 뿌리 기준 → overlay 기준(머리 칸만큼 밀림) + const box = (r: number, c: number) => { + const b = ctx.grid.editorSlot(r, c).box; + return { ...b, x: b.x - overlay.offsetLeft, y: b.y - overlay.offsetTop }; + }; + const sheet = ctx.sheet(); + const parts: HTMLElement[] = []; + for (const f of sheet.그림 ?? []) { + const rg = parseRange(f.닻); + if (!rg) continue; + const a = box(rg.r0, rg.c0); + const b = box(rg.r1, rg.c1); + const frame = document.createElement("div"); + frame.className = "ss-figure"; + frame.dataset.key = f.열쇠; + css(frame, { + position: "absolute", + left: `${a.x}px`, + top: `${a.y}px`, + width: `${Math.max(0, b.x + b.w - a.x)}px`, + height: `${Math.max(0, b.y + b.h - a.y)}px`, + boxSizing: "border-box", + background: "#fff", + }); + const src = figureSrc?.(f.열쇠); + if (src) { + let img = imgs.get(f.열쇠); + if (!img || img.getAttribute("src") !== src) { + img = document.createElement("img"); + img.src = src; + img.alt = f.이름 ?? f.열쇠; + css(img, { width: "100%", height: "100%", objectFit: "contain", display: "block" }); + imgs.set(f.열쇠, img); + } + frame.append(img); + } else { + css(frame, { + border: "1px dashed #888", + color: "#666", + font: "12px sans-serif", + padding: "4px", + }); + frame.textContent = f.이름 ?? f.열쇠; + } + parts.push(frame); + } + const view = ctx.grid.visibleRange(); + for (const k of sheet.인쇄?.쪽나눔 ?? []) { + const at = box(k - 1, view.c0); + const line = document.createElement("div"); + line.className = "ss-pagebreak"; + line.dataset.row = String(k); + css(line, { + position: "absolute", + left: "0", + right: "0", + top: `${at.y + at.h - 1}px`, + borderTop: "2px dashed #3b6fd8", + }); + parts.push(line); + } + layer.replaceChildren(...parts); + } + + const onScroll = () => draw(); + ctx.grid.root.addEventListener("scroll", onScroll, true); + ctx.grid.root.addEventListener("aislo-grid-zoom", onScroll); + draw(); + + return { + root: null, + refresh: draw, + destroy() { + ctx.grid.root.removeEventListener("scroll", onScroll, true); + ctx.grid.root.removeEventListener("aislo-grid-zoom", onScroll); + layer.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_print_html.ts b/A00_Common/spreadsheet/spreadsheet_print_html.ts new file mode 100644 index 000000000..b08238da7 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_print_html.ts @@ -0,0 +1,238 @@ +/* ============================================================================= + * spreadsheet_print_html.ts (주인 P · 534 sub6) + * 통합문서 → 인쇄 HTML 한 벌(요구 347-5) — 서버 Node 진입 `print` 이 부르고 PDF 는 + * `common_util_print_pdf.render_html_to_pdf(page_size=None)` 이 만듦(쪽 크기 = CSS @page). + * DOM 없음 · 값 = 화면과 같은 엔진(`createCalcEngine`) · 보이는 글 = `formatValue` · 서식 = `cellStyleAt`. + * + * 규칙: + * 시트마다 새 쪽(@page 이름 = 시트마다 용지 · 방향 · 여백) · 숨긴 시트 · 빈 시트 · 숨긴 행열은 안 찍음. + * `인쇄.쪽나눔` 행 아래에서 새 쪽(덩이마다 표 하나) · `머리행` = 덩이마다 (자동 쪽 나눔 때도 되풀이) · + * 머리행 위 행(제목 등)은 첫 덩이 앞 표 하나로. 병합 = rowspan · colspan(덩이 경계에서 잘림 · 이어진 쪽은 빈 칸). + * 열 폭 = (화면과 같은 px 환산) · 행 높이 = pt · `맞춤.폭` = 폭에 맞춰 줄임 · 아니면 `배율` %. + * 그림 = 닻 왼위 칸 안 (data: 주소 · 범위 크기) — 주소가 없는 그림은 안 찍음. + * 화면 · 클립보드가 같이 쓰는 서식 → CSS(`styleToCss`)도 여기 둠(DOM 없는 파일). + * ========================================================================== */ + +import { colName, parseRange } from "./spreadsheet_address"; +import { cellStyleAt, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; +import { createCalcEngine } from "./spreadsheet_graph"; +import { colCharsToPx, DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; +import { formatValue } from "./spreadsheet_numfmt"; +import type { BorderLine, CellRange, CellStyle, Sheet, Workbook } from "./spreadsheet_types"; + +const BORDER_CSS: Record = { + hair: "1px dotted", + thin: "1px solid", + medium: "2px solid", + thick: "3px solid", + double: "3px double", + dotted: "1px dotted", + dashed: "1px dashed", +}; +const SIDES = { 위: "border-top", 아래: "border-bottom", 왼: "border-left", 오른: "border-right" }; + +/** 서식 → 인라인 CSS — 안 준 맞춤 = 가운데 · 가운데(237-1, 화면 · 엑셀 붙여넣기 · 인쇄 공통). */ +export function styleToCss(style: CellStyle): string { + const parts: string[] = []; + if (style.글꼴) parts.push(`font-family:${style.글꼴}`); + if (style.크기) parts.push(`font-size:${style.크기}pt`); + if (style.굵게) parts.push("font-weight:bold"); + if (style.기울임) parts.push("font-style:italic"); + const deco = [style.밑줄 && "underline", style.취소선 && "line-through"] + .filter(Boolean) + .join(" "); + if (deco) parts.push(`text-decoration:${deco}`); + if (style.글자색) parts.push(`color:${style.글자색}`); + if (style.채움) parts.push(`background-color:${style.채움}`); + const h = shownHAlign(style.가로); + parts.push( + `text-align:${h === "centerContinuous" ? "center" : h === "distributed" ? "justify" : h}`, + ); + const v = shownVAlign(style.세로); + parts.push(`vertical-align:${v === "center" ? "middle" : v}`); + if (style.줄바꿈) parts.push("white-space:pre-wrap"); + for (const side of ["위", "아래", "왼", "오른"] as const) { + const s = style.테두리?.[side]; + if (s) parts.push(`${SIDES[side]}:${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`); + } + return parts.join(";"); +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +const PAPER_MM = { A4: [210, 297], A3: [297, 420] } as const; +const PX_PER_MM = 96 / 25.4; + +/** 시트가 쓰는 마지막 행 · 열(0 부터) — 칸 · 병합 · 그림 닻. 빈 시트 = null */ +function usedExtent(sheet: Sheet): { rows: number; cols: number } | null { + let r1 = -1; + let c1 = -1; + const grow = (rg: CellRange | null) => { + if (rg) [r1, c1] = [Math.max(r1, rg.r1), Math.max(c1, rg.c1)]; + }; + for (const a1 of Object.keys(sheet.칸)) grow(parseRange(a1)); + for (const m of sheet.병합 ?? []) grow(parseRange(m)); + for (const f of sheet.그림 ?? []) grow(parseRange(f.닻)); + return r1 < 0 ? null : { rows: r1 + 1, cols: c1 + 1 }; +} + +const range = (n: number) => Array.from({ length: n }, (_, i) => i); + +/** 시트 한 장 → `
`(덩이 표 여럿) · 표 폭 px */ +function sheetHtml( + book: Workbook, + sheet: Sheet, + value: (r: number, c: number) => string, + figures: Record, + pageName: string, + printWidthPx: number, +): string | null { + const ext = usedExtent(sheet); + if (!ext) return null; + const cols = range(ext.cols).filter((c) => !sheet.열?.[colName(c)]?.숨김); + const rows = range(ext.rows).filter((r) => { + const info = sheet.행?.[String(r + 1)]; + return !info?.숨김 && !info?.걸러짐; + }); + const colPx = (c: number) => + colCharsToPx( + sheet.열?.[colName(c)]?.폭 ?? sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS, + ); + const rowPt = (r: number) => + sheet.행?.[String(r + 1)]?.높이 ?? sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT; + const tableW = cols.reduce((sum, c) => sum + colPx(c), 0); + const colgroup = `${cols.map((c) => ``).join("")}`; + const merges = (sheet.병합 ?? []).map((m) => parseRange(m)).filter((m): m is CellRange => !!m); + const figs = (sheet.그림 ?? []) + .map((f) => ({ src: figures[f.열쇠], rg: parseRange(f.닻), alt: f.이름 ?? f.열쇠 })) + .filter((f): f is { src: string; rg: CellRange; alt: string } => !!f.src && !!f.rg); + const placed = new Set(); + + /** 행 몇 줄 → 들(병합은 이 줄 묶음 안에서만) */ + const rowsHtml = (part: number[]): string => { + const skip = new Set(); + const spans = new Map(); + for (const m of merges) { + const rIn = part.filter((r) => r >= m.r0 && r <= m.r1); + const cIn = cols.filter((c) => c >= m.c0 && c <= m.c1); + if (!rIn.length || !cIn.length) continue; + for (const r of rIn) for (const c of cIn) skip.add(`${r},${c}`); + const origin = rIn[0] === m.r0 && cIn[0] === m.c0; + spans.set(`${rIn[0]},${cIn[0]}`, { rs: rIn.length, cs: cIn.length, origin }); + } + return part + .map((r) => { + const tds = cols.map((c) => { + const key = `${r},${c}`; + const span = spans.get(key); + if (skip.has(key) && !span) return ""; + const rs = span?.rs ?? 1; + const cs = span?.cs ?? 1; + let css = styleToCss(cellStyleAt(book, sheet, r, c)); + const text = span && !span.origin ? "" : value(r, c); + // 이 칸 상자에 닻 왼위가 든 그림 + const boxRows = part.slice(part.indexOf(r), part.indexOf(r) + rs); + const boxCols = cols.slice(cols.indexOf(c), cols.indexOf(c) + cs); + let imgs = ""; + figs.forEach((f, i) => { + if (placed.has(i) || !boxRows.includes(f.rg.r0) || !boxCols.includes(f.rg.c0)) return; + placed.add(i); + const w = cols + .filter((x) => x >= f.rg.c0 && x <= f.rg.c1) + .reduce((s, x) => s + colPx(x), 0); + const h = rows + .filter((x) => x >= f.rg.r0 && x <= f.rg.r1) + .reduce((s, x) => s + rowPt(x), 0); + imgs += + `${escapeHtml(f.alt)}`; + }); + if (imgs) css += ";position:relative;overflow:visible"; + const attrs = (rs > 1 ? ` rowspan="${rs}"` : "") + (cs > 1 ? ` colspan="${cs}"` : ""); + return `${escapeHtml(text)}${imgs}`; + }); + return `${tds.join("")}`; + }) + .join(""); + }; + + const pr = sheet.인쇄 ?? {}; + const head = pr.머리행 + ? rows.filter((r) => r >= pr.머리행![0] - 1 && r <= pr.머리행![1] - 1) + : []; + const thead = head.length ? `${rowsHtml(head)}` : ""; + const body = rows.filter((r) => !head.includes(r)); + // 덩이 = 쪽 나눔 행 아래에서 끊음 + const cuts = [...new Set(pr.쪽나눔 ?? [])].sort((a, b) => a - b); + const chunks: number[][] = [[]]; + for (const r of body) { + if ( + chunks[chunks.length - 1].length && + cuts.some((k) => r >= k && chunks[chunks.length - 1].at(-1)! < k) + ) + chunks.push([]); + chunks[chunks.length - 1].push(r); + } + const table = (inner: string) => `${colgroup}${inner}
`; + const html = chunks.map((part, i) => { + const pre = i === 0 && head.length ? part.filter((r) => r < head[0]) : []; + const main = part.filter((r) => !pre.includes(r)); + const tbody = main.length ? `${rowsHtml(main)}` : ""; + return ( + `
${pre.length ? table(`${rowsHtml(pre)}`) : ""}` + + `${thead || tbody ? table(thead + tbody) : ""}
` + ); + }); + const zoom = pr.맞춤?.폭 + ? Math.min(1, printWidthPx / Math.max(1, tableW)) + : (pr.배율 ?? 100) / 100; + const style = `page:${pageName}${zoom !== 1 ? `;zoom:${zoom.toFixed(4)}` : ""}`; + return `
${html.join("")}
`; +} + +const BASE_CSS = `html,body{margin:0} +body{font-family:"Malgun Gothic","맑은 고딕",sans-serif;font-size:10pt;color:#000} +.sheet{break-after:page}.sheet:last-child{break-after:auto} +.chunk+.chunk{break-before:page} +table{table-layout:fixed;border-collapse:collapse} +td{padding:0 2px;overflow:hidden;white-space:nowrap} +thead{display:table-header-group} +tr{break-inside:avoid}`; + +/** 통합문서 → 인쇄 HTML 문서 — `figures` = 그림 열쇠 → data: 주소 */ +export function printHtml(book: Workbook, figures: Record = {}): string { + const engine = createCalcEngine(book); + const pages: string[] = []; + const sections: string[] = []; + book.시트.forEach((sheet, i) => { + if (sheet.숨김) return; + const pr = sheet.인쇄 ?? {}; + const [pw, ph] = PAPER_MM[pr.용지 ?? "A4"]; + const land = pr.방향 === "landscape"; + const m = { 위: 10, 아래: 10, 왼: 10, 오른: 10, ...pr.여백 }; + const name = `p${i}`; + pages.push( + `@page ${name}{size:${pr.용지 ?? "A4"} ${land ? "landscape" : "portrait"};` + + `margin:${m.위}mm ${m.오른}mm ${m.아래}mm ${m.왼}mm}`, + ); + const widthPx = ((land ? ph : pw) - m.왼 - m.오른) * PX_PER_MM; + const value = (r: number, c: number) => + formatValue(engine.value(sheet.id, r, c), cellStyleAt(book, sheet, r, c).형식).글; + const html = sheetHtml(book, sheet, value, figures, name, widthPx); + if (html) sections.push(html); + }); + const fontCss = + book.기본?.글꼴 || book.기본?.크기 + ? `body{${book.기본.글꼴 ? `font-family:${book.기본.글꼴};` : ""}${book.기본.크기 ? `font-size:${book.기본.크기}pt` : ""}}` + : ""; + return ( + `${sections.join("")}` + ); +} diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py index 607f9fa9b..99ceee456 100644 --- a/common_util/common_util_spreadsheet.py +++ b/common_util/common_util_spreadsheet.py @@ -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) diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index d669a82e9..57dcf687e 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -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 }[]; }; 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 })); diff --git a/common_util/common_util_svg_png.py b/common_util/common_util_svg_png.py new file mode 100644 index 000000000..95fcf35b4 --- /dev/null +++ b/common_util/common_util_svg_png.py @@ -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 마다 요소만 찍음. 실패 = 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'{svg}' + ) + 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) diff --git a/common_util/common_util_workbook_xlsx.py b/common_util/common_util_workbook_xlsx.py new file mode 100644 index 000000000..12bbbd659 --- /dev/null +++ b/common_util/common_util_workbook_xlsx.py @@ -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() diff --git a/resources/tester/spreadsheet/test_spreadsheet_print.py b/resources/tester/spreadsheet/test_spreadsheet_print.py new file mode 100644 index 000000000..661e5d663 --- /dev/null +++ b/resources/tester/spreadsheet/test_spreadsheet_print.py @@ -0,0 +1,129 @@ +"""534-2 — 내장 엑셀 내보내기: 인쇄 HTML(Node 번들 `printHtml`) · xlsx 되읽기 · SVG → PNG · PDF 쪽 수. + +책 하나(시트 둘 · 병합 · 열 폭 · 숨긴 열 · 머리 행 · 쪽 나눔 둘 · 그림 · 식)로 셋을 함께 봄. +""" + +import io +import re +import shutil + +import pytest +from openpyxl import load_workbook + +from common_util import common_util_spreadsheet as spreadsheet +from common_util.common_util_print_pdf import PrintError +from common_util.common_util_svg_png import svgs_to_pngs +from common_util.common_util_workbook_xlsx import workbook_to_xlsx + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +SVG = ( + '' + '' +) +BREAKS = [6, 12] + + +def _book() -> dict: + cells = {"A1": {"값": "제목", "서식": 1}, "A2": {"값": "측점", "서식": 2}, "B2": {"값": "Fs"}} + for r in range(3, 31): + cells[f"A{r}"] = {"값": f"No.{r}"} + cells[f"B{r}"] = {"값": r / 10, "서식": 3} + cells["C3"] = {"식": "SUM(B3:B30)", "서식": 3} + cells["D3"] = {"값": "숨김"} + return { + "종류": "통합문서", + "판": 1, + "열": "stab_report", + "서식": [ + {}, + {"크기": 14, "굵게": True}, + {"채움": "#EEEEEE", "테두리": {"아래": {"선": "medium"}}}, + {"형식": "0.00"}, + ], + "시트": [ + { + "id": "s1", + "이름": "측점표", + "칸": cells, + "병합": ["A1:C1", "E5:E8"], + "열": {"A": {"폭": 12}, "B": {"폭": 6}, "D": {"숨김": True}}, + "행": {"1": {"높이": 24}}, + "틀고정": {"행": 2}, + "인쇄": { + "방향": "landscape", + "맞춤": {"폭": 1, "높이": 0}, + "머리행": [2, 2], + "쪽나눔": BREAKS, + }, + "그림": [{"열쇠": "fig/a.png", "닻": "F3:H10", "이름": "단면"}], + }, + {"id": "s2", "이름": "숨김", "칸": {"A1": {"값": 1}}, "숨김": True}, + ], + "활성": "s1", + } + + +@pytest.fixture(scope="module") +def png() -> bytes: + try: + return svgs_to_pngs([SVG])[0] + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + + +def test_svg_png_크기(png): + from PIL import Image + + assert Image.open(io.BytesIO(png)).size == (240, 120) # scale 2 + + +def test_인쇄_html(png): + html = spreadsheet.print_htmls([{"book": _book(), "figures": {"fig/a.png": png}}])[0] + sheets = re.findall(r'
')[1:] + assert len(chunks) == len(BREAKS) + 1 + for chunk in chunks: # 덩이마다 머리 행 한 줄 + thead = re.search(r"(.*?)", chunk).group(1) + assert thead.count("', chunks[0]) + assert widths[:3] == ["89", "47", "64"] and len(widths) == 7 * 2 # 앞 표(제목) + 본 표 + # 병합 A1:C1 · E5:E8 = 쪽 나눔(6 아래)에서 잘림 — 첫 덩이 5~6 · 둘째 덩이 7~8 + assert 'colspan="3"' in chunks[0] and 'rowspan="2"' in chunks[0] and 'rowspan="2"' in chunks[1] + assert "rowspan" not in chunks[2] + assert html.count("46.20<" in html # 식 값 = 화면 엔진 · 형식 0.00(SUM 0.3..3.0) + + +def test_xlsx_되읽기(png): + data = workbook_to_xlsx(_book(), {"fig/a.png": png}) + wb = load_workbook(io.BytesIO(data)) + assert wb.sheetnames == ["측점표", "숨김"] + ws = wb["측점표"] + assert sorted(str(m) for m in ws.merged_cells.ranges) == ["A1:C1", "E5:E8"] + assert [b.id for b in ws.row_breaks.brk] == BREAKS + assert len(ws._images) == 1 + assert ws.print_title_rows == "$2:$2" + assert ws["C3"].value == "=SUM(B3:B30)" + assert ws["B3"].number_format == "0.00" and ws["A1"].font.b + assert ws.column_dimensions["A"].width == 12 and ws.column_dimensions["D"].hidden + assert ws.row_dimensions[1].height == 24 and ws.freeze_panes == "A3" + assert ws.page_setup.orientation == "landscape" and ws.page_setup.fitToWidth == 1 + assert wb["숨김"].sheet_state == "hidden" + + +def test_pdf_쪽수(png): + book = _book() + try: + pdf = spreadsheet.workbook_pdf(book, {"fig/a.png": png}) + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + from pypdf import PdfReader + + pages = PdfReader(io.BytesIO(pdf)).pages + assert len(pages) == len(BREAKS) + 1 + assert all(float(p.mediabox.width) > float(p.mediabox.height) for p in pages) # 가로 + assert all("측점" in p.extract_text() for p in pages) # 머리 행 쪽마다