Spaces:
Running
Running
feat: HWP/HWPX 실제 추출 (rhwp WASM + Node 22 Docker) — getSectionCount/getParagraphCount fix
Browse files- Dockerfile +9 -3
- app.py +39 -9
- pkg/extract_hwp.mjs +79 -0
- pkg/rhwp.js +0 -0
- pkg/rhwp_bg.wasm +3 -0
Dockerfile
CHANGED
|
@@ -1,14 +1,20 @@
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
-
WORKDIR /app
|
| 3 |
|
| 4 |
-
#
|
| 5 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
-
curl ca-certificates \
|
|
|
|
|
|
|
| 7 |
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
|
|
|
|
|
|
|
| 9 |
COPY requirements.txt .
|
| 10 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
COPY app.py .
|
| 13 |
COPY static/ ./static/
|
| 14 |
|
|
|
|
| 1 |
FROM python:3.12-slim
|
|
|
|
| 2 |
|
| 3 |
+
# Node.js 22 (rhwp.js WASM HWP/HWPX 추출용 — HanSoo 이식)
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
curl ca-certificates gnupg \
|
| 6 |
+
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
| 7 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
+
# HWP 추출 (rhwp WASM via Node)
|
| 16 |
+
COPY pkg/ ./pkg/
|
| 17 |
+
|
| 18 |
COPY app.py .
|
| 19 |
COPY static/ ./static/
|
| 20 |
|
app.py
CHANGED
|
@@ -185,18 +185,48 @@ def extract_txt(data: bytes) -> str:
|
|
| 185 |
except UnicodeDecodeError: continue
|
| 186 |
return data.decode("utf-8", errors="replace")
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
async def extract_hwp(data: bytes, fname: str) -> str:
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
try:
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
except Exception as e:
|
| 199 |
-
return f"[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
def extract_image_meta(data: bytes, fname: str) -> str:
|
| 202 |
"""이미지: 메타만 텍스트로. 실제 vision 분석은 base64로 LLM에 직접 전달."""
|
|
|
|
| 185 |
except UnicodeDecodeError: continue
|
| 186 |
return data.decode("utf-8", errors="replace")
|
| 187 |
|
| 188 |
+
import tempfile, asyncio as _asyncio
|
| 189 |
+
PKG_DIR = Path(__file__).parent / "pkg"
|
| 190 |
+
RHWP_SCRIPT = PKG_DIR / "extract_hwp.mjs"
|
| 191 |
+
|
| 192 |
async def extract_hwp(data: bytes, fname: str) -> str:
|
| 193 |
+
"""HanSoo rhwp WASM (Rust) — Node.js subprocess로 HWP/HWPX 한글 텍스트 직접 추출.
|
| 194 |
+
외부 API 의존성 0. JGOS Space 내부 처리.
|
| 195 |
+
"""
|
| 196 |
+
if not RHWP_SCRIPT.exists():
|
| 197 |
+
return f"[HWP 처리기 미설치: {RHWP_SCRIPT}]"
|
| 198 |
+
suffix = ".hwpx" if fname.lower().endswith(".hwpx") else ".hwp"
|
| 199 |
+
tmp_path = None
|
| 200 |
try:
|
| 201 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
|
| 202 |
+
tf.write(data); tf.flush(); tmp_path = tf.name
|
| 203 |
+
proc = await _asyncio.create_subprocess_exec(
|
| 204 |
+
"node", str(RHWP_SCRIPT), tmp_path,
|
| 205 |
+
stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE,
|
| 206 |
+
)
|
| 207 |
+
try:
|
| 208 |
+
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=60)
|
| 209 |
+
except _asyncio.TimeoutError:
|
| 210 |
+
proc.kill(); return f"[HWP 변환 타임아웃 60s: {fname}]"
|
| 211 |
+
if proc.returncode != 0:
|
| 212 |
+
err = (stderr.decode("utf-8", errors="ignore")[:300]) if stderr else "no stderr"
|
| 213 |
+
return f"[HWP 변환 실패 (exit {proc.returncode}): {err}]"
|
| 214 |
+
try:
|
| 215 |
+
result = json.loads(stdout.decode("utf-8", errors="ignore"))
|
| 216 |
+
except Exception as e:
|
| 217 |
+
return f"[HWP 결과 파싱 실패: {e}]"
|
| 218 |
+
if not result.get("ok"):
|
| 219 |
+
return f"[HWP 추출 실패: {result.get('error','unknown')}]"
|
| 220 |
+
text = result.get("text", "")
|
| 221 |
+
return text or f"[{fname}: 추출 텍스트 없음 (paragraphs={result.get('paragraphs',0)})]"
|
| 222 |
+
except FileNotFoundError as e:
|
| 223 |
+
return f"[Node.js 미설치 — Docker rebuild 필요: {e}]"
|
| 224 |
except Exception as e:
|
| 225 |
+
return f"[HWP 처리 오류: {type(e).__name__}: {e}]"
|
| 226 |
+
finally:
|
| 227 |
+
if tmp_path:
|
| 228 |
+
try: os.unlink(tmp_path)
|
| 229 |
+
except: pass
|
| 230 |
|
| 231 |
def extract_image_meta(data: bytes, fname: str) -> str:
|
| 232 |
"""이미지: 메타만 텍스트로. 실제 vision 분석은 base64로 LLM에 직접 전달."""
|
pkg/extract_hwp.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// extract_hwp.mjs — HWP/HWPX 파일을 텍스트로 추출 (rhwp WASM 사용)
|
| 2 |
+
// 사용: node extract_hwp.mjs <input_hwp_path>
|
| 3 |
+
// 출력: stdout JSON {ok, text, paragraphs, sections, error?}
|
| 4 |
+
|
| 5 |
+
import init, { HwpDocument } from "./rhwp.js";
|
| 6 |
+
import { readFile } from "node:fs/promises";
|
| 7 |
+
|
| 8 |
+
async function main() {
|
| 9 |
+
const inputPath = process.argv[2];
|
| 10 |
+
if (!inputPath) {
|
| 11 |
+
console.error(JSON.stringify({ ok: false, error: "missing arg: input path" }));
|
| 12 |
+
process.exit(1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
try {
|
| 16 |
+
// WASM 초기화 — Node.js fs로 직접 로드
|
| 17 |
+
const { readFileSync } = await import("node:fs");
|
| 18 |
+
const wasmPath = new URL("./rhwp_bg.wasm", import.meta.url);
|
| 19 |
+
const wasmBytes = readFileSync(wasmPath);
|
| 20 |
+
await init({ module_or_path: wasmBytes });
|
| 21 |
+
|
| 22 |
+
// HWP 파일 로드
|
| 23 |
+
const fileBytes = await readFile(inputPath);
|
| 24 |
+
const doc = new HwpDocument(new Uint8Array(fileBytes));
|
| 25 |
+
|
| 26 |
+
// Method 1: getTextRange로 모든 section/paragraph 순회
|
| 27 |
+
const allParas = [];
|
| 28 |
+
let sectionCount = 0;
|
| 29 |
+
try {
|
| 30 |
+
sectionCount = doc.getSectionCount();
|
| 31 |
+
} catch (e) {
|
| 32 |
+
sectionCount = 1;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
for (let s = 0; s < sectionCount; s++) {
|
| 36 |
+
let paraCount = 0;
|
| 37 |
+
try {
|
| 38 |
+
paraCount = doc.getParagraphCount(s);
|
| 39 |
+
} catch (e) { continue; }
|
| 40 |
+
|
| 41 |
+
for (let p = 0; p < paraCount; p++) {
|
| 42 |
+
let paraLen = 0;
|
| 43 |
+
try {
|
| 44 |
+
paraLen = doc.getParagraphLength(s, p);
|
| 45 |
+
} catch (e) { continue; }
|
| 46 |
+
if (paraLen === 0) { allParas.push(""); continue; }
|
| 47 |
+
try {
|
| 48 |
+
const text = doc.getTextRange(s, p, 0, paraLen);
|
| 49 |
+
allParas.push(text || "");
|
| 50 |
+
} catch (e) {
|
| 51 |
+
allParas.push(`[para ${s}.${p} 추출 실패]`);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const finalText = allParas.filter(p => p && p.trim()).join("\n");
|
| 57 |
+
|
| 58 |
+
// free
|
| 59 |
+
try { doc.free(); } catch (e) {}
|
| 60 |
+
|
| 61 |
+
console.log(JSON.stringify({
|
| 62 |
+
ok: true,
|
| 63 |
+
text: finalText,
|
| 64 |
+
paragraphs: allParas.length,
|
| 65 |
+
sections: sectionCount,
|
| 66 |
+
char_count: finalText.length,
|
| 67 |
+
}, null, 0));
|
| 68 |
+
|
| 69 |
+
} catch (e) {
|
| 70 |
+
console.error(JSON.stringify({
|
| 71 |
+
ok: false,
|
| 72 |
+
error: `${e.name || "Error"}: ${e.message || e}`,
|
| 73 |
+
stack: (e.stack || "").split("\n").slice(0, 5).join(" | "),
|
| 74 |
+
}));
|
| 75 |
+
process.exit(2);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
main();
|
pkg/rhwp.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pkg/rhwp_bg.wasm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:72dd73f7343948584bc7f8b0ffcd7c8f5d05489a63b41b1f657ea41b24095bea
|
| 3 |
+
size 3703777
|