// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT), // 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·고치기·붙여넣기·삭제(mnuGunguEdit·TfrmGunguEdit popPaste·mnuGunguDel, 필드 DifineMemo). // 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n. // Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다. // 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다. // 원본 콤보 글자는 오름/내림이 뒤바뀌어 보이나(분석 추정) 웹은 실제 순서대로 적는다. // 호표 정리: 현재 차례 그대로 묶음 머리 Index 를 시작번호부터 다시 매기고 제목표 같은 코드도 맞춘다(행·코드 불변). const EstTidy = (() => { const num = (value) => Number(value) || 0; const base = ["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"]; const grouped = { HeadMainDat: "HeadTitleDat", CostMainDat: "CostTitleDat", MachMainDat: "MachTitleDat", }; const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0); const key = (row) => `${row.Name ?? ""}${row.Size ?? ""}`; function sortBase(project, table, byCode = false) { if (!base.includes(table)) throw Error("자재·노임·실적·견적 단가 화면에서 정렬하세요."); const rows = project.tables[table] || []; const kept = rows.filter( (row) => num(row.Index) > 0 && (key(row).trim() || num(row.GN) || num(row.TC)), ); const out = []; for (const gangub of [0, 1, 2]) out.push( ...kept .filter((row) => num(row.Gangub) === gangub) .sort((a, b) => byCode ? compare(String(a.Code ?? ""), String(b.Code ?? "")) : compare(key(a), key(b)), ), ); out.forEach((row, i) => (row.Index = i + 1)); project.tables[table] = out; return rows.length - out.length; } function arrange(project, table, descending, start) { if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다."); const order = (a, b) => compare(String(a ?? ""), String(b ?? "")) * (descending ? -1 : 1); if (base.includes(table)) { const rows = (project.tables[table] || []).filter((row) => row.Code); rows.sort((a, b) => order(a.Name, b.Name)).forEach((row, i) => (row.Index = start + i)); project.tables[table] = rows; return rows.length; } if (!grouped[table]) throw Error("이 화면은 위치정리를 할 수 없습니다."); const groups = [...EstNative.groups(project.tables[table] || []).values()].sort((a, b) => order(a.rows[0].Name, b.rows[0].Name), ); project.tables[table] = groups.flatMap((g) => g.rows); numberHeads(project, table, start); return groups.length; } function numberHeads(project, table, start) { if (!grouped[table]) throw Error("일위대가·단가산출·기계경비 화면에서 호표를 정리하세요."); if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다."); const titles = new Map((project.tables[grouped[table]] || []).map((t) => [t.Code, t])); let n = start; for (const row of project.tables[table] || []) if (num(row.Index) > 0) { row.Index = n; const title = titles.get(row.Code); if (title) title.Index = n; n++; } return n - start; } // 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 글자만 꺼낸다. async function memoText(memo) { if (memo == null) return ""; if (typeof memo === "string") return memo; if (!memo.binary) return ""; 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(), ); const source = new TextDecoder("euc-kr").decode(bytes); return EstQuantity.entities( source .replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "") .replace(/<\/(div|p)>|/gi, "\n") .replace(/<[^>]*>/g, ""), ) .replace(/\n{3,}/g, "\n\n") .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]; if (state.selection.size !== 1 || !row) throw Error("근거(메모)를 볼 행 하나를 고르세요."); return { index, row }; } async function run(name, state) { if (name === "tidy-sort") { const table = state.view; let byCode = false; if (table === "SilJukDat") byCode = !confirm("품명,규격으로 정렬할까요? [취소] 코드로 정렬"); if (!confirm("가,나,다 순으로 정렬합니다. 빈 행과 번호 없는 행은 빠집니다.")) return; let dropped; EstApp.change((project) => (dropped = sortBase(project, table, byCode))); state.notice = `정렬했습니다${dropped ? ` (빈 행 ${dropped}개 제외)` : ""}.`; } else if (name === "tidy-arrange" || name === "tidy-number") { EstActions.form( name === "tidy-arrange" ? "데이타 위치정리" : "호표 정리(번호정렬)", [ ...(name === "tidy-arrange" ? [ { name: "order", label: "품명 정렬", value: "asc", choices: [ ["asc", "오름차순(가나다)"], ["desc", "내림차순"], ], }, ] : []), { name: "start", label: "시작번호", type: "number", value: 1 }, ], (values) => EstApp.change((project) => name === "tidy-arrange" ? arrange(project, state.view, values.order === "desc", values.start) : numberHeads(project, state.view, values.start), ), ); } else if (name === "memo-view") { // 근거자료보기(TfrmGunguEdit): 원본 서식 메모를 문단·표·그림(자리표)으로 펼쳐 고치고 붙여넣고 저장 const { index, row } = memoTarget(state); memo = { view: state.view, index, objects: [] }; EstApp.showDialog( `근거자료보기 — ${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 = "근거(메모)를 지웠습니다."; } } 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) => { state.notice = error.message; EstApp.render(); }); return true; } return { sortBase, arrange, numberHeads, memoText, handle }; })();