feat(est-test): 품셈 보기와 품셈자료 붙여넣기
- 원본 BookData 7권 해독(DBISAM 암호 표 Blowfish-CBC, zlib RTF) 서버 book-store.cjs, /api/book 경로 - 목차 트리·본문 표·제목/본문/키워드 검색·본문 강조·복사하기, 단가산출·내역·일위·기계 붙여넣기 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// 원본 품셈 보기 자료(System\BookData — civil·archit·machine·electric·culture·forest·woods) 읽기.
|
||||
// DBISAM 4 암호 표: 키 = MD5(원본 세션 암호), Blowfish-CBC(IV 0) — .dat 머리 0x200 은 평문, 필드 설명은 한 사슬,
|
||||
// 레코드는 레코드마다·.blb 512 바이트 블록은 블록마다 사슬을 새로 시작. 필드 pkid·upperid·orderno·chapter·title·keywords·content(BLOB).
|
||||
// 본문 BLOB = zlib 압축 RTF(WPTools) → 글자(문단 줄바꿈, 표 칸 탭). Node 가 Blowfish 를 빼서 직접 구현(상수는 π 16진 자리).
|
||||
// 설치 폴더는 EST_BOOK_DIR 로 바꿀 수 있다. 읽기 전용.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DIR = process.env.EST_BOOK_DIR || "C:/Dasansoft/Est Plus 2/System/BookData";
|
||||
const PASSWORD = 'LW`bdGD"4[';
|
||||
|
||||
// π 소수부 16진 자리 → Blowfish P(18)·S(4×256) 초기값
|
||||
function piWords(count) {
|
||||
const digits = count * 8 + 8,
|
||||
scale = 1n << BigInt(digits * 4 + 64);
|
||||
const atan = (x) => {
|
||||
const xx = x * x;
|
||||
let term = scale / x,
|
||||
sum = term,
|
||||
k = 1n;
|
||||
for (let sign = -1n; term; sign = -sign, k += 2n) {
|
||||
term /= xx;
|
||||
sum += (sign * term) / (k + 2n);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const pi = 16n * atan(5n) - 4n * atan(239n);
|
||||
const frac = (pi - 3n * scale) >> 64n;
|
||||
const hex = frac.toString(16).padStart(digits, "0");
|
||||
return Array.from({ length: count }, (_, i) => parseInt(hex.slice(i * 8, i * 8 + 8), 16) >>> 0);
|
||||
}
|
||||
|
||||
function blowfish(key) {
|
||||
const init = piWords(18 + 1024),
|
||||
P = init.slice(0, 18),
|
||||
S = [0, 1, 2, 3].map((i) => init.slice(18 + i * 256, 18 + (i + 1) * 256));
|
||||
const F = (x) =>
|
||||
((((S[0][x >>> 24] + S[1][(x >>> 16) & 255]) >>> 0) ^ S[2][(x >>> 8) & 255]) +
|
||||
S[3][x & 255]) >>>
|
||||
0;
|
||||
function encrypt(l, r) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
l = (l ^ P[i]) >>> 0;
|
||||
r = (r ^ F(l)) >>> 0;
|
||||
[l, r] = [r, l];
|
||||
}
|
||||
[l, r] = [r, l];
|
||||
r = (r ^ P[16]) >>> 0;
|
||||
l = (l ^ P[17]) >>> 0;
|
||||
return [l, r];
|
||||
}
|
||||
function decrypt(l, r) {
|
||||
for (let i = 17; i > 1; i--) {
|
||||
l = (l ^ P[i]) >>> 0;
|
||||
r = (r ^ F(l)) >>> 0;
|
||||
[l, r] = [r, l];
|
||||
}
|
||||
[l, r] = [r, l];
|
||||
r = (r ^ P[1]) >>> 0;
|
||||
l = (l ^ P[0]) >>> 0;
|
||||
return [l, r];
|
||||
}
|
||||
for (let i = 0, k = 0; i < 18; i++) {
|
||||
let w = 0;
|
||||
for (let j = 0; j < 4; j++, k++) w = ((w << 8) | key[k % key.length]) >>> 0;
|
||||
P[i] = (P[i] ^ w) >>> 0;
|
||||
}
|
||||
let l = 0,
|
||||
r = 0;
|
||||
for (let i = 0; i < 18; i += 2) {
|
||||
[l, r] = encrypt(l, r);
|
||||
P[i] = l;
|
||||
P[i + 1] = r;
|
||||
}
|
||||
for (const box of S)
|
||||
for (let i = 0; i < 256; i += 2) {
|
||||
[l, r] = encrypt(l, r);
|
||||
box[i] = l;
|
||||
box[i + 1] = r;
|
||||
}
|
||||
// CBC 풀기(IV 0), 8 바이트 미만 꼬리는 그대로
|
||||
return function cbc(buf) {
|
||||
const out = Buffer.from(buf),
|
||||
n = buf.length - (buf.length % 8);
|
||||
let pl = 0,
|
||||
pr = 0;
|
||||
for (let o = 0; o < n; o += 8) {
|
||||
const cl = buf.readUInt32BE(o),
|
||||
cr = buf.readUInt32BE(o + 4),
|
||||
[dl, dr] = decrypt(cl, cr);
|
||||
out.writeUInt32BE((dl ^ pl) >>> 0, o);
|
||||
out.writeUInt32BE((dr ^ pr) >>> 0, o + 4);
|
||||
pl = cl;
|
||||
pr = cr;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
let cbc = null;
|
||||
const dec = (buf) =>
|
||||
(cbc ||= blowfish(crypto.createHash("md5").update(PASSWORD, "latin1").digest()))(buf);
|
||||
const cp949 = new TextDecoder("euc-kr");
|
||||
const books = new Map();
|
||||
|
||||
function load(name) {
|
||||
if (books.has(name)) return books.get(name);
|
||||
if (!/^[a-z]+$/.test(name)) throw Error("품셈 이름이 올바르지 않습니다.");
|
||||
const d = fs.readFileSync(path.join(DIR, `${name}.dat`));
|
||||
const size = d.readUInt16LE(0x2d),
|
||||
nf = d[0x2f],
|
||||
descEnd = d.indexOf(0, 0x48),
|
||||
desc = cp949.decode(d.subarray(0x48, Math.min(descEnd < 0 ? 0x68 : descEnd, 0x68)));
|
||||
const fd = dec(d.subarray(0x200, 0x200 + nf * 0x300));
|
||||
const fields = [];
|
||||
for (let i = 0; i < nf; i++) {
|
||||
const b = fd.subarray(i * 0x300, (i + 1) * 0x300);
|
||||
fields.push([b.subarray(3, 3 + b[2]).toString("latin1"), b[0xa4], b.readUInt16LE(0xac)]);
|
||||
}
|
||||
const rows = [];
|
||||
for (let o = 0x200 + nf * 0x300; o + size <= d.length; o += size) {
|
||||
const r = dec(d.subarray(o, o + size)),
|
||||
row = {};
|
||||
for (const [fn, t, off] of fields) {
|
||||
if (!r[off]) row[fn] = null;
|
||||
else if (t === 6) row[fn] = r.readInt32LE(off + 1);
|
||||
else if (t === 1) {
|
||||
const end = r.indexOf(0, off + 1);
|
||||
row[fn] = cp949.decode(r.subarray(off + 1, end < 0 ? off + 302 : Math.min(end, off + 302)));
|
||||
} else row[fn] = r.readUInt32LE(off + 1);
|
||||
}
|
||||
if (row.pkid != null) rows.push(row);
|
||||
}
|
||||
const book = { name, desc, rows, byId: new Map(rows.map((r) => [r.pkid, r])), texts: new Map() };
|
||||
books.set(name, book);
|
||||
return book;
|
||||
}
|
||||
|
||||
function blob(name, first) {
|
||||
const b = fs.readFileSync(path.join(DIR, `${name}.blb`));
|
||||
const parts = [];
|
||||
let block = first,
|
||||
total = null,
|
||||
got = 0;
|
||||
const seen = new Set();
|
||||
while (block && !seen.has(block)) {
|
||||
seen.add(block);
|
||||
const x = dec(b.subarray(block * 512, (block + 1) * 512)),
|
||||
next = x.readUInt32LE(4),
|
||||
length = x.readUInt16LE(8);
|
||||
if (total == null) total = x.readUInt32LE(14);
|
||||
parts.push(x.subarray(18, 18 + length));
|
||||
got += length;
|
||||
block = next;
|
||||
if (got >= total) break;
|
||||
}
|
||||
return zlib.inflateSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
const SKIP = /^\{\\(\*|fonttbl|colortbl|stylesheet|info|pict|listtable|listoverridetable|object)/;
|
||||
function rtfText(raw) {
|
||||
const s = raw.toString("latin1"),
|
||||
out = [],
|
||||
pend = [];
|
||||
let i = 0,
|
||||
depth = 0,
|
||||
skip = null,
|
||||
uc = 1;
|
||||
const flush = () => {
|
||||
if (pend.length) out.push(cp949.decode(Buffer.from(pend.splice(0))));
|
||||
};
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === "{") {
|
||||
depth++;
|
||||
if (skip == null && SKIP.test(s.slice(i, i + 24))) skip = depth;
|
||||
i++;
|
||||
} else if (c === "}") {
|
||||
if (skip === depth) skip = null;
|
||||
depth--;
|
||||
i++;
|
||||
} else if (c === "\\") {
|
||||
const hx = /^\\'([0-9a-fA-F]{2})/.exec(s.slice(i, i + 4));
|
||||
if (hx) {
|
||||
if (skip == null) pend.push(parseInt(hx[1], 16));
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
const cw = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(s.slice(i, i + 40));
|
||||
if (cw) {
|
||||
const [whole, w, a] = cw;
|
||||
i += whole.length;
|
||||
if (skip != null) continue;
|
||||
if (w === "u" && a) {
|
||||
flush();
|
||||
out.push(String.fromCharCode(Number(a) & 0xffff));
|
||||
i += uc;
|
||||
} else if (w === "uc" && a) uc = Number(a);
|
||||
else if (["par", "line", "row"].includes(w)) {
|
||||
flush();
|
||||
out.push("\n");
|
||||
} else if (["cell", "tab"].includes(w)) {
|
||||
flush();
|
||||
out.push("\t");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (skip == null && "\\{}".includes(s[i + 1] ?? "")) pend.push(s.charCodeAt(i + 1));
|
||||
i += 2;
|
||||
} else if (c === "\r" || c === "\n") i++;
|
||||
else {
|
||||
if (skip == null) pend.push(s.charCodeAt(i) & 0xff);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function list() {
|
||||
const names = fs
|
||||
.readFileSync(path.join(DIR, "filelist.ini"), "latin1")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
return names.map((name) => {
|
||||
const b = load(name);
|
||||
return { name, desc: b.desc, nodes: b.rows.length };
|
||||
});
|
||||
}
|
||||
function tree(name) {
|
||||
const b = load(name);
|
||||
return b.rows.map(({ pkid, upperid, orderno, chapter, title, content }) => ({
|
||||
pkid,
|
||||
parent: b.byId.has(upperid) ? upperid : null,
|
||||
order: orderno,
|
||||
chapter: chapter ?? "",
|
||||
title: title ?? "",
|
||||
content: Boolean(content),
|
||||
}));
|
||||
}
|
||||
function text(name, pkid) {
|
||||
const b = load(name),
|
||||
r = b.byId.get(Number(pkid));
|
||||
if (!r) throw Error("품셈 항목이 없습니다.");
|
||||
if (!r.content) return { pkid: r.pkid, chapter: r.chapter, title: r.title, text: "" };
|
||||
if (!b.texts.has(r.pkid)) b.texts.set(r.pkid, rtfText(blob(name, r.content)));
|
||||
return {
|
||||
pkid: r.pkid,
|
||||
chapter: r.chapter ?? "",
|
||||
title: r.title ?? "",
|
||||
text: b.texts.get(r.pkid),
|
||||
};
|
||||
}
|
||||
// 원본 전체검색(actFullSearch 0xdf8514): 검색어의 빈칸·'|' 를 지우고 제목(또는 제목+본문)에 들어 있으면.
|
||||
// 키워드검색(actKeywordSearch): keywords 칸에 들어 있으면.
|
||||
function search(name, word, mode = "title") {
|
||||
const b = load(name),
|
||||
kw = String(word ?? "").replace(/[\s|]/g, "");
|
||||
if (!kw) throw Error("검색어를 입력하세요.");
|
||||
return b.rows
|
||||
.filter((r) => {
|
||||
if (mode === "keyword") return String(r.keywords ?? "").includes(kw);
|
||||
if (String(r.title ?? "").includes(kw)) return true;
|
||||
return mode === "body" && r.content && text(name, r.pkid).text.includes(kw);
|
||||
})
|
||||
.map((r) => ({ pkid: r.pkid, chapter: r.chapter ?? "", title: r.title ?? "" }));
|
||||
}
|
||||
|
||||
module.exports = { list, tree, text, search, piWords, blowfish, rtfText };
|
||||
Reference in New Issue
Block a user