// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word. // 문단(
) 은 산출 줄, 표() 는 한 줄 { 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(/]*\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(/]*\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(//gi, (_, id) => { const next = superscripts.has(id), value = next && !raised ? "^(" : !next && raised ? ")" : ""; raised = next; return value; }); if (raised) body += ")"; body = body .replace(/]*\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(/]*\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( /|]*>([\s\S]*?)<\/div>/gi, )) { if (/^
]*>([\s\S]*?)<\/tr>/gi)].map((tr) => [...tr[1].matchAll(/]*>([\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;