- 엑셀형 산출서: 범위 고르기, 행·열 삽입/삭제(수식 참조·병합·수량 연결 이동, 지운 칸 #REF!), 잘라내기·복사·붙여넣기(상대 참조), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선, 다시 계산, 품명·규격·단위로 지정, 원본 .xls 병합 정보 읽기 - 조달청 표준공사코드 적용(원본 TfrmCodeChange): 7종 탭, 코드변경·검사 색·변경취소·적용(참조 행 포함, 재계산 없음), 코드일괄변경(.estx), 자동변경, 다산코드정리, 사용자정의코드, 검색. 조달청 목록은 사용자가 고른 PPS_*.dat 를 /api/pps-table 로 읽음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
// 원본 엑셀형 수량산출서(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 };
|