// 원본 문서 레코드(.opt = MIDAS TClientDataSet 이진 묶음, 머리 96 19 E0 BD) 일반 읽기 — 갑지·표지·공사설명서·시방서. // 필드형: 1/4 정수 · 3/2 짧은 정수 · 4/8 실수 · 73/1 문자열(cp949) · 75/4 BLOB(바이트 그대로). // WPTools BLOB 은 wptText 로 글자만 꺼낸다(서식 버림). const zlib = require("node:zlib"); const decoder = new TextDecoder("euc-kr"); function readRecords(buffer) { if (!buffer || buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996) throw Error("문서 레코드 머리 오류"); const count = buffer.readUInt16LE(12), rows = buffer.readUInt16LE(14), start = buffer.readUInt16LE(22); if (count > 1024 || rows > 10000 || start > buffer.length) throw Error("문서 레코드 크기 오류"); let at = 24; const take = (n) => { if (n < 0 || at + n > buffer.length) throw Error("문서 레코드 범위 오류"); const out = buffer.subarray(at, at + n); at += n; return out; }; const fields = []; for (let i = 0; i < count; i++) { const name = take(take(1)[0]).toString("ascii"), width = take(2).readUInt16LE(), kind = take(2).readUInt16LE(); take(2); const attributes = take(2).readUInt16LE(); for (let j = 0; j < attributes; j++) { take(take(1)[0]); const size = take(2).readUInt16LE(), type = take(2).readUInt16LE(); if (type === 73) take(take(2).readUInt16LE()); else take(size); } fields.push({ name, width, kind }); } at = start; const out = []; for (let r = 0; r < rows; r++) { take(1); const status = take(Math.ceil(count / 4)), row = {}; fields.forEach(({ name, width, kind }, i) => { const state = (status[i >> 2] >> ((i % 4) * 2)) & 3; if (state === 1 || state === 2) return (row[name] = null); if (kind === 1 && width === 4) row[name] = take(4).readInt32LE(); else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE(); else if (kind === 4 && width === 8) row[name] = take(8).readDoubleLE(); else if (kind === 73 && width === 1) row[name] = decoder.decode(take(take(1)[0])).replace(/\0+$/, ""); else if (kind === 75 && width === 4) row[name] = Buffer.from(take(take(4).readUInt32LE())); else throw Error(`문서 레코드 필드형 오류: ${name}/${kind}/${width}`); }); out.push(row); } return out; } function wptText(blob) { if (!blob?.length) return ""; let bytes = blob; if (bytes[0] === 0x78) bytes = zlib.inflateSync(bytes); const source = decoder.decode(bytes); if (!source.startsWith("/gi, "") .replace(/<\/(div|p)>|/gi, "\n") .replace(/<[^>]*>/g, "") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/&/g, "&") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } // 갑지·표지·공사설명서·시방서 → project.docs (글자 필드만; 서식·여백은 원본에 그대로 둔다) function readDocuments(files) { const docs = {}; const one = (name) => { const key = [...files.keys()].find((k) => k.toLowerCase() === name.toLowerCase()); return key ? readRecords(files.get(key))[0] || null : null; }; const gabgi = one("DatGabgi.opt"); if (gabgi) docs.gabgi = { Name: gabgi.Name ?? "", Position: gabgi.Position ?? "", CompanyName: gabgi.CompanyName ?? "", Explan: wptText(gabgi.EXPLEAN), Cost: wptText(gabgi.Cost), Gulga: gabgi.Gulga ? decoder .decode(gabgi.Gulga) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) : [], Date: gabgi.Date ?? "", YearShow: Boolean(gabgi.YearShow), DayShow: Boolean(gabgi.DayShow), CompanyPos: gabgi.CompanyPos ?? 1, }; const cover = one("DatCover.opt"); if (cover) docs.cover = { Name: cover.Name ?? "", Name2: cover.Name2 ?? "", CompanyName: cover.CompanyName ?? "", Date: cover.Date ?? "", YearShow: Boolean(cover.YearShow), CompanyPos: cover.CompanyPos ?? 1, }; for (const [key, file] of [ ["explan", "DatExplean.opt"], ["sibang", "DatSiBang.opt"], ]) { const row = one(file); if (row) docs[key] = { text: wptText(row.Editor) }; } return docs; } module.exports = { readRecords, wptText, readDocuments };