- 가져오기가 WPT 표를 버리던 결함 고침: 표 줄(칸·수량 표지·수식·세로병합) 보존, 문서 읽기 native-quantity-wpt.cjs 로 분리 - 표 셀 합·수량 지정·병합/분리·표 만들기·표 모양, 문단·그림·쪽 삽입/삭제·품명/규격 지정·공종명칭 입력·테두리선·산출근거 양식 - 수량 불러오기·수량목록보기·선택수량복사·내역서 수량붙여넣기(연결) - ESTX 저장은 표 줄을 건너 문단만 맞춤, 표·서식 편집은 거부 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.5 KiB
JavaScript
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(/"/g, '"')
|
|
.replace(/'|'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/ /g, " ")
|
|
.replace(/&/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;
|