// REA f2a1fc/f254c8: LineData 표지와 일반·성분·마지막 합계의 정밀도. const EstText = (() => { const { num, components, floating, sum } = EstMath; const marker = /\x12([-\d.,]+)\x12/; const component = { [-1]: "NC", [-2]: "GC", [-3]: "JC", [-4]: "SC" }; const clean = (s) => String(s || "") .replace(/[\x06\x07][^\x06\x07]*[\x06\x07]/g, "") .replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, ""); const numeric = (s) => Number(String(s).replaceAll(",", "")); const sumCurrency = (values) => values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000; const autoTotal = (row) => num(row.LinkIndex) !== 62 && String(row.LineData || "").includes("\x1a"); const stripLabels = (text) => String(text) .replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\([가-힣][^()]*\)/g, "") .replace(/[;;]/g, ":"); const mathBraces = (text) => String(text).replace(/\{([^{}]*)\}/g, (_, content, offset, source) => (!source.slice(0, offset).trimEnd() || /[=+×÷*/(]$/.test(source.slice(0, offset).trimEnd())) && /\d/.test(content) && /[+×÷*/]/.test(content) && !/[가-힣]/.test(content) ? `(${content})` : "", ); function expression(text) { return clean( mathBraces(text) .replace(///g, "/") .replace( /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|[〔[][^〕]]*[〕]]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g, "", ), ) .replace(/^\s*[가-힣]+\s+\d+조당\s+[가-힣]+\s+(?=\d)/, "") .replace( /(? null) { const rows = structuredClone(input), titles = [], errors = [], checks = []; let head = null, variables = Object.create(null), changed = new Map(), currentSource = null, finalOverride = null, totals = {}, finalLevel = -1, plainTotals = {}, singleComponent = -1, hasAutoTotals = false; function finish() { if (!head) return; if (finalOverride) { components.forEach((key) => (head[key] = num(finalOverride[key]))); head.TC = sum(head); titles.push({ ...head }); return; } components.forEach( (key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])), ); head.TC = sum(head); titles.push({ ...head }); } for (let index = 0; index < rows.length; index++) { const row = rows[index]; currentSource = null; delete row.Error; for (const key of ["ResourceFactor", "ResourceCode", "ResourceKind", "ResourceComponent"]) delete row[key]; if (num(row.Index) > 0) { finish(); head = row; variables = Object.create(null); changed = new Map(); currentSource = null; finalOverride = null; totals = {}; plainTotals = {}; for (const [key, tag] of [ ["Name", "\x0e"], ["Size", "\x13"], ["Unite", "\x15"], ]) { const match = String(row.LineData || "").match(new RegExp(`${tag}([^${tag}]+)${tag}`)); if (match) row[key] = match[1]; } const levels = [], componentRows = []; for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++) if (autoTotal(rows[j])) levels.push(num(rows[j].Level)); else if (component[num(rows[j].SCheck)] && marker.test(String(rows[j].LineData || ""))) componentRows.push(j); finalLevel = levels.length ? Math.min(...levels) : -1; hasAutoTotals = levels.length > 0; singleComponent = !hasAutoTotals && num(head.SCheck) > 0 && componentRows.length === 1 ? componentRows[0] : -1; continue; } if (!head || !row.LineData) continue; if (num(row.Level) > 0) finalOverride = null; if ([51, 52].includes(num(row.LinkIndex))) { if (num(row.LinkIndex) === 52) { const rate = String(row.LineData).match(/\x15([-\d.,]+)\x15/); const multiplier = rate ? numeric(rate[1]) / 100 : 0; const base = Object.fromEntries(components.map((key) => [key, 0])); for (let j = index - 1; j >= 0; j--) { const previous = rows[j], key = component[num(previous.SCheck)]; if (num(previous.Level) > 0 || num(previous.Index) > 0) break; if (!key) continue; const values = [ ...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g), ]; const value = values.length ? numeric(values.at(-1)[2]) : num(previous.TC); // f2c680/f2c74e overwrites NC while walking backward; the other components add. base[key] = key === "NC" ? value : base[key] + value; } components.forEach( (key) => (row[key] = EstMath.even(base[key] * multiplier * 10000) / 10000), ); row.TC = sum(row); } finalOverride = row; continue; } const original = String(row.LineData), matched = original.match(marker), sc = num(row.SCheck); const oldValue = matched ? numeric(matched[1]) : null; if ( [5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) && (row.Code || /[\x0e\x03]/.test(original)) ) { const name = original.match(/\x0e([^\x0e]+)\x0e/), size = original.match(/\x13([^\x13]+)\x13/); try { currentSource = null; currentSource = resolve({ ...row, Name: name?.[1] ?? row.Name, Size: size?.[1] ?? row.Size, }); } catch (error) { errors.push(`${head.Code}:${index + 1} ${error.message}`); } } let text = original.replace(/\{([+-])\}/g, "$1"); let selectionError = ""; if (num(row.LinkIndex) === 62) { text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => { const values = []; for (let j = index - 1; j >= 0 && !(num(rows[j].Index) > 0); j--) for (const match of String(rows[j].LineData || "").matchAll( /\x06([A-Z])\x06\s*([\x03\x12\x0c])([-\d.,]+)\2/g, )) if (match[1] === group) values.unshift(numeric(match[3])); if (!values.length) selectionError = `합계 구간 ${group}에 지정된 결과가 없습니다.`; return values.length ? `${reference}\x1a${values.join(" + ")}\x1a` : whole; }); } if (num(row.LinkIndex) === 61 && text.includes("\x0c")) { const values = []; for (let j = index - 1; j >= 0; j--) { const previous = rows[j]; if (num(previous.Index) !== -1) { const results = [ ...String(previous.LineData || "").matchAll(/([\x03\x12\x0c])([-\d.,]+)\1/g), ]; if (results.length) values.push(numeric(results.at(-1)[2])); } if (num(previous.Level) > 0 || num(previous.Index) > 0 || num(previous.Index) === -20) break; } const precision = num(row.DigetIndex) && num(row.Diget) > 0 ? EstMath.precision(row) : { digits: 1, method: 2 }; const subtotal = floating( // f251d5-f251df accumulates Extended values before applying the row precision. values.reduce((total, value) => total + value, 0), options.CostValueTrunc && sc < 0 ? 0 : precision.digits, precision.method, ); text = text.replace(/\x0c([-\d.,]+)\x0c/g, (whole, value) => { changed.set(numeric(value), subtotal); return `\x0c${subtotal}\x0c`; }); row.TC = subtotal; row.LineData = text; } if (num(row.LinkIndex) === 58 && text.includes("\x0c")) { let sourceKind = num(row.OCheck); if (/노무|인건|품/.test(clean(text))) sourceKind = -1; else if (/재료|자재/.test(clean(text))) sourceKind = -2; else if (/경비/.test(clean(text))) sourceKind = -3; let value; for (let j = index - 1; j >= 0; j--) { const previous = rows[j], kind = num(previous.SCheck); if ((!sourceKind && kind < 0 && kind > -4) || (sourceKind && kind === sourceKind)) { const results = [ ...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g), ]; if (results.length) { value = numeric(results.at(-1)[2]); row.OCheck = kind; break; } } if (num(previous.Level) > 0 || num(previous.Index) > 0) break; } if (value !== undefined) text = text.replace(/\x0c[-\d.,]+\x0c/g, `\x0c${value}\x0c`); } if (currentSource && sc < 0 && component[sc]) text = text.replace( /\x03([-\d.,]+)\x03/g, () => `\x03${num(currentSource[component[sc]])}\x03`, ); else if (currentSource && sc === 0 && /\x0e/.test(original)) text = text.replace(/\x03([-\d.,]+)\x03/g, () => `\x03${num(currentSource.TC)}\x03`); row.LineData = text; text = text.replace( /\x11([-\d.,]+)\x11/g, (whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`, ); if (![58, 61].includes(num(row.LinkIndex))) text = text.replace( /\x0c([-\d.,]+)\x0c/g, (whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`, ); if (text.includes("\x1a") && text.includes(":") && sc !== -5) { const start = text.indexOf(":") + 1, end = text.includes("\x12") ? text.indexOf("\x12") : text.length; text = text.slice(0, start) + text .slice(start, end) .replace(/\d[\d,]*\.?\d*/g, (value) => String(changed.get(numeric(value)) ?? numeric(value)), ) + text.slice(end); row.LineData = text; } let calculated = null, digits = 0; const assignedResults = new Map(); const assignedNames = new Map(); let componentDeclaration; try { if (selectionError) throw Error(selectionError); if (!matched) for (const m of text.matchAll(/\(([A-Za-z][A-Za-z0-9]*)\)\s*[::]\s*([-+]?\d[\d,.]*)/g)) variables[m[1]] = numeric(m[2]); if ( (matched || /^\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(text)) && /─/.test(text) && !(!matched && /⇒.*─/.test(text)) ) { const above = clean(rows[index - 1]?.LineData), below = clean(rows[index + 1]?.LineData); const columns = (value) => value .replace(/\s*([×÷·+*/−-])\s*/g, "$1") .trim() .split(/\s+/); const tops = columns(above), bottoms = columns(below); let part = 0; const formula = text .slice(text.indexOf("=") + 1) .split("=")[0] .replace(/─+/g, () => { const i = part++; return `(${expression(tops[i] || "")})/(${expression(bottoms[i] || "")})`; }); calculated = EstExpression.evaluate(expression(formula), (key) => variables[key]); } else if (matched && text.includes("\x1a")) { if (sc === -5) { const section = []; for (let j = index - 1; j >= 0; j--) { const previous = rows[j]; if (!previous.LineData) continue; if (!autoTotal(previous) || !component[num(previous.SCheck)]) break; section.push(num(previous.TC)); } calculated = sumCurrency(section); } else { let body = clean(text.slice(0, text.indexOf("\x12"))) .replace(/=\s*$/, "") .trim(); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); calculated = body ? EstExpression.evaluate(body, (key) => variables[key]) : 0; } } else if (matched && sc === -5) { const section = []; for ( let j = index - 1; j >= 0 && num(rows[j].Index) <= 0 && num(rows[j].Level) <= 0 && num(rows[j].SCheck) !== -5; j-- ) if (component[num(rows[j].SCheck)]) section.push(num(rows[j].TC)); calculated = sumCurrency(section); } else if (matched && sc < 0) { let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, ""); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); const parts = body.split("="); if (parts.length > 1 && /^[A-Za-z가-힣][A-Za-z0-9가-힣]*$/.test(parts[0].trim())) { componentDeclaration = parts[0].trim(); body = parts[1]; } else body = parts[0]; calculated = EstExpression.evaluate(expression(body), (key) => variables[key]); } const assignmentText = text.replace( /\{[^}]*\}|\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g, (value) => " ".repeat(value.length), ); const assignments = sc < 0 ? [] : [ ...assignmentText.matchAll( /(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|(?<=hr)\s+|[::]\s*|['"]\s*)([A-Za-z\u0370-\u03ff][A-Za-z0-9\u0370-\u03ff]*)\s*=/g, ), ].filter((match) => !/[+*/÷×−-]\s*$/.test(assignmentText.slice(0, match.index))); for (let j = 0; j < assignments.length; j++) { const assignment = assignments[j], end = assignments[j + 1]?.index ?? text.length; let body = text.slice(assignment.index + assignment[0].length, end); if (!matched && /⇒.*─/.test(body)) body = body.split("⇒")[0]; if (!matched) body = body.split(/[≈≒]/)[0]; if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\*\s*$/, ""); body = mathBraces(body); if (/['"]\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(assignment[0])) body = body.replace(/['"]\s*$/, ""); if (!matched) body = body.split("{")[0]; body = body.split(/\s+[::]\s*/)[0].replace(/[,,]\s*$/, ""); if (body.includes("─") && calculated !== null) { variables[assignment[1]] = floating(calculated, 2, 1); continue; } const result = body.match(marker); if (!result && /\d\s*[~~]\s*\d/.test(body)) { delete variables[assignment[1]]; continue; } const parts = body.split("="); body = parts[0]; body = expression(body) .trim() .replace(/\s+(?:min|hr|sec)\s*$/i, "") .split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0] .replace(/[,,]\s*$/, ""); if (!result) body = body .replace(/\s*,\s+[가-힣].*$/, "") .replace(/^([-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(?:\s{2,}|\s*,\s+).*$/, "$1"); // 단위가 붙은 상수와 주석은 수치 부분만 사용한다. const literal = body.match(/^([-+]?\d[\d,.]*)(?:\s*[A-Za-z가-힣㎥㎡ℓ˚°()].*)$/); if (literal && !/^[xX](?=[A-Za-z\d.(])/.test(body.slice(literal[1].length).trim())) body = literal[1]; let value; try { value = EstExpression.evaluate(body, (key) => variables[key]); } catch (error) { if (!result && error.message.startsWith("참조 항목 없음:")) continue; if (!result && parts.length > 1) value = EstExpression.evaluate(expression(parts.at(-1)), (key) => variables[key]); else if (parts.length > 1) value = EstExpression.evaluate( expression(parts[parts.length - 2]), (key) => variables[key], ); else throw Error(`${error.message} (${assignment[1]} = ${body})`); } const savedDigits = result?.[1].includes(".") ? result[1].split(".")[1].length : 0; const precision = result ? Math.max(2, savedDigits) : 8; const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null; variables[assignment[1]] = result ? floating(value, row.Precision ?? explicit?.digits ?? precision, explicit?.method ?? 1) : value; if (result) { const position = text.indexOf("\x12", assignment.index + assignment[0].length); assignedResults.set(position, variables[assignment[1]]); assignedNames.set(position, assignment[1]); if (position === text.indexOf("\x12")) calculated = variables[assignment[1]]; } } if (matched && calculated === null && sc === 0 && text.includes("=")) { let body = stripLabels(text.slice(0, text.indexOf("\x12"))) .replace(/\x06[^\x06]*\x06/g, "") .split("=") .filter((x) => x.trim()) .pop(); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); if (/^\s*\{[\s\S]*\}\s*$/.test(text)) body = body.replace(/^\s*\{/, ""); body = expression(body) .replace(/\s*([×÷·+*/−-])\s*/g, "$1") .trim() .split(/\s{2,}/) .pop(); calculated = EstExpression.evaluate(body, (key) => variables[key]); } if (matched && calculated !== null) { const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null; digits = row.Precision ?? explicit?.digits ?? (sc < 0 || num(row.LinkIndex) !== 0 ? (num(row.Level) === finalLevel && autoTotal(row)) || index === singleComponent || options.CostValueTrunc ? 0 : 1 : Math.max(2, matched[1].split(".")[1]?.length || 0)); const method = row.RoundMode || explicit?.method || (sc < 0 || num(row.LinkIndex) !== 0 ? 2 : 1); const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)]; const intermediate = results.length > 1 && sc < 0 && !explicit && row.Precision === undefined; calculated = floating( calculated, intermediate ? (options.MidCostDiget ?? 2) : digits, intermediate ? 2 : method, ); const firstValue = calculated; let rendered = text.replace(marker, `\x12${calculated}\x12`); let assignmentName = assignedNames.get(results[0]?.index); for (let part = 1; part < results.length; part++) { const previous = results[part - 1], next = results[part]; const format = row.ResultFormats?.[part]; const operation = expression( text.slice(previous.index + previous[0].length, next.index), ).trim(); calculated = assignedResults.has(next.index) ? assignedResults.get(next.index) : floating( EstExpression.evaluate( expression(`${calculated}${operation}`), (key) => variables[key], ), format?.digits ?? (intermediate && part < results.length - 1 ? (options.MidCostDiget ?? 2) : digits), format?.method ?? (intermediate && part < results.length - 1 ? 2 : method), ); assignmentName = assignedNames.get(next.index) || assignmentName; if (assignmentName) variables[assignmentName] = calculated; const old = numeric(next[1]); let occurrence = 0; rendered = rendered.replace(/\x12([-\d.,]+)\x12/g, (value) => occurrence++ === part ? `\x12${calculated}\x12` : value, ); changed.set(old, calculated); checks.push({ slot: index, part, stored: old, calculated }); } row.TC = calculated; if (componentDeclaration) variables[componentDeclaration] = calculated; if (currentSource && text.includes("\x03")) { let body = stripLabels(text.slice(0, text.indexOf("\x12"))).split("=")[0]; if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); try { row.ResourceFactor = EstExpression.evaluate( expression(body.replace(/\x03[-\d.,]+\x03/g, "1")), (key) => variables[key], ); for (let part = 1; part < results.length; part++) { const operation = expression( text.slice( results[part - 1].index + results[part - 1][0].length, results[part].index, ), ); row.ResourceFactor = EstExpression.evaluate( expression(`${row.ResourceFactor}${operation}`), (key) => variables[key], ); } row.ResourceCode = currentSource.Code; row.ResourceKind = currentSource.SourceKind; row.ResourceComponent = component[sc] || ""; } catch { row.ResourceFactor = null; } } row.LineData = rendered; changed.set(oldValue, firstValue); checks.push({ slot: index, stored: oldValue, calculated: firstValue }); if (autoTotal(row) && component[sc]) { if (num(row.Level) === finalLevel) totals[component[sc]] = calculated; } } else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) { const body = stripLabels(text.slice(text.indexOf(":") + 1)); row.TC = floating( EstExpression.evaluate(expression(body), (key) => variables[key]), 1, ); row.LineData = text; } else if (!matched && autoTotal(row) && component[sc]) { let value = clean(text).split(":").slice(1).join(":").trim(); // Bare final totals can contain an already truncated amount rather than an equation. // Rebuild only when the original component rows establish that subtotal relationship. const savedValue = clean(original).split(":").slice(1).join(":").trim(); if (num(row.Level) === finalLevel && /^[-\d.,]+$/.test(savedValue)) { const before = [], after = []; for (let j = index - 1; j >= 0 && num(rows[j].Index) <= 0; j--) { if (num(rows[j].SCheck) !== sc) continue; if (autoTotal(rows[j])) break; const results = [...String(input[j].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)]; if (!results.length && input[j].TC == null) continue; before.push(results.length ? numeric(results.at(-1)[1]) : num(input[j].TC)); after.push(num(rows[j].TC)); if (num(rows[j].LinkIndex) === 61) break; } if (before.length && floating(sumCurrency(before), 0, 2) === numeric(savedValue)) value = String(sumCurrency(after)); } const calculated = floating( EstExpression.evaluate(expression(value || "0"), (key) => variables[key]), num(row.Level) === finalLevel ? 0 : 1, ); row.TC = calculated; if (value !== clean(text).split(":").slice(1).join(":").trim()) { const start = text.indexOf(":") + 1, end = text.lastIndexOf("\x1a"); row.LineData = text.slice(0, start) + calculated + text.slice(end); } if (num(row.Level) === finalLevel) totals[component[sc]] = calculated; } } catch (error) { errors.push(`${head.Code}:${index + 1} ${error.message}`); row.Error = error.message; } if (!hasAutoTotals && component[sc]) plainTotals[component[sc]] = num(plainTotals[component[sc]]) + num(row.TC); } finish(); return { rows, titles, errors, checks }; } const names = { "\x06": "구간지정", "\x07": "구간합계", "\x03": "단가", "\x0c": "다른줄", "\x0e": "품명", "\x11": "변수", "\x12": "결과", "\x13": "규격", "\x15": "단위", "\x1a": "합계", }; const display = (text) => String(text || "").replace( /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x1a]/g, (ch) => `⟦${names[ch]}⟧`, ); const encode = (text) => String(text).replace( /⟦([^⟦⟧]+)⟧/g, (whole, name) => Object.keys(names).find((key) => names[key] === name) || whole, ); function replaceWords(rows, search, replacement) { if ( typeof search !== "string" || typeof replacement !== "string" || !search || /[\x00-\x1f]/.test(search + replacement) ) throw Error("찾을 문구를 입력하고 제어 문자를 제외하세요."); const protectedMarkers = new Set(["\x03", "\x06", "\x07", "\x0c", "\x11", "\x12"]); return rows.flatMap(({ row, index }) => { if (typeof row.LineData !== "string") return []; let protectedMarker = ""; const next = row.LineData.split(/([\x00-\x1f])/) .map((part, i) => { if (i % 2) { if (part === protectedMarker) protectedMarker = ""; else if (!protectedMarker && protectedMarkers.has(part)) protectedMarker = part; return part; } return protectedMarker ? part : part.split(search).join(replacement); }) .join(""); if (next.length > 200000) throw Error("변경 후 산출근거의 길이가 한도를 초과합니다."); return next === row.LineData ? [] : [{ index, value: next }]; }); } return { calculate, clean, display, encode, replaceWords }; })();