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;