diff --git a/tools/line-stack-test/app.js b/tools/line-stack-test/app.js
new file mode 100644
index 000000000..8453ae604
--- /dev/null
+++ b/tools/line-stack-test/app.js
@@ -0,0 +1,247 @@
+// 줄 쌓기 단가산출 시험 페이지(요구 669~676 · PLAN 804-1) — 서버 없음 · 저장 = 이 브라우저 localStorage
+// 지우기 = 이 폴더 삭제 + main.py 「/tools/line-stack-test」 마운트 덩이 + vite.config.ts 「/tools」 프록시 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이
+import { compute } from "./calc.js";
+import { renderOut, fmtP, esc } from "./render.js";
+import { parsePaste } from "./paste.js";
+import { LABOR, MACHINE, MATERIAL, SANGEUN, SAMPLES, TABLE_E } from "./samples.js";
+import { renderEdit, allTemplates } from "./edit.js";
+
+const KEY_DOC = "lineStackTest.doc";
+const KEY_TPL = "lineStackTest.myTemplates";
+const $ = (s) => document.querySelector(s);
+const clone = (x) => structuredClone(x);
+
+let doc = clone(SAMPLES[0].doc);
+let expect = SAMPLES[0].expect;
+let expectFrom = SAMPLES[0].label;
+let mine = JSON.parse(localStorage.getItem(KEY_TPL) || "[]");
+let res = compute(doc);
+
+const get = (path) => path.split(".").reduce((o, k) => o?.[k], doc);
+const setPath = (path, v) => {
+ const ks = path.split(".");
+ const last = ks.pop();
+ ks.reduce((o, k) => o[k], doc)[last] = v;
+};
+
+/** 칸 값만 바뀜 — 오른쪽 출력 · 합계 · 공식 값 칸만 다시(왼쪽은 그대로 → 입력 자리 안 잃음) */
+function update() {
+ res = compute(doc);
+ $("#out").innerHTML = renderOut(doc, res);
+ const t = res.tot;
+ $("#tot").innerHTML =
+ ["l", "m", "e"]
+ .map(
+ (c) =>
+ `
${{ l: "노무비", m: "재료비", e: "경비" }[c]}${fmtP(t[c])}
`,
+ )
+ .join("") + `계${fmtP(t.sum)}
`;
+ const same = expect && ["l", "m", "e", "sum"].every((c) => t[c] === expect[c]);
+ $("#cmp").innerHTML = expect
+ ? `${esc(expectFrom)} 거창 파일 ${fmtP(expect.sum)} (노 ${fmtP(expect.l)} · 재 ${fmtP(expect.m)} · 경 ${fmtP(expect.e)}) → ${same ? "같음" : "다름"}`
+ : "";
+ document.querySelectorAll("[data-val]").forEach((el) => {
+ const m = el.dataset.val.match(/^items\.(\d+)\.lines\.(\d+)\.rows\.(\d+)$/);
+ const rr = res.items[m[1]]?.lines[m[2]]?.rows?.[m[3]];
+ el.textContent = rr?.err ?? rr?.val ?? "";
+ el.classList.toggle("bad", !!rr?.err);
+ el.parentElement.querySelector(".expr")?.classList.toggle("bad", !!rr?.err);
+ });
+}
+
+/** 줄 더함 · 지움 · 옮김 등 꼴이 바뀜 — 왼쪽까지 다시 */
+function redraw() {
+ res = compute(doc);
+ $("#edit").innerHTML = renderEdit(doc, res, mine);
+ update();
+}
+
+function newLine(kind) {
+ const [k, tplId] = kind.split(":");
+ if (k === "formula") {
+ const tpl = allTemplates(mine).find((t) => t.id === tplId);
+ return {
+ k,
+ tpl: tpl ? tpl.id : null,
+ rows: tpl ? clone(tpl.rows) : [{ n: "", x: "", u: "" }],
+ open: !tpl,
+ };
+ }
+ if (k === "mach") return { k, res: { ...MACHINE[0] }, ops: [{ op: "÷", v: "Q", u: "" }] };
+ if (k === "ref") return { k, res: { ...SANGEUN[0] }, ops: [{ op: "×", v: "Q", u: "" }] };
+ if (k === "labor") return { k, res: { ...LABOR[0] }, ops: [{ op: "×", v: "1", u: "인" }] };
+ if (k === "mat")
+ return { k, col: "m", res: { ...MATERIAL[0] }, ops: [{ op: "×", v: "1", u: "" }] };
+ if (k === "rate")
+ return {
+ k,
+ name: "공구손료 및 경장비",
+ of: "노무비",
+ baseName: "",
+ baseCol: "l",
+ scope: "item",
+ rate: "3",
+ col: "m",
+ };
+ return { k: "text", text: "" };
+}
+
+function loadSample(s) {
+ doc = clone(s.doc);
+ expect = s.expect;
+ expectFrom = s.label;
+ redraw();
+}
+
+// ── 머리 단추
+$("#samples").innerHTML = SAMPLES.map(
+ (s, i) => ``,
+).join("");
+$("#samples").addEventListener("click", (e) => {
+ const i = e.target.dataset.sample;
+ if (i !== undefined) loadSample(SAMPLES[i]);
+});
+$("#save").onclick = () => {
+ localStorage.setItem(KEY_DOC, JSON.stringify({ doc, expect, expectFrom }));
+ $("#msg").textContent = "이 브라우저에 저장함";
+};
+$("#load").onclick = () => {
+ const s = JSON.parse(localStorage.getItem(KEY_DOC) || "null");
+ if (!s) return void ($("#msg").textContent = "저장한 것 없음");
+ ({ doc, expect, expectFrom } = s);
+ redraw();
+ $("#msg").textContent = "불러옴";
+};
+
+// ── 왼쪽 칸 입력
+$("#edit").addEventListener("input", (e) => {
+ const p = e.target.dataset.p;
+ if (!p) return;
+ setPath(p, e.target.type === "checkbox" ? e.target.checked : e.target.value);
+ if (e.target.type === "checkbox") return redraw();
+ update();
+ if (/\.rows\.\d+\.[nx]$/.test(p)) {
+ // 틀 이름표(「내 틀 … 에서 변경」)만 다시 — 입력 자리는 그대로
+ const lp = p.replace(/\.rows\.\d+\.[nx]$/, "");
+ const card = e.target.closest(".line");
+ const fresh = document.createElement("div");
+ fresh.innerHTML = renderEdit(doc, res, mine);
+ const [, i, j] = lp.match(/items\.(\d+)\.lines\.(\d+)/);
+ const tag = fresh.querySelector(`.line[data-loc="${i}.${j}"] .tpl`);
+ if (tag) card.querySelector(".tpl").innerHTML = tag.innerHTML;
+ }
+});
+
+$("#edit").addEventListener("change", (e) => {
+ const act = e.target.dataset.act;
+ if (!act || !e.target.value) return;
+ if (act === "addline") {
+ doc.items[e.target.dataset.i].lines.push(newLine(e.target.value));
+ return redraw();
+ }
+ if (act.startsWith("res:")) {
+ const [, kind, p] = act.split(":");
+ const list = { mach: MACHINE, ref: SANGEUN, labor: LABOR, mat: MATERIAL }[kind];
+ get(p).res = { ...list[e.target.value] };
+ return redraw();
+ }
+});
+
+$("#edit").addEventListener("click", (e) => {
+ const b = e.target.closest("button");
+ if (!b) return;
+ const { act, p, k, loc, i } = b.dataset;
+ const at = loc?.split(".").map(Number);
+ if (act === "additem") doc.items.push({ title: "", lines: [] });
+ else if (act === "delitem") doc.items.splice(Number(i), 1);
+ else if (act === "delline") doc.items[at[0]].lines.splice(at[1], 1);
+ else if (act === "addop") (get(p).ops ??= []).push({ op: "×", v: "", u: "" });
+ else if (act === "delop") get(p).ops.splice(Number(k), 1);
+ else if (act === "addrow") get(p).rows.push({ n: "", x: "", u: "" });
+ else if (act === "delrow") get(p).rows.splice(Number(k), 1);
+ else if (act === "fold") get(p).open = !get(p).open;
+ else if (act === "savetpl") {
+ const ln = get(p);
+ const name = prompt("틀 이름", "내 틀");
+ if (!name?.trim()) return;
+ const id = `my${Date.now()}`;
+ mine.push({ id, name: name.trim(), rows: clone(ln.rows).map(({ src, ...r }) => r) });
+ localStorage.setItem(KEY_TPL, JSON.stringify(mine));
+ ln.tpl = id;
+ } else if (act === "table") return openTable(p);
+ else return;
+ redraw();
+});
+
+// ── 끌어 순서 바꿈(줄 위 = 그 앞 · 항목 머리 = 그 항목 끝)
+let dragFrom = null;
+$("#edit").addEventListener("dragstart", (e) => {
+ dragFrom = e.target.dataset?.loc?.split(".").map(Number) ?? null;
+ e.dataTransfer?.setData("text/plain", "line");
+});
+$("#edit").addEventListener("dragover", (e) => {
+ if (dragFrom && e.target.closest(".line, [data-drop]")) e.preventDefault();
+});
+$("#edit").addEventListener("drop", (e) => {
+ if (!dragFrom) return;
+ e.preventDefault();
+ const [fi, fj] = dragFrom;
+ dragFrom = null;
+ const ln = doc.items[fi].lines.splice(fj, 1)[0];
+ const card = e.target.closest(".line");
+ if (card) {
+ let [ti, tj] = card.dataset.loc.split(".").map(Number);
+ if (ti === fi && tj > fj) tj--;
+ doc.items[ti].lines.splice(tj, 0, ln);
+ } else {
+ doc.items[Number(e.target.closest("[data-drop]").dataset.drop)].lines.push(ln);
+ }
+ redraw();
+});
+
+// ── 표에서 고르기(견본 표 하나) — 값 + 출처 꼬리표
+function openTable(p) {
+ const dlg = $("#tbl");
+ dlg.querySelector("h3").textContent = TABLE_E.title;
+ dlg.querySelector("table").innerHTML =
+ `${TABLE_E.head.map((h) => `| ${esc(h)} | `).join("")}
` +
+ TABLE_E.rows
+ .map(
+ (r) =>
+ `| ${esc(r[0])} | ${r
+ .slice(1)
+ .map((v, c) =>
+ v == null
+ ? " | "
+ : ` | `,
+ )
+ .join("")}
`,
+ )
+ .join("");
+ dlg.onclick = (e) => {
+ const b = e.target.closest("button[data-v]");
+ if (b) {
+ const row = get(p);
+ row.x = b.dataset.v;
+ row.src = b.dataset.src;
+ dlg.close();
+ redraw();
+ } else if (e.target.closest("[data-close]")) dlg.close();
+ };
+ dlg.showModal();
+}
+
+// ── 엑셀 글 붙여넣기
+$("#pasteGo").onclick = () => {
+ const text = $("#paste").value;
+ if (!text.trim()) return void ($("#pasteMsg").textContent = "붙일 글이 없음");
+ const { doc: d, expect: ex, stat } = parsePaste(text);
+ doc = d;
+ expect = ex;
+ expectFrom = "붙여넣은 블록";
+ redraw();
+ $("#pasteMsg").textContent =
+ `읽은 줄 ${stat.read} · 글 줄 ${stat.text}${ex ? "" : " · 머리 합계 없음(비교 안 함)"}`;
+};
+
+redraw();
diff --git a/tools/line-stack-test/calc.js b/tools/line-stack-test/calc.js
new file mode 100644
index 000000000..47acde38e
--- /dev/null
+++ b/tools/line-stack-test/calc.js
@@ -0,0 +1,182 @@
+// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · node 로도 돎)
+// 끝수 = 거창 출력과 같은 자리: 줄 값 0.1 자리 버림 · 비목 합계 원 단위 버림 · 공식 변수(식인 줄) 소수 둘째 반올림
+export const COLS = ["l", "m", "e"];
+export const COL_NAME = { l: "노무비", m: "재료비", e: "경 비" };
+
+const TOKEN = /\s*(?:(\d[\d,]*\.?\d*|\.\d+)|([A-Za-z가-힣_][A-Za-z0-9가-힣_]*)|([+\-*/×÷^()]))/y;
+
+export function tokenize(s) {
+ const out = [];
+ TOKEN.lastIndex = 0;
+ const src = String(s ?? "").trim();
+ while (TOKEN.lastIndex < src.length) {
+ const at = TOKEN.lastIndex;
+ const m = TOKEN.exec(src);
+ if (!m || m.index !== at) throw new Error(`읽을 수 없는 글자: ${src.slice(at).trim()[0]}`);
+ if (m[1] !== undefined) out.push({ t: "num", v: Number(m[1].replace(/,/g, "")) });
+ else if (m[2] !== undefined) out.push({ t: "var", n: m[2] });
+ else out.push({ t: "op", v: m[3] });
+ if (TOKEN.lastIndex >= src.length || !src.slice(TOKEN.lastIndex).trim()) break;
+ }
+ return out;
+}
+
+/** 식 → 나무(괄호는 그대로 남겨 화면에 쓴 꼴대로 보임) */
+export function parse(s) {
+ const tk = tokenize(s);
+ if (!tk.length) throw new Error("빈 식");
+ let i = 0;
+ const peek = () => tk[i];
+ const isOp = (...ops) => peek()?.t === "op" && ops.includes(peek().v);
+ const add = () => {
+ let a = mul();
+ while (isOp("+", "-")) a = { t: "bin", op: tk[i++].v, a, b: mul() };
+ return a;
+ };
+ const mul = () => {
+ let a = unary();
+ while (isOp("*", "/", "×", "÷")) a = { t: "bin", op: tk[i++].v, a, b: unary() };
+ return a;
+ };
+ const unary = () => (isOp("-") ? (i++, { t: "neg", a: unary() }) : pow());
+ const pow = () => {
+ const a = atom();
+ return isOp("^") ? (i++, { t: "bin", op: "^", a, b: unary() }) : a;
+ };
+ const atom = () => {
+ const k = tk[i++];
+ if (!k) throw new Error("식이 덜 끝남");
+ if (k.t === "num" || k.t === "var") return k;
+ if (k.v === "(") {
+ const a = add();
+ if (!isOp(")")) throw new Error("괄호가 안 닫힘");
+ i++;
+ return { t: "par", a };
+ }
+ throw new Error(`「${k.v}」 자리가 틀림`);
+ };
+ const ast = add();
+ if (i < tk.length) throw new Error(`「${tk[i].v ?? tk[i].n}」 앞에서 식이 끊김`);
+ return ast;
+}
+
+export function varsIn(ast, out = []) {
+ if (!ast) return out;
+ if (ast.t === "var") out.includes(ast.n) || out.push(ast.n);
+ for (const k of ["a", "b"]) if (ast[k]) varsIn(ast[k], out);
+ return out;
+}
+
+export function evalAst(ast, vars) {
+ switch (ast.t) {
+ case "num":
+ return ast.v;
+ case "var":
+ if (!(ast.n in vars)) throw new Error(`없는 이름: ${ast.n}`);
+ return vars[ast.n];
+ case "par":
+ return evalAst(ast.a, vars);
+ case "neg":
+ return -evalAst(ast.a, vars);
+ }
+ const a = evalAst(ast.a, vars);
+ const b = evalAst(ast.b, vars);
+ if (ast.op === "+") return a + b;
+ if (ast.op === "-") return a - b;
+ if (ast.op === "*" || ast.op === "×") return a * b;
+ if (ast.op === "^") return a ** b;
+ return a / b;
+}
+
+export const floor1 = (x) => Math.floor(x * 10 + 1e-6) / 10;
+export const floor0 = (x) => Math.floor(x + 1e-6);
+export const round1 = (x) => Math.round(x * 10) / 10;
+export const roundTo = (x, d) => Math.round(x * 10 ** d + 1e-9) / 10 ** d;
+
+/** 곱나눔 꼬리 하나 값(% 면 100 으로 나눔) */
+export function opValue(op, vars) {
+ const v = evalAst(parse(op.v), vars);
+ return op.u?.trim() === "%" ? v / 100 : v;
+}
+
+export function applyOps(x, ops, vars) {
+ for (const op of ops ?? []) {
+ const v = opValue(op, vars);
+ x = op.op === "×" || op.op === "*" ? x * v : x / v;
+ }
+ return x;
+}
+
+const isPlain = (ast) => ast.t === "num" || (ast.t === "neg" && ast.a.t === "num");
+
+/** 단가산출 하나를 셈 — 줄마다 결과 · 항목 소계 · 합계 항(경쟁사 꼴) · 비목 합계 */
+export function compute(doc) {
+ const vars = {};
+ const terms = { l: [], m: [], e: [] };
+ const all = { l: 0, m: 0, e: 0 };
+ const items = [];
+ const errors = [];
+ for (const item of doc.items ?? []) {
+ const it = { lines: [], sum: { l: 0, m: 0, e: 0 }, terms: { l: [], m: [], e: [] } };
+ it.hasSub = (item.lines ?? []).some((ln) => ln.k === "mach" || ln.k === "ref");
+ const put = (col, val, r) => {
+ it.sum[col] = round1(it.sum[col] + val);
+ it.terms[col].push(val);
+ r.cols[col] = val;
+ };
+ for (const ln of item.lines ?? []) {
+ const r = { cols: {}, err: null, vars: { ...vars } };
+ try {
+ if (ln.k === "formula") {
+ r.rows = (ln.rows ?? []).map((row) => calcRow(row, vars));
+ } else if (ln.k === "mach" || ln.k === "ref") {
+ for (const c of COLS) {
+ const price = Number(ln.res?.[c]) || 0;
+ if (price) put(c, floor1(applyOps(price, ln.cops?.[c] ?? ln.ops, vars)), r);
+ }
+ } else if (ln.k === "labor" || ln.k === "mat") {
+ const col = ln.k === "labor" ? "l" : ln.col || "m";
+ put(col, floor1(applyOps(Number(ln.res?.price) || 0, ln.ops, vars)), r);
+ } else if (ln.k === "rate") {
+ const bc = ln.baseCol || "l";
+ const base = round1(ln.scope === "all" ? all[bc] + it.sum[bc] : it.sum[bc]);
+ r.base = base;
+ if (ln.baseName && ln.scope !== "all") it.terms[bc] = base ? [base] : [];
+ put(ln.col || "m", floor1((base * Number(ln.rate || 0)) / 100), r);
+ }
+ } catch (e) {
+ r.err = e.message;
+ errors.push(e.message);
+ }
+ it.lines.push(r);
+ }
+ for (const c of COLS) {
+ all[c] = round1(all[c] + it.sum[c]);
+ terms[c].push(...(it.hasSub ? (it.sum[c] ? [it.sum[c]] : []) : it.terms[c]));
+ }
+ items.push(it);
+ }
+ const tot = { l: floor0(all.l), m: floor0(all.m), e: floor0(all.e) };
+ tot.sum = tot.l + tot.m + tot.e;
+ return { items, terms, all, tot, errors, vars };
+}
+
+/** 공식 줄 한 칸 — 「이름 = 식」 · 없는 이름은 unknown 으로(빨강) */
+export function calcRow(row, vars) {
+ const r = { val: null, err: null, unknown: [], env: { ...vars } };
+ if (!row.x?.toString().trim()) return r;
+ try {
+ r.ast = parse(row.x);
+ r.unknown = varsIn(r.ast).filter((n) => !(n in vars));
+ if (r.unknown.length) {
+ r.err = `없는 이름: ${r.unknown.join(", ")}`;
+ return r;
+ }
+ const v = evalAst(r.ast, vars);
+ r.val = isPlain(r.ast) ? v : roundTo(v, row.d ?? 2);
+ if (row.n?.trim()) vars[row.n.trim()] = r.val;
+ } catch (e) {
+ r.err = e.message;
+ }
+ return r;
+}
diff --git a/tools/line-stack-test/check.mjs b/tools/line-stack-test/check.mjs
new file mode 100644
index 000000000..7fe0fd767
--- /dev/null
+++ b/tools/line-stack-test/check.mjs
@@ -0,0 +1,11 @@
+// node tools/line-stack-test/check.mjs — 견본 셋 금액 = 거창 출력 숫자(원 단위)
+import { compute } from "./calc.js";
+import { SAMPLES } from "./samples.js";
+let bad = 0;
+for (const s of SAMPLES.filter((x) => x.expect)) {
+ const { tot, errors } = compute(s.doc);
+ const ok = ["l", "m", "e", "sum"].every((c) => tot[c] === s.expect[c]) && !errors.length;
+ if (!ok) bad++;
+ console.log(ok ? "같음" : "다름", s.label, JSON.stringify(tot), errors.join(" "));
+}
+process.exit(bad ? 1 : 0);
diff --git a/tools/line-stack-test/edit.js b/tools/line-stack-test/edit.js
new file mode 100644
index 000000000..d8f9cce74
--- /dev/null
+++ b/tools/line-stack-test/edit.js
@@ -0,0 +1,165 @@
+// 줄 쌓기 단가산출 시험 — 왼쪽 줄 쌓기 편집(종류마다 필요한 칸만 · 칸은 data-p 경로로 doc 에 바로 씀)
+import { LABOR, MACHINE, MATERIAL, SANGEUN, TEMPLATES } from "./samples.js";
+import { esc } from "./render.js";
+
+export const KINDS = {
+ mach: "① 기계",
+ ref: "② 다른 단산 참조",
+ labor: "③ 인력",
+ rate: "④ 율",
+ mat: "⑤ 재료",
+ formula: "⑥ 공식",
+ text: "⑦ 글 · 근거",
+};
+const COLSEL = { l: "노무비", m: "재료비", e: "경비" };
+
+const inp = (p, v, cls = "", ph = "") =>
+ ``;
+const sel = (p, v, opts) =>
+ ``;
+const pick = (list, act, label) =>
+ ``;
+
+export function allTemplates(mine) {
+ return [...TEMPLATES, ...mine];
+}
+
+/** 「+ 줄」 고르개 — 공식은 틀마다 하나씩 */
+export function addLineSelect(i, mine) {
+ const tpls = allTemplates(mine)
+ .map((t) => ``)
+ .join("");
+ return ``;
+}
+
+function opsEdit(p, ops) {
+ const one = (o, k) =>
+ `${sel(`${p}.ops.${k}.op`, o.op, { "×": "×", "÷": "÷", "/": "/" })}${inp(
+ `${p}.ops.${k}.v`,
+ o.v,
+ "v",
+ "값 · 이름",
+ )}${inp(`${p}.ops.${k}.u`, o.u, "u", "단위")}`;
+ return `${(ops ?? []).map(one).join("")}
`;
+}
+
+const copsNote = (ln) =>
+ ln.cops
+ ? `비목 따로 꼬리(붙여넣기): ${Object.entries(ln.cops)
+ .map(
+ ([c, ops]) =>
+ `${COLSEL[c]} ${ops.map((o) => `${o.op} ${esc(o.v)} ${esc(o.u)}`).join(" ")}`,
+ )
+ .join(" · ")}
`
+ : "";
+
+/** 공식 줄 — 접힘 = 값 칸만 · 풀림 = 「이름 = 식」 글 줄 전부 */
+function formulaEdit(p, ln, r, mine) {
+ const tpl = allTemplates(mine).find((t) => t.id === ln.tpl);
+ const changed = tpl && JSON.stringify(shape(tpl.rows)) !== JSON.stringify(shape(ln.rows));
+ const label = !tpl
+ ? ln.tpl === null && !ln.rows?.some((x) => x.x)
+ ? "새 공식"
+ : "내 공식"
+ : changed
+ ? `내 틀 (${esc(tpl.name)} 에서 변경)`
+ : esc(tpl.name);
+ const rows = (ln.rows ?? [])
+ .map((row, k) => {
+ const rr = r?.rows?.[k] ?? {};
+ const rp = `${p}.rows.${k}`;
+ const plain = /^-?[\d.]+$/.test(String(row.x ?? "").trim());
+ const val = `${rr.err ? esc(rr.err) : (rr.val ?? "")}`;
+ const src = row.src ? `표` : "";
+ const table = ``;
+ if (!ln.open) {
+ if (plain || !row.x)
+ return `${esc(row.n || "?")} = ${inp(`${rp}.x`, row.x, "num")} ${esc(row.u)} ${table}${src}
`;
+ return `${esc(row.n)} = ${esc(row.x)} = ${val} ${esc(row.u)}
`;
+ }
+ return `${inp(`${rp}.n`, row.n, "name", "이름")} = ${inp(`${rp}.x`, row.x, `expr ${rr.err ? "bad" : ""}`, "값 또는 식")}${inp(
+ `${rp}.u`,
+ row.u,
+ "u",
+ "단위",
+ )}${val}${table}${src}
`;
+ })
+ .join("");
+ return `${label}
+
+
${rows}
+ ${ln.open ? `` : ""}`;
+}
+
+/** 틀과 견줄 꼴 — 값 줄은 이름만 · 식 줄은 이름과 식 */
+export function shape(rows) {
+ return (rows ?? []).map((x) =>
+ /^-?[\d.]+$/.test(String(x.x).trim()) ? [x.n] : [x.n, String(x.x).replace(/\s/g, "")],
+ );
+}
+
+function lineBody(p, ln, r, mine) {
+ const price3 = () =>
+ `노무 ${inp(`${p}.res.l`, ln.res?.l, "num")} 재료 ${inp(`${p}.res.m`, ln.res?.m, "num")} 경비 ${inp(
+ `${p}.res.e`,
+ ln.res?.e,
+ "num",
+ )}
`;
+ switch (ln.k) {
+ case "mach":
+ return `${pick(MACHINE, `res:mach:${p}`, "견본 기계 ▾")}${inp(`${p}.res.name`, ln.res?.name, "wide", "기계 이름")}
${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
+ case "ref":
+ return `${pick(SANGEUN, `res:ref:${p}`, "견본 단산 ▾")} 단산 ${inp(`${p}.res.no`, ln.res?.no, "num s")}${inp(
+ `${p}.res.name`,
+ ln.res?.name,
+ "wide",
+ "단가산출 이름",
+ )}
${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
+ case "labor":
+ case "mat":
+ return `${pick(ln.k === "labor" ? LABOR : MATERIAL, `res:${ln.k}:${p}`, ln.k === "labor" ? "견본 노임 ▾" : "견본 자재 ▾")}${inp(
+ `${p}.res.name`,
+ ln.res?.name,
+ "wide",
+ "이름",
+ )} 단가 ${inp(`${p}.res.price`, ln.res?.price, "num")}${ln.k === "mat" ? ` → ${sel(`${p}.col`, ln.col || "m", COLSEL)}` : ""}
${opsEdit(p, ln.ops)}`;
+ case "rate":
+ return `${inp(`${p}.name`, ln.name, "wide", "이름(예: 공구손료 및 경장비)")}
+ 기준 ${sel(`${p}.scope`, ln.scope || "item", { item: "이 항목 위 줄", all: "위 전부" })} 의 ${sel(`${p}.baseCol`, ln.baseCol || "l", COLSEL)}
+ × ${inp(`${p}.rate`, ln.rate, "num s")} % → ${sel(`${p}.col`, ln.col || "m", COLSEL)}
+ 글 「( ${inp(`${p}.of`, ln.of, "s2", "노무비")}의 % )」 · 소계 이름 ${inp(`${p}.baseName`, ln.baseName, "s2", "비우면 안 찍음")}
`;
+ case "formula":
+ return formulaEdit(p, ln, r, mine);
+ case "text":
+ return inp(`${p}.text`, ln.text, "full", "< 품셈 근거 > · ※ 메모 · 별산");
+ }
+ return "";
+}
+
+export function renderEdit(doc, res, mine) {
+ const items = (doc.items ?? [])
+ .map((item, i) => {
+ const lines = (item.lines ?? [])
+ .map((ln, j) => {
+ const p = `items.${i}.lines.${j}`;
+ const r = res.items[i]?.lines[j];
+ return `⠿${KINDS[ln.k]}${
+ r?.err ? `${esc(r.err)}` : ""
+ }
${lineBody(p, ln, r, mine)}
`;
+ })
+ .join("");
+ return `${inp(`items.${i}.title`, item.title, "title", "항목 이름(비우면 번호 없음)")}
+ ${addLineSelect(i, mine)}
+
${lines || '
줄 없음 — [+ 줄 ▾]
'}
`;
+ })
+ .join("");
+ return `이름 ${inp("name", doc.name, "wide")} 단위 ${inp("unit", doc.unit, "s")}
${items}`;
+}
diff --git a/tools/line-stack-test/index.html b/tools/line-stack-test/index.html
new file mode 100644
index 000000000..9399c3535
--- /dev/null
+++ b/tools/line-stack-test/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ 시험: 줄 쌓기 단가산출
+
+
+
+
+
+ 시험: 줄 쌓기 단가산출
+ 서버 없음 · 저장은 이 브라우저에만 · 마음에 안 들면 폴더째 지움
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/line-stack-test/paste.js b/tools/line-stack-test/paste.js
new file mode 100644
index 000000000..8118e1b6f
--- /dev/null
+++ b/tools/line-stack-test/paste.js
@@ -0,0 +1,257 @@
+import { compute } from "./calc.js";
+// 줄 쌓기 단가산출 시험 — 엑셀 「단가산출」 블록 글 → 줄(일곱 종류로 읽히는 만큼 · 못 읽는 줄은 글 줄)
+const NUM = /^-?[\d,]*\.?\d+\.?$|^-?\d[\d,]*\.$/;
+const isNum = (t) => NUM.test(t);
+const num = (t) => Number(String(t).replace(/,/g, "").replace(/\.$/, ""));
+const clean = (t) =>
+ String(t)
+ .replace(/(\d)\.(?!\d)/g, "$1")
+ .replace(/,(?=\d{3})/g, "");
+const OPS = ["×", "÷", "/", "*"];
+const KEY = { 노무비: "l", 재료비: "m", 경비: "e" };
+
+/** 「× 4 인 ÷ 170 ㎡」 → 꼬리 [{op, v, u}] · 괄호 등 못 읽으면 null */
+export function parseOps(s, known = {}) {
+ const tk = s.trim().split(/\s+/).filter(Boolean);
+ const ops = [];
+ let i = 0;
+ while (i < tk.length) {
+ const op = tk[i] === "*" ? "×" : tk[i];
+ let v = tk[i + 1];
+ if (!OPS.includes(tk[i]) || v === undefined) return null;
+ if (isNum(v)) {
+ v = clean(v);
+ // 값이 위 공식의 Q · QH 와 같으면 이름으로 이음(틀을 고치면 따라 바뀜)
+ const hit = Object.entries(known).find(([n, val]) => /^Q/.test(n) && String(val) === v);
+ if (hit) v = hit[0];
+ } else if (!/^[A-Za-z]\w*$/.test(v)) return null;
+ i += 2;
+ const u = [];
+ while (i < tk.length && !OPS.includes(tk[i])) u.push(tk[i++]);
+ if (u.some((x) => /[()]/.test(x))) return null;
+ ops.push({ op, v, u: u.join(" ") });
+ }
+ return ops;
+}
+
+/** 「q = 0.7 ㎥ K = 0.55 f = 1. / 1.325 = 0.75」 → 줄 [{n, x, u}] */
+export function parseDefs(line) {
+ const re = /(^|\s)([A-Za-z][A-Za-z0-9]*)\s*=\s/g;
+ const starts = [];
+ let m;
+ while ((m = re.exec(line))) {
+ const before = line.slice(starts.length ? starts.at(-1).body : 0, m.index).trim();
+ if (starts.length && /[+\-×÷*/(]$/.test(before)) continue;
+ starts.push({ n: m[2], at: m.index, body: m.index + m[0].length });
+ }
+ return starts.map((s, i) => {
+ const seg = line.slice(s.body, starts[i + 1]?.at ?? line.length).trim();
+ const parts = seg.split(/\s=\s/);
+ const last = parts.at(-1).trim();
+ const lm = last.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
+ if (parts.length === 1)
+ return { n: s.n, x: lm ? clean(lm[1]) : clean(last), u: lm ? lm[2] : "" };
+ return { n: s.n, x: clean(parts[0]), u: lm ? lm[2] : "", got: lm ? num(lm[1]) : null };
+ });
+}
+
+/** 율 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」 → 율 줄 */
+function rateHead(s, next) {
+ const m = s.match(/^(.*?)\s*\(\s*(\S+?)의\s*([\d.]+)\s*%\s*\)\s*$/);
+ if (!m || !/×\s*[\d.]+\s*%\s*=/.test(next ?? "")) return null;
+ const bc = /노무/.test(m[2]) ? "l" : /경비/.test(m[2]) ? "e" : "m";
+ return { k: "rate", name: m[1], of: m[2], baseCol: bc, rate: String(num(m[3])), col: "m" };
+}
+
+/** 분수 세 줄(분자 · 「Q = ─ … = 23.39 ㎥ / hr」 · 분모) → 줄 하나 · 기호 반 · 값 반으로 나눔 */
+function parseFrac(top, mid, bot) {
+ const mm = mid.match(/^([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*)$/);
+ if (!mm) return null;
+ const parts = mm[2].split(/\s=\s/);
+ const sym = parts[0];
+ const groups = sym.match(/─(\s*─)*/g) ?? [];
+ const half = (s) => {
+ const t = s.trim().split(/\s+/);
+ return t.length % 2 ? null : t.slice(0, t.length / 2);
+ };
+ const a = half(top);
+ const b = half(bot);
+ if (!a || !b || !groups.length) return null;
+ if (groups.length > 1 && (a.length !== groups.length || b.length !== groups.length)) return null;
+ let k = 0;
+ const x = sym.replace(/─(\s*─)*/g, () => {
+ const [na, nb] = groups.length > 1 ? [a[k], b[k]] : [a.join(" "), b.join(" ")];
+ k++;
+ return `(${na}) / (${nb})`;
+ });
+ const tail = parts
+ .at(-1)
+ .trim()
+ .match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
+ return {
+ n: mm[1],
+ x: clean(x),
+ u: tail && parts.length > 1 ? tail[2] : "",
+ got: tail ? num(tail[1]) : null,
+ };
+}
+
+export function parsePaste(text) {
+ const lines = String(text)
+ .replace(/\t/g, " ")
+ .split(/\r?\n/)
+ .map((s) => s.replace(/\s+/g, " ").trim());
+ const doc = { name: "붙여넣은 단가산출", unit: "", items: [] };
+ let expect = null;
+ let item = null;
+ let summary = false;
+ let pendingBase = "";
+ let skipRate = false;
+ const known = {};
+ const sums = { l: [], m: [], e: [] };
+ const stat = { read: 0, text: 0 };
+ const cur = () => item ?? ((item = { title: "", lines: [] }), doc.items.push(item), item);
+ const add = (ln) => (cur().lines.push(ln), ln.k === "text" ? stat.text++ : stat.read++);
+ const formula = () => {
+ const last = cur().lines.at(-1);
+ if (last?.k === "formula") return last;
+ const f = { k: "formula", tpl: null, rows: [] };
+ add(f);
+ return f;
+ };
+ const addRow = (r) => {
+ formula().rows.push({ n: r.n, x: r.x, u: r.u });
+ if (r.got != null) known[r.n] = String(r.got);
+ else if (isNum(r.x)) known[r.n] = r.x;
+ };
+ for (let i = 0; i < lines.length; i++) {
+ const s = lines[i];
+ if (!s || /^단가산출$|^산 ?출 ?근 ?거/.test(s)) continue;
+ let m = s.match(/^(\d+)\.(\S.*?)\[(.+?)\]\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*$/);
+ if (m && !doc.items.length) {
+ doc.name = m[2].trim();
+ doc.unit = m[3];
+ if (m[4])
+ expect = { sum: num(m[4]), l: num(m[5] ?? 0), m: num(m[6] ?? 0), e: num(m[7] ?? 0) };
+ continue;
+ }
+ // 분수 세 줄
+ if (lines[i + 1]?.includes("─")) {
+ const r = parseFrac(s, lines[i + 1], lines[i + 2] ?? "");
+ if (r) {
+ addRow(r);
+ i += 2;
+ continue;
+ }
+ for (const t of [s, lines[i + 1], lines[i + 2] ?? ""]) add({ k: "text", text: t });
+ i += 2;
+ continue;
+ }
+ if ((m = s.match(/^(\d+)\s*\.\s+(.*)$/)) || (m = s.match(/^([가-하])\s*\.\s+(.*)$/))) {
+ summary = /^(합계|소계)$/.test(m[2].replace(/\s/g, ""));
+ if (summary) continue;
+ item = { title: m[2], lines: [], ...(/[가-하]/.test(m[1]) ? { sub: true } : {}) };
+ doc.items.push(item);
+ const rm = rateHead(m[2], lines[i + 1]);
+ if (rm) {
+ add({ ...rm, name: "", scope: "all", baseName: "" });
+ pendingBase = "";
+ i++;
+ }
+ continue;
+ }
+ const key = s.split(":")[0].replace(/\s/g, "");
+ if (summary && /^(노무비|재료비|경비|계)$/.test(key)) {
+ if (KEY[key]) {
+ const nums =
+ s
+ .slice(s.indexOf(":") + 1)
+ .split("=")[0]
+ .match(/[\d,]+\.?\d*/g) ?? [];
+ sums[KEY[key]].push(...nums.map(num));
+ }
+ continue;
+ }
+ if (/^소계$/.test(key)) continue;
+ if (s.includes(":") && /소계$/.test(key)) {
+ pendingBase = key.replace(/소계$/, "");
+ continue;
+ }
+ // 율 줄 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」
+ const rm = rateHead(s, lines[i + 1]);
+ if (rm) {
+ add({ ...rm, baseName: pendingBase, scope: "item" });
+ pendingBase = "";
+ i++;
+ continue;
+ }
+ // 「q = 0.7 K = 0.55」 값 · 식 줄
+ if (/^[A-Za-z][A-Za-z0-9]*\s*=\s/.test(s) && !s.includes(":")) {
+ parseDefs(s).forEach(addRow);
+ continue;
+ }
+ // 「노무비 : 59,020 ÷ 23.39 = 2,523.3」 셋 → 기계 · 참조 줄
+ if (KEY[key] && s.includes(":")) {
+ const rhs = s
+ .slice(s.indexOf(":") + 1)
+ .split("=")[0]
+ .trim();
+ const pm = rhs.match(/^([\d,]+\.?)\s+(.*)$/);
+ const ops = pm && parseOps(pm[2], known);
+ if (!pm || !ops) {
+ if (rhs) add({ k: "text", text: s });
+ continue;
+ }
+ const c = KEY[key];
+ let last = cur().lines.at(-1);
+ if (!(
+ last &&
+ (last.k === "mach" || last.k === "ref") &&
+ !(c in last.res) &&
+ last.res._open
+ )) {
+ const ref = (item?.title ?? "").match(/단산\s*(\d+)\s*참조/);
+ last = ref
+ ? { k: "ref", res: { no: Number(ref[1]), name: item.title, _open: true }, ops }
+ : { k: "mach", res: { name: item?.title ?? "", _open: true }, ops };
+ add(last);
+ }
+ last.res[c] = num(pm[1]);
+ if (JSON.stringify(ops) !== JSON.stringify(last.ops)) (last.cops ??= {})[c] = ops;
+ continue;
+ }
+ // 「치즐 ( 223,000. × 0.006 ) ÷ 5.」 → 괄호 걷음(곱나눔 차례 그대로라 값 같음)
+ const s2 = s.replace(/\(\s*([\d,]+\.?\s+[×÷][^()]*?)\s*\)/, "$1");
+ // 「보통인부 : 172,068 ÷ 8. ÷ 23.39 = 919.5」 → 인력
+ if ((m = s2.match(/^([^:]+?)\s*:\s*([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/))) {
+ const ops = parseOps(m[3], known);
+ if (ops) {
+ add({ k: "labor", res: { name: m[1], price: num(m[2]) }, ops });
+ continue;
+ }
+ }
+ // 「합판(내수),대전 (…) 11,455. × 1.03 ㎡ × 38. % = 4,483.4」 → 재료
+ if ((m = s2.match(/^(.*\S)\s+([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/)) && !s.includes(":")) {
+ const ops = parseOps(m[3], known);
+ if (ops) {
+ add({ k: "mat", col: "m", res: { name: m[1], price: num(m[2]) }, ops });
+ continue;
+ }
+ }
+ add({ k: "text", text: s });
+ }
+ for (const it of doc.items) for (const ln of it.lines) if (ln.res) delete ln.res._open;
+ // 율 줄이 든 비목 = 합계 줄에 그 값이 놓인 칸
+ const res = compute(doc);
+ doc.items.forEach((it, ii) =>
+ it.lines.forEach((ln, li) => {
+ if (ln.k !== "rate") return;
+ const v = Object.values(res.items[ii].lines[li].cols)[0];
+ const col =
+ ["m", "e", "l"].find((c) => sums[c].includes(v)) ??
+ ["m", "e", "l"].find((c) => sums[c].includes(Math.floor(v)));
+ if (col) ln.col = col;
+ }),
+ );
+ return { doc, expect, stat };
+}
diff --git a/tools/line-stack-test/render.js b/tools/line-stack-test/render.js
new file mode 100644
index 000000000..9335d4667
--- /dev/null
+++ b/tools/line-stack-test/render.js
@@ -0,0 +1,181 @@
+// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
+import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
+
+export const esc = (s) =>
+ String(s ?? "").replace(
+ /[&<>"]/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
+ );
+export const fmtP = (n) => Math.round(n).toLocaleString("en-US");
+export const fmt1 = (n) =>
+ n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
+export const fmtV = (n) =>
+ n == null || Number.isNaN(n) ? "?" : n.toLocaleString("en-US", { maximumFractionDigits: 4 });
+const OP = { "*": "×", "/": "/", "×": "×", "÷": "÷", "+": "+", "-": "-", "^": "^" };
+
+/** 식 나무 → 글(sym = 기호 · val = 값) · 기호가 든 식의 「/」 는 분수로 */
+export function showAst(ast, mode, env, frac = varsIn(ast).length > 0) {
+ const go = (a) => showAst(a, mode, env, frac);
+ const bare = (a) => (a.t === "par" ? go(a.a) : go(a));
+ switch (ast.t) {
+ case "num":
+ return fmtV(ast.v);
+ case "var":
+ return mode === "sym"
+ ? esc(ast.n)
+ : ast.n in env
+ ? fmtV(env[ast.n])
+ : `${esc(ast.n)}`;
+ case "par":
+ return `( ${go(ast.a)} )`;
+ case "neg":
+ return `-${go(ast.a)}`;
+ }
+ if (ast.op === "/" && frac)
+ return `${bare(ast.a)}${bare(ast.b)}`;
+ return `${go(ast.a)} ${OP[ast.op]} ${go(ast.b)}`;
+}
+
+const opsText = (ops, env) =>
+ (ops ?? [])
+ .map((o) => {
+ let v;
+ try {
+ v = showAst(parse(o.v), "val", env);
+ } catch {
+ v = `${esc(o.v)}`;
+ }
+ return `${esc(OP[o.op] ?? o.op)} ${v}${o.u ? " " + esc(o.u) : ""}`;
+ })
+ .join(" ");
+
+/** 공식 줄 — 값 줄은 넷까지 한 줄에 · 식 줄은 「이름 = 기호식 = 값식 = 결과 단위」 */
+function formulaRows(ln, r, push) {
+ let pack = [];
+ const flush = () =>
+ pack.length && (push({ h: pack.join('') }), (pack = []));
+ (ln.rows ?? []).forEach((row, i) => {
+ const rr = r.rows?.[i];
+ if (!row.n?.trim() && !row.x?.toString().trim()) return;
+ const u = row.u ? " " + esc(row.u) : "";
+ const name = esc(row.n || "?");
+ if (!rr?.ast || rr.err) {
+ flush();
+ return push({ h: `${name} = ${esc(row.x)} ${esc(rr?.err ?? "")}` });
+ }
+ const plain = rr.ast.t === "num";
+ const hasVar = varsIn(rr.ast).length > 0;
+ if (!hasVar) {
+ pack.push(
+ `${name} = ${showAst(rr.ast, "sym", rr.env)}${plain ? "" : " = " + fmtV(rr.val)}${u}`,
+ );
+ if (pack.length >= 4) flush();
+ return;
+ }
+ flush();
+ push({
+ h: `${name} = ${showAst(rr.ast, "sym", rr.env)} = ${showAst(rr.ast, "val", rr.env)} = ${fmtV(rr.val)}${u}`,
+ tall: /class="fr"/.test(showAst(rr.ast, "sym", rr.env)),
+ });
+ });
+ flush();
+}
+
+/** 출력 줄 목록 — {h, cols?, cls?} */
+export function outRows(doc, res) {
+ const rows = [];
+ const push = (r) => rows.push(r);
+ push({
+ h: `${esc(doc.name)}${doc.unit ? ` [${esc(doc.unit)}]` : ""}`,
+ cols: { ...res.tot },
+ cls: "head",
+ won: true,
+ });
+ let n = 0;
+ let sub = 0;
+ (doc.items ?? []).forEach((item, ii) => {
+ const it = res.items[ii];
+ if (item.title?.trim()) {
+ const label = item.sub ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
+ if (!item.sub) sub = 0;
+ push({ h: label + esc(item.title), cls: item.sub ? "item sub" : "item" });
+ }
+ // 율 줄이 소계 이름을 가지면 그 앞 같은 비목 줄은 칸 값을 안 찍음(소계 줄이 찍음)
+ const hideCol = (li, col) =>
+ item.lines.some(
+ (x, j) =>
+ j > li && x.k === "rate" && x.baseName && x.scope !== "all" && (x.baseCol || "l") === col,
+ );
+ item.lines.forEach((ln, li) => {
+ const r = it.lines[li];
+ if (r.err) return push({ h: `${esc(r.err)}`, err: true });
+ if (ln.k === "text") return push({ h: esc(ln.text) });
+ if (ln.k === "formula") return formulaRows(ln, r, push);
+ if (ln.k === "mach" || ln.k === "ref") {
+ for (const c of COLS) {
+ if (r.cols[c] === undefined) continue;
+ push({
+ h: `${COL_NAME[c]} : ${fmtP(ln.res[c])} ${opsText(ln.cops?.[c] ?? ln.ops, r.vars)} = ${fmt1(r.cols[c])}`,
+ });
+ }
+ return;
+ }
+ if (ln.k === "labor" || ln.k === "mat") {
+ const col = ln.k === "labor" ? "l" : ln.col || "m";
+ const v = r.cols[col];
+ const sep = ln.k === "labor" ? " :" : "";
+ return push({
+ h: `${esc(ln.res?.name)}${sep} ${fmtP(Number(ln.res?.price) || 0)} ${opsText(ln.ops, r.vars)} = ${fmt1(v)}`,
+ cols: hideCol(li, col) ? null : { sum: v, [col]: v },
+ });
+ }
+ if (ln.k === "rate") {
+ const col = ln.col || "m";
+ const bc = ln.baseCol || "l";
+ if (ln.baseName && ln.scope !== "all")
+ push({
+ h: `${esc(ln.baseName)}소계 : ${fmt1(r.base)}`,
+ cols: { sum: r.base, [bc]: r.base },
+ cls: "subsum",
+ });
+ push({ h: `${esc(ln.name)} ( ${esc(ln.of || COL_NAME[bc])}의 ${esc(ln.rate)} % )` });
+ push({
+ h: `${fmt1(r.base)} × ${esc(ln.rate)} % = ${fmt1(r.cols[col])}`,
+ cols: { sum: r.cols[col], [col]: r.cols[col] },
+ });
+ }
+ });
+ if (it.hasSub && COLS.some((c) => it.sum[c])) {
+ const s = Math.round((it.sum.l + it.sum.m + it.sum.e) * 10) / 10;
+ push({ h: `소 계 : ${fmt1(s)}`, cols: { sum: s, ...it.sum }, cls: "subsum" });
+ }
+ });
+ push({ h: `${++n}. 합 계`, cls: "item" });
+ for (const c of COLS) {
+ const t = res.terms[c];
+ const body =
+ t.length > 1
+ ? `${t.map(fmt1).join(" + ")} = ${fmtP(res.tot[c])}`
+ : t.length
+ ? fmtP(res.tot[c])
+ : "";
+ push({ h: `${COL_NAME[c]} : ${body}` });
+ }
+ push({ h: `계 : ${fmtP(res.tot.sum)}`, cols: { ...res.tot }, cls: "total", won: true });
+ return rows;
+}
+
+export function renderOut(doc, res) {
+ const cell = (r, k) => {
+ const v = r.cols?.[k];
+ if (v === undefined || v === null || (r.won && !v && k !== "sum")) return " | ";
+ return `${r.won ? fmtP(v) : fmt1(v)} | `;
+ };
+ const body = outRows(doc, res)
+ .map(
+ (r) =>
+ `| ${r.h} | ${["sum", ...COLS].map((k) => cell(r, k)).join("")}
`,
+ )
+ .join("");
+ return `| 산 출 근 거 | 합 계 | 노무비 | 재료비 | 경 비 |
${body}
`;
+}
diff --git a/tools/line-stack-test/samples.js b/tools/line-stack-test/samples.js
new file mode 100644
index 000000000..4e5d9eb69
--- /dev/null
+++ b/tools/line-stack-test/samples.js
@@ -0,0 +1,221 @@
+// 줄 쌓기 단가산출 시험 — 견본 자원 · 공식 틀 · 견본 표 · 견본 단가산출(거창 산불 출력 「단가산출」 시트 1 · 41 · 15 번)
+
+export const LABOR = [
+ { name: "보통인부", price: 172068 },
+ { name: "형틀목공", price: 275790 },
+ { name: "석공", price: 268908 },
+];
+
+export const MATERIAL = [
+ { name: "합판(내수),대전 (12t×1220×2440mm)", price: 11455 },
+ { name: "외송각재,대전 (30×30×3600mm)", price: 586824 },
+ { name: "치즐(0.7㎥)", price: 223000 },
+];
+
+/** 기계 = 시간당 기계경비 노무 · 재료 · 경비 */
+export const MACHINE = [
+ { name: "굴삭기(무한궤도) (0.7㎥)", l: 59020, m: 19473, e: 24554 },
+ { name: "굴삭기(무한궤도) (0.6㎥+부착용집게)", l: 59020, m: 16280, e: 29209 },
+ { name: "덤프트럭 (15Ton)", l: 59020, m: 30192, e: 20659 },
+];
+
+/** 다른 단가산출 = 그 단산의 노무 · 재료 · 경비 */
+export const SANGEUN = [
+ { no: 13, name: "뒷채움돌 기초잡석 채집운반(Φ150m/m) [㎡]", l: 9889, m: 2262, e: 3858 },
+ { no: 28, name: "기초다짐및뒷채움(소형장비사용) [㎥]", l: 11206, m: 1077, e: 1259 },
+ { no: 1, name: "측구터파기(토사) [㎥]", l: 3442, m: 832, e: 1049 },
+];
+
+const row = (n, x, u = "", d) => (d === undefined ? { n, x, u } : { n, x, u, d });
+
+/** 공식 틀 — 「이름 = 값」 · 「이름 = 식」 줄 묶음 */
+export const TEMPLATES = [
+ {
+ id: "excavator",
+ name: "굴삭기 Q (품셈 8-2-3)",
+ rows: [
+ row("q", "0.7", "㎥"),
+ row("K", "0.55"),
+ row("f", "1 / 1.325"),
+ row("E", "0.45"),
+ row("Cm", "20", "sec ( 135 ˚ )"),
+ row("Q", "3600 × q × K × f × E / Cm", "㎥ / hr"),
+ ],
+ },
+ {
+ id: "dump",
+ name: "덤프 Q (q · K 는 위 굴삭기 것)",
+ rows: [
+ row("L", "0.1", "km"),
+ row("V1", "7", "km / hr"),
+ row("V2", "8", "km / hr"),
+ row("T", "15", "ton"),
+ row("rt", "1.9", "ton / ㎥"),
+ row("E", "0.9"),
+ row("qt", "T ÷ rt", "㎥"),
+ row("f", "0.89"),
+ row("Cms", "22"),
+ row("Es", "0.35"),
+ row("n", "qt / (q × K)"),
+ row("t1", "Cms × n / (60 × Es)"),
+ row("t2", "(L / V1 + L / V2) × 60", "분 ( 왕복 )"),
+ row("t3", "1.1", "분 ( 적하 )"),
+ row("t4", "0.7", "분 ( 대기 )"),
+ row("Cm", "t1 + t2 + t3 + t4", "분"),
+ row("QH", "(Cm - t1) / Cm"),
+ row("Q", "60 × qt × f × E / Cm", "㎥ / hr"),
+ ],
+ },
+ { id: "directQ", name: "직접 Q", rows: [row("Q", "0.35")] },
+];
+
+/** 견본 표 하나 — [표에서 고르기] (표준품셈 8-2-3 굴착기 2. 작업효율 E) */
+export const TABLE_E = {
+ title: "굴착기 작업효율 E (표준품셈 8-2-3)",
+ head: ["토질", "자연 양호", "자연 보통", "자연 불량", "흐트 양호", "흐트 보통", "흐트 불량"],
+ rows: [
+ ["모래, 사질토", 0.85, 0.7, 0.55, 0.9, 0.75, 0.6],
+ ["자갈섞인 흙, 점성토", 0.75, 0.6, 0.45, 0.8, 0.65, 0.5],
+ ["파쇄암", null, null, null, null, 0.45, 0.35],
+ ],
+};
+
+const op = (o, v, u = "") => ({ op: o, v, u });
+const tplRows = (id) => structuredClone(TEMPLATES.find((t) => t.id === id).rows);
+
+export const SAMPLES = [
+ {
+ key: "s1",
+ label: "① 측구터파기(토사)",
+ expect: { l: 3442, m: 832, e: 1049, sum: 5323 },
+ doc: {
+ name: "측구터파기(토사)",
+ unit: "㎥",
+ items: [
+ {
+ title: "기 계 ( 100 % ) : 굴삭기(무한궤도) (0.7㎥)",
+ lines: [
+ { k: "formula", tpl: "excavator", rows: tplRows("excavator") },
+ { k: "mach", res: { ...MACHINE[0] }, ops: [op("÷", "Q")] },
+ ],
+ },
+ {
+ title: "보조인부 ( 1 일당 1 인 )",
+ lines: [{ k: "labor", res: { ...LABOR[0] }, ops: [op("÷", "8"), op("÷", "Q")] }],
+ },
+ ],
+ },
+ },
+ {
+ key: "s41",
+ label: "② 합판거푸집(4회)",
+ expect: { l: 38290, m: 14506, e: 0, sum: 52796 },
+ doc: {
+ name: "합판거푸집(4회)",
+ unit: "㎡",
+ items: [
+ {
+ title: "",
+ lines: [
+ { k: "text", text: "< 토목품셈 6-3-1 합판거푸집(2022개정) >" },
+ { k: "text", text: "※ 사용횟수 4 회 : 보통" },
+ ],
+ },
+ {
+ title: "재료비",
+ lines: [
+ {
+ k: "mat",
+ col: "m",
+ res: { ...MATERIAL[0] },
+ ops: [op("×", "1.03", "㎡"), op("×", "38", "%")],
+ },
+ {
+ k: "mat",
+ col: "m",
+ res: { ...MATERIAL[1] },
+ ops: [op("×", "0.038", "㎥"), op("×", "38", "%")],
+ },
+ {
+ k: "rate",
+ name: "소모자재 ( 박리재등 )",
+ of: "주자재비",
+ baseName: "주자재",
+ baseCol: "m",
+ scope: "item",
+ rate: "9",
+ col: "m",
+ },
+ ],
+ },
+ {
+ title: "인건비 ( 24 년 품셈 개정 )",
+ lines: [
+ { k: "labor", res: { ...LABOR[1] }, ops: [op("×", "5", "인"), op("/", "45", "㎡")] },
+ { k: "labor", res: { ...LABOR[0] }, ops: [op("×", "2", "인"), op("/", "45", "㎡")] },
+ {
+ k: "rate",
+ name: "공구손료 및 경장비 기계경비",
+ of: "노무비",
+ baseName: "인부",
+ baseCol: "l",
+ scope: "item",
+ rate: "1",
+ col: "m",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ key: "s15",
+ label: "③ 전석 메쌓기",
+ expect: { l: 74880, m: 7029, e: 12306, sum: 94215 },
+ doc: {
+ name: "전석 메쌓기",
+ unit: "㎡",
+ items: [
+ { title: "전석 구입운반 : 별산", lines: [] },
+ {
+ title: "뒷채움돌 운반 및 채집 : 뒷채움돌기초잡석채집운반 (Φ150m/m) [㎡] * 단산 13 참조 *",
+ lines: [
+ { k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
+ { k: "ref", res: { ...SANGEUN[0] }, ops: [op("×", "Q")] },
+ ],
+ },
+ {
+ title: "뒤채움돌 다짐 기초다짐및뒷채움 (소형장비사용) [㎥] * 단산 28 참조 *",
+ lines: [
+ { k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
+ { k: "ref", res: { ...SANGEUN[1] }, ops: [op("×", "Q")] },
+ ],
+ },
+ {
+ title: "전석쌓기 기계: 굴삭기(무한궤도) (0.6㎥+부착용집게)",
+ lines: [
+ { k: "formula", tpl: "directQ", rows: [row("Q", "0.36", "㎥ / hr")] },
+ { k: "mach", res: { ...MACHINE[1] }, ops: [op("×", "Q")] },
+ ],
+ },
+ {
+ title: "전석쌓기 인부",
+ lines: [
+ { k: "labor", res: { ...LABOR[2] }, ops: [op("×", "0.14", "인")] },
+ { k: "labor", res: { ...LABOR[0] }, ops: [op("×", "0.05", "인")] },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ key: "new",
+ label: "④ 새 공식",
+ expect: null,
+ doc: {
+ name: "새 단가산출",
+ unit: "",
+ items: [{ title: "", lines: [{ k: "formula", tpl: null, rows: [row("", "")] }] }],
+ },
+ },
+];
diff --git a/tools/line-stack-test/style.css b/tools/line-stack-test/style.css
new file mode 100644
index 000000000..16d1737c9
--- /dev/null
+++ b/tools/line-stack-test/style.css
@@ -0,0 +1,306 @@
+/* 시험 페이지 전용 — 본 프로그램 부품 상자 밖(폴더째 지움) */
+:root {
+ --bg: #f6f7f9;
+ --panel: #fff;
+ --line: #d5d9e0;
+ --text: #1d232b;
+ --muted: #6b7380;
+ --accent: #2f6fde;
+ --bad: #d23b3b;
+ --ok: #1f8a4c;
+ --chip: #eef2f8;
+ color-scheme: light dark;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #15181d;
+ --panel: #1e2228;
+ --line: #343a44;
+ --text: #e3e7ec;
+ --muted: #9aa3ae;
+ --accent: #6c9ef0;
+ --bad: #ff6b6b;
+ --ok: #4cc27f;
+ --chip: #2a3039;
+ }
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ font:
+ 13px/1.45 "Malgun Gothic",
+ system-ui,
+ sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+header,
+footer {
+ padding: 6px 10px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+footer {
+ border-top: 1px solid var(--line);
+ border-bottom: 0;
+}
+h1 {
+ font-size: 15px;
+ margin: 0 8px 0 0;
+}
+.hint,
+#msg,
+#pasteMsg,
+.note,
+i {
+ color: var(--muted);
+ font-size: 12px;
+}
+main {
+ flex: 1;
+ display: grid;
+ grid-template-columns: minmax(420px, 46%) 1fr;
+ min-height: 0;
+}
+#edit,
+#outWrap {
+ overflow: auto;
+ padding: 8px;
+}
+#edit {
+ border-right: 1px solid var(--line);
+}
+button,
+select,
+input,
+textarea {
+ font: inherit;
+ color: inherit;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ padding: 2px 6px;
+}
+button {
+ cursor: pointer;
+ background: var(--chip);
+}
+button:hover {
+ border-color: var(--accent);
+}
+input.wide {
+ width: 260px;
+}
+input.num {
+ width: 80px;
+ text-align: right;
+}
+input.s {
+ width: 50px;
+}
+input.s2 {
+ width: 90px;
+}
+input.full,
+input.title {
+ width: 100%;
+}
+input.title {
+ flex: 1;
+ font-weight: 600;
+}
+input.v {
+ width: 60px;
+}
+input.u {
+ width: 50px;
+}
+input.name {
+ width: 60px;
+}
+input.expr {
+ width: 220px;
+ font-family: Consolas, monospace;
+}
+.dochead,
+.row,
+.ih,
+.lh,
+.ops,
+.frow,
+.fhead {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+ margin: 2px 0;
+}
+.item {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ padding: 6px;
+ margin: 6px 0;
+}
+.line {
+ border-left: 3px solid var(--accent);
+ background: var(--bg);
+ padding: 4px 6px;
+ margin: 4px 0 4px 8px;
+ border-radius: 3px;
+}
+.line.k-text {
+ border-left-color: var(--muted);
+}
+.line.k-formula {
+ border-left-color: #a46bd8;
+}
+.line.k-rate {
+ border-left-color: #d8973b;
+}
+.grip {
+ cursor: grab;
+ padding: 0 4px;
+ user-select: none;
+}
+.kind {
+ font-weight: 600;
+}
+.lh button {
+ margin-left: auto;
+}
+.op {
+ display: inline-flex;
+ gap: 2px;
+ padding: 1px 3px;
+ background: var(--chip);
+ border-radius: 4px;
+}
+.tpl {
+ font-weight: 600;
+ color: #a46bd8;
+}
+.val {
+ min-width: 50px;
+ font-family: Consolas, monospace;
+}
+.src {
+ background: var(--ok);
+ color: #fff;
+ border-radius: 3px;
+ padding: 0 4px;
+ font-size: 11px;
+}
+.empty {
+ color: var(--muted);
+ padding: 4px 8px;
+}
+.bad,
+input.bad {
+ color: var(--bad);
+ border-color: var(--bad);
+}
+.ok {
+ color: var(--ok);
+}
+table.out {
+ border-collapse: collapse;
+ width: 100%;
+ background: var(--panel);
+}
+.out th,
+.out td {
+ border: 1px solid var(--line);
+ padding: 2px 6px;
+ vertical-align: middle;
+}
+.out th {
+ background: var(--chip);
+}
+.out td.h {
+ min-width: 380px;
+}
+.out td.n {
+ text-align: right;
+ font-family: Consolas, monospace;
+ width: 90px;
+}
+.out tr.head td,
+.out tr.item td.h {
+ font-weight: 600;
+}
+.out tr.item td.h {
+ padding-top: 6px;
+}
+.out tr.subsum td,
+.out tr.total td {
+ background: var(--chip);
+}
+.gap {
+ display: inline-block;
+ width: 18px;
+}
+.fr {
+ white-space: nowrap;
+ display: inline-flex;
+ flex-direction: column;
+ vertical-align: middle;
+ text-align: center;
+ margin: 0 2px;
+}
+.fr > span:first-child {
+ border-bottom: 1px solid currentColor;
+ padding: 0 3px;
+}
+.fr > span:last-child {
+ padding: 0 3px;
+}
+#tot {
+ display: flex;
+ gap: 6px;
+}
+.box {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 2px 10px;
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+}
+.box b {
+ font-size: 15px;
+}
+.box.sum {
+ border-color: var(--accent);
+}
+details {
+ width: 100%;
+}
+textarea {
+ width: 100%;
+ font-family: Consolas, monospace;
+}
+dialog {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+dialog table {
+ border-collapse: collapse;
+ margin: 6px 0;
+}
+dialog th,
+dialog td {
+ border: 1px solid var(--line);
+ padding: 2px 6px;
+}