// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md. const { readXls } = require("./xls-read.cjs"); const excelSheet = require("./native-excel-sheet.cjs"); const { readDocuments } = require("./midas-read.cjs"); 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 signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d001", "hex"); 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")) continue; if (data.subarray(8, 24).equals(signature.subarray(0, 16)) && data[24] !== signature[16]) throw Error(`${filename}: DBISAM 엔진 서명이 올바르지 않습니다.`); if (!data.subarray(8, 25).equals(signature)) { if ( /^(?:stuffcdat|laborcdat|siljukdat|estimatedat|machgijundat|machmaindat|machtitledat|costmaindat|costtitledat|headmaindat|headtitledat|estmaindat|mulmaindat|multitledat|wongadat)\.dat$/.test( filename, ) ) throw Error(`${filename}: DBISAM 테이블 서명이 올바르지 않습니다.`); 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; const sheet = quantity.fromWpt(row.Code, name, text, items, { digits: props.TMulLangOption?.Diget ?? 2, roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1, }); // 엑셀형 수량산출서(OLE2 .xls)는 읽기 전용 셀 값으로 붙인다. if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0) { sheet.excel = readXls(Buffer.from(bytes)); sheet.excelLinks = excelSheet.inferLinks(sheet); } quantitySheets.push(sheet); } catch (error) { warnings.add(`수량산출 ${row.Code}: ${error.message}`); } } existingTargets(tables, quantitySheets, warnings); let docs = {}; try { docs = readDocuments(files); } catch (error) { warnings.add(`갑지·표지 문서: ${error.message}`); } return { schema: "est-web-2", name: props.TCommonOption?.ProjectName || name, tables, docs, 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 };