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