Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
218 lines
9.4 KiB
TypeScript
218 lines
9.4 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 { resolveName } from "../A00_Common/spreadsheet/spreadsheet_names";
|
||
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";
|
||
|
||
/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */
|
||
export const NAME_GONE = "이름없음";
|
||
|
||
/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면
|
||
* 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */
|
||
interface TableAsk {
|
||
book: Workbook;
|
||
시트: string;
|
||
범위: string;
|
||
이름?: string;
|
||
글자크기?: number;
|
||
한도?: number;
|
||
}
|
||
|
||
/** 면적 시트에 보낸 범위 이름을 더하거나(같은 이름 있으면) 범위를 갱신(104-1 · 처음 보낼 때 1번) */
|
||
interface NameAsk {
|
||
book: Workbook;
|
||
시트: string;
|
||
범위: string;
|
||
이름: string;
|
||
}
|
||
|
||
/** 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;
|
||
let sheetId = ask.시트;
|
||
let range: CellRange;
|
||
if (ask.이름) {
|
||
const found = resolveName(book, ask.시트, ask.이름);
|
||
if (!found) throw new Error(NAME_GONE); // 이름을 지웠거나 범위가 통째로 지워짐
|
||
sheetId = found.시트;
|
||
range = found.범위;
|
||
} else {
|
||
const parsed = parseRange(ask.범위);
|
||
if (!parsed) throw new Error(`범위 꼴 아님 「${ask.범위}」`);
|
||
range = parsed;
|
||
}
|
||
const sheet = book.시트.find((s) => s.id === sheetId) ?? book.시트.find((s) => s.이름 === sheetId);
|
||
if (!sheet) throw new Error(`없는 시트 「${sheetId}」`);
|
||
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), 값 };
|
||
}
|
||
|
||
/** 이름정의를 더하거나(있으면) 범위만 바꿈 — 책 사본을 돌려줌(저장은 python 쪽) */
|
||
function defineAreaName(ask: NameAsk): { book: Workbook } {
|
||
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 kept = (sheet.이름정의 ?? []).filter((n) => n.이름 !== ask.이름);
|
||
sheet.이름정의 = [...kept, { 이름: ask.이름, 범위: range }];
|
||
return { book };
|
||
}
|
||
|
||
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[];
|
||
names?: NameAsk[];
|
||
};
|
||
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) };
|
||
}
|
||
});
|
||
const names = (input.names ?? []).map((ask) => {
|
||
try {
|
||
return { ok: true, ...defineAreaName(ask) };
|
||
} catch (error) {
|
||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||
}
|
||
});
|
||
writeFileSync(outputPath, JSON.stringify({ results, tables, names }));
|