const zlib = require("node:zlib"); const { importEstx, unzip, Values, memoryRows } = require("./import-estx.cjs"); const { pack } = require("./archive.cjs"); const { updateOptions } = require("./export-options.cjs"); const { updateQuantities, quantityFields } = require("./export-quantity.cjs"); const { updatePrintOptions } = require("./export-print.cjs"); const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d001", "hex"); const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); let characters; function cp949(text) { if (!characters) { characters = new Map(); const decoder = new TextDecoder("euc-kr", { fatal: true }); for (let i = 0; i < 128; i++) characters.set(String.fromCharCode(i), Buffer.from([i])); for (let a = 0x81; a <= 0xfe; a++) for (let b = 0x41; b <= 0xfe; b++) { try { const bytes = Buffer.from([a, b]), s = decoder.decode(bytes); if (s.length === 1 && !characters.has(s)) characters.set(s, bytes); } catch {} } } const parts = []; for (const c of String(text)) { const bytes = characters.get(c); if (!bytes) throw Error(`CP949로 저장할 수 없는 문자: ${c}`); parts.push(bytes); } return Buffer.concat(parts); } function value(data) { if (data === null) return Buffer.from([13]); if (typeof data === "boolean") return Buffer.from([data ? 9 : 8]); if (typeof data === "number") { if (!Number.isFinite(data)) throw Error("TPF0 숫자 오류"); if (Number.isInteger(data) && data >= -128 && data <= 127) { const b = Buffer.alloc(2); b[0] = 2; b.writeInt8(data, 1); return b; } if (Number.isInteger(data) && data >= -2147483648 && data <= 2147483647) { const b = Buffer.alloc(5); b[0] = 4; b.writeInt32LE(data, 1); return b; } const b = Buffer.alloc(9); b[0] = 21; b.writeDoubleLE(data, 1); return b; } if (typeof data === "string") { const b = Buffer.from(data, "utf16le"), h = Buffer.alloc(5); h[0] = 18; h.writeUInt32LE(data.length, 1); return Buffer.concat([h, b]); } if (Buffer.isBuffer(data)) { const h = Buffer.alloc(5); h[0] = 10; h.writeUInt32LE(data.length, 1); return Buffer.concat([h, data]); } if (Array.isArray(data)) return Buffer.concat([Buffer.from([1]), ...data.map(value), Buffer.from([0])]); throw Error("지원하지 않는 TPF0 값"); } function memory(buffer, rows, allowListChanges = false) { const compressed = buffer[0] === 0x78, bytes = compressed ? zlib.inflateSync(buffer) : buffer, reader = new Values(bytes), values = []; while (reader.position < bytes.length) values.push(reader.value()); const names = values[1].filter((v, i) => i % 10 === 0), entries = values[4]; if (rows.length !== entries.length && !allowListChanges) throw Error("원가 서식 행 추가/삭제는 ESTX 인덱스 재생성이 필요합니다."); const oldRows = allowListChanges ? memoryRows(buffer) : [], oldCodes = new Map(oldRows.map((row, index) => [row.Code, index])), sameCodes = allowListChanges && same( oldRows.map((row) => row.Code), rows.map((row) => row.Code), ); if (allowListChanges && !sameCodes && oldCodes.size !== oldRows.length) throw Error("수량 목록의 기존 코드가 중복됩니다."); if ( allowListChanges && rows.some((row) => !oldCodes.has(row.Code)) && ((!entries.length && !same(names, quantityFields)) || entries.some((entry) => entry[0] !== 1)) ) throw Error("수량 목록의 신규 행 형식을 확인할 수 없습니다."); values[4] = rows.map((row, i) => [ entries[allowListChanges && !sameCodes ? oldCodes.get(row.Code) : i]?.[0] ?? 1, ...names.flatMap((name) => (row[name] == null ? [true] : [false, row[name]])), ]); const result = Buffer.concat([Buffer.from("TPF0"), ...values.map(value)]); const output = compressed ? zlib.deflateSync(result) : result; if ( !same( memoryRows(output), rows.map((row) => Object.fromEntries(names.map((name) => [name, row[name] ?? null]))), ) ) throw Error("원가 서식 인코딩 검산 실패"); return output; } function updateBlob(file, start, id, bytes) { const blocks = [], seen = new Set(); let block = start; while (block) { if (seen.has(block) || block * 512 + 512 > file.length) throw Error("BLOB 체인 오류"); seen.add(block); blocks.push(block); block = file.readUInt32LE(block * 512 + 4); } if (file.readUInt32LE(start * 512 + 10) !== id) throw Error("BLOB ID 오류"); const extra = Math.max(0, Math.ceil(bytes.length / 494) - blocks.length); if (extra) { const oldCount = file.length / 512; if (!Number.isInteger(oldCount) || file.readUInt32LE(29) !== oldCount) throw Error("BLOB 블록 수 헤더가 원본 형식과 다릅니다."); if ((oldCount + extra) * 512 > 256 * 1024 * 1024) throw Error("BLOB 크기 한도 초과"); file = Buffer.concat([file, Buffer.alloc(extra * 512)]); for (let i = 0; i < extra; i++) { const previous = blocks.at(-1), number = oldCount + i; file.writeUInt32LE(number, previous * 512 + 4); file.writeUInt32LE(previous, number * 512); blocks.push(number); } file.writeUInt32LE(oldCount + extra, 29); } let at = 0; for (const [i, number] of blocks.entries()) { const offset = number * 512, take = Math.min(494, bytes.length - at); file.writeUInt16LE(take, offset + 8); file.fill(0, offset + 18, offset + 512); bytes.copy(file, offset + 18, at, at + take); at += take; if (i === 0) file.writeUInt32LE(bytes.length, offset + 14); } return file; } function exportEstx(project) { if (!project.originalArchive) throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다."); if (project.attachments?.length) throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if (project.excelQuantityLinks?.length) throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if (project.schedule?.length) throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if ( project.documents && Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0) ) throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); const original = Buffer.from(project.originalArchive, "base64"), base = importEstx(original), files = unzip(original); const optionFields = { GCToWonga: ["TWongaOption", "GCToWonga"], GangubExceptJodalRatio: ["TWongaOption", "GangubExceptJodalRatio"], EYunUp: ["TWongaOption", "EYunUp"], VerFormat: ["TWongaOption", "VerFormat"], TotalFix: ["TWongaOption", "TotalFix"], AutoCalc: ["TEstOption", "CalcAuto"], CostValueTrunc: ["TCostOption", "CostValueTrunc"], MidCostDiget: ["TCostOption", "MidCostDiget"], HeadDigits: ["THeadOption", "SumValueDiget"], TotalFit: ["THeadOption", "CalcTrunc"], DollarCostCut: ["TMachOption", "DollarCostCut"], JDLCalcMethod: ["TCommonOption", "JDLCalcMethod"], OldRecalcMethod: ["TChangeOption", "OldRecalcMethod"], }; const optionChanges = {}; const setOption = (cls, name, data) => { (optionChanges[cls] ||= {})[name] = data; }; if (project.name !== base.name) setOption("TCommonOption", "ProjectName", project.name); for (const key of Object.keys(base.options)) if (key !== "SourceRefresh" && !same(project.options[key], base.options[key])) { if (!optionFields[key]) throw Error(`계산 옵션 ${key} 저장 구조 미확인`); setOption(...optionFields[key], project.options[key]); } if ( project.progress?.length || project.annual?.length || project.baseline || project.contractReview ) throw Error( "웹 기성/연차/당초 사본/계약심사는 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.", ); if (!same(project.schedule || [], base.schedule || [])) throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if ( project.documents && Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0) ) throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if (!same(project.quantitySheets || [], base.quantitySheets || [])) project = updateQuantities(project, base, cp949, memory); let changed = 0; if (project.nativePrintChanges && Object.keys(project.nativePrintChanges).length) { const print = files.get("datprintoption.opt"); if (!print) throw Error("ESTX 원본 인쇄 설정 파일 없음"); files.set("datprintoption.opt", updatePrintOptions(print, project.nativePrintChanges)); changed++; } if (Object.keys(optionChanges).length) { const options = files.get("datestoption.opt"); if (!options) throw Error("ESTX 원본 옵션 파일 없음"); files.set("datestoption.opt", updateOptions(options, optionChanges, value)); changed++; } const oldByLower = new Map(Object.keys(base.tables).map((key) => [key.toLowerCase(), key])); for (const [filename, data] of files) { if (!filename.endsWith(".dat") || !data.subarray(8, 25).equals(signature)) continue; const key = oldByLower.get(filename.slice(0, -4)); if (!key) continue; const rows = project.tables[key], before = base.tables[key]; if (!rows || rows.length !== before.length) throw Error(`${key}: 행 추가/삭제는 인덱스 재생성이 필요합니다.`); const stride = data.readUInt16LE(45), count = data.readUInt16LE(47), start = 512 + 768 * count; const blobName = filename.replace(/\.dat$/, ".blb"); for (let i = 0; i < rows.length; i++) for (let f = 0; f < count; f++) { const desc = 512 + 768 * f, name = data.subarray(desc + 3, desc + 3 + data[desc + 2]).toString("ascii"), kind = data[desc + 164], width = data.readUInt16LE(desc + 169), payload = data.readUInt32LE(desc + 172), at = start + i * stride + payload, old = before[i][name], next = rows[i][name]; if (same(next, old)) continue; // All potential index keys stay byte-identical. No stale index is ever emitted. if (/^(PkID|Index|Code|OldCode|TitleCode|RecNo|Level|Flag)$/i.test(name)) throw Error(`${key}.${name}: 인덱스 키 변경은 지원하지 않습니다.`); if (key.startsWith("Cad")) throw Error("CAD 수정 저장은 범위 밖입니다."); if (next == null) { data[at] = 0; data.fill(0, at + 1, at + 1 + width); changed++; continue; } if (kind === 1) { const bytes = cp949(next); if (bytes.length >= width) throw Error(`${key}.${name}: 원본 문자열 길이 초과`); data.fill(0, at + 1, at + 1 + width); bytes.copy(data, at + 1); } else if (kind === 7 && width === 8) { if (typeof next !== "number" || !Number.isFinite(next)) throw Error("ESTX 실수 오류"); data.writeDoubleLE(next, at + 1); } else if ([2, 6].includes(kind) && width === 4) { if (!Number.isInteger(next)) throw Error("ESTX 정수 오류"); data.writeInt32LE(next, at + 1); } else if (kind === 4 && width === 2) { if (!Number.isInteger(next)) throw Error("ESTX 정수 오류"); data.writeInt16LE(next, at + 1); } else if ( kind === 3 && width === 8 && next?.binary && old?.binary && files.has(blobName) && data[at] ) files.set( blobName, updateBlob( files.get(blobName), data.readUInt32LE(at + 1), data.readUInt32LE(at + 5), Buffer.from(next.binary, "base64"), ), ); else throw Error(`${key}.${name}: 미확인 필드 저장형 ${kind}`); data[at] = 1; changed++; } } // Wonga's active web form is explicit; the original archive/import always chooses form zero. if (!same(project.tables.Wonga, base.tables.Wonga)) { const dat = files.get("wongadat.dat"), blb = files.get("wongadat.blb"); if (!dat || !blb) throw Error("원가 서식 저장 표 없음"); const count = dat.readUInt16LE(47), start = 512 + 768 * count; let field; for (let f = 0; f < count; f++) { const desc = 512 + f * 768; if (dat[desc + 164] === 3) { field = dat.readUInt32LE(desc + 172); break; } } if (field == null) throw Error("원가 BLOB 필드 없음"); const originalBlob = Object.values(base.tables.wongadat[0]).find((v) => v?.binary); const bytes = memory(Buffer.from(originalBlob.binary, "base64"), project.tables.Wonga); files.set( "wongadat.blb", updateBlob( blb, dat.readUInt32LE(start + field + 1), dat.readUInt32LE(start + field + 5), bytes, ), ); changed++; } if (!changed) return original; const result = pack(files), check = importEstx(result); if (check.name !== project.name) throw Error("공사명 저장 후 재읽기 검산 실패"); for (const key of Object.keys(base.options)) if (key !== "SourceRefresh" && !same(check.options[key], project.options[key])) throw Error(`${key}: 옵션 저장 후 재읽기 검산 실패`); for (const [key, rows] of Object.entries(base.tables)) { if (key === "Wonga" || key === "wongadat") continue; for (let i = 0; i < rows.length; i++) for (const name of Object.keys(rows[i])) if (!same(check.tables[key][i][name], project.tables[key][i][name])) throw Error(`${key}.${name}: 저장 후 재읽기 검산 실패`); } if (!same(check.tables.Wonga, project.tables.Wonga)) throw Error("원가 저장 후 재읽기 검산 실패"); if (!same(check.quantitySheets, project.quantitySheets)) throw Error("수량산출서 저장 후 재읽기 검산 실패"); return result; } module.exports = { exportEstx, memory, cp949 };