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(/>[^<>]+(?:[^<>]+)+/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(/]*>[\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 };