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
+16
View File
@@ -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);
@@ -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<BorderLine, string> = {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
const { r0, c0, r1, c1 } = range;
@@ -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<CSSStyleDeclaration>) =>
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<string, HTMLImageElement>(); // 열쇠 → 그림(다시 그려도 다시 안 받음)
function draw(): void {
const overlay = ctx.grid.root.querySelector<HTMLElement>(".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();
},
};
}
@@ -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 이름 = 시트마다 용지 · 방향 · 여백) · 숨긴 시트 · 빈 시트 · 숨긴 행열은 안 찍음.
* `인쇄.쪽나눔` 행 아래에서 새 쪽(덩이마다 표 하나) · `머리행` = 덩이마다 <thead>(자동 쪽 나눔 때도 되풀이) ·
* 머리행 위 행(제목 등)은 첫 덩이 앞 표 하나로. 병합 = rowspan · colspan(덩이 경계에서 잘림 · 이어진 쪽은 빈 칸).
* 열 폭 = <colgroup>(화면과 같은 px 환산) · 행 높이 = pt · `맞춤.폭` = 폭에 맞춰 줄임 · 아니면 `배율` %.
* 그림 = 닻 왼위 칸 안 <img>(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<BorderLine, string> = {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
/** 시트 한 장 → `<section>`(덩이 표 여럿) · 표 폭 px */
function sheetHtml(
book: Workbook,
sheet: Sheet,
value: (r: number, c: number) => string,
figures: Record<string, string>,
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 = `<colgroup>${cols.map((c) => `<col style="width:${colPx(c)}px">`).join("")}</colgroup>`;
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<number>();
/** 행 몇 줄 → <tr> 들(병합은 이 줄 묶음 안에서만) */
const rowsHtml = (part: number[]): string => {
const skip = new Set<string>();
const spans = new Map<string, { rs: number; cs: number; origin: boolean }>();
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 +=
`<img src="${escapeHtml(f.src)}" alt="${escapeHtml(f.alt)}" style="position:absolute;` +
`left:0;top:0;width:${w}px;height:${h}pt;object-fit:contain">`;
});
if (imgs) css += ";position:relative;overflow:visible";
const attrs = (rs > 1 ? ` rowspan="${rs}"` : "") + (cs > 1 ? ` colspan="${cs}"` : "");
return `<td${attrs} style="${escapeHtml(css)}">${escapeHtml(text)}${imgs}</td>`;
});
return `<tr style="height:${rowPt(r)}pt">${tds.join("")}</tr>`;
})
.join("");
};
const pr = sheet.인쇄 ?? {};
const head = pr.머리행
? rows.filter((r) => r >= pr.머리행![0] - 1 && r <= pr.머리행![1] - 1)
: [];
const thead = head.length ? `<thead>${rowsHtml(head)}</thead>` : "";
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) => `<table style="width:${tableW}px">${colgroup}${inner}</table>`;
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 ? `<tbody>${rowsHtml(main)}</tbody>` : "";
return (
`<div class="chunk">${pre.length ? table(`<tbody>${rowsHtml(pre)}</tbody>`) : ""}` +
`${thead || tbody ? table(thead + tbody) : ""}</div>`
);
});
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 `<section class="sheet" data-sheet="${escapeHtml(sheet.id)}" style="${style}">${html.join("")}</section>`;
}
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, string> = {}): 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 (
`<!doctype html><html><head><meta charset="utf-8"><style>${BASE_CSS}\n${pages.join("\n")}` +
`${fontCss}</style></head><body>${sections.join("")}</body></html>`
);
}
+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()
@@ -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 = (
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60">'
'<rect width="120" height="60" fill="#0a0"/></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'<section class="sheet" data-sheet="(\w+)"', html)
assert sheets == ["s1"] # 숨긴 시트 안 찍음
chunks = html.split('<div class="chunk">')[1:]
assert len(chunks) == len(BREAKS) + 1
for chunk in chunks: # 덩이마다 머리 행 한 줄
thead = re.search(r"<thead>(.*?)</thead>", chunk).group(1)
assert thead.count("<tr") == 1 and "측점" in thead
# 열 폭 = 화면 px 환산(글자 × 7 + 5) · 숨긴 D 빠짐(A B C E F G H = 7)
widths = re.findall(r'<col style="width:(\d+)px">', 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("<img ") == 1 and "data:image/png;base64," in html
assert "@page p0{size:A4 landscape" in html
assert ">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) # 머리 행 쪽마다