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,1383 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Atomic, allow-listed mutation commands for the semantic agent interface.
|
|
3
|
+
|
|
4
|
+
The public contract in :mod:`hwpx.agent.model` deliberately has no raw XML or
|
|
5
|
+
package-part escape hatch. This module is the only compiler from that compact
|
|
6
|
+
command vocabulary to the existing python-hwpx editing primitives. A batch is
|
|
7
|
+
applied to one disposable in-memory document, serialized once, and handed to
|
|
8
|
+
the normal :class:`~hwpx.quality.SavePipeline` exactly once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from xml.etree import ElementTree as ET
|
|
19
|
+
|
|
20
|
+
from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
|
|
21
|
+
|
|
22
|
+
from hwpx.document import HwpxDocument
|
|
23
|
+
from hwpx.oxml import HwpxOxmlTable
|
|
24
|
+
from hwpx.quality import SavePipeline
|
|
25
|
+
|
|
26
|
+
from .catalog import catalog_hash
|
|
27
|
+
from .document import HwpxAgentDocument, NodeRecord
|
|
28
|
+
from .model import (
|
|
29
|
+
NODE_PROPERTY_CATALOG_V1,
|
|
30
|
+
AgentBatchResult,
|
|
31
|
+
AgentContractError,
|
|
32
|
+
validate_agent_batch,
|
|
33
|
+
)
|
|
34
|
+
from .path import parse_path
|
|
35
|
+
from .story import (
|
|
36
|
+
HEADER_STORY_EDITABLE_PROPERTIES,
|
|
37
|
+
HEADER_STORY_KIND,
|
|
38
|
+
HeaderStoryBinding,
|
|
39
|
+
try_parse_header_story_path,
|
|
40
|
+
)
|
|
41
|
+
# Verification/orchestration primitives for apply_document_commands live in
|
|
42
|
+
# _batch_verification (split out to keep this file under its line-count
|
|
43
|
+
# ratchet). _error_from_exception/_member_diff/_quality_policy/_revision are
|
|
44
|
+
# re-exported here (explicit `as` aliases) because blueprint/replay.py still
|
|
45
|
+
# imports them from this module.
|
|
46
|
+
from ._batch_verification import (
|
|
47
|
+
DomainVerifier,
|
|
48
|
+
FaultInjector,
|
|
49
|
+
IdempotencyStore,
|
|
50
|
+
_EMPTY_REVISION,
|
|
51
|
+
_apply_commands_build_candidate_report,
|
|
52
|
+
_apply_commands_domain_verification,
|
|
53
|
+
_apply_commands_idempotency_lookup,
|
|
54
|
+
_apply_commands_run_save_pipeline,
|
|
55
|
+
_call_fault,
|
|
56
|
+
_error_from_exception as _error_from_exception,
|
|
57
|
+
_failure_result,
|
|
58
|
+
_member_diff as _member_diff,
|
|
59
|
+
_quality_policy as _quality_policy,
|
|
60
|
+
_request_hash,
|
|
61
|
+
_require_candidate_structural_safety,
|
|
62
|
+
_revision as _revision,
|
|
63
|
+
_validate_apply_commands_input,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_HWP_UNITS_PER_MM = 7200 / 25.4
|
|
67
|
+
_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
68
|
+
_PARAGRAPH_ALIGNMENTS = frozenset(
|
|
69
|
+
{"LEFT", "CENTER", "RIGHT", "JUSTIFY", "DISTRIBUTE", "DISTRIBUTE_SPACE"}
|
|
70
|
+
)
|
|
71
|
+
_TABLE_ALIGNMENTS = frozenset({"LEFT", "CENTER", "RIGHT", "INSIDE", "OUTSIDE"})
|
|
72
|
+
_VERTICAL_ALIGNMENTS = frozenset({"TOP", "CENTER", "BOTTOM"})
|
|
73
|
+
_INLINE_KINDS = frozenset({"table", "picture", "shape", "footnote", "endnote"})
|
|
74
|
+
_SHAPE_KINDS = frozenset({"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine"})
|
|
75
|
+
|
|
76
|
+
# Creation needs a few geometry fields that are readable, rather than editable,
|
|
77
|
+
# after the node exists. Everything else comes directly from the frozen v1
|
|
78
|
+
# property catalog.
|
|
79
|
+
_CREATE_PROPERTIES: dict[str, frozenset[str]] = {
|
|
80
|
+
"section": frozenset({"pageWidthMm", "pageHeightMm"}),
|
|
81
|
+
"paragraph": frozenset(NODE_PROPERTY_CATALOG_V1["paragraph"]["editable"]),
|
|
82
|
+
"run": frozenset(NODE_PROPERTY_CATALOG_V1["run"]["editable"]),
|
|
83
|
+
"table": frozenset({"rowCount", "columnCount", "caption", "widthMm", "alignment"}),
|
|
84
|
+
"row": frozenset({"cellCount", "heightMm"}),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_ADD_PARENTS = {
|
|
88
|
+
"section": "document",
|
|
89
|
+
"paragraph": "section",
|
|
90
|
+
"run": "paragraph",
|
|
91
|
+
"table": "paragraph",
|
|
92
|
+
"row": "table",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_MOVE_COPY_PARENTS = {
|
|
96
|
+
"paragraph": "section",
|
|
97
|
+
"run": "paragraph",
|
|
98
|
+
"table": "paragraph",
|
|
99
|
+
"row": "table",
|
|
100
|
+
"picture": "paragraph",
|
|
101
|
+
"shape": "paragraph",
|
|
102
|
+
"memo": "section",
|
|
103
|
+
"footnote": "paragraph",
|
|
104
|
+
"endnote": "paragraph",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def _local_name(element: Any) -> str:
|
|
108
|
+
return str(getattr(element, "tag", "")).rsplit("}", 1)[-1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tag_like(element: Any, local_name: str) -> str:
|
|
112
|
+
tag = str(element.tag)
|
|
113
|
+
return f"{tag.rsplit('}', 1)[0]}}}{local_name}" if "}" in tag else local_name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _element(native: Any) -> Any:
|
|
117
|
+
return getattr(native, "element", native)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _require_string(value: Any, name: str, *, allow_empty: bool = True) -> str:
|
|
121
|
+
if not isinstance(value, str) or (not allow_empty and not value):
|
|
122
|
+
raise AgentContractError("invalid_syntax", f"{name} must be a string", target=name)
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _require_bool(value: Any, name: str) -> bool:
|
|
127
|
+
if not isinstance(value, bool):
|
|
128
|
+
raise AgentContractError("invalid_syntax", f"{name} must be boolean", target=name)
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _require_number(value: Any, name: str, *, positive: bool = False) -> float:
|
|
133
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
134
|
+
raise AgentContractError("invalid_syntax", f"{name} must be numeric", target=name)
|
|
135
|
+
result = float(value)
|
|
136
|
+
if positive and result <= 0:
|
|
137
|
+
raise AgentContractError("invalid_syntax", f"{name} must be positive", target=name)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _require_integer(value: Any, name: str, *, positive: bool = False) -> int:
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
143
|
+
raise AgentContractError("invalid_syntax", f"{name} must be an integer", target=name)
|
|
144
|
+
if positive and value <= 0:
|
|
145
|
+
raise AgentContractError("invalid_syntax", f"{name} must be positive", target=name)
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _require_color(value: Any, name: str) -> str:
|
|
150
|
+
color = _require_string(value, name, allow_empty=False)
|
|
151
|
+
if not _COLOR_PATTERN.fullmatch(color):
|
|
152
|
+
raise AgentContractError("invalid_syntax", f"{name} must be #RRGGBB", target=name)
|
|
153
|
+
return color.upper()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _require_enum(value: Any, name: str, choices: frozenset[str]) -> str:
|
|
157
|
+
text = _require_string(value, name, allow_empty=False).upper()
|
|
158
|
+
if text not in choices:
|
|
159
|
+
raise AgentContractError(
|
|
160
|
+
"invalid_syntax", f"{name} must be one of {sorted(choices)}", target=name
|
|
161
|
+
)
|
|
162
|
+
return text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_property_values(kind: str, properties: Mapping[str, Any], *, creating: bool) -> None:
|
|
166
|
+
allowed = (
|
|
167
|
+
_CREATE_PROPERTIES.get(kind, frozenset())
|
|
168
|
+
if creating
|
|
169
|
+
else frozenset(NODE_PROPERTY_CATALOG_V1[kind]["editable"])
|
|
170
|
+
)
|
|
171
|
+
unknown = sorted(set(properties) - allowed)
|
|
172
|
+
if unknown:
|
|
173
|
+
raise AgentContractError(
|
|
174
|
+
"unknown_property",
|
|
175
|
+
f"{kind} does not support properties: {unknown}",
|
|
176
|
+
target=f"{kind}.{unknown[0]}",
|
|
177
|
+
)
|
|
178
|
+
for name, value in properties.items():
|
|
179
|
+
target = f"{kind}.{name}"
|
|
180
|
+
if name in {"text", "value", "altText", "caption", "fontName", "style"}:
|
|
181
|
+
_require_string(value, target)
|
|
182
|
+
elif name in {"bold", "italic", "underline", "breakBefore", "keepWithNext", "readOnly"}:
|
|
183
|
+
_require_bool(value, target)
|
|
184
|
+
elif name in {"fontSizePt", "lineSpacingPercent", "heightMm", "widthMm", "pageWidthMm", "pageHeightMm"}:
|
|
185
|
+
_require_number(value, target, positive=True)
|
|
186
|
+
elif name in {"rowCount", "columnCount", "cellCount"}:
|
|
187
|
+
_require_integer(value, target, positive=True)
|
|
188
|
+
elif name == "color" or name == "backgroundColor":
|
|
189
|
+
_require_color(value, target)
|
|
190
|
+
elif name == "alignment":
|
|
191
|
+
_require_enum(
|
|
192
|
+
value,
|
|
193
|
+
target,
|
|
194
|
+
_TABLE_ALIGNMENTS if kind == "table" else _PARAGRAPH_ALIGNMENTS,
|
|
195
|
+
)
|
|
196
|
+
elif name == "verticalAlignment":
|
|
197
|
+
_require_enum(value, target, _VERTICAL_ALIGNMENTS)
|
|
198
|
+
else: # pragma: no cover - catalog additions must choose a type above
|
|
199
|
+
raise AgentContractError("unknown_property", f"untyped property: {target}", target=target)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _validate_header_story_properties(properties: Mapping[str, Any]) -> None:
|
|
203
|
+
unknown = sorted(set(properties) - HEADER_STORY_EDITABLE_PROPERTIES)
|
|
204
|
+
if unknown:
|
|
205
|
+
raise AgentContractError(
|
|
206
|
+
"unknown_property",
|
|
207
|
+
f"header does not support properties: {unknown}",
|
|
208
|
+
target=f"header.{unknown[0]}",
|
|
209
|
+
)
|
|
210
|
+
if "text" not in properties:
|
|
211
|
+
raise AgentContractError(
|
|
212
|
+
"invalid_syntax", "existing header set requires text", target="header.text"
|
|
213
|
+
)
|
|
214
|
+
_require_string(properties["text"], "header.text")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _preflight_reference_kind(value: str, alias_kinds: Mapping[str, Mapping[str, str]]) -> str:
|
|
218
|
+
if value.startswith("$"):
|
|
219
|
+
command_id, field = value[1:].split(".", 1)
|
|
220
|
+
try:
|
|
221
|
+
return alias_kinds[command_id][field]
|
|
222
|
+
except KeyError as exc: # validate_agent_batch already checks ordering
|
|
223
|
+
raise AgentContractError("not_found", f"unknown command alias: {value}") from exc
|
|
224
|
+
story = try_parse_header_story_path(value)
|
|
225
|
+
if story is not None:
|
|
226
|
+
return story.kind
|
|
227
|
+
parsed = parse_path(value)
|
|
228
|
+
return parsed.segments[-1].kind if parsed.segments else "document"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _preflight_parent_kind(value: str, alias_kinds: Mapping[str, Mapping[str, str]]) -> str:
|
|
232
|
+
if value.startswith("$"):
|
|
233
|
+
command_id, field = value[1:].split(".", 1)
|
|
234
|
+
try:
|
|
235
|
+
return alias_kinds[command_id]["parentPath" if field == "path" else field]
|
|
236
|
+
except KeyError:
|
|
237
|
+
return "document"
|
|
238
|
+
story = try_parse_header_story_path(value)
|
|
239
|
+
if story is not None:
|
|
240
|
+
return "section"
|
|
241
|
+
parsed = parse_path(value)
|
|
242
|
+
return parsed.segments[-2].kind if len(parsed.segments) > 1 else "document"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _preflight_add_command(command: Mapping[str, Any], alias_kinds: dict[str, dict[str, str]]) -> None:
|
|
246
|
+
kind = command["kind"]
|
|
247
|
+
if kind not in _ADD_PARENTS:
|
|
248
|
+
raise AgentContractError(
|
|
249
|
+
"unsupported_operation", f"add is unsupported for {kind}", target=command["commandId"]
|
|
250
|
+
)
|
|
251
|
+
_validate_property_values(kind, command["properties"], creating=True)
|
|
252
|
+
if kind == "table" and not {"rowCount", "columnCount"} <= set(command["properties"]):
|
|
253
|
+
raise AgentContractError(
|
|
254
|
+
"invalid_syntax", "table add requires rowCount and columnCount", target=command["commandId"]
|
|
255
|
+
)
|
|
256
|
+
if kind == "row" and "cellCount" not in command["properties"]:
|
|
257
|
+
raise AgentContractError(
|
|
258
|
+
"invalid_syntax", "row add requires cellCount", target=command["commandId"]
|
|
259
|
+
)
|
|
260
|
+
destination_kind = _preflight_reference_kind(command["parent"], alias_kinds)
|
|
261
|
+
expected_parent = _ADD_PARENTS[kind]
|
|
262
|
+
if destination_kind != expected_parent:
|
|
263
|
+
raise AgentContractError(
|
|
264
|
+
"incompatible_parent",
|
|
265
|
+
f"{kind} requires a {expected_parent} parent, not {destination_kind}",
|
|
266
|
+
target=command["commandId"],
|
|
267
|
+
)
|
|
268
|
+
alias_kinds[command["commandId"]] = {
|
|
269
|
+
"path": kind,
|
|
270
|
+
"parentPath": destination_kind,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _preflight_mutate_command(
|
|
275
|
+
op: str, command: Mapping[str, Any], alias_kinds: dict[str, dict[str, str]]
|
|
276
|
+
) -> None:
|
|
277
|
+
source_kind = _preflight_reference_kind(command["path"], alias_kinds)
|
|
278
|
+
if source_kind == HEADER_STORY_KIND:
|
|
279
|
+
if op != "set":
|
|
280
|
+
raise AgentContractError(
|
|
281
|
+
"unsupported_operation",
|
|
282
|
+
f"{op} is unsupported for {source_kind}",
|
|
283
|
+
target=command["commandId"],
|
|
284
|
+
)
|
|
285
|
+
_validate_header_story_properties(command["properties"])
|
|
286
|
+
alias_kinds[command["commandId"]] = {
|
|
287
|
+
"path": source_kind,
|
|
288
|
+
"parentPath": "section",
|
|
289
|
+
}
|
|
290
|
+
return
|
|
291
|
+
if op not in NODE_PROPERTY_CATALOG_V1[source_kind]["operations"]:
|
|
292
|
+
raise AgentContractError(
|
|
293
|
+
"unsupported_operation",
|
|
294
|
+
f"{op} is unsupported for {source_kind}",
|
|
295
|
+
target=command["commandId"],
|
|
296
|
+
)
|
|
297
|
+
if op == "set":
|
|
298
|
+
_validate_property_values(source_kind, command["properties"], creating=False)
|
|
299
|
+
destination_kind = _preflight_parent_kind(command["path"], alias_kinds)
|
|
300
|
+
if op in {"move", "copy"}:
|
|
301
|
+
destination_kind = _preflight_reference_kind(command["parent"], alias_kinds)
|
|
302
|
+
move_parent = _MOVE_COPY_PARENTS.get(source_kind)
|
|
303
|
+
if destination_kind != move_parent:
|
|
304
|
+
raise AgentContractError(
|
|
305
|
+
"incompatible_parent",
|
|
306
|
+
f"{source_kind} requires a {move_parent} parent, not {destination_kind}",
|
|
307
|
+
target=command["commandId"],
|
|
308
|
+
)
|
|
309
|
+
alias_kinds[command["commandId"]] = {
|
|
310
|
+
"path": source_kind,
|
|
311
|
+
"parentPath": destination_kind,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _preflight(batch: Mapping[str, Any]) -> None:
|
|
316
|
+
"""Reject the complete static command matrix before the first mutation."""
|
|
317
|
+
|
|
318
|
+
alias_kinds: dict[str, dict[str, str]] = {}
|
|
319
|
+
|
|
320
|
+
for command in batch["commands"]:
|
|
321
|
+
op = command["op"]
|
|
322
|
+
if op == "add":
|
|
323
|
+
_preflight_add_command(command, alias_kinds)
|
|
324
|
+
continue
|
|
325
|
+
_preflight_mutate_command(op, command, alias_kinds)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _resolve_alias(value: str, aliases: Mapping[str, Mapping[str, str]]) -> str:
|
|
329
|
+
if not value.startswith("$"):
|
|
330
|
+
return value
|
|
331
|
+
command_id, field = value[1:].split(".", 1)
|
|
332
|
+
try:
|
|
333
|
+
return aliases[command_id][field]
|
|
334
|
+
except KeyError as exc:
|
|
335
|
+
raise AgentContractError(
|
|
336
|
+
"not_found", f"command alias is unavailable: {value}", target=value
|
|
337
|
+
) from exc
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resolved_position(
|
|
341
|
+
position: Mapping[str, Any], aliases: Mapping[str, Mapping[str, str]]
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
result = dict(position)
|
|
344
|
+
if "path" in result:
|
|
345
|
+
result["path"] = _resolve_alias(str(result["path"]), aliases)
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _record_for_native(view: HwpxAgentDocument, native_or_element: Any) -> NodeRecord:
|
|
350
|
+
target = _element(native_or_element)
|
|
351
|
+
for record in view.records:
|
|
352
|
+
if _element(record.native) is target:
|
|
353
|
+
return record
|
|
354
|
+
raise AgentContractError("not_found", "mutated node was not present after projection")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _ensure_operation(record: NodeRecord, operation: str) -> None:
|
|
358
|
+
if operation not in NODE_PROPERTY_CATALOG_V1[record.kind]["operations"]:
|
|
359
|
+
raise AgentContractError(
|
|
360
|
+
"unsupported_operation",
|
|
361
|
+
f"{operation} is unsupported for {record.kind}",
|
|
362
|
+
target=record.path,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _ensure_parent(record: NodeRecord, *, source_kind: str, operation: str) -> None:
|
|
367
|
+
expected = _ADD_PARENTS.get(source_kind) if operation == "add" else _MOVE_COPY_PARENTS.get(source_kind)
|
|
368
|
+
if expected != record.kind:
|
|
369
|
+
raise AgentContractError(
|
|
370
|
+
"incompatible_parent",
|
|
371
|
+
f"{source_kind} requires a {expected} parent, not {record.kind}",
|
|
372
|
+
target=record.path,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _sibling_elements(view: HwpxAgentDocument, parent: NodeRecord, kind: str) -> list[Any]:
|
|
377
|
+
return [
|
|
378
|
+
_element(record.native)
|
|
379
|
+
for record in view.records
|
|
380
|
+
if record.parent_path == parent.path and record.kind == kind
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _insertion_index(
|
|
385
|
+
view: HwpxAgentDocument,
|
|
386
|
+
parent: NodeRecord,
|
|
387
|
+
kind: str,
|
|
388
|
+
position: Mapping[str, Any],
|
|
389
|
+
*,
|
|
390
|
+
siblings: list[Any] | None = None,
|
|
391
|
+
) -> int:
|
|
392
|
+
siblings = list(siblings if siblings is not None else _sibling_elements(view, parent, kind))
|
|
393
|
+
mode = position["mode"]
|
|
394
|
+
if mode == "append":
|
|
395
|
+
return len(siblings)
|
|
396
|
+
if mode == "prepend":
|
|
397
|
+
return 0
|
|
398
|
+
if mode == "index":
|
|
399
|
+
index = int(position["index"])
|
|
400
|
+
if index > len(siblings) + 1:
|
|
401
|
+
raise AgentContractError(
|
|
402
|
+
"not_found", f"one-based insertion index {index} is out of range", target=parent.path
|
|
403
|
+
)
|
|
404
|
+
return index - 1
|
|
405
|
+
anchor = view.resolve_record(str(position["path"]))
|
|
406
|
+
if anchor.parent_path != parent.path or anchor.kind != kind:
|
|
407
|
+
raise AgentContractError(
|
|
408
|
+
"incompatible_parent", "position anchor is not a compatible sibling", target=anchor.path
|
|
409
|
+
)
|
|
410
|
+
try:
|
|
411
|
+
index = siblings.index(_element(anchor.native))
|
|
412
|
+
except ValueError as exc:
|
|
413
|
+
raise AgentContractError("not_found", "position anchor is unavailable", target=anchor.path) from exc
|
|
414
|
+
return index + (1 if mode == "after" else 0)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _insert_direct_child(
|
|
418
|
+
view: HwpxAgentDocument,
|
|
419
|
+
parent: NodeRecord,
|
|
420
|
+
kind: str,
|
|
421
|
+
element: Any,
|
|
422
|
+
position: Mapping[str, Any],
|
|
423
|
+
) -> None:
|
|
424
|
+
siblings = _sibling_elements(view, parent, kind)
|
|
425
|
+
index = _insertion_index(view, parent, kind, position, siblings=siblings)
|
|
426
|
+
container = _element(parent.native)
|
|
427
|
+
if index >= len(siblings):
|
|
428
|
+
if siblings:
|
|
429
|
+
absolute = list(container).index(siblings[-1]) + 1
|
|
430
|
+
container.insert(absolute, element)
|
|
431
|
+
else:
|
|
432
|
+
container.append(element)
|
|
433
|
+
else:
|
|
434
|
+
container.insert(list(container).index(siblings[index]), element)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _insert_inline(
|
|
438
|
+
view: HwpxAgentDocument,
|
|
439
|
+
parent: NodeRecord,
|
|
440
|
+
kind: str,
|
|
441
|
+
element: Any,
|
|
442
|
+
position: Mapping[str, Any],
|
|
443
|
+
) -> Any:
|
|
444
|
+
paragraph = parent.native
|
|
445
|
+
siblings = _sibling_elements(view, parent, kind)
|
|
446
|
+
index = _insertion_index(view, parent, kind, position, siblings=siblings)
|
|
447
|
+
run = paragraph.element.makeelement(
|
|
448
|
+
_tag_like(paragraph.element, "run"),
|
|
449
|
+
{"charPrIDRef": paragraph.char_pr_id_ref or "0"},
|
|
450
|
+
)
|
|
451
|
+
run.append(element)
|
|
452
|
+
if index >= len(siblings):
|
|
453
|
+
paragraph.element.append(run)
|
|
454
|
+
else:
|
|
455
|
+
anchor = siblings[index]
|
|
456
|
+
anchor_run = anchor.getparent() if hasattr(anchor, "getparent") else next(
|
|
457
|
+
child for child in paragraph.element if anchor in list(child)
|
|
458
|
+
)
|
|
459
|
+
paragraph.element.insert(list(paragraph.element).index(anchor_run), run)
|
|
460
|
+
paragraph.section.mark_dirty()
|
|
461
|
+
return run
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _remove_inline_element(element: Any, paragraph: Any) -> None:
|
|
465
|
+
run = element.getparent() if hasattr(element, "getparent") else next(
|
|
466
|
+
child for child in paragraph.element if element in list(child)
|
|
467
|
+
)
|
|
468
|
+
run.remove(element)
|
|
469
|
+
if not list(run) and run in list(paragraph.element):
|
|
470
|
+
paragraph.element.remove(run)
|
|
471
|
+
paragraph.section.mark_dirty()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _set_shape_comment(element: Any, value: str) -> None:
|
|
475
|
+
comment = next((child for child in element if _local_name(child) == "shapeComment"), None)
|
|
476
|
+
if comment is None:
|
|
477
|
+
comment = element.makeelement(_tag_like(element, "shapeComment"), {})
|
|
478
|
+
element.append(comment)
|
|
479
|
+
comment.text = value
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _next_numeric_identity(document: HwpxDocument) -> str:
|
|
483
|
+
maximum = 0
|
|
484
|
+
for section in document.sections:
|
|
485
|
+
for node in section.element.iter():
|
|
486
|
+
for name in ("id", "instid", "instId"):
|
|
487
|
+
value = node.get(name)
|
|
488
|
+
if value and value.isdigit():
|
|
489
|
+
maximum = max(maximum, int(value))
|
|
490
|
+
return str(maximum + 1)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _table_caption(table: Any, value: str) -> None:
|
|
494
|
+
element = table.element
|
|
495
|
+
caption = next((child for child in element if _local_name(child) == "caption"), None)
|
|
496
|
+
if caption is None:
|
|
497
|
+
caption = element.makeelement(
|
|
498
|
+
_tag_like(element, "caption"),
|
|
499
|
+
{"side": "TOP", "fullSz": "0", "width": "0", "gap": "850", "lastWidth": "0"},
|
|
500
|
+
)
|
|
501
|
+
sublist = caption.makeelement(
|
|
502
|
+
_tag_like(caption, "subList"),
|
|
503
|
+
{
|
|
504
|
+
"id": "",
|
|
505
|
+
"textDirection": "HORIZONTAL",
|
|
506
|
+
"lineWrap": "BREAK",
|
|
507
|
+
"vertAlign": "TOP",
|
|
508
|
+
"linkListIDRef": "0",
|
|
509
|
+
"linkListNextIDRef": "0",
|
|
510
|
+
"textWidth": "0",
|
|
511
|
+
"textHeight": "0",
|
|
512
|
+
"hasTextRef": "0",
|
|
513
|
+
"hasNumRef": "0",
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
paragraph = sublist.makeelement(
|
|
517
|
+
_tag_like(sublist, "p"),
|
|
518
|
+
{
|
|
519
|
+
"id": _next_numeric_identity(table.paragraph.section.document),
|
|
520
|
+
"paraPrIDRef": "0",
|
|
521
|
+
"styleIDRef": "0",
|
|
522
|
+
"pageBreak": "0",
|
|
523
|
+
"columnBreak": "0",
|
|
524
|
+
"merged": "0",
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
run = paragraph.makeelement(_tag_like(paragraph, "run"), {"charPrIDRef": "0"})
|
|
528
|
+
text = run.makeelement(_tag_like(run, "t"), {})
|
|
529
|
+
run.append(text)
|
|
530
|
+
paragraph.append(run)
|
|
531
|
+
sublist.append(paragraph)
|
|
532
|
+
caption.append(sublist)
|
|
533
|
+
children = list(element)
|
|
534
|
+
position_index = next(
|
|
535
|
+
(index + 1 for index, child in enumerate(children) if _local_name(child) == "pos"),
|
|
536
|
+
min(2, len(children)),
|
|
537
|
+
)
|
|
538
|
+
element.insert(position_index, caption)
|
|
539
|
+
text_nodes = [node for node in caption.iter() if _local_name(node) == "t"]
|
|
540
|
+
if not text_nodes:
|
|
541
|
+
raise AgentContractError("unsupported_content", "table caption has no editable text node")
|
|
542
|
+
text_nodes[0].text = value
|
|
543
|
+
for node in text_nodes[1:]:
|
|
544
|
+
node.text = ""
|
|
545
|
+
table.mark_dirty()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _global_paragraph_index(document: HwpxDocument, paragraph: Any) -> int:
|
|
549
|
+
for index, candidate in enumerate(document.paragraphs):
|
|
550
|
+
if candidate.element is paragraph.element:
|
|
551
|
+
return index
|
|
552
|
+
raise AgentContractError("not_found", "paragraph is detached")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _style_id(document: HwpxDocument, value: str) -> str:
|
|
556
|
+
if value in document.styles:
|
|
557
|
+
return value
|
|
558
|
+
matches = [style_id for style_id, style in document.styles.items() if style.name == value]
|
|
559
|
+
if len(matches) != 1:
|
|
560
|
+
raise AgentContractError(
|
|
561
|
+
"not_found" if not matches else "ambiguous_target",
|
|
562
|
+
f"paragraph style is not uniquely resolvable: {value!r}",
|
|
563
|
+
target="paragraph.style",
|
|
564
|
+
)
|
|
565
|
+
return matches[0]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _mark_containing_section(document: HwpxDocument, target: Any) -> None:
|
|
569
|
+
for section in document.sections:
|
|
570
|
+
if any(node is target for node in section.element.iter()):
|
|
571
|
+
section.mark_dirty()
|
|
572
|
+
return
|
|
573
|
+
raise AgentContractError("not_found", "mutated element is detached")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _apply_header_story_set(
|
|
577
|
+
binding: HeaderStoryBinding, properties: Mapping[str, Any]
|
|
578
|
+
) -> dict[str, Any]:
|
|
579
|
+
"""Apply the bounded existing-header mutation through its OXML owner."""
|
|
580
|
+
|
|
581
|
+
_validate_header_story_properties(properties)
|
|
582
|
+
try:
|
|
583
|
+
binding.native.set_simple_text_preserving(properties["text"])
|
|
584
|
+
except AgentContractError:
|
|
585
|
+
raise
|
|
586
|
+
except ValueError as exc:
|
|
587
|
+
# The OXML owner uses ValueError for rich/control-bearing or otherwise
|
|
588
|
+
# structurally unsafe headers. Do not let it degrade to the generic
|
|
589
|
+
# invariant envelope or fall back to the destructive whole-story setter.
|
|
590
|
+
message = str(exc) or "header content cannot be edited losslessly"
|
|
591
|
+
lowered = message.lower()
|
|
592
|
+
code = (
|
|
593
|
+
"ambiguous_target"
|
|
594
|
+
if "ambiguous" in lowered
|
|
595
|
+
else "not_found"
|
|
596
|
+
if "not found" in lowered
|
|
597
|
+
else "unsupported_content"
|
|
598
|
+
)
|
|
599
|
+
raise AgentContractError(
|
|
600
|
+
code,
|
|
601
|
+
message,
|
|
602
|
+
target=binding.path,
|
|
603
|
+
) from exc
|
|
604
|
+
return {
|
|
605
|
+
"text": {
|
|
606
|
+
"before": binding.text,
|
|
607
|
+
"after": properties["text"],
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _apply_set_paragraph(document: HwpxDocument, native: Any, properties: Mapping[str, Any]) -> None:
|
|
613
|
+
if "text" in properties:
|
|
614
|
+
native.text = properties["text"]
|
|
615
|
+
if "style" in properties:
|
|
616
|
+
native.style_id_ref = _style_id(document, properties["style"])
|
|
617
|
+
format_kwargs: dict[str, Any] = {}
|
|
618
|
+
if "alignment" in properties:
|
|
619
|
+
format_kwargs["alignment"] = properties["alignment"]
|
|
620
|
+
if "lineSpacingPercent" in properties:
|
|
621
|
+
format_kwargs["line_spacing_percent"] = properties["lineSpacingPercent"]
|
|
622
|
+
if "keepWithNext" in properties:
|
|
623
|
+
format_kwargs["keep_with_next"] = properties["keepWithNext"]
|
|
624
|
+
if "breakBefore" in properties:
|
|
625
|
+
format_kwargs["page_break_before"] = properties["breakBefore"]
|
|
626
|
+
if format_kwargs:
|
|
627
|
+
document.set_paragraph_format(
|
|
628
|
+
paragraph_index=_global_paragraph_index(document, native), **format_kwargs
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _apply_set_run(document: HwpxDocument, native: Any, properties: Mapping[str, Any]) -> None:
|
|
633
|
+
if "text" in properties:
|
|
634
|
+
native.text = properties["text"]
|
|
635
|
+
style = native.style
|
|
636
|
+
child_attrs = style.child_attributes if style is not None else {}
|
|
637
|
+
flags = {
|
|
638
|
+
"bold": "bold" in child_attrs,
|
|
639
|
+
"italic": "italic" in child_attrs,
|
|
640
|
+
"underline": child_attrs.get("underline", {}).get("type", "NONE").upper() != "NONE",
|
|
641
|
+
}
|
|
642
|
+
for name in flags:
|
|
643
|
+
if name in properties:
|
|
644
|
+
flags[name] = properties[name]
|
|
645
|
+
if set(properties) - {"text"}:
|
|
646
|
+
requested_font = properties.get("fontName")
|
|
647
|
+
if requested_font is not None and not any(
|
|
648
|
+
header.font_ref_for_face(requested_font) is not None
|
|
649
|
+
for header in document._root.headers # type: ignore[attr-defined]
|
|
650
|
+
):
|
|
651
|
+
raise AgentContractError(
|
|
652
|
+
"not_found", f"font is not declared by the document: {requested_font!r}", target="run.fontName"
|
|
653
|
+
)
|
|
654
|
+
style_id = document.ensure_run_style(
|
|
655
|
+
**flags,
|
|
656
|
+
color=properties.get("color"),
|
|
657
|
+
font=requested_font,
|
|
658
|
+
size=properties.get("fontSizePt"),
|
|
659
|
+
base_char_pr_id=native.char_pr_id_ref,
|
|
660
|
+
)
|
|
661
|
+
native.char_pr_id_ref = style_id
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _apply_set_table(native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
|
|
665
|
+
if "caption" in properties:
|
|
666
|
+
_table_caption(native, properties["caption"])
|
|
667
|
+
if "alignment" in properties:
|
|
668
|
+
pos = next((child for child in native.element if _local_name(child) == "pos"), None)
|
|
669
|
+
if pos is None:
|
|
670
|
+
raise AgentContractError("unsupported_content", "table has no position element", target=record.path)
|
|
671
|
+
pos.set("horzAlign", properties["alignment"].upper())
|
|
672
|
+
native.mark_dirty()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _apply_set_row(native: Any, properties: Mapping[str, Any]) -> None:
|
|
676
|
+
height = round(_require_number(properties["heightMm"], "row.heightMm", positive=True) * _HWP_UNITS_PER_MM)
|
|
677
|
+
for cell in native.cells:
|
|
678
|
+
cell.set_size(height=height)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _apply_set_cell(native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
|
|
682
|
+
if "text" in properties:
|
|
683
|
+
native.text = properties["text"]
|
|
684
|
+
if "verticalAlignment" in properties:
|
|
685
|
+
sublist = next((child for child in native.element if _local_name(child) == "subList"), None)
|
|
686
|
+
if sublist is None:
|
|
687
|
+
raise AgentContractError("unsupported_content", "cell has no subList", target=record.path)
|
|
688
|
+
sublist.set("vertAlign", properties["verticalAlignment"].upper())
|
|
689
|
+
native.table.mark_dirty()
|
|
690
|
+
if "backgroundColor" in properties:
|
|
691
|
+
row, column = native.address
|
|
692
|
+
native.table.set_cell_shading(row, column, properties["backgroundColor"])
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _apply_set_form_field(document: HwpxDocument, native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
|
|
696
|
+
if "value" in properties:
|
|
697
|
+
document.fill_form_field(properties["value"], field_index=int(native["index"]))
|
|
698
|
+
if "readOnly" in properties:
|
|
699
|
+
runs = native.get("_runs") or []
|
|
700
|
+
try:
|
|
701
|
+
ctrl = list(runs[int(native["run_index"])])[int(native["child_index"])]
|
|
702
|
+
begin = next(child for child in ctrl if _local_name(child) == "fieldBegin")
|
|
703
|
+
except (IndexError, KeyError, StopIteration, TypeError) as exc:
|
|
704
|
+
raise AgentContractError(
|
|
705
|
+
"unsupported_content", "form-field control is incomplete", target=record.path
|
|
706
|
+
) from exc
|
|
707
|
+
begin.set("editable", "false" if properties["readOnly"] else "true")
|
|
708
|
+
paragraph = native["_paragraph"]
|
|
709
|
+
paragraph.section.mark_dirty()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _apply_set_picture_shape(document: HwpxDocument, native: Any, kind: str, properties: Mapping[str, Any]) -> None:
|
|
713
|
+
target_element = _element(native)
|
|
714
|
+
_set_shape_comment(target_element, properties["altText"])
|
|
715
|
+
if kind == "shape":
|
|
716
|
+
native.paragraph.section.mark_dirty()
|
|
717
|
+
else:
|
|
718
|
+
_mark_containing_section(document, target_element)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _set_result_summary(record: NodeRecord, properties: Mapping[str, Any]) -> dict[str, Any]:
|
|
722
|
+
return {name: {"before": record.summary.get(name), "after": value} for name, value in properties.items()}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _apply_set(document: HwpxDocument, record: NodeRecord, properties: Mapping[str, Any]) -> dict[str, Any]:
|
|
726
|
+
_ensure_operation(record, "set")
|
|
727
|
+
_validate_property_values(record.kind, properties, creating=False)
|
|
728
|
+
native = record.native
|
|
729
|
+
kind = record.kind
|
|
730
|
+
|
|
731
|
+
if kind == "paragraph":
|
|
732
|
+
_apply_set_paragraph(document, native, properties)
|
|
733
|
+
elif kind == "run":
|
|
734
|
+
_apply_set_run(document, native, properties)
|
|
735
|
+
elif kind == "table":
|
|
736
|
+
_apply_set_table(native, properties, record)
|
|
737
|
+
elif kind == "row":
|
|
738
|
+
_apply_set_row(native, properties)
|
|
739
|
+
elif kind == "cell":
|
|
740
|
+
_apply_set_cell(native, properties, record)
|
|
741
|
+
elif kind == "form-field":
|
|
742
|
+
_apply_set_form_field(document, native, properties, record)
|
|
743
|
+
elif kind in {"picture", "shape"}:
|
|
744
|
+
_apply_set_picture_shape(document, native, kind, properties)
|
|
745
|
+
elif kind == "memo":
|
|
746
|
+
native.text = properties["text"]
|
|
747
|
+
elif kind in {"footnote", "endnote"}:
|
|
748
|
+
native.text = properties["text"]
|
|
749
|
+
else:
|
|
750
|
+
raise AgentContractError("unsupported_operation", f"set is unsupported for {kind}", target=record.path)
|
|
751
|
+
return _set_result_summary(record, properties)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _apply_create_properties(
|
|
755
|
+
document: HwpxDocument, record: NodeRecord, properties: Mapping[str, Any]
|
|
756
|
+
) -> dict[str, Any]:
|
|
757
|
+
# Geometry used to create tables/rows/sections is already present. Apply the
|
|
758
|
+
# remaining editable subset through the exact same set compiler.
|
|
759
|
+
editable = set(NODE_PROPERTY_CATALOG_V1[record.kind]["editable"])
|
|
760
|
+
setters = {name: value for name, value in properties.items() if name in editable}
|
|
761
|
+
return _apply_set(document, record, setters) if setters else {}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _add(
|
|
765
|
+
document: HwpxDocument,
|
|
766
|
+
view: HwpxAgentDocument,
|
|
767
|
+
parent: NodeRecord,
|
|
768
|
+
kind: str,
|
|
769
|
+
properties: Mapping[str, Any],
|
|
770
|
+
position: Mapping[str, Any],
|
|
771
|
+
) -> Any:
|
|
772
|
+
_ensure_parent(parent, source_kind=kind, operation="add")
|
|
773
|
+
if kind == "section":
|
|
774
|
+
if position["mode"] != "append":
|
|
775
|
+
raise AgentContractError(
|
|
776
|
+
"unsupported_operation", "section insertion is append-only in v1", target=parent.path
|
|
777
|
+
)
|
|
778
|
+
created = document.add_section()
|
|
779
|
+
size_kwargs: dict[str, int] = {}
|
|
780
|
+
if "pageWidthMm" in properties:
|
|
781
|
+
size_kwargs["width"] = round(properties["pageWidthMm"] * _HWP_UNITS_PER_MM)
|
|
782
|
+
if "pageHeightMm" in properties:
|
|
783
|
+
size_kwargs["height"] = round(properties["pageHeightMm"] * _HWP_UNITS_PER_MM)
|
|
784
|
+
if size_kwargs:
|
|
785
|
+
created.properties.set_page_size(
|
|
786
|
+
width=size_kwargs.get("width"),
|
|
787
|
+
height=size_kwargs.get("height"),
|
|
788
|
+
)
|
|
789
|
+
return created
|
|
790
|
+
if kind == "paragraph":
|
|
791
|
+
created = parent.native.add_paragraph(str(properties.get("text", "")))
|
|
792
|
+
# add_paragraph appends; relocate its native element for all other positions.
|
|
793
|
+
if position["mode"] != "append":
|
|
794
|
+
if hasattr(created.element, "getparent"):
|
|
795
|
+
created.element.getparent().remove(created.element)
|
|
796
|
+
else:
|
|
797
|
+
parent.native.element.remove(created.element)
|
|
798
|
+
_insert_direct_child(view, parent, kind, created.element, position)
|
|
799
|
+
parent.native.mark_dirty()
|
|
800
|
+
return created
|
|
801
|
+
if kind == "run":
|
|
802
|
+
run_kwargs = {
|
|
803
|
+
key: value
|
|
804
|
+
for key, value in {
|
|
805
|
+
"bold": properties.get("bold", False),
|
|
806
|
+
"italic": properties.get("italic", False),
|
|
807
|
+
"underline": properties.get("underline", False),
|
|
808
|
+
"color": properties.get("color"),
|
|
809
|
+
"font": properties.get("fontName"),
|
|
810
|
+
"size": properties.get("fontSizePt"),
|
|
811
|
+
}.items()
|
|
812
|
+
if value is not None
|
|
813
|
+
}
|
|
814
|
+
created = parent.native.add_run(str(properties.get("text", "")), **run_kwargs)
|
|
815
|
+
if position["mode"] != "append":
|
|
816
|
+
parent.native.element.remove(created.element)
|
|
817
|
+
_insert_direct_child(view, parent, kind, created.element, position)
|
|
818
|
+
parent.native.section.mark_dirty()
|
|
819
|
+
return created
|
|
820
|
+
if kind == "table":
|
|
821
|
+
created = parent.native.add_table(
|
|
822
|
+
int(properties["rowCount"]),
|
|
823
|
+
int(properties["columnCount"]),
|
|
824
|
+
width=(round(properties["widthMm"] * _HWP_UNITS_PER_MM) if "widthMm" in properties else None),
|
|
825
|
+
)
|
|
826
|
+
if position["mode"] != "append":
|
|
827
|
+
_remove_inline_element(created.element, parent.native)
|
|
828
|
+
_insert_inline(view, parent, kind, created.element, position)
|
|
829
|
+
return created
|
|
830
|
+
if kind == "row":
|
|
831
|
+
table = parent.native
|
|
832
|
+
if int(properties["cellCount"]) != table.column_count:
|
|
833
|
+
raise AgentContractError(
|
|
834
|
+
"incompatible_parent", "row cellCount must equal table columnCount", target=parent.path
|
|
835
|
+
)
|
|
836
|
+
sample = HwpxOxmlTable.create(
|
|
837
|
+
1,
|
|
838
|
+
table.column_count,
|
|
839
|
+
width=next(
|
|
840
|
+
(int(child.get("width", "0")) for child in table.element if _local_name(child) == "sz"),
|
|
841
|
+
None,
|
|
842
|
+
),
|
|
843
|
+
border_fill_id_ref=table.element.get("borderFillIDRef") or "0",
|
|
844
|
+
)
|
|
845
|
+
created = next(child for child in sample if _local_name(child) == "tr")
|
|
846
|
+
if type(created) is not type(table.element):
|
|
847
|
+
payload = ET.tostring(created, encoding="utf-8")
|
|
848
|
+
created = LET.fromstring(payload) if isinstance(table.element, LET._Element) else ET.fromstring(payload)
|
|
849
|
+
_insert_direct_child(view, parent, kind, created, position)
|
|
850
|
+
table.mark_dirty()
|
|
851
|
+
_refresh_table_rows(table)
|
|
852
|
+
return created
|
|
853
|
+
raise AgentContractError("unsupported_operation", f"add is unsupported for {kind}")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _table_has_vertical_merge(table: Any) -> bool:
|
|
857
|
+
return any(cell.span[0] > 1 for row in table.rows for cell in row.cells)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _refresh_table_rows(table: Any) -> None:
|
|
861
|
+
rows = table.rows
|
|
862
|
+
table.element.set("rowCnt", str(len(rows)))
|
|
863
|
+
for row_index, row in enumerate(rows):
|
|
864
|
+
for column_index, cell in enumerate(row.cells):
|
|
865
|
+
addr = next((child for child in cell.element if _local_name(child) == "cellAddr"), None)
|
|
866
|
+
if addr is not None:
|
|
867
|
+
addr.set("rowAddr", str(row_index))
|
|
868
|
+
# Preserve explicit merged-cell column addresses where present.
|
|
869
|
+
if addr.get("colAddr") is None:
|
|
870
|
+
addr.set("colAddr", str(column_index))
|
|
871
|
+
table.mark_dirty()
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _remove(document: HwpxDocument, record: NodeRecord) -> None:
|
|
875
|
+
_ensure_operation(record, "remove")
|
|
876
|
+
native = record.native
|
|
877
|
+
if record.kind == "paragraph":
|
|
878
|
+
native.remove()
|
|
879
|
+
elif record.kind == "run":
|
|
880
|
+
native.remove()
|
|
881
|
+
elif record.kind == "table":
|
|
882
|
+
_remove_inline_element(native.element, native.paragraph)
|
|
883
|
+
elif record.kind == "row":
|
|
884
|
+
table = native.table
|
|
885
|
+
if len(table.rows) <= 1:
|
|
886
|
+
raise AgentContractError("invariant_violation", "a table must retain one row", target=record.path)
|
|
887
|
+
if _table_has_vertical_merge(table):
|
|
888
|
+
raise AgentContractError(
|
|
889
|
+
"unsupported_content", "row removal with vertical merges is unsupported", target=record.path
|
|
890
|
+
)
|
|
891
|
+
table.element.remove(native.element)
|
|
892
|
+
_refresh_table_rows(table)
|
|
893
|
+
elif record.kind == "picture":
|
|
894
|
+
parent = _element(record.native).getparent()
|
|
895
|
+
if parent is None:
|
|
896
|
+
raise AgentContractError("not_found", "picture is detached", target=record.path)
|
|
897
|
+
parent.remove(_element(record.native))
|
|
898
|
+
_mark_containing_section(document, parent)
|
|
899
|
+
elif record.kind == "shape":
|
|
900
|
+
_remove_inline_element(native.element, native.paragraph)
|
|
901
|
+
elif record.kind == "memo":
|
|
902
|
+
native.remove()
|
|
903
|
+
elif record.kind in {"footnote", "endnote"}:
|
|
904
|
+
_remove_inline_element(native.element, native.paragraph)
|
|
905
|
+
else:
|
|
906
|
+
raise AgentContractError(
|
|
907
|
+
"unsupported_operation", f"remove is unsupported for {record.kind}", target=record.path
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _detach(view: HwpxAgentDocument, record: NodeRecord) -> Any:
|
|
912
|
+
element = _element(record.native)
|
|
913
|
+
if record.kind == "paragraph":
|
|
914
|
+
record.native.section.element.remove(element)
|
|
915
|
+
record.native.section.mark_dirty()
|
|
916
|
+
elif record.kind == "run":
|
|
917
|
+
record.native.paragraph.element.remove(element)
|
|
918
|
+
record.native.paragraph.section.mark_dirty()
|
|
919
|
+
elif record.kind in _INLINE_KINDS:
|
|
920
|
+
paragraph = record.native.paragraph if hasattr(record.native, "paragraph") else None
|
|
921
|
+
if paragraph is None and record.parent_path is not None:
|
|
922
|
+
parent_record = view.resolve_record(record.parent_path)
|
|
923
|
+
paragraph = parent_record.native if parent_record.kind == "paragraph" else None
|
|
924
|
+
if paragraph is None:
|
|
925
|
+
raise AgentContractError("unsupported_content", "inline object has no paragraph binding")
|
|
926
|
+
_remove_inline_element(element, paragraph)
|
|
927
|
+
elif record.kind == "row":
|
|
928
|
+
table = record.native.table
|
|
929
|
+
if _table_has_vertical_merge(table):
|
|
930
|
+
raise AgentContractError(
|
|
931
|
+
"unsupported_content", "row movement with vertical merges is unsupported", target=record.path
|
|
932
|
+
)
|
|
933
|
+
table.element.remove(element)
|
|
934
|
+
_refresh_table_rows(table)
|
|
935
|
+
elif record.kind == "memo":
|
|
936
|
+
record.native.group.element.remove(element)
|
|
937
|
+
record.native.group.section.mark_dirty()
|
|
938
|
+
else:
|
|
939
|
+
raise AgentContractError("unsupported_operation", f"cannot detach {record.kind}")
|
|
940
|
+
return element
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _move(
|
|
944
|
+
view: HwpxAgentDocument,
|
|
945
|
+
source: NodeRecord,
|
|
946
|
+
parent: NodeRecord,
|
|
947
|
+
position: Mapping[str, Any],
|
|
948
|
+
) -> Any:
|
|
949
|
+
_ensure_operation(source, "move")
|
|
950
|
+
_ensure_parent(parent, source_kind=source.kind, operation="move")
|
|
951
|
+
if source.path == parent.path or parent.path.startswith(source.path.rstrip("/") + "/"):
|
|
952
|
+
raise AgentContractError(
|
|
953
|
+
"incompatible_parent", "a node cannot move into its own subtree", target=parent.path
|
|
954
|
+
)
|
|
955
|
+
if source.kind == "row" and source.native.table.column_count != parent.native.column_count:
|
|
956
|
+
raise AgentContractError(
|
|
957
|
+
"incompatible_parent", "row destination must have the same column count", target=parent.path
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
element = _element(source.native)
|
|
961
|
+
siblings = _sibling_elements(view, parent, source.kind)
|
|
962
|
+
# Resolve before detaching so before/after anchors still exist. Moving to an
|
|
963
|
+
# explicit index uses the final sibling list after the source disappears.
|
|
964
|
+
anchor_index = _insertion_index(view, parent, source.kind, position, siblings=siblings)
|
|
965
|
+
if element in siblings:
|
|
966
|
+
old_index = siblings.index(element)
|
|
967
|
+
siblings.pop(old_index)
|
|
968
|
+
if position["mode"] in {"append", "prepend", "index"}:
|
|
969
|
+
anchor_index = _insertion_index(view, parent, source.kind, position, siblings=siblings)
|
|
970
|
+
elif old_index < anchor_index:
|
|
971
|
+
anchor_index -= 1
|
|
972
|
+
detached = _detach(view, source)
|
|
973
|
+
|
|
974
|
+
if source.kind in {"paragraph", "run", "row"}:
|
|
975
|
+
container = _element(parent.native)
|
|
976
|
+
if anchor_index >= len(siblings):
|
|
977
|
+
if siblings:
|
|
978
|
+
container.insert(list(container).index(siblings[-1]) + 1, detached)
|
|
979
|
+
else:
|
|
980
|
+
container.append(detached)
|
|
981
|
+
else:
|
|
982
|
+
container.insert(list(container).index(siblings[anchor_index]), detached)
|
|
983
|
+
if source.kind == "paragraph":
|
|
984
|
+
parent.native.mark_dirty()
|
|
985
|
+
elif source.kind == "run":
|
|
986
|
+
parent.native.section.mark_dirty()
|
|
987
|
+
else:
|
|
988
|
+
_refresh_table_rows(source.native.table)
|
|
989
|
+
_refresh_table_rows(parent.native)
|
|
990
|
+
elif source.kind in _INLINE_KINDS:
|
|
991
|
+
synthetic_position = {"mode": "index", "index": anchor_index + 1}
|
|
992
|
+
fresh_view = HwpxAgentDocument.from_document(view.document, revision=view.revision)
|
|
993
|
+
fresh_parent = _record_for_native(fresh_view, parent.native)
|
|
994
|
+
_insert_inline(fresh_view, fresh_parent, source.kind, detached, synthetic_position)
|
|
995
|
+
elif source.kind == "memo":
|
|
996
|
+
group = parent.native._memo_group_element(create=True)
|
|
997
|
+
if group is None: # pragma: no cover
|
|
998
|
+
raise AgentContractError("invariant_violation", "memo group creation failed")
|
|
999
|
+
siblings = [memo.element for memo in parent.native.memos]
|
|
1000
|
+
if position["mode"] == "append" or not siblings:
|
|
1001
|
+
group.append(detached)
|
|
1002
|
+
else:
|
|
1003
|
+
fresh_view = HwpxAgentDocument.from_document(view.document, revision=view.revision)
|
|
1004
|
+
fresh_parent = _record_for_native(fresh_view, parent.native)
|
|
1005
|
+
index = _insertion_index(fresh_view, fresh_parent, "memo", position)
|
|
1006
|
+
group.insert(index, detached)
|
|
1007
|
+
parent.native.mark_dirty()
|
|
1008
|
+
return detached
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _identity_kind(local: str) -> str:
|
|
1012
|
+
return {
|
|
1013
|
+
"p": "paragraph",
|
|
1014
|
+
"tbl": "table",
|
|
1015
|
+
"pic": "picture",
|
|
1016
|
+
"memo": "memo",
|
|
1017
|
+
"footNote": "footnote",
|
|
1018
|
+
"endNote": "endnote",
|
|
1019
|
+
"fieldBegin": "form-field",
|
|
1020
|
+
}.get(local, "shape" if local in _SHAPE_KINDS else local)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _refresh_copy_identities(document: HwpxDocument, clone: Any) -> list[dict[str, str]]:
|
|
1024
|
+
used: set[str] = set()
|
|
1025
|
+
max_numeric = 0
|
|
1026
|
+
for section in document.sections:
|
|
1027
|
+
for node in section.element.iter():
|
|
1028
|
+
for name in ("id", "instid", "instId"):
|
|
1029
|
+
value = node.get(name)
|
|
1030
|
+
if value:
|
|
1031
|
+
used.add(value)
|
|
1032
|
+
if value.isdigit():
|
|
1033
|
+
max_numeric = max(max_numeric, int(value))
|
|
1034
|
+
|
|
1035
|
+
def allocate() -> str:
|
|
1036
|
+
nonlocal max_numeric
|
|
1037
|
+
while True:
|
|
1038
|
+
max_numeric += 1
|
|
1039
|
+
value = str(max_numeric)
|
|
1040
|
+
if value not in used:
|
|
1041
|
+
used.add(value)
|
|
1042
|
+
return value
|
|
1043
|
+
|
|
1044
|
+
identity_map: list[dict[str, str]] = []
|
|
1045
|
+
field_ids: dict[str, str] = {}
|
|
1046
|
+
for node in clone.iter():
|
|
1047
|
+
local = _local_name(node)
|
|
1048
|
+
names: tuple[str, ...] = ()
|
|
1049
|
+
if local in {"p", "tbl", "pic", "memo", "fieldBegin", *_SHAPE_KINDS}:
|
|
1050
|
+
names = tuple(name for name in ("id", "instid", "instId") if node.get(name) is not None)
|
|
1051
|
+
elif local in {"footNote", "endNote"}:
|
|
1052
|
+
names = tuple(name for name in ("instId", "id") if node.get(name) is not None)
|
|
1053
|
+
paired_identity = allocate() if len(names) > 1 and local in {"pic", *_SHAPE_KINDS} else None
|
|
1054
|
+
for name in names:
|
|
1055
|
+
old = str(node.get(name))
|
|
1056
|
+
new = paired_identity or allocate()
|
|
1057
|
+
node.set(name, new)
|
|
1058
|
+
identity_map.append(
|
|
1059
|
+
{"kind": _identity_kind(local), "attribute": name, "old": old, "new": new}
|
|
1060
|
+
)
|
|
1061
|
+
if local == "fieldBegin" and name == "id":
|
|
1062
|
+
field_ids[old] = new
|
|
1063
|
+
if field_ids:
|
|
1064
|
+
for node in clone.iter():
|
|
1065
|
+
if _local_name(node) != "fieldEnd":
|
|
1066
|
+
continue
|
|
1067
|
+
old = node.get("beginIDRef")
|
|
1068
|
+
if old in field_ids:
|
|
1069
|
+
node.set("beginIDRef", field_ids[old])
|
|
1070
|
+
return identity_map
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _copy_node(
|
|
1074
|
+
document: HwpxDocument,
|
|
1075
|
+
view: HwpxAgentDocument,
|
|
1076
|
+
source: NodeRecord,
|
|
1077
|
+
parent: NodeRecord,
|
|
1078
|
+
position: Mapping[str, Any],
|
|
1079
|
+
) -> tuple[Any, list[dict[str, str]]]:
|
|
1080
|
+
_ensure_operation(source, "copy")
|
|
1081
|
+
_ensure_parent(parent, source_kind=source.kind, operation="copy")
|
|
1082
|
+
if source.kind == "row":
|
|
1083
|
+
if source.native.table.column_count != parent.native.column_count:
|
|
1084
|
+
raise AgentContractError(
|
|
1085
|
+
"incompatible_parent", "row destination must have the same column count", target=parent.path
|
|
1086
|
+
)
|
|
1087
|
+
if _table_has_vertical_merge(source.native.table) or _table_has_vertical_merge(parent.native):
|
|
1088
|
+
raise AgentContractError(
|
|
1089
|
+
"unsupported_content", "row copy with vertical merges is unsupported", target=source.path
|
|
1090
|
+
)
|
|
1091
|
+
clone = copy.deepcopy(_element(source.native))
|
|
1092
|
+
identity_map = _refresh_copy_identities(document, clone)
|
|
1093
|
+
if source.kind in {"paragraph", "run", "row"}:
|
|
1094
|
+
_insert_direct_child(view, parent, source.kind, clone, position)
|
|
1095
|
+
if source.kind == "paragraph":
|
|
1096
|
+
parent.native.mark_dirty()
|
|
1097
|
+
elif source.kind == "run":
|
|
1098
|
+
parent.native.section.mark_dirty()
|
|
1099
|
+
else:
|
|
1100
|
+
_refresh_table_rows(parent.native)
|
|
1101
|
+
elif source.kind in _INLINE_KINDS:
|
|
1102
|
+
_insert_inline(view, parent, source.kind, clone, position)
|
|
1103
|
+
elif source.kind == "memo":
|
|
1104
|
+
group = parent.native._memo_group_element(create=True)
|
|
1105
|
+
if group is None: # pragma: no cover
|
|
1106
|
+
raise AgentContractError("invariant_violation", "memo group creation failed")
|
|
1107
|
+
siblings = [memo.element for memo in parent.native.memos]
|
|
1108
|
+
index = _insertion_index(view, parent, "memo", position, siblings=siblings)
|
|
1109
|
+
group.insert(index, clone)
|
|
1110
|
+
parent.native.mark_dirty()
|
|
1111
|
+
else:
|
|
1112
|
+
raise AgentContractError("unsupported_operation", f"copy is unsupported for {source.kind}")
|
|
1113
|
+
return clone, identity_map
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _dispatch_command_op(
|
|
1117
|
+
document: HwpxDocument,
|
|
1118
|
+
view: HwpxAgentDocument,
|
|
1119
|
+
command: Mapping[str, Any],
|
|
1120
|
+
aliases: dict[str, dict[str, str]],
|
|
1121
|
+
input_revision: str,
|
|
1122
|
+
) -> tuple[str | None, str | None, dict[str, Any], list[dict[str, str]], Any, HeaderStoryBinding | None]:
|
|
1123
|
+
"""Apply one command's op and return its raw effects.
|
|
1124
|
+
|
|
1125
|
+
Returns (resolved_path, parent_path, changed, generated, target_native, story_before).
|
|
1126
|
+
"""
|
|
1127
|
+
|
|
1128
|
+
op = command["op"]
|
|
1129
|
+
resolved_path: str | None = None
|
|
1130
|
+
parent_path: str | None = None
|
|
1131
|
+
changed: dict[str, Any] = {}
|
|
1132
|
+
generated: list[dict[str, str]] = []
|
|
1133
|
+
target_native: Any | None = None
|
|
1134
|
+
story_before: HeaderStoryBinding | None = None
|
|
1135
|
+
if op == "set":
|
|
1136
|
+
resolved_path = _resolve_alias(command["path"], aliases)
|
|
1137
|
+
story_path = try_parse_header_story_path(resolved_path)
|
|
1138
|
+
if story_path is not None:
|
|
1139
|
+
story_before = view._resolve_header_story(story_path, expected_revision=input_revision)
|
|
1140
|
+
changed = _apply_header_story_set(story_before, command["properties"])
|
|
1141
|
+
else:
|
|
1142
|
+
record = view.resolve_record(resolved_path, expected_revision=input_revision)
|
|
1143
|
+
changed = _apply_set(document, record, command["properties"])
|
|
1144
|
+
target_native = record.native
|
|
1145
|
+
elif op == "add":
|
|
1146
|
+
parent_path = _resolve_alias(command["parent"], aliases)
|
|
1147
|
+
parent = view.resolve_record(parent_path, expected_revision=input_revision)
|
|
1148
|
+
position = _resolved_position(command["position"], aliases)
|
|
1149
|
+
target_native = _add(document, view, parent, command["kind"], command["properties"], position)
|
|
1150
|
+
created_view = HwpxAgentDocument.from_document(document, revision=input_revision)
|
|
1151
|
+
created_record = _record_for_native(created_view, target_native)
|
|
1152
|
+
changed = _apply_create_properties(document, created_record, command["properties"])
|
|
1153
|
+
elif op == "remove":
|
|
1154
|
+
resolved_path = _resolve_alias(command["path"], aliases)
|
|
1155
|
+
record = view.resolve_record(resolved_path, expected_revision=input_revision)
|
|
1156
|
+
parent_path = record.parent_path
|
|
1157
|
+
_remove(document, record)
|
|
1158
|
+
target_native = None
|
|
1159
|
+
elif op == "move":
|
|
1160
|
+
resolved_path = _resolve_alias(command["path"], aliases)
|
|
1161
|
+
parent_path = _resolve_alias(command["parent"], aliases)
|
|
1162
|
+
source = view.resolve_record(resolved_path, expected_revision=input_revision)
|
|
1163
|
+
parent = view.resolve_record(parent_path, expected_revision=input_revision)
|
|
1164
|
+
position = _resolved_position(command["position"], aliases)
|
|
1165
|
+
target_native = _move(view, source, parent, position)
|
|
1166
|
+
else: # copy
|
|
1167
|
+
resolved_path = _resolve_alias(command["path"], aliases)
|
|
1168
|
+
parent_path = _resolve_alias(command["parent"], aliases)
|
|
1169
|
+
source = view.resolve_record(resolved_path, expected_revision=input_revision)
|
|
1170
|
+
parent = view.resolve_record(parent_path, expected_revision=input_revision)
|
|
1171
|
+
position = _resolved_position(command["position"], aliases)
|
|
1172
|
+
target_native, generated = _copy_node(document, view, source, parent, position)
|
|
1173
|
+
return resolved_path, parent_path, changed, generated, target_native, story_before
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _finalize_command_result(
|
|
1177
|
+
view: HwpxAgentDocument,
|
|
1178
|
+
command: Mapping[str, Any],
|
|
1179
|
+
command_id: str,
|
|
1180
|
+
op: str,
|
|
1181
|
+
resolved_path: str | None,
|
|
1182
|
+
parent_path: str | None,
|
|
1183
|
+
target_native: Any,
|
|
1184
|
+
story_before: HeaderStoryBinding | None,
|
|
1185
|
+
story_expectations: dict[str, Mapping[str, str]],
|
|
1186
|
+
) -> tuple[str, str, str | None]:
|
|
1187
|
+
"""Return (result_path, result_parent, result_stable_id) for one applied command."""
|
|
1188
|
+
|
|
1189
|
+
result_stable_id: str | None = None
|
|
1190
|
+
if story_before is not None and resolved_path is not None:
|
|
1191
|
+
target_story = view._resolve_header_story(resolved_path)
|
|
1192
|
+
if target_story.stable_id != story_before.stable_id:
|
|
1193
|
+
raise AgentContractError(
|
|
1194
|
+
"invariant_violation",
|
|
1195
|
+
"header story identity changed during text edit",
|
|
1196
|
+
target=resolved_path,
|
|
1197
|
+
)
|
|
1198
|
+
result_path = target_story.path
|
|
1199
|
+
result_parent = target_story.parent_path
|
|
1200
|
+
result_stable_id = target_story.stable_id
|
|
1201
|
+
story_expectations[target_story.binding_key] = {
|
|
1202
|
+
"commandId": command_id,
|
|
1203
|
+
"path": target_story.path,
|
|
1204
|
+
"stableId": target_story.stable_id,
|
|
1205
|
+
"pageType": target_story.page_type,
|
|
1206
|
+
"text": command["properties"]["text"],
|
|
1207
|
+
}
|
|
1208
|
+
elif op == "set" and resolved_path is not None:
|
|
1209
|
+
# Some native bindings (notably form fields) are request-local
|
|
1210
|
+
# match dictionaries. A property edit is non-structural, so
|
|
1211
|
+
# its canonical path is the durable post-edit lookup key.
|
|
1212
|
+
target_record = view.resolve_record(resolved_path)
|
|
1213
|
+
result_path = target_record.path
|
|
1214
|
+
result_parent = target_record.parent_path or "/"
|
|
1215
|
+
elif target_native is not None:
|
|
1216
|
+
target_record = _record_for_native(view, target_native)
|
|
1217
|
+
result_path = target_record.path
|
|
1218
|
+
result_parent = target_record.parent_path or "/"
|
|
1219
|
+
else:
|
|
1220
|
+
result_path = resolved_path or parent_path or "/"
|
|
1221
|
+
result_parent = parent_path or "/"
|
|
1222
|
+
return result_path, result_parent, result_stable_id
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def apply_document_commands(
|
|
1226
|
+
batch: Mapping[str, Any],
|
|
1227
|
+
*,
|
|
1228
|
+
idempotency_store: IdempotencyStore | None = None,
|
|
1229
|
+
fault_injector: FaultInjector | None = None,
|
|
1230
|
+
domain_verifier: DomainVerifier | None = None,
|
|
1231
|
+
save_pipeline: SavePipeline | None = None,
|
|
1232
|
+
) -> AgentBatchResult:
|
|
1233
|
+
"""Validate and atomically apply one ``hwpx.agent-batch/v1`` request.
|
|
1234
|
+
|
|
1235
|
+
``idempotency_store`` is explicitly caller-owned. The core keeps no hidden
|
|
1236
|
+
resident state in Leap A; MCP/CLI adapters may supply their existing scoped
|
|
1237
|
+
stores. Any failure returns a structured rolled-back result and leaves the
|
|
1238
|
+
declared output untouched.
|
|
1239
|
+
"""
|
|
1240
|
+
|
|
1241
|
+
normalized: Mapping[str, Any] | None = None
|
|
1242
|
+
input_revision = _EMPTY_REVISION
|
|
1243
|
+
command_results: list[Mapping[str, Any]] = []
|
|
1244
|
+
verification: dict[str, Any] = {}
|
|
1245
|
+
try:
|
|
1246
|
+
normalized = validate_agent_batch(batch)
|
|
1247
|
+
_preflight(normalized)
|
|
1248
|
+
verification.update(
|
|
1249
|
+
{
|
|
1250
|
+
"schemaVersion": normalized["schemaVersion"],
|
|
1251
|
+
"catalogHash": catalog_hash(),
|
|
1252
|
+
"requirements": list(normalized["verificationRequirements"]),
|
|
1253
|
+
}
|
|
1254
|
+
)
|
|
1255
|
+
request_hash = _request_hash(normalized)
|
|
1256
|
+
key = normalized["idempotencyKey"]
|
|
1257
|
+
cached_result = _apply_commands_idempotency_lookup(
|
|
1258
|
+
verification, idempotency_store, key=key, request_hash=request_hash
|
|
1259
|
+
)
|
|
1260
|
+
if cached_result is not None:
|
|
1261
|
+
return cached_result
|
|
1262
|
+
|
|
1263
|
+
input_path = Path(normalized["input"]["filename"])
|
|
1264
|
+
output_path = Path(normalized["output"]["filename"])
|
|
1265
|
+
input_data = input_path.read_bytes()
|
|
1266
|
+
input_revision = _revision(input_data)
|
|
1267
|
+
_validate_apply_commands_input(normalized, verification, input_data, input_revision, output_path)
|
|
1268
|
+
|
|
1269
|
+
aliases: dict[str, dict[str, str]] = {}
|
|
1270
|
+
identity_changes: list[Mapping[str, str]] = []
|
|
1271
|
+
semantic_changes: list[Mapping[str, Any]] = []
|
|
1272
|
+
story_expectations: dict[str, Mapping[str, str]] = {}
|
|
1273
|
+
_call_fault(fault_injector, "before_open")
|
|
1274
|
+
with HwpxDocument.open(input_data) as document:
|
|
1275
|
+
view = HwpxAgentDocument.from_document(document, revision=input_revision)
|
|
1276
|
+
for index, command in enumerate(normalized["commands"]):
|
|
1277
|
+
_call_fault(fault_injector, "before_command", index)
|
|
1278
|
+
command_id = command["commandId"]
|
|
1279
|
+
op = command["op"]
|
|
1280
|
+
resolved_path, parent_path, changed, generated, target_native, story_before = _dispatch_command_op(
|
|
1281
|
+
document, view, command, aliases, input_revision
|
|
1282
|
+
)
|
|
1283
|
+
identity_changes.extend(generated)
|
|
1284
|
+
|
|
1285
|
+
_call_fault(fault_injector, "after_command", index)
|
|
1286
|
+
view = HwpxAgentDocument.from_document(document, revision=input_revision)
|
|
1287
|
+
result_path, result_parent, result_stable_id = _finalize_command_result(
|
|
1288
|
+
view,
|
|
1289
|
+
command,
|
|
1290
|
+
command_id,
|
|
1291
|
+
op,
|
|
1292
|
+
resolved_path,
|
|
1293
|
+
parent_path,
|
|
1294
|
+
target_native,
|
|
1295
|
+
story_before,
|
|
1296
|
+
story_expectations,
|
|
1297
|
+
)
|
|
1298
|
+
aliases[command_id] = {"path": result_path, "parentPath": result_parent}
|
|
1299
|
+
result = {
|
|
1300
|
+
"commandId": command_id,
|
|
1301
|
+
"op": op,
|
|
1302
|
+
"ok": True,
|
|
1303
|
+
"path": result_path,
|
|
1304
|
+
"parentPath": result_parent,
|
|
1305
|
+
"changedProperties": changed,
|
|
1306
|
+
"generatedIdentities": generated,
|
|
1307
|
+
"warnings": [],
|
|
1308
|
+
}
|
|
1309
|
+
if result_stable_id is not None:
|
|
1310
|
+
# Command results are already an untyped JSON mapping. This
|
|
1311
|
+
# story-only receipt carries the actual stable identity
|
|
1312
|
+
# without changing AgentBatchResult or the ToolSpec schema.
|
|
1313
|
+
result["stableId"] = result_stable_id
|
|
1314
|
+
command_results.append(result)
|
|
1315
|
+
semantic_changes.append(
|
|
1316
|
+
{
|
|
1317
|
+
"commandId": command_id,
|
|
1318
|
+
"op": op,
|
|
1319
|
+
"beforePath": resolved_path,
|
|
1320
|
+
"afterPath": None if op == "remove" else result_path,
|
|
1321
|
+
"changedProperties": changed,
|
|
1322
|
+
}
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
_call_fault(fault_injector, "before_serialize")
|
|
1326
|
+
candidate_data = document.to_bytes()
|
|
1327
|
+
_call_fault(fault_injector, "after_serialize")
|
|
1328
|
+
|
|
1329
|
+
candidate_revision, semantic_diff, safety, byte_report = _apply_commands_build_candidate_report(
|
|
1330
|
+
input_data,
|
|
1331
|
+
candidate_data,
|
|
1332
|
+
input_revision,
|
|
1333
|
+
semantic_changes,
|
|
1334
|
+
identity_changes,
|
|
1335
|
+
story_expectations,
|
|
1336
|
+
verification,
|
|
1337
|
+
)
|
|
1338
|
+
requirements = set(normalized["verificationRequirements"])
|
|
1339
|
+
_apply_commands_domain_verification(candidate_data, normalized, requirements, domain_verifier, verification)
|
|
1340
|
+
_require_candidate_structural_safety(safety, byte_report)
|
|
1341
|
+
|
|
1342
|
+
_call_fault(fault_injector, "before_save")
|
|
1343
|
+
_apply_commands_run_save_pipeline(
|
|
1344
|
+
candidate_data,
|
|
1345
|
+
input_path,
|
|
1346
|
+
output_path,
|
|
1347
|
+
document,
|
|
1348
|
+
normalized,
|
|
1349
|
+
requirements,
|
|
1350
|
+
save_pipeline,
|
|
1351
|
+
verification,
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
batch_result = AgentBatchResult(
|
|
1355
|
+
ok=True,
|
|
1356
|
+
rolled_back=False,
|
|
1357
|
+
dry_run=normalized["dryRun"],
|
|
1358
|
+
input_revision=input_revision,
|
|
1359
|
+
document_revision=candidate_revision,
|
|
1360
|
+
output_filename=str(output_path),
|
|
1361
|
+
command_results=tuple(command_results),
|
|
1362
|
+
semantic_diff=semantic_diff,
|
|
1363
|
+
verification_report=verification,
|
|
1364
|
+
)
|
|
1365
|
+
if key is not None and idempotency_store is not None:
|
|
1366
|
+
idempotency_store[key] = {"requestHash": request_hash, "result": batch_result}
|
|
1367
|
+
return batch_result
|
|
1368
|
+
except BaseException as exc: # fail closed; caller receives a stable error contract
|
|
1369
|
+
return _failure_result(
|
|
1370
|
+
exc=exc,
|
|
1371
|
+
batch=normalized or batch,
|
|
1372
|
+
input_revision=input_revision,
|
|
1373
|
+
command_results=command_results,
|
|
1374
|
+
verification=verification,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
__all__ = [
|
|
1379
|
+
"DomainVerifier",
|
|
1380
|
+
"FaultInjector",
|
|
1381
|
+
"IdempotencyStore",
|
|
1382
|
+
"apply_document_commands",
|
|
1383
|
+
]
|