const zlib = require("node:zlib"); const quantity = require("./native-quantity.cjs"); const { rowParser } = require("./wpt-parse.cjs"); const { calculateQuantity } = require("./quantity-calculate.cjs"); const { memoryRows } = require("./import-estx.cjs"); const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const quantityFields = [ "Index", "Code", "LinkIndex", "LinkCode", "Name", "Size", "Unite", "GN", "GN_", ]; 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); const oldCodes = new Set(oldSheet.items.map((item) => item.Code)); const sameCodes = same( oldSheet.items.map((item) => item.Code), sheet.items.map((item) => item.Code), ); const codes = new Set(); for (const item of sheet.items) { if (!sameCodes && (!item.Code || codes.has(item.Code))) throw Error("수량 항목 코드가 없거나 중복됩니다."); codes.add(item.Code); } for (const item of oldSheet.items) { if (codes.has(item.Code)) continue; if ( item.TargetCode || item.TargetKey || item.LinkCode || item.LinkIndex || oldSheet.rows.some( (row) => (row.markers || []).includes(item.Code) || row.LineData.includes(item.Code), ) ) throw Error("참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다."); } const added = sheet.items.filter((item) => !oldCodes.has(item.Code)); for (const item of added) { if (!/^r\d{6}$/.test(item.Code || "")) throw Error("수량 항목 코드가 없거나 중복됩니다."); if (item.TargetCode || item.TargetKey || item.LinkCode || item.LinkIndex) throw Error("신규 수량 항목의 내역 연결은 ESTX 저장을 지원하지 않습니다."); if ( [ [item.Name, 80], [item.Size, 80], [item.Unite, 8], ].some( ([value, limit]) => value != null && (typeof value !== "string" || value.length > limit), ) || (item.GN != null && (typeof item.GN !== "number" || !Number.isFinite(item.GN))) ) throw Error("신규 수량 항목의 이름·규격·단위 또는 수량 형식 오류"); if ( Object.keys(item).some( (key) => !["Code", "Name", "Size", "Unite", "GN", "TargetCode"].includes(key), ) ) throw Error("신규 수량 항목에 저장할 수 없는 속성이 있습니다."); } if ( !same( { ...oldSheet, Name: "", rows: [], items: [] }, { ...sheet, Name: "", rows: [], items: [] }, ) || !same( withoutLabels(oldSheet.items.filter((item) => codes.has(item.Code))), withoutLabels(sheet.items.filter((item) => oldCodes.has(item.Code))), ) || oldSheet.rows.length !== sheet.rows.length ) throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다."); const parse = rowParser(text); 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 appendRows(text, sheet, rows) { if (!/]*>/gi)] .map((match) => quantity.entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]) .filter(Boolean), ); let nextTag = 1; for (const match of text.matchAll(/\bTag=(\d+)/gi)) nextTag = Math.max(nextTag, Number(match[1]) + 1); for (const row of rows) { const matches = [...row.LineData.matchAll(/\x12([^\x12]*)\x12/g)]; const code = row.markers?.[0]; if ( matches.length !== 1 || row.markers?.length !== 1 || !/^r\d{6}$/.test(code || "") || used.has(code) ) throw Error("추가 산출 행은 단일 결과 표지와 숫자 코드가 필요합니다."); if (!sheet.items.some((item) => item.Code === code)) throw Error("추가 산출 행에 연결된 수량 항목이 없습니다."); used.add(code); const [match] = matches; const plain = row.LineData.slice(0, match.index) + row.LineData.slice(match.index + match[0].length); if (/[\x00-\x1f]|\^\(/.test(plain) || /[\x00-\x1f]/.test(match[1])) throw Error("추가 산출 행의 복합 표지나 위첨자는 ESTX 저장을 지원하지 않습니다."); const tag = nextTag++; text += `
${escapeText(row.LineData.slice(0, match.index))}${escapeText(match[1])}${escapeText(row.LineData.slice(match.index + match[0].length))}
\r\n`; } return text; } function insertRows(text, sheet, inserts) { let staged = text; const snippets = inserts.map(({ row, at }) => { const next = appendRows(staged, sheet, [row]); const snippet = next.slice(staged.length); staged = next; return { at, snippet }; }); const parse = rowParser(text); let index = 0; const stylePosition = text.search(/]*>[\s\S]*?<\/div>/gi, (div, offset) => { if (!parse(div).length) return div; const before = snippets .filter((item) => item.at === index) .map((item) => item.snippet) .join(""); if (before && offset < stylePosition) throw Error("추가 위치 앞에 기본 글자 서식이 없어 ESTX에 저장할 수 없습니다."); index++; return before + div; }); return ( updated + snippets .filter((item) => item.at === index) .map((item) => item.snippet) .join("") ); } function removeRows(text, sheet, codes, plainIndexes = new Set()) { const parse = rowParser(text); const tags = tagCounts(text); let index = 0; const removed = new Set(); const removedPlain = new Set(); const document = text.replace(/]*>[\s\S]*?<\/div>/gi, (div) => { const parsed = parse(div); if (!parsed.length) return div; const rowIndex = index++, row = sheet.rows[rowIndex], code = row?.markers?.[0]; if (!row || !same(parsed[0], row)) throw Error("수량산출서 원본 행 대응 오류"); if (!codes.has(code) && !plainIndexes.has(rowIndex)) return div; if (plainIndexes.has(rowIndex)) { if (row.markers?.length || /<(?!\/?div\b|c\b|cs\b)[^>]+>/i.test(div)) throw Error("문서 표지나 특수 서식이 있는 산출 행은 ESTX에서 삭제할 수 없습니다."); removedPlain.add(rowIndex); return [...div.matchAll(/]*>/gi)].map((match) => match[0]).join(""); } const item = sheet.items.find((value) => value.Code === code); const begins = [...div.matchAll(/]*\bTag=(\d+)[^>]*>/gi)]; if ( row.markers.length !== 1 || removed.has(code) || !item || item.TargetCode || item.TargetKey || item.LinkCode || item.LinkIndex || begins.length !== 1 || tags.get(begins[0]?.[1]) !== 2 || /<(?!\/?div\b|c\b|cs\b|begin\b|end\b)[^>]+>/i.test(div) ) throw Error("다른 표지나 연결을 포함한 산출 행은 ESTX에서 삭제할 수 없습니다."); removed.add(code); return [...div.matchAll(/]*>/gi)].map((match) => match[0]).join(""); }); if ( index !== sheet.rows.length || removed.size !== codes.size || removedPlain.size !== plainIndexes.size ) throw Error("삭제할 산출 행을 원본 문서에서 찾을 수 없습니다."); return document; } function tagCounts(text) { const counts = new Map(); for (const match of text.matchAll(/\bTag=(\d+)(?=\D|$)/gi)) counts.set(match[1], (counts.get(match[1]) || 0) + 1); return counts; } function markerCounts(values) { const counts = new Map(); for (const row of values) { if (!row.markers?.length) continue; const key = JSON.stringify(row.markers); counts.set(key, (counts.get(key) || 0) + 1); } return counts; } function reorderRows(text, sheet, rows) { const before = markerCounts(sheet.rows), after = markerCounts(rows); const keyFor = (row) => { const markers = JSON.stringify(row.markers); return row.markers?.length && before.get(markers) === 1 && after.get(markers) === 1 ? `marker:${markers}` : `row:${JSON.stringify(row)}`; }; const remaining = new Map(); sheet.rows.forEach((row, index) => { const key = keyFor(row); if (!remaining.has(key)) remaining.set(key, { indexes: [], next: 0 }); remaining.get(key).indexes.push(index); }); const order = []; for (const row of rows) { const entry = remaining.get(keyFor(row)); if (!entry || entry.next >= entry.indexes.length) return { text, rows: sheet.rows }; order.push(entry.indexes[entry.next++]); } if (order.length !== sheet.rows.length || order.every((value, index) => value === index)) return { text, rows: sheet.rows }; const parse = rowParser(text); const divs = [...text.matchAll(/]*>[\s\S]*?<\/div>/gi)].filter( (match) => parse(match[0]).length, ); if (divs.length !== sheet.rows.length) throw Error("수량산출서 원본 행 대응 오류"); if (divs.some((match, index) => !same(parse(match[0])[0], sheet.rows[index]))) throw Error("수량산출서 원본 행 대응 오류"); const changed = order .map((source, index) => (source === index ? -1 : index)) .filter((n) => n >= 0); const first = divs[changed[0]].index; const styles = new Set( [...text.slice(0, first).matchAll(/]*\bnr=(\d+)\b/gi)].map((m) => m[1]), ); const last = divs[changed.at(-1)]; const redefined = new Set( [...text.slice(first, last.index + last[0].length).matchAll(/]*\bnr=(\d+)\b/gi)].map( (m) => m[1], ), ); const tags = tagCounts(text); for (const index of changed) { const div = divs[index][0]; const referenced = [ ...[...div.matchAll(/]*\bcs=(\d+)/gi)].map((m) => m[1]), ...[...div.matchAll(/]*\bnr=(\d+)/gi)].map((m) => m[1]), ]; const begins = [...div.matchAll(/]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]); const ends = [...div.matchAll(/]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]); if ( / !styles.has(id) || redefined.has(id)) || /<(?!\/?div\b|c\b|begin\b|end\b)[^>]+>/i.test(div) || !same(begins.slice().sort(), ends.slice().sort()) || begins.some((id) => tags.get(id) !== 2) ) throw Error("공유 표지나 글자 서식이 있는 산출 행은 ESTX에서 이동할 수 없습니다."); } let index = 0; const document = text.replace(/]*>[\s\S]*?<\/div>/gi, (div) => { if (!parse(div).length) return div; return divs[order[index++]][0]; }); return { text: document, rows: order.map((source) => sheet.rows[source]) }; } function updateQuantities(project, base, cp949, memory) { const sheets = project.quantitySheets || [], originals = base.quantitySheets || []; if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다."); if (sheets.some((sheet) => sheet.markerEdited)) throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code)) throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); const rows = project.tables.MulMainDat.map((r) => ({ ...r })); const titles = (project.tables.MulTitleDat || []).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 (sheets[i].Name !== originals[i].Name) { const matches = titles.filter((title) => title.Code === originals[i].Code), oldMatches = (base.tables.MulTitleDat || []).filter( (title) => title.Code === originals[i].Code, ); if (matches.length !== 1 || oldMatches.length !== 1 || matches[0].Name !== oldMatches[0].Name) throw Error("수량산출서 원본 제목 데이터 충돌"); if (typeof sheets[i].Name !== "string" || !sheets[i].Name.trim()) throw Error("수량산출서 이름을 입력하세요."); matches[0].Name = sheets[i].Name; } if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor)) throw Error("수량산출서 원본 편집기 데이터 충돌"); const resultBase = (code) => String(code ?? "").match(/^([LrG]\d{6})(?:[123]\d)?$/)?.[1]; if ( originals[i].items.some( (item) => resultBase(item.Code) && !sheets[i].items.some((next) => next.Code === item.Code) && sheets[i].items.some((next) => resultBase(next.Code) === resultBase(item.Code)), ) ) throw Error("수량 항목 끝수 처리 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); const removed = new Set( originals[i].items .filter((item) => !sheets[i].items.some((next) => next.Code === item.Code)) .map((item) => item.Code), ); if (removed.size) for (const item of [ ...(base.tables.EstMainDat || []), ...(project.tables.EstMainDat || []), ]) { if (!item.MulCode?.binary) continue; const bytes = Buffer.from(item.MulCode.binary, "base64"); let text; try { const wide = bytes.length > 1 && bytes.length % 2 === 0 && [...bytes].every((byte, index) => index % 2 === 0 || byte === 0); text = wide ? bytes.toString("utf16le") : new TextDecoder("euc-kr", { fatal: true }).decode(bytes); } catch { throw Error("원본 수량 연결을 확인할 수 없어 항목을 삭제할 수 없습니다."); } const [sheetCode, itemCode] = text.replace(/\0+$/, "").split("\t"); if (sheetCode === originals[i].Code && removed.has(itemCode)) throw Error("내역에서 참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다."); } const fields = Object.keys(memoryRows(Buffer.from(oldRow.MulList.binary, "base64"))[0] || {}); const originalCodes = new Set(originals[i].items.map((item) => item.Code)); const savedItems = (items) => items.map((item) => originalCodes.has(item.Code) ? item : Object.fromEntries( (fields.length ? fields : quantityFields).map((field) => [ field, item[field] ?? null, ]), ), ); 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("수량산출서 원본 인코딩 보존 불가"); const presentMarkers = new Set(sheets[i].rows.flatMap((line) => line.markers || [])); const deletedRows = new Set( originals[i].rows.flatMap((line) => (line.markers || []).filter((code) => removed.has(code) && !presentMarkers.has(code)), ), ); const originalMarkers = new Set(originals[i].rows.flatMap((line) => line.markers || [])); const existingRows = [], inserts = []; for (const line of sheets[i].rows) { const marker = line.markers?.[0]; if (line.markers?.length === 1 && !originalMarkers.has(marker) && !originalCodes.has(marker)) inserts.push({ row: line, at: existingRows.length }); else existingRows.push(line); } let retainedRows = originals[i].rows.filter( (line) => !(line.markers || []).some((code) => deletedRows.has(code)), ); const plainIndexes = new Set(); if (existingRows.length < retainedRows.length) { const counts = new Map(); const oldMarkers = markerCounts(retainedRows), newMarkers = markerCounts(existingRows); for (const line of originals[i].rows) { const key = JSON.stringify(line); counts.set(key, (counts.get(key) || 0) + 1); } let target = 0; for (let n = 0; n < originals[i].rows.length; n++) { const line = originals[i].rows[n]; if ((line.markers || []).some((code) => deletedRows.has(code))) continue; const key = JSON.stringify(line.markers); const matchesMarker = line.markers?.length && oldMarkers.get(key) === 1 && newMarkers.get(key) === 1 && key === JSON.stringify(existingRows[target]?.markers); if (target < existingRows.length && (same(line, existingRows[target]) || matchesMarker)) target++; else if (!line.markers?.length && counts.get(JSON.stringify(line)) === 1) plainIndexes.add(n); else throw Error("중복되거나 함께 수정된 산출 행은 ESTX에서 삭제할 수 없습니다."); } if (target !== existingRows.length) throw Error("삭제할 산출 행을 확인할 수 없습니다."); retainedRows = originals[i].rows.filter( (line, n) => !plainIndexes.has(n) && !(line.markers || []).some((code) => deletedRows.has(code)), ); } if (existingRows.length !== retainedRows.length) throw Error("수량산출 행 삭제는 ESTX 저장을 지원하지 않습니다."); const existing = { ...sheets[i], rows: existingRows }; const source = deletedRows.size || plainIndexes.size ? removeRows(text, originals[i], deletedRows, plainIndexes) : text; let retained = { ...originals[i], rows: retainedRows, items: originals[i].items.filter((item) => !deletedRows.has(item.Code)), }; const reordered = reorderRows(source, retained, existingRows); retained = { ...retained, rows: reordered.rows }; let document = editDocument(reordered.text, retained, existing); if (inserts.length) { document = insertRows(document, sheets[i], inserts); if ( !same( quantity.fromWpt( sheets[i].Code, sheets[i].Name, document, sheets[i].items, sheets[i].options, ), sheets[i], ) ) throw Error("추가 산출 행 재읽기 검산 실패"); } if (same(sheets[i].rows, originals[i].rows)) { 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, true).toString( "base64", ), }; savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) }; 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), updatedMarkerCount = sheets[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 === updatedMarkerCount && 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, true).toString( "base64", ), }; savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) }; const output = cp949(document); row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") }; continue; } if ( baseline.checks.length !== markerCount || calculated.checks.length !== updatedMarkerCount || 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, true).toString( "base64", ), }; current.items = savedItems(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, MulTitleDat: titles }, }; } module.exports = { editDocument, updateQuantities, appendRows, insertRows, removeRows, reorderRows, quantityFields, };