// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 본문 — MulMainDat.Editor 에 든 옛 Excel(.xls, OLE2 복합문서 + BIFF8) 읽기. // 읽기 전용: 시트 이름과 셀 값(문자·숫자·불·오류), 수식 셀은 저장된 계산값과 수식 여부만 꺼낸다. 서식·그림은 읽지 않는다. const zlib = require("node:zlib"); function cfb(buffer) { if (buffer.readUInt32LE(0) !== 0xe011cfd0 || buffer.readUInt32LE(4) !== 0xe11ab1a1) throw Error("엑셀형 수량산출서 형식(OLE2)이 아닙니다."); const sectorSize = 1 << buffer.readUInt16LE(30), miniSize = 1 << buffer.readUInt16LE(32), fatCount = buffer.readUInt32LE(44), dirStart = buffer.readInt32LE(48), miniCutoff = buffer.readUInt32LE(56), miniFatStart = buffer.readInt32LE(60), difatStart = buffer.readInt32LE(68); const sector = (n) => buffer.subarray(512 + n * sectorSize, 512 + (n + 1) * sectorSize); const difat = []; for (let i = 0; i < 109; i++) difat.push(buffer.readInt32LE(76 + i * 4)); for (let next = difatStart, guard = 0; next >= 0 && guard < 10000; guard++) { const s = sector(next); for (let i = 0; i < sectorSize / 4 - 1; i++) difat.push(s.readInt32LE(i * 4)); next = s.readInt32LE(sectorSize - 4); } const fat = []; for (const n of difat.slice(0, fatCount)) { if (n < 0) continue; const s = sector(n); for (let i = 0; i < sectorSize / 4; i++) fat.push(s.readInt32LE(i * 4)); } const chain = (start, table) => { const out = []; for (let n = start, guard = 0; n >= 0 && guard < table.length + 1; guard++) { out.push(n); n = table[n]; } return out; }; const read = (start) => Buffer.concat(chain(start, fat).map(sector)); const dir = read(dirStart), entries = []; for (let at = 0; at + 128 <= dir.length; at += 128) { const nameLength = dir.readUInt16LE(at + 64); entries.push({ name: dir.toString("utf16le", at, at + Math.max(0, nameLength - 2)), type: dir[at + 66], start: dir.readInt32LE(at + 116), size: dir.readUInt32LE(at + 120), }); } const root = entries[0], miniStream = root ? read(root.start) : Buffer.alloc(0), miniFat = []; if (miniFatStart >= 0) { const m = read(miniFatStart); for (let i = 0; i < m.length / 4; i++) miniFat.push(m.readInt32LE(i * 4)); } return (name) => { const entry = entries.find((e) => e.type === 2 && e.name.toLowerCase() === name.toLowerCase()); if (!entry) return null; if (entry.size < miniCutoff) return Buffer.concat( chain(entry.start, miniFat).map((n) => miniStream.subarray(n * miniSize, (n + 1) * miniSize), ), ).subarray(0, entry.size); return read(entry.start).subarray(0, entry.size); }; } function rk(value) { const raw = value & 1 ? 100 : 1; const number = value & 2 ? value >> 2 : (() => { const b = Buffer.alloc(8); b.writeInt32LE(value & 0xfffffffc, 4); return b.readDoubleLE(0); })(); return number / raw; } // BIFF8 유니코드 문자열(길이·옵션·리치/확장 정보) — CONTINUE 경계는 SST 에서만 따로 처리한다. function unicode(data, at, lengthBytes = 2) { const length = lengthBytes === 2 ? data.readUInt16LE(at) : data[at]; let pos = at + lengthBytes; const flags = data[pos++], wide = flags & 1; let runs = 0, ext = 0; if (flags & 8) ((runs = data.readUInt16LE(pos)), (pos += 2)); if (flags & 4) ((ext = data.readUInt32LE(pos)), (pos += 4)); const bytes = wide ? length * 2 : length; const text = wide ? data.toString("utf16le", pos, pos + bytes) : data.toString("latin1", pos, pos + bytes); return { text, end: pos + bytes + runs * 4 + ext }; } function records(stream) { const out = []; for (let at = 0; at + 4 <= stream.length;) { const type = stream.readUInt16LE(at), size = stream.readUInt16LE(at + 2); out.push({ type, data: stream.subarray(at + 4, at + 4 + size) }); at += 4 + size; } return out; } function sst(list, index) { // SST 와 뒤따르는 CONTINUE 를 문자열 단위로 이어 읽는다. const parts = [list[index].data]; for (let i = index + 1; i < list.length && list[i].type === 0x3c; i++) parts.push(list[i].data); const strings = [], count = parts[0].readUInt32LE(4); let part = 0, data = parts[0], pos = 8; const next = () => { part++; data = parts[part]; pos = 0; }; while (strings.length < count && data) { if (pos >= data.length) { next(); continue; } const length = data.readUInt16LE(pos); let flags = data[pos + 2], p = pos + 3, runs = 0, ext = 0; if (flags & 8) ((runs = data.readUInt16LE(p)), (p += 2)); if (flags & 4) ((ext = data.readUInt32LE(p)), (p += 4)); let text = "", left = length; pos = p; while (left > 0) { if (pos >= data.length) { next(); if (!data) break; flags = data[pos++]; } const wide = flags & 1, room = Math.floor((data.length - pos) / (wide ? 2 : 1)), take = Math.min(left, room); text += wide ? data.toString("utf16le", pos, pos + take * 2) : data.toString("latin1", pos, pos + take); pos += take * (wide ? 2 : 1); left -= take; } let skip = runs * 4 + ext; while (skip > 0 && data) { const step = Math.min(skip, data.length - pos); pos += step; skip -= step; if (skip > 0) next(); } strings.push(text); } return strings; } // BIFF8 수식 토큰(rgce) → A1 식. 원본 엑셀형 산출서에 쓰인 것: 참조·범위·+−×÷^·괄호·숫자·문자·SUM 등 기본 함수. // 모르는 토큰이 있으면 null(저장된 계산값만 씀). const functions = { 1: "IF", 4: "SUM", 5: "AVERAGE", 6: "MIN", 7: "MAX", 24: "ABS", 25: "INT", 27: "ROUND", 197: "TRUNC", 212: "ROUNDUP", 213: "ROUNDDOWN", 20: "SQRT", 183: "PRODUCT", }; const fixedArgs = { 24: 1, 25: 1, 27: 2, 20: 1, 212: 2, 213: 2 }; function columnName(index) { let name = ""; for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name; return name; } function decodeFormula(rgce) { const stack = []; const binary = { 3: "+", 4: "-", 5: "*", 6: "/", 7: "^", 8: "&", 9: "<", 10: "<=", 11: "=", 12: ">=", 13: ">", 14: "<>", }; const ref = (row, col) => `${columnName(col & 0x3fff)}${row + 1}`; for (let i = 0; i < rgce.length;) { const id = rgce[i]; if (binary[id]) { const b = stack.pop(), a = stack.pop(); if (a == null || b == null) return null; stack.push(`${a}${binary[id]}${b}`); i += 1; } else if (id === 0x12 || id === 0x13) { const a = stack.pop(); if (a == null) return null; stack.push(`${id === 0x13 ? "-" : "+"}${a}`); i += 1; } else if (id === 0x14) { stack.push(`${stack.pop()}/100`); i += 1; } else if (id === 0x15) { stack.push(`(${stack.pop()})`); i += 1; } else if (id === 0x16) { stack.push(""); i += 1; } else if (id === 0x1e) { stack.push(String(rgce.readUInt16LE(i + 1))); i += 3; } else if (id === 0x1f) { stack.push(String(rgce.readDoubleLE(i + 1))); i += 9; } else if (id === 0x1d) { stack.push(rgce[i + 1] ? "TRUE" : "FALSE"); i += 2; } else if (id === 0x17) { const n = rgce[i + 1], wide = rgce[i + 2] & 1, text = wide ? rgce.toString("utf16le", i + 3, i + 3 + n * 2) : rgce.toString("latin1", i + 3, i + 3 + n); stack.push(`"${text.replaceAll('"', '""')}"`); i += 3 + (wide ? n * 2 : n); } else if (id === 0x19) { const opt = rgce[i + 1]; if (opt & 0x10) stack.push(`SUM(${stack.pop()})`); i += opt & 0x04 ? 4 + 2 * (rgce.readUInt16LE(i + 2) + 1) : 4; } else if ([0x24, 0x44, 0x64].includes(id)) { stack.push(ref(rgce.readUInt16LE(i + 1), rgce.readUInt16LE(i + 3))); i += 5; } else if ([0x25, 0x45, 0x65].includes(id)) { stack.push( `${ref(rgce.readUInt16LE(i + 1), rgce.readUInt16LE(i + 5))}:${ref(rgce.readUInt16LE(i + 3), rgce.readUInt16LE(i + 7))}`, ); i += 9; } else if ([0x21, 0x41, 0x61, 0x22, 0x42, 0x62].includes(id)) { const variable = (id & 0x0f) === 2, fn = variable ? rgce.readUInt16LE(i + 2) & 0x7fff : rgce.readUInt16LE(i + 1), argc = variable ? rgce[i + 1] & 0x7f : fixedArgs[fn]; if (!functions[fn] || argc == null || stack.length < argc) return null; const args = stack.splice(stack.length - argc, argc); stack.push(`${functions[fn]}(${args.join(",")})`); i += variable ? 4 : 3; } else return null; } return stack.length === 1 ? stack[0] : null; } function readXls(input) { let buffer = Buffer.from(input); if (buffer[0] === 0x78) buffer = zlib.inflateSync(buffer); const open = cfb(buffer), stream = open("Workbook") || open("Book"); if (!stream) throw Error("엑셀형 수량산출서에 Workbook 이 없습니다."); const list = records(stream); let strings = []; const sheets = []; list.forEach((record, i) => { if (record.type === 0xfc) strings = sst(list, i); if (record.type === 0x85) sheets.push({ offset: record.data.readUInt32LE(0), name: unicode(record.data, 6, 1).text, cells: {}, }); }); const byOffset = new Map(); let at = 0; for (const record of list) { byOffset.set(at, record); at += 4 + record.data.length; } const offsets = [...byOffset.keys()]; for (const sheet of sheets) { let index = offsets.indexOf(sheet.offset); if (index < 0) continue; let pendingFormula = null; for (index++; index < offsets.length; index++) { const { type, data } = byOffset.get(offsets[index]); if (type === 0x0a) break; const put = (row, col, value, formula = false) => (sheet.cells[`${row},${col}`] = formula ? { value, formula } : { value }); if (type === 0xfd) put(data.readUInt16LE(0), data.readUInt16LE(2), strings[data.readUInt32LE(6)] ?? ""); else if (type === 0x203) put(data.readUInt16LE(0), data.readUInt16LE(2), data.readDoubleLE(6)); else if (type === 0x27e) put(data.readUInt16LE(0), data.readUInt16LE(2), rk(data.readInt32LE(6))); else if (type === 0xbd) { const row = data.readUInt16LE(0), first = data.readUInt16LE(2), last = data.readUInt16LE(data.length - 2); for (let c = first; c <= last; c++) put(row, c, rk(data.readInt32LE(4 + (c - first) * 6 + 2))); } else if (type === 0x204) put(data.readUInt16LE(0), data.readUInt16LE(2), unicode(data, 6).text); else if (type === 0x205) { const v = data[6]; put(data.readUInt16LE(0), data.readUInt16LE(2), data[7] ? `#ERR${v}` : Boolean(v)); } else if (type === 0x06) { const row = data.readUInt16LE(0), col = data.readUInt16LE(2), text = decodeFormula(data.subarray(22, 22 + data.readUInt16LE(20))) || true; if (data.readUInt16LE(12) === 0xffff) { const kind = data[6]; if (kind === 0) pendingFormula = { row, col, text }; else put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", text); } else put(row, col, data.readDoubleLE(6), text); } else if (type === 0xe5) { // MERGEDCELLS: 개수, (첫 행·끝 행·첫 열·끝 열) 반복 for (let i = 0, count = data.readUInt16LE(0); i < count; i++) { const at = 2 + i * 8; (sheet.merges ||= []).push({ r1: data.readUInt16LE(at), r2: data.readUInt16LE(at + 2), c1: data.readUInt16LE(at + 4), c2: data.readUInt16LE(at + 6), }); } } else if (type === 0x207 && pendingFormula) { put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, pendingFormula.text); pendingFormula = null; } } } return sheets.map(({ name, cells, merges }) => merges ? { name, cells, merges } : { name, cells }, ); } module.exports = { readXls, cfb, decodeFormula, columnName };