fix: HWP 읽기 — LibreOffice 제거, olefile 순수 Python 파서로 교체
Browse files- requirements.txt: olefile==0.47 추가
- Dockerfile: libreoffice-writer 제거 (이미지 크기 대폭 감소)
- app.py: _ext_hwp()를 OLE2 직접 파싱 방식으로 재구현
(HWPTAG_PARA_TEXT 레코드에서 UTF-16LE 텍스트 추출)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile +0 -5
- app.py +52 -14
- requirements.txt +1 -0
Dockerfile
CHANGED
|
@@ -2,11 +2,6 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
WORKDIR /code
|
| 4 |
|
| 5 |
-
# LibreOffice: .hwp 파일을 텍스트로 변환
|
| 6 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
-
libreoffice-writer \
|
| 8 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
-
|
| 10 |
COPY requirements.txt .
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /code
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
COPY requirements.txt .
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
app.py
CHANGED
|
@@ -286,20 +286,58 @@ def _ext_hwpx(path: Path) -> str:
|
|
| 286 |
|
| 287 |
|
| 288 |
def _ext_hwp(path: Path) -> str:
|
| 289 |
-
import
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
if
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
|
| 305 |
def _ext_docx(path: Path) -> str:
|
|
|
|
| 286 |
|
| 287 |
|
| 288 |
def _ext_hwp(path: Path) -> str:
|
| 289 |
+
import struct, zlib
|
| 290 |
+
try:
|
| 291 |
+
import olefile
|
| 292 |
+
except ImportError:
|
| 293 |
+
raise RuntimeError("olefile 라이브러리 없음 (pip install olefile)")
|
| 294 |
+
|
| 295 |
+
ole = olefile.OleFileIO(str(path))
|
| 296 |
+
|
| 297 |
+
compressed = True
|
| 298 |
+
if ole.exists("FileHeader"):
|
| 299 |
+
hdr = ole.openstream("FileHeader").read()
|
| 300 |
+
if len(hdr) >= 40:
|
| 301 |
+
compressed = bool(struct.unpack_from("<I", hdr, 36)[0] & 0x1)
|
| 302 |
+
|
| 303 |
+
lines: list[str] = []
|
| 304 |
+
idx = 1
|
| 305 |
+
while True:
|
| 306 |
+
sname = f"BodyText/Section{idx:04d}"
|
| 307 |
+
if not ole.exists(sname):
|
| 308 |
+
break
|
| 309 |
+
raw = ole.openstream(sname).read()
|
| 310 |
+
data = zlib.decompress(raw, -15) if compressed else raw
|
| 311 |
+
|
| 312 |
+
pos = 0
|
| 313 |
+
while pos + 4 <= len(data):
|
| 314 |
+
hval = struct.unpack_from("<I", data, pos)[0]
|
| 315 |
+
tag_id = hval & 0x3FF
|
| 316 |
+
size = (hval >> 20) & 0xFFF
|
| 317 |
+
pos += 4
|
| 318 |
+
if size == 0xFFF:
|
| 319 |
+
if pos + 4 > len(data): break
|
| 320 |
+
size = struct.unpack_from("<I", data, pos)[0]
|
| 321 |
+
pos += 4
|
| 322 |
+
|
| 323 |
+
if tag_id == 67 and size >= 2: # HWPTAG_PARA_TEXT
|
| 324 |
+
chunk = data[pos:pos + size]
|
| 325 |
+
t, chars = 0, []
|
| 326 |
+
while t + 2 <= len(chunk):
|
| 327 |
+
cp = struct.unpack_from("<H", chunk, t)[0]
|
| 328 |
+
t += 2
|
| 329 |
+
if cp == 0x0D:
|
| 330 |
+
lines.append("".join(chars)); chars = []
|
| 331 |
+
elif 0x01 <= cp <= 0x0C:
|
| 332 |
+
t += 8 # inline object: skip 8-byte attribute block
|
| 333 |
+
elif cp >= 0x20:
|
| 334 |
+
chars.append(chr(cp))
|
| 335 |
+
if chars:
|
| 336 |
+
lines.append("".join(chars))
|
| 337 |
+
pos += size
|
| 338 |
+
idx += 1
|
| 339 |
+
|
| 340 |
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(l for l in lines if l)).strip()
|
| 341 |
|
| 342 |
|
| 343 |
def _ext_docx(path: Path) -> str:
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ aiofiles==23.2.1
|
|
| 10 |
python-multipart==0.0.12
|
| 11 |
python-docx==1.1.2
|
| 12 |
pypdf==5.1.0
|
|
|
|
|
|
| 10 |
python-multipart==0.0.12
|
| 11 |
python-docx==1.1.2
|
| 12 |
pypdf==5.1.0
|
| 13 |
+
olefile==0.47
|