diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 1b15084db..3d8dab1b9 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -487,6 +487,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] 공사설명서·시방서 서식 편집(원본 TFRMESTEDITOR mnuFormatPar·mnuTableCreate·mnuFormatTable·popupBookMark·mnuFormatTableS·mnuSaveToDefault) — 서식 있는 편집 칸: 문단모양(정렬·들여쓰기·왼쪽 여백·줄간격), 표만들기(줄·칸), 표모양(테두리·칸 여백·너비), 책갈피(넣기·찾아가기), 양식목록(서버 양식 보관함 종류 document — 불러오기·지우기·현재 문서 저장), 기본양식으로 저장(빈 문서를 열면 채움). 본문은 `docs[kind].html`(허용 태그·속성만)과 글자 `text` 를 함께 두고 인쇄는 html 우선. 갑지에서 붙여넣기는 커서 자리에. 원본 WPT 서식 가져오기·ESTX 저장은 여전히 하지 않음.
+ - 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 설명서 첫 문단 가운데 정렬, 3×3 표·테두리 2px, 공사명 붙여넣기, 저장 뒤 인쇄 html 에 표·정렬 반영, 기본양식 저장 → 빈 문서 열 때 표 채움, 양식·문서 지워 복구. (DOM 이 필요해 pytest 단위 시험은 없음)
- [x] 원가계산서 산출근거 약어로 표시(원본 popupSNameShow 0xd0e494 · 약어 생성 0xc97790) — 원가계산서(W) 메뉴 단추로 켜고 끔. 산출근거 식의 기호(영문 시작 또는 2자 이하, 범위 기호 뒤 제외)를 그 행 약어(SName, 없으면 빈칸 뺀 이름)로, 숫자×100 이 그 행 요율이면 요율 글자로, 「:」 는 「~」 로 바꿔 화면·인쇄에 보이고 그동안 산출근거 칸 잠금. 저장 값(VSusic)은 그대로. 기성 원가 화면의 같은 확인란과 같은 설정.
- 검증완료: `test_est_wonga_tools.py` 5개(샘플 「직.노 × 16.5%」·「( 산출경비 ~ 18 )」·「순.공 × 8%」, 번호 열쇠, 원본 식 불변) 통과. ORCA 브라우저에서 켜기 → 약어 표시·입력칸 0, 끄기 → 입력칸 31, 설정 지워 복구.
- [x] 발췌근거(원본 barbtnColumn — TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84) — 자재·노임 화면 「발췌근거」 단추로 출처 열(Cost1~6) 이름·쪽수(자재)·보기 편집, 자재는 8가지 출처 이름에서 고르고 중복 거부, 숨긴 출처는 최저·평균·최고 단가 고르기에서 빠짐(서버 재계산도 같은 코드). 값은 계산 환경 `PriceSources`(ESTX 저장에는 남지 않음 — 원본도 출처 이름은 격자 배치에만 둠).
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index c7a665023..a6bf29b44 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -76,6 +76,8 @@
편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다.
+공사설명서·시방서 편집 창에서는 문단 정렬·들여쓰기·줄간격, 표 만들기와 표 테두리, 책갈피를 쓸 수 있습니다. `양식목록`으로 자주 쓰는 문서를 저장·불러오고, `기본양식으로 저장`한 문서는 빈 설명서·시방서를 열 때 자동으로 채워집니다.
+
원가계산서(W) › `산출근거 약어로 표시`를 누르면 산출근거가 「직.노 × 16.5%」처럼 비목 약어와 요율로 보이고(인쇄 포함), 다시 누르면 원래 식으로 돌아옵니다. 약어로 보이는 동안에는 산출근거를 고칠 수 없습니다.
자재단가·노임단가 화면의 `발췌근거` 단추로 가격 출처 열(출처1~6)의 이름과 쪽수를 바꾸거나 숨길 수 있습니다. 숨긴 출처는 최저·평균·최고 단가를 고를 때 빠집니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index c7c1f77ed..ece14587d 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -54,6 +54,7 @@
+
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 05a2f1153..0e187a513 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -626,7 +626,8 @@ const EstBusinessActions = (() => {
(typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) ||
(typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) ||
(typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node)) ||
- (typeof EstSources !== "undefined" && EstSources.handle(name, state))
+ (typeof EstSources !== "undefined" && EstSources.handle(name, state)) ||
+ (typeof EstDocEditor !== "undefined" && EstDocEditor.handle(name, state, node))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-doc-editor.js b/tools/est-test/native-doc-editor.js
new file mode 100644
index 000000000..be39e9e2a
--- /dev/null
+++ b/tools/est-test/native-doc-editor.js
@@ -0,0 +1,306 @@
+// 원본 공사설명서·시방서 편집기(TFRMESTEDITOR) 서식 — 문단모양(mnuFormatPar: 정렬·들여쓰기·줄간격), 표만들기(mnuTableCreate),
+// 표모양(mnuFormatTable: 테두리·칸 여백·너비), 책갈피(popupBookMark: 이름 붙이기·찾아가기),
+// 양식목록(mnuFormatTableS·popupListTableS: 서버 양식 보관함 종류 document 에서 불러오기·지우기),
+// 기본양식으로 저장(mnuSaveToDefault·popupSavetoDefault: 이름 「기본양식-설명서」·「기본양식-시방서」 — 빈 문서를 열 때 채움).
+// 본문은 docs[kind].html(허용한 태그·속성만 남김)과 글자만 뽑은 docs[kind].text 를 함께 둔다.
+const EstDocEditor = (() => {
+ const TAGS = new Set([
+ "P",
+ "DIV",
+ "BR",
+ "B",
+ "STRONG",
+ "I",
+ "EM",
+ "U",
+ "TABLE",
+ "TBODY",
+ "THEAD",
+ "TR",
+ "TD",
+ "TH",
+ "A",
+ "SPAN",
+ "H1",
+ "H2",
+ "H3",
+ "UL",
+ "OL",
+ "LI",
+ ]);
+ const STYLE =
+ /^(text-align|text-indent|line-height|margin-left|border|border-collapse|padding|width|font-weight|font-style|text-decoration)$/;
+ const DEFAULT = { explan: "기본양식-설명서", sibang: "기본양식-시방서" };
+
+ // 허용 태그·속성만 남김(id 는 책갈피, style 은 정해진 속성만)
+ function clean(html) {
+ const box = document.createElement("div");
+ box.innerHTML = String(html ?? "");
+ const walk = (node) => {
+ for (const child of [...node.childNodes]) {
+ if (child.nodeType === 3) continue;
+ if (child.nodeType !== 1 || !TAGS.has(child.tagName)) {
+ if (child.nodeType === 1) {
+ walk(child);
+ child.replaceWith(...child.childNodes);
+ } else child.remove();
+ continue;
+ }
+ for (const attr of [...child.attributes]) {
+ if (attr.name === "id" && child.tagName === "A") continue;
+ if (["colspan", "rowspan"].includes(attr.name)) continue;
+ if (attr.name === "style") {
+ const kept = attr.value
+ .split(";")
+ .map((p) => p.split(":").map((x) => x.trim()))
+ .filter(([k, v]) => k && v && STYLE.test(k) && !/url|expression/i.test(v))
+ .map(([k, v]) => `${k}:${v}`)
+ .join(";");
+ if (kept) child.setAttribute("style", kept);
+ else child.removeAttribute("style");
+ continue;
+ }
+ child.removeAttribute(attr.name);
+ }
+ walk(child);
+ }
+ };
+ walk(box);
+ return box.innerHTML;
+ }
+ const escText = (t) =>
+ String(t ?? "")
+ .replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c])
+ .split(/\r?\n/)
+ .map((l) => `
${l || "
"}
`)
+ .join("");
+ function source(d) {
+ return d.html ? clean(d.html) : escText(d.text);
+ }
+ function printHtml(d) {
+ if (d.html) return `${clean(d.html)}
`;
+ return "";
+ }
+
+ function body(d) {
+ return `${[
+ ["doc-par", "문단모양"],
+ ["doc-table", "표만들기"],
+ ["doc-table-style", "표모양"],
+ ["doc-bookmark", "책갈피"],
+ ["doc-forms", "양식목록"],
+ ["doc-default", "기본양식으로 저장"],
+ ]
+ .map(([a, l]) => ``)
+ .join(
+ "",
+ )}
${source(d)}
`;
+ }
+ const rich = () => document.getElementById("doc-rich");
+ function collect() {
+ const node = rich();
+ return {
+ html: clean(node.innerHTML),
+ text: node.innerText.replace(/\n{3,}/g, "\n\n").trimEnd(),
+ };
+ }
+
+ // 커서 자리 기억(대화 단추를 누르면 선택이 풀리므로)
+ let saved = null;
+ function remember() {
+ const sel = getSelection();
+ if (sel.rangeCount && rich()?.contains(sel.anchorNode)) saved = sel.getRangeAt(0).cloneRange();
+ }
+ function restore() {
+ const node = rich();
+ if (!node) return null;
+ node.focus();
+ const sel = getSelection();
+ sel.removeAllRanges();
+ if (saved && node.contains(saved.startContainer)) sel.addRange(saved);
+ else {
+ const r = document.createRange();
+ r.selectNodeContents(node);
+ r.collapse(false);
+ sel.addRange(r);
+ }
+ return sel.getRangeAt(0);
+ }
+ function insert(html) {
+ const range = restore();
+ range.deleteContents();
+ const frag = range.createContextualFragment(clean(html));
+ const last = frag.lastChild;
+ range.insertNode(frag);
+ if (last) {
+ range.setStartAfter(last);
+ range.collapse(true);
+ }
+ }
+ function insertText(text) {
+ const range = restore();
+ range.deleteContents();
+ range.insertNode(document.createTextNode(text));
+ range.collapse(false);
+ }
+ function blockAt(range) {
+ let n = range.startContainer;
+ if (n.nodeType === 3) n = n.parentNode;
+ const node = rich();
+ while (n && n !== node && !/^(P|DIV|H1|H2|H3|LI|TD|TH)$/.test(n.tagName)) n = n.parentNode;
+ if (!n || n === node) {
+ document.execCommand("formatBlock", false, "p");
+ n = getSelection().anchorNode;
+ if (n?.nodeType === 3) n = n.parentNode;
+ }
+ return n && n !== node ? n : null;
+ }
+ function tableAt(range) {
+ let n = range.startContainer;
+ while (n && n !== rich() && n.tagName !== "TABLE") n = n.parentNode;
+ return n?.tagName === "TABLE" ? n : null;
+ }
+
+ // 작은 입력 창(대화 안에 겹쳐 띄움)
+ function panel(html) {
+ let box = document.getElementById("doc-panel");
+ if (!box) {
+ box = document.createElement("div");
+ box.id = "doc-panel";
+ box.className = "business-tools";
+ rich().before(box);
+ }
+ box.innerHTML = html;
+ }
+ const v = (name) => document.querySelector(`#doc-panel [name="${name}"]`)?.value;
+
+ async function run(name, state, node) {
+ const d = state.docEditor;
+ if (name === "doc-par") {
+ remember();
+ panel(
+ `문단모양 들여쓰기(pt) 왼쪽 여백(pt) 줄간격(%) `,
+ );
+ } else if (name === "doc-par-ok") {
+ const block = blockAt(restore());
+ if (!block) throw Error("문단을 고르세요.");
+ block.style.textAlign = v("align");
+ block.style.textIndent = `${Number(v("indent")) || 0}pt`;
+ block.style.marginLeft = `${Number(v("margin")) || 0}pt`;
+ block.style.lineHeight = `${Number(v("line")) || 160}%`;
+ panel("");
+ } else if (name === "doc-table") {
+ remember();
+ panel(
+ `표만들기 줄 칸 `,
+ );
+ } else if (name === "doc-table-ok") {
+ const rows = Math.min(100, Math.max(1, Number(v("rows")) || 1)),
+ cols = Math.min(30, Math.max(1, Number(v("cols")) || 1));
+ insert(
+ `${Array.from({ length: rows }, () => `${Array.from({ length: cols }, () => '
| ').join("")}
`).join("")}
`,
+ );
+ panel("");
+ } else if (name === "doc-table-style") {
+ remember();
+ if (!saved || !tableAt(saved)) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
+ panel(
+ `표모양 테두리(px) 칸 여백(pt) 너비(%) `,
+ );
+ } else if (name === "doc-table-style-ok") {
+ const table = tableAt(restore());
+ if (!table) throw Error("표 안을 누른 뒤 표모양을 고르세요.");
+ table.style.width = `${Number(v("width")) || 100}%`;
+ for (const cell of table.querySelectorAll("td,th")) {
+ cell.style.border = `${Number(v("border")) || 0}px solid #000`;
+ cell.style.padding = `${Number(v("pad")) || 0}pt`;
+ }
+ panel("");
+ } else if (name === "doc-bookmark") {
+ remember();
+ const marks = [...rich().querySelectorAll("a[id]")].map((a) => a.id);
+ panel(
+ `책갈피 이름 ${marks.map((m) => ``).join("")}`,
+ );
+ } else if (name === "doc-bookmark-ok") {
+ const mark = String(v("mark") ?? "").trim();
+ if (!/^[\w가-힣-]{1,40}$/.test(mark))
+ throw Error("책갈피 이름은 글자·숫자로 40자 안에서 적으세요.");
+ if (rich().querySelector(`a[id="${CSS.escape(mark)}"]`))
+ throw Error("같은 책갈피가 있습니다.");
+ insert(``);
+ panel("");
+ } else if (name === "doc-bookmark-go") {
+ rich()
+ .querySelector(`a[id="${CSS.escape(node.dataset.mark)}"]`)
+ ?.scrollIntoView({ block: "center" });
+ } else if (name === "doc-forms") {
+ const list = (await EstApp.api("/api/forms")).filter((f) => f.kind === "document");
+ panel(
+ list.length
+ ? `양식목록 ${list.map((f) => ``).join(" ")} `
+ : `저장한 양식이 없습니다. `,
+ );
+ } else if (name === "doc-form-load") {
+ const f = (await EstApp.api("/api/forms")).find(
+ (x) => x.kind === "document" && x.name === node.dataset.name,
+ );
+ if (!f) throw Error("양식이 없습니다.");
+ if (rich().innerText.trim() && !confirm("현재 내용을 양식으로 바꾸시겠습니까?")) return;
+ rich().innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
+ panel("");
+ } else if (name === "doc-form-delete") {
+ if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return;
+ await EstApp.api("/api/forms/delete", json({ kind: "document", name: node.dataset.name }));
+ return run("doc-forms", state, node);
+ } else if (name === "doc-form-save" || name === "doc-default") {
+ const fname = name === "doc-default" ? DEFAULT[d.kind] : String(v("form-name") ?? "").trim();
+ if (!fname) throw Error("양식 이름을 적으세요.");
+ await EstApp.api("/api/forms", json({ kind: "document", name: fname, data: collect() }));
+ panel(`「${EstApp.esc(fname)}」 양식으로 저장했습니다.`);
+ }
+ }
+ const json = (body) => ({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ // 빈 문서를 열면 기본양식을 채운다
+ async function fill(kind) {
+ const node = rich();
+ if (!node || node.innerText.trim()) return;
+ const f = (await EstApp.api("/api/forms")).find(
+ (x) => x.kind === "document" && x.name === DEFAULT[kind],
+ );
+ if (f && !node.innerText.trim())
+ node.innerHTML = f.data.html ? clean(f.data.html) : escText(f.data.text);
+ }
+
+ const names = new Set([
+ "doc-par",
+ "doc-par-ok",
+ "doc-table",
+ "doc-table-ok",
+ "doc-table-style",
+ "doc-table-style-ok",
+ "doc-bookmark",
+ "doc-bookmark-ok",
+ "doc-bookmark-go",
+ "doc-forms",
+ "doc-form-load",
+ "doc-form-delete",
+ "doc-form-save",
+ "doc-default",
+ ]);
+ function handle(name, state, node) {
+ if (!names.has(name)) return false;
+ run(name, state, node).catch((error) => panel(EstApp.esc(error.message)));
+ return true;
+ }
+ if (typeof document !== "undefined" && document.addEventListener)
+ document.addEventListener("selectionchange", () => {
+ if (rich()) remember();
+ });
+
+ return { clean, body, collect, printHtml, insertText, fill, handle };
+})();
diff --git a/tools/est-test/native-docs.js b/tools/est-test/native-docs.js
index 6144c0976..62b073b00 100644
--- a/tools/est-test/native-docs.js
+++ b/tools/est-test/native-docs.js
@@ -2,7 +2,7 @@
// 공사명은 열 때 공사 이름을 따르고 고치면 공사 이름에도 적는다(원본 TCommonOption.ProjectName 동기화).
// 공사금액 자동입력: 원가계산서에서 고른 비목을 두 방식으로 적는다(원본 c_cb2e78·c_cb3080, 한글금액 c_931b44).
// 1 = 「도급액 : 금 일천오백만원(₩15,000,000)」 · 2 = 「도급액 : 금15,000,000원(금일천오백만원)」, 「일」을 줄이지 않는다.
-// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식·여백은 다루지 않는다.
+// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식은 native-doc-editor.js.
const EstDocs = (() => {
const digits = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
const places = ["천", "백", "십", ""];
@@ -101,8 +101,9 @@ const EstDocs = (() => {
return `${gulga}
${esc(g.Date)}
${g.Position ? `
공사위치 : ${esc(g.Position)}
` : ""}
${esc(g.Name)}
${g.Explan ? `
공사개요 :
${esc(g.Explan)}` : ""}${g.Cost ? `
공사금액 :
${esc(g.Cost)}` : ""}
${esc(g.CompanyName)}
`;
}
if (form === 10 || form === 11) {
- const text = doc(project, form === 10 ? "sibang" : "explan").text;
- return text ? `${esc(text)}` : "";
+ const d = doc(project, form === 10 ? "sibang" : "explan");
+ if (d.html && typeof EstDocEditor !== "undefined") return EstDocEditor.printHtml(d);
+ return d.text ? `${esc(d.text)}` : "";
}
return "";
}
@@ -143,7 +144,7 @@ const EstDocs = (() => {
area("Gulga", "결재란(한 줄에 하나)", d.Gulga.join("\n"), 4);
} else
body =
- area("text", kind === "explan" ? "공사설명서" : "시방서", d.text, 14) +
+ EstDocEditor.body(d) +
`갑지에서 붙여넣기: ${[
["Name", "공사명"],
["Position", "공사위치"],
@@ -157,6 +158,10 @@ const EstDocs = (() => {
`${title} 편집`,
`
${body}
`,
);
+ if (kind === "explan" || kind === "sibang") {
+ state.docEditor = { kind };
+ EstDocEditor.fill(kind);
+ }
}
function collect(kind) {
const value = (name) => field(`doc-${name}`)?.value ?? "";
@@ -182,7 +187,7 @@ const EstDocs = (() => {
.map((l) => l.trim())
.filter(Boolean),
};
- return { text: value("text") };
+ return EstDocEditor.collect();
}
function handle(name, state, node) {
@@ -205,9 +210,7 @@ const EstDocs = (() => {
g = doc(state.project, "gabgi"),
text = String(g[key] ?? "").trim();
if (!text) throw Error("갑지에서 입력을 하지 않았습니다.");
- const area = field("doc-text"),
- at = area.selectionStart ?? area.value.length;
- area.value = area.value.slice(0, at) + text + area.value.slice(area.selectionEnd ?? at);
+ EstDocEditor.insertText(text);
} else return false;
return true;
}
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 52ff7ef96..6f8acafa2 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -75,6 +75,7 @@ const allowed = new Set([
"native-name-reg.js",
"native-tidy.js",
"native-multi-search.js",
+ "native-doc-editor.js",
"native-docs.js",
"native-wonga-tools.js",
"native-estimate-print.js",