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,1078 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Content-edit automation handlers for the optional MCP adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from .. import quality as quality_contract
9
+ from ..core.content import (
10
+ add_heading_to_doc,
11
+ add_page_break_to_doc,
12
+ add_paragraph_to_doc,
13
+ add_table_to_doc,
14
+ delete_paragraph_from_doc,
15
+ fill_by_path_in_doc,
16
+ format_table_in_doc,
17
+ insert_paragraph_to_doc,
18
+ merge_cells_in_table,
19
+ set_cell_text,
20
+ split_cell_in_table,
21
+ )
22
+ from ..core.document import open_doc
23
+ from ..core.locations import location_from_anchor, resolve_paragraph_reference
24
+ from ..core.search import _replace_in_runs, batch_replace_in_doc, replace_in_doc
25
+ from ..mutation_models import (
26
+ EditOperation,
27
+ operation_payloads,
28
+ )
29
+ from ..office.utilities import table_compute as build_hwpx_table_compute
30
+ from ..runtime_services import RUNTIME_SERVICES
31
+ from ..upstream import (
32
+ repair_pathological_text_spacing,
33
+ )
34
+ from ..utils.helpers import resolve_path
35
+ from ._shared import (
36
+ _decode_image_base64,
37
+ _id_integrity_payload,
38
+ _idempotency_fingerprint,
39
+ _idempotency_replay,
40
+ _idempotency_scope,
41
+ _idempotency_store,
42
+ _normalize_fill_mappings,
43
+ _revision_guard,
44
+ _save_doc_verification,
45
+ _with_dry_run_verification,
46
+ _with_save_verification,
47
+ )
48
+
49
+
50
+ def table_compute(
51
+ table: dict | list,
52
+ value_columns: list = None,
53
+ operations: list = None,
54
+ append: str = "rows",
55
+ group_by: str | int = None, # type: ignore[assignment] # Frozen ToolSpec default.
56
+ label_column: str | int = None, # type: ignore[assignment] # Frozen ToolSpec default.
57
+ labels: dict = None,
58
+ ) -> dict:
59
+ """일반 표에 합계·평균·소계 행/열을 추가하고 계산 근거를 반환합니다."""
60
+ if build_hwpx_table_compute is None:
61
+ raise RuntimeError("installed python-hwpx does not provide table compute tools")
62
+ return build_hwpx_table_compute(
63
+ table,
64
+ value_columns=value_columns,
65
+ operations=operations,
66
+ append=append,
67
+ group_by=group_by,
68
+ label_column=label_column,
69
+ labels=labels,
70
+ )
71
+
72
+
73
+ def _operation_value(
74
+ operation: dict[str, Any], *names: str, default: Any = None
75
+ ) -> Any:
76
+ for name in names:
77
+ if name in operation:
78
+ return operation[name]
79
+ return default
80
+
81
+
82
+ def _apply_edit_operation(
83
+ doc: Any, operation: dict[str, Any], index: int
84
+ ) -> dict[str, Any]:
85
+ if not isinstance(operation, dict):
86
+ raise TypeError(f"operation {index} must be an object")
87
+ raw_type = _operation_value(operation, "type", "op", "operation")
88
+ if not isinstance(raw_type, str) or not raw_type.strip():
89
+ raise ValueError(f"operation {index} must include a type")
90
+ op_type = raw_type.strip().replace("-", "_")
91
+
92
+ if op_type == "replace_text":
93
+ find = _operation_value(operation, "findText", "find_text", "find")
94
+ replace = _operation_value(
95
+ operation, "replaceText", "replace_text", "replace", default=""
96
+ )
97
+ if find is None:
98
+ raise ValueError("replace_text requires findText")
99
+ count = replace_in_doc(doc, find_text=str(find), replace_text=str(replace))
100
+ return {"type": op_type, "replaced_count": count}
101
+
102
+ if op_type == "batch_replace":
103
+ replacements = _operation_value(operation, "replacements")
104
+ if not isinstance(replacements, list):
105
+ raise ValueError("batch_replace requires a replacements list")
106
+ result = batch_replace_in_doc(doc, replacements)
107
+ return {"type": op_type, **result}
108
+
109
+ if op_type == "add_heading":
110
+ text = _operation_value(operation, "text", default="")
111
+ level = int(_operation_value(operation, "level", default=1))
112
+ paragraph_index = add_heading_to_doc(doc, str(text), level)
113
+ return {"type": op_type, "paragraph_index": paragraph_index}
114
+
115
+ if op_type == "add_paragraph":
116
+ text = _operation_value(operation, "text", default="")
117
+ style = _operation_value(operation, "style")
118
+ paragraph_index = add_paragraph_to_doc(doc, str(text), style)
119
+ return {"type": op_type, "paragraph_index": paragraph_index}
120
+
121
+ if op_type == "insert_paragraph":
122
+ paragraph_index = _operation_value(
123
+ operation, "paragraphIndex", "paragraph_index"
124
+ )
125
+ if paragraph_index is None:
126
+ raise ValueError("insert_paragraph requires paragraphIndex")
127
+ text = _operation_value(operation, "text", default="")
128
+ style = _operation_value(operation, "style")
129
+ inserted = insert_paragraph_to_doc(doc, int(paragraph_index), str(text), style)
130
+ return {"type": op_type, "inserted_index": inserted}
131
+
132
+ if op_type == "delete_paragraph":
133
+ paragraph_index = _operation_value(
134
+ operation, "paragraphIndex", "paragraph_index"
135
+ )
136
+ if paragraph_index is None:
137
+ raise ValueError("delete_paragraph requires paragraphIndex")
138
+ remaining = delete_paragraph_from_doc(doc, int(paragraph_index))
139
+ return {
140
+ "type": op_type,
141
+ "deleted_index": int(paragraph_index),
142
+ "remaining_paragraphs": remaining,
143
+ }
144
+
145
+ if op_type == "add_table":
146
+ rows = _operation_value(operation, "rows")
147
+ cols = _operation_value(operation, "cols", "columns")
148
+ if rows is None or cols is None:
149
+ raise ValueError("add_table requires rows and cols")
150
+ data = _operation_value(operation, "data")
151
+ table_index = add_table_to_doc(doc, int(rows), int(cols), data)
152
+ return {"type": op_type, "table_index": table_index}
153
+
154
+ if op_type == "set_table_cell_text":
155
+ table_index = _operation_value(
156
+ operation, "tableIndex", "table_index", default=0
157
+ )
158
+ row = _operation_value(operation, "row")
159
+ col = _operation_value(operation, "col", "column")
160
+ text = _operation_value(operation, "text", default="")
161
+ if row is None or col is None:
162
+ raise ValueError("set_table_cell_text requires row and col")
163
+ preserve_format = bool(
164
+ _operation_value(
165
+ operation, "preserveFormat", "preserve_format", default=True
166
+ )
167
+ )
168
+ split_paragraphs = bool(
169
+ _operation_value(
170
+ operation, "splitParagraphs", "split_paragraphs", default=False
171
+ )
172
+ )
173
+ set_cell_text(
174
+ doc,
175
+ int(table_index),
176
+ int(row),
177
+ int(col),
178
+ str(text),
179
+ preserve_format=preserve_format,
180
+ split_paragraphs=split_paragraphs,
181
+ )
182
+ return {
183
+ "type": op_type,
184
+ "table_index": int(table_index),
185
+ "row": int(row),
186
+ "col": int(col),
187
+ }
188
+
189
+ if op_type == "fill_by_path":
190
+ mappings = _operation_value(operation, "mappings")
191
+ if not isinstance(mappings, dict):
192
+ raise ValueError("fill_by_path requires mappings")
193
+ result = fill_by_path_in_doc(doc, _normalize_fill_mappings(mappings))
194
+ return {"type": op_type, **result}
195
+
196
+ if op_type == "add_page_break":
197
+ add_page_break_to_doc(doc)
198
+ return {"type": op_type, "success": True}
199
+
200
+ raise ValueError(f"unsupported operation type: {raw_type}")
201
+
202
+
203
+ def search_and_replace(
204
+ filename: str,
205
+ find_text: str,
206
+ replace_text: str,
207
+ dry_run: bool = False,
208
+ expected_revision: str = None,
209
+ idempotency_key: str = None,
210
+ ) -> dict:
211
+ """문서에서 텍스트를 치환합니다. dry_run=True이면 원본을 저장하지 않습니다."""
212
+ path = resolve_path(filename)
213
+ scope = _idempotency_scope("search_and_replace", path, idempotency_key)
214
+ fingerprint = _idempotency_fingerprint(
215
+ {
216
+ "filename": filename,
217
+ "find_text": find_text,
218
+ "replace_text": replace_text,
219
+ "dry_run": dry_run,
220
+ "expected_revision": expected_revision,
221
+ }
222
+ )
223
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
224
+ if replay is not None:
225
+ return replay
226
+ guard = _revision_guard(path, expected_revision)
227
+ if guard is not None:
228
+ return guard
229
+ doc = open_doc(path)
230
+ replaced_count = replace_in_doc(doc, find_text=find_text, replace_text=replace_text)
231
+ result = {
232
+ "replaced_count": replaced_count,
233
+ "find_text": find_text,
234
+ "replace_text": replace_text,
235
+ }
236
+ if dry_run:
237
+ return _idempotency_store(
238
+ scope,
239
+ fingerprint=fingerprint,
240
+ payload=_with_dry_run_verification(result, doc, path),
241
+ )
242
+ verification = _save_doc_verification(doc, path)
243
+ return _idempotency_store(
244
+ scope,
245
+ fingerprint=fingerprint,
246
+ payload=_with_save_verification(result, verification),
247
+ )
248
+
249
+
250
+ def batch_replace(
251
+ filename: str,
252
+ replacements: list[dict[str, str]],
253
+ dry_run: bool = False,
254
+ expected_revision: str = None,
255
+ idempotency_key: str = None,
256
+ ) -> dict:
257
+ """여러 치환 규칙을 순서대로 적용합니다. dry_run=True이면 원본을 저장하지 않습니다."""
258
+ path = resolve_path(filename)
259
+ scope = _idempotency_scope("batch_replace", path, idempotency_key)
260
+ fingerprint = _idempotency_fingerprint(
261
+ {
262
+ "filename": filename,
263
+ "replacements": replacements,
264
+ "dry_run": dry_run,
265
+ "expected_revision": expected_revision,
266
+ }
267
+ )
268
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
269
+ if replay is not None:
270
+ return replay
271
+ guard = _revision_guard(path, expected_revision)
272
+ if guard is not None:
273
+ return guard
274
+ doc = open_doc(path)
275
+ result = batch_replace_in_doc(doc, replacements)
276
+ if dry_run:
277
+ return _idempotency_store(
278
+ scope,
279
+ fingerprint=fingerprint,
280
+ payload=_with_dry_run_verification(result, doc, path),
281
+ )
282
+ verification = _save_doc_verification(doc, path)
283
+ return _idempotency_store(
284
+ scope,
285
+ fingerprint=fingerprint,
286
+ payload=_with_save_verification(result, verification),
287
+ )
288
+
289
+
290
+ def apply_edits(
291
+ filename: str,
292
+ operations: list[EditOperation],
293
+ dry_run: bool = False,
294
+ expected_revision: str = None,
295
+ idempotency_key: str = None,
296
+ quality: dict[str, Any] | str | None = None,
297
+ ) -> dict:
298
+ """여러 편집 operation을 원자적으로 적용합니다. 실패 시 원본 파일은 변경하지 않습니다.
299
+
300
+ ``quality``는 저장 게이트 정책입니다(생략 시 transparent = 열림안전만). ``"strict"``
301
+ 또는 ``{"mode":"strict","overflowPolicy":"fail","layoutLint":"strict"}`` 처럼 올리면
302
+ SavePipeline이 FormFit/레이아웃/시각 게이트를 적용하고, 실패 시 저장을 보류하며
303
+ ``visualComplete`` 블록과 구조화된 오류 코드를 반환합니다.
304
+ """
305
+ operations_payload = operation_payloads(operations)
306
+ path = resolve_path(filename)
307
+ scope = _idempotency_scope("apply_edits", path, idempotency_key)
308
+ fingerprint = _idempotency_fingerprint(
309
+ {
310
+ "filename": filename,
311
+ "operations": operations_payload,
312
+ "dry_run": dry_run,
313
+ "expected_revision": expected_revision,
314
+ }
315
+ )
316
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
317
+ if replay is not None:
318
+ return replay
319
+ guard = _revision_guard(path, expected_revision)
320
+ if guard is not None:
321
+ return guard
322
+ if not isinstance(operations, list):
323
+ raise TypeError("operations must be a list")
324
+
325
+ doc = open_doc(path)
326
+ operation_results: list[dict[str, Any]] = []
327
+ try:
328
+ for index, operation in enumerate(operations_payload):
329
+ result = _apply_edit_operation(doc, operation, index)
330
+ result["operationIndex"] = index
331
+ operation_results.append(result)
332
+ except Exception as exc:
333
+ return {
334
+ "ok": False,
335
+ "rolledBack": True,
336
+ "dryRun": dry_run,
337
+ "filename": filename,
338
+ "failedOperationIndex": len(operation_results),
339
+ "error": str(exc),
340
+ "operationsApplied": 0,
341
+ }
342
+
343
+ result = {
344
+ "ok": True,
345
+ "rolledBack": False,
346
+ "filename": filename,
347
+ "operationsApplied": len(operation_results),
348
+ "operationResults": operation_results,
349
+ }
350
+ if dry_run:
351
+ return _idempotency_store(
352
+ scope,
353
+ fingerprint=fingerprint,
354
+ payload=_with_dry_run_verification(result, doc, path, quality=quality),
355
+ )
356
+ verification = _save_doc_verification(doc, path, quality=quality)
357
+ return _idempotency_store(
358
+ scope,
359
+ fingerprint=fingerprint,
360
+ payload=_with_save_verification(result, verification),
361
+ )
362
+
363
+
364
+ def undo_last_edit(filename: str) -> dict:
365
+ """마지막 저장 전 .bak 백업과 현재 문서를 교체해 직전 편집을 되돌립니다."""
366
+ path = resolve_path(filename)
367
+ return RUNTIME_SERVICES.ops.undo_last_edit(path)
368
+
369
+
370
+ def byte_preserving_patch(
371
+ filename: str,
372
+ patches: list[dict[str, Any]],
373
+ output: str | None = None,
374
+ ) -> dict:
375
+ """section XML 바이트 splice 기반 문단 텍스트 패치를 적용합니다.
376
+
377
+ 바이트 보존 fast path: python-hwpx의 ``patch`` → SavePipeline(open-safety)로 게이트되고
378
+ capability handshake로 fail-closed 됩니다. 단, 바이트를 보존하므로 전체 재렌더(VisualComplete
379
+ render) 게이트는 적용되지 않습니다(설계상 카브아웃).
380
+ """
381
+ quality_contract.assert_write_capability() # fail-closed on capability skew
382
+ return RUNTIME_SERVICES.ops.byte_preserving_patch(filename, patches, output=output)
383
+
384
+
385
+ def add_heading(
386
+ filename: str,
387
+ text: str,
388
+ level: int = 1,
389
+ dry_run: bool = False,
390
+ expected_revision: str = None,
391
+ idempotency_key: str = None,
392
+ ) -> dict:
393
+ """문서 끝에 제목 문단을 추가합니다. dry_run=True이면 원본을 저장하지 않습니다."""
394
+ path = resolve_path(filename)
395
+ scope = _idempotency_scope("add_heading", path, idempotency_key)
396
+ fingerprint = _idempotency_fingerprint(
397
+ {
398
+ "filename": filename,
399
+ "text": text,
400
+ "level": level,
401
+ "dry_run": dry_run,
402
+ "expected_revision": expected_revision,
403
+ }
404
+ )
405
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
406
+ if replay is not None:
407
+ return replay
408
+ guard = _revision_guard(path, expected_revision)
409
+ if guard is not None:
410
+ return guard
411
+ doc = open_doc(path)
412
+ idx = add_heading_to_doc(doc, text, level)
413
+ if dry_run:
414
+ return _idempotency_store(
415
+ scope,
416
+ fingerprint=fingerprint,
417
+ payload=_with_dry_run_verification({"paragraph_index": idx}, doc, path),
418
+ )
419
+ verification = _save_doc_verification(doc, path)
420
+ return _idempotency_store(
421
+ scope,
422
+ fingerprint=fingerprint,
423
+ payload=_with_save_verification({"paragraph_index": idx}, verification),
424
+ )
425
+
426
+
427
+ def add_paragraph(
428
+ filename: str,
429
+ text: str,
430
+ style: str | None = None,
431
+ dry_run: bool = False,
432
+ expected_revision: str = None,
433
+ idempotency_key: str = None,
434
+ ) -> dict:
435
+ """문서 끝에 문단을 추가합니다. dry_run=True이면 원본을 저장하지 않습니다."""
436
+ path = resolve_path(filename)
437
+ scope = _idempotency_scope("add_paragraph", path, idempotency_key)
438
+ fingerprint = _idempotency_fingerprint(
439
+ {
440
+ "filename": filename,
441
+ "text": text,
442
+ "style": style,
443
+ "dry_run": dry_run,
444
+ "expected_revision": expected_revision,
445
+ }
446
+ )
447
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
448
+ if replay is not None:
449
+ return replay
450
+ guard = _revision_guard(path, expected_revision)
451
+ if guard is not None:
452
+ return guard
453
+ doc = open_doc(path)
454
+ idx = add_paragraph_to_doc(doc, text, style)
455
+ if dry_run:
456
+ return _idempotency_store(
457
+ scope,
458
+ fingerprint=fingerprint,
459
+ payload=_with_dry_run_verification({"paragraph_index": idx}, doc, path),
460
+ )
461
+ verification = _save_doc_verification(doc, path)
462
+ return _idempotency_store(
463
+ scope,
464
+ fingerprint=fingerprint,
465
+ payload=_with_save_verification({"paragraph_index": idx}, verification),
466
+ )
467
+
468
+
469
+ def insert_paragraph(
470
+ filename: str,
471
+ paragraph_index: int,
472
+ text: str,
473
+ style: str | None = None,
474
+ dry_run: bool = False,
475
+ expected_revision: str = None,
476
+ idempotency_key: str = None,
477
+ ) -> dict:
478
+ """지정 위치 앞에 문단을 삽입합니다. dry_run=True이면 원본을 저장하지 않습니다."""
479
+ path = resolve_path(filename)
480
+ scope = _idempotency_scope("insert_paragraph", path, idempotency_key)
481
+ fingerprint = _idempotency_fingerprint(
482
+ {
483
+ "filename": filename,
484
+ "paragraph_index": paragraph_index,
485
+ "text": text,
486
+ "style": style,
487
+ "dry_run": dry_run,
488
+ "expected_revision": expected_revision,
489
+ }
490
+ )
491
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
492
+ if replay is not None:
493
+ return replay
494
+ guard = _revision_guard(path, expected_revision)
495
+ if guard is not None:
496
+ return guard
497
+ doc = open_doc(path)
498
+ idx = insert_paragraph_to_doc(doc, paragraph_index, text, style)
499
+ if dry_run:
500
+ return _idempotency_store(
501
+ scope,
502
+ fingerprint=fingerprint,
503
+ payload=_with_dry_run_verification({"inserted_index": idx}, doc, path),
504
+ )
505
+ verification = _save_doc_verification(doc, path)
506
+ return _idempotency_store(
507
+ scope,
508
+ fingerprint=fingerprint,
509
+ payload=_with_save_verification({"inserted_index": idx}, verification),
510
+ )
511
+
512
+
513
+ def delete_paragraph(
514
+ filename: str,
515
+ paragraph_index: int,
516
+ dry_run: bool = False,
517
+ expected_revision: str = None,
518
+ idempotency_key: str = None,
519
+ ) -> dict:
520
+ """지정 문단을 삭제합니다. dry_run=True이면 원본을 저장하지 않습니다."""
521
+ path = resolve_path(filename)
522
+ scope = _idempotency_scope("delete_paragraph", path, idempotency_key)
523
+ fingerprint = _idempotency_fingerprint(
524
+ {
525
+ "filename": filename,
526
+ "paragraph_index": paragraph_index,
527
+ "dry_run": dry_run,
528
+ "expected_revision": expected_revision,
529
+ }
530
+ )
531
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
532
+ if replay is not None:
533
+ return replay
534
+ guard = _revision_guard(path, expected_revision)
535
+ if guard is not None:
536
+ return guard
537
+ doc = open_doc(path)
538
+ remaining = delete_paragraph_from_doc(doc, paragraph_index)
539
+ result = {"deleted_index": paragraph_index, "remaining_paragraphs": remaining}
540
+ if dry_run:
541
+ return _idempotency_store(
542
+ scope,
543
+ fingerprint=fingerprint,
544
+ payload=_with_dry_run_verification(result, doc, path),
545
+ )
546
+ verification = _save_doc_verification(doc, path)
547
+ return _idempotency_store(
548
+ scope,
549
+ fingerprint=fingerprint,
550
+ payload=_with_save_verification(result, verification),
551
+ )
552
+
553
+
554
+ def add_table(
555
+ filename: str,
556
+ rows: int,
557
+ cols: int,
558
+ data: list[list[str]] = None,
559
+ dry_run: bool = False,
560
+ expected_revision: str = None,
561
+ idempotency_key: str = None,
562
+ ) -> dict:
563
+ """문서 끝에 표를 추가합니다. dry_run=True이면 원본을 저장하지 않습니다."""
564
+ path = resolve_path(filename)
565
+ scope = _idempotency_scope("add_table", path, idempotency_key)
566
+ fingerprint = _idempotency_fingerprint(
567
+ {
568
+ "filename": filename,
569
+ "rows": rows,
570
+ "cols": cols,
571
+ "data": data,
572
+ "dry_run": dry_run,
573
+ "expected_revision": expected_revision,
574
+ }
575
+ )
576
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
577
+ if replay is not None:
578
+ return replay
579
+ guard = _revision_guard(path, expected_revision)
580
+ if guard is not None:
581
+ return guard
582
+ doc = open_doc(path)
583
+ idx = add_table_to_doc(doc, rows, cols, data)
584
+ if dry_run:
585
+ return _idempotency_store(
586
+ scope,
587
+ fingerprint=fingerprint,
588
+ payload=_with_dry_run_verification({"table_index": idx}, doc, path),
589
+ )
590
+ verification = _save_doc_verification(doc, path)
591
+ return _idempotency_store(
592
+ scope,
593
+ fingerprint=fingerprint,
594
+ payload=_with_save_verification({"table_index": idx}, verification),
595
+ )
596
+
597
+
598
+ def set_table_cell_text(
599
+ filename: str,
600
+ table_index: int,
601
+ row: int,
602
+ col: int,
603
+ text: str,
604
+ preserve_format: bool = True,
605
+ split_paragraphs: bool = False,
606
+ dry_run: bool = False,
607
+ expected_revision: str = None,
608
+ idempotency_key: str = None,
609
+ ) -> dict:
610
+ """표 셀 텍스트를 변경합니다. dry_run=True이면 원본을 저장하지 않습니다."""
611
+ path = resolve_path(filename)
612
+ scope = _idempotency_scope("set_table_cell_text", path, idempotency_key)
613
+ fingerprint = _idempotency_fingerprint(
614
+ {
615
+ "filename": filename,
616
+ "table_index": table_index,
617
+ "row": row,
618
+ "col": col,
619
+ "text": text,
620
+ "preserve_format": preserve_format,
621
+ "split_paragraphs": split_paragraphs,
622
+ "dry_run": dry_run,
623
+ "expected_revision": expected_revision,
624
+ }
625
+ )
626
+ replay = _idempotency_replay(scope, fingerprint=fingerprint)
627
+ if replay is not None:
628
+ return replay
629
+ guard = _revision_guard(path, expected_revision)
630
+ if guard is not None:
631
+ return guard
632
+ doc = open_doc(path)
633
+ set_cell_text(
634
+ doc,
635
+ table_index,
636
+ row,
637
+ col,
638
+ text,
639
+ preserve_format=preserve_format,
640
+ split_paragraphs=split_paragraphs,
641
+ )
642
+ result = {
643
+ "table_index": table_index,
644
+ "row": row,
645
+ "col": col,
646
+ "text": text,
647
+ "preserve_format": preserve_format,
648
+ "split_paragraphs": split_paragraphs,
649
+ }
650
+ if dry_run:
651
+ return _idempotency_store(
652
+ scope,
653
+ fingerprint=fingerprint,
654
+ payload=_with_dry_run_verification(result, doc, path),
655
+ )
656
+ verification = _save_doc_verification(doc, path)
657
+ return _idempotency_store(
658
+ scope,
659
+ fingerprint=fingerprint,
660
+ payload=_with_save_verification(result, verification),
661
+ )
662
+
663
+
664
+ def add_page_break(
665
+ filename: str,
666
+ dry_run: bool = False,
667
+ expected_revision: str = None,
668
+ ) -> dict:
669
+ """문서 끝에 페이지 나누기를 추가합니다. dry_run=True이면 원본을 저장하지 않습니다."""
670
+ path = resolve_path(filename)
671
+ guard = _revision_guard(path, expected_revision)
672
+ if guard is not None:
673
+ return guard
674
+ doc = open_doc(path)
675
+ add_page_break_to_doc(doc)
676
+ if dry_run:
677
+ return _with_dry_run_verification({"success": True}, doc, path)
678
+ verification = _save_doc_verification(doc, path)
679
+ return _with_save_verification({"success": True}, verification)
680
+
681
+
682
+ def insert_picture(
683
+ filename: str,
684
+ image_base64: str,
685
+ image_format: str = "png",
686
+ width: int | None = None,
687
+ height: int | None = None,
688
+ width_mm: float | None = None,
689
+ height_mm: float | None = None,
690
+ section_index: int | None = None,
691
+ align: str | None = None,
692
+ output: str | None = None,
693
+ dry_run: bool = False,
694
+ expected_revision: str = None,
695
+ ) -> dict:
696
+ """본문에 그림 객체를 삽입하고 BinData/manifest 참조를 함께 저장합니다."""
697
+ path = resolve_path(filename)
698
+ guard = _revision_guard(path, expected_revision)
699
+ if guard is not None:
700
+ return guard
701
+ target_path = resolve_path(output) if output else path
702
+ doc = open_doc(path)
703
+ image_data = _decode_image_base64(image_base64)
704
+ doc.add_picture(
705
+ image_data,
706
+ image_format,
707
+ width=width,
708
+ height=height,
709
+ width_mm=width_mm,
710
+ height_mm=height_mm,
711
+ section_index=section_index,
712
+ align=align,
713
+ )
714
+ picture_refs = doc.picture_references()
715
+ result = {
716
+ "ok": True,
717
+ "filename": filename,
718
+ "outputPath": target_path,
719
+ "picture": picture_refs[-1] if picture_refs else None,
720
+ "pictureReferences": picture_refs,
721
+ "idIntegrity": _id_integrity_payload(doc),
722
+ }
723
+ if dry_run:
724
+ return _with_dry_run_verification(result, doc, target_path)
725
+ verification = _save_doc_verification(doc, target_path)
726
+ return _with_save_verification(result, verification)
727
+
728
+
729
+ def replace_picture(
730
+ filename: str,
731
+ image_base64: str,
732
+ image_format: str = "png",
733
+ picture_index: int = 0,
734
+ binary_item_id_ref: str | None = None,
735
+ remove_orphaned: bool = True,
736
+ output: str | None = None,
737
+ dry_run: bool = False,
738
+ expected_revision: str = None,
739
+ ) -> dict:
740
+ """그림 객체의 geometry를 유지하고 연결된 이미지 asset만 교체합니다."""
741
+ path = resolve_path(filename)
742
+ guard = _revision_guard(path, expected_revision)
743
+ if guard is not None:
744
+ return guard
745
+ target_path = resolve_path(output) if output else path
746
+ doc = open_doc(path)
747
+ image_data = _decode_image_base64(image_base64)
748
+ replacement = doc.replace_picture(
749
+ image_data,
750
+ image_format,
751
+ picture_index=picture_index,
752
+ binary_item_id_ref=binary_item_id_ref,
753
+ remove_orphaned=remove_orphaned,
754
+ )
755
+ result = {
756
+ "ok": True,
757
+ "filename": filename,
758
+ "outputPath": target_path,
759
+ "replacement": replacement,
760
+ "pictureReferences": doc.picture_references(),
761
+ "idIntegrity": _id_integrity_payload(doc),
762
+ }
763
+ if dry_run:
764
+ return _with_dry_run_verification(result, doc, target_path)
765
+ verification = _save_doc_verification(doc, target_path)
766
+ return _with_save_verification(result, verification)
767
+
768
+
769
+ def _anchor_position(anchor: dict[str, Any] | str) -> int | None:
770
+ if isinstance(anchor, dict):
771
+ value = anchor.get("position")
772
+ if value is None:
773
+ return None
774
+ return int(value)
775
+ if isinstance(anchor, str) and "@" in anchor:
776
+ return int(anchor.rsplit("@", 1)[1])
777
+ return None
778
+
779
+
780
+ def _replace_visible_span_in_runs(
781
+ runs: list[Any],
782
+ start: int,
783
+ end: int,
784
+ replacement: str,
785
+ ) -> int:
786
+ if start < 0 or end < start:
787
+ raise ValueError("invalid replacement span")
788
+
789
+ boundaries: list[tuple[int, int, Any]] = []
790
+ cursor = 0
791
+ for run in runs:
792
+ text = run.text or ""
793
+ next_cursor = cursor + len(text)
794
+ boundaries.append((cursor, next_cursor, run))
795
+ cursor = next_cursor
796
+
797
+ affected = [
798
+ (run_start, run_end, run)
799
+ for run_start, run_end, run in boundaries
800
+ if start < run_end and end > run_start
801
+ ]
802
+ if not affected:
803
+ return 0
804
+
805
+ first_start, first_end, first_run = affected[0]
806
+ last_start, last_end, last_run = affected[-1]
807
+ first_text = first_run.text or ""
808
+ last_text = last_run.text or ""
809
+ prefix = first_text[: max(0, start - first_start)]
810
+ suffix = last_text[max(0, end - last_start) :]
811
+
812
+ first_run.text = prefix + replacement + (suffix if first_run is last_run else "")
813
+ for _, _, run in affected[1:-1]:
814
+ run.text = ""
815
+ if last_run is not first_run:
816
+ last_run.text = suffix
817
+ return 1
818
+
819
+
820
+ def replace_in_paragraph(
821
+ filename: str,
822
+ old_text: str,
823
+ new_text: str,
824
+ paragraph_index: int | None = None,
825
+ location: dict[str, Any] | None = None,
826
+ count: int | None = None,
827
+ dry_run: bool = False,
828
+ expected_revision: str = None,
829
+ ) -> dict:
830
+ """본문/표 셀 문단 하나에서 run 서식을 유지하며 부분 텍스트를 치환합니다."""
831
+ if old_text == "":
832
+ raise ValueError("old_text는 빈 문자열일 수 없습니다.")
833
+ if count is not None and count <= 0:
834
+ return {
835
+ "replaced_count": 0,
836
+ "location": location or {"paragraph_index": paragraph_index},
837
+ "dryRun": dry_run,
838
+ }
839
+
840
+ path = resolve_path(filename)
841
+ guard = _revision_guard(path, expected_revision)
842
+ if guard is not None:
843
+ return guard
844
+ doc = open_doc(path)
845
+ resolved = resolve_paragraph_reference(
846
+ doc, paragraph_index=paragraph_index, location=location
847
+ )
848
+ paragraph = resolved.paragraph
849
+ runs = list(getattr(paragraph, "runs", []))
850
+ before_run_texts = [run.text or "" for run in runs]
851
+
852
+ if count is None:
853
+ replaced = _replace_in_runs(runs, old_text, new_text) if runs else 0
854
+ else:
855
+ replaced = 0
856
+ for run in runs:
857
+ remaining = count - replaced
858
+ if remaining <= 0:
859
+ break
860
+ if not (run.text or ""):
861
+ continue
862
+ if hasattr(run, "replace_text"):
863
+ replaced += int(run.replace_text(old_text, new_text, count=remaining))
864
+ else:
865
+ before = run.text or ""
866
+ after = before.replace(old_text, new_text, remaining)
867
+ if after != before:
868
+ run.text = after
869
+ replaced += before.count(old_text) - after.count(old_text)
870
+
871
+ if replaced == 0 and not runs:
872
+ before = paragraph.text or ""
873
+ limit = -1 if count is None else count
874
+ after = before.replace(old_text, new_text, limit)
875
+ if after != before:
876
+ paragraph.text = after
877
+ replaced = (
878
+ before.count(old_text)
879
+ if count is None
880
+ else min(before.count(old_text), count)
881
+ )
882
+
883
+ if replaced:
884
+ changed_runs = [
885
+ run
886
+ for index, run in enumerate(getattr(paragraph, "runs", []) or [])
887
+ if (run.text or "")
888
+ and (
889
+ index >= len(before_run_texts)
890
+ or (run.text or "") != before_run_texts[index]
891
+ )
892
+ ]
893
+ repair_pathological_text_spacing(
894
+ doc,
895
+ paragraph=paragraph,
896
+ runs=changed_runs,
897
+ )
898
+ result = {"replaced_count": replaced, "location": resolved.location}
899
+ if dry_run:
900
+ return _with_dry_run_verification(result, doc, path)
901
+ verification = _save_doc_verification(doc, path)
902
+ return _with_save_verification(result, verification)
903
+ return {
904
+ "replaced_count": replaced,
905
+ "location": resolved.location,
906
+ "dryRun": dry_run,
907
+ }
908
+
909
+
910
+ def replace_by_anchor(
911
+ filename: str,
912
+ anchor: dict[str, Any] | str,
913
+ old_text: str,
914
+ new_text: str,
915
+ dry_run: bool = False,
916
+ expected_revision: str = None,
917
+ ) -> dict:
918
+ """find_text가 반환한 anchor 위치에서 run 서식을 유지하며 텍스트를 치환합니다."""
919
+ if old_text == "":
920
+ raise ValueError("old_text는 빈 문자열일 수 없습니다.")
921
+
922
+ location = location_from_anchor(anchor)
923
+ position = _anchor_position(anchor)
924
+ if position is None:
925
+ return replace_in_paragraph(
926
+ filename,
927
+ old_text,
928
+ new_text,
929
+ location=location,
930
+ count=1,
931
+ dry_run=dry_run,
932
+ expected_revision=expected_revision,
933
+ )
934
+
935
+ path = resolve_path(filename)
936
+ guard = _revision_guard(path, expected_revision)
937
+ if guard is not None:
938
+ return guard
939
+ doc = open_doc(path)
940
+ resolved = resolve_paragraph_reference(doc, location=location)
941
+ paragraph = resolved.paragraph
942
+ before = paragraph.text or ""
943
+ end = position + len(old_text)
944
+ if before[position:end] != old_text:
945
+ raise ValueError("anchor position does not match old_text")
946
+
947
+ runs = list(getattr(paragraph, "runs", []))
948
+ before_run_texts = [run.text or "" for run in runs]
949
+ if runs:
950
+ replaced = _replace_visible_span_in_runs(runs, position, end, new_text)
951
+ else:
952
+ paragraph.text = before[:position] + new_text + before[end:]
953
+ replaced = 1
954
+
955
+ changed_runs = [
956
+ run
957
+ for index, run in enumerate(getattr(paragraph, "runs", []) or [])
958
+ if (run.text or "")
959
+ and (
960
+ index >= len(before_run_texts)
961
+ or (run.text or "") != before_run_texts[index]
962
+ )
963
+ ]
964
+ repair_pathological_text_spacing(
965
+ doc,
966
+ paragraph=paragraph,
967
+ runs=changed_runs,
968
+ )
969
+
970
+ result = {
971
+ "replaced_count": replaced,
972
+ "location": resolved.location,
973
+ "position": position,
974
+ }
975
+ if dry_run:
976
+ return _with_dry_run_verification(result, doc, path)
977
+ verification = _save_doc_verification(doc, path)
978
+ return _with_save_verification(result, verification)
979
+
980
+
981
+ def merge_table_cells(
982
+ filename: str,
983
+ table_index: int,
984
+ start_row: int,
985
+ start_col: int,
986
+ end_row: int,
987
+ end_col: int,
988
+ dry_run: bool = False,
989
+ expected_revision: str = None,
990
+ ) -> dict:
991
+ """표 셀 범위를 병합합니다. dry_run=True이면 원본을 저장하지 않습니다."""
992
+ path = resolve_path(filename)
993
+ guard = _revision_guard(path, expected_revision)
994
+ if guard is not None:
995
+ return guard
996
+ doc = open_doc(path)
997
+ merge_cells_in_table(doc, table_index, start_row, start_col, end_row, end_col)
998
+ result = {
999
+ "merged": True,
1000
+ "range": f"({start_row},{start_col})~({end_row},{end_col})",
1001
+ }
1002
+ if dry_run:
1003
+ return _with_dry_run_verification(result, doc, path)
1004
+ verification = _save_doc_verification(doc, path)
1005
+ return _with_save_verification(result, verification)
1006
+
1007
+
1008
+ def split_table_cell(
1009
+ filename: str,
1010
+ table_index: int,
1011
+ row: int,
1012
+ col: int,
1013
+ dry_run: bool = False,
1014
+ expected_revision: str = None,
1015
+ ) -> dict:
1016
+ """병합된 셀을 분할합니다. dry_run=True이면 원본을 저장하지 않습니다."""
1017
+ path = resolve_path(filename)
1018
+ guard = _revision_guard(path, expected_revision)
1019
+ if guard is not None:
1020
+ return guard
1021
+ doc = open_doc(path)
1022
+ span_info = split_cell_in_table(doc, table_index, row, col)
1023
+ if dry_run:
1024
+ return _with_dry_run_verification(
1025
+ {"split": True, "original_span": span_info}, doc, path
1026
+ )
1027
+ verification = _save_doc_verification(doc, path)
1028
+ return _with_save_verification(
1029
+ {"split": True, "original_span": span_info}, verification
1030
+ )
1031
+
1032
+
1033
+ def format_table(
1034
+ filename: str,
1035
+ table_index: int,
1036
+ has_header_row: bool = None,
1037
+ dry_run: bool = False,
1038
+ expected_revision: str = None,
1039
+ ) -> dict:
1040
+ """표 서식을 적용합니다. dry_run=True이면 원본을 저장하지 않습니다."""
1041
+ path = resolve_path(filename)
1042
+ guard = _revision_guard(path, expected_revision)
1043
+ if guard is not None:
1044
+ return guard
1045
+ doc = open_doc(path)
1046
+ format_table_in_doc(doc, table_index, has_header_row=has_header_row)
1047
+ if dry_run:
1048
+ return _with_dry_run_verification(
1049
+ {"formatted": True, "table_index": table_index}, doc, path
1050
+ )
1051
+ verification = _save_doc_verification(doc, path)
1052
+ return _with_save_verification(
1053
+ {"formatted": True, "table_index": table_index}, verification
1054
+ )
1055
+
1056
+
1057
+ __all__ = [
1058
+ "add_heading",
1059
+ "add_paragraph",
1060
+ "insert_paragraph",
1061
+ "delete_paragraph",
1062
+ "add_page_break",
1063
+ "apply_edits",
1064
+ "undo_last_edit",
1065
+ "replace_by_anchor",
1066
+ "replace_in_paragraph",
1067
+ "search_and_replace",
1068
+ "batch_replace",
1069
+ "byte_preserving_patch",
1070
+ "insert_picture",
1071
+ "replace_picture",
1072
+ "add_table",
1073
+ "set_table_cell_text",
1074
+ "merge_table_cells",
1075
+ "split_table_cell",
1076
+ "format_table",
1077
+ "table_compute",
1078
+ ]