Spaces:
Sleeping
Sleeping
File size: 11,224 Bytes
7f105c8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | """HWP/PDF/text 입력 → clean_text 변환.
파이프라인 [1] 단계. 호스트 앱이 어떤 양식으로 보내든 백엔드가 텍스트로 흡수.
지원:
- text (text/plain) → 그대로
- PDF (application/pdf) → pdfplumber 본문 + 표 [표] 섹션
- HWP/HWPX → LibreOffice + H2Orestart로 ODT 변환 → content.xml 직접 파싱
이미지(.jpg/.png) OCR은 별도 단계 (세종님 OCR 합류 시 추가).
ODT 경로 채택 이유 (vs 이전 HWP→PDF):
HWP→PDF→pdfplumber는 LibreOffice가 텍스트를 두 번 그려 글자가 중복
추출되는 문제 ("22002266학학년년도도"). ODT(zip+content.xml)는 구조화된
단일 출력이라 중복 0. 검증: hwp5txt 28b 실패, docx 0c 실패, odt 1899c
키워드 6/6 보존.
보안 모델:
- 원본 filename은 .suffix 추출에만 사용. 추출된 suffix는 화이트리스트 검사
(TEXT_EXTS / PDF_EXTS / HWP_EXTS) 통과 못 하면 ParserError로 즉시 거부.
- 디스크 저장 경로는 tempfile.TemporaryDirectory + 고정 이름 input{suffix}.
원본 filename은 어떤 경로/명령에도 사용되지 않음.
- subprocess 호출은 list 인자 형태(쉘 미사용) → 명령 주입 표면 없음.
"""
from __future__ import annotations
import os
import re
import subprocess
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
# pdfplumber는 외부 의존이라 CI/테스트 안전하게 가드.
# 운영에선 requirements.txt + Dockerfile로 보장. 부재면 첫 PDF 호출 시점에 명확한 메시지.
try:
import pdfplumber # type: ignore
except ImportError as error:
print(f"[parser] pdfplumber unavailable: {error}")
pdfplumber = None
PDF_EXTS = {".pdf"}
HWP_EXTS = {".hwp", ".hwpx"}
TEXT_EXTS = {".txt", ".md"}
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_EXTS = PDF_EXTS | HWP_EXTS | TEXT_EXTS | IMG_EXTS
# LibreOffice 변환 타임아웃 (초). 큰 HWP는 ENV로 오버라이드 가능.
LIBREOFFICE_TIMEOUT_SECONDS = int(os.environ.get("PARSER_LIBREOFFICE_TIMEOUT", "300"))
class ParserError(RuntimeError):
"""변환 실패 시 호출부가 잡을 수 있는 단일 예외."""
def normalize(text: str) -> str:
"""null 제거 + 한 줄 안 다중 공백만 정리. 줄바꿈 보존, 연속 빈 줄은 1개로."""
text = text.replace("\x00", " ")
out_lines: list[str] = []
prev_empty = False
for line in text.split("\n"):
line = re.sub(r"[ \t]+", " ", line).strip()
if not line:
if not prev_empty:
out_lines.append(line)
prev_empty = True
else:
out_lines.append(line)
prev_empty = False
return "\n".join(out_lines).strip()
def _pdf_to_text(pdf_path: Path) -> str:
"""본문 텍스트(표 영역 제외) + 표(행 단위 정리) 분리."""
if pdfplumber is None:
raise ParserError(
"pdfplumber 미설치. backend 컨테이너 재빌드(docker compose build backend) "
"또는 pip install pdfplumber 필요."
)
body_pages: list[str] = []
table_blocks: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
tables = page.extract_tables() or []
for tbl in tables:
rows: list[str] = []
for row in tbl:
cells = [(c or "").replace("\n", " ").strip() for c in row]
if any(cells):
rows.append(" | ".join(cells))
if rows:
table_blocks.append("\n".join(rows))
table_bboxes = [t.bbox for t in (page.find_tables() or [])]
if table_bboxes:
def outside_tables(obj):
if obj.get("object_type") != "char":
return True
cx = (obj["x0"] + obj["x1"]) / 2
cy = (obj["top"] + obj["bottom"]) / 2
for bbox in table_bboxes:
x0, top, x1, bottom = bbox
if x0 <= cx <= x1 and top <= cy <= bottom:
return False
return True
page_view = page.filter(outside_tables)
body = page_view.extract_text() or ""
else:
body = page.extract_text() or ""
if body:
body_pages.append(body)
parts: list[str] = []
if body_pages:
parts.append("\n\n".join(body_pages))
if table_blocks:
parts.append("[표]\n" + "\n\n".join(table_blocks))
return "\n\n".join(parts)
def _image_to_text(img_path: Path) -> str:
"""카메라 사진(.jpg/.png) → 텍스트. Tesseract 한국어 OCR.
전체 이미지 1차 OCR → 한국어 문자 외 노이즈 정리.
표 영역 재처리(2차 OCR)는 별도 로직으로 확장 가능.
"""
try:
import pytesseract
from PIL import Image
except ImportError as e:
raise ParserError(f"OCR 의존 미설치: {e}. Docker 재빌드 필요.")
try:
img = Image.open(img_path).convert("RGB")
except Exception as e:
raise ParserError(f"이미지 열기 실패: {e}")
try:
# psm 3: 자동 레이아웃 감지 (표·단락 혼재 가정통신문에 적합)
text = pytesseract.image_to_string(img, lang="kor", config="--psm 3 --oem 1")
except Exception as e:
raise ParserError(f"Tesseract OCR 실패: {e}")
return text
# ODT content.xml 네임스페이스
_ODT_TEXT_NS = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}"
_ODT_TABLE_NS = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}"
_ODT_DRAW_NS = "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}"
def _hwp_to_odt(hwp_path: Path, out_dir: Path) -> Path:
"""LibreOffice headless로 HWP → ODT.
HWP→PDF 경로의 doubled-char 문제 회피. ODT는 zip 구조라 본문/표가
단일 트리에 한 번만 들어감. 타임아웃: PARSER_LIBREOFFICE_TIMEOUT.
"""
out_dir.mkdir(parents=True, exist_ok=True)
try:
result = subprocess.run(
[
"libreoffice", "--headless",
"--convert-to", "odt",
"--outdir", str(out_dir),
str(hwp_path),
],
capture_output=True, text=True,
timeout=LIBREOFFICE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
raise ParserError(
f"LibreOffice 변환 타임아웃 ({LIBREOFFICE_TIMEOUT_SECONDS}초 초과). "
"큰 HWP면 PARSER_LIBREOFFICE_TIMEOUT 환경변수로 늘릴 수 있음."
)
odt_path = out_dir / f"{hwp_path.stem}.odt"
# H2Orestart가 ODT 변환 후 종료 시점에 종종 Signal 11 (cleanup 버그)을 내지만
# 출력 파일은 정상. 파일 존재 여부를 성공 기준으로 — returncode/stderr는 참고만.
if not odt_path.exists():
raise ParserError(
f"LibreOffice ODT 변환 실패 (출력 파일 없음). "
f"returncode={result.returncode}, stderr={result.stderr.strip()[:200]}"
)
return odt_path
def _odt_to_text(odt_path: Path, mark_header: bool = False) -> str:
"""ODT(zip) content.xml → 본문 + 표 영역 평면 텍스트.
표 안 paragraph는 본문 처리에서 제외(중복 방지). 표는 셀 단위 공백 합치고
행 단위 줄바꿈으로 평면화 — `|` 구분자 X, `[표]` 마커 X.
윤정님 split_sentences가 헤더 키워드 lookahead("운영시간"/"운영방법"/...)로
행 안에서 의미 단위 자연 분리하므로 셀 구분자 불필요.
mark_header=True: 각 표의 첫 번째 행 앞에 "[헤더] " 마킹 + 셀을 " | " 구분.
기본값 False — 기존 호출부(parse_bytes_to_text, batch_convert.py) 변경 없음.
"""
with zipfile.ZipFile(odt_path) as z:
with z.open("content.xml") as f:
tree = ET.parse(f)
# 표/draw:frame 안 element id 모음 → 본문 처리에서 제외
# draw:frame: 텍스트 상자/이미지 프레임 — 본문과 같은 텍스트가 중복 저장돼
# 3배 이상 반복되는 아티팩트 원인. 표 inner 제외와 동일 방식.
table_inner_ids: set[int] = set()
for table in tree.iter(_ODT_TABLE_NS + "table"):
for elem in table.iter():
table_inner_ids.add(id(elem))
for frame in tree.iter(_ODT_DRAW_NS + "frame"):
for elem in frame.iter():
table_inner_ids.add(id(elem))
body_parts: list[str] = []
for elem in tree.iter():
tag = elem.tag
if tag in (_ODT_TEXT_NS + "p", _ODT_TEXT_NS + "h"):
if id(elem) in table_inner_ids:
continue
text = "".join(elem.itertext()).strip()
if text:
body_parts.append(text)
table_blocks: list[str] = []
for table in tree.iter(_ODT_TABLE_NS + "table"):
rows: list[str] = []
for row_idx, row in enumerate(table.iter(_ODT_TABLE_NS + "table-row")):
cells: list[str] = []
for cell in row.iter(_ODT_TABLE_NS + "table-cell"):
cell_text = "".join(cell.itertext()).strip()
if cell_text:
cells.append(cell_text)
if cells:
if mark_header and row_idx == 0:
rows.append("[헤더] " + " | ".join(cells))
else:
rows.append(" ".join(cells))
if rows:
table_blocks.append("\n".join(rows))
parts: list[str] = []
if body_parts:
parts.append("\n".join(body_parts))
if table_blocks:
parts.append("\n\n".join(table_blocks))
return "\n\n".join(parts)
def parse_bytes_to_text(data: bytes, filename: str) -> str:
"""업로드된 bytes + 파일명 → 정규화된 clean_text.
호출부(라우터)는 파일 확장자 분기 신경 안 쓰고 이 함수만 부르면 됨.
"""
if not data:
return ""
suffix = Path(filename).suffix.lower()
# 화이트리스트 검사: 알 수 없는 suffix는 일찍 거부.
# (subprocess는 어차피 list-form이라 명령 주입은 불가능하지만 표면을 줄임)
if suffix and suffix not in ALLOWED_EXTS:
raise ParserError(f"지원하지 않는 파일 형식: {suffix}")
if suffix in TEXT_EXTS or suffix == "":
return normalize(data.decode("utf-8", errors="replace"))
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
# 디스크 경로는 항상 tempdir 안의 고정 이름. 원본 filename은 어디에도 안 들어감.
src_path = tmp_dir / f"input{suffix}"
src_path.write_bytes(data)
if suffix in PDF_EXTS:
raw = _pdf_to_text(src_path)
return normalize(raw)
if suffix in HWP_EXTS:
odt_path = _hwp_to_odt(src_path, tmp_dir)
raw = _odt_to_text(odt_path)
return normalize(raw)
if suffix in IMG_EXTS:
raw = _image_to_text(src_path)
return normalize(raw)
raise ParserError(f"지원하지 않는 파일 형식: {suffix}")
|