chrismarspink Claude Opus 4.7 (1M context) commited on
Commit
7648494
ยท
1 Parent(s): 2570f03

PII Scanner: HWP (Hancom) file support via pyhwp + olefile fallback

Browse files

Adds .hwp parsing so HWP v5 documents can be uploaded and analyzed
the same way as .txt/.pdf/.docx.

hwp_extract.py (new module)
- extract_hwp_text(raw_bytes) is the public entry point.
- Primary path: invoke pyhwp's `hwp5txt` CLI via subprocess.
Pure-Python and works on any platform once pyhwp is pip-installed.
- Fallback path: parse the file ourselves with olefile + zlib +
struct, walking the HWP v5 record stream and decoding the
HWPTAG_PARA_TEXT records (UTF-16-LE plus inline / 8-wchar
extended control chars). This keeps things working even if
pyhwp isn't installed or its CLI is not on PATH.

requirements*.txt
- pyhwp>=0.1b15 and olefile>=0.46 added to both full and lite
requirement lists. Both are pure Python so they install cleanly
on Python 3.11โ€“3.14.

app.py / app_lite.py
- extract_text() now dispatches '.hwp' to hwp_extract.extract_hwp_text.

templates/index.html, README.md
- Drag area and README list .hwp as a supported format and call out
the pyhwp + olefile pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (7) hide show
  1. README.md +2 -0
  2. app.py +4 -0
  3. app_lite.py +4 -0
  4. hwp_extract.py +223 -0
  5. requirements.txt +3 -0
  6. requirements_lite.txt +3 -0
  7. templates/index.html +1 -1
README.md CHANGED
@@ -16,6 +16,8 @@
16
  ์ถ”๊ฐ€ ๊ธฐ๋Šฅ:
17
 
18
  - **์‚ฌ์šฉ์ž ์ •์˜ ๋ฃฐ** (`custom_patterns.yaml`) โ€” ์ •๊ทœ์‹ยทํ‚ค์›Œ๋“œ ์ธ์‹๊ธฐ ์ž์œ ๋กญ๊ฒŒ ์ถ”๊ฐ€
 
 
19
  - **ํ‚ค / ์•”ํ˜ธ๋ฌธ ์ง๋ ฌํ™”** โ€” Public/Secret/Relin Key ์™€ Encryption Parameters, Ciphertext ๋ฅผ base64 ๋กœ ๋ณด๊ณ /๋ณต์‚ฌ/๋‹ค์šด๋กœ๋“œ
20
  - **RRN ๊ฒ€์ƒ‰ ๋ฌถ์Œ ๋‹ค์šด๋กœ๋“œ** โ€” ๋ชจ๋“  ํ›„๋ณด ct + ์„œ๋ฒ„ ๊ฒฐ๊ณผ ct + ๋ฉ”ํƒ€๋ฅผ JSON ํ•œ ํŒŒ์ผ๋กœ ์ €์žฅ
21
 
 
16
  ์ถ”๊ฐ€ ๊ธฐ๋Šฅ:
17
 
18
  - **์‚ฌ์šฉ์ž ์ •์˜ ๋ฃฐ** (`custom_patterns.yaml`) โ€” ์ •๊ทœ์‹ยทํ‚ค์›Œ๋“œ ์ธ์‹๊ธฐ ์ž์œ ๋กญ๊ฒŒ ์ถ”๊ฐ€
19
+ - **์–ธ์–ด ์„ ํƒ** โ€” PII Scanner ์—์„œ ์ž๋™(ํ•œ+์˜) / ํ•œ๊ตญ์–ด / English ํ† ๊ธ€
20
+ - **์ง€์› ํฌ๋งท** โ€” `.txt .log .csv .json .md .yaml .xml .html .pdf .docx .hwp` (HWP ๋Š” [pyhwp](https://pypi.org/project/pyhwp/) ์˜ `hwp5txt` CLI ๋ฅผ ์šฐ์„  ์‚ฌ์šฉ, ๋ฏธ์„ค์น˜ ์‹œ `olefile` ๊ธฐ๋ฐ˜ ์ž์ฒด ํŒŒ์„œ๋กœ ํด๋ฐฑ)
21
  - **ํ‚ค / ์•”ํ˜ธ๋ฌธ ์ง๋ ฌํ™”** โ€” Public/Secret/Relin Key ์™€ Encryption Parameters, Ciphertext ๋ฅผ base64 ๋กœ ๋ณด๊ณ /๋ณต์‚ฌ/๋‹ค์šด๋กœ๋“œ
22
  - **RRN ๊ฒ€์ƒ‰ ๋ฌถ์Œ ๋‹ค์šด๋กœ๋“œ** โ€” ๋ชจ๋“  ํ›„๋ณด ct + ์„œ๋ฒ„ ๊ฒฐ๊ณผ ct + ๋ฉ”ํƒ€๋ฅผ JSON ํ•œ ํŒŒ์ผ๋กœ ์ €์žฅ
23
 
app.py CHANGED
@@ -198,6 +198,10 @@ def extract_text(filename: str, raw: bytes) -> str:
198
  parts.append(cell.text)
199
  return "\n".join(parts)
200
 
 
 
 
 
201
  return _smart_decode(raw)
202
 
203
 
 
198
  parts.append(cell.text)
199
  return "\n".join(parts)
200
 
201
+ if ext == ".hwp":
202
+ from hwp_extract import extract_hwp_text # lazy import
203
+ return extract_hwp_text(raw)
204
+
205
  return _smart_decode(raw)
206
 
207
 
app_lite.py CHANGED
@@ -347,6 +347,10 @@ def extract_text(filename: str, raw: bytes) -> str:
347
  parts.append(cell.text)
348
  return "\n".join(parts)
349
 
 
 
 
 
350
  return _smart_decode(raw)
351
 
352
 
 
347
  parts.append(cell.text)
348
  return "\n".join(parts)
349
 
350
+ if ext == ".hwp":
351
+ from hwp_extract import extract_hwp_text
352
+ return extract_hwp_text(raw)
353
+
354
  return _smart_decode(raw)
355
 
356
 
hwp_extract.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HWP (Hancom Office) ํŒŒ์ผ์—์„œ ํ‰๋ฌธ ํ…์ŠคํŠธ๋ฅผ ์ถ”์ถœํ•˜๋Š” ํ—ฌํผ.
3
+
4
+ ์ „๋žต:
5
+ 1) pyhwp ํŒจํ‚ค์ง€์˜ `hwp5txt` CLI ๊ฐ€ PATH ์— ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ์„ ์‚ฌ์šฉ (๊ฐ€์žฅ ์ •ํ™•).
6
+ โ†’ pip install pyhwp
7
+ 2) ์‚ฌ์šฉ ๋ถˆ๊ฐ€ ์‹œ olefile + ์ž์ฒด ๋ฐ”์ด๋„ˆ๋ฆฌ ํŒŒ์„œ๋กœ ํด๋ฐฑ.
8
+ HWP v5 = OLE Compound Document โ†’ BodyText/Section* ์ŠคํŠธ๋ฆผ zlib ํ•ด์ œ
9
+ โ†’ HWP ๋ ˆ์ฝ”๋“œ ์ŠคํŠธ๋ฆผ ์›Œํฌ โ†’ HWPTAG_PARA_TEXT ์˜ UTF-16-LE ํ…์ŠคํŠธ ๋””์ฝ”๋“œ.
10
+
11
+ ํ˜ธ์ถœ: text = extract_hwp_text(raw_bytes)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import logging
18
+ import os
19
+ import shutil
20
+ import struct
21
+ import subprocess
22
+ import tempfile
23
+ import zlib
24
+ from typing import Optional
25
+
26
+ log = logging.getLogger("hwp")
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Public entry point
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def extract_hwp_text(raw: bytes) -> str:
33
+ text = _try_pyhwp_cli(raw)
34
+ if text is not None:
35
+ return text
36
+ log.info("pyhwp(hwp5txt) ๋ฏธ์‚ฌ์šฉ/์‹คํŒจ โ†’ ์ž์ฒด olefile ํŒŒ์„œ๋กœ ํด๋ฐฑ")
37
+ return _extract_manual(raw)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # 1) pyhwp ์˜ CLI (hwp5txt) ์‚ฌ์šฉ
42
+ # ---------------------------------------------------------------------------
43
+
44
+ _HWP5TXT_PATH: Optional[str] = None
45
+ _HWP5TXT_CHECKED = False
46
+
47
+
48
+ def _hwp5txt_path() -> Optional[str]:
49
+ global _HWP5TXT_PATH, _HWP5TXT_CHECKED
50
+ if _HWP5TXT_CHECKED:
51
+ return _HWP5TXT_PATH
52
+ _HWP5TXT_CHECKED = True
53
+ p = shutil.which("hwp5txt") or shutil.which("hwp5txt.exe")
54
+ if p:
55
+ log.info("hwp5txt found at: %s", p)
56
+ _HWP5TXT_PATH = p
57
+ return p
58
+
59
+
60
+ def _try_pyhwp_cli(raw: bytes) -> Optional[str]:
61
+ exe = _hwp5txt_path()
62
+ if not exe:
63
+ return None
64
+ fd, tmp = tempfile.mkstemp(suffix=".hwp")
65
+ try:
66
+ os.write(fd, raw)
67
+ os.close(fd)
68
+ try:
69
+ result = subprocess.run(
70
+ [exe, tmp],
71
+ capture_output=True,
72
+ timeout=30,
73
+ )
74
+ except Exception as e: # noqa: BLE001
75
+ log.warning("hwp5txt ์‹คํ–‰ ์‹คํŒจ: %s", e)
76
+ return None
77
+ if result.returncode != 0:
78
+ log.warning(
79
+ "hwp5txt ๋น„0 ์ข…๋ฃŒ(%s): %s",
80
+ result.returncode,
81
+ (result.stderr or b"").decode("utf-8", errors="replace")[:200],
82
+ )
83
+ return None
84
+ return result.stdout.decode("utf-8", errors="replace")
85
+ finally:
86
+ try:
87
+ os.unlink(tmp)
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # 2) olefile + ์ž์ฒด ๋ฐ”์ด๋„ˆ๋ฆฌ ํŒŒ์„œ
94
+ # ---------------------------------------------------------------------------
95
+
96
+ # HWP v5 paragraph text record tag
97
+ HWPTAG_BEGIN = 0x10
98
+ HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 0x32 # = 0x42
99
+
100
+ # 1-wchar inline ์ œ์–ด๋ฌธ์ž (ํ™•์žฅ ์ œ์–ด๊ตฌ์กฐ๊ฐ€ ์•„๋‹˜)
101
+ _INLINE_CTRL = {0x00, 0x0A, 0x0D, 0x18, 0x1E, 0x1F}
102
+
103
+
104
+ def _extract_manual(raw: bytes) -> str:
105
+ try:
106
+ import olefile # type: ignore
107
+ except ImportError:
108
+ log.error("olefile ๋ฏธ์„ค์น˜ โ€” HWP ํŒŒ์‹ฑ ๋ถˆ๊ฐ€. pip install olefile")
109
+ return ""
110
+
111
+ if not olefile.isOleFile(io.BytesIO(raw)):
112
+ log.warning("HWP ํŒŒ์ผ์ด OLE ํ˜•์‹์ด ์•„๋‹˜ (HWP v3 ๋“ฑ ๋ฏธ์ง€์›)")
113
+ return ""
114
+
115
+ ole = olefile.OleFileIO(io.BytesIO(raw))
116
+ try:
117
+ compressed = _read_compression_flag(ole)
118
+ section_paths = sorted(
119
+ [
120
+ e for e in ole.listdir()
121
+ if len(e) == 2 and e[0] == "BodyText" and e[1].startswith("Section")
122
+ ],
123
+ key=lambda e: _section_index(e[1]),
124
+ )
125
+ parts = []
126
+ for entry in section_paths:
127
+ try:
128
+ data = ole.openstream(entry).read()
129
+ except Exception as e: # noqa: BLE001
130
+ log.warning("HWP ์„น์…˜ ์ฝ๊ธฐ ์‹คํŒจ (%s): %s", entry, e)
131
+ continue
132
+ if compressed:
133
+ data = _zlib_decompress(data)
134
+ if data is None:
135
+ log.warning("HWP ์„น์…˜ zlib ํ•ด์ œ ์‹คํŒจ (%s)", entry)
136
+ continue
137
+ parts.append(_walk_records(data))
138
+ return "\n".join(parts).strip()
139
+ finally:
140
+ ole.close()
141
+
142
+
143
+ def _section_index(name: str) -> int:
144
+ suffix = name.replace("Section", "")
145
+ return int(suffix) if suffix.isdigit() else 0
146
+
147
+
148
+ def _read_compression_flag(ole) -> bool:
149
+ """FileHeader ์ŠคํŠธ๋ฆผ์˜ 36๋ฒˆ์งธ ๋ฐ”์ดํŠธ bit0 = ์••์ถ• ์—ฌ๋ถ€.
150
+ ๋ชป ์ฝ์œผ๋ฉด ์••์ถ•์œผ๋กœ ๊ฐ€์ •(๋Œ€๋ถ€๋ถ„ ์••์ถ•๋จ)."""
151
+ try:
152
+ header = ole.openstream("FileHeader").read()
153
+ if len(header) > 36:
154
+ return bool(header[36] & 0x01)
155
+ except Exception:
156
+ pass
157
+ return True
158
+
159
+
160
+ def _zlib_decompress(data: bytes) -> Optional[bytes]:
161
+ # HWP ๋Š” raw deflate ์‚ฌ์šฉ (zlib ํ—ค๏ฟฝ๏ฟฝ๏ฟฝ ์—†์Œ, wbits=-15)
162
+ for wbits in (-15, 15):
163
+ try:
164
+ return zlib.decompress(data, wbits)
165
+ except zlib.error:
166
+ continue
167
+ return None
168
+
169
+
170
+ def _walk_records(data: bytes) -> str:
171
+ """HWP v5 record stream ์„ ์›Œํฌํ•˜๋ฉฐ PARA_TEXT ๋งŒ ๋ชจ์€๋‹ค."""
172
+ pos = 0
173
+ out = []
174
+ n = len(data)
175
+ while pos + 4 <= n:
176
+ header = struct.unpack_from("<I", data, pos)[0]
177
+ pos += 4
178
+ tag = header & 0x3FF
179
+ size = (header >> 20) & 0xFFF
180
+ if size == 0xFFF:
181
+ if pos + 4 > n:
182
+ break
183
+ size = struct.unpack_from("<I", data, pos)[0]
184
+ pos += 4
185
+ if pos + size > n:
186
+ break
187
+ record = data[pos : pos + size]
188
+ pos += size
189
+ if tag == HWPTAG_PARA_TEXT:
190
+ t = _decode_para_text(record)
191
+ if t:
192
+ out.append(t)
193
+ return "\n".join(out)
194
+
195
+
196
+ def _decode_para_text(data: bytes) -> str:
197
+ """UTF-16-LE ๋ณธ๋ฌธ + HWP ์ œ์–ด๋ฌธ์ž (1-wchar inline / 8-wchar extended)."""
198
+ pos = 0
199
+ n = len(data)
200
+ out = []
201
+ while pos + 2 <= n:
202
+ wc = data[pos] | (data[pos + 1] << 8)
203
+ pos += 2
204
+ if wc < 0x20:
205
+ if wc in _INLINE_CTRL:
206
+ if wc == 0x0A or wc == 0x0D:
207
+ out.append("\n")
208
+ # else: NUL / 0x18 (hyphen marker) / 0x1E / 0x1F โ€” skip
209
+ else:
210
+ # Extended control: 8 wchar ์ด๊ธธ์ด โ†’ ์ถ”๊ฐ€๋กœ 7 wchar(14 byte) ๊ฑด๋„ˆ๋œ€
211
+ pos += 14
212
+ else:
213
+ if 0xD800 <= wc <= 0xDBFF and pos + 2 <= n:
214
+ wc2 = data[pos] | (data[pos + 1] << 8)
215
+ pos += 2
216
+ if 0xDC00 <= wc2 <= 0xDFFF:
217
+ cp = 0x10000 + ((wc - 0xD800) << 10) + (wc2 - 0xDC00)
218
+ out.append(chr(cp))
219
+ else:
220
+ out.append(chr(wc))
221
+ else:
222
+ out.append(chr(wc))
223
+ return "".join(out)
requirements.txt CHANGED
@@ -6,3 +6,6 @@ pypdf==4.3.1
6
  python-docx==1.1.2
7
  spacy==3.7.5
8
  numpy<2.0
 
 
 
 
6
  python-docx==1.1.2
7
  spacy==3.7.5
8
  numpy<2.0
9
+ # HWP (Hancom) ํŒŒ์ผ ์ง€์›
10
+ pyhwp>=0.1b15
11
+ olefile>=0.46
requirements_lite.txt CHANGED
@@ -3,3 +3,6 @@ Flask==3.0.3
3
  PyYAML==6.0.2
4
  pypdf==4.3.1
5
  python-docx==1.1.2
 
 
 
 
3
  PyYAML==6.0.2
4
  pypdf==4.3.1
5
  python-docx==1.1.2
6
+ # HWP (Hancom) ํŒŒ์ผ ์ง€์›
7
+ pyhwp>=0.1b15
8
+ olefile>=0.46
templates/index.html CHANGED
@@ -136,7 +136,7 @@
136
  <div id="drop">
137
  <div><b>์—ฌ๊ธฐ์— ํŒŒ์ผ์„ ๋“œ๋กญ</b> ํ•˜๊ฑฐ๋‚˜ ํด๋ฆญํ•ด์„œ ์„ ํƒ</div>
138
  <div style="color:var(--muted); font-size:12px; margin-top:6px;">
139
- .txt, .log, .csv, .json, .md, .pdf, .docx ์ง€์› (์ตœ๋Œ€ 32MB)
140
  </div>
141
  <input id="file" type="file" />
142
  </div>
 
136
  <div id="drop">
137
  <div><b>์—ฌ๊ธฐ์— ํŒŒ์ผ์„ ๋“œ๋กญ</b> ํ•˜๊ฑฐ๋‚˜ ํด๋ฆญํ•ด์„œ ์„ ํƒ</div>
138
  <div style="color:var(--muted); font-size:12px; margin-top:6px;">
139
+ .txt, .log, .csv, .json, .md, .pdf, .docx, <b>.hwp</b> ์ง€์› (์ตœ๋Œ€ 32MB)
140
  </div>
141
  <input id="file" type="file" />
142
  </div>