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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:14:08 +09:00

154 lines
6.3 KiB
JavaScript

// 근거(메모) 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;