- 근거(메모)를 문단·굵게·표·그림 자리표로 펼쳐 편집, 붙여넣기(서식 있는 글·표), 저장 - WPTools 글 형식 읽기·쓰기(native-memo-wpt.cjs) — 그림 원문 보존, ASCII 로 써서 cp949 변환 없음 - ESTX 저장: 원래 메모가 있던 행만, 새 메모는 JSON 저장 안내 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.3 KiB
JavaScript
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(/</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(
|
|
/<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:'GulimChe';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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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;
|