Files

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