feat(est): reproduce native tables and calculations

This commit is contained in:
2026-11-05 06:07:05 +09:00
parent 646eab6245
commit 60ccedcd9a
30 changed files with 5249 additions and 22 deletions
+334
View File
@@ -0,0 +1,334 @@
// 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 stripLabels = (text) =>
String(text).replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g, "");
function expression(text) {
return clean(
String(text).replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([^()]*[가-힣][^()]*\)/g,
"",
),
)
.replace(
/(\d)\s*(?:공\/?㎥|공|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
function calculate(input, options = {}, resolve = () => null) {
const rows = structuredClone(input),
titles = [],
errors = [],
checks = [];
let head = null,
variables = Object.create(null),
changed = new Map(),
currentSource = null,
totals = {},
levelTotals = new Map(),
finalLevel = -1;
function finish() {
if (!head) return;
components.forEach((key) => (head[key] = num(totals[key])));
head.TC = sum(head);
titles.push({ ...head });
}
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (num(row.Index) > 0) {
finish();
head = row;
variables = Object.create(null);
changed = new Map();
currentSource = null;
totals = {};
levelTotals = new Map();
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 = [];
for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++)
if (String(rows[j].LineData || "").includes("\x1a")) levels.push(num(rows[j].Level));
finalLevel = levels.length ? Math.min(...levels) : -1;
continue;
}
if (!head || !row.LineData) continue;
const original = String(row.LineData),
matched = original.match(marker),
sc = num(row.SCheck);
const oldValue = matched ? numeric(matched[1]) : null;
if (num(row.LinkIndex) > 0 && /\x0e/.test(original)) {
const name = original.match(/\x0e([^\x0e]+)\x0e/),
size = original.match(/\x13([^\x13]+)\x13/);
try {
currentSource = resolve({ ...row, Name: name?.[1], Size: size?.[1] });
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
}
}
let text = original;
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`);
text = text.replace(
/\x11([-\d.,]+)\x11/g,
(whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`,
);
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();
try {
if (matched && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
const tops = above.trim().split(/\s{2,}/),
bottoms = below.trim().split(/\s{2,}/);
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)
calculated = sumCurrency(Object.values(levelTotals.get(num(row.Level)) || totals));
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);
body = body.split("=")[0];
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
const assignmentText = text.replace(/\{[^}]*\}/g, (value) => " ".repeat(value.length));
const assignments =
sc < 0
? []
: [
...assignmentText.matchAll(
/(?:^| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
].filter((a) => !text.includes("\x0e") || a.index < text.indexOf("\x0e"));
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);
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);
const parts = body.split("=");
body = parts[0];
body = expression(body)
.trim()
.replace(/\s+(?:min|hr|sec)\s*$/i, "")
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0];
// 단위가 붙은 상수와 주석은 수치 부분만 사용한다.
const literal = body.match(/^([-+]?\d[\d,.]*)(?:\s*[A-Za-z가-힣㎥㎡ℓ˚°()].*)$/);
if (literal) body = literal[1];
let value;
try {
value = EstExpression.evaluate(body, (key) => variables[key]);
} catch (error) {
if (!result && error.message.startsWith("참조 항목 없음:")) continue;
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);
calculated = EstExpression.evaluate(expression(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 && text.includes("\x1a")
? 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);
calculated = floating(calculated, digits, method);
const firstValue = calculated;
let rendered = text.replace(marker, `\x12${calculated}\x12`);
const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
let assignmentName = assignedNames.get(results[0]?.index);
for (let part = 1; part < results.length; part++) {
const previous = results[part - 1],
next = results[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],
),
digits,
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 (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],
);
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 (text.includes("\x1a") && component[sc]) {
if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
levelTotals.get(num(row.Level))[component[sc]] = calculated;
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 && text.includes("\x1a") && component[sc]) {
const value = clean(text).split(":").slice(1).join(":").trim();
const calculated = floating(
changed.get(numeric(value)) ?? numeric(value),
num(row.Level) === finalLevel ? 0 : 1,
);
if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
levelTotals.get(num(row.Level))[component[sc]] = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
row.Error = error.message;
}
}
finish();
return { rows, titles, errors, checks };
}
const names = {
"\x03": "단가",
"\x0c": "다른줄",
"\x0e": "품명",
"\x11": "변수",
"\x12": "결과",
"\x13": "규격",
"\x15": "단위",
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(/[\x03\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,
);
return { calculate, clean, display, encode };
})();