Files

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 };