Files
Aislo/tools/est-test/book-store.cjs
T
eomsangdonandClaude Opus 5.5 db9bc2dee4 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>
2026-11-12 17:41:02 +09:00

272 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 원본 품셈 보기 자료(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 };