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,42 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """MCP Resource 직렬화 모델."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, List
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class ResourceModel(BaseModel):
12
+ model_config = ConfigDict(populate_by_name=True, extra="forbid", frozen=True)
13
+
14
+
15
+ class ParagraphResourceEntry(ResourceModel):
16
+ paragraph_index: int = Field(alias="paragraphIndex")
17
+ text: str
18
+
19
+
20
+ class TableResourceEntry(ResourceModel):
21
+ table_index: int = Field(alias="tableIndex")
22
+ row_count: int = Field(alias="rowCount")
23
+ column_count: int = Field(alias="columnCount")
24
+
25
+
26
+ class DocumentMetadataResource(ResourceModel):
27
+ handle_id: str = Field(alias="handleId")
28
+ locator: Dict[str, Any]
29
+ meta: Dict[str, Any]
30
+ section_count: int = Field(alias="sectionCount")
31
+ paragraph_count: int = Field(alias="paragraphCount")
32
+ header_count: int = Field(alias="headerCount")
33
+
34
+
35
+ class DocumentParagraphsResource(ResourceModel):
36
+ handle_id: str = Field(alias="handleId")
37
+ paragraphs: List[ParagraphResourceEntry]
38
+
39
+
40
+ class DocumentTablesResource(ResourceModel):
41
+ handle_id: str = Field(alias="handleId")
42
+ tables: List[TableResourceEntry]
@@ -0,0 +1,296 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+ from xml.etree import ElementTree as ET
6
+
7
+ from ..upstream import repair_pathological_text_spacing
8
+ from .locations import iter_paragraph_locations, location_anchor
9
+
10
+ _HP_NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
11
+
12
+
13
+ def _replace_within_runs(runs: list[Any], find_text: str, replace_text: str) -> int:
14
+ replaced_total = 0
15
+ for run in runs:
16
+ run_text = run.text or ""
17
+ if not run_text or find_text not in run_text:
18
+ continue
19
+ expected = run_text.count(find_text)
20
+ if expected <= 0:
21
+ continue
22
+
23
+ replaced = 0
24
+ if hasattr(run, "replace_text"):
25
+ try:
26
+ replaced = int(run.replace_text(find_text, replace_text))
27
+ except Exception: # noqa: BLE001
28
+ replaced = 0
29
+
30
+ if replaced <= 0:
31
+ run.text = run_text.replace(find_text, replace_text)
32
+ replaced = expected
33
+
34
+ replaced_total += replaced
35
+ return replaced_total
36
+
37
+
38
+ def _append_xml_text_node(run_element: Any) -> Any:
39
+ maker = getattr(run_element, "makeelement", None)
40
+ if callable(maker):
41
+ node = maker(f"{_HP_NS}t", {})
42
+ run_element.append(node)
43
+ return node
44
+ return ET.SubElement(run_element, f"{_HP_NS}t")
45
+
46
+
47
+ def _xml_run_text(run_element: Any) -> str:
48
+ parts: list[str] = []
49
+ for text_node in run_element.findall(f"{_HP_NS}t"):
50
+ parts.append("".join(text_node.itertext()))
51
+ return "".join(parts)
52
+
53
+
54
+ def _set_xml_run_text(run_element: Any, value: str) -> None:
55
+ text_nodes = list(run_element.findall(f"{_HP_NS}t"))
56
+ primary = text_nodes[0] if text_nodes else _append_xml_text_node(run_element)
57
+ for child in list(primary):
58
+ primary.remove(child)
59
+ primary.text = value
60
+ for node in text_nodes[1:]:
61
+ for child in list(node):
62
+ node.remove(child)
63
+ node.text = ""
64
+
65
+
66
+ def _clear_xml_paragraph_layout_cache(paragraph_element: Any) -> None:
67
+ for child in list(paragraph_element):
68
+ tag = getattr(child, "tag", "")
69
+ local_name = tag.rsplit("}", 1)[-1] if "}" in tag else tag
70
+ if local_name.lower() == "linesegarray":
71
+ paragraph_element.remove(child)
72
+
73
+
74
+ def _replace_across_runs(runs: list[Any], find_text: str, replace_text: str) -> int:
75
+ if not runs or not find_text:
76
+ return 0
77
+
78
+ texts = [run.text or "" for run in runs]
79
+ merged = "".join(texts)
80
+ if find_text not in merged:
81
+ return 0
82
+
83
+ replaced_count = merged.count(find_text)
84
+ new_merged = merged.replace(find_text, replace_text)
85
+ runs[0].text = new_merged
86
+ for run in runs[1:]:
87
+ run.text = ""
88
+
89
+ return replaced_count
90
+
91
+
92
+ def _replace_in_runs(runs: list[Any], find_text: str, replace_text: str) -> int:
93
+ if not runs:
94
+ return 0
95
+
96
+ replaced = _replace_within_runs(runs, find_text, replace_text)
97
+ merged_after_simple = "".join(run.text or "" for run in runs)
98
+ if find_text in merged_after_simple:
99
+ replaced += _replace_across_runs(runs, find_text, replace_text)
100
+ return replaced
101
+
102
+
103
+ def _replace_within_xml_runs(run_elements: list[Any], find_text: str, replace_text: str) -> int:
104
+ replaced_total = 0
105
+ for run_element in run_elements:
106
+ run_text = _xml_run_text(run_element)
107
+ if not run_text or find_text not in run_text:
108
+ continue
109
+ replaced = run_text.count(find_text)
110
+ _set_xml_run_text(run_element, run_text.replace(find_text, replace_text))
111
+ replaced_total += replaced
112
+ return replaced_total
113
+
114
+
115
+ def _replace_across_xml_runs(run_elements: list[Any], find_text: str, replace_text: str) -> int:
116
+ if not run_elements or not find_text:
117
+ return 0
118
+
119
+ texts = [_xml_run_text(run_element) for run_element in run_elements]
120
+ merged = "".join(texts)
121
+ if find_text not in merged:
122
+ return 0
123
+
124
+ replaced_count = merged.count(find_text)
125
+ new_merged = merged.replace(find_text, replace_text)
126
+ _set_xml_run_text(run_elements[0], new_merged)
127
+ for run_element in run_elements[1:]:
128
+ _set_xml_run_text(run_element, "")
129
+
130
+ return replaced_count
131
+
132
+
133
+ def _replace_in_xml_runs(run_elements: list[Any], find_text: str, replace_text: str) -> int:
134
+ if not run_elements:
135
+ return 0
136
+
137
+ replaced = _replace_within_xml_runs(run_elements, find_text, replace_text)
138
+ merged_after_simple = "".join(_xml_run_text(run_element) for run_element in run_elements)
139
+ if find_text in merged_after_simple:
140
+ replaced += _replace_across_xml_runs(run_elements, find_text, replace_text)
141
+ return replaced
142
+
143
+
144
+ def find_in_doc(doc: Any, text_to_find: str, match_case: bool = True, max_results: int = 50) -> dict:
145
+ if text_to_find == "":
146
+ raise ValueError("text_to_find는 빈 문자열일 수 없습니다.")
147
+ matches: list[dict] = []
148
+ needle = text_to_find if match_case else text_to_find.lower()
149
+
150
+ for resolved in iter_paragraph_locations(doc):
151
+ location = resolved.location
152
+ haystack_raw = resolved.paragraph.text or ""
153
+ haystack = haystack_raw if match_case else haystack_raw.lower()
154
+ cursor = 0
155
+ while True:
156
+ pos = haystack.find(needle, cursor)
157
+ if pos < 0:
158
+ break
159
+ context_start = max(0, pos - 20)
160
+ context_end = min(len(haystack_raw), pos + len(text_to_find) + 20)
161
+ match = {
162
+ "location": location,
163
+ "anchor": location_anchor(location, pos),
164
+ "position": pos,
165
+ "context": haystack_raw[context_start:context_end],
166
+ }
167
+ if location.get("kind") == "body_paragraph":
168
+ match["paragraph_index"] = location["paragraph_index"]
169
+ else:
170
+ match["table_index"] = location["table_index"]
171
+ match["row"] = location["row"]
172
+ match["col"] = location["col"]
173
+ match["cell_paragraph_index"] = location["cell_paragraph_index"]
174
+ matches.append(match)
175
+ if len(matches) >= max_results:
176
+ return {"matches": matches, "total_matches": len(matches)}
177
+ cursor = pos + max(1, len(text_to_find))
178
+
179
+ return {"matches": matches, "total_matches": len(matches)}
180
+
181
+
182
+ def replace_in_doc(doc: Any, find_text: str, replace_text: str) -> int:
183
+ if find_text == "":
184
+ raise ValueError("find_text는 빈 문자열일 수 없습니다.")
185
+
186
+ count = 0
187
+ try:
188
+ for para in doc.paragraphs:
189
+ runs = list(getattr(para, "runs", []))
190
+ before_run_texts = [run.text or "" for run in runs]
191
+ replaced = _replace_in_runs(runs, find_text, replace_text) if runs else 0
192
+ has_tables = bool(getattr(para, "tables", []))
193
+ if replaced == 0 and not has_tables:
194
+ text = para.text or ""
195
+ if find_text in text:
196
+ replaced = text.count(find_text)
197
+ para.text = text.replace(find_text, replace_text)
198
+ if replaced:
199
+ current_runs = list(getattr(para, "runs", []))
200
+ changed_runs = [
201
+ run
202
+ for index, run in enumerate(current_runs)
203
+ if (run.text or "")
204
+ and (index >= len(before_run_texts) or (run.text or "") != before_run_texts[index])
205
+ ]
206
+ repair_pathological_text_spacing(doc, paragraph=para, runs=changed_runs)
207
+ count += replaced
208
+
209
+ for para in doc.paragraphs:
210
+ for table in getattr(para, "tables", []):
211
+ for row in table.rows:
212
+ for cell in row.cells:
213
+ cell_paragraphs = list(getattr(cell, "paragraphs", []))
214
+ if cell_paragraphs:
215
+ cell_replaced = 0
216
+ for cell_para in cell_paragraphs:
217
+ runs = list(getattr(cell_para, "runs", []))
218
+ before_run_texts = [run.text or "" for run in runs]
219
+ para_replaced = _replace_in_runs(runs, find_text, replace_text) if runs else 0
220
+ if para_replaced == 0:
221
+ text = cell_para.text or ""
222
+ if find_text in text:
223
+ para_replaced = text.count(find_text)
224
+ cell_para.text = text.replace(find_text, replace_text)
225
+ if para_replaced:
226
+ current_runs = list(getattr(cell_para, "runs", []))
227
+ changed_runs = [
228
+ run
229
+ for index, run in enumerate(current_runs)
230
+ if (run.text or "")
231
+ and (
232
+ index >= len(before_run_texts)
233
+ or (run.text or "") != before_run_texts[index]
234
+ )
235
+ ]
236
+ repair_pathological_text_spacing(
237
+ doc,
238
+ paragraph=cell_para,
239
+ runs=changed_runs,
240
+ )
241
+ cell_replaced += para_replaced
242
+ count += cell_replaced
243
+ continue
244
+
245
+ xml_replaced = 0
246
+ cell_element = getattr(cell, "element", None)
247
+ if cell_element is not None:
248
+ for cell_para_element in cell_element.findall(f".//{_HP_NS}p"):
249
+ run_elements = list(cell_para_element.findall(f"{_HP_NS}run"))
250
+ if not run_elements:
251
+ continue
252
+ para_replaced = _replace_in_xml_runs(run_elements, find_text, replace_text)
253
+ if para_replaced:
254
+ repair_pathological_text_spacing(
255
+ doc,
256
+ runs=[
257
+ run
258
+ for run in run_elements
259
+ if _xml_run_text(run)
260
+ ],
261
+ )
262
+ _clear_xml_paragraph_layout_cache(cell_para_element)
263
+ xml_replaced += para_replaced
264
+ if xml_replaced:
265
+ count += xml_replaced
266
+ section = getattr(para, "section", None)
267
+ if section is not None and hasattr(section, "mark_dirty"):
268
+ section.mark_dirty()
269
+ continue
270
+
271
+ text = cell.text or ""
272
+ if find_text in text:
273
+ replaced = text.count(find_text)
274
+ cell.text = text.replace(find_text, replace_text)
275
+ count += replaced
276
+ except Exception as e: # noqa: BLE001
277
+ raise RuntimeError(f"텍스트 치환 중 오류: {e}") from e
278
+ return count
279
+
280
+
281
+ def batch_replace_in_doc(doc: Any, replacements: list[dict]) -> dict:
282
+ results: list[dict] = []
283
+ total = 0
284
+ for index, item in enumerate(replacements):
285
+ if not isinstance(item, dict):
286
+ raise ValueError(f"replacements[{index}]는 dict여야 합니다.")
287
+
288
+ found = str(item.get("find", ""))
289
+ repl = str(item.get("replace", ""))
290
+ if found == "":
291
+ raise ValueError(f"replacements[{index}].find는 빈 문자열일 수 없습니다.")
292
+
293
+ replaced = replace_in_doc(doc, found, repl)
294
+ total += replaced
295
+ results.append({"find": found, "replace": repl, "replaced_count": replaced})
296
+ return {"results": results, "total_replaced": total}