/* ============================================================================= * common_util_spreadsheet_node.ts * 산출근거 스프레드시트 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본 `계산값`. * * 풀이는 화면이 쓰는 `A00_Common/spreadsheet/spreadsheet_recalc.ts` 그대로 — 여기에는 계산이 없음(CLAUDE.md 5장 ②). * 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`. * * 실행: node <번들> <입력.json> <출력.json> * 입력 { books?: Workbook[], tables?: TableAsk[] } * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[], * tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂 * 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · * 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함. * 끝 코드: 0 성공 / 2 인자 오류 * ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게. * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import { colName, parseRange, rangeToA1, rangesOverlap, toA1, } from "../A00_Common/spreadsheet/spreadsheet_address"; import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle"; import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers"; import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; /** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */ interface TableAsk { book: Workbook; 시트: string; 범위: string; 글자크기?: number; 한도?: number; } /** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */ interface TableCell { text: string; colSpan?: number; rowSpan?: number; align?: "left" | "center" | "right"; bold?: boolean; key: string; } const FONT = 2.5; const LIMIT = 2000; /** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */ const COL_K = 0.7; const ROW_K = 1.8; const ALIGN: Partial> = { left: "left", center: "center", right: "right", distributed: "center", centerContinuous: "center", }; function areaTable(ask: TableAsk) { const book = ask.book; const sheet = book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트); if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`); const range = parseRange(ask.범위); if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`); const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT; const limit = ask.한도 ?? LIMIT; const span = (a: number, b: number): number[] => Array.from({ length: b - a + 1 }, (_, i) => a + i); const rowShown = (r: number): boolean => { const info = sheet.행?.[String(r + 1)]; return !info?.숨김 && !info?.걸러짐; }; const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김; if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4) throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음 const rows = span(range.r0, range.r1).filter(rowShown); const cols = span(range.c0, range.c1).filter(colShown); if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음"); if (rows.length * cols.length > limit) throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`); const engine = createCalcEngine(book); const merges: { whole: CellRange; part: CellRange }[] = []; for (const text of sheet.병합 ?? []) { const whole = parseRange(text); if (!whole || !rangesOverlap(whole, range)) continue; const part = { r0: Math.max(whole.r0, range.r0), c0: Math.max(whole.c0, range.c0), r1: Math.min(whole.r1, range.r1), c1: Math.min(whole.c1, range.c1), }; // 범위 밖 병합은 잘라 냄 merges.push({ whole, part }); } const mergeAt = (r: number, c: number) => merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1); const 값: Record = {}; const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => { const style = cellStyleAt(book, sheet, at.r, at.c); const has = sheet.칸[toA1(at.r, at.c)]; const shown = has && (has.값 !== undefined || has.식 !== undefined) ? formatValue(engine.value(sheet.id, at.r, at.c), style.형식) : { 글: "", 정렬: "left" as const }; const key = `${sheet.id}!${toA1(r, c)}`; 값[key] = shown.글; const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; }; const cells = rows.map((r) => cols.map((c): TableCell | null => { const merge = mergeAt(r, c); if (!merge) return cellOf(r, c, { r, c }); const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1); const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1); if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리 const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸 return { ...cell, ...(inCols.length > 1 ? { colSpan: inCols.length } : {}), ...(inRows.length > 1 ? { rowSpan: inRows.length } : {}), }; }), ); const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS; const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT; const 표 = { type: "Table", lineWidth: 1, shapeData: { origin: { x: 0, y: 0 }, columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K), rowHeights: rows.map( (r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K, ), cells, style: { fontSize: font, padding: 1, 연결: "" }, }, }; return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 }; } const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { console.error("사용법: node <번들> <입력.json> <출력.json>"); process.exit(2); } const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[]; tables?: TableAsk[]; }; const results = (input.books ?? []).map((book) => { try { return { ok: true, 계산값: recalcWorkbook(book) }; } catch (error) { return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; } }); const tables = (input.tables ?? []).map((ask) => { try { return { ok: true, ...areaTable(ask) }; } catch (error) { return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; } }); writeFileSync(outputPath, JSON.stringify({ results, tables }));