260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
const zlib = require("node:zlib");
|
|
const quantity = require("./native-quantity.cjs");
|
|
const { calculateQuantity } = require("./quantity-calculate.cjs");
|
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
function escapeText(text) {
|
|
return [...text]
|
|
.map((c) => {
|
|
if (c === "&") return "&";
|
|
if (c === "<") return "<";
|
|
if (c === ">") return ">";
|
|
const code = c.codePointAt(0);
|
|
return code > 126 ? `&#${code};` : c;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function editRun(div, before, after, parse) {
|
|
for (const match of div.matchAll(/>([^<>]+)(?=<)/g)) {
|
|
const raw = match[1],
|
|
decoded = quantity.entities(raw.replace(/\r?\n/g, ""));
|
|
if (!decoded) continue;
|
|
let position = -1;
|
|
while ((position = before.LineData.indexOf(decoded, position + 1)) >= 0) {
|
|
const prefix = before.LineData.slice(0, position),
|
|
suffix = before.LineData.slice(position + decoded.length);
|
|
if (
|
|
!after.LineData.startsWith(prefix) ||
|
|
!after.LineData.endsWith(suffix) ||
|
|
after.LineData.length < prefix.length + suffix.length
|
|
)
|
|
continue;
|
|
const replacement = after.LineData.slice(
|
|
prefix.length,
|
|
after.LineData.length - suffix.length,
|
|
);
|
|
const at = match.index + 1;
|
|
const candidate = div.slice(0, at) + escapeText(replacement) + div.slice(at + raw.length);
|
|
if (same(parse(candidate)[0], after)) return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function editStyledRuns(div, before, after, parse) {
|
|
const a = before.LineData,
|
|
b = after.LineData;
|
|
let start = 0,
|
|
endA = a.length,
|
|
endB = b.length;
|
|
while (start < endA && start < endB && a[start] === b[start]) start++;
|
|
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
endA--;
|
|
endB--;
|
|
}
|
|
for (const block of div.matchAll(/>[^<>]+(?:<c\s+nr=\d+\s*\/>[^<>]+)+/gi)) {
|
|
const runs = [...block[0].matchAll(/>([^<>]+)(?=<|$)/g)];
|
|
const values = runs.map((run) => quantity.entities(run[1].replace(/\r?\n/g, "")));
|
|
const combined = values.join("");
|
|
let position = -1;
|
|
while ((position = a.indexOf(combined, position + 1)) >= 0) {
|
|
if (start < position || endA > position + combined.length) continue;
|
|
let patched = block[0],
|
|
inserted = false;
|
|
for (let i = runs.length - 1; i >= 0; i--) {
|
|
const begin = position + values.slice(0, i).reduce((n, value) => n + value.length, 0),
|
|
end = begin + values[i].length;
|
|
if (end < start || begin > endA) continue;
|
|
const left = values[i].slice(0, Math.max(0, start - begin)),
|
|
right = values[i].slice(Math.max(0, endA - begin));
|
|
const replacement =
|
|
begin <= start && (start < end || (i === runs.length - 1 && start === end))
|
|
? b.slice(start, endB)
|
|
: "";
|
|
if (replacement) inserted = true;
|
|
const at = runs[i].index + 1;
|
|
patched =
|
|
patched.slice(0, at) +
|
|
escapeText(left + replacement + right) +
|
|
patched.slice(at + runs[i][1].length);
|
|
}
|
|
if (!inserted && endB > start) continue;
|
|
const candidate =
|
|
div.slice(0, block.index) + patched + div.slice(block.index + block[0].length);
|
|
if (same(parse(candidate)[0], after)) return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Change text inside an existing styled run, never WPTools tags or bookmarks.
|
|
function editDocument(text, oldSheet, sheet) {
|
|
const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
|
|
if (
|
|
!same({ ...oldSheet, rows: [], items: [] }, { ...sheet, rows: [], items: [] }) ||
|
|
!same(withoutLabels(oldSheet.items), withoutLabels(sheet.items)) ||
|
|
oldSheet.rows.length !== sheet.rows.length
|
|
)
|
|
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
|
|
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
|
|
const parse = (div) => quantity.fromWpt("", "", declarations + div, [], {}).rows;
|
|
let rowIndex = 0;
|
|
const result = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
|
|
const parsed = parse(div);
|
|
if (!parsed.length) return div;
|
|
const before = oldSheet.rows[rowIndex],
|
|
after = sheet.rows[rowIndex++];
|
|
if (!before || !after || !same(parsed[0], before)) throw Error("수량산출서 원본 행 대응 오류");
|
|
if (same(before, after)) return div;
|
|
if (!same(before.markers, after.markers))
|
|
throw Error("수량산출서 표지 변경은 저장할 수 없습니다.");
|
|
const direct = editRun(div, before, after, parse);
|
|
if (direct) return direct;
|
|
const styled = editStyledRuns(div, before, after, parse);
|
|
if (styled) return styled;
|
|
const split = /(\^\([^)]+\)|[\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
|
|
isSpecial = (s) => /^\^\([^)]+\)$/.test(s) || /[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/.test(s),
|
|
previous = before.LineData.split(split),
|
|
next = after.LineData.split(split);
|
|
if (
|
|
previous.length === next.length &&
|
|
previous.every(
|
|
(v, i) =>
|
|
!isSpecial(v) ||
|
|
(isSpecial(next[i]) &&
|
|
(v === next[i] || (v.startsWith("^(") && next[i].startsWith("^(")))),
|
|
)
|
|
) {
|
|
let candidate = div,
|
|
current = before;
|
|
for (let i = 0; i < previous.length; i++) {
|
|
if (previous[i] === next[i]) continue;
|
|
previous[i] = next[i];
|
|
const updated = { ...current, LineData: previous.join("") };
|
|
const patched =
|
|
editRun(candidate, current, updated, parse) ||
|
|
editStyledRuns(candidate, current, updated, parse);
|
|
if (!patched) break;
|
|
candidate = patched;
|
|
current = updated;
|
|
}
|
|
if (same(current, after)) return candidate;
|
|
}
|
|
throw Error(
|
|
`수량산출서 ${rowIndex}행: 기존 서식 구간을 넘는 편집은 ESTX에 저장할 수 없습니다.`,
|
|
);
|
|
});
|
|
if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치");
|
|
if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet))
|
|
throw Error("수량산출서 문서 재읽기 검산 실패");
|
|
return result;
|
|
}
|
|
|
|
function updateQuantities(project, base, cp949, memory) {
|
|
const sheets = project.quantitySheets || [],
|
|
originals = base.quantitySheets || [];
|
|
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
|
|
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
|
|
const savedSheets = sheets.slice();
|
|
for (let i = 0; i < sheets.length; i++) {
|
|
if (same(sheets[i], originals[i])) continue;
|
|
const row = rows.find((r) => r.Code === originals[i].Code);
|
|
const oldRow = base.tables.MulMainDat.find((r) => r.Code === originals[i].Code);
|
|
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
|
|
throw Error("수량산출서 원본 편집기 데이터 충돌");
|
|
const packed = Buffer.from(row.Editor.binary, "base64"),
|
|
compressed = packed[0] === 0x78;
|
|
const bytes = compressed
|
|
? zlib.inflateSync(packed, { maxOutputLength: 256 * 1024 * 1024 })
|
|
: packed;
|
|
const text = new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
|
|
if (!cp949(text).equals(bytes)) throw Error("수량산출서 원본 인코딩 보존 불가");
|
|
let document = editDocument(text, originals[i], sheets[i]);
|
|
if (same(sheets[i].rows, originals[i].rows)) {
|
|
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
|
row.MulList = {
|
|
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
|
"base64",
|
|
),
|
|
};
|
|
continue;
|
|
}
|
|
const baseline = calculateQuantity(originals[i]),
|
|
calculated = calculateQuantity(sheets[i]);
|
|
if (calculated.errors.length || baseline.errors.length)
|
|
throw Error(`수량산출서 계산 오류: ${calculated.errors[0] || baseline.errors[0]}`);
|
|
const markerCount = originals[i].rows.reduce((total, row) => total + row.markers.length, 0);
|
|
const results = (value) => ({
|
|
checks: value.checks.map(({ name, calculated }) => ({ name, calculated })),
|
|
items: value.items.map(({ Code, GN }) => ({ Code, GN })),
|
|
});
|
|
const resultMarkers = (sheet) =>
|
|
sheet.rows.map((row) => [...row.LineData.matchAll(/\x12[^\x12]*\x12/g)].map((m) => m[0]));
|
|
if (
|
|
baseline.checks.length === markerCount &&
|
|
calculated.checks.length === markerCount &&
|
|
baseline.checks.every((value) => Number.isFinite(value.calculated)) &&
|
|
calculated.checks.every((value) => Number.isFinite(value.calculated)) &&
|
|
same(resultMarkers(originals[i]), resultMarkers(sheets[i])) &&
|
|
same(results(baseline), results(calculated))
|
|
) {
|
|
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
|
if (!same(sheets[i].items, originals[i].items))
|
|
row.MulList = {
|
|
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
|
"base64",
|
|
),
|
|
};
|
|
const output = cp949(document);
|
|
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
|
|
continue;
|
|
}
|
|
if (
|
|
baseline.checks.length !== markerCount ||
|
|
calculated.checks.length !== markerCount ||
|
|
baseline.checks.some((v) => !Number.isFinite(v.stored) || v.stored !== v.calculated) ||
|
|
baseline.items.some((v, n) => v.GN !== originals[i].items[n].GN)
|
|
)
|
|
throw Error("수량산출서 원본 결과와 재계산 값이 다릅니다. JSON으로 저장해 확인하세요.");
|
|
let current = structuredClone(sheets[i]);
|
|
for (let n = 0; n < current.rows.length; n++) {
|
|
const desired = calculated.rows[n].LineData;
|
|
const before = [...current.rows[n].LineData.matchAll(/\x12([-\d.,]+)\x12/g)];
|
|
const after = [...desired.matchAll(/\x12([-\d.,]+)\x12/g)];
|
|
if (before.length !== after.length) throw Error("수량산출 결과 표지 수 불일치");
|
|
for (let j = 0; j < after.length; j++) {
|
|
const matches = [...current.rows[n].LineData.matchAll(/\x12([-\d.,]+)\x12/g)];
|
|
if (matches[j][0] === after[j][0]) continue;
|
|
const next = structuredClone(current),
|
|
at = matches[j].index;
|
|
next.rows[n].LineData =
|
|
next.rows[n].LineData.slice(0, at) +
|
|
after[j][0] +
|
|
next.rows[n].LineData.slice(at + matches[j][0].length);
|
|
document = editDocument(document, current, next);
|
|
current = next;
|
|
}
|
|
if (current.rows[n].LineData !== desired) throw Error("수량산출 결과 재계산 검산 실패");
|
|
}
|
|
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
|
if (!same(calculated.items, originals[i].items)) {
|
|
row.MulList = {
|
|
binary: memory(Buffer.from(row.MulList.binary, "base64"), calculated.items).toString(
|
|
"base64",
|
|
),
|
|
};
|
|
current.items = calculated.items;
|
|
}
|
|
savedSheets[i] = current;
|
|
const output = cp949(document);
|
|
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
|
|
}
|
|
return {
|
|
...project,
|
|
quantitySheets: savedSheets,
|
|
tables: { ...project.tables, MulMainDat: rows },
|
|
};
|
|
}
|
|
module.exports = { editDocument, updateQuantities };
|