python-hwpx-automation 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,801 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Bounded semantic projection over one HWPX document revision."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ from collections import Counter, defaultdict
8
+ from dataclasses import dataclass, field
9
+ from os import PathLike
10
+ from pathlib import Path
11
+ from typing import Any, Sequence, cast
12
+
13
+ from hwpx.document import HwpxDocument
14
+ from hwpx.oxml import HwpxOxmlShape
15
+
16
+ from .model import (
17
+ MAX_CHILDREN_PER_NODE,
18
+ MAX_TEXT_CHARS,
19
+ MAX_VIEW_DEPTH,
20
+ AgentContractError,
21
+ AgentNode,
22
+ NODE_PROPERTY_CATALOG_V1,
23
+ )
24
+ from .path import SemanticPath, canonicalize_path, identified_segment, indexed_segment
25
+ from .query import QueryRecord as _QueryRecord
26
+ from .query import QueryResult, evaluate_selector, parse_selector
27
+ from .story import (
28
+ HeaderStoryBinding,
29
+ HeaderStoryPath,
30
+ parse_header_story_path,
31
+ resolve_header_story,
32
+ )
33
+
34
+ _HWP_UNITS_PER_MM = 7200 / 25.4
35
+ _SHAPE_KINDS = frozenset({"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine"})
36
+ _IGNORED_INLINE_KINDS = frozenset({"t", "tab", "lineBreak", "secPr", "fieldEnd"})
37
+
38
+
39
+ def _local_name(element: Any) -> str:
40
+ tag = getattr(element, "tag", "")
41
+ return str(tag).rsplit("}", 1)[-1]
42
+
43
+
44
+ def _bounded_text(value: object, *, limit: int = 512) -> str:
45
+ limit = min(limit, MAX_TEXT_CHARS)
46
+ text = str(value or "")
47
+ return text if len(text) <= limit else text[: limit - 1] + "…"
48
+
49
+
50
+ def _mm(value: object) -> float | None:
51
+ try:
52
+ units = int(str(value))
53
+ except (TypeError, ValueError):
54
+ return None
55
+ return round(units / _HWP_UNITS_PER_MM, 3)
56
+
57
+
58
+ def _first_child(element: Any, local_name: str) -> Any | None:
59
+ for child in element:
60
+ if _local_name(child) == local_name:
61
+ return child
62
+ return None
63
+
64
+
65
+ def _child_text(element: Any, local_name: str) -> str | None:
66
+ child = _first_child(element, local_name)
67
+ if child is None:
68
+ return None
69
+ value = "".join(str(part) for part in child.itertext()).strip()
70
+ return _bounded_text(value) if value else None
71
+
72
+
73
+ def _identity(element: Any, *names: str) -> str | None:
74
+ for name in names:
75
+ value = element.get(name)
76
+ if value is not None and str(value).strip():
77
+ return str(value).strip()
78
+ return None
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class NodeRecord:
83
+ """Request-local native binding; never serialized directly."""
84
+
85
+ kind: str
86
+ path: str
87
+ stable_id: str | None
88
+ stability: str
89
+ summary: dict[str, Any]
90
+ native: Any
91
+ parent_path: str | None
92
+ attributes: dict[str, str] = field(default_factory=dict)
93
+ search_text: str = ""
94
+ child_paths: list[str] = field(default_factory=list)
95
+ unsupported_child_count: int = 0
96
+ unsupported_child_kinds: Counter[str] = field(default_factory=Counter)
97
+
98
+ def mark_unsupported(self, local_name: str, count: int = 1) -> None:
99
+ self.unsupported_child_kinds[local_name] += count
100
+ self.unsupported_child_count += count
101
+
102
+
103
+ class HwpxAgentDocument:
104
+ """A revision-bound semantic view with request-local native bindings."""
105
+
106
+ def __init__(self, document: HwpxDocument, revision: str, *, owns_document: bool = False) -> None:
107
+ if not revision.startswith("sha256:") or len(revision) != 71:
108
+ raise AgentContractError("invalid_syntax", "revision must be sha256", target="revision")
109
+ self.document = document
110
+ self.revision = revision
111
+ self._owns_document = owns_document
112
+ self._records: dict[str, NodeRecord] = {}
113
+ self._ordered_paths: list[str] = []
114
+ self._ambiguous_paths: set[str] = set()
115
+ self._form_fields_by_paragraph: dict[Any, list[dict[str, Any]]] = defaultdict(list)
116
+ self._form_control_positions: set[tuple[int, int, int, int]] = set()
117
+ self._id_counts: Counter[tuple[str, str]] = Counter()
118
+ self._field_name_counts: Counter[str] = Counter()
119
+ self._collect_identities()
120
+ self._collect_form_fields()
121
+ self._project()
122
+
123
+ @classmethod
124
+ def open(cls, source: str | PathLike[str] | bytes) -> "HwpxAgentDocument":
125
+ if isinstance(source, bytes):
126
+ data = source
127
+ else:
128
+ data = Path(source).read_bytes()
129
+ revision = "sha256:" + hashlib.sha256(data).hexdigest()
130
+ return cls(HwpxDocument.open(data), revision, owns_document=True)
131
+
132
+ @classmethod
133
+ def from_document(cls, document: HwpxDocument, *, revision: str) -> "HwpxAgentDocument":
134
+ return cls(document, revision, owns_document=False)
135
+
136
+ def __enter__(self) -> "HwpxAgentDocument":
137
+ return self
138
+
139
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: # type: ignore[exit-return] # frozen public signature
140
+ self.close()
141
+ return False
142
+
143
+ def close(self) -> None:
144
+ if self._owns_document:
145
+ self.document.close()
146
+ self._owns_document = False
147
+
148
+ @property
149
+ def records(self) -> tuple[NodeRecord, ...]:
150
+ return tuple(self._records[path] for path in self._ordered_paths)
151
+
152
+ def _collect_identities(self) -> None:
153
+ tag_to_kind = {
154
+ "p": "paragraph",
155
+ "tbl": "table",
156
+ "pic": "picture",
157
+ "memo": "memo",
158
+ "footNote": "footnote",
159
+ "endNote": "endnote",
160
+ **{name: "shape" for name in _SHAPE_KINDS},
161
+ }
162
+ for section in self.document.sections:
163
+ for element in section.element.iter():
164
+ kind = tag_to_kind.get(_local_name(element))
165
+ if kind is None:
166
+ continue
167
+ value = _identity(element, "id", "instId", "instid")
168
+ if value:
169
+ self._id_counts[(kind, value)] += 1
170
+
171
+ def _collect_form_fields(self) -> None:
172
+ # This private iterator is deliberately consumed inside python-hwpx so
173
+ # later mutation can reuse its already-safe field matching semantics.
174
+ matches = self.document._iter_form_field_matches() # type: ignore[attr-defined]
175
+ for match in matches:
176
+ section_index = int(match["section_index"])
177
+ paragraph_index = int(match["paragraph_index_in_section"])
178
+ paragraph = match.get("_paragraph")
179
+ if paragraph is not None:
180
+ self._form_fields_by_paragraph[paragraph.element].append(match)
181
+ self._form_control_positions.add(
182
+ (
183
+ section_index,
184
+ paragraph_index,
185
+ int(match["run_index"]),
186
+ int(match["child_index"]),
187
+ )
188
+ )
189
+ field_id = str(match.get("field_id") or match.get("id") or "").strip()
190
+ if field_id:
191
+ self._id_counts[("form-field", field_id)] += 1
192
+ name = str(match.get("name") or "").strip()
193
+ if name:
194
+ self._field_name_counts[name] += 1
195
+
196
+ def _add_record(self, record: NodeRecord) -> None:
197
+ if record.path in self._records:
198
+ raise AgentContractError(
199
+ "invariant_violation", f"duplicate canonical path: {record.path}", target=record.path
200
+ )
201
+ self._records[record.path] = record
202
+ self._ordered_paths.append(record.path)
203
+ if record.parent_path is not None:
204
+ self._records[record.parent_path].child_paths.append(record.path)
205
+
206
+ def _segment_for(
207
+ self,
208
+ *,
209
+ kind: str,
210
+ index: int,
211
+ identity: str | None,
212
+ name: str | None = None,
213
+ ) -> tuple[Any, str | None, str]:
214
+ if identity:
215
+ candidate = identified_segment(kind, identity)
216
+ if self._id_counts[(kind, identity)] == 1:
217
+ return candidate, f"{kind}:{identity}", "native"
218
+ self._ambiguous_paths.add(SemanticPath((candidate,)).canonical)
219
+ return indexed_segment(kind, index), None, "positional"
220
+ if kind == "form-field" and name and self._field_name_counts[name] == 1:
221
+ return identified_segment(kind, name, attribute="name"), f"form-field-name:{name}", "derived"
222
+ if kind == "form-field" and name:
223
+ name_segment = identified_segment(kind, name, attribute="name")
224
+ self._ambiguous_paths.add(SemanticPath((name_segment,)).canonical)
225
+ return indexed_segment(kind, index), None, "positional"
226
+
227
+ def _register_ambiguous_relative(
228
+ self,
229
+ parent: SemanticPath,
230
+ kind: str,
231
+ identity: str,
232
+ *,
233
+ attribute: str = "id",
234
+ ) -> None:
235
+ segment = identified_segment(kind, identity, attribute=attribute)
236
+ self._ambiguous_paths.add(parent.child(segment).canonical)
237
+
238
+ def _project(self) -> None:
239
+ root = NodeRecord(
240
+ kind="document",
241
+ path="/",
242
+ stable_id="document",
243
+ stability="derived",
244
+ summary={},
245
+ native=self.document,
246
+ parent_path=None,
247
+ attributes={"type": "document"},
248
+ )
249
+ self._add_record(root)
250
+ for section_index, section in enumerate(self.document.sections, start=1):
251
+ section_path = SemanticPath().child(indexed_segment("section", section_index))
252
+ page_size = section.properties.page_size
253
+ section_record = NodeRecord(
254
+ kind="section",
255
+ path=section_path.canonical,
256
+ stable_id=None,
257
+ stability="positional",
258
+ summary={
259
+ "index": section_index,
260
+ "partId": Path(section.part_name).name,
261
+ "paragraphCount": len(section.paragraphs),
262
+ "pageWidthMm": _mm(page_size.width),
263
+ "pageHeightMm": _mm(page_size.height),
264
+ },
265
+ native=section,
266
+ parent_path="/",
267
+ attributes={"type": "section"},
268
+ )
269
+ self._add_record(section_record)
270
+ for child in section.element:
271
+ local = _local_name(child)
272
+ if local not in {"p", "memogroup"}:
273
+ section_record.mark_unsupported(local)
274
+ for paragraph_index, paragraph in enumerate(section.paragraphs, start=1):
275
+ self._project_paragraph(
276
+ paragraph,
277
+ section_path,
278
+ paragraph_index,
279
+ section_index=section_index - 1,
280
+ paragraph_index_in_section=paragraph_index - 1,
281
+ )
282
+ for memo_index, memo in enumerate(section.memos, start=1):
283
+ identity = memo.id
284
+ segment, stable_id, stability = self._segment_for(
285
+ kind="memo", index=memo_index, identity=identity
286
+ )
287
+ if identity and stability == "positional":
288
+ self._register_ambiguous_relative(section_path, "memo", identity)
289
+ memo_path = section_path.child(segment)
290
+ self._add_record(
291
+ NodeRecord(
292
+ kind="memo",
293
+ path=memo_path.canonical,
294
+ stable_id=stable_id,
295
+ stability=stability,
296
+ summary={
297
+ "text": _bounded_text(memo.text),
298
+ "author": memo.attributes.get("author"),
299
+ },
300
+ native=memo,
301
+ parent_path=section_path.canonical,
302
+ attributes={"id": identity or "", "type": "memo"},
303
+ search_text=memo.text,
304
+ )
305
+ )
306
+ kinds = Counter(record.kind for record in self.records)
307
+ root.summary.update(
308
+ {
309
+ "sectionCount": kinds["section"],
310
+ "paragraphCount": kinds["paragraph"],
311
+ "tableCount": kinds["table"],
312
+ }
313
+ )
314
+
315
+ def _paragraph_summary(self, paragraph: Any) -> tuple[dict[str, Any], dict[str, str]]:
316
+ style_ref = paragraph.element.get("styleIDRef")
317
+ style = self.document.style(style_ref)
318
+ style_name = style.name if style is not None and style.name else style_ref
319
+ para_pr = self.document.paragraph_property(paragraph.element.get("paraPrIDRef"))
320
+ summary: dict[str, Any] = {"text": _bounded_text(paragraph.text), "style": style_name}
321
+ if para_pr is not None:
322
+ summary["alignment"] = para_pr.align.horizontal if para_pr.align is not None else None
323
+ summary["breakBefore"] = (
324
+ para_pr.break_setting.page_break_before if para_pr.break_setting is not None else None
325
+ )
326
+ summary["keepWithNext"] = (
327
+ para_pr.break_setting.keep_with_next if para_pr.break_setting is not None else None
328
+ )
329
+ summary["lineSpacingPercent"] = (
330
+ para_pr.line_spacing.value if para_pr.line_spacing is not None else None
331
+ )
332
+ attributes = {
333
+ "id": paragraph.element.get("id", ""),
334
+ "style": style_name or "",
335
+ "type": style.type if style is not None and style.type else "paragraph",
336
+ }
337
+ return summary, attributes
338
+
339
+ def _project_paragraph(
340
+ self,
341
+ paragraph: Any,
342
+ parent: SemanticPath,
343
+ index: int,
344
+ *,
345
+ section_index: int,
346
+ paragraph_index_in_section: int | None = None,
347
+ ) -> None:
348
+ identity = _identity(paragraph.element, "id")
349
+ segment, stable_id, stability = self._segment_for(kind="paragraph", index=index, identity=identity)
350
+ if identity and stability == "positional":
351
+ self._register_ambiguous_relative(parent, "paragraph", identity)
352
+ path = parent.child(segment)
353
+ summary, attributes = self._paragraph_summary(paragraph)
354
+ record = NodeRecord(
355
+ kind="paragraph",
356
+ path=path.canonical,
357
+ stable_id=stable_id,
358
+ stability=stability,
359
+ summary=summary,
360
+ native=paragraph,
361
+ parent_path=parent.canonical,
362
+ attributes=attributes,
363
+ search_text=paragraph.text,
364
+ )
365
+ self._add_record(record)
366
+ for child in paragraph.element:
367
+ local = _local_name(child)
368
+ if local not in {"run", "lineSegArray", "linesegarray"}:
369
+ record.mark_unsupported(local)
370
+ fields_by_position: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
371
+ for match in self._form_fields_by_paragraph[paragraph.element]:
372
+ fields_by_position[(int(match["run_index"]), int(match["child_index"]))].append(match)
373
+
374
+ object_indexes: Counter[str] = Counter()
375
+ for run_index, run in enumerate(paragraph.runs, start=1):
376
+ run_path = path.child(indexed_segment("run", run_index))
377
+ style = run.style
378
+ child_attrs = style.child_attributes if style is not None else {}
379
+ run_summary = {
380
+ "text": _bounded_text(run.text),
381
+ "bold": "bold" in child_attrs,
382
+ "italic": "italic" in child_attrs,
383
+ "underline": child_attrs.get("underline", {}).get("type", "NONE").upper() != "NONE",
384
+ "fontSizePt": (
385
+ round(int(style.attributes["height"]) / 100, 2)
386
+ if style is not None and str(style.attributes.get("height", "")).isdigit()
387
+ else None
388
+ ),
389
+ "color": style.text_color() if style is not None else None,
390
+ }
391
+ self._add_record(
392
+ NodeRecord(
393
+ kind="run",
394
+ path=run_path.canonical,
395
+ stable_id=None,
396
+ stability="positional",
397
+ summary=run_summary,
398
+ native=run,
399
+ parent_path=path.canonical,
400
+ attributes={"type": "run"},
401
+ search_text=run.text,
402
+ )
403
+ )
404
+ for child_index, child in enumerate(run.element):
405
+ local = _local_name(child)
406
+ if local == "tbl":
407
+ object_indexes["table"] += 1
408
+ self._project_table(paragraph, child, path, object_indexes["table"])
409
+ elif local == "pic":
410
+ object_indexes["picture"] += 1
411
+ self._project_picture(child, paragraph, path, object_indexes["picture"])
412
+ elif local in _SHAPE_KINDS:
413
+ object_indexes["shape"] += 1
414
+ self._project_shape(child, paragraph, path, object_indexes["shape"])
415
+ elif local in {"footNote", "endNote"}:
416
+ kind = "footnote" if local == "footNote" else "endnote"
417
+ object_indexes[kind] += 1
418
+ notes = paragraph.footnotes if kind == "footnote" else paragraph.endnotes
419
+ note = next(candidate for candidate in notes if candidate.element is child)
420
+ self._project_note(note, path, kind, object_indexes[kind])
421
+ elif local == "ctrl":
422
+ matches = fields_by_position.get((run_index - 1, child_index), [])
423
+ if matches:
424
+ for match in matches:
425
+ object_indexes["form-field"] += 1
426
+ self._project_form_field(match, path, object_indexes["form-field"])
427
+ elif len(child) > 0 and all(
428
+ _local_name(control_child) in {"fieldEnd", "colPr", "secPr"}
429
+ for control_child in child
430
+ ):
431
+ # A matched FORM field is projected from its fieldBegin. Its paired
432
+ # fieldEnd and section/column controls carry no additional subtree
433
+ # semantics and must not make an otherwise complete block unsupported.
434
+ continue
435
+ else:
436
+ record.mark_unsupported("ctrl")
437
+ elif local not in _IGNORED_INLINE_KINDS:
438
+ record.mark_unsupported(local)
439
+
440
+ def _project_table(self, paragraph: Any, element: Any, parent: SemanticPath, index: int) -> None:
441
+ table = next(candidate for candidate in paragraph.tables if candidate.element is element)
442
+ identity = _identity(element, "id")
443
+ segment, stable_id, stability = self._segment_for(kind="table", index=index, identity=identity)
444
+ if identity and stability == "positional":
445
+ self._register_ambiguous_relative(parent, "table", identity)
446
+ path = parent.child(segment)
447
+ size = _first_child(element, "sz")
448
+ pos = _first_child(element, "pos")
449
+ table_record = NodeRecord(
450
+ kind="table",
451
+ path=path.canonical,
452
+ stable_id=stable_id,
453
+ stability=stability,
454
+ summary={
455
+ "rowCount": table.row_count,
456
+ "columnCount": table.column_count,
457
+ "caption": _child_text(element, "caption"),
458
+ "widthMm": _mm(size.get("width")) if size is not None else None,
459
+ "alignment": pos.get("horzAlign") if pos is not None else None,
460
+ },
461
+ native=table,
462
+ parent_path=parent.canonical,
463
+ attributes={"id": identity or "", "type": element.get("numberingType", "table")},
464
+ search_text=paragraph.text,
465
+ )
466
+ self._add_record(table_record)
467
+ for child in element:
468
+ local = _local_name(child)
469
+ if local not in {"sz", "pos", "caption", "outMargin", "inMargin", "tr"}:
470
+ table_record.mark_unsupported(local)
471
+ for row_index, row in enumerate(table.rows, start=1):
472
+ row_path = path.child(indexed_segment("row", row_index))
473
+ heights = [cell.height for cell in row.cells]
474
+ self._add_record(
475
+ NodeRecord(
476
+ kind="row",
477
+ path=row_path.canonical,
478
+ stable_id=None,
479
+ stability="positional",
480
+ summary={
481
+ "index": row_index,
482
+ "cellCount": len(row.cells),
483
+ "heightMm": _mm(max(heights)) if heights else None,
484
+ },
485
+ native=row,
486
+ parent_path=path.canonical,
487
+ attributes={"type": "row"},
488
+ )
489
+ )
490
+ for cell_index, cell in enumerate(row.cells, start=1):
491
+ cell_path = row_path.child(indexed_segment("cell", cell_index))
492
+ address_row, address_column = cell.address
493
+ row_span, column_span = cell.span
494
+ sublist = _first_child(cell.element, "subList")
495
+ cell_record = NodeRecord(
496
+ kind="cell",
497
+ path=cell_path.canonical,
498
+ stable_id=None,
499
+ stability="positional",
500
+ summary={
501
+ "text": _bounded_text(cell.text),
502
+ "row": address_row + 1,
503
+ "column": address_column + 1,
504
+ "rowSpan": row_span,
505
+ "columnSpan": column_span,
506
+ "verticalAlignment": (
507
+ sublist.get("vertAlign") if sublist is not None else None
508
+ ),
509
+ },
510
+ native=cell,
511
+ parent_path=row_path.canonical,
512
+ attributes={"type": "cell"},
513
+ search_text=cell.text,
514
+ )
515
+ self._add_record(cell_record)
516
+ for child in cell.element:
517
+ local = _local_name(child)
518
+ if local not in {
519
+ "subList",
520
+ "cellAddr",
521
+ "cellSpan",
522
+ "cellSz",
523
+ "cellMargin",
524
+ }:
525
+ cell_record.mark_unsupported(local)
526
+ for paragraph_index, nested in enumerate(cell.paragraphs, start=1):
527
+ self._project_paragraph(
528
+ nested,
529
+ cell_path,
530
+ paragraph_index,
531
+ section_index=-1,
532
+ paragraph_index_in_section=None,
533
+ )
534
+
535
+ def _project_picture(
536
+ self, element: Any, paragraph: Any, parent: SemanticPath, index: int
537
+ ) -> None:
538
+ identity = _identity(element, "id", "instid", "instId")
539
+ segment, stable_id, stability = self._segment_for(kind="picture", index=index, identity=identity)
540
+ if identity and stability == "positional":
541
+ self._register_ambiguous_relative(parent, "picture", identity)
542
+ path = parent.child(segment)
543
+ image = next((child for child in element.iter() if _local_name(child) == "img"), None)
544
+ size = _first_child(element, "sz")
545
+ name = element.get("name") or (image.get("binaryItemIDRef") if image is not None else None)
546
+ self._add_record(
547
+ NodeRecord(
548
+ kind="picture",
549
+ path=path.canonical,
550
+ stable_id=stable_id,
551
+ stability=stability,
552
+ summary={
553
+ "name": name,
554
+ "altText": (
555
+ _child_text(element, "shapeComment")
556
+ or element.get("altText")
557
+ or element.get("title")
558
+ ),
559
+ "widthMm": _mm(size.get("width")) if size is not None else None,
560
+ "heightMm": _mm(size.get("height")) if size is not None else None,
561
+ },
562
+ native=element,
563
+ parent_path=parent.canonical,
564
+ attributes={"id": identity or "", "name": name or "", "type": "picture"},
565
+ search_text=name or "",
566
+ )
567
+ )
568
+
569
+ def _project_shape(
570
+ self, element: Any, paragraph: Any, parent: SemanticPath, index: int
571
+ ) -> None:
572
+ shape = HwpxOxmlShape(element, paragraph)
573
+ identity = shape.inst_id
574
+ segment, stable_id, stability = self._segment_for(kind="shape", index=index, identity=identity)
575
+ if identity and stability == "positional":
576
+ self._register_ambiguous_relative(parent, "shape", identity)
577
+ path = parent.child(segment)
578
+ pos = _first_child(element, "pos")
579
+ name = element.get("name")
580
+ self._add_record(
581
+ NodeRecord(
582
+ kind="shape",
583
+ path=path.canonical,
584
+ stable_id=stable_id,
585
+ stability=stability,
586
+ summary={
587
+ "shapeType": shape.shape_type,
588
+ "name": name,
589
+ "altText": (
590
+ _child_text(element, "shapeComment")
591
+ or element.get("altText")
592
+ or element.get("title")
593
+ ),
594
+ "xMm": _mm(pos.get("horzOffset")) if pos is not None else None,
595
+ "yMm": _mm(pos.get("vertOffset")) if pos is not None else None,
596
+ "widthMm": _mm(shape.width),
597
+ "heightMm": _mm(shape.height),
598
+ },
599
+ native=shape,
600
+ parent_path=parent.canonical,
601
+ attributes={"id": identity or "", "name": name or "", "type": shape.shape_type},
602
+ search_text=name or "",
603
+ )
604
+ )
605
+
606
+ def _project_note(self, note: Any, parent: SemanticPath, kind: str, index: int) -> None:
607
+ identity = note.inst_id
608
+ segment, stable_id, stability = self._segment_for(kind=kind, index=index, identity=identity)
609
+ if identity and stability == "positional":
610
+ self._register_ambiguous_relative(parent, kind, identity)
611
+ path = parent.child(segment)
612
+ self._add_record(
613
+ NodeRecord(
614
+ kind=kind,
615
+ path=path.canonical,
616
+ stable_id=stable_id,
617
+ stability=stability,
618
+ summary={"text": _bounded_text(note.text)},
619
+ native=note,
620
+ parent_path=parent.canonical,
621
+ attributes={"id": identity or "", "type": kind},
622
+ search_text=note.text,
623
+ )
624
+ )
625
+
626
+ def _project_form_field(
627
+ self, match: dict[str, Any], parent: SemanticPath, index: int
628
+ ) -> None:
629
+ identity = str(match.get("field_id") or match.get("id") or "").strip() or None
630
+ name = str(match.get("name") or "").strip() or None
631
+ segment, stable_id, stability = self._segment_for(
632
+ kind="form-field", index=index, identity=identity, name=name
633
+ )
634
+ if identity and stability == "positional":
635
+ self._register_ambiguous_relative(parent, "form-field", identity)
636
+ elif name and stability == "positional":
637
+ self._register_ambiguous_relative(parent, "form-field", name, attribute="name")
638
+ path = parent.child(segment)
639
+ runs = match.get("_runs") or []
640
+ ctrl = None
641
+ begin = None
642
+ try:
643
+ ctrl = list(runs[int(match["run_index"])])[int(match["child_index"])]
644
+ begin = next(child for child in ctrl if _local_name(child) == "fieldBegin")
645
+ except (IndexError, KeyError, StopIteration, TypeError, ValueError):
646
+ pass
647
+ read_only = None
648
+ if begin is not None:
649
+ editable = begin.get("editable")
650
+ if editable is not None:
651
+ read_only = str(editable).strip().lower() in {"0", "false", "no"}
652
+ self._add_record(
653
+ NodeRecord(
654
+ kind="form-field",
655
+ path=path.canonical,
656
+ stable_id=stable_id,
657
+ stability=stability,
658
+ summary={
659
+ "name": name,
660
+ "value": _bounded_text(match.get("current_value")),
661
+ "fieldType": match.get("field_type"),
662
+ "readOnly": read_only,
663
+ },
664
+ native=match,
665
+ parent_path=parent.canonical,
666
+ attributes={
667
+ "id": identity or "",
668
+ "name": name or "",
669
+ "type": str(
670
+ match.get("field_type") or match.get("control_type") or "form-field"
671
+ ),
672
+ },
673
+ search_text=" ".join(
674
+ str(match.get(key) or "")
675
+ for key in ("name", "current_value", "prompt", "instruction")
676
+ ),
677
+ )
678
+ )
679
+
680
+ def resolve_record(
681
+ self,
682
+ path: str,
683
+ *,
684
+ expected_revision: str | None = None,
685
+ require_stable: bool = False,
686
+ ) -> NodeRecord:
687
+ if expected_revision is not None and expected_revision != self.revision:
688
+ raise AgentContractError("stale_revision", "document revision does not match", target=path)
689
+ canonical = canonicalize_path(path)
690
+ if canonical in self._ambiguous_paths:
691
+ raise AgentContractError("ambiguous_target", "path identity is duplicated", target=canonical)
692
+ record = self._records.get(canonical)
693
+ if record is None:
694
+ raise AgentContractError("not_found", f"semantic path not found: {canonical}", target=canonical)
695
+ if require_stable and record.stability == "positional":
696
+ raise AgentContractError("volatile_target", "target has a positional path", target=canonical)
697
+ return record
698
+
699
+ def _resolve_header_story(
700
+ self,
701
+ path: str | HeaderStoryPath,
702
+ *,
703
+ expected_revision: str | None = None,
704
+ ) -> HeaderStoryBinding:
705
+ """Resolve the private command-only existing-header seam.
706
+
707
+ Header stories deliberately remain absent from ``records``, ``get``,
708
+ and ``query`` so the frozen public projection/catalog does not drift.
709
+ """
710
+
711
+ parsed = parse_header_story_path(path) if isinstance(path, str) else path
712
+ if expected_revision is not None and expected_revision != self.revision:
713
+ raise AgentContractError(
714
+ "stale_revision", "document revision does not match", target=parsed.canonical
715
+ )
716
+ return resolve_header_story(self.document, parsed)
717
+
718
+ def _public_node(self, record: NodeRecord, *, depth: int, child_limit: int) -> AgentNode:
719
+ supported_total = len(record.child_paths)
720
+ selected_paths = record.child_paths[:child_limit] if depth > 0 else []
721
+ children = tuple(
722
+ self._public_node(self._records[path], depth=depth - 1, child_limit=child_limit)
723
+ for path in selected_paths
724
+ )
725
+ truncated = supported_total - len(selected_paths)
726
+ catalog = NODE_PROPERTY_CATALOG_V1[record.kind]
727
+ return AgentNode(
728
+ kind=record.kind,
729
+ path=record.path,
730
+ stable_id=record.stable_id,
731
+ stability=record.stability,
732
+ summary=record.summary,
733
+ child_count=supported_total + record.unsupported_child_count,
734
+ children=children,
735
+ unsupported_child_count=record.unsupported_child_count,
736
+ truncated_child_count=truncated,
737
+ readable_properties=tuple(catalog["readable"]),
738
+ editable_properties=tuple(catalog["editable"]),
739
+ operations=tuple(catalog["operations"]),
740
+ revision=self.revision,
741
+ )
742
+
743
+ def get(
744
+ self,
745
+ path: str = "/",
746
+ *,
747
+ depth: int = 1,
748
+ child_limit: int = 50,
749
+ expected_revision: str | None = None,
750
+ ) -> AgentNode:
751
+ if isinstance(depth, bool) or not isinstance(depth, int) or not 0 <= depth <= MAX_VIEW_DEPTH:
752
+ raise AgentContractError(
753
+ "resource_limit", f"depth must be 0..{MAX_VIEW_DEPTH}", target="depth"
754
+ )
755
+ if (
756
+ isinstance(child_limit, bool)
757
+ or not isinstance(child_limit, int)
758
+ or not 1 <= child_limit <= MAX_CHILDREN_PER_NODE
759
+ ):
760
+ raise AgentContractError(
761
+ "resource_limit",
762
+ f"childLimit must be 1..{MAX_CHILDREN_PER_NODE}",
763
+ target="childLimit",
764
+ )
765
+ record = self.resolve_record(path, expected_revision=expected_revision)
766
+ return self._public_node(record, depth=depth, child_limit=child_limit)
767
+
768
+ def query(
769
+ self,
770
+ selector: str,
771
+ *,
772
+ limit: int,
773
+ node_depth: int = 0,
774
+ child_limit: int = 20,
775
+ expected_revision: str | None = None,
776
+ ) -> QueryResult:
777
+ if expected_revision is not None and expected_revision != self.revision:
778
+ raise AgentContractError("stale_revision", "document revision does not match", target="selector")
779
+ parsed = parse_selector(selector)
780
+ query_records = cast(Sequence[_QueryRecord], self.records)
781
+ matches, truncated = evaluate_selector(query_records, parsed, limit=limit)
782
+ if not 0 <= node_depth <= MAX_VIEW_DEPTH:
783
+ raise AgentContractError("resource_limit", "nodeDepth is out of bounds", target="nodeDepth")
784
+ if not 1 <= child_limit <= MAX_CHILDREN_PER_NODE:
785
+ raise AgentContractError("resource_limit", "childLimit is out of bounds", target="childLimit")
786
+ return QueryResult(
787
+ selector=selector,
788
+ revision=self.revision,
789
+ nodes=tuple(
790
+ self._public_node(
791
+ cast(NodeRecord, record),
792
+ depth=node_depth,
793
+ child_limit=child_limit,
794
+ )
795
+ for record in matches
796
+ ),
797
+ truncated=truncated,
798
+ )
799
+
800
+
801
+ __all__ = ["HwpxAgentDocument", "NodeRecord"]