// 구버전 EST Plus 자료(.est/.ebk = ZIP, 표마다 Delphi 고정 레코드 파일 — tmp 역분석 spec_estplus.md) 읽기·쓰기. // 레코드 394 바이트(기본) · 565 바이트(.ini [공통] data_version = 3). 문자 = ShortString(cp949), 금액 = Extended(10바이트). // 읽기(mnuFileEstPlusLoad → 0x112ee48 · 표별 변환 0xd91504): .dtm 내역 · .hdt 일위 · .cct 단산 · .mdt 기계 · .ldt 노임 · .sdt 자재 · // .sil 표준 · .sti 견적(565 만) · .wdt/.swdt 원가 · c* = 설계변경(「_」 칸). 원본처럼 대부분 행의 코드를 비우므로, 웹은 호표 머리에 // 코드를 매기고 본문 연결 행은 같은 품명·규격의 원천 코드를 찾아 붙인다(웹 보충). 메모(.MEM)·.mot(기계 기준)는 읽지 않는다. // 쓰기(ActionConvertToEstPlus → 0xd96354/0xd960b8): 394 판으로 위 표 파일 + .ini 를 ZIP 으로. // 단가비교(TfrmCompare 0xf8998c): 고른 종류 파일의 Index > 0 레코드 노무·재료·경비 금액을 차례로. const { pack, unpack } = require("./archive.cjs"); const num = (v) => Number(v) || 0; const { HEAD, AMOUNTS, BODY, KIND, LINK_IN, LINK_OUT, readX, writeX, decoder, writeS, records, decode, cp949, } = require("./legacy-estplus-layout.cjs"); const link = (v) => LINK_IN[v] ?? v; const percent = (r, li, skip) => ["%", "%"].includes(r.Unite) && !skip.includes(li) && r.GN < 1 ? r.GN * 100 : r.GN; const clean = (t) => String(t ?? "") .replace(/\n/g, "") .trim(); const pad = (p, n) => p + String(n).padStart(10, "0"); // 연결 코드 줄이기(0xd86924): A 로 시작하면 끝의 0 을 1자 남을 때까지 const trimA = (code) => (/^A/.test(code) ? code.replace(/0+$/, "") || "A" : code); function convert(files, name) { const warnings = new Set(); const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1]; const iniText = ini ? decoder.decode(ini) : ""; const iniValue = (key) => new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "mi").exec(iniText)?.[1]?.trim(); const size = Number(iniValue("data_version")) === 3 ? 565 : 394; const file = (ext) => [...files].find(([k]) => k.endsWith("." + ext))?.[1]; const parse = (ext, type = ext) => { const buf = file(ext); if (!buf) return []; if (buf.length % size) warnings.add(`${ext}: 레코드 크기(${size})로 나누어지지 않습니다.`); return records(buf, size).map((r) => decode(r, size, type)); }; const t = {}; const projectName = iniValue("project_title_name") || name; // 내역서 const est = (rows) => rows.map((r) => { let li = link(r.link), calc = 0; if (r.link === 64) ((li = 0), (calc = 1)); const row = { Index: li === 57 && r.check ? r.check : r.Index, LinkIndex: li, Code: r.link === 21 ? r.code : "", Name: r.link === 56 ? projectName : clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), Bigo: r.Bigo, Level: r.Level, Pr: r.Pr, ...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])), SKind: r.SKind, OCheck: r.OCheck, SCheck: r.check > 0 ? 3 : r.SCheck, Gangub: r.Gangub, Biul: r.Biul, CalcIndex: calc, NewItem: r.NewItem, PageSkip: r.PageSkip, MullangIndex: r.MullangIndex, ExcelSheetIndex: r.ExcelSheetIndex, }; row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]); if ([51, 62].includes(li) && row.Code) row.Code = trimA(row.Code); return row; }); t.EstMainDat = est(parse("dtm")); // 일위대가 const heads = parse("hdt"); t.HeadMainDat = heads.map((r) => { const li = r.link === 64 ? 0 : link(r.link); const row = { Index: r.Index, LinkIndex: li, Code: r.Index > 0 ? pad("U", r.Index) : [21, 62].includes(r.link) ? r.code : "", Name: clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), Bigo: r.Bigo, Level: r.Level, ...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])), SKind: r.SKind, OCheck: r.OCheck, SCheck: r.check > 0 ? 3 : r.SCheck, Gangub: r.Gangub, Biul: r.Biul, CalcIndex: r.link === 64 ? 1 : 0, MullangIndex: r.MullangIndex, ExcelSheetIndex: r.ExcelSheetIndex, }; row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]); if ([51, 52, 58, 62].includes(li) && row.Code && r.Index <= 0) row.Code = trimA(row.Code); return row; }); t.HeadTitleDat = heads .filter((r) => r.Index > 0) .map((r) => ({ Index: r.Index, Code: pad("U", r.Index), Name: clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC, OTC: r.TC, ONC: r.NC, OGC: r.GC, OJC: r.JC, Bigo: r.Bigo, NewItem: r.NewItem, UserInput: r.link === 64, })); // 단가산출: 한 줄 = Name+Size+Bigo+Unite 이어 붙임 · 머리는 꼬리표(#14 품명 · #19 규격 · #21 단위)로 나눔 const costs = parse("cct"); const tag = (line, c) => { const at = line.indexOf(c); return at < 0 ? "" : line .slice(at + 1) .split(/[\x00-\x1f]/)[0] .trim(); }; t.CostMainDat = costs.map((r) => { const line = r.Name + r.Size + r.Bigo + r.Unite; let index = r.Index === -4 ? -5 : r.Index, ocheck = r.OCheck, scheck = r.SCheck; if (index < 0 && index >= -5) { scheck = index; ocheck = index; index = 0; } return { Index: index, LinkIndex: r.link === 64 ? 0 : link(r.link), Code: r.Index > 0 ? pad("D", r.Index) : r.link === 21 ? r.code : "", ...(r.Index > 0 ? { Name: tag(line, "\x0e") || clean(r.Name), Size: tag(line, "\x13"), Unite: tag(line, "\x15"), } : { LineData: line }), Level: r.Level, Diget: r.Diget, DigetIndex: r.DigetIndex, TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC, SKind: r.SKind, OCheck: ocheck, SCheck: scheck, Biul: r.Biul, CalcIndex: r.link === 64 ? 1 : 0, }; }); t.CostTitleDat = t.CostMainDat.filter((r) => r.Index > 0).map((r) => ({ Index: r.Index, Code: r.Code, Name: r.Name, Size: r.Size, Unite: r.Unite, TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC, OTC: r.TC, ONC: r.NC, OGC: r.GC, OJC: r.JC, })); // 기계경비: 머리 X220 TC · 210 NC · 200 GC · 190 JC, 본문 −3 손료 · −2 연료 · −1 노무 t.MachMainDat = []; t.MachTitleDat = []; for (const r of parse("mdt")) { if (!r.code && !clean(r.Name) && !r.X220) continue; const base = { LinkIndex: link(r.link), Code: "", Name: clean(r.Name), CalcIndex: r.link === 64 ? 1 : 0, }; if (r.Index > 0) { if (t.MachTitleDat.length) t.MachMainDat.push({ Name: " " }); const head = { ...base, Index: r.Index, Code: pad("E", r.Index), Size: clean(r.Size), Unite: "hr", TC: r.X220, NC: r.X210, GC: r.X200, JC: r.X190, }; t.MachMainDat.push({ ...head, Selected: 0 }); t.MachTitleDat.push({ ...head, UserInput: r.link === 64 }); } else if (r.Index === -3) t.MachMainDat.push({ ...base, Index: r.Index, Selected: 1, Size: "", GN: r.X200, Ratio: r.X210, TU: r.X190, JC: r.X220, Unite: clean(r.Size).replace(/\\/g, "₩"), }); else if (r.Index === -2) t.MachMainDat.push({ ...base, Index: r.Index, Selected: 6, Size: clean(r.Size), GN: r.X190, TU: r.X200, GC: r.X220, Unite: /^(경|휘|중)/.test(clean(r.Unite)) ? "ℓ" : /잡/.test(base.Name) ? "%" : clean(r.Unite), }); else if (r.Index === -1) t.MachMainDat.push({ ...base, Index: r.Index, Selected: 11, Size: clean(r.Size), GN: r.X190, TU: r.X200, NC: r.X220, NCGasu: Number(clean(r.Size).replace(/[^\d.]/g, "")) || 1, Unite: clean(r.Unite) || "인", }); } // 본문 행 TC := NC + GC + JC for (const r of t.MachMainDat) if ([1, 6, 11].includes(r.Selected)) { for (const f of ["NC", "GC", "JC"]) r[f] = num(r[f]); r.TC = r.NC + r.GC + r.JC; } // 기초단가 const skip = (r) => !r.code && !clean(r.Name); t.LaborcDat = parse("ldt") .filter((r) => !skip(r)) .map((r, i) => ({ Index: i + 1, Code: pad("L", i + 1), Name: clean(r.Name), Size: clean(r.Size), Unite: "인", Cost1: r.TC, TC: r.TC, OTC: r.TC, ValueIndex: 0, Gangub: 0, Bigo: r.Bigo, })); t.StuffcDat = parse("sdt") .filter((r) => !skip(r)) .map((r, i) => ({ Index: i + 1, Code: pad("M", i + 1), Name: clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), ...Object.fromEntries( [1, 2, 3, 4, 5, 6].flatMap((n) => [ [`Cost${n}`, r[`Cost${n}`]], [`Page${n}`, r[`Page${n}`]], ]), ), TC: r.TC, OTC: r.TC, Bigo: r.Bigo, })); t.SilJukDat = parse("sil") .filter((r) => !skip(r)) .map((r, i) => { const only = !r.TC; const v = only ? { TC: r.JC, NC: 0, GC: 0, JC: r.JC } : { TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC }; return { Index: i + 1, Code: r.code, Name: clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), Cost1: v.TC, ...v, OTC: v.TC, ONC: v.NC, OGC: v.GC, OJC: v.JC, NCRatio: r.NCRatio, }; }); t.EstiMateDat = size === 565 ? parse("sti") .filter((r) => !skip(r)) .map((r, i) => ({ Index: i + 1, Code: r.code, Name: clean(r.Name), Size: clean(r.Size), Unite: clean(r.Unite), GN: r.GN, TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC, OTC: r.TC, ONC: r.NC, OGC: r.GC, OJC: r.JC, Ratio: r.Ratio, NachalRatio: r.NachalRatio, Diget: r.Diget, DigetIndex: r.DigetIndex, })) : []; const wonga = file("wdt") ? parse("wdt") : parse("swdt", "wdt"); t.Wonga = wonga.map((r) => ({ Index: r.Index, LinkIndex: r.link, Name: clean(r.Name), VSusic: clean(r.Size), VGungu: clean(r.Size), Biul: clean(r.Unite), Youl: clean(r.Unite), Checked: r.Checked, Cost: r.Cost, Diget: Math.round(r.Diget), EndLineIndex: r.EndLineIndex, Bimog1: r.Bimog1, Bimog2: r.Bimog2, Gubun: r.Gubun, Pr: "", GabgiInput: false, CalcIndex: r.link === 64 ? 1 : 0, })); // 설계변경(c*) → 같은 표의 「_」 칸, 차례대로 const change = { cdtm: ["EstMainDat", "dtm"], chdt: ["HeadMainDat", "hdt"], ccct: ["CostMainDat", "cct"], }; let changed = false; for (const [ext, [table, type]] of Object.entries(change)) { const rows = parse(ext, type); if (!rows.length) continue; changed = true; rows.forEach((r, i) => { const target = (t[table][i] ||= {}); target.Index_ = r.Index; for (const f of ["Name", "Size", "Unite"]) target[f + "_"] = clean(r[f]); for (const f of AMOUNTS) if (r[f] != null) target[f + "_"] = r[f]; }); } // 본문 연결 행: 같은 품명·규격의 원천 코드(웹 보충) const source = { 5: t.StuffcDat, 6: t.LaborcDat, 21: t.SilJukDat, 101: t.EstiMateDat, 103: t.HeadTitleDat, 104: t.CostTitleDat, 105: t.MachTitleDat, }; const key = (r) => `${clean(r.Name).replace(/\s/g, "")}|${clean(r.Size).replace(/\s/g, "")}`; const maps = Object.fromEntries( Object.entries(source).map(([k, rows]) => [k, new Map(rows.map((r) => [key(r), r.Code]))]), ); let linked = 0, missing = 0; for (const rows of [t.EstMainDat, t.HeadMainDat]) for (const r of rows) if (!(num(r.Index) > 0) && maps[r.LinkIndex] && !r.Code) { const code = maps[r.LinkIndex].get(key(r)); if (code) ((r.Code = code), linked++); else missing++; } if (missing) warnings.add( `연결 원천을 품명·규격으로 찾지 못한 행 ${missing}개(코드 없음 — 저장 금액 유지).`, ); if (files.has(".mem") || [...files.keys()].some((k) => k.endsWith(".mem"))) warnings.add("근거 메모(.MEM)는 읽지 않았습니다."); if (file("mot")) warnings.add("기계경비 기준표(.mot)는 읽지 않았습니다."); const mode = Number(iniValue("last_work_mode")) || 1; const standard = /^(1|true)$/i.test(iniValue("siljuk_est_mode") || ""); for (const k of ["MachGijunDat", "CadMainDat", "CadTitleDat"]) t[k] ||= []; return { schema: "est-web-2", name: projectName, tables: t, options: { SourceRefresh: false, AutoCalc: false, TotalFix: false, VerFormat: true, WorkMode: standard ? (mode === 2 ? 4 : 3) : mode, ...(changed && { Mode: "설계변경" }), }, quantities: [], quantitySheets: [], progress: [], annual: [], schedule: [], drawings: [], importWarnings: [ `구버전 EST Plus 자료(레코드 ${size}바이트)를 변환했습니다. 저장된 금액을 그대로 두려고 자동계산을 껐습니다. 품명·규격으로 연결 ${linked}개.`, ...warnings, ], }; } const lower = (files) => new Map([...files].map(([k, v]) => [k.toLowerCase(), v])); function read(buffer, name = "EST Plus 자료") { return convert(lower(unpack(buffer)), name); } // 쓰기(394 판) function record(type, row) { const rec = Buffer.alloc(394), h = HEAD[394]; rec.writeInt32LE(-1, 0); rec.writeInt32LE(Math.trunc(num(row.Index)), 5); rec[9] = row.UserInput ? 64 : (LINK_OUT[num(row.LinkIndex)] ?? num(row.LinkIndex)) & 0xff; for (const [k, f] of [ ["code", "Code"], ["name", "Name"], ["size", "Size"], ["unite", "Unite"], ["bigo", "Bigo"], ]) writeS(rec, h[k][0], h[k][1], row[f]); rec.writeInt32LE(-1, h.memo1); rec.writeInt32LE(Math.trunc(num(row.MullangIndex)), h.mul); rec.writeInt32LE(-1, h.memo2); rec[h.page] = num(row.PageSkip) & 0xff; rec[171] = 1; rec[h.checked] = row.Checked ? 1 : 0; rec[h.newItem] = row.NewItem ? 1 : 0; rec[h.sheet] = num(row.ExcelSheetIndex) & 0xff; for (const at of [177, 181, 185]) rec.writeInt32LE(-1, at); rec[h.kind] = KIND[type]; for (const [f, t, at, , n] of BODY[type === "swdt" ? "wdt" : type] || []) { if (!at) continue; const v = row[f]; if (t === "X") writeX(rec, at, v); else if (t === "L") rec.writeInt32LE(Math.trunc(num(v)), at); else if (t === "b") rec.writeInt8(Math.max(-128, Math.min(127, Math.trunc(num(v)))), at); else if (t === "B") rec[at] = num(v) & 0xff; else writeS(rec, at, n, v); } return rec; } // 단가산출 글줄을 Name(50)·Size(50)·Bigo(20)·Unite(8) 칸에 차례로 나눠 담음(읽을 때 이어 붙이면 같은 줄) function splitLine(text) { const parts = ["", "", "", ""], caps = [50, 50, 20, 8]; let k = 0, used = 0; for (const ch of String(text ?? "")) { const n = cp949(ch).length; while (k < 4 && used + n > caps[k]) ((k += 1), (used = 0)); if (k >= 4) break; parts[k] += ch; used += n; } return { Name: parts[0], Size: parts[1], Bigo: parts[2], Unite: parts[3] }; } function write(project) { const t = project.tables, out = []; const add = (ext, type, rows) => rows.length && out.push([`data.${ext}`, Buffer.concat(rows.map((r) => record(type, r)))]); // % 단위 수량은 소수로 씀(읽을 때 1 미만이면 ×100 — 음수·100 넘는 값도 되돌아오게) const pct = (rows) => rows.map((r) => ["%", "%"].includes(String(r.Unite ?? "").trim()) && ![51, 52, 57, 58, 62, 64].includes(num(r.LinkIndex)) && !(num(r.Index) > 0) ? { ...r, GN: num(r.GN) / 100 } : r, ); add("dtm", "dtm", pct(t.EstMainDat || [])); add("hdt", "hdt", pct(t.HeadMainDat || [])); add( "cct", "cct", (t.CostMainDat || []).map((r) => ({ ...r, ...splitLine( num(r.Index) > 0 && !r.LineData ? `\x0e${r.Name ?? ""}\x0e\x13${r.Size ?? ""}\x13\x15${r.Unite ?? ""}\x15` : r.LineData, ), Index: num(r.Index) > 0 ? r.Index : num(r.SCheck) < 0 ? num(r.SCheck) === -5 ? -4 : r.SCheck : r.Index, })), ); add( "mdt", "mdt", (t.MachMainDat || []) .filter((r) => String(r.Name ?? "").trim() || num(r.Index) > 0) .map((r) => { const s = num(r.Selected); if (num(r.Index) > 0) return { ...r, X220: r.TC, X210: r.NC, X200: r.GC, X190: r.JC }; if (s === 1) return { ...r, Index: -3, Size: r.Unite, X200: r.GN, X210: r.Ratio, X190: r.TU, X220: r.JC, }; if (s === 6) return { ...r, Index: -2, X190: r.GN, X200: r.TU, X220: r.GC }; return { ...r, Index: -1, X190: r.GN, X200: r.TU, X220: r.NC }; }), ); add("ldt", "ldt", t.LaborcDat || []); add("sdt", "sdt", t.StuffcDat || []); add("sil", "sil", t.SilJukDat || []); add( "wdt", "wdt", (t.Wonga || []).map((r) => ({ ...r, Size: r.VSusic, Unite: r.Youl ?? r.Biul, Bigo: String(Math.trunc(num(r.Cost))), })), ); const mode = num(project.options?.WorkMode) || 1; out.push([ "data.ini", cp949( `[공통]\r\nlast_work_mode=${mode > 2 ? mode - 2 : mode}\r\nsiljuk_est_mode=${mode > 2 ? 1 : 0}\r\ndata_version=0\r\nproject_title_name=${project.name ?? ""}\r\n`, ), ]); return pack(new Map(out)); } // 단가비교: 종류(0 기계 · 1 단산 · 2 일위 · 3 내역) 파일의 Index > 0 레코드 노무·재료·경비 const COMPARE = [ ["mdt", 210, 200, 190], ["cct", 203, 213, 223], ["hdt", 231, 251, 271], ["dtm", 258, 278, 298], ]; function compare(buffer, kind) { const files = lower(unpack(buffer)), [ext, n, g, j] = COMPARE[kind] || []; if (!ext) throw Error("비교할 항목을 선택하세요."); const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1]; if (ini && /data_version\s*=\s*3/i.test(decoder.decode(ini))) throw Error("565 바이트 판 자료의 단가비교 자리는 확인되지 않았습니다."); const buf = [...files].find(([k]) => k.endsWith("." + ext))?.[1]; if (!buf) throw Error(`비교 파일(.${ext})이 없습니다.`); return records(buf, 394) .filter((r) => r.readInt32LE(5) > 0) .map((r) => ({ ONU: readX(r, n), OGU: readX(r, g), OJU: readX(r, j) })); } module.exports = { read, write, compare, readX, writeX, splitLine };