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,1295 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Read and export automation handlers for the optional MCP adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import binascii
8
+ import html
9
+ import os
10
+ import re
11
+ from datetime import datetime
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.error import HTTPError, URLError
16
+ from urllib.request import Request
17
+
18
+ from hwpx.ingest import DocumentIngestError, DocumentIngestor
19
+ from hwpx.tools import read_fidelity as _read_fidelity
20
+
21
+ from ..configuration import env_float, env_int
22
+ from ..core.content import (
23
+ collect_full_text,
24
+ get_paragraph_text_from_doc,
25
+ get_table_data,
26
+ get_table_map_in_doc,
27
+ )
28
+ from ..core.document import open_doc
29
+ from ..core.formatting import (
30
+ outline_style_levels,
31
+ )
32
+ from ..core.search import find_in_doc
33
+ from ..network_policy import NetworkPolicy, NetworkPolicyError, open_url
34
+ from ..office.compliance import DEFAULT_POLICY, mask_pii
35
+ from ..runtime_services import RUNTIME_SERVICES
36
+ from ..upstream import (
37
+ HP_NS,
38
+ open_document,
39
+ )
40
+ from ..utils.helpers import default_max_chars, resolve_path, truncate_response
41
+ from ._shared import _with_document_state
42
+
43
+ _OUTPUT_MODES = {"full", "chunks"}
44
+
45
+
46
+ _CHUNK_STRATEGIES = {"section", "paragraph"}
47
+
48
+
49
+ _DEFAULT_MAX_CHARS_PER_CHUNK = 8000
50
+
51
+
52
+ _DEFAULT_MAX_INPUT_BYTES = 20 * 1024 * 1024
53
+
54
+
55
+ _DEFAULT_FETCH_TIMEOUT_SECONDS = 20.0
56
+
57
+
58
+ _FIGURE_CAPTION_RE = re.compile(r"^\s*(?:Figure|Fig\.|그림)\s*\d*", re.IGNORECASE)
59
+
60
+
61
+ def _mask_pii_text(value: str, mask: bool = True) -> str:
62
+ """Mask machine-set PII (rrn/phone/email/card) in user-facing extract output.
63
+
64
+ On by default (safe-by-default per 개인정보 보호법); contextual types stay
65
+ label-gated low-confidence inside ``mask_pii`` so free text isn't over-masked.
66
+ """
67
+ if not mask or not value:
68
+ return value
69
+ return mask_pii(value, DEFAULT_POLICY)
70
+
71
+
72
+ def _deep_mask_pii(obj: Any, mask: bool = True) -> Any:
73
+ """Recursively mask PII in string VALUES of a nested JSON-able structure.
74
+
75
+ Dict keys are left untouched; only string values are masked (``mask_pii`` only
76
+ rewrites machine-set PII + label-gated contextual, so normal text is unchanged).
77
+ """
78
+ if not mask:
79
+ return obj
80
+ if isinstance(obj, str):
81
+ return mask_pii(obj, DEFAULT_POLICY)
82
+ if isinstance(obj, list):
83
+ return [_deep_mask_pii(item, True) for item in obj]
84
+ if isinstance(obj, dict):
85
+ return {key: _deep_mask_pii(val, True) for key, val in obj.items()}
86
+ return obj
87
+
88
+
89
+ def _normalize_output_mode(output: str | None) -> str:
90
+ value = (output or "full").strip().lower()
91
+ if value not in _OUTPUT_MODES:
92
+ expected = ", ".join(sorted(_OUTPUT_MODES))
93
+ raise ValueError(f"output must be one of: {expected}")
94
+ return value
95
+
96
+
97
+ def _normalize_chunk_strategy(chunk_strategy: str | None) -> str:
98
+ value = (chunk_strategy or "section").strip().lower()
99
+ if value not in _CHUNK_STRATEGIES:
100
+ expected = ", ".join(sorted(_CHUNK_STRATEGIES))
101
+ raise ValueError(f"chunk_strategy must be one of: {expected}")
102
+ return value
103
+
104
+
105
+ def _resolve_chunk_size(max_chars_per_chunk: int | None) -> int:
106
+ if max_chars_per_chunk is None:
107
+ return max(1, env_int("MAX_CHARS_PER_CHUNK", _DEFAULT_MAX_CHARS_PER_CHUNK))
108
+ if max_chars_per_chunk <= 0:
109
+ raise ValueError("max_chars_per_chunk must be greater than 0")
110
+ return max_chars_per_chunk
111
+
112
+
113
+ def _normalize_heading_text(text: str) -> str:
114
+ stripped = (text or "").strip()
115
+ if stripped.startswith("#"):
116
+ return stripped.lstrip("#").strip()
117
+ return stripped
118
+
119
+
120
+ def _looks_like_figure_caption(text: str) -> bool:
121
+ return bool(_FIGURE_CAPTION_RE.match((text or "").strip()))
122
+
123
+
124
+ def _download_hwpx_from_url(url: str, *, max_input_bytes: int) -> bytes:
125
+ request = Request(url, headers={"User-Agent": "python-hwpx-automation/6"})
126
+ timeout = env_float("FETCH_TIMEOUT_SECONDS", _DEFAULT_FETCH_TIMEOUT_SECONDS)
127
+ try:
128
+ with open_url(
129
+ request,
130
+ policy=NetworkPolicy.from_environment(),
131
+ timeout=timeout,
132
+ ) as response:
133
+ payload = response.read(max_input_bytes + 1)
134
+ except NetworkPolicyError:
135
+ raise
136
+ except HTTPError as exc:
137
+ raise ValueError(f"failed to download url: HTTP {exc.code}") from exc
138
+ except URLError as exc:
139
+ raise ValueError(f"failed to download url: {exc.reason}") from exc
140
+
141
+ if len(payload) > max_input_bytes:
142
+ raise ValueError(f"input is too large: limit is {max_input_bytes} bytes")
143
+ if not payload:
144
+ raise ValueError("downloaded payload is empty")
145
+ return payload
146
+
147
+
148
+ def _load_hwpx_payload(
149
+ hwpx_base64: str | None, url: str | None
150
+ ) -> tuple[bytes, dict[str, Any]]:
151
+ use_base64 = bool(hwpx_base64 and hwpx_base64.strip())
152
+ use_url = bool(url and url.strip())
153
+ if use_base64 == use_url:
154
+ raise ValueError("provide exactly one of hwpx_base64 or url")
155
+
156
+ max_input_bytes = max(1, env_int("MAX_INPUT_BYTES", _DEFAULT_MAX_INPUT_BYTES))
157
+ if use_base64:
158
+ try:
159
+ payload = base64.b64decode((hwpx_base64 or "").strip(), validate=True)
160
+ except (binascii.Error, ValueError) as exc:
161
+ raise ValueError("invalid hwpx_base64 payload") from exc
162
+ if len(payload) > max_input_bytes:
163
+ raise ValueError(f"input is too large: limit is {max_input_bytes} bytes")
164
+ if not payload:
165
+ raise ValueError("hwpx_base64 decoded to empty payload")
166
+ return payload, {"source_type": "base64", "size_bytes": len(payload)}
167
+
168
+ source_url = (url or "").strip()
169
+ payload = _download_hwpx_from_url(source_url, max_input_bytes=max_input_bytes)
170
+ return payload, {
171
+ "source_type": "url",
172
+ "source_url": source_url,
173
+ "size_bytes": len(payload),
174
+ }
175
+
176
+
177
+ def _open_hwpx_from_payload(hwpx_base64: str | None, url: str | None):
178
+ payload, source_meta = _load_hwpx_payload(hwpx_base64, url)
179
+ try:
180
+ doc = open_document(BytesIO(payload))
181
+ except Exception as exc: # pragma: no cover - delegated to parser
182
+ raise ValueError(f"failed to parse hwpx payload: {exc}") from exc
183
+ return doc, source_meta
184
+
185
+
186
+ def _table_rows(table: Any) -> list[list[str]]:
187
+ rows: list[list[str]] = []
188
+ for row in getattr(table, "rows", []):
189
+ rows.append([(cell.text or "") for cell in getattr(row, "cells", [])])
190
+ return rows
191
+
192
+
193
+ def _table_to_markdown(rows: list[list[str]]) -> str:
194
+ if not rows:
195
+ return ""
196
+ width = max((len(row) for row in rows), default=0)
197
+ if width <= 0:
198
+ return ""
199
+
200
+ def _pad(row: list[str]) -> list[str]:
201
+ return row + [""] * (width - len(row))
202
+
203
+ normalized = [_pad([str(cell) for cell in row]) for row in rows]
204
+ header = normalized[0]
205
+ divider = ["---"] * width
206
+
207
+ def _render(cells: list[str]) -> str:
208
+ escaped = [cell.replace("|", r"\|").replace("\n", "<br>") for cell in cells]
209
+ return f"| {' | '.join(escaped)} |"
210
+
211
+ lines = [_render(header), _render(divider)]
212
+ for row in normalized[1:]:
213
+ lines.append(_render(row))
214
+ return "\n".join(lines)
215
+
216
+
217
+ def _table_to_html(rows: list[list[str]]) -> str:
218
+ if not rows:
219
+ return "<table></table>"
220
+ width = max((len(row) for row in rows), default=0)
221
+ if width <= 0:
222
+ return "<table></table>"
223
+
224
+ def _pad(row: list[str]) -> list[str]:
225
+ return row + [""] * (width - len(row))
226
+
227
+ normalized = [_pad([str(cell) for cell in row]) for row in rows]
228
+ header = normalized[0]
229
+ body_rows = normalized[1:]
230
+
231
+ head_html = "".join(f"<th>{html.escape(cell)}</th>" for cell in header)
232
+ body_html = []
233
+ for row in body_rows:
234
+ cells = "".join(f"<td>{html.escape(cell)}</td>" for cell in row)
235
+ body_html.append(f"<tr>{cells}</tr>")
236
+
237
+ if body_html:
238
+ body = "<tbody>" + "".join(body_html) + "</tbody>"
239
+ else:
240
+ body = ""
241
+ return f"<table><thead><tr>{head_html}</tr></thead>{body}</table>"
242
+
243
+
244
+ def _run_format_detail(
245
+ run: Any, fontfaces: dict[str, dict[str, str]] | None = None
246
+ ) -> dict[str, Any]:
247
+ """Resolved inline formatting for one run.
248
+
249
+ Named fields (bold/italic/underline/strikeout/color/fontSize/fontName/
250
+ super-subscript) come from the canonical :mod:`hwpx.tools.read_fidelity`
251
+ resolver so this surface and the fidelity harness agree by construction.
252
+ ``strikeout`` is shape-normalised (the always-present
253
+ ``<hh:strikeout shape="NONE"/>`` previously read as always-on); ``underline``
254
+ is the type or ``None`` when off. Legacy keys are preserved for compat.
255
+ """
256
+ style = getattr(run, "style", None)
257
+ span = _read_fidelity.run_span(
258
+ getattr(run, "text", "") or "", style, fontfaces or {}
259
+ )
260
+ return {
261
+ "text": span.text,
262
+ "charPrIDRef": getattr(run, "char_pr_id_ref", None),
263
+ "bold": span.bold,
264
+ "italic": span.italic,
265
+ "underline": span.underline,
266
+ "strikeout": span.strikeout,
267
+ "color": span.color,
268
+ "fontSize": span.size_pt,
269
+ "fontName": span.font,
270
+ "superscript": span.superscript,
271
+ "subscript": span.subscript,
272
+ # legacy back-compat keys
273
+ "textColor": span.color,
274
+ "underlineType": style.underline_type() if style is not None else None,
275
+ "underlineColor": style.underline_color() if style is not None else None,
276
+ "attributes": dict(getattr(style, "attributes", {}) or {}),
277
+ }
278
+
279
+
280
+ def _paragraph_format_detail(
281
+ paragraph: Any, fontfaces: dict[str, dict[str, str]] | None = None
282
+ ) -> dict[str, Any]:
283
+ return {
284
+ "paraPrIDRef": getattr(paragraph, "para_pr_id_ref", None),
285
+ "styleIDRef": getattr(paragraph, "style_id_ref", None),
286
+ "charPrIDRef": getattr(paragraph, "char_pr_id_ref", None),
287
+ "runs": [
288
+ _run_format_detail(run, fontfaces) for run in getattr(paragraph, "runs", [])
289
+ ],
290
+ }
291
+
292
+
293
+ def _cell_format_detail(cell: Any) -> dict[str, Any]:
294
+ return {
295
+ "width": getattr(cell, "width", None),
296
+ "height": getattr(cell, "height", None),
297
+ "span": list(getattr(cell, "span", ()) or ()),
298
+ "address": list(getattr(cell, "address", ()) or ()),
299
+ "borderFillIDRef": getattr(
300
+ getattr(cell, "element", None), "get", lambda _name, _default=None: _default
301
+ )("borderFillIDRef"),
302
+ }
303
+
304
+
305
+ def _table_format_detail(table: Any) -> dict[str, Any]:
306
+ rows = []
307
+ for row in getattr(table, "rows", []):
308
+ cells = [_cell_format_detail(cell) for cell in getattr(row, "cells", [])]
309
+ rows.append(cells)
310
+ return {
311
+ "columnCount": getattr(table, "column_count", None),
312
+ "rowCount": getattr(table, "row_count", None),
313
+ "cells": rows,
314
+ }
315
+
316
+
317
+ def _build_read_model(doc: Any, *, format_detail: bool = False) -> dict[str, Any]:
318
+ toc: list[dict[str, Any]] = []
319
+ sections: list[dict[str, Any]] = []
320
+ tables: list[dict[str, Any]] = []
321
+ figures: list[dict[str, Any]] = []
322
+ items: list[dict[str, Any]] = []
323
+
324
+ current_section: dict[str, Any] = {
325
+ "index": 0,
326
+ "title": None,
327
+ "level": 0,
328
+ "paragraphs": [],
329
+ "tables": [],
330
+ "figures": [],
331
+ }
332
+
333
+ def _flush_current_section() -> None:
334
+ if (
335
+ not current_section["paragraphs"]
336
+ and not current_section["tables"]
337
+ and not current_section["figures"]
338
+ ):
339
+ return
340
+ sections.append(
341
+ {
342
+ "index": len(sections),
343
+ "title": current_section["title"],
344
+ "level": current_section["level"],
345
+ "paragraphs": list(current_section["paragraphs"]),
346
+ "tables": list(current_section["tables"]),
347
+ "figures": list(current_section["figures"]),
348
+ }
349
+ )
350
+
351
+ table_index = 0
352
+ style_levels = outline_style_levels(doc)
353
+ fontfaces = _read_fidelity.fontface_maps(doc) if format_detail else {}
354
+ for paragraph_index, paragraph in enumerate(doc.paragraphs):
355
+ text = (paragraph.text or "").strip()
356
+ level = _paragraph_outline_level(paragraph, text, style_levels)
357
+ paragraph_payload = {"index": paragraph_index, "text": text}
358
+ if format_detail:
359
+ paragraph_payload["format"] = _paragraph_format_detail(paragraph, fontfaces)
360
+
361
+ if level > 0 and text:
362
+ _flush_current_section()
363
+ current_section = {
364
+ "index": len(sections),
365
+ "title": _normalize_heading_text(text),
366
+ "level": level,
367
+ "paragraphs": [],
368
+ "tables": [],
369
+ "figures": [],
370
+ }
371
+ heading_text = _normalize_heading_text(text)
372
+ toc.append(
373
+ {
374
+ "level": level,
375
+ "text": heading_text,
376
+ "paragraph_index": paragraph_index,
377
+ }
378
+ )
379
+ item = {
380
+ "type": "heading",
381
+ "level": level,
382
+ "text": heading_text,
383
+ "paragraph_index": paragraph_index,
384
+ }
385
+ if format_detail:
386
+ item["format"] = _paragraph_format_detail(paragraph, fontfaces)
387
+ items.append(item)
388
+ elif text:
389
+ item = {
390
+ "type": "paragraph",
391
+ "text": text,
392
+ "paragraph_index": paragraph_index,
393
+ }
394
+ if format_detail:
395
+ item["format"] = _paragraph_format_detail(paragraph, fontfaces)
396
+ items.append(item)
397
+
398
+ if text:
399
+ current_section["paragraphs"].append(paragraph_payload)
400
+ if _looks_like_figure_caption(text):
401
+ figure = {
402
+ "figure_index": len(figures),
403
+ "paragraph_index": paragraph_index,
404
+ "caption": text,
405
+ }
406
+ figures.append(figure)
407
+ current_section["figures"].append(figure)
408
+
409
+ for table in getattr(paragraph, "tables", []):
410
+ rows = _table_rows(table)
411
+ table_payload = {
412
+ "table_index": table_index,
413
+ "paragraph_index": paragraph_index,
414
+ "rows": len(rows),
415
+ "cols": max((len(row) for row in rows), default=0),
416
+ "data": rows,
417
+ }
418
+ if format_detail:
419
+ table_payload["format"] = _table_format_detail(table)
420
+ tables.append(table_payload)
421
+ current_section["tables"].append(table_payload)
422
+ item = {
423
+ "type": "table",
424
+ "table_index": table_index,
425
+ "paragraph_index": paragraph_index,
426
+ "data": rows,
427
+ }
428
+ if format_detail:
429
+ item["format"] = table_payload["format"]
430
+ items.append(item)
431
+ table_index += 1
432
+
433
+ _flush_current_section()
434
+ try:
435
+ notes = [note.to_dict() for note in _read_fidelity.collect_notes(doc)]
436
+ except Exception: # pragma: no cover - defensive: never break a read
437
+ notes = []
438
+ return {
439
+ "title": toc[0]["text"] if toc else None,
440
+ "toc": toc,
441
+ "sections": sections,
442
+ "tables": tables,
443
+ "figures": figures,
444
+ "items": items,
445
+ "notes": notes,
446
+ }
447
+
448
+
449
+ def _append_notes_markdown(
450
+ markdown: str, notes: list[dict[str, Any]], mask: bool
451
+ ) -> str:
452
+ """Append a footnote/endnote definition appendix (reference-style).
453
+
454
+ The reading surfaces used to drop note bodies entirely; this preserves them
455
+ at the installed surface as ``[^fn1]: body`` lines under a rule.
456
+ """
457
+ if not notes:
458
+ return markdown
459
+ fn_i = en_i = 0
460
+ lines: list[str] = []
461
+ for note in notes:
462
+ if note.get("kind") == "footNote":
463
+ fn_i += 1
464
+ marker, label = f"[^fn{fn_i}]", "각주"
465
+ else:
466
+ en_i += 1
467
+ marker, label = f"[^en{en_i}]", "미주"
468
+ body = _mask_pii_text(note.get("bodyText", "") or "", mask)
469
+ lines.append(f"{marker}: ({label}) {body}")
470
+ appendix = "\n".join(lines)
471
+ return f"{markdown}\n\n---\n\n{appendix}" if markdown else appendix
472
+
473
+
474
+ def _render_markdown(model: dict[str, Any]) -> str:
475
+ blocks: list[str] = []
476
+ for item in model["items"]:
477
+ kind = item["type"]
478
+ if kind == "heading":
479
+ level = max(1, min(6, int(item["level"])))
480
+ blocks.append(f"{'#' * level} {item['text']}")
481
+ elif kind == "paragraph":
482
+ blocks.append(item["text"])
483
+ elif kind == "table":
484
+ table_markdown = _table_to_markdown(item["data"])
485
+ if table_markdown:
486
+ blocks.append(table_markdown)
487
+ return "\n\n".join(blocks).strip()
488
+
489
+
490
+ def _render_html(model: dict[str, Any]) -> str:
491
+ body: list[str] = ['<article class="hwpx-document">']
492
+ for item in model["items"]:
493
+ kind = item["type"]
494
+ if kind == "heading":
495
+ level = max(1, min(6, int(item["level"])))
496
+ body.append(f"<h{level}>{html.escape(item['text'])}</h{level}>")
497
+ elif kind == "paragraph":
498
+ body.append(f"<p>{html.escape(item['text'])}</p>")
499
+ elif kind == "table":
500
+ body.append(_table_to_html(item["data"]))
501
+ body.append("</article>")
502
+ return (
503
+ "<!doctype html>"
504
+ "<html><head><meta charset='utf-8'><title>HWPX Document</title></head>"
505
+ f"<body>{''.join(body)}</body></html>"
506
+ )
507
+
508
+
509
+ def _section_markdown(section: dict[str, Any]) -> str:
510
+ blocks: list[str] = []
511
+ title = section.get("title")
512
+ level = max(1, min(6, int(section.get("level") or 1)))
513
+ paragraphs = list(section.get("paragraphs") or [])
514
+ if title:
515
+ blocks.append(f"{'#' * level} {title}")
516
+ if paragraphs and paragraphs[0].get("text") == title:
517
+ paragraphs = paragraphs[1:]
518
+ for paragraph in paragraphs:
519
+ text = (paragraph.get("text") or "").strip()
520
+ if text:
521
+ blocks.append(text)
522
+ for table in section.get("tables") or []:
523
+ markdown_table = _table_to_markdown(table.get("data") or [])
524
+ if markdown_table:
525
+ blocks.append(markdown_table)
526
+ return "\n\n".join(blocks).strip()
527
+
528
+
529
+ def _section_html(section: dict[str, Any]) -> str:
530
+ parts: list[str] = ["<section>"]
531
+ title = section.get("title")
532
+ level = max(1, min(6, int(section.get("level") or 1)))
533
+ paragraphs = list(section.get("paragraphs") or [])
534
+ if title:
535
+ parts.append(f"<h{level}>{html.escape(title)}</h{level}>")
536
+ if paragraphs and paragraphs[0].get("text") == title:
537
+ paragraphs = paragraphs[1:]
538
+ for paragraph in paragraphs:
539
+ text = (paragraph.get("text") or "").strip()
540
+ if text:
541
+ parts.append(f"<p>{html.escape(text)}</p>")
542
+ for table in section.get("tables") or []:
543
+ parts.append(_table_to_html(table.get("data") or []))
544
+ parts.append("</section>")
545
+ return "".join(parts)
546
+
547
+
548
+ def _chunk_paragraphs(paragraphs: list[str], max_chars_per_chunk: int) -> list[str]:
549
+ chunks: list[str] = []
550
+ buffer: list[str] = []
551
+ used = 0
552
+ for paragraph in paragraphs:
553
+ text = (paragraph or "").strip()
554
+ if not text:
555
+ continue
556
+ additional = len(text) + (2 if buffer else 0)
557
+ if buffer and used + additional > max_chars_per_chunk:
558
+ chunks.append("\n\n".join(buffer))
559
+ buffer = []
560
+ used = 0
561
+ if len(text) > max_chars_per_chunk:
562
+ if buffer:
563
+ chunks.append("\n\n".join(buffer))
564
+ buffer = []
565
+ used = 0
566
+ for start in range(0, len(text), max_chars_per_chunk):
567
+ chunks.append(text[start : start + max_chars_per_chunk])
568
+ continue
569
+ buffer.append(text)
570
+ used += additional
571
+ if buffer:
572
+ chunks.append("\n\n".join(buffer))
573
+ return chunks
574
+
575
+
576
+ def _markdown_chunks(
577
+ model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
578
+ ) -> list[str]:
579
+ if chunk_strategy == "section":
580
+ return [
581
+ chunk
582
+ for section in model["sections"]
583
+ if (chunk := _section_markdown(section))
584
+ ]
585
+
586
+ paragraphs = [
587
+ item["text"]
588
+ for item in model["items"]
589
+ if item["type"] in {"heading", "paragraph"}
590
+ ]
591
+ return _chunk_paragraphs(paragraphs, max_chars_per_chunk)
592
+
593
+
594
+ def _ingest_markdown_chunks(markdown: str, *, max_chars_per_chunk: int) -> list[str]:
595
+ paragraphs = [part for part in re.split(r"\n{2,}", markdown or "") if part.strip()]
596
+ return _chunk_paragraphs(paragraphs, max_chars_per_chunk)
597
+
598
+
599
+ def _html_chunks(
600
+ model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
601
+ ) -> list[str]:
602
+ if chunk_strategy == "section":
603
+ chunks = [_section_html(section) for section in model["sections"]]
604
+ return [chunk for chunk in chunks if chunk]
605
+
606
+ paragraphs = [
607
+ f"<p>{html.escape(item['text'])}</p>"
608
+ for item in model["items"]
609
+ if item["type"] in {"heading", "paragraph"}
610
+ ]
611
+ plain_chunks = _chunk_paragraphs(paragraphs, max_chars_per_chunk)
612
+ return [
613
+ f"<article class='hwpx-document'>{chunk}</article>" for chunk in plain_chunks
614
+ ]
615
+
616
+
617
+ def _json_chunks(
618
+ model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
619
+ ) -> list[dict[str, Any]]:
620
+ if chunk_strategy == "section":
621
+ return [
622
+ {"chunk_index": index, "strategy": "section", "section": section}
623
+ for index, section in enumerate(model["sections"])
624
+ ]
625
+
626
+ paragraphs = []
627
+ for section in model["sections"]:
628
+ for paragraph in section.get("paragraphs") or []:
629
+ paragraphs.append(
630
+ {
631
+ "section_index": section.get("index"),
632
+ "paragraph_index": paragraph.get("index"),
633
+ "text": paragraph.get("text") or "",
634
+ }
635
+ )
636
+ groups = _chunk_paragraphs(
637
+ [item["text"] for item in paragraphs], max_chars_per_chunk
638
+ )
639
+ chunks: list[dict[str, Any]] = []
640
+ offset = 0
641
+ for chunk_index, chunk in enumerate(groups):
642
+ consumed = len(chunk.split("\n\n")) if chunk else 0
643
+ selected = paragraphs[offset : offset + consumed]
644
+ offset += consumed
645
+ chunks.append(
646
+ {
647
+ "chunk_index": chunk_index,
648
+ "strategy": "paragraph",
649
+ "paragraphs": selected,
650
+ }
651
+ )
652
+ return chunks
653
+
654
+
655
+ def _build_conversion_meta(
656
+ model: dict[str, Any], source_meta: dict[str, Any]
657
+ ) -> dict[str, Any]:
658
+ return {
659
+ "source_type": source_meta.get("source_type"),
660
+ "source_url": source_meta.get("source_url"),
661
+ "size_bytes": source_meta.get("size_bytes"),
662
+ "section_count": len(model["sections"]),
663
+ "paragraph_count": sum(
664
+ 1 for item in model["items"] if item["type"] in {"heading", "paragraph"}
665
+ ),
666
+ "table_count": len(model["tables"]),
667
+ "figure_caption_count": len(model["figures"]),
668
+ }
669
+
670
+
671
+ def _ingest_local_document(filename: str):
672
+ if DocumentIngestor is None:
673
+ raise RuntimeError("installed python-hwpx does not provide document ingest")
674
+ from ..ingest_adapters import MarkItDownAdapter, MissingMarkItDownDependency
675
+
676
+ path = resolve_path(filename)
677
+ ingestor = DocumentIngestor.default()
678
+ ingestor.register_converter(MarkItDownAdapter(), priority=100.0)
679
+ try:
680
+ return path, ingestor.convert(path)
681
+ except DocumentIngestError as exc:
682
+ for attempt in getattr(exc, "attempts", []) or []:
683
+ if getattr(attempt, "error_type", None) == "MissingMarkItDownDependency":
684
+ raise MissingMarkItDownDependency(
685
+ str(attempt.message), attempts=exc.attempts
686
+ ) from exc
687
+ raise
688
+
689
+
690
+ def _document_ingest_error_payload(exc: Exception, filename: str) -> dict[str, Any]:
691
+ if DocumentIngestError is not None and isinstance(exc, DocumentIngestError):
692
+ payload = exc.as_dict()
693
+ else:
694
+ payload = {"error": type(exc).__name__, "message": str(exc), "attempts": []}
695
+ payload.update({"ok": False, "filename": filename})
696
+ return payload
697
+
698
+
699
+ def _document_ingest_meta(result: Any) -> dict[str, Any]:
700
+ source_info = getattr(result, "source_info", None)
701
+ meta = {
702
+ "source_format": getattr(result, "source_format", None),
703
+ "engine": getattr(result, "engine", None),
704
+ "engine_version": getattr(result, "engine_version", None),
705
+ "lossiness": getattr(result, "lossiness", None),
706
+ }
707
+ if source_info is not None:
708
+ meta["source_info"] = {
709
+ "mimetype": getattr(source_info, "mimetype", None),
710
+ "extension": getattr(source_info, "extension", None),
711
+ "charset": getattr(source_info, "charset", None),
712
+ "filename": getattr(source_info, "filename", None),
713
+ "local_path": getattr(source_info, "local_path", None),
714
+ "url": getattr(source_info, "url", None),
715
+ }
716
+ metadata = getattr(result, "metadata", None)
717
+ if isinstance(metadata, dict):
718
+ meta.update(metadata)
719
+ return {key: value for key, value in meta.items() if value is not None}
720
+
721
+
722
+ def _attempts_payload(result: Any) -> list[dict[str, Any]]:
723
+ attempts = getattr(result, "attempts", []) or []
724
+ return [
725
+ attempt.as_dict() if hasattr(attempt, "as_dict") else dict(attempt)
726
+ for attempt in attempts
727
+ ]
728
+
729
+
730
+ def _paragraph_count(doc) -> int:
731
+ return len(doc.paragraphs)
732
+
733
+
734
+ def _table_count(doc) -> int:
735
+ table_tag = f"{HP_NS}tbl"
736
+ count = 0
737
+ for section in getattr(doc, "sections", []):
738
+ section_element = getattr(section, "element", None)
739
+ if section_element is None or not hasattr(section_element, "iter"):
740
+ continue
741
+ count += sum(1 for _ in section_element.iter(table_tag))
742
+ return count
743
+
744
+
745
+ def _outline_level(text: str) -> int:
746
+ stripped = (text or "").strip()
747
+ if not stripped:
748
+ return 0
749
+ if stripped.startswith("#"):
750
+ return min(6, len(stripped) - len(stripped.lstrip("#")))
751
+ if stripped[:2].isdigit() and "." in stripped[:6]:
752
+ return 2
753
+ if stripped[:1].isdigit() and "." in stripped[:4]:
754
+ return 1
755
+ return 1 if len(stripped) < 60 else 0
756
+
757
+
758
+ def _paragraph_outline_level(
759
+ paragraph: Any, text: str, style_levels: dict[str, int]
760
+ ) -> int:
761
+ """개요 문단 스타일을 우선하고, 구버전 '#' 헤딩만 fallback으로 인식한다."""
762
+ if not (text or "").strip():
763
+ return 0
764
+ ref = getattr(paragraph, "style_id_ref", None)
765
+ if ref is not None and str(ref) in style_levels:
766
+ return style_levels[str(ref)]
767
+ if text.strip().startswith("#"):
768
+ return _outline_level(text)
769
+ if style_levels:
770
+ return 0
771
+ return _outline_level(text)
772
+
773
+
774
+ def get_document_info(filename: str) -> dict:
775
+ """문서 메타데이터와 구조 요약을 조회합니다."""
776
+ path = resolve_path(filename)
777
+ doc = open_doc(path)
778
+ file_size = Path(path).stat().st_size
779
+ return _with_document_state(
780
+ {
781
+ "filename": filename,
782
+ "sections": len(doc.sections),
783
+ "paragraphs": _paragraph_count(doc),
784
+ "tables": _table_count(doc),
785
+ "file_size": str(file_size),
786
+ },
787
+ path,
788
+ )
789
+
790
+
791
+ def get_document_text(
792
+ filename: str, max_chars: int | None = None, mask: bool = True
793
+ ) -> dict:
794
+ """문서 전체 텍스트를 조회합니다. (기본: 기계검증 PII 마스킹 ON — `mask=False`로 원본)"""
795
+ path = resolve_path(filename)
796
+ doc = open_doc(path)
797
+ text = _mask_pii_text(collect_full_text(doc), mask)
798
+ return _with_document_state(truncate_response(text, max_chars=max_chars), path)
799
+
800
+
801
+ def get_document_outline(filename: str) -> dict:
802
+ """문단 기준 제목/개요 구조를 조회합니다."""
803
+ path = resolve_path(filename)
804
+ doc = open_doc(path)
805
+ outline: list[dict] = []
806
+ style_levels = outline_style_levels(doc)
807
+ for index, para in enumerate(doc.paragraphs):
808
+ text = (para.text or "").strip()
809
+ level = _paragraph_outline_level(para, text, style_levels)
810
+ if level > 0 and text:
811
+ outline.append(
812
+ {
813
+ "level": level,
814
+ "text": _normalize_heading_text(text),
815
+ "paragraph_index": index,
816
+ }
817
+ )
818
+ return _with_document_state({"outline": outline}, path)
819
+
820
+
821
+ def _summary_table_map(path) -> dict:
822
+ """Compact per-table map (FR-006) via python-hwpx table_summary — bounded."""
823
+ try:
824
+ from hwpx.table_patch import table_summary
825
+ except Exception as exc: # pragma: no cover - dependency compatibility
826
+ return {"tables": [], "count": 0, "note": f"summary unavailable: {exc}"}
827
+ tables = table_summary(path)
828
+ return {"tables": tables, "count": len(tables), "detail": "summary"}
829
+
830
+
831
+ def get_document_map(
832
+ filename: str,
833
+ max_preview_chars: int = 80,
834
+ detail: str = "full",
835
+ ) -> dict:
836
+ """문서 개요, 표, 양식 필드, 앵커를 한 번에 조회합니다.
837
+
838
+ detail="summary" (FR-006): 표는 셀 덤프 없이 표당 {tableIndex, rows, cols,
839
+ merges, heading, firstRow} 요약만 반환 — 37표 양식도 토큰한도 내 1콜. 헤딩은
840
+ apply_table_ops/fill_cells의 tableAnchor로 그대로 쓸 수 있는 텍스트.
841
+ detail="full"(기본)은 종전대로 셀 단위 표 지도를 반환한다."""
842
+ path = resolve_path(filename)
843
+ doc = open_doc(path)
844
+ model = _build_read_model(doc)
845
+ preview_limit = max(0, int(max_preview_chars))
846
+ summary_mode = str(detail).lower() == "summary"
847
+
848
+ paragraph_anchors = []
849
+ for item in model["items"]:
850
+ if item.get("type") not in {"heading", "paragraph"}:
851
+ continue
852
+ text = str(item.get("text") or "")
853
+ paragraph_index = item.get("paragraph_index")
854
+ paragraph_anchors.append(
855
+ {
856
+ "kind": item.get("type"),
857
+ "paragraphIndex": paragraph_index,
858
+ "textPreview": text[:preview_limit],
859
+ "anchor": {
860
+ "kind": "body_paragraph",
861
+ "paragraphIndex": paragraph_index,
862
+ },
863
+ }
864
+ )
865
+
866
+ table_anchors = [
867
+ {
868
+ "kind": "table",
869
+ "tableIndex": table.get("table_index"),
870
+ "paragraphIndex": table.get("paragraph_index"),
871
+ "rows": table.get("rows"),
872
+ "cols": table.get("cols"),
873
+ "anchor": {
874
+ "kind": "table",
875
+ "tableIndex": table.get("table_index"),
876
+ "paragraphIndex": table.get("paragraph_index"),
877
+ },
878
+ }
879
+ for table in model["tables"]
880
+ ]
881
+
882
+ try:
883
+ form_fields = RUNTIME_SERVICES.ops.list_form_fields(path)
884
+ except Exception as exc: # pragma: no cover - diagnostic fallback
885
+ form_fields = {"fields": [], "error": str(exc)}
886
+
887
+ result = {
888
+ "filename": filename,
889
+ "info": {
890
+ "sections": len(doc.sections),
891
+ "paragraphs": _paragraph_count(doc),
892
+ "tables": _table_count(doc),
893
+ },
894
+ "outline": model["toc"],
895
+ "sections": [
896
+ {
897
+ "index": section.get("index"),
898
+ "title": section.get("title"),
899
+ "level": section.get("level"),
900
+ "paragraphCount": len(section.get("paragraphs") or []),
901
+ "tableCount": len(section.get("tables") or []),
902
+ "figureCount": len(section.get("figures") or []),
903
+ }
904
+ for section in model["sections"]
905
+ ],
906
+ "tables": _summary_table_map(path)
907
+ if summary_mode
908
+ else get_table_map_in_doc(doc),
909
+ "formFields": form_fields,
910
+ "anchors": {
911
+ "paragraphs": paragraph_anchors,
912
+ "tables": table_anchors,
913
+ "figures": model["figures"],
914
+ },
915
+ "sourceTools": [
916
+ "get_document_info",
917
+ "get_document_outline",
918
+ "get_table_map",
919
+ "list_form_fields",
920
+ "hwpx_extract_json",
921
+ ],
922
+ }
923
+ return _with_document_state(result, path)
924
+
925
+
926
+ def hwpx_to_markdown(
927
+ hwpx_base64: str | None = None,
928
+ url: str | None = None,
929
+ output: str = "full",
930
+ chunk_strategy: str = "section",
931
+ max_chars_per_chunk: int | None = None,
932
+ mask: bool = True,
933
+ ) -> dict:
934
+ """HWPX payload 또는 URL을 Markdown으로 변환합니다. (기본: 기계검증 PII 마스킹 ON)"""
935
+ mode = _normalize_output_mode(output)
936
+ strategy = _normalize_chunk_strategy(chunk_strategy)
937
+ chunk_size = _resolve_chunk_size(max_chars_per_chunk)
938
+
939
+ doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
940
+ model = _build_read_model(doc)
941
+ markdown = _mask_pii_text(_render_markdown(model), mask)
942
+ markdown = _append_notes_markdown(markdown, model.get("notes", []), mask)
943
+
944
+ result: dict[str, Any] = {
945
+ "markdown": markdown,
946
+ "meta": _build_conversion_meta(model, source_meta),
947
+ }
948
+ if mode == "chunks":
949
+ result["chunks"] = _deep_mask_pii(
950
+ _markdown_chunks(
951
+ model,
952
+ chunk_strategy=strategy,
953
+ max_chars_per_chunk=chunk_size,
954
+ ),
955
+ mask,
956
+ )
957
+ result["meta"]["chunk_strategy"] = strategy
958
+ result["meta"]["max_chars_per_chunk"] = chunk_size
959
+ return result
960
+
961
+
962
+ def document_to_markdown(
963
+ filename: str,
964
+ output: str = "full",
965
+ chunk_strategy: str = "section",
966
+ max_chars_per_chunk: int | None = None,
967
+ mask: bool = True,
968
+ ) -> dict:
969
+ """로컬 문서를 Markdown으로 변환합니다. 현재 HWPX는 python-hwpx ingest 엔진으로 처리합니다."""
970
+ mode = _normalize_output_mode(output)
971
+ strategy = _normalize_chunk_strategy(chunk_strategy)
972
+ chunk_size = _resolve_chunk_size(max_chars_per_chunk)
973
+
974
+ try:
975
+ path, ingest_result = _ingest_local_document(filename)
976
+ except Exception as exc:
977
+ return _document_ingest_error_payload(exc, filename)
978
+
979
+ markdown = _mask_pii_text(ingest_result.markdown, mask)
980
+ payload: dict[str, Any] = {
981
+ "ok": True,
982
+ "filename": str(path),
983
+ "markdown": markdown,
984
+ "meta": _document_ingest_meta(ingest_result),
985
+ "warnings": list(getattr(ingest_result, "warnings", []) or []),
986
+ "attempts": _attempts_payload(ingest_result),
987
+ }
988
+ if mode == "chunks":
989
+ payload["chunks"] = _deep_mask_pii(
990
+ _ingest_markdown_chunks(markdown, max_chars_per_chunk=chunk_size),
991
+ mask,
992
+ )
993
+ payload["meta"]["chunk_strategy"] = strategy
994
+ payload["meta"]["max_chars_per_chunk"] = chunk_size
995
+ return payload
996
+
997
+
998
+ def hwpx_to_html(
999
+ hwpx_base64: str | None = None,
1000
+ url: str | None = None,
1001
+ output: str = "full",
1002
+ chunk_strategy: str = "section",
1003
+ max_chars_per_chunk: int | None = None,
1004
+ ) -> dict:
1005
+ """HWPX payload 또는 URL을 HTML로 변환합니다."""
1006
+ mode = _normalize_output_mode(output)
1007
+ strategy = _normalize_chunk_strategy(chunk_strategy)
1008
+ chunk_size = _resolve_chunk_size(max_chars_per_chunk)
1009
+
1010
+ doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
1011
+ model = _build_read_model(doc)
1012
+ payload: dict[str, Any] = {
1013
+ "html": _render_html(model),
1014
+ "meta": _build_conversion_meta(model, source_meta),
1015
+ }
1016
+ payload["meta"]["image_policy"] = "omitted"
1017
+
1018
+ if mode == "chunks":
1019
+ payload["chunks"] = _html_chunks(
1020
+ model,
1021
+ chunk_strategy=strategy,
1022
+ max_chars_per_chunk=chunk_size,
1023
+ )
1024
+ payload["meta"]["chunk_strategy"] = strategy
1025
+ payload["meta"]["max_chars_per_chunk"] = chunk_size
1026
+ return payload
1027
+
1028
+
1029
+ def hwpx_extract_json(
1030
+ hwpx_base64: str | None = None,
1031
+ url: str | None = None,
1032
+ output: str = "full",
1033
+ chunk_strategy: str = "section",
1034
+ max_chars_per_chunk: int | None = None,
1035
+ format_detail: bool = False,
1036
+ mask: bool = True,
1037
+ ) -> dict:
1038
+ """HWPX payload 또는 URL에서 구조화된 JSON을 추출합니다. (기본: 기계검증 PII 마스킹 ON)"""
1039
+ mode = _normalize_output_mode(output)
1040
+ strategy = _normalize_chunk_strategy(chunk_strategy)
1041
+ chunk_size = _resolve_chunk_size(max_chars_per_chunk)
1042
+
1043
+ doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
1044
+ model = _build_read_model(doc, format_detail=bool(format_detail))
1045
+ doc_payload = _deep_mask_pii(
1046
+ {
1047
+ "title": model["title"],
1048
+ "toc": model["toc"],
1049
+ "sections": model["sections"],
1050
+ "tables": model["tables"],
1051
+ "figures": model["figures"],
1052
+ "notes": model.get("notes", []),
1053
+ },
1054
+ mask,
1055
+ )
1056
+ result: dict[str, Any] = {
1057
+ "doc": doc_payload,
1058
+ "meta": _build_conversion_meta(model, source_meta),
1059
+ }
1060
+ if format_detail:
1061
+ result["meta"]["format_detail"] = True
1062
+ if mode == "chunks":
1063
+ result["chunks"] = _deep_mask_pii(
1064
+ _json_chunks(
1065
+ model,
1066
+ chunk_strategy=strategy,
1067
+ max_chars_per_chunk=chunk_size,
1068
+ ),
1069
+ mask,
1070
+ )
1071
+ result["meta"]["chunk_strategy"] = strategy
1072
+ result["meta"]["max_chars_per_chunk"] = chunk_size
1073
+ return result
1074
+
1075
+
1076
+ def document_extract_json(
1077
+ filename: str,
1078
+ output: str = "full",
1079
+ chunk_strategy: str = "section",
1080
+ max_chars_per_chunk: int | None = None,
1081
+ format_detail: bool = False,
1082
+ mask: bool = True,
1083
+ ) -> dict:
1084
+ """로컬 문서에서 Markdown과 구조화된 JSON을 함께 추출합니다. 현재 HWPX ingest를 우선 사용합니다."""
1085
+ del format_detail
1086
+ mode = _normalize_output_mode(output)
1087
+ strategy = _normalize_chunk_strategy(chunk_strategy)
1088
+ chunk_size = _resolve_chunk_size(max_chars_per_chunk)
1089
+
1090
+ try:
1091
+ path, ingest_result = _ingest_local_document(filename)
1092
+ except Exception as exc:
1093
+ return _document_ingest_error_payload(exc, filename)
1094
+
1095
+ markdown = _mask_pii_text(ingest_result.markdown, mask)
1096
+ doc_payload = _deep_mask_pii(
1097
+ {
1098
+ "title": getattr(ingest_result, "title", None),
1099
+ "markdown": markdown,
1100
+ "sections": getattr(ingest_result, "sections", []) or [],
1101
+ "tables": getattr(ingest_result, "tables", []) or [],
1102
+ "metadata": getattr(ingest_result, "metadata", {}) or {},
1103
+ },
1104
+ mask,
1105
+ )
1106
+ payload: dict[str, Any] = {
1107
+ "ok": True,
1108
+ "filename": str(path),
1109
+ "doc": doc_payload,
1110
+ "meta": _document_ingest_meta(ingest_result),
1111
+ "warnings": list(getattr(ingest_result, "warnings", []) or []),
1112
+ "attempts": _attempts_payload(ingest_result),
1113
+ }
1114
+ if mode == "chunks":
1115
+ payload["chunks"] = _deep_mask_pii(
1116
+ _ingest_markdown_chunks(markdown, max_chars_per_chunk=chunk_size),
1117
+ mask,
1118
+ )
1119
+ payload["meta"]["chunk_strategy"] = strategy
1120
+ payload["meta"]["max_chars_per_chunk"] = chunk_size
1121
+ return payload
1122
+
1123
+
1124
+ def get_paragraph_text(
1125
+ filename: str,
1126
+ paragraph_index: int | None = None,
1127
+ location: dict[str, Any] | None = None,
1128
+ ) -> dict:
1129
+ """본문 문단 또는 표 셀 문단 텍스트를 조회합니다."""
1130
+ path = resolve_path(filename)
1131
+ doc = open_doc(path)
1132
+ result = get_paragraph_text_from_doc(
1133
+ doc, paragraph_index=paragraph_index, location=location
1134
+ )
1135
+ if result["location"].get("kind") == "body_paragraph":
1136
+ result["paragraph_index"] = result["location"]["paragraph_index"]
1137
+ return _with_document_state(result, path)
1138
+
1139
+
1140
+ def get_location_text(filename: str, location: dict[str, Any]) -> dict:
1141
+ """get_table_map/find_text가 반환한 location으로 텍스트를 조회합니다."""
1142
+ return get_paragraph_text(filename, location=location)
1143
+
1144
+
1145
+ def get_paragraphs_text(
1146
+ filename: str,
1147
+ start_index: int = 0,
1148
+ end_index: int = None,
1149
+ max_chars: int | None = None,
1150
+ ) -> dict:
1151
+ """문단 범위 텍스트를 조회합니다."""
1152
+ path = resolve_path(filename)
1153
+ doc = open_doc(path)
1154
+ if max_chars is None:
1155
+ max_chars = default_max_chars()
1156
+
1157
+ total = len(doc.paragraphs)
1158
+ end = total if end_index is None else min(end_index, total)
1159
+ start = max(0, start_index)
1160
+ picked = []
1161
+ used = 0
1162
+ truncated = False
1163
+ for index in range(start, end):
1164
+ text = doc.paragraphs[index].text or ""
1165
+ next_size = used + len(text)
1166
+ if next_size > max_chars:
1167
+ remaining = max(0, max_chars - used)
1168
+ picked.append({"index": index, "text": text[:remaining]})
1169
+ truncated = True
1170
+ break
1171
+ picked.append({"index": index, "text": text})
1172
+ used = next_size
1173
+ return _with_document_state({"paragraphs": picked, "truncated": truncated}, path)
1174
+
1175
+
1176
+ def find_text(
1177
+ filename: str, text_to_find: str, match_case: bool = True, max_results: int = 50
1178
+ ) -> dict:
1179
+ """문서에서 텍스트를 검색합니다. 원본은 수정하지 않습니다."""
1180
+ path = resolve_path(filename)
1181
+ doc = open_doc(path)
1182
+ return _with_document_state(
1183
+ find_in_doc(
1184
+ doc,
1185
+ text_to_find=text_to_find,
1186
+ match_case=match_case,
1187
+ max_results=max_results,
1188
+ ),
1189
+ path,
1190
+ )
1191
+
1192
+
1193
+ def get_table_text(filename: str, table_index: int = 0) -> dict:
1194
+ """표 셀 텍스트를 2D 배열로 조회합니다."""
1195
+ path = resolve_path(filename)
1196
+ doc = open_doc(path)
1197
+ result = get_table_data(doc, table_index)
1198
+ return _with_document_state(
1199
+ {
1200
+ "table_index": table_index,
1201
+ "rows": result["rows"],
1202
+ "cols": result["cols"],
1203
+ "data": result["data"],
1204
+ },
1205
+ path,
1206
+ )
1207
+
1208
+
1209
+ def get_table_map(filename: str) -> dict:
1210
+ """문서 내 표 위치, 크기, 문맥 요약을 조회합니다."""
1211
+ path = resolve_path(filename)
1212
+ doc = open_doc(path)
1213
+ return _with_document_state(get_table_map_in_doc(doc), path)
1214
+
1215
+
1216
+ def list_available_documents(directory: str = ".") -> dict:
1217
+ """지정 디렉토리의 .hwpx 파일 목록을 조회합니다."""
1218
+ import glob
1219
+
1220
+ path = resolve_path(directory)
1221
+ files = glob.glob(os.path.join(path, "*.hwpx"))
1222
+ docs = []
1223
+ for file_path in sorted(files):
1224
+ stat = os.stat(file_path)
1225
+ docs.append(
1226
+ {
1227
+ "filename": os.path.basename(file_path),
1228
+ "size": f"{stat.st_size / 1024:.1f}KB",
1229
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
1230
+ }
1231
+ )
1232
+ return {"directory": directory, "documents": docs, "count": len(docs)}
1233
+
1234
+
1235
+ def package_parts(filename: str) -> dict:
1236
+ """[고급] HWPX 패키지 파트 목록을 조회합니다."""
1237
+ path = resolve_path(filename)
1238
+ return RUNTIME_SERVICES.ops.package_parts(path)
1239
+
1240
+
1241
+ def package_get_xml(filename: str, part_name: str, max_chars: int = 5000) -> dict:
1242
+ """[고급] 특정 패키지 파트의 XML을 조회합니다."""
1243
+ path = resolve_path(filename)
1244
+ result = RUNTIME_SERVICES.ops.package_get_xml(path, part_name)
1245
+ return truncate_response(result.get("xmlString", ""), max_chars=max_chars)
1246
+
1247
+
1248
+ def package_get_text(filename: str, part_name: str, max_chars: int = 5000) -> dict:
1249
+ """[고급] 특정 패키지 파트의 텍스트를 조회합니다."""
1250
+ path = resolve_path(filename)
1251
+ result = RUNTIME_SERVICES.ops.package_get_text(path, part_name)
1252
+ return truncate_response(result.get("text", ""), max_chars=max_chars)
1253
+
1254
+
1255
+ def object_find_by_tag(filename: str, tag_name: str, max_results: int = 20) -> dict:
1256
+ """[고급] 문서 XML에서 태그를 검색합니다."""
1257
+ path = resolve_path(filename)
1258
+ return RUNTIME_SERVICES.ops.object_find_by_tag(
1259
+ path, tag_name, max_results=max_results
1260
+ )
1261
+
1262
+
1263
+ def object_find_by_attr(
1264
+ filename: str, attr_name: str, attr_value: str = None, max_results: int = 20
1265
+ ) -> dict:
1266
+ """[고급] 문서 XML에서 속성을 검색합니다."""
1267
+ path = resolve_path(filename)
1268
+ return RUNTIME_SERVICES.ops.object_find_by_attr(
1269
+ path, None, attr_name, attr_value, max_results=max_results
1270
+ )
1271
+
1272
+
1273
+ __all__ = [
1274
+ "get_document_text",
1275
+ "get_document_info",
1276
+ "get_document_outline",
1277
+ "get_document_map",
1278
+ "get_paragraph_text",
1279
+ "get_paragraphs_text",
1280
+ "get_location_text",
1281
+ "get_table_text",
1282
+ "get_table_map",
1283
+ "find_text",
1284
+ "list_available_documents",
1285
+ "hwpx_to_markdown",
1286
+ "document_to_markdown",
1287
+ "hwpx_to_html",
1288
+ "hwpx_extract_json",
1289
+ "document_extract_json",
1290
+ "package_parts",
1291
+ "package_get_text",
1292
+ "package_get_xml",
1293
+ "object_find_by_attr",
1294
+ "object_find_by_tag",
1295
+ ]