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,434 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Transactional edit helpers for stateless HWPX tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import difflib
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+
14
+ from .. import quality as quality_contract
15
+ from ..storage import build_hwpx_verification_report, require_hwpx_editor_open_safe
16
+ from ..upstream import HwpxDocument, open_document
17
+
18
+ _MAX_SUMMARY_ITEMS = 20
19
+ _MAX_SNIPPET = 80
20
+ HwpxSnapshotSource = str | Path | bytes
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BackupReport:
25
+ backup_path: Path | None
26
+ rotated_paths: tuple[Path, ...] = ()
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return {
30
+ "created": self.backup_path is not None,
31
+ "path": str(self.backup_path) if self.backup_path else None,
32
+ "rotatedPaths": [str(path) for path in self.rotated_paths],
33
+ }
34
+
35
+
36
+ def backup_path_for(path: Path) -> Path:
37
+ return path.with_suffix(path.suffix + ".bak")
38
+
39
+
40
+ def rotated_backup_path(path: Path, index: int) -> Path:
41
+ if index < 1:
42
+ raise ValueError("backup rotation index must be >= 1")
43
+ return path.with_suffix(path.suffix + f".bak.{index}")
44
+
45
+
46
+ def rotate_and_backup(path: Path, *, max_backups: int = 5) -> BackupReport:
47
+ """Rotate existing ``.bak`` files and copy *path* to the current backup."""
48
+
49
+ if not path.exists():
50
+ return BackupReport(None)
51
+
52
+ if path.suffix.lower() == ".hwpx":
53
+ require_hwpx_editor_open_safe(path, role="transaction backup source")
54
+
55
+ backup = backup_path_for(path)
56
+ rotated: list[Path] = []
57
+ if max_backups > 0:
58
+ oldest = rotated_backup_path(path, max_backups)
59
+ if oldest.exists():
60
+ oldest.unlink()
61
+ for index in range(max_backups - 1, 0, -1):
62
+ src = rotated_backup_path(path, index)
63
+ if src.exists():
64
+ dst = rotated_backup_path(path, index + 1)
65
+ os.replace(src, dst)
66
+ rotated.append(dst)
67
+ if backup.exists():
68
+ dst = rotated_backup_path(path, 1)
69
+ os.replace(backup, dst)
70
+ rotated.append(dst)
71
+
72
+ shutil.copy2(path, backup)
73
+ return BackupReport(backup, tuple(rotated))
74
+
75
+
76
+ def _snippet(text: str) -> str:
77
+ compact = " ".join((text or "").split())
78
+ if len(compact) <= _MAX_SNIPPET:
79
+ return compact
80
+ return compact[: _MAX_SNIPPET - 1] + "..."
81
+
82
+
83
+ def _iter_tables(document: HwpxDocument) -> Iterable[Any]:
84
+ for paragraph in document.paragraphs:
85
+ for table in getattr(paragraph, "tables", []) or []:
86
+ yield table
87
+ yield from _iter_nested_tables(table)
88
+
89
+
90
+ def _iter_nested_tables(table: Any) -> Iterable[Any]:
91
+ for row in getattr(table, "rows", []) or []:
92
+ for cell in getattr(row, "cells", []) or []:
93
+ for paragraph in getattr(cell, "paragraphs", []) or []:
94
+ for nested in getattr(paragraph, "tables", []) or []:
95
+ yield nested
96
+ yield from _iter_nested_tables(nested)
97
+
98
+
99
+ def _paragraph_signature(paragraph: Any) -> dict[str, Any]:
100
+ element = getattr(paragraph, "element", None)
101
+ attrs = dict(getattr(element, "attrib", {}) or {}) if element is not None else {}
102
+ runs: list[dict[str, Any]] = []
103
+ for run in getattr(paragraph, "runs", []) or []:
104
+ run_element = getattr(run, "element", None)
105
+ runs.append(
106
+ {
107
+ "text": getattr(run, "text", "") or "",
108
+ "attrs": dict(getattr(run_element, "attrib", {}) or {})
109
+ if run_element is not None
110
+ else {},
111
+ }
112
+ )
113
+ return {"attrs": attrs, "runs": runs}
114
+
115
+
116
+ def _source_bytes(source: HwpxSnapshotSource) -> bytes:
117
+ return source if isinstance(source, bytes) else Path(source).read_bytes()
118
+
119
+
120
+ def snapshot_document(source: HwpxSnapshotSource) -> dict[str, Any]:
121
+ source_bytes = _source_bytes(source)
122
+ display_path = "<memory>" if isinstance(source, bytes) else str(Path(source))
123
+ document = open_document(source_bytes)
124
+ try:
125
+ paragraphs = [
126
+ {
127
+ "index": index,
128
+ "text": paragraph.text or "",
129
+ "signature": _paragraph_signature(paragraph),
130
+ }
131
+ for index, paragraph in enumerate(document.paragraphs)
132
+ ]
133
+ tables: list[dict[str, Any]] = []
134
+ for table_index, table in enumerate(_iter_tables(document)):
135
+ data = [
136
+ [cell.text or "" for cell in getattr(row, "cells", []) or []]
137
+ for row in getattr(table, "rows", []) or []
138
+ ]
139
+ rows = len(data)
140
+ cols = max((len(row) for row in data), default=0)
141
+ tables.append(
142
+ {
143
+ "index": table_index,
144
+ "rows": rows,
145
+ "cols": cols,
146
+ "data": data,
147
+ }
148
+ )
149
+ return {
150
+ "path": display_path,
151
+ "paragraphs": paragraphs,
152
+ "tables": tables,
153
+ "paragraphCount": len(paragraphs),
154
+ "tableCount": len(tables),
155
+ }
156
+ finally:
157
+ close = getattr(document, "close", None)
158
+ if callable(close):
159
+ close()
160
+
161
+
162
+ def _paragraph_diff_items(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[dict[str, Any]]:
163
+ items: list[dict[str, Any]] = []
164
+ before_text = [entry["text"] for entry in before]
165
+ after_text = [entry["text"] for entry in after]
166
+ matcher = difflib.SequenceMatcher(a=before_text, b=after_text, autojunk=False)
167
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
168
+ if len(items) >= _MAX_SUMMARY_ITEMS:
169
+ break
170
+ if tag == "equal":
171
+ for offset, before_index in enumerate(range(i1, i2)):
172
+ if len(items) >= _MAX_SUMMARY_ITEMS:
173
+ break
174
+ after_index = j1 + offset
175
+ if before[before_index]["signature"] != after[after_index]["signature"]:
176
+ index = after[after_index]["index"]
177
+ items.append(
178
+ {
179
+ "type": "paragraph_format_changed",
180
+ "paragraphIndex": index,
181
+ "summary": f"Paragraph {index} formatting changed",
182
+ }
183
+ )
184
+ continue
185
+ if tag == "replace" and (i2 - i1) == (j2 - j1):
186
+ for before_index, after_index in zip(range(i1, i2), range(j1, j2)):
187
+ if len(items) >= _MAX_SUMMARY_ITEMS:
188
+ break
189
+ index = after[after_index]["index"]
190
+ items.append(
191
+ {
192
+ "type": "paragraph_changed",
193
+ "paragraphIndex": index,
194
+ "before": before[before_index]["text"],
195
+ "after": after[after_index]["text"],
196
+ "summary": (
197
+ f"Paragraph {index} text changed: "
198
+ f"'{_snippet(before[before_index]['text'])}' -> "
199
+ f"'{_snippet(after[after_index]['text'])}'"
200
+ ),
201
+ }
202
+ )
203
+ continue
204
+ for before_index in range(i1, i2):
205
+ if len(items) >= _MAX_SUMMARY_ITEMS:
206
+ break
207
+ index = before[before_index]["index"]
208
+ items.append(
209
+ {
210
+ "type": "paragraph_removed",
211
+ "paragraphIndex": index,
212
+ "before": before[before_index]["text"],
213
+ "summary": f"Paragraph {index} removed: '{_snippet(before[before_index]['text'])}'",
214
+ }
215
+ )
216
+ for after_index in range(j1, j2):
217
+ if len(items) >= _MAX_SUMMARY_ITEMS:
218
+ break
219
+ index = after[after_index]["index"]
220
+ items.append(
221
+ {
222
+ "type": "paragraph_added",
223
+ "paragraphIndex": index,
224
+ "after": after[after_index]["text"],
225
+ "summary": f"Paragraph {index} added: '{_snippet(after[after_index]['text'])}'",
226
+ }
227
+ )
228
+ return items
229
+
230
+
231
+ def _table_diff_items(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[dict[str, Any]]:
232
+ items: list[dict[str, Any]] = []
233
+ for table_index in range(min(len(before), len(after))):
234
+ if len(items) >= _MAX_SUMMARY_ITEMS:
235
+ break
236
+ before_table = before[table_index]
237
+ after_table = after[table_index]
238
+ if before_table["rows"] != after_table["rows"] or before_table["cols"] != after_table["cols"]:
239
+ items.append(
240
+ {
241
+ "type": "table_shape_changed",
242
+ "tableIndex": table_index,
243
+ "summary": (
244
+ f"Table {table_index} shape changed: "
245
+ f"{before_table['rows']}x{before_table['cols']} -> "
246
+ f"{after_table['rows']}x{after_table['cols']}"
247
+ ),
248
+ }
249
+ )
250
+ max_rows = max(before_table["rows"], after_table["rows"])
251
+ for row in range(max_rows):
252
+ if len(items) >= _MAX_SUMMARY_ITEMS:
253
+ break
254
+ before_row = before_table["data"][row] if row < before_table["rows"] else []
255
+ after_row = after_table["data"][row] if row < after_table["rows"] else []
256
+ max_cols = max(len(before_row), len(after_row))
257
+ for col in range(max_cols):
258
+ if len(items) >= _MAX_SUMMARY_ITEMS:
259
+ break
260
+ before_text = before_row[col] if col < len(before_row) else ""
261
+ after_text = after_row[col] if col < len(after_row) else ""
262
+ if before_text != after_text:
263
+ items.append(
264
+ {
265
+ "type": "table_cell_changed",
266
+ "tableIndex": table_index,
267
+ "row": row,
268
+ "col": col,
269
+ "before": before_text,
270
+ "after": after_text,
271
+ "summary": (
272
+ f"Table {table_index} cell ({row}, {col}) changed: "
273
+ f"'{_snippet(before_text)}' -> '{_snippet(after_text)}'"
274
+ ),
275
+ }
276
+ )
277
+ for table_index in range(len(before), len(after)):
278
+ if len(items) >= _MAX_SUMMARY_ITEMS:
279
+ break
280
+ table = after[table_index]
281
+ items.append(
282
+ {
283
+ "type": "table_added",
284
+ "tableIndex": table_index,
285
+ "summary": f"Table {table_index} added: {table['rows']}x{table['cols']}",
286
+ }
287
+ )
288
+ for table_index in range(len(after), len(before)):
289
+ if len(items) >= _MAX_SUMMARY_ITEMS:
290
+ break
291
+ table = before[table_index]
292
+ items.append(
293
+ {
294
+ "type": "table_removed",
295
+ "tableIndex": table_index,
296
+ "summary": f"Table {table_index} removed: {table['rows']}x{table['cols']}",
297
+ }
298
+ )
299
+ return items
300
+
301
+
302
+ def semantic_diff(
303
+ before_source: HwpxSnapshotSource,
304
+ after_source: HwpxSnapshotSource,
305
+ ) -> dict[str, Any]:
306
+ before_bytes = _source_bytes(before_source)
307
+ after_bytes = _source_bytes(after_source)
308
+ before = snapshot_document(before_bytes)
309
+ after = snapshot_document(after_bytes)
310
+ items = _paragraph_diff_items(before["paragraphs"], after["paragraphs"])
311
+ remaining = max(0, _MAX_SUMMARY_ITEMS - len(items))
312
+ if remaining:
313
+ items.extend(_table_diff_items(before["tables"], after["tables"])[:remaining])
314
+
315
+ changed = before_bytes != after_bytes
316
+ if changed and not items:
317
+ items.append(
318
+ {
319
+ "type": "package_changed",
320
+ "summary": "Package XML changed without paragraph/table text changes",
321
+ }
322
+ )
323
+
324
+ summaries = [item["summary"] for item in items]
325
+ return {
326
+ "schemaVersion": "hwpx.semantic-diff.v1",
327
+ "changed": changed,
328
+ "summary": "; ".join(summaries) if summaries else "No semantic changes",
329
+ "items": items,
330
+ "truncated": len(items) >= _MAX_SUMMARY_ITEMS,
331
+ "counts": {
332
+ "paragraphsBefore": before["paragraphCount"],
333
+ "paragraphsAfter": after["paragraphCount"],
334
+ "tablesBefore": before["tableCount"],
335
+ "tablesAfter": after["tableCount"],
336
+ },
337
+ }
338
+
339
+
340
+ def save_dry_run(
341
+ document: HwpxDocument, target: str | Path, *, quality: Any = None
342
+ ) -> dict[str, Any]:
343
+ target_path = Path(target)
344
+ fd, tmp_name = tempfile.mkstemp(
345
+ prefix=f".{target_path.stem}.dry-run.",
346
+ suffix=target_path.suffix or ".hwpx",
347
+ dir=str(target_path.parent),
348
+ )
349
+ tmp_path = Path(tmp_name)
350
+ try:
351
+ os.close(fd)
352
+ try:
353
+ report = quality_contract.save_through_pipeline(document, tmp_path, quality=quality)
354
+ except quality_contract.QualityGateError as gate:
355
+ # A dry run reports the gate verdict instead of committing.
356
+ return {
357
+ "dryRun": True,
358
+ "wouldSave": False,
359
+ "visualComplete": gate.block,
360
+ "openSafety": None,
361
+ "semanticDiff": None,
362
+ }
363
+ verification = build_hwpx_verification_report(tmp_path)
364
+ if not verification["openSafety"]["ok"]:
365
+ raise RuntimeError(
366
+ "dry-run HWPX failed open-safety verification: "
367
+ + verification["openSafety"]["summary"]
368
+ )
369
+ diff = semantic_diff(target_path, tmp_path) if target_path.exists() else None
370
+ verification["filePath"] = str(target_path)
371
+ verification["dryRunTempDeleted"] = True
372
+ verification["visualComplete"] = quality_contract.visual_complete_block(report)
373
+ return {
374
+ "dryRun": True,
375
+ "wouldSave": True,
376
+ "verificationReport": verification,
377
+ "openSafety": verification.get("openSafety"),
378
+ "semanticDiff": diff,
379
+ "visualComplete": verification["visualComplete"],
380
+ }
381
+ finally:
382
+ tmp_path.unlink(missing_ok=True)
383
+
384
+
385
+ def undo_last_backup(target: str | Path) -> dict[str, Any]:
386
+ target_path = Path(target)
387
+ backup_path = backup_path_for(target_path)
388
+ if not target_path.exists():
389
+ raise FileNotFoundError(f"target document does not exist: {target_path}")
390
+ if not backup_path.exists():
391
+ raise FileNotFoundError(f"backup document does not exist: {backup_path}")
392
+
393
+ require_hwpx_editor_open_safe(backup_path, role="undo backup source")
394
+ require_hwpx_editor_open_safe(target_path, role="undo current source")
395
+
396
+ current_fd, current_name = tempfile.mkstemp(
397
+ prefix=f".{target_path.stem}.current.",
398
+ suffix=target_path.suffix or ".hwpx",
399
+ dir=str(target_path.parent),
400
+ )
401
+ backup_fd, backup_name = tempfile.mkstemp(
402
+ prefix=f".{target_path.stem}.backup.",
403
+ suffix=target_path.suffix or ".hwpx",
404
+ dir=str(target_path.parent),
405
+ )
406
+ current_tmp = Path(current_name)
407
+ backup_tmp = Path(backup_name)
408
+ try:
409
+ os.close(current_fd)
410
+ os.close(backup_fd)
411
+ shutil.copy2(target_path, current_tmp)
412
+ shutil.copy2(backup_path, backup_tmp)
413
+ os.replace(backup_tmp, target_path)
414
+ os.replace(current_tmp, backup_path)
415
+ verification = build_hwpx_verification_report(target_path)
416
+ if not verification["openSafety"]["ok"]:
417
+ raise RuntimeError(
418
+ "undo HWPX failed open-safety verification: "
419
+ + verification["openSafety"]["summary"]
420
+ )
421
+ verification["filePath"] = str(target_path)
422
+ diff = semantic_diff(backup_path, target_path)
423
+ return {
424
+ "restored": True,
425
+ "filename": str(target_path),
426
+ "backupPath": str(backup_path),
427
+ "verificationReport": verification,
428
+ "openSafety": verification.get("openSafety"),
429
+ "semanticDiff": diff,
430
+ }
431
+ except BaseException:
432
+ current_tmp.unlink(missing_ok=True)
433
+ backup_tmp.unlink(missing_ok=True)
434
+ raise
@@ -0,0 +1,48 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Transaction helpers for hardened editing pipeline."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ from typing import Any, Callable, Dict, Generic, TypeVar
8
+
9
+
10
+ class TransactionError(RuntimeError):
11
+ """Base error for transaction handling."""
12
+
13
+
14
+ class IdempotentReplayError(TransactionError):
15
+ """Raised when the same idempotency key is replayed."""
16
+
17
+ def __init__(self, key: str) -> None:
18
+ super().__init__(f"idempotency key '{key}' has already been processed")
19
+ self.key = key
20
+
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ class TransactionManager(Generic[T]):
26
+ """Provide atomic execution and idempotency tracking."""
27
+
28
+ def __init__(self) -> None:
29
+ self._idempotency: Dict[str, Dict[str, Any]] = {}
30
+
31
+ def ensure_idempotency(self, scope: str, key: str) -> None:
32
+ token = self._token(scope, key)
33
+ if token in self._idempotency:
34
+ raise IdempotentReplayError(key)
35
+
36
+ def record_idempotency(self, scope: str, key: str, payload: Dict[str, Any]) -> None:
37
+ token = self._token(scope, key)
38
+ self._idempotency[token] = dict(payload)
39
+
40
+ def atomic(self, snapshot: T, mutator: Callable[[T], T], applier: Callable[[T], None]) -> T:
41
+ working_copy = copy.deepcopy(snapshot)
42
+ result = mutator(working_copy)
43
+ applier(result)
44
+ return result
45
+
46
+ @staticmethod
47
+ def _token(scope: str, key: str) -> str:
48
+ return f"{scope}::{key}"
@@ -0,0 +1,95 @@
1
+ """Document revision and best-effort lock detection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def document_revision(path: str | Path) -> str:
11
+ """Return a stable content revision for optimistic concurrency checks."""
12
+
13
+ digest = hashlib.sha256()
14
+ with Path(path).open("rb") as stream:
15
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
16
+ digest.update(chunk)
17
+ return f"sha256:{digest.hexdigest()}"
18
+
19
+
20
+ def _candidate_lock_paths(path: Path) -> list[Path]:
21
+ name = path.name
22
+ stem = path.stem
23
+ parent = path.parent
24
+ candidates = [
25
+ parent / f"~${name}",
26
+ parent / f".~{name}",
27
+ parent / f".{name}.lock",
28
+ parent / f".{name}.lck",
29
+ parent / f"{name}.lock",
30
+ parent / f"{name}.lck",
31
+ parent / f"{stem}.lock",
32
+ parent / f"{stem}.lck",
33
+ ]
34
+ seen: set[Path] = set()
35
+ unique: list[Path] = []
36
+ for candidate in candidates:
37
+ if candidate in seen:
38
+ continue
39
+ seen.add(candidate)
40
+ unique.append(candidate)
41
+ return unique
42
+
43
+
44
+ def document_lock_warnings(path: str | Path) -> list[dict[str, Any]]:
45
+ """Return warnings for lock/temp files near *path*.
46
+
47
+ Hancom Office lock behavior varies by host and version. This deliberately
48
+ uses conservative adjacent-file markers so normal backups are not reported
49
+ as active locks.
50
+ """
51
+
52
+ target = Path(path)
53
+ warnings: list[dict[str, Any]] = []
54
+ for candidate in _candidate_lock_paths(target):
55
+ if not candidate.exists():
56
+ continue
57
+ warnings.append(
58
+ {
59
+ "code": "possible_document_lock",
60
+ "severity": "warning",
61
+ "message": "A lock or temporary file next to the HWPX suggests the document may be open elsewhere.",
62
+ "path": str(candidate),
63
+ "documentPath": str(target),
64
+ "source": "adjacent-temp-file",
65
+ }
66
+ )
67
+ return warnings
68
+
69
+
70
+ def document_state_payload(path: str | Path) -> dict[str, Any]:
71
+ return {
72
+ "document_revision": document_revision(path),
73
+ "documentWarnings": document_lock_warnings(path),
74
+ }
75
+
76
+
77
+ def revision_mismatch_response(
78
+ path: str | Path,
79
+ expected_revision: str | None,
80
+ ) -> dict[str, Any] | None:
81
+ if expected_revision is None:
82
+ return None
83
+ actual_revision = document_revision(path)
84
+ if expected_revision == actual_revision:
85
+ return None
86
+ return {
87
+ "ok": False,
88
+ "handoff_status": "blocked",
89
+ "reason": "document revision mismatch",
90
+ "expected_revision": expected_revision,
91
+ "document_revision": actual_revision,
92
+ "documentWarnings": document_lock_warnings(path),
93
+ "suggestion": "Re-read the document, review the external changes, then retry with the new document_revision as expected_revision.",
94
+ "next_tool": "get_document_info",
95
+ }