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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
]
|