Files
Aislo/tools/est-test/native-quantity-wpt.cjs
T
eomsangdonandClaude Opus 5.5 892285843d feat(est-test): 워드형 수량산출서 표 보존과 편집 도구
- 가져오기가 WPT 표를 버리던 결함 고침: 표 줄(칸·수량 표지·수식·세로병합) 보존, 문서 읽기 native-quantity-wpt.cjs 로 분리
- 표 셀 합·수량 지정·병합/분리·표 만들기·표 모양, 문단·그림·쪽 삽입/삭제·품명/규격 지정·공종명칭 입력·테두리선·산출근거 양식
- 수량 불러오기·수량목록보기·선택수량복사·내역서 수량붙여넣기(연결)
- ESTX 저장은 표 줄을 건너 문단만 맞춤, 표·서식 편집은 거부

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:35:50 +09:00

191 lines
7.5 KiB
JavaScript

// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word.
// 문단(<div>) 은 산출 줄, 표(<table>) 는 한 줄 { table: [[칸]] } 로 둔다. 칸 = { text, mark(수량 표지 이름 r·L·G), name·size(품명·규격 표지),
// formula(=SUM(B3:B5) — 원본 PAR_COMMAND), vmerge(세로 병합으로 가려짐) }.
// 방향 셀 합(ActionAddRight·Left·Up·Down 0xd3c5f0): 옆 칸부터 밖으로 — 빈칸·숫자 칸은 범위를 늘리고 글자 칸은 건너뛰며,
// 품명·규격 표지 칸이나 표 끝에서 멈춤. 칸에 SUM 수식을 두고 바로 계산(값 글자 = 1,234.5 꼴, 소수 9자리까지, 끝수 처리 없음).
// 계산 때마다 수식 칸을 왼쪽 위부터 한 번씩 다시 셈. 수량 표지 칸의 수는 그 항목 수량.
const EstQuantityWpt = (() => {
function entities(text) {
return String(text)
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n);
return code <= 0x10ffff ? String.fromCodePoint(code) : "";
})
.replace(/&quot;/g, '"')
.replace(/&apos;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
const tag = (name) =>
/^[rLG]\d+$/.test(name)
? "\x12"
: /^[Fa]\d+$/.test(name)
? "\x16"
: /^N\d+$/.test(name)
? "\x0e"
: /^S\d+$/.test(name)
? "\x13"
: "";
function fromWpt(code, name, text, items, options) {
const tags = new Map(),
rows = [];
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
.map((m) => m[1]),
);
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
if (name) tags.set(match[1], name);
}
// 문단·칸 글 → 표지 글자(\x12 결과·\x16 누계·\x0e 품명·\x13 규격)
function convert(html, cell = false) {
// 문단의 G 표지는 원본처럼 표지로 보지 않음(표 칸에서만 수량)
const tagOf = (n) => (!cell && /^G/.test(n) ? "" : tag(n));
const markers = [],
totals = [],
names = [];
let raised = false;
let body = html.replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
raised = next;
return value;
});
if (raised) body += ")";
body = body
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
names.push(name);
if (tagOf(name) === "\x12" && !/^G/.test(name)) markers.push(name);
if (tagOf(name) === "\x16") totals.push(name);
return tagOf(name);
})
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tagOf(tags.get(id) || ""));
return {
body: entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")),
markers,
totals,
names,
};
}
for (const match of text.matchAll(
/<table\b[\s\S]*?<\/table>|<div\b[^>]*>([\s\S]*?)<\/div>/gi,
)) {
if (/^<table/i.test(match[0])) {
const table = [...match[0].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => {
const c = convert(td[1], true),
mark = c.names.find((n) => tag(n) === "\x12");
return {
text: c.body.replace(/[\x0e\x12\x13\x16]/g, "").trim(),
...(mark && { mark }),
...(c.names.some((n) => /^N\d+$/.test(n)) && { name: true }),
...(c.names.some((n) => /^S\d+$/.test(n)) && { size: true }),
};
}),
);
if (table.length) rows.push({ LineData: "", markers: [], table });
continue;
}
const c = convert(match[1]);
if (c.body.trim())
rows.push({
LineData: c.body,
markers: c.markers,
...(c.totals.length && { totals: c.totals }),
});
}
return { Code: code, Name: name, rows, items, options };
}
// ── 표 계산 ──
const L = (n) => {
let s = "";
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const numeric = (t) => /^[+-]?[\d,]*\.?\d+$/.test(String(t).trim());
const value = (t) => (numeric(t) ? Number(String(t).replaceAll(",", "")) : 0);
const show = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 9 });
function recalc(table) {
const errors = [];
table.forEach((row) =>
row.forEach((cell) => {
if (!cell.formula) return;
try {
const X =
typeof EstExcelSheet !== "undefined"
? EstExcelSheet
: require("./native-excel-sheet.cjs");
const v = X.evaluate(cell.formula, (r, c) => value(table[r]?.[c]?.text ?? ""));
cell.text = show(typeof v === "number" ? v : Number(v) || 0);
} catch (error) {
errors.push(error.message);
}
}),
);
return errors;
}
// 항목 수량: 수량 표지 칸의 수
function values(sheet) {
const out = new Map();
for (const row of sheet.rows || []) {
if (!row.table) continue;
recalc(row.table);
for (const r of row.table)
for (const c of r) if (c.mark && !c.vmerge) out.set(c.mark, value(c.text));
}
return out;
}
function sumFormula(table, r, c, dir) {
const [dr, dc] = { right: [0, -1], left: [0, 1], up: [1, 0], down: [-1, 0] }[dir] || [];
if (dr == null) throw Error("방향을 고르세요.");
// 방향 = 결과 칸이 있는 쪽(원본 Ctrl+← 는 오른쪽 칸들을 더함)
let rr = r + dr,
cc = c + dc,
last = null;
const first = [rr, cc];
while (table[rr]?.[cc]) {
const cell = table[rr][cc];
if (cell.name || cell.size) break;
if (!cell.text.trim() || numeric(cell.text)) last = [rr, cc];
rr += dr;
cc += dc;
}
if (!table[first[0]]?.[first[1]]) throw Error("더할 셀이 없습니다.");
if (!last) last = dir === "right" ? [r, 0] : first;
const a = [Math.min(first[0], last[0]), Math.min(first[1], last[1])],
b = [Math.max(first[0], last[0]), Math.max(first[1], last[1])];
return `SUM(${L(a[1])}${a[0] + 1}:${L(b[1])}${b[0] + 1})`;
}
function vmerge(table, r1, r2, c) {
if (r2 <= r1) throw Error("세로로 여러 칸을 고르세요.");
const top = table[r1][c];
for (let r = r1 + 1; r <= r2; r++) {
const cell = table[r][c];
if (cell.text.trim()) top.text = `${top.text} ${cell.text}`.trim();
cell.text = "";
cell.vmerge = true;
}
top.vtop = r2 - r1 + 1;
}
function vsplit(table, r, c) {
const top = table[r][c];
for (let i = r + 1; i < r + (top.vtop || 1); i++) if (table[i]?.[c]) delete table[i][c].vmerge;
delete top.vtop;
}
function create(rows, cols) {
if (!(rows >= 1 && rows <= 100 && cols >= 1 && cols <= 30))
throw Error("줄 1~100, 칸 1~30 입니다.");
return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: "" })));
}
return { entities, fromWpt, recalc, values, sumFormula, vmerge, vsplit, create, letters: L };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantityWpt;