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,601 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Optional, Sequence, cast
9
+
10
+ from ..core.resources import (
11
+ DocumentMetadataResource,
12
+ DocumentParagraphsResource,
13
+ DocumentTablesResource,
14
+ ParagraphResourceEntry,
15
+ TableResourceEntry,
16
+ )
17
+ from ..upstream import (
18
+ AnnotationOptions,
19
+ create_object_finder,
20
+ create_text_extractor,
21
+ )
22
+
23
+ from .context import DocumentContext
24
+
25
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
26
+
27
+
28
+ class ReadQueryService:
29
+ def __init__(self, context: DocumentContext) -> None:
30
+ self._context = context
31
+
32
+ def get_metadata_by_handle(self, handle_id: str) -> Dict[str, Any]:
33
+ handle = self._context.get_registered_handle(handle_id)
34
+ payload = self.open_info(handle.path)
35
+ model = DocumentMetadataResource(
36
+ handleId=handle.handle_id,
37
+ locator=handle.model_dump(by_alias=True),
38
+ meta=payload["meta"],
39
+ sectionCount=payload["sectionCount"],
40
+ paragraphCount=payload["paragraphCount"],
41
+ headerCount=payload["headerCount"],
42
+ )
43
+ return model.model_dump(by_alias=True)
44
+
45
+ def get_paragraphs_by_handle(self, handle_id: str) -> Dict[str, Any]:
46
+ handle = self._context.get_registered_handle(handle_id)
47
+ resolved = self._context._resolve_path(handle.path)
48
+ if resolved.suffix.lower() == ".hwp":
49
+ paragraphs, _, _ = self._context._read_only_hwp_paragraphs(handle.path)
50
+ hwp_serialized = [
51
+ ParagraphResourceEntry(paragraphIndex=index, text=text)
52
+ for index, text in enumerate(paragraphs)
53
+ ]
54
+ model = DocumentParagraphsResource(
55
+ handleId=handle.handle_id, paragraphs=hwp_serialized
56
+ )
57
+ return model.model_dump(by_alias=True)
58
+
59
+ serialized: List[ParagraphResourceEntry] = []
60
+ with create_text_extractor(resolved) as extractor:
61
+ for paragraph in extractor.iter_document_paragraphs():
62
+ serialized.append(
63
+ ParagraphResourceEntry(
64
+ paragraphIndex=paragraph.index,
65
+ text=paragraph.text(preserve_breaks=True),
66
+ )
67
+ )
68
+ model = DocumentParagraphsResource(
69
+ handleId=handle.handle_id, paragraphs=serialized
70
+ )
71
+ return model.model_dump(by_alias=True)
72
+
73
+ def get_tables_by_handle(self, handle_id: str) -> Dict[str, Any]:
74
+ handle = self._context.get_registered_handle(handle_id)
75
+ resolved = self._context._resolve_path(handle.path)
76
+ if resolved.suffix.lower() == ".hwp":
77
+ model = DocumentTablesResource(handleId=handle.handle_id, tables=[])
78
+ return model.model_dump(by_alias=True)
79
+
80
+ document, _ = self._context._open_document(handle.path)
81
+ tables = self._context._iter_tables(document)
82
+ serialized = [
83
+ TableResourceEntry(
84
+ tableIndex=index,
85
+ rowCount=len(table.rows),
86
+ columnCount=table.column_count,
87
+ )
88
+ for index, table in enumerate(tables)
89
+ ]
90
+ model = DocumentTablesResource(handleId=handle.handle_id, tables=serialized)
91
+ return model.model_dump(by_alias=True)
92
+
93
+ def open_info(self, path: str) -> Dict[str, Any]:
94
+ resolved = self._context._resolve_path(path)
95
+ if resolved.suffix.lower() == ".hwp":
96
+ paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
97
+ stat = resolved.stat()
98
+ meta = {
99
+ "path": self._context._relative_path(resolved),
100
+ "absolutePath": str(resolved),
101
+ "size": stat.st_size,
102
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
103
+ "format": "hwp",
104
+ "readOnly": True,
105
+ "extractionSource": source,
106
+ }
107
+ return {
108
+ "meta": meta,
109
+ "sectionCount": 0,
110
+ "paragraphCount": len(paragraphs),
111
+ "headerCount": 0,
112
+ }
113
+
114
+ document, resolved = self._context._open_document(path)
115
+ sections = document.sections
116
+ section_count = len(sections)
117
+ paragraph_count = sum(len(section.paragraphs) for section in sections)
118
+ header_count = len(document.headers)
119
+ stat = resolved.stat()
120
+ meta = {
121
+ "path": self._context._relative_path(resolved),
122
+ "absolutePath": str(resolved),
123
+ "size": stat.st_size,
124
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
125
+ }
126
+ return {
127
+ "meta": meta,
128
+ "sectionCount": section_count,
129
+ "paragraphCount": paragraph_count,
130
+ "headerCount": header_count,
131
+ }
132
+
133
+ def list_sections(self, path: str) -> Dict[str, Any]:
134
+ document, _ = self._context._open_document(path)
135
+ sections: List[Dict[str, Any]] = []
136
+ for index, section in enumerate(document.sections):
137
+ sections.append(
138
+ {
139
+ "index": index,
140
+ "paragraphCount": len(section.paragraphs),
141
+ "partName": getattr(section, "part_name", None),
142
+ }
143
+ )
144
+ return {"sections": sections}
145
+
146
+ def list_headers(self, path: str) -> Dict[str, Any]:
147
+ document, _ = self._context._open_document(path)
148
+ headers: List[Dict[str, Any]] = []
149
+ has_master_page = bool(document.master_pages)
150
+ for index, header in enumerate(document.headers):
151
+ headers.append(
152
+ {
153
+ "index": index,
154
+ "styleCount": len(header.styles),
155
+ "bulletCount": len(header.bullets),
156
+ "hasMasterPage": has_master_page,
157
+ "partName": getattr(header, "part_name", None),
158
+ }
159
+ )
160
+ return {"headers": headers}
161
+
162
+ def read_text(
163
+ self,
164
+ path: str,
165
+ *,
166
+ offset: int = 0,
167
+ limit: Optional[int] = None,
168
+ with_highlights: bool = False,
169
+ with_footnotes: bool = False,
170
+ ) -> Dict[str, Any]:
171
+ resolved = self._context._resolve_path(path)
172
+ if resolved.suffix.lower() == ".hwp":
173
+ hwp_paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
174
+ effective_limit = (
175
+ self._context.paging_limit if limit is None else max(1, limit)
176
+ )
177
+ start = max(0, offset)
178
+ chunk = hwp_paragraphs[start : start + effective_limit]
179
+ hwp_next_offset = None
180
+ if start + effective_limit < len(hwp_paragraphs):
181
+ hwp_next_offset = start + effective_limit
182
+ return {"textChunk": "\n".join(chunk), "nextOffset": hwp_next_offset}
183
+
184
+ if limit is None:
185
+ effective_limit = self._context.paging_limit
186
+ else:
187
+ effective_limit = max(1, limit)
188
+ annotations = None
189
+ if with_highlights or with_footnotes:
190
+ annotations = AnnotationOptions(
191
+ highlight="markers" if with_highlights else "ignore",
192
+ footnote="inline" if with_footnotes else "ignore",
193
+ endnote="inline" if with_footnotes else "ignore",
194
+ )
195
+ paragraphs: List[str] = []
196
+ next_offset: Optional[int] = None
197
+ start = max(0, offset)
198
+ with create_text_extractor(resolved) as extractor:
199
+ paragraph_iter = extractor.iter_document_paragraphs()
200
+ sentinel = object()
201
+
202
+ skip_exhausted = False
203
+ for _ in range(start):
204
+ if next(paragraph_iter, sentinel) is sentinel:
205
+ skip_exhausted = True
206
+ break
207
+
208
+ if not skip_exhausted:
209
+ while len(paragraphs) < effective_limit:
210
+ paragraph = next(paragraph_iter, sentinel)
211
+ if paragraph is sentinel:
212
+ break
213
+ paragraphs.append(
214
+ cast(Any, paragraph).text(annotations=annotations, preserve_breaks=True)
215
+ )
216
+
217
+ if len(paragraphs) == effective_limit:
218
+ if next(paragraph_iter, sentinel) is not sentinel:
219
+ next_offset = start + len(paragraphs)
220
+
221
+ return {"textChunk": "\n".join(paragraphs), "nextOffset": next_offset}
222
+
223
+ def get_paragraphs(
224
+ self,
225
+ path: str,
226
+ paragraph_indexes: Sequence[int],
227
+ *,
228
+ with_highlights: bool = False,
229
+ with_footnotes: bool = False,
230
+ ) -> Dict[str, Any]:
231
+ if not paragraph_indexes:
232
+ return {"paragraphs": []}
233
+ normalized_indexes: List[int] = []
234
+ unique_indexes: set[int] = set()
235
+ for index in paragraph_indexes:
236
+ if index < 0:
237
+ raise ValueError("paragraphIndexes must contain non-negative integers")
238
+ normalized_indexes.append(int(index))
239
+ unique_indexes.add(int(index))
240
+
241
+ resolved = self._context._resolve_path(path)
242
+ if resolved.suffix.lower() == ".hwp":
243
+ paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
244
+ hwp_collected = {
245
+ idx: paragraphs[idx] for idx in unique_indexes if idx < len(paragraphs)
246
+ }
247
+ missing = [index for index in normalized_indexes if index not in hwp_collected]
248
+ if missing:
249
+ raise ValueError(
250
+ "paragraphIndexes out of range: "
251
+ + ", ".join(str(idx) for idx in sorted(set(missing)))
252
+ )
253
+ return {
254
+ "paragraphs": [
255
+ {"paragraphIndex": index, "text": hwp_collected[index]}
256
+ for index in normalized_indexes
257
+ ]
258
+ }
259
+
260
+ annotations = None
261
+ if with_highlights or with_footnotes:
262
+ annotations = AnnotationOptions(
263
+ highlight="markers" if with_highlights else "ignore",
264
+ footnote="inline" if with_footnotes else "ignore",
265
+ endnote="inline" if with_footnotes else "ignore",
266
+ )
267
+
268
+ collected: Dict[int, str] = {}
269
+ with create_text_extractor(resolved) as extractor:
270
+ for paragraph in extractor.iter_document_paragraphs():
271
+ para_index = paragraph.index
272
+ if para_index in unique_indexes and para_index not in collected:
273
+ collected[para_index] = paragraph.text(
274
+ annotations=annotations, preserve_breaks=True
275
+ )
276
+ if len(collected) == len(unique_indexes):
277
+ break
278
+
279
+ missing = [index for index in normalized_indexes if index not in collected]
280
+ if missing:
281
+ raise ValueError(
282
+ "paragraphIndexes out of range: "
283
+ + ", ".join(str(idx) for idx in sorted(set(missing)))
284
+ )
285
+
286
+ return {
287
+ "paragraphs": [
288
+ {"paragraphIndex": index, "text": collected[index]}
289
+ for index in normalized_indexes
290
+ ]
291
+ }
292
+
293
+ def text_extract_report(self, path: str, mode: str = "plain") -> Dict[str, Any]:
294
+ resolved = self._context._resolve_path(path)
295
+ if resolved.suffix.lower() == ".hwp":
296
+ paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
297
+ return {
298
+ "content": "\n".join(paragraphs)
299
+ + f"\n\n[HWP read-only mode] extraction_source={source}; annotations/structure are unavailable."
300
+ }
301
+
302
+ annotations = None
303
+ if mode == "with_annotations":
304
+ annotations = AnnotationOptions(
305
+ highlight="markers",
306
+ footnote="inline",
307
+ endnote="inline",
308
+ control="placeholder",
309
+ )
310
+ with create_text_extractor(resolved) as extractor:
311
+ content = extractor.extract_text(
312
+ annotations=annotations,
313
+ include_nested=True,
314
+ )
315
+ return {"content": content}
316
+
317
+ def analyze_template_structure(
318
+ self,
319
+ path: str,
320
+ *,
321
+ placeholder_patterns: Optional[Sequence[str]] = None,
322
+ lock_keywords: Optional[Sequence[str]] = None,
323
+ ) -> Dict[str, Any]:
324
+ resolved = self._context._resolve_path(path)
325
+ if resolved.suffix.lower() == ".hwp":
326
+ paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
327
+ else:
328
+ paragraphs = []
329
+ with create_text_extractor(resolved) as extractor:
330
+ for paragraph in extractor.iter_document_paragraphs():
331
+ paragraphs.append(paragraph.text(preserve_breaks=True))
332
+ source = "hwpx.text_extractor"
333
+
334
+ paragraph_count = len(paragraphs)
335
+ if paragraph_count == 0:
336
+ return {
337
+ "summary": {
338
+ "isTemplate": False,
339
+ "paragraphCount": 0,
340
+ "placeholderCount": 0,
341
+ "extractionSource": source,
342
+ },
343
+ "regions": [],
344
+ "placeholders": [],
345
+ }
346
+
347
+ top_band = max(1, min(3, max(1, paragraph_count // 10)))
348
+ bottom_band = max(1, min(3, max(1, paragraph_count // 10)))
349
+ if top_band + bottom_band > paragraph_count:
350
+ bottom_band = max(1, paragraph_count - top_band)
351
+
352
+ header_range = (0, max(0, top_band - 1))
353
+ body_range = (top_band, max(top_band, paragraph_count - bottom_band - 1))
354
+ footer_range = (max(0, paragraph_count - bottom_band), paragraph_count - 1)
355
+
356
+ default_placeholder_patterns = [
357
+ r"\{\{[^{}]+\}\}",
358
+ r"\[[^\[\]]*(입력|작성|기재)[^\[\]]*\]",
359
+ r"(본문 영역|제목을 입력하세요|날짜를 입력하세요|제20\d{2}년)",
360
+ ]
361
+ default_lock_keywords = [
362
+ "로고",
363
+ "교훈",
364
+ "연락처",
365
+ "슬로건",
366
+ "학교장",
367
+ "직인",
368
+ ]
369
+
370
+ compiled_patterns = [
371
+ re.compile(pattern)
372
+ for pattern in (placeholder_patterns or default_placeholder_patterns)
373
+ ]
374
+ lock_terms = [
375
+ term.strip()
376
+ for term in (lock_keywords or default_lock_keywords)
377
+ if term and term.strip()
378
+ ]
379
+
380
+ def paragraph_zone(index: int) -> str:
381
+ if header_range[0] <= index <= header_range[1]:
382
+ return "header"
383
+ if footer_range[0] <= index <= footer_range[1]:
384
+ return "footer"
385
+ return "body"
386
+
387
+ placeholders: List[Dict[str, Any]] = []
388
+ locked_indexes: set[int] = set()
389
+ for index, text in enumerate(paragraphs):
390
+ stripped = text.strip()
391
+ if not stripped:
392
+ continue
393
+
394
+ zone = paragraph_zone(index)
395
+ contains_lock_keyword = any(keyword in stripped for keyword in lock_terms)
396
+ if zone in {"header", "footer"} or contains_lock_keyword:
397
+ locked_indexes.add(index)
398
+
399
+ for pattern in compiled_patterns:
400
+ for match in pattern.finditer(stripped):
401
+ token = match.group(0)
402
+ placeholders.append(
403
+ {
404
+ "token": token,
405
+ "paragraphIndex": index,
406
+ "zone": zone,
407
+ "editable": index not in locked_indexes,
408
+ "context": stripped[:200],
409
+ }
410
+ )
411
+
412
+ is_template = bool(placeholders) or any(
413
+ index in locked_indexes for index in range(paragraph_count)
414
+ )
415
+ regions = [
416
+ {
417
+ "name": "header",
418
+ "startParagraph": header_range[0],
419
+ "endParagraph": header_range[1],
420
+ "editable": False,
421
+ "reason": "상단 고정 영역(휴리스틱)",
422
+ },
423
+ {
424
+ "name": "body",
425
+ "startParagraph": body_range[0],
426
+ "endParagraph": body_range[1],
427
+ "editable": True,
428
+ "reason": "본문 편집 가능 영역(휴리스틱)",
429
+ },
430
+ {
431
+ "name": "footer",
432
+ "startParagraph": footer_range[0],
433
+ "endParagraph": footer_range[1],
434
+ "editable": False,
435
+ "reason": "하단 고정 영역(휴리스틱)",
436
+ },
437
+ ]
438
+
439
+ return {
440
+ "summary": {
441
+ "isTemplate": is_template,
442
+ "paragraphCount": paragraph_count,
443
+ "placeholderCount": len(placeholders),
444
+ "lockedParagraphCount": len(locked_indexes),
445
+ "extractionSource": source,
446
+ },
447
+ "regions": regions,
448
+ "placeholders": placeholders,
449
+ }
450
+
451
+ def find(
452
+ self,
453
+ path: str,
454
+ query: str,
455
+ *,
456
+ is_regex: bool = False,
457
+ max_results: int = 100,
458
+ context_radius: int = 80,
459
+ ) -> Dict[str, Any]:
460
+ if not query:
461
+ raise ValueError("query must be a non-empty string")
462
+ resolved = self._context._resolve_path(path)
463
+ matches: List[Dict[str, Any]] = []
464
+ radius = max(0, context_radius)
465
+
466
+ def build_context(text: str, start: int, end: int) -> str:
467
+ context_start = max(0, start - radius)
468
+ context_end = min(len(text), end + radius)
469
+ snippet = text[context_start:context_end]
470
+ if context_start > 0:
471
+ snippet = "..." + snippet
472
+ if context_end < len(text):
473
+ snippet = snippet + "..."
474
+ return snippet
475
+
476
+ pattern = re.compile(query) if is_regex else None
477
+ if resolved.suffix.lower() == ".hwp":
478
+ paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
479
+ for para_index, text in enumerate(paragraphs):
480
+ if is_regex:
481
+ for match in pattern.finditer(text): # type: ignore[union-attr]
482
+ matches.append(
483
+ {
484
+ "paragraphIndex": para_index,
485
+ "start": match.start(),
486
+ "end": match.end(),
487
+ "context": build_context(
488
+ text, match.start(), match.end()
489
+ ),
490
+ }
491
+ )
492
+ if len(matches) >= max_results:
493
+ return {"matches": matches}
494
+ else:
495
+ start = 0
496
+ while True:
497
+ found = text.find(query, start)
498
+ if found == -1:
499
+ break
500
+ matches.append(
501
+ {
502
+ "paragraphIndex": para_index,
503
+ "start": found,
504
+ "end": found + len(query),
505
+ "context": build_context(
506
+ text, found, found + len(query)
507
+ ),
508
+ }
509
+ )
510
+ if len(matches) >= max_results:
511
+ return {"matches": matches}
512
+ start = found + len(query)
513
+ return {"matches": matches}
514
+
515
+ with create_text_extractor(resolved) as extractor:
516
+ for paragraph in extractor.iter_document_paragraphs():
517
+ text = paragraph.text()
518
+ if is_regex:
519
+ for match in pattern.finditer(text): # type: ignore[union-attr]
520
+ matches.append(
521
+ {
522
+ "paragraphIndex": paragraph.index,
523
+ "start": match.start(),
524
+ "end": match.end(),
525
+ "context": build_context(
526
+ text, match.start(), match.end()
527
+ ),
528
+ }
529
+ )
530
+ if len(matches) >= max_results:
531
+ return {"matches": matches}
532
+ else:
533
+ start = 0
534
+ while True:
535
+ found = text.find(query, start)
536
+ if found == -1:
537
+ break
538
+ matches.append(
539
+ {
540
+ "paragraphIndex": paragraph.index,
541
+ "start": found,
542
+ "end": found + len(query),
543
+ "context": build_context(
544
+ text, found, found + len(query)
545
+ ),
546
+ }
547
+ )
548
+ if len(matches) >= max_results:
549
+ return {"matches": matches}
550
+ start = found + len(query)
551
+ return {"matches": matches}
552
+
553
+ def object_find_by_tag(
554
+ self,
555
+ path: str,
556
+ tag_name: str,
557
+ *,
558
+ max_results: int = 200,
559
+ ) -> Dict[str, Any]:
560
+ resolved = self._context._resolve_path(path)
561
+ finder = create_object_finder(resolved)
562
+ objects = []
563
+ for found in finder.iter(tag=tag_name, limit=max_results):
564
+ element = found.element
565
+ objects.append(
566
+ {
567
+ "type": element.tag,
568
+ "text": element.text or "",
569
+ "attrs": dict(element.attrib),
570
+ "path": found.path,
571
+ }
572
+ )
573
+ return {"objects": objects}
574
+
575
+ def object_find_by_attr(
576
+ self,
577
+ path: str,
578
+ element_type: str | None,
579
+ attr: str,
580
+ value: str | None,
581
+ *,
582
+ max_results: int = 200,
583
+ ) -> Dict[str, Any]:
584
+ resolved = self._context._resolve_path(path)
585
+ finder = create_object_finder(resolved)
586
+ tag_filter = None if element_type in {None, "", "*"} else element_type
587
+ attr_matcher: Any = value if value is not None else (lambda _: True)
588
+ objects = []
589
+ for found in finder.iter(
590
+ tag=tag_filter, attrs={attr: attr_matcher}, limit=max_results
591
+ ):
592
+ element = found.element
593
+ objects.append(
594
+ {
595
+ "type": element.tag,
596
+ "text": element.text or "",
597
+ "attrs": dict(element.attrib),
598
+ "path": found.path,
599
+ }
600
+ )
601
+ return {"objects": objects}