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("");
}
// Change text inside an existing styled run, never WPTools tags or bookmarks.
function editDocument(text, oldSheet, sheet) {
if (
!same({ ...oldSheet, rows: [] }, { ...sheet, rows: [] }) ||
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(/
]*>[\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("수량산출서 표지 변경은 저장할 수 없습니다.");
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;
}
}
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]);
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);
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, current.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 };