- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
145 lines
5.6 KiB
JavaScript
145 lines
5.6 KiB
JavaScript
// Minimal HWP5 text extractor: CFB (OLE2) reader + zlib + PARA_TEXT records. No external packages.
|
|
const fs = require('fs');
|
|
const zlib = require('zlib');
|
|
const [,, inFile, outFile] = process.argv;
|
|
const buf = fs.readFileSync(inFile);
|
|
|
|
const sig = buf.slice(0, 8).toString('hex');
|
|
if (sig !== 'd0cf11e0a1b11ae1') { console.error('not an OLE file', sig); process.exit(1); }
|
|
const secShift = buf.readUInt16LE(0x1e);
|
|
const secSize = 1 << secShift;
|
|
const miniShift = buf.readUInt16LE(0x20);
|
|
const miniSize = 1 << miniShift;
|
|
const nFat = buf.readUInt32LE(0x2c);
|
|
const dirStart = buf.readUInt32LE(0x30);
|
|
const miniCutoff = buf.readUInt32LE(0x38);
|
|
const miniFatStart = buf.readUInt32LE(0x3c);
|
|
let difatStart = buf.readUInt32LE(0x44);
|
|
const nDifat = buf.readUInt32LE(0x48);
|
|
const ENDOFCHAIN = 0xfffffffe;
|
|
const FREE = 0xffffffff;
|
|
|
|
function secOff(s) { return (s + 1) * secSize; }
|
|
function readSector(s) { return buf.slice(secOff(s), secOff(s) + secSize); }
|
|
|
|
// FAT sector list from header DIFAT + DIFAT chain
|
|
const fatSecs = [];
|
|
for (let i = 0; i < 109 && fatSecs.length < nFat; i++) {
|
|
const v = buf.readUInt32LE(0x4c + i * 4);
|
|
if (v === FREE || v === ENDOFCHAIN) break;
|
|
fatSecs.push(v);
|
|
}
|
|
let d = difatStart;
|
|
for (let i = 0; i < nDifat && d !== ENDOFCHAIN && d !== FREE; i++) {
|
|
const sec = readSector(d);
|
|
for (let j = 0; j < secSize / 4 - 1 && fatSecs.length < nFat; j++) {
|
|
const v = sec.readUInt32LE(j * 4);
|
|
if (v !== FREE && v !== ENDOFCHAIN) fatSecs.push(v);
|
|
}
|
|
d = sec.readUInt32LE(secSize - 4);
|
|
}
|
|
const fat = [];
|
|
for (const s of fatSecs) {
|
|
const sec = readSector(s);
|
|
for (let j = 0; j < secSize / 4; j++) fat.push(sec.readUInt32LE(j * 4));
|
|
}
|
|
function chain(start) {
|
|
const out = [];
|
|
let s = start;
|
|
let guard = 0;
|
|
while (s !== ENDOFCHAIN && s !== FREE && guard++ < 10000000) { out.push(s); s = fat[s]; }
|
|
return out;
|
|
}
|
|
function readStream(start, size) {
|
|
const parts = chain(start).map(readSector);
|
|
return Buffer.concat(parts).slice(0, size);
|
|
}
|
|
|
|
// directory
|
|
const dirBuf = readStream(dirStart, chain(dirStart).length * secSize);
|
|
const entries = [];
|
|
for (let i = 0; i < dirBuf.length / 128; i++) {
|
|
const e = dirBuf.slice(i * 128, i * 128 + 128);
|
|
const nl = e.readUInt16LE(0x40);
|
|
if (nl === 0) { entries.push(null); continue; }
|
|
const name = e.slice(0, nl - 2).toString('utf16le');
|
|
entries.push({
|
|
name, type: e.readUInt8(0x42), left: e.readUInt32LE(0x44), right: e.readUInt32LE(0x48), child: e.readUInt32LE(0x4c),
|
|
start: e.readUInt32LE(0x74), size: Number(e.readBigUInt64LE(0x78)), id: i,
|
|
});
|
|
}
|
|
const root = entries[0];
|
|
const miniStream = readStream(root.start, root.size);
|
|
const miniFat = [];
|
|
{
|
|
const mf = Buffer.concat(chain(miniFatStart).map(readSector));
|
|
for (let j = 0; j < mf.length / 4; j++) miniFat.push(mf.readUInt32LE(j * 4));
|
|
}
|
|
function readMini(start, size) {
|
|
const parts = [];
|
|
let s = start;
|
|
let guard = 0;
|
|
while (s !== ENDOFCHAIN && s !== FREE && guard++ < 10000000) {
|
|
parts.push(miniStream.slice(s * miniSize, s * miniSize + miniSize));
|
|
s = miniFat[s];
|
|
}
|
|
return Buffer.concat(parts).slice(0, size);
|
|
}
|
|
function streamOf(e) { return e.size < miniCutoff ? readMini(e.start, e.size) : readStream(e.start, e.size); }
|
|
|
|
// walk tree to build path -> entry
|
|
const paths = {};
|
|
function walk(id, prefix) {
|
|
if (id === 0xffffffff || id >= entries.length || !entries[id]) return;
|
|
const e = entries[id];
|
|
walk(e.left, prefix);
|
|
const p = prefix + '/' + e.name;
|
|
paths[p] = e;
|
|
if (e.type === 1) walk(e.child, p);
|
|
walk(e.right, prefix);
|
|
}
|
|
walk(entries[0].child, '');
|
|
|
|
const header = streamOf(paths['/FileHeader']);
|
|
const flags = header.readUInt32LE(36);
|
|
const compressed = (flags & 1) !== 0;
|
|
const encrypted = (flags & 2) !== 0;
|
|
if (encrypted) { console.error('encrypted document'); process.exit(2); }
|
|
console.error('HWP version', header.readUInt32LE(32).toString(16), 'compressed', compressed, 'sections', Object.keys(paths).filter(p => /BodyText\/Section\d+$/.test(p)).length);
|
|
|
|
const secNames = Object.keys(paths).filter(p => /^\/BodyText\/Section\d+$/.test(p)).sort((a, b) => parseInt(a.match(/\d+$/)[0]) - parseInt(b.match(/\d+$/)[0]));
|
|
const EXT = new Set([1, 2, 3, 11, 12, 14, 15, 16, 17, 18, 21, 22, 23]);
|
|
const INL = new Set([4, 5, 6, 7, 8, 9, 19, 20]);
|
|
let outTxt = [];
|
|
for (const sn of secNames) {
|
|
let data = streamOf(paths[sn]);
|
|
if (compressed) data = zlib.inflateRawSync(data);
|
|
let pos = 0;
|
|
while (pos + 4 <= data.length) {
|
|
const h = data.readUInt32LE(pos); pos += 4;
|
|
const tag = h & 0x3ff; let size = (h >>> 20) & 0xfff;
|
|
if (size === 0xfff) { size = data.readUInt32LE(pos); pos += 4; }
|
|
const rec = data.slice(pos, pos + size); pos += size;
|
|
if (tag === 0x43) { // PARA_TEXT
|
|
let s = '';
|
|
for (let i = 0; i + 1 < rec.length;) {
|
|
const ch = rec.readUInt16LE(i);
|
|
if (ch >= 32) { s += String.fromCharCode(ch); i += 2; }
|
|
else if (EXT.has(ch) || INL.has(ch)) { if (ch === 9) s += '\t'; i += 16; }
|
|
else if (ch === 10 || ch === 13) { s += '\n'; i += 2; }
|
|
else if (ch === 24 || ch === 25 || ch === 26 || ch === 27 || ch === 28 || ch === 29 || ch === 30 || ch === 31) { if (ch === 24) s += '-'; if (ch === 30 || ch === 31) s += ' '; i += 2; }
|
|
else { i += 2; }
|
|
}
|
|
outTxt.push(s.replace(/\n+$/, ''));
|
|
} else if (tag === 0x58) { // EQEDIT (equation script)
|
|
try {
|
|
const n = rec.readUInt16LE(4);
|
|
const script = rec.slice(6, 6 + n * 2).toString('utf16le').replace(/\s+/g, ' ').trim();
|
|
outTxt.push(' [수식: ' + script + ']');
|
|
} catch (e) { outTxt.push(' [수식 읽기 실패]'); }
|
|
}
|
|
}
|
|
}
|
|
fs.writeFileSync(outFile, outTxt.join('\n'), 'utf8');
|
|
console.error('paragraphs', outTxt.length, 'chars', outTxt.join('\n').length);
|