From 6f32263ea34d1887c73bd79fe57c5253367315cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 19:49:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=97=91=EC=85=80=ED=98=95?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=EC=84=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 엑셀형 산출서 본문(OLE2 .xls BIFF8) 셀 값을 읽는 xls-read.cjs - 가져올 때 시트를 붙이고 수량산출 화면에 읽기 전용 격자로 표시 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_xls_read.py | 53 +++++ tools/est-test/PLAN.md | 4 + tools/est-test/README.md | 2 + tools/est-test/import-estx.cjs | 15 +- tools/est-test/native-quantity-sheets.js | 32 ++- tools/est-test/native-quantity.cjs | 2 +- tools/est-test/xls-read.cjs | 235 +++++++++++++++++++++++ 7 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_est_xls_read.py create mode 100644 tools/est-test/xls-read.cjs diff --git a/resources/tester/test_est_xls_read.py b/resources/tester/test_est_xls_read.py new file mode 100644 index 000000000..18f58600c --- /dev/null +++ b/resources/tester/test_est_xls_read.py @@ -0,0 +1,53 @@ +"""EST 테스트 앱 엑셀형 수량산출서(.xls OLE2/BIFF8) 읽기 — 원본 TFRMMULEXCEL 본문.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + + +def test_reads_embedded_excel_sheets_from_installed_main(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + script = """ + const fs = require('fs'), imp = require(process.cwd() + '/tools/est-test/import-estx.cjs'); + const p = imp.importEstx(fs.readFileSync(process.argv[1])); + const sheets = p.quantitySheets.filter((s) => s.excel); + const first = sheets.find((s) => s.Code === 'N0000000136').excel[0]; + const formulas = Object.values(first.cells).filter((c) => c.formula); + process.stdout.write(JSON.stringify({ count: sheets.length, name: first.name, + title: first.cells['1,1'].value, station: first.cells['6,1'].value, number: first.cells['6,2'].value, + formulas: formulas.length, numeric: formulas.every((c) => typeof c.value === 'number' || typeof c.value === 'string') })); + """ + result = json.loads( + subprocess.check_output(["node", "-e", script, str(MAIN)], cwd=ROOT).decode("utf-8") + ) + assert result == { + "count": 6, + "name": "Sheet1", + "title": "토적계산표", + "station": "No.1", + "number": 20, + "formulas": 16, + "numeric": True, + } + + +def test_rejects_non_ole_and_renders_grid(): + script = """ + const fs = require('fs'), vm = require('vm'); + const { readXls } = require(process.cwd() + '/tools/est-test/xls-read.cjs'); + let message = ''; try { readXls(Buffer.from('not an xls file at all')); } catch (e) { message = e.message; } + const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v) } }); + vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx); + ctx.sheet = { excel: [{ name: 'S', cells: { '0,0': { value: '측점' }, '1,1': { value: 3, formula: true } } }] }; + const html = vm.runInContext('EstQuantitySheets.excelView(sheet)', ctx); + process.stdout.write(JSON.stringify({ message, html: [html.includes('측점'), html.includes('B'), html.includes('font-weight:bold'), vm.runInContext('EstQuantitySheets.excelView({})', ctx)] })); + """ + result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + assert result["message"].startswith("엑셀형 수량산출서 형식") + assert result["html"] == [True, True, True, ""] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 7232d7ce2..e04bbc76f 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -469,6 +469,10 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 메모 편집(원본 서식 저장), 위치정리 드래그 이동. - [x] 통합 검색(원본 TFRMMULTISERCH 자료 7종·검색어·검색위치) — 검색위치는 이 앱 저장 작업(`search-store.cjs`, `/api/search`), 고른 자료를 하위 연결째 현재 공사로 가져옴(EstTransfer). 원본 「Main Data에서 검색」·폴더 고르기는 웹 서버에 원본 자료 폴더가 없어 저장 작업으로 대신함. - 검증완료: `test_est_multi_search.py`(낱말 모두 포함·자료 종류·제외 작업·깨진 파일 무시·빈 검색어/종류 거부) 통과. ORCA 브라우저에서 저장 작업 59개 대상 「콘크리트」 단가산출 85건 → 없는 D0000000140 가져오기 → 연결 자료 3건·단가산출 21행 추가·단가 28,878 원본과 같음·오류 0, 샘플 복구. + - [ ] 엑셀형 수량산출서(원본 TFRMMULEXCEL, MulMainDat.Editor = OLE2 .xls BIFF8) + - [x] 1단계 읽기 — `xls-read.cjs`(복합문서·SST/CONTINUE·LABELSST·NUMBER·RK·MULRK·LABEL·BOOLERR·FORMULA 저장값), 가져올 때 `sheet.excel`, 수량산출 화면 읽기 전용 격자(수식 셀 굵게). + - 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구. + - 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 34334f9c3..8e5978469 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -48,6 +48,8 @@ `편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. +원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다. + 산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다. 수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs index e6c774f08..664eb38ee 100644 --- a/tools/est-test/import-estx.cjs +++ b/tools/est-test/import-estx.cjs @@ -1,4 +1,5 @@ // Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md. +const { readXls } = require("./xls-read.cjs"); const zlib = require("node:zlib"); const quantity = require("./native-quantity.cjs"); const { readPrintOptions } = require("./import-print.cjs"); @@ -366,12 +367,14 @@ function importEstx(buffer, name = "가져온 내역서") { 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; - quantitySheets.push( - quantity.fromWpt(row.Code, name, text, items, { - digits: props.TMulLangOption?.Diget ?? 2, - roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1, - }), - ); + 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)); + quantitySheets.push(sheet); } catch (error) { warnings.add(`수량산출 ${row.Code}: ${error.message}`); } diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index 9ea2a0831..9d91345b7 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -137,5 +137,35 @@ const EstQuantitySheets = (() => { return true; } - return { copy, move, mark, free, handle }; + // 엑셀형 수량산출서(원본 TFRMMULEXCEL) — 가져온 셀 값을 읽기 전용 격자로 보인다. 수식 셀은 저장된 계산값(굵게). + function excelView(sheet) { + if (!sheet?.excel?.length) return ""; + const { esc, number } = EstApp; + const letter = (n) => { + let s = ""; + for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; + }; + return sheet.excel + .map(({ name, cells }) => { + const keys = Object.keys(cells).map((k) => k.split(",").map(Number)); + if (!keys.length) return ""; + const rows = Math.max(...keys.map(([r]) => r)) + 1, + cols = Math.min(40, Math.max(...keys.map(([, c]) => c)) + 1); + const body = Array.from({ length: rows }, (_, r) => { + const cellsHtml = Array.from({ length: cols }, (_, c) => { + const cell = cells[`${r},${c}`]; + if (!cell) return ""; + const value = typeof cell.value === "number" ? number(cell.value) : esc(cell.value); + return `${value}`; + }).join(""); + return `${r + 1}${cellsHtml}`; + }).join(""); + return `

엑셀형 산출서 · ${esc(name)} (읽기 전용)

${Array.from({ length: cols }, (_, c) => ``).join("")}${body}
${letter(c)}
`; + }) + .join(""); + } + + return { copy, move, mark, free, excelView, handle }; })(); diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index cc9afd702..54717dff3 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -315,7 +315,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/xls-read.cjs b/tools/est-test/xls-read.cjs new file mode 100644 index 000000000..63cfcc47c --- /dev/null +++ b/tools/est-test/xls-read.cjs @@ -0,0 +1,235 @@ +// 원본 엑셀형 수량산출서(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; +} + +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: true } : { 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); + if (data.readUInt16LE(12) === 0xffff) { + const kind = data[6]; + if (kind === 0) pendingFormula = { row, col }; + else + put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", true); + } else put(row, col, data.readDoubleLE(6), true); + } else if (type === 0x207 && pendingFormula) { + put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, true); + pendingFormula = null; + } + } + } + return sheets.map(({ name, cells }) => ({ name, cells })); +} + +module.exports = { readXls, cfb };