feat(est-test): 근거자료보기 고치기·붙여넣기·원본 서식 저장

- 근거(메모)를 문단·굵게·표·그림 자리표로 펼쳐 편집, 붙여넣기(서식 있는 글·표), 저장
- WPTools 글 형식 읽기·쓰기(native-memo-wpt.cjs) — 그림 원문 보존, ASCII 로 써서 cp949 변환 없음
- ESTX 저장: 원래 메모가 있던 행만, 새 메모는 JSON 저장 안내

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 19:14:08 +09:00
co-authored by Claude Opus 5.5
parent 863a958398
commit f6d73458bb
7 changed files with 329 additions and 10 deletions
+52
View File
@@ -0,0 +1,52 @@
"""근거(메모) WPTools 글 형식 읽기·쓰기: 문단·굵게·표 칸·그림 원문 보존."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SOURCE = (
"<!WPTools_Format V=560/>\r\n<stylesheet></stylesheet>"
"<cs nr=1 wpsty=[[CharFont:&#39;A&#39;;]]/><cs nr=2 wpsty=[[CharStyleMask:1;CharStyleON:1;]]/>"
"<div cs=2><c nr=2/>&lt;&#44540;&#44144;&gt;<c nr=1/> A&amp;B</div>\r\n<div cs=1></div>\r\n"
"<table cs=1><tr><td cs=1><c nr=1/>&#44277;&#51333;</td>\r\n<td cs=1><c nr=2/>1.5</td></tr></table>"
'<div cs=1><c nr=1/>x <txtobj wpsty=[[Type:12;]] type="TWPOImage">\r\n<!--QUJD--></txtobj></div>\r\n'
)
def node(script):
code = (
"const W=require(process.argv[1]);const S=process.argv[2];"
f"console.log(JSON.stringify(({script})()))"
)
out = subprocess.check_output(
["node", "-e", code, str(ROOT / "tools/est-test/native-memo-wpt.cjs"), SOURCE],
text=True,
encoding="utf-8",
)
return json.loads(out)
def test_read_paragraphs_bold_table_and_object():
nodes = node("()=>W.read(S)")
assert nodes[0] == {
"p": [{"text": "<근거>", "bold": True}, {"text": " A&B", "bold": False}]
}
assert nodes[1] == {"p": []}
assert nodes[2]["table"] == [
[[{"p": [{"text": "공종", "bold": False}]}], [{"p": [{"text": "1.5", "bold": True}]}]]
]
assert nodes[3]["p"][0] == {"text": "x ", "bold": False}
assert nodes[3]["p"][1]["object"].startswith("<txtobj") and "QUJD" in nodes[3]["p"][1]["object"]
def test_write_round_trips_and_stays_ascii():
result = node(
"()=>{const n=W.read(S),w=W.write(n);"
"return {same:JSON.stringify(W.read(w))===JSON.stringify(n),ascii:/^[\\x00-\\x7e]*$/.test(w),"
"html:W.html(n)}}"
)
assert result["same"] is True
assert result["ascii"] is True
assert "<b>&lt;근거&gt;</b>" in result["html"]
assert 'data-obj="0"' in result["html"] and "<table" in result["html"]
+2
View File
@@ -497,6 +497,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: 시험 2개 통과. ORCA 브라우저에서 코드 끔·레벨·노재경 구분·사정 끔·수량 0 숨김 반영과 취소 원복 확인, 임시 회차·옵션 원복.
- [x] 도급내역 선택(spec_pb1 §3) — 설계변경 내역서에서 기성 회차를 추가하면 당초/변경 고르기. 당초면 행마다 당초 칸(_)으로 Index·품명·규격·단위(당초가 비면 지금 값)·수량·단가·금액. 웹은 `_` = 당초(설계변경 시작 때 복사본) 해석을 따름(spec_pb1 은 원본 분기상 `_` = 변경으로 읽음 — 실무 샘플 3건의 `_` 값이 서로 같아 당초 쪽으로 판단). 원본의 GC 누락 결함은 재현하지 않음.
- 검증완료: 시험 1개 통과(당초 수량 4·변경 10). ORCA 브라우저에서 고르기 창·회차 loadMethod 확인, 원복.
- [x] 근거자료보기 고치기·붙여넣기(TfrmGunguEdit popPaste · spec_pc §8 · spec_sort_memo §3) — 근거(메모)를 문단·굵게·표·그림(자리표)으로 펼쳐 고치고, 붙여넣기(서식 있는 글·표)·저장. 저장은 원본 WPTools 글 형식(굴림체 보통·굵게, 표 기본 테두리, 그림 원문 그대로, zlib). 문단 간격·들여쓰기·글꼴 크기는 버림. 원래 메모가 있던 행은 ESTX 저장 가능, 새 메모는 JSON 저장만(새 BLOB 할당 미지원).
- 검증완료: 메모 시험 2개·정리 시험 2개 통과. 설치 샘플 메모 201건 글자 201/201 일치·그림 71/71 보존·되쓰기 188/201 동일(나머지는 칸 안 빈 줄만 빠짐), 고친 메모 ESTX 저장 → 다시 읽기 일치. ORCA 브라우저에서 굵게·표 저장 후 다시 열기 확인, 원복.
- [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
- [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
- [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+5 -1
View File
@@ -282,7 +282,11 @@ function exportEstx(project) {
} else if (kind === 4 && width === 2) {
if (!Number.isInteger(next)) throw Error("ESTX 정수 오류");
data.writeInt16LE(next, at + 1);
} else if (
} else if (kind === 3 && next?.binary && !old?.binary)
throw Error(
`${key}.${name}: 새 근거(메모)는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`,
);
else if (
kind === 3 &&
width === 8 &&
next?.binary &&
+1
View File
@@ -14,6 +14,7 @@
<script defer src="native-excel-edit.cjs"></script>
<script defer src="native-quantity-total.js"></script>
<script defer src="native-quantity-wpt.cjs"></script>
<script defer src="native-memo-wpt.cjs"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-quantity-table.js"></script>
<script defer src="native-quantity-list.js"></script>
+153
View File
@@ -0,0 +1,153 @@
// 근거(메모) DifineMemo 의 WPTools 글 형식(V=560, zlib 안) 읽기·쓰기 — 근거자료보기 창(TfrmGunguEdit · spec_sort_memo §3)이
// 고친 글을 원본 형식으로 되돌려 담는다. 읽기: 문단(<div>)·표(<table><tr><td>)·그림 개체(<txtobj> 원문 그대로)와
// 글자 굵게(문자 서식 cs 의 CharStyleON 1번 비트). 쓰기: 굴림체(영문 이름 GulimChe) 보통·굵게 두 서식, 표는 테두리 있는 기본 꼴,
// 그림은 읽은 원문 그대로, ASCII 밖 글자는 &#코드; — cp949 변환 없이 원본이 읽는 꼴. 문단 서식(간격·들여쓰기·글꼴 크기)은 버린다.
const EstMemoWpt = (() => {
const decode = (text) =>
text
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
const encode = (text) =>
[...String(text)]
.map((ch) => {
if (ch === "&") return "&amp;";
if (ch === "<") return "&lt;";
if (ch === ">") return "&gt;";
const code = ch.codePointAt(0);
return code < 32 || code > 126 ? `&#${code};` : ch;
})
.join("");
// source → 마디 목록: { p: [{ text, bold } | { object }] } · { table: [[ [마디…] ]] }
function read(source) {
const objects = [];
const text = source.replace(
/<txtobj[\s\S]*?<\/txtobj>/g,
(m) => `<obj ${objects.push(m) - 1}/>`,
);
const bold = new Map();
for (const m of text.matchAll(/<cs nr=(\d+) wpsty=\[\[([\s\S]*?)\]\]\/>/g)) {
const on = /CharStyleON:(\d+)/.exec(m[2]);
bold.set(m[1], Boolean(on && Number(on[1]) & 1));
}
const root = [],
stack = [{ list: root }];
let para = null,
current = false;
const top = () => stack.at(-1);
const push = (run) => {
if (!para) {
para = { p: [] };
top().list.push(para);
}
const last = para.p.at(-1);
if (run.text != null && last?.text != null && last.bold === run.bold) last.text += run.text;
else para.p.push(run);
};
for (const part of text.split(/(<[^>]*>)/)) {
if (!part) continue;
if (!part.startsWith("<")) {
const t = decode(part.replace(/\r?\n/g, ""));
if (t && para) push({ text: t, bold: current });
continue;
}
const tag = /^<\/?([a-zA-Z]+)/.exec(part)?.[1]?.toLowerCase(),
closing = part.startsWith("</");
if (tag === "div") {
if (closing) para = null;
else {
// 칸 첫 문단이 비어 있으면(칸 자체가 문단) 그 자리를 씀
if (!(para && !para.p.length && top().list.at(-1) === para)) {
para = { p: [] };
top().list.push(para);
}
const cs = /\bcs=(\d+)/.exec(part);
current = cs ? bold.get(cs[1]) || false : false;
}
} else if (tag === "c") current = bold.get(/nr=(\d+)/.exec(part)?.[1]) || false;
else if (tag === "obj") push({ object: objects[Number(/obj (\d+)/.exec(part)[1])] });
else if (tag === "table" && !closing) {
const table = { table: [] };
top().list.push(table);
stack.push({ table });
} else if (tag === "tr" && !closing) {
const row = [];
stack.findLast((s) => s.table)?.table.table.push(row);
stack.push({ row });
} else if (tag === "td" && !closing) {
const cell = [];
stack.findLast((s) => s.row)?.row.push(cell);
stack.push({ list: cell });
// 칸 = 문단 하나(글이 td 바로 안에) — 안에 div 가 더 있으면 이어지는 문단
para = { p: [] };
cell.push(para);
const cs = /\bcs=(\d+)/.exec(part);
current = cs ? bold.get(cs[1]) || false : false;
} else if (["table", "tr", "td"].includes(tag) && closing) {
const key = { table: "table", tr: "row", td: "list" }[tag];
while (stack.length > 1 && !stack.at(-1)[key]) stack.pop();
if (stack.length > 1) stack.pop();
para = null;
}
}
return root;
}
function write(nodes) {
const font = "CharFont:&#39;GulimChe&#39;;CharFontSize:1000;";
const runs = (p) =>
p.map((r) => (r.object ? r.object : `<c nr=${r.bold ? 2 : 1}/>${encode(r.text)}`)).join("");
const list = (items) =>
items
.map((n) =>
n.table
? `<table cs=1>${n.table
.map(
(row) =>
`<tr wpsty=[[BorderFlags:15;BorderType:0;]]>${row.map((cell) => `<td cs=1 wpsty=[[BorderFlags:15;]]>${runs(cell[0]?.p || [])}${list(cell.slice(1))}</td>`).join("")}</tr>`,
)
.join("")}</table>\r\n`
: `<div cs=1>${runs(n.p)}</div>\r\n`,
)
.join("");
return (
"<!WPTools_Format V=560/>\r\n" +
'<GlobalPageFormat wpcss="landscape:0;paperw:11906;paperh:16838;margr:1880;margl:1880;margt:1440;margb:1440;marg_header:720;marg_footer:720;deftabstop:720;marginmirror:0;"/>' +
"<StandardFont wpcss=\"CharFont:'Arial';CharFontSize:1100;\"/>\r\n" +
"<numberstyles></numberstyles>\r\n<stylesheet></stylesheet>" +
`<cs nr=1 wpsty=[[${font}]]/><cs nr=2 wpsty=[[${font}CharStyleMask:1;CharStyleON:1;]]/>` +
list(nodes)
);
}
// 편집 창용 html(그림은 고칠 수 없는 자리표 — data-obj 번호로 원문을 되찾음)
function html(nodes, objects = []) {
const esc = (s) =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const runs = (p) =>
p
.map((r) =>
r.object
? `<span contenteditable="false" data-obj="${objects.push(r.object) - 1}" style="border:1px dashed #888;padding:0 4px">[그림]</span>`
: r.bold
? `<b>${esc(r.text)}</b>`
: esc(r.text),
)
.join("") || "<br>";
const list = (items) =>
items
.map((n) =>
n.table
? `<table border="1" style="border-collapse:collapse">${n.table.map((row) => `<tr>${row.map((cell) => `<td>${list(cell)}</td>`).join("")}</tr>`).join("")}</table>`
: `<div>${runs(n.p)}</div>`,
)
.join("");
return list(nodes);
}
return { read, write, html, encode, decode };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstMemoWpt;
+115 -9
View File
@@ -1,5 +1,5 @@
// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT),
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·삭제(mnuGunguEdit·mnuGunguDel, 필드 DifineMemo).
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·고치기·붙여넣기·삭제(mnuGunguEdit·TfrmGunguEdit popPaste·mnuGunguDel, 필드 DifineMemo).
// 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n.
// Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다.
// 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다.
@@ -69,7 +69,7 @@ const EstTidy = (() => {
return n - start;
}
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 웹은 글자만 꺼내 읽기 전용으로 보인다.
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 글자만 꺼낸다.
async function memoText(memo) {
if (memo == null) return "";
if (typeof memo === "string") return memo;
@@ -92,6 +92,82 @@ const EstTidy = (() => {
.trim();
}
async function memoHtml(memo, objects) {
if (memo == null) return "<div><br></div>";
if (typeof memo === "string")
return memo
.split(/\r?\n/)
.map((l) => `<div>${EstApp.esc(l) || "<br>"}</div>`)
.join("");
let bytes = Uint8Array.from(atob(memo.binary || ""), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
return EstMemoWpt.html(EstMemoWpt.read(new TextDecoder("euc-kr").decode(bytes)), objects);
}
async function pack(text) {
const bytes = new Uint8Array(
await new Response(
new Blob([new TextEncoder().encode(text)])
.stream()
.pipeThrough(new CompressionStream("deflate")),
).arrayBuffer(),
);
let out = "";
for (let i = 0; i < bytes.length; i += 0x8000)
out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(out);
}
// 편집 창 → 마디 목록(문단·표·그림): 블록 요소·<br> 마다 문단을 끊고 굵게는 b/strong·굵은 글꼴
function memoNodes(editor, objects) {
const BLOCK = /^(DIV|P|LI|H[1-6]|PRE|BLOCKQUOTE)$/;
function walk(root) {
const out = [];
let para = null;
const close = () => (para = null);
const add = (run) => {
if (!para) out.push((para = { p: [] }));
const last = para.p.at(-1);
if (run.text != null && last?.text != null && last.bold === run.bold) last.text += run.text;
else para.p.push(run);
};
const visit = (node, bold) => {
if (node.nodeType === 3) {
if (node.data && !/^\s*\n\s*$/.test(node.data))
add({ text: node.data.replace(/\n/g, " "), bold });
return;
}
if (node.nodeType !== 1) return;
if (node.dataset.obj != null) return add({ object: objects[Number(node.dataset.obj)] });
if (node.tagName === "BR") {
if (!para) out.push({ p: [] });
return close();
}
if (node.tagName === "TABLE") {
close();
out.push({
table: [...node.rows].map((tr) => [...tr.cells].map((td) => walk(td))),
});
return;
}
const b =
bold ||
/^(B|STRONG)$/.test(node.tagName) ||
Number(node.style.fontWeight) >= 600 ||
node.style.fontWeight === "bold";
const block = BLOCK.test(node.tagName);
if (block) close();
for (const child of node.childNodes) visit(child, b);
if (block) close();
};
for (const child of root.childNodes) visit(child, false);
return out;
}
return walk(editor);
}
function memoTarget(state) {
const index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
@@ -135,21 +211,51 @@ const EstTidy = (() => {
),
);
} else if (name === "memo-view") {
const { row } = memoTarget(state),
body = await memoText(row.DifineMemo);
// 근거자료보기(TfrmGunguEdit): 원본 서식 메모를 문단·표·그림(자리표)으로 펼쳐 고치고 붙여넣고 저장
const { index, row } = memoTarget(state);
memo = { view: state.view, index, objects: [] };
EstApp.showDialog(
`근거(메모) — ${row.Name ?? row.Code ?? ""}`,
body
? `<pre style="white-space:pre-wrap;max-height:400px;overflow:auto">${EstApp.esc(body)}</pre>${typeof row.DifineMemo === "string" ? "" : "<p>원본 서식 메모라 웹에서는 읽기만 합니다.</p>"}`
: "<p>이 행에는 근거(메모)가 없습니다.</p>",
`근거자료보기 — ${row.Name ?? row.Code ?? ""}`,
`<div id="memo-editor" contenteditable="true" style="min-height:260px;max-height:420px;overflow:auto;border:1px solid #999;padding:6px;white-space:pre-wrap">${await memoHtml(row.DifineMemo, memo.objects)}</div><button data-action="memo-paste">붙여넣기</button> <button data-action="memo-save">저장</button>`,
);
} else if (name === "memo-paste") {
// popPaste(Ctrl+V): 클립보드 그대로(서식 있는 글·표 포함)
const editor = document.getElementById("memo-editor");
editor.focus();
let html = "";
try {
for (const item of await navigator.clipboard.read())
if (item.types.includes("text/html"))
html = await (await item.getType("text/html")).text();
} catch {}
if (html) document.execCommand("insertHTML", false, EstDocEditor.clean(html));
else document.execCommand("insertText", false, await navigator.clipboard.readText());
} else if (name === "memo-save") {
const editor = document.getElementById("memo-editor");
if (!editor || !memo) return;
const nodes = memoNodes(editor, memo.objects),
empty = !nodes.some((n) => n.table || n.p.some((r) => r.object || r.text.trim()));
const value = empty ? null : { binary: await pack(EstMemoWpt.write(nodes)) };
const { view, index } = memo;
EstApp.change((project) => (project.tables[view][index].DifineMemo = value), false);
EstApp.byId("dialog").close();
state.notice = "근거(메모)를 저장했습니다.";
} else if (name === "memo-delete") {
const { index } = memoTarget(state);
EstApp.change((project) => (project.tables[state.view][index].DifineMemo = null), false);
state.notice = "근거(메모)를 지웠습니다.";
}
}
const names = new Set(["tidy-sort", "tidy-arrange", "tidy-number", "memo-view", "memo-delete"]);
let memo = null;
const names = new Set([
"tidy-sort",
"tidy-arrange",
"tidy-number",
"memo-view",
"memo-paste",
"memo-save",
"memo-delete",
]);
function handle(name, state) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
+1
View File
@@ -32,6 +32,7 @@ const scripts = [
"native-excel-sheet.cjs",
"native-quantity-total.js",
"native-quantity-wpt.cjs",
"native-memo-wpt.cjs",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",