feat(est-test): 인쇄양식(.pfot) 불러오기·저장하기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
@@ -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% 맞춤(계산 불변).
|
||||
|
||||
@@ -113,7 +113,9 @@
|
||||
<hr />
|
||||
<button data-action="csv">현재 자료 CSV 내보내기</button
|
||||
><button data-action="print">인쇄</button>
|
||||
<button data-action="print-batch">묶음 인쇄(설계서 일체·간지)</button>
|
||||
<button data-action="print-batch">묶음 인쇄(설계서 일체·간지)</button
|
||||
><button data-action="pfot-load">인쇄양식 불러오기(.pfot)</button
|
||||
><button data-action="pfot-save">인쇄양식 저장하기(.pfot)</button>
|
||||
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
|
||||
<button data-action="import-xlsx">Excel 표 불러오기</button>
|
||||
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user