diff --git a/resources/tester/test_est_memo.py b/resources/tester/test_est_memo.py new file mode 100644 index 000000000..c59c945fb --- /dev/null +++ b/resources/tester/test_est_memo.py @@ -0,0 +1,52 @@ +"""근거(메모) WPTools 글 형식 읽기·쓰기: 문단·굵게·표 칸·그림 원문 보존.""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ( + "\r\n" + "" + "
<근거> A&B
\r\n
\r\n" + "\r\n
공종1.5
" + '
x \r\n
\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("{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 "<근거>" in result["html"] + assert 'data-obj="0"' in result["html"] and " + diff --git a/tools/est-test/native-memo-wpt.cjs b/tools/est-test/native-memo-wpt.cjs new file mode 100644 index 000000000..0dcd0560f --- /dev/null +++ b/tools/est-test/native-memo-wpt.cjs @@ -0,0 +1,153 @@ +// 근거(메모) DifineMemo 의 WPTools 글 형식(V=560, zlib 안) 읽기·쓰기 — 근거자료보기 창(TfrmGunguEdit · spec_sort_memo §3)이 +// 고친 글을 원본 형식으로 되돌려 담는다. 읽기: 문단(
)·표(
)·그림 개체( 원문 그대로)와 +// 글자 굵게(문자 서식 cs 의 CharStyleON 1번 비트). 쓰기: 굴림체(영문 이름 GulimChe) 보통·굵게 두 서식, 표는 테두리 있는 기본 꼴, +// 그림은 읽은 원문 그대로, ASCII 밖 글자는 &#코드; — cp949 변환 없이 원본이 읽는 꼴. 문단 서식(간격·들여쓰기·글꼴 크기)은 버린다. +const EstMemoWpt = (() => { + const decode = (text) => + text + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/ /g, " ") + .replace(/&/g, "&"); + const encode = (text) => + [...String(text)] + .map((ch) => { + if (ch === "&") return "&"; + if (ch === "<") return "<"; + if (ch === ">") return ">"; + 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( + //g, + (m) => ``, + ); + const bold = new Map(); + for (const m of text.matchAll(//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(" 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:'GulimChe';CharFontSize:1000;"; + const runs = (p) => + p.map((r) => (r.object ? r.object : `${encode(r.text)}`)).join(""); + const list = (items) => + items + .map((n) => + n.table + ? `${n.table + .map( + (row) => + `${row.map((cell) => ``).join("")}`, + ) + .join("")}
${runs(cell[0]?.p || [])}${list(cell.slice(1))}
\r\n` + : `
${runs(n.p)}
\r\n`, + ) + .join(""); + return ( + "\r\n" + + '' + + "\r\n" + + "\r\n" + + `` + + list(nodes) + ); + } + + // 편집 창용 html(그림은 고칠 수 없는 자리표 — data-obj 번호로 원문을 되찾음) + function html(nodes, objects = []) { + const esc = (s) => + s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + const runs = (p) => + p + .map((r) => + r.object + ? `[그림]` + : r.bold + ? `${esc(r.text)}` + : esc(r.text), + ) + .join("") || "
"; + const list = (items) => + items + .map((n) => + n.table + ? `${n.table.map((row) => `${row.map((cell) => ``).join("")}`).join("")}
${list(cell)}
` + : `
${runs(n.p)}
`, + ) + .join(""); + return list(nodes); + } + return { read, write, html, encode, decode }; +})(); +if (typeof module !== "undefined" && module.exports) module.exports = EstMemoWpt; diff --git a/tools/est-test/native-tidy.js b/tools/est-test/native-tidy.js index b08900869..51b7ad582 100644 --- a/tools/est-test/native-tidy.js +++ b/tools/est-test/native-tidy.js @@ -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 "

"; + if (typeof memo === "string") + return memo + .split(/\r?\n/) + .map((l) => `
${EstApp.esc(l) || "
"}
`) + .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); + } + // 편집 창 → 마디 목록(문단·표·그림): 블록 요소·
마다 문단을 끊고 굵게는 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 - ? `
${EstApp.esc(body)}
${typeof row.DifineMemo === "string" ? "" : "

원본 서식 메모라 웹에서는 읽기만 합니다.

"}` - : "

이 행에는 근거(메모)가 없습니다.

", + `근거자료보기 — ${row.Name ?? row.Code ?? ""}`, + `
${await memoHtml(row.DifineMemo, memo.objects)}
`, ); + } 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) => { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 256ce2a4c..88cd00962 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -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",