python-hwpx-automation 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,227 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HWP(.hwp) -> HWPX(.hwpx) 변환 유틸리티."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ import xml.etree.ElementTree as ET
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, List, Sequence
11
+
12
+ from hwpx.document import HwpxDocument
13
+
14
+ from .core.document import save_doc
15
+ from .hwp_support import HwpBinaryError, extract_hwp_text
16
+
17
+
18
+ class HwpConversionError(RuntimeError):
19
+ """HWP 변환 과정에서 발생하는 오류."""
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class TablePayload:
24
+ """변환 대상 표 데이터."""
25
+
26
+ rows: List[List[str]]
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ConversionResult:
31
+ """HWP -> HWPX 변환 결과 요약."""
32
+
33
+ success: bool
34
+ output_path: str
35
+ paragraphs_converted: int
36
+ tables_converted: int
37
+ skipped_elements: List[str]
38
+ warnings: List[str]
39
+ verification: dict[str, Any]
40
+ open_safety: dict[str, Any]
41
+
42
+
43
+ def _local_name(tag: str) -> str:
44
+ if "}" in tag:
45
+ return tag.rsplit("}", 1)[1]
46
+ return tag
47
+
48
+
49
+ def _iter_by_names(root: ET.Element, names: Sequence[str]) -> Iterable[ET.Element]:
50
+ wanted = {name.lower() for name in names}
51
+ for element in root.iter():
52
+ if _local_name(element.tag).lower() in wanted:
53
+ yield element
54
+
55
+
56
+ def _normalize_text(value: str) -> str:
57
+ return " ".join(value.split())
58
+
59
+
60
+ def _collect_paragraph_text(paragraph_element: ET.Element) -> str:
61
+ text_chunks: List[str] = []
62
+ for element in paragraph_element.iter():
63
+ local = _local_name(element.tag).lower()
64
+ if local in {"text", "t", "char"} and element.text:
65
+ normalized = _normalize_text(element.text)
66
+ if normalized:
67
+ text_chunks.append(normalized)
68
+ if not text_chunks:
69
+ for part in paragraph_element.itertext():
70
+ normalized = _normalize_text(part)
71
+ if normalized:
72
+ text_chunks.append(normalized)
73
+ return " ".join(text_chunks).strip()
74
+
75
+
76
+ def _parse_table_rows(table_element: ET.Element) -> TablePayload | None:
77
+ row_elements = list(_iter_by_names(table_element, ["TableRow", "tr", "row"]))
78
+ rows: List[List[str]] = []
79
+
80
+ if row_elements:
81
+ for row_element in row_elements:
82
+ row_cells: List[str] = []
83
+ cell_elements = list(_iter_by_names(row_element, ["TableCell", "tc", "cell", "td"]))
84
+ for cell_element in cell_elements:
85
+ text = _collect_paragraph_text(cell_element)
86
+ row_cells.append(text)
87
+ if row_cells:
88
+ rows.append(row_cells)
89
+ else:
90
+ cells = list(_iter_by_names(table_element, ["TableCell", "tc", "cell", "td"]))
91
+ if cells:
92
+ rows.append([_collect_paragraph_text(cell) for cell in cells])
93
+
94
+ if not rows:
95
+ return None
96
+
97
+ max_cols = max(len(row) for row in rows)
98
+ for row in rows:
99
+ if len(row) < max_cols:
100
+ row.extend([""] * (max_cols - len(row)))
101
+ return TablePayload(rows=rows)
102
+
103
+
104
+ def _parse_hwp5proc_xml(xml_text: str) -> tuple[List[str], List[TablePayload], List[str]]:
105
+ try:
106
+ root = ET.fromstring(xml_text)
107
+ except ET.ParseError as exc:
108
+ raise HwpConversionError(f"hwp5proc XML 파싱 실패: {exc}") from exc
109
+
110
+ paragraphs: List[str] = []
111
+ tables: List[TablePayload] = []
112
+ warnings: List[str] = []
113
+ parent_map = {child: parent for parent in root.iter() for child in parent}
114
+
115
+ def _inside_table(node: ET.Element) -> bool:
116
+ current = parent_map.get(node)
117
+ while current is not None:
118
+ if _local_name(current.tag).lower() in {"tablecontrol", "tbl", "table"}:
119
+ return True
120
+ current = parent_map.get(current)
121
+ return False
122
+
123
+ for paragraph_element in _iter_by_names(root, ["Paragraph", "p", "para"]):
124
+ if _inside_table(paragraph_element):
125
+ continue
126
+ text = _collect_paragraph_text(paragraph_element)
127
+ if text:
128
+ paragraphs.append(text)
129
+
130
+ for table_element in _iter_by_names(root, ["TableControl", "tbl", "table"]):
131
+ parsed = _parse_table_rows(table_element)
132
+ if parsed is not None:
133
+ tables.append(parsed)
134
+
135
+ if not paragraphs:
136
+ warnings.append("문단 노드를 직접 매핑하지 못해 보조 텍스트 추출로 대체될 수 있습니다.")
137
+
138
+ return paragraphs, tables, warnings
139
+
140
+
141
+ def _run_hwp5proc_xml(hwp_path: Path) -> str:
142
+ try:
143
+ completed = subprocess.run(
144
+ ["hwp5proc", "xml", "--format", "nested", str(hwp_path)],
145
+ check=False,
146
+ capture_output=True,
147
+ text=True,
148
+ encoding="utf-8",
149
+ )
150
+ except FileNotFoundError as exc:
151
+ raise HwpConversionError("hwp5proc 명령을 찾을 수 없습니다. pyhwp 설치 여부를 확인하세요.") from exc
152
+
153
+ if completed.returncode != 0:
154
+ stderr = completed.stderr.strip() or "unknown error"
155
+ raise HwpConversionError(f"hwp5proc 실행 실패: {stderr}")
156
+
157
+ xml_text = completed.stdout.strip()
158
+ if not xml_text:
159
+ raise HwpConversionError("hwp5proc XML 출력이 비어 있습니다")
160
+ return xml_text
161
+
162
+
163
+ def convert_hwp_to_hwpx(hwp_path: str, output_path: str) -> ConversionResult:
164
+ source = Path(hwp_path).expanduser().resolve()
165
+ target = Path(output_path).expanduser().resolve()
166
+
167
+ if source.suffix.lower() != ".hwp":
168
+ raise HwpConversionError("source는 .hwp 파일이어야 합니다")
169
+ if not source.exists():
170
+ raise HwpConversionError(f"입력 파일이 존재하지 않습니다: {source}")
171
+
172
+ target.parent.mkdir(parents=True, exist_ok=True)
173
+
174
+ xml_text = _run_hwp5proc_xml(source)
175
+ paragraphs, tables, warnings = _parse_hwp5proc_xml(xml_text)
176
+
177
+ skipped_elements: List[str] = []
178
+ unsupported_pairs = [
179
+ ("OLE 개체", ["OLE", "OleObject"]),
180
+ ("각주/미주", ["FootNote", "EndNote"]),
181
+ ("변경 추적", ["TrackChange", "Revision"]),
182
+ ("양식 컨트롤", ["FormObject", "Field"]),
183
+ ]
184
+ for label, tags in unsupported_pairs:
185
+ if any(f"<{tag}" in xml_text for tag in tags):
186
+ skipped_elements.append(label)
187
+
188
+ if not paragraphs:
189
+ try:
190
+ fallback = extract_hwp_text(source)
191
+ except HwpBinaryError:
192
+ fallback = None
193
+ if fallback and fallback.paragraphs:
194
+ paragraphs = list(fallback.paragraphs)
195
+ warnings.append(f"pyhwp XML 직접 매핑 실패로 {fallback.source} 텍스트를 사용했습니다.")
196
+
197
+ document = HwpxDocument.new()
198
+ for paragraph_text in paragraphs:
199
+ document.add_paragraph(paragraph_text)
200
+
201
+ for table_payload in tables:
202
+ row_count = len(table_payload.rows)
203
+ col_count = len(table_payload.rows[0]) if table_payload.rows else 0
204
+ if row_count <= 0 or col_count <= 0:
205
+ continue
206
+ table = document.add_table(rows=row_count, cols=col_count)
207
+ for row_index, row in enumerate(table_payload.rows):
208
+ for col_index, text in enumerate(row):
209
+ table.rows[row_index].cells[col_index].text = text
210
+
211
+ verification = save_doc(document, str(target))
212
+
213
+ if skipped_elements:
214
+ warnings.append(
215
+ "변환 제외 요소: " + ", ".join(skipped_elements)
216
+ )
217
+
218
+ return ConversionResult(
219
+ success=True,
220
+ output_path=str(target),
221
+ paragraphs_converted=len(paragraphs),
222
+ tables_converted=len(tables),
223
+ skipped_elements=skipped_elements,
224
+ warnings=warnings,
225
+ verification=verification,
226
+ open_safety=verification["openSafety"],
227
+ )
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HWP(바이너리) 파일의 읽기 전용 텍스트 추출 유틸리티."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import zlib
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ try: # legacy HWP5 (OLE2) support is an optional [hwp] extra
13
+ import olefile
14
+ except ImportError: # pragma: no cover - exercised only without the extra
15
+ olefile = None
16
+
17
+ _OLEFILE_INSTALL_HINT = (
18
+ "Reading legacy .hwp (HWP5/OLE2) files requires the 'olefile' package. "
19
+ "Install it with: pip install 'python-hwpx-automation[hwp]' "
20
+ "(or 'hwpx-mcp-server[hwp]', or pip install olefile)."
21
+ )
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class HwpTextSnapshot:
26
+ """HWP 파일에서 추출한 텍스트 스냅샷."""
27
+
28
+ paragraphs: List[str]
29
+ source: str
30
+
31
+
32
+ class HwpBinaryError(RuntimeError):
33
+ """HWP 바이너리 처리 과정에서 발생한 오류."""
34
+
35
+
36
+ def _decode_preview_text(payload: bytes) -> str:
37
+ try:
38
+ return payload.decode("utf-16-le", errors="ignore").replace("\x00", "")
39
+ except Exception as exc: # pragma: no cover
40
+ raise HwpBinaryError(f"PrvText 디코딩 실패: {exc}") from exc
41
+
42
+
43
+ def _iter_bodytext_stream_names(ole: olefile.OleFileIO) -> List[List[str]]:
44
+ names: List[List[str]] = []
45
+ for stream_path in ole.listdir(streams=True, storages=False):
46
+ if len(stream_path) == 2 and stream_path[0] == "BodyText" and stream_path[1].startswith("Section"):
47
+ names.append(stream_path)
48
+ names.sort(key=lambda parts: int(re.sub(r"[^0-9]", "", parts[1]) or "0"))
49
+ return names
50
+
51
+
52
+ def _decode_section_payload(payload: bytes) -> str:
53
+ try:
54
+ inflated = zlib.decompress(payload, -15)
55
+ except zlib.error:
56
+ return ""
57
+ # HWP record binary에서 사람이 읽을 수 있는 UTF-16 조각만 보수적으로 추출
58
+ text = inflated.decode("utf-16-le", errors="ignore")
59
+ text = text.replace("\x00", "")
60
+ return text
61
+
62
+
63
+ def extract_hwp_text(path: Path) -> HwpTextSnapshot:
64
+ if olefile is None:
65
+ raise HwpBinaryError(_OLEFILE_INSTALL_HINT)
66
+ if path.suffix.lower() != ".hwp":
67
+ raise HwpBinaryError(".hwp 파일만 처리할 수 있습니다")
68
+
69
+ if not olefile.isOleFile(str(path)):
70
+ raise HwpBinaryError("유효한 OLE2(HWP) 파일이 아닙니다")
71
+
72
+ try:
73
+ with olefile.OleFileIO(str(path)) as ole:
74
+ if ole.exists("PrvText"):
75
+ raw = ole.openstream("PrvText").read()
76
+ preview_text = _decode_preview_text(raw)
77
+ paragraphs = [line.strip() for line in preview_text.splitlines() if line.strip()]
78
+ return HwpTextSnapshot(paragraphs=paragraphs, source="PrvText")
79
+
80
+ body_lines: List[str] = []
81
+ for stream_name in _iter_bodytext_stream_names(ole):
82
+ raw = ole.openstream(stream_name).read()
83
+ decoded = _decode_section_payload(raw)
84
+ for line in decoded.splitlines():
85
+ cleaned = line.strip()
86
+ if cleaned:
87
+ body_lines.append(cleaned)
88
+
89
+ if body_lines:
90
+ return HwpTextSnapshot(paragraphs=body_lines, source="BodyText")
91
+ except OSError as exc:
92
+ raise HwpBinaryError(f"HWP 스트림 읽기 실패: {exc}") from exc
93
+
94
+ return HwpTextSnapshot(paragraphs=[], source="empty")