"""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}")