// 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);