327 lines
13 KiB
JavaScript
327 lines
13 KiB
JavaScript
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 } = require("./export-quantity.cjs");
|
|
const { updatePrintOptions } = require("./export-print.cjs");
|
|
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "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) {
|
|
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)
|
|
throw Error("원가 서식 행 추가/삭제는 ESTX 인덱스 재생성이 필요합니다.");
|
|
values[4] = rows.map((row, i) => [
|
|
entries[i][0],
|
|
...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, 24).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 };
|