From 6dc9fb84c5535195eb7c56bb283283603b91e9a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:48:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=9D=B8=EC=87=84=EC=96=91?= =?UTF-8?q?=EC=8B=9D(.pfot)=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=C2=B7?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_print_format.py | 34 ++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 4 +- tools/est-test/native-misc-tools.js | 55 +++++++++ tools/est-test/print-format.cjs | 139 ++++++++++++++++++++++ tools/est-test/server.cjs | 19 +++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_print_format.py create mode 100644 tools/est-test/print-format.cjs diff --git a/resources/tester/test_est_print_format.py b/resources/tester/test_est_print_format.py new file mode 100644 index 000000000..f0c9bedce --- /dev/null +++ b/resources/tester/test_est_print_format.py @@ -0,0 +1,34 @@ +"""EST 테스트 앱 인쇄양식 파일(.pfot — 원본 mnuPrintFormatSave·Load) 읽기·쓰기.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PFOT = Path("C:/Dasansoft/Est Plus 2/System/EstFormat") + + +def test_pfot_round_trip_is_byte_identical_and_edits_apply(): + if not (PFOT / "main.pfot").exists(): + pytest.skip("installed EST print formats unavailable") + code = r""" + const P = require(process.cwd() + '/tools/est-test/print-format.cjs'), fs = require('fs'); + const out = {}; + for (const f of ['main.pfot', 'main(가로양식).pfot']) { + const b = fs.readFileSync(process.argv[1] + '/' + f), rows = P.readFull(b); + out[f] = [rows.length, P.write(b, rows).equals(b)]; + } + const b = fs.readFileSync(process.argv[1] + '/main.pfot'), rows = P.readFull(b); + rows[1].Name2 = '원가계산서(수정)'; rows[1].Checked = 0; + const again = P.read(P.write(b, rows)); + out.edit = [again[1].Name2, again[1].Checked, again.length]; + process.stdout.write(JSON.stringify(out)); + """ + out = json.loads( + subprocess.check_output(["node", "-e", code, str(PFOT)], cwd=ROOT).decode("utf-8") + ) + assert out["main.pfot"] == [41, True] + assert out["main(가로양식).pfot"][1] is True + assert out["edit"] == ["원가계산서(수정)", 0, 41] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 3520cc57c..466d20862 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. + - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트). - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동). - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index e03a04309..13a7cf025 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -113,7 +113,9 @@
- + diff --git a/tools/est-test/native-misc-tools.js b/tools/est-test/native-misc-tools.js index 2b5b16c0d..11d9757ff 100644 --- a/tools/est-test/native-misc-tools.js +++ b/tools/est-test/native-misc-tools.js @@ -200,7 +200,62 @@ const EstMiscTools = (() => { ); } + // 인쇄양식 불러오기·저장하기(mnuPrintFormatLoad·Save 0x10ed318·0x10ed000): .pfot = 인쇄정보표 한 벌(원본 이진 그대로). + // 불러오면 공사 인쇄표(printCatalog)를 그 값으로, 저장은 지금 인쇄표(불러온 원본 행 우선)를 .pfot 로. .gfot(격자 배치)는 다루지 않음. + async function pfot(name, state) { + if (name === "pfot-load") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".pfot"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const rows = await EstApp.api("/api/pfot-read", { + method: "POST", + body: await file.arrayBuffer(), + }); + EstApp.change((project) => { + project.printFormat = rows; + project.printCatalog = rows.map((r) => + Object.fromEntries(Object.entries(r).filter(([k, v]) => k !== "__nulls" && !v?.binary)), + ); + }, false); + state.notice = `인쇄양식 「${file.name}」(서식 ${rows.length}개)을 불러왔습니다.`; + } else { + const defaults = await EstApp.api("/api/print-catalog"), + saved = new Map((state.project.printCatalog || []).map((r) => [r.Index, r])), + full = new Map((state.project.printFormat || []).map((r) => [r.Index, r])); + const rows = defaults.map((r) => ({ + ...r, + ...full.get(r.Index), + ...saved.get(r.Index), + __nulls: full.get(r.Index)?.__nulls, + })); + const response = await fetch("/api/pfot-write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "인쇄양식"}.pfot`, + await response.blob(), + "application/octet-stream", + ); + } + EstApp.render(); + } + function handle(name, state) { + if (name === "pfot-load" || name === "pfot-save") { + pfot(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } if (name === "jodal-fee") jodal(state); else if (name === "cost-to-wonga") costToWonga(state); else if (name === "jun-load") junLoad(state); diff --git a/tools/est-test/print-format.cjs b/tools/est-test/print-format.cjs new file mode 100644 index 000000000..50fce143d --- /dev/null +++ b/tools/est-test/print-format.cjs @@ -0,0 +1,139 @@ +// 원본 인쇄양식 파일(.pfot — mnuPrintFormatSave 0x10ed000 · Load 0x10ed318): 인쇄정보표(DatPrintOption) MIDAS 이진 한 벌. +// 읽기는 import-print.cjs 와 같은 꼴. 쓰기는 같은 꼴의 바탕 파일(필드 설명자)을 받아 행 부분만 새로 만든다. +// .gfot(화면 격자 열 배치)는 웹 격자와 호환되지 않아 다루지 않는다. +const { readPrintOptions } = require("./import-print.cjs"); + +function layout(buffer) { + if (buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996) + throw Error("인쇄양식 파일 헤더 오류"); + const count = buffer.readUInt16LE(12), + start = buffer.readUInt16LE(22); + let at = 24; + const fields = []; + for (let i = 0; i < count; i++) { + const length = buffer[at]; + const name = buffer.subarray(at + 1, at + 1 + length).toString("ascii"); + at += 1 + length; + const width = buffer.readUInt16LE(at), + kind = buffer.readUInt16LE(at + 2), + attributes = buffer.readUInt16LE(at + 6); + at += 8; + for (let j = 0; j < attributes; j++) { + at += 1 + buffer[at]; + const size = buffer.readUInt16LE(at), + type = buffer.readUInt16LE(at + 2); + at += 4; + at += type === 73 ? 2 + buffer.readUInt16LE(at) : size; + } + fields.push({ name, width, kind }); + } + return { count, start, fields, flag: buffer[start] }; +} + +// rows: 인쇄정보표 행들(읽은 값). BLOB(75) 칸은 비워 씀. +function write(template, rows) { + const { count, start, fields, flag } = layout(template); + const encoder = (s) => { + const bytes = []; + for (const ch of String(s)) { + const code = ch.codePointAt(0); + if (code < 0x80) bytes.push(code); + else { + const found = cp949Table().get(ch); + if (found == null) throw Error(`인쇄양식 글자를 cp949 로 바꿀 수 없습니다: ${ch}`); + bytes.push(found >> 8, found & 0xff); + } + } + return Buffer.from(bytes); + }; + const parts = [Buffer.from(template.subarray(0, start))]; + parts[0].writeUInt16LE(rows.length, 14); + for (const row of rows) { + const status = Buffer.alloc(Math.ceil(count / 4)), + values = []; + fields.forEach(({ name, width, kind }, i) => { + const v = row[name]; + if (kind === 75 && v?.binary != null) { + const data = Buffer.from(v.binary, "base64"), + length = Buffer.alloc(4); + length.writeUInt32LE(data.length); + values.push(length, data); + return; + } + if (v == null || kind === 75) { + status[i >> 2] |= (row.__nulls?.[name] || 1) << ((i % 4) * 2); + return; + } + if (kind === 1 && width === 4) { + const b = Buffer.alloc(4); + b.writeInt32LE(Number(v) | 0); + values.push(b); + } else if (kind === 3 && width === 2) { + const b = Buffer.alloc(2); + b.writeInt16LE(Number(v) | 0); + values.push(b); + } else if (kind === 4 && width === 8) { + const b = Buffer.alloc(8); + b.writeDoubleLE(Number(v)); + values.push(b); + } else if (kind === 73 && width === 1) { + const text = encoder(v); + if (text.length > 255) throw Error(`인쇄양식 글이 너무 깁니다: ${name}`); + values.push(Buffer.from([text.length]), text); + } else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`); + }); + parts.push(Buffer.from([flag]), status, ...values); + } + return Buffer.concat(parts); +} + +let table = null; +function cp949Table() { + if (table) return table; + table = new Map(); + const decoder = new TextDecoder("euc-kr"); + for (let hi = 0x81; hi <= 0xfe; hi++) + for (let lo = 0x41; lo <= 0xfe; lo++) { + const ch = decoder.decode(Buffer.from([hi, lo])); + if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, (hi << 8) | lo); + } + return table; +} + +// BLOB(설명 등)까지 담아 읽기 — 그대로 다시 쓰면 원본과 같은 바이트 +function readFull(buffer) { + const { count, start, fields } = layout(buffer), + rows = buffer.readUInt16LE(14), + decoder = new TextDecoder("euc-kr"); + let at = start; + const out = []; + for (let r = 0; r < rows; r++) { + at += 1; + const status = buffer.subarray(at, at + Math.ceil(count / 4)); + at += status.length; + const row = {}; + fields.forEach(({ name, width, kind }, i) => { + const state = (status[i >> 2] >> ((i % 4) * 2)) & 3; + if (state) { + if (state !== 1) (row.__nulls ||= {})[name] = state; // 빈 칸 표지(1·2)도 그대로 되살리게 + return; + } + if (kind === 1 && width === 4) ((row[name] = buffer.readInt32LE(at)), (at += 4)); + else if (kind === 3 && width === 2) ((row[name] = buffer.readInt16LE(at)), (at += 2)); + else if (kind === 4 && width === 8) ((row[name] = buffer.readDoubleLE(at)), (at += 8)); + else if (kind === 73 && width === 1) { + const n = buffer[at]; + row[name] = decoder.decode(buffer.subarray(at + 1, at + 1 + n)); + at += 1 + n; + } else if (kind === 75) { + const n = buffer.readUInt32LE(at); + row[name] = { binary: buffer.subarray(at + 4, at + 4 + n).toString("base64") }; + at += 4 + n; + } else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`); + }); + out.push(row); + } + return out; +} + +module.exports = { read: (buffer) => readPrintOptions(buffer), readFull, write, layout }; diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e4620e34f..c96fcca5d 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -7,6 +7,9 @@ const http = require("node:http"), const { importEstx, table: dbisamTable } = require("./import-estx.cjs"); const { readXls } = require("./xls-read.cjs"); const bookStore = require("./book-store.cjs"); +const printFormat = require("./print-format.cjs"); +const pfotTemplate = + process.env.EST_PFOT_TEMPLATE || "C:/Dasansoft/Est Plus 2/System/EstFormat/main.pfot"; const mainDataPath = process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx"; let mainData = null; @@ -311,6 +314,22 @@ const server = http.createServer(async (req, res) => { ); return; } + if (req.method === "POST" && url.pathname === "/api/pfot-read") { + reply(res, 200, printFormat.readFull(await body(req))); + return; + } + if (req.method === "POST" && url.pathname === "/api/pfot-write") { + // 인쇄양식(.pfot) 쓰기: 원본 설치 인쇄양식의 필드 설명자를 바탕으로 행만 새로 + const input = JSON.parse((await body(req)).toString("utf8")); + if (!Array.isArray(input.rows)) throw Error("인쇄양식 행 형식 오류"); + reply( + res, + 200, + printFormat.write(fs.readFileSync(pfotTemplate), input.rows), + "application/octet-stream", + ); + return; + } if (req.method === "POST" && url.pathname === "/api/read-xls") { reply(res, 200, readXls(await body(req))); return;