- common_util_spreadsheet_node.ts areaTable: 시트 · 범위 → CAD 표 객체(표시 글 = formatValue · cellStyleAt · 병합 → colSpan/rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행열 뺌 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 칸 수 한도 · 칸 key 「시트id!A1」 · style.연결 빈 글)
- 파이썬은 부르기만(common_util_spreadsheet.area_tables · 새 M02_Area_Table.py)
- POST /api/m02/layers/{층}/area/{열}/table {시트, 범위, 글자크기} → {표, 시트, 범위, 값} · 저장 안 함 · 층 읽기 권한
- 면적 저장(Store.write · 비우기 · 복사 · 프로젝트 층 길) 뒤 그 열 도면 연결표마다 표 칸 글 다시 · 마지막 값 갱신 · 표 없으면 기록 둠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
174 lines
7.5 KiB
TypeScript
174 lines
7.5 KiB
TypeScript
/* =============================================================================
|
||
* 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<Record<HAlign, TableCell["align"]>> = {
|
||
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<string, string> = {};
|
||
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 }));
|