391 lines
15 KiB
JavaScript
391 lines
15 KiB
JavaScript
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
|
|
const zlib = require("node:zlib");
|
|
const quantity = require("./native-quantity.cjs");
|
|
const { readPrintOptions } = require("./import-print.cjs");
|
|
const { existingTargets } = require("./native-links.cjs");
|
|
const decoder = new TextDecoder("euc-kr", { fatal: true });
|
|
const LIMIT = 256 * 1024 * 1024;
|
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
|
|
return n >>> 0;
|
|
});
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
function unzip(buffer) {
|
|
let end = -1;
|
|
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65557); i--)
|
|
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
|
end = i;
|
|
break;
|
|
}
|
|
if (end < 0) throw Error("ZIP 디렉터리를 찾을 수 없습니다.");
|
|
const count = buffer.readUInt16LE(end + 10),
|
|
files = new Map();
|
|
if (count > 3000) throw Error("ZIP 항목이 너무 많습니다.");
|
|
let position = buffer.readUInt32LE(end + 16),
|
|
total = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
if (buffer.readUInt32LE(position) !== 0x02014b50) throw Error("ZIP 디렉터리 오류");
|
|
const flags = buffer.readUInt16LE(position + 8),
|
|
method = buffer.readUInt16LE(position + 10),
|
|
crc = buffer.readUInt32LE(position + 16);
|
|
const compressed = buffer.readUInt32LE(position + 20),
|
|
size = buffer.readUInt32LE(position + 24);
|
|
const nameLength = buffer.readUInt16LE(position + 28),
|
|
extra = buffer.readUInt16LE(position + 30),
|
|
comment = buffer.readUInt16LE(position + 32),
|
|
local = buffer.readUInt32LE(position + 42);
|
|
const name = buffer.subarray(position + 46, position + 46 + nameLength).toString("utf8");
|
|
if (flags & 1) throw Error("암호화된 ESTX는 지원하지 않습니다.");
|
|
if (!name || name.includes("..") || /[\\/]/.test(name)) throw Error("예상하지 못한 ZIP 경로");
|
|
total += size;
|
|
if (total > LIMIT) throw Error("압축 해제 크기 한도 초과");
|
|
if (buffer.readUInt32LE(local) !== 0x04034b50) throw Error("ZIP 로컬 헤더 오류");
|
|
const start = local + 30 + buffer.readUInt16LE(local + 26) + buffer.readUInt16LE(local + 28);
|
|
if (start + compressed > buffer.length) throw Error("ZIP 데이터 범위 오류");
|
|
const source = buffer.subarray(start, start + compressed);
|
|
const data =
|
|
method === 8
|
|
? zlib.inflateRawSync(source, { maxOutputLength: LIMIT })
|
|
: method === 0
|
|
? source
|
|
: null;
|
|
if (!data || data.length !== size || crc32(data) !== crc)
|
|
throw Error(`ZIP CRC/길이 오류: ${name}`);
|
|
if (files.has(name.toLowerCase())) throw Error(`중복 파일: ${name}`);
|
|
files.set(name.toLowerCase(), data);
|
|
position += 46 + nameLength + extra + comment;
|
|
}
|
|
return files;
|
|
}
|
|
function blob(file, start, id) {
|
|
if (!file || start === 0) return Buffer.alloc(0);
|
|
const parts = [],
|
|
seen = new Set();
|
|
let block = start,
|
|
previous = 0,
|
|
expected = 0,
|
|
received = 0;
|
|
while (block) {
|
|
const offset = block * 512;
|
|
if (seen.has(block) || offset + 512 > file.length) throw Error("BLOB 체인 범위/순환 오류");
|
|
seen.add(block);
|
|
if (file.readUInt32LE(offset) !== previous) throw Error("BLOB 역연결 오류");
|
|
const length = file.readUInt16LE(offset + 8);
|
|
if (length > 494) throw Error("BLOB 조각 길이 오류");
|
|
if (block === start) {
|
|
if (file.readUInt32LE(offset + 10) !== id) throw Error("BLOB 식별자 오류");
|
|
expected = file.readUInt32LE(offset + 14);
|
|
if (expected > LIMIT) throw Error("BLOB 크기 한도 초과");
|
|
}
|
|
const take = Math.min(length, expected - received);
|
|
parts.push(file.subarray(offset + 18, offset + 18 + take));
|
|
received += take;
|
|
// Reused DBISAM BLOB allocations may retain linked capacity after the
|
|
// logical end. The first block's declared length owns the payload boundary.
|
|
if (received === expected) break;
|
|
previous = block;
|
|
block = file.readUInt32LE(offset + 4);
|
|
}
|
|
const result = Buffer.concat(parts);
|
|
if (result.length !== expected)
|
|
throw Error(
|
|
`BLOB 총 길이 오류: 블록 ${start}, ID ${id}, 선언 ${expected}, 읽음 ${result.length}`,
|
|
);
|
|
return result;
|
|
}
|
|
function table(buffer, blobs, warnings) {
|
|
if (buffer.length < 512) throw Error("테이블 헤더가 짧습니다.");
|
|
const stride = buffer.readUInt16LE(45),
|
|
count = buffer.readUInt16LE(47),
|
|
start = 512 + 768 * count;
|
|
if (!stride || count > 1024 || start > buffer.length || (buffer.length - start) % stride)
|
|
throw Error("DBISAM 후보 레이아웃과 다른 파일입니다.");
|
|
const fields = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const offset = 512 + 768 * i,
|
|
name = buffer.subarray(offset + 3, offset + 3 + buffer[offset + 2]).toString("ascii");
|
|
const kind = buffer[offset + 164],
|
|
width = buffer.readUInt16LE(offset + 169),
|
|
payload = buffer.readUInt32LE(offset + 172);
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || payload + width + 1 > stride)
|
|
throw Error("필드 설명자 오류");
|
|
fields.push({ name, kind, width, payload });
|
|
}
|
|
const rows = [];
|
|
for (let offset = start; offset < buffer.length; offset += stride) {
|
|
const row = {};
|
|
for (const f of fields) {
|
|
const flag = buffer[offset + f.payload],
|
|
raw = buffer.subarray(offset + f.payload + 1, offset + f.payload + 1 + f.width);
|
|
if (!flag) {
|
|
row[f.name] = null;
|
|
continue;
|
|
}
|
|
if (flag !== 1) throw Error(`지원하지 않는 행 존재 플래그: ${flag}`);
|
|
if (f.kind === 1) {
|
|
const zero = raw.indexOf(0);
|
|
const bytes = zero >= 0 ? raw.subarray(0, zero) : raw;
|
|
try {
|
|
row[f.name] = decoder.decode(bytes);
|
|
} catch {
|
|
row[f.name] = new TextDecoder("euc-kr").decode(bytes);
|
|
row._originalTextBytes ||= {};
|
|
row._originalTextBytes[f.name] = bytes.toString("base64");
|
|
warnings.add(`CP949 범위 밖 문자열: ${f.name}. 원문 바이트를 별도 보존했습니다.`);
|
|
}
|
|
} else if (f.kind === 7 && f.width === 8) {
|
|
row[f.name] = raw.readDoubleLE();
|
|
if (!Number.isFinite(row[f.name])) throw Error("유효하지 않은 실수 필드");
|
|
} else if ([2, 6].includes(f.kind) && f.width === 4) row[f.name] = raw.readInt32LE();
|
|
else if (f.kind === 4 && f.width === 2) row[f.name] = raw.readInt16LE();
|
|
else if (f.kind === 3 && f.width === 8) {
|
|
const bytes = blob(blobs, raw.readUInt32LE(), raw.readUInt32LE(4));
|
|
row[f.name] = { binary: bytes.toString("base64") };
|
|
} else {
|
|
row[f.name] = { binary: raw.toString("base64"), kind: f.kind };
|
|
warnings.add(`미해석 필드형: ${f.kind}/${f.width}`);
|
|
}
|
|
}
|
|
rows.push(row);
|
|
}
|
|
return rows;
|
|
}
|
|
class Values {
|
|
constructor(buffer) {
|
|
this.buffer = buffer;
|
|
this.position = 4;
|
|
}
|
|
take(n) {
|
|
if (n < 0 || this.position + n > this.buffer.length) throw Error("TPF0 범위 오류");
|
|
const bytes = this.buffer.subarray(this.position, this.position + n);
|
|
this.position += n;
|
|
return bytes;
|
|
}
|
|
byte() {
|
|
return this.take(1)[0];
|
|
}
|
|
length() {
|
|
return this.take(4).readUInt32LE();
|
|
}
|
|
short() {
|
|
return decoder.decode(this.take(this.byte()));
|
|
}
|
|
value(depth = 0) {
|
|
if (depth > 40) throw Error("TPF0 중첩 한도 초과");
|
|
const tag = this.byte();
|
|
if ([0, 13].includes(tag)) return null;
|
|
if (tag === 1) {
|
|
const values = [];
|
|
while (this.buffer[this.position] !== 0) values.push(this.value(depth + 1));
|
|
this.byte();
|
|
return values;
|
|
}
|
|
if (tag === 2) return this.take(1).readInt8();
|
|
if (tag === 3) return this.take(2).readInt16LE();
|
|
if (tag === 4) return this.take(4).readInt32LE();
|
|
if (tag === 19) return Number(this.take(8).readBigInt64LE());
|
|
if (tag === 5) {
|
|
const raw = this.take(10),
|
|
mantissa = Number(raw.readBigUInt64LE()),
|
|
exponent = raw.readUInt16LE(8),
|
|
sign = exponent & 0x8000 ? -1 : 1;
|
|
return ((sign * mantissa) / 2 ** 63) * 2 ** ((exponent & 0x7fff) - 16383);
|
|
}
|
|
if (tag === 16) return Number(this.take(8).readBigInt64LE()) / 10000;
|
|
if ([17, 21].includes(tag)) return this.take(8).readDoubleLE();
|
|
if (tag === 15) return this.take(4).readFloatLE();
|
|
if ([6, 7].includes(tag)) return decoder.decode(this.take(this.byte()));
|
|
if (tag === 8 || tag === 9) return tag === 9;
|
|
if (tag === 10) return this.take(this.length());
|
|
if (tag === 12) return decoder.decode(this.take(this.length()));
|
|
if (tag === 18) return this.take(this.length() * 2).toString("utf16le");
|
|
if (tag === 20) return this.take(this.length()).toString("utf8");
|
|
if (tag === 11) {
|
|
const values = [];
|
|
while (this.buffer[this.position] !== 0) values.push(decoder.decode(this.take(this.byte())));
|
|
this.byte();
|
|
return values;
|
|
}
|
|
throw Error(`미지원 TPF0 태그: ${tag}`);
|
|
}
|
|
}
|
|
function optionObjects(buffer, warnings) {
|
|
const objects = [];
|
|
let offset = 0;
|
|
while ((offset = buffer.indexOf("TPF0", offset)) >= 0) {
|
|
const length = offset >= 4 ? buffer.readUInt32LE(offset - 4) : 0;
|
|
if (length < 6 || offset + length > buffer.length) {
|
|
offset += 4;
|
|
continue;
|
|
}
|
|
try {
|
|
const reader = new Values(buffer.subarray(offset, offset + length));
|
|
const className = reader.short(),
|
|
name = reader.short(),
|
|
properties = {};
|
|
if (!/^T[A-Za-z]+Option$/.test(className)) {
|
|
offset += 4;
|
|
continue;
|
|
}
|
|
while (reader.buffer[reader.position] !== 0) properties[reader.short()] = reader.value();
|
|
reader.byte();
|
|
reader.byte();
|
|
if (reader.position !== reader.buffer.length) throw Error("옵션 객체 길이 불일치");
|
|
objects.push({ className, name, properties });
|
|
} catch (error) {
|
|
warnings.add(`옵션 객체: ${error.message}`);
|
|
}
|
|
offset += Math.max(4, length);
|
|
}
|
|
return objects;
|
|
}
|
|
function memoryRows(buffer) {
|
|
if (buffer[0] === 0x78) buffer = zlib.inflateSync(buffer, { maxOutputLength: LIMIT });
|
|
if (buffer.subarray(0, 4).toString() !== "TPF0") throw Error("TPF0 메모리 표가 아닙니다.");
|
|
const reader = new Values(buffer),
|
|
values = [];
|
|
while (reader.position < buffer.length) values.push(reader.value());
|
|
const descriptors = values[1],
|
|
entries = values[4];
|
|
if (!Array.isArray(descriptors) || !Array.isArray(entries) || descriptors.length % 10)
|
|
throw Error("메모리 표 설명자 오류");
|
|
const fields = Array.from({ length: descriptors.length / 10 }, (_, i) => descriptors[i * 10]);
|
|
return entries.map((entry) => {
|
|
const row = {};
|
|
let position = 1;
|
|
for (const name of fields) {
|
|
const absent = entry[position++];
|
|
row[name] = absent ? null : entry[position++];
|
|
}
|
|
if (position !== entry.length) throw Error("메모리 표 행 길이 오류");
|
|
return row;
|
|
});
|
|
}
|
|
function importEstx(buffer, name = "가져온 내역서") {
|
|
const files = unzip(buffer),
|
|
tables = {},
|
|
warnings = new Set(),
|
|
templates = [];
|
|
for (const [filename, data] of files) {
|
|
if (
|
|
!filename.endsWith(".dat") ||
|
|
!data.subarray(8, 24).equals(Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"))
|
|
)
|
|
continue;
|
|
let rows;
|
|
try {
|
|
rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
|
|
} catch (error) {
|
|
throw Error(`${filename}: ${error.message}`);
|
|
}
|
|
const key = filename.replace(/\.dat$/, "");
|
|
const canonical =
|
|
{
|
|
stuffcdat: "StuffcDat",
|
|
laborcdat: "LaborcDat",
|
|
siljukdat: "SilJukDat",
|
|
estimatedat: "EstiMateDat",
|
|
machgijundat: "MachGijunDat",
|
|
machmaindat: "MachMainDat",
|
|
machtitledat: "MachTitleDat",
|
|
costmaindat: "CostMainDat",
|
|
costtitledat: "CostTitleDat",
|
|
headmaindat: "HeadMainDat",
|
|
headtitledat: "HeadTitleDat",
|
|
estmaindat: "EstMainDat",
|
|
mulmaindat: "MulMainDat",
|
|
multitledat: "MulTitleDat",
|
|
cadmaindat: "CadMainDat",
|
|
cadtitledat: "CadTitleDat",
|
|
}[key] || key;
|
|
tables[canonical] = rows;
|
|
if (key === "wongadat")
|
|
for (const row of rows) {
|
|
const value = Object.values(row).find((value) => value?.binary);
|
|
if (value) {
|
|
try {
|
|
templates.push({
|
|
index: row.Index,
|
|
rows: memoryRows(Buffer.from(value.binary, "base64")),
|
|
});
|
|
} catch (error) {
|
|
warnings.add(`원가 서식 ${row.Index}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!tables.EstMainDat) throw Error("내역 테이블이 없는 ESTX입니다.");
|
|
tables.Wonga = templates[0]?.rows || [];
|
|
const objects = files.has("datestoption.opt")
|
|
? optionObjects(files.get("datestoption.opt"), warnings)
|
|
: [];
|
|
const props = Object.fromEntries(objects.map((o) => [o.className, o.properties]));
|
|
const options = {
|
|
GCToWonga: props.TWongaOption?.GCToWonga ?? false,
|
|
GangubExceptJodalRatio: props.TWongaOption?.GangubExceptJodalRatio ?? false,
|
|
EYunUp: props.TWongaOption?.EYunUp ?? false,
|
|
SourceRefresh: false,
|
|
VerFormat: props.TWongaOption?.VerFormat ?? true,
|
|
TotalFix: props.TWongaOption?.TotalFix ?? tables.Wonga.some((r) => r.LinkIndex === 55),
|
|
AutoCalc: props.TEstOption?.CalcAuto ?? true,
|
|
CostValueTrunc: props.TCostOption?.CostValueTrunc ?? false,
|
|
MidCostDiget: props.TCostOption?.MidCostDiget ?? 2,
|
|
HeadDigits: props.THeadOption?.SumValueDiget || 0,
|
|
TotalFit: props.THeadOption?.CalcTrunc || false,
|
|
DollarCostCut: props.TMachOption?.DollarCostCut || false,
|
|
JDLCalcMethod: props.TCommonOption?.JDLCalcMethod || false,
|
|
OldRecalcMethod: props.TChangeOption?.OldRecalcMethod || 0,
|
|
};
|
|
const quantitySheets = [];
|
|
let printCatalog = [];
|
|
if (files.has("datprintoption.opt")) {
|
|
try {
|
|
printCatalog = readPrintOptions(files.get("datprintoption.opt"));
|
|
} catch (error) {
|
|
warnings.add(`인쇄 설정: ${error.message}`);
|
|
}
|
|
}
|
|
for (const row of tables.MulMainDat || []) {
|
|
if (!row.Editor?.binary || !row.MulList?.binary) continue;
|
|
try {
|
|
const packed = Buffer.from(row.Editor.binary, "base64");
|
|
const bytes =
|
|
packed[0] === 0x78 ? zlib.inflateSync(packed, { maxOutputLength: LIMIT }) : packed;
|
|
const text = new TextDecoder("euc-kr").decode(bytes);
|
|
const items = memoryRows(Buffer.from(row.MulList.binary, "base64"));
|
|
const name = tables.MulTitleDat?.find((r) => r.Code === row.Code)?.Name || row.Code;
|
|
quantitySheets.push(
|
|
quantity.fromWpt(row.Code, name, text, items, {
|
|
digits: props.TMulLangOption?.Diget ?? 2,
|
|
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
warnings.add(`수량산출 ${row.Code}: ${error.message}`);
|
|
}
|
|
}
|
|
existingTargets(tables, quantitySheets, warnings);
|
|
return {
|
|
schema: "est-web-2",
|
|
name: props.TCommonOption?.ProjectName || name,
|
|
tables,
|
|
wongaTemplates: templates,
|
|
originalOptions: objects,
|
|
options,
|
|
quantities: [],
|
|
quantitySheets,
|
|
printCatalog,
|
|
progress: [],
|
|
annual: [],
|
|
schedule: [],
|
|
drawings: [],
|
|
importWarnings: [...warnings, "활성 원가 서식과 인덱스 편집 규칙은 자동 확정하지 않았습니다."],
|
|
originalArchive: buffer.toString("base64"),
|
|
};
|
|
}
|
|
module.exports = { importEstx, unzip, table, blob, memoryRows, optionObjects, crc32, Values };
|