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,656 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Baseline-driven template-preserving HWPX form-fit helpers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from hwpx.document import HwpxDocument
17
+ from hwpx.tools.package_validator import (
18
+ validate_editor_open_safety,
19
+ validate_package,
20
+ )
21
+ from hwpx.tools.validator import validate_document
22
+
23
+ TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION = "hwpx.template-formfit.baseline.v1"
24
+ TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION = "hwpx.template-formfit.plan.v1"
25
+
26
+ _DEFAULT_RESIDUAL_MARKERS = ("작성 필요", "TODO", "□□□□", "○○", "(예시)")
27
+
28
+
29
+ def analyze_template_formfit(
30
+ source: str | Path,
31
+ *,
32
+ baseline: Mapping[str, Any] | str | Path,
33
+ content: Mapping[str, Any],
34
+ destination: str | Path | None = None,
35
+ options: Mapping[str, Any] | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Analyze a template-preserving generation run without mutating files."""
38
+
39
+ source_path = Path(source)
40
+ baseline_payload = _load_baseline(baseline)
41
+ content_payload = copy.deepcopy(dict(content or {}))
42
+ before_hash = _sha256_file(source_path)
43
+ before_mtime = source_path.stat().st_mtime_ns
44
+ doc = HwpxDocument.open(source_path)
45
+ try:
46
+ resolved, unresolved = _analyze_targets(doc, baseline_payload, content_payload)
47
+ info = {
48
+ "paragraph_count": len(doc.paragraphs),
49
+ "table_count": _table_count(doc),
50
+ "anchor_count": len(_anchors_from_baseline(baseline_payload)),
51
+ }
52
+ finally:
53
+ doc.close()
54
+
55
+ after_hash = _sha256_file(source_path)
56
+ destination_path = Path(destination) if destination is not None else None
57
+ return {
58
+ "schemaVersion": TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION,
59
+ "baseline": {
60
+ "schemaVersion": baseline_payload.get("schemaVersion"),
61
+ "baselineId": baseline_payload.get("baselineId"),
62
+ },
63
+ "source": {
64
+ "path": str(source_path),
65
+ "sha256": before_hash,
66
+ "mtime_ns": before_mtime,
67
+ "unchanged_after_analysis": after_hash == before_hash,
68
+ },
69
+ "destination": {
70
+ "path": str(destination_path) if destination_path is not None else None,
71
+ "required_for_apply": destination_path is not None,
72
+ },
73
+ "document": info,
74
+ "content": content_payload,
75
+ "resolved": resolved,
76
+ "unresolved": unresolved,
77
+ "resolved_count": len(resolved),
78
+ "unresolved_count": len(unresolved),
79
+ "mutated": False,
80
+ "visual_review_required": bool(_visual_review_regions(baseline_payload)),
81
+ "visual_review_regions": _visual_review_regions(baseline_payload),
82
+ "residual_marker_policy": _residual_marker_policy(baseline_payload),
83
+ "next_tool": "apply_template_formfit",
84
+ "options": dict(options or {}),
85
+ }
86
+
87
+
88
+ def apply_template_formfit(
89
+ *,
90
+ analysis: Mapping[str, Any] | None = None,
91
+ source: str | Path | None = None,
92
+ baseline: Mapping[str, Any] | str | Path | None = None,
93
+ content: Mapping[str, Any] | None = None,
94
+ destination: str | Path | None = None,
95
+ confirm: bool = True,
96
+ ) -> dict[str, Any]:
97
+ """Apply a resolved template form-fit plan to a copied destination."""
98
+
99
+ if not confirm:
100
+ raise ValueError("confirm must be true to apply template form-fit mutations")
101
+
102
+ plan = copy.deepcopy(dict(analysis or {}))
103
+ if not plan:
104
+ if source is None or baseline is None or content is None:
105
+ raise ValueError("provide analysis or source, baseline, and content")
106
+ plan = analyze_template_formfit(
107
+ source,
108
+ baseline=baseline,
109
+ content=content,
110
+ destination=destination,
111
+ )
112
+
113
+ source_path = Path(str(source or plan.get("source", {}).get("path") or ""))
114
+ destination_value = destination or plan.get("destination", {}).get("path")
115
+ if not destination_value:
116
+ raise ValueError("destination is required")
117
+ destination_path = Path(str(destination_value))
118
+
119
+ if source_path.resolve(strict=False) == destination_path.resolve(strict=False):
120
+ return {
121
+ "handoff_status": "blocked",
122
+ "reason": "source-in-place edit refused",
123
+ "source": {"path": str(source_path), "sha256": _sha256_file(source_path)},
124
+ "destination": {"path": str(destination_path)},
125
+ }
126
+
127
+ unresolved = list(plan.get("unresolved") or [])
128
+ if unresolved:
129
+ return {
130
+ "handoff_status": "blocked",
131
+ "reason": "unresolved template targets remain",
132
+ "unresolved": unresolved,
133
+ "source": {"path": str(source_path), "sha256": _sha256_file(source_path)},
134
+ "destination": {"path": str(destination_path)},
135
+ }
136
+
137
+ source_before_hash = _sha256_file(source_path)
138
+ source_before_mtime = source_path.stat().st_mtime_ns
139
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
140
+ fd, tmp_name = tempfile.mkstemp(
141
+ dir=str(destination_path.parent),
142
+ suffix=(destination_path.suffix or ".hwpx") + ".tmp",
143
+ )
144
+ os.close(fd)
145
+ tmp_path = Path(tmp_name)
146
+ try:
147
+ shutil.copy2(source_path, tmp_path)
148
+ copied_hash = _sha256_file(tmp_path)
149
+
150
+ applied: list[dict[str, Any]] = []
151
+ doc = HwpxDocument.open(tmp_path)
152
+ try:
153
+ for target in plan.get("resolved", []):
154
+ applied.append(_apply_target(doc, dict(target)))
155
+ # Funnel the write through the single SavePipeline and keep its uniform
156
+ # report (plan §2 Phase B). The serialized bytes reach disk only via the
157
+ # pipeline; the os.replace below merely publishes that gated temp.
158
+ visual_complete = doc.save_report(tmp_path)
159
+ finally:
160
+ doc.close()
161
+
162
+ validation = _runtime_validation(tmp_path)
163
+ if not validation["openSafety"]["ok"]:
164
+ raise ValueError(
165
+ "template form-fit output failed editor-open safety validation: "
166
+ + validation["openSafety"]["summary"]
167
+ )
168
+ residual_markers = _residual_markers(
169
+ tmp_path,
170
+ plan.get("residual_marker_policy") or {},
171
+ )
172
+ destination_hash = _sha256_file(tmp_path)
173
+ os.replace(tmp_path, destination_path)
174
+ except BaseException:
175
+ tmp_path.unlink(missing_ok=True)
176
+ raise
177
+
178
+ source_after_hash = _sha256_file(source_path)
179
+ source_after_mtime = source_path.stat().st_mtime_ns
180
+ ready = (
181
+ bool(validation["validate_package"]["ok"])
182
+ and bool(validation["validate_document"]["ok"])
183
+ and bool(validation["openSafety"]["ok"])
184
+ and not residual_markers["blocking"]
185
+ )
186
+ return {
187
+ "handoff_status": "ready" if ready else "needs_revision",
188
+ "source": {
189
+ "path": str(source_path),
190
+ "sha256_before": source_before_hash,
191
+ "sha256_after": source_after_hash,
192
+ "mtime_ns_before": source_before_mtime,
193
+ "mtime_ns_after": source_after_mtime,
194
+ "preserved": (
195
+ source_before_hash == source_after_hash
196
+ and source_before_mtime == source_after_mtime
197
+ ),
198
+ },
199
+ "destination": {
200
+ "path": str(destination_path),
201
+ "sha256_after_copy": copied_hash,
202
+ "sha256_after_apply": destination_hash,
203
+ "changed": copied_hash != destination_hash,
204
+ },
205
+ "applied": applied,
206
+ "validation": validation,
207
+ "visual_complete": visual_complete.to_dict(),
208
+ "residual_markers": residual_markers,
209
+ "visual_review_required": bool(plan.get("visual_review_required", True)),
210
+ "visual_review_regions": list(plan.get("visual_review_regions") or []),
211
+ "persisted": True,
212
+ }
213
+
214
+
215
+ def _load_baseline(baseline: Mapping[str, Any] | str | Path) -> dict[str, Any]:
216
+ if isinstance(baseline, Mapping):
217
+ payload = copy.deepcopy(dict(baseline))
218
+ else:
219
+ baseline_path = Path(baseline)
220
+ if baseline_path.exists():
221
+ payload = json.loads(baseline_path.read_text(encoding="utf-8"))
222
+ else:
223
+ payload = json.loads(str(baseline))
224
+ schema_version = payload.get("schemaVersion")
225
+ if schema_version != TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION:
226
+ raise ValueError(
227
+ "baseline schemaVersion must be "
228
+ f"{TEMPLATE_FORMFIT_BASELINE_SCHEMA_VERSION!r}"
229
+ )
230
+ return payload
231
+
232
+
233
+ def _analyze_targets(
234
+ doc: HwpxDocument,
235
+ baseline: Mapping[str, Any],
236
+ content: Mapping[str, Any],
237
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
238
+ resolved: list[dict[str, Any]] = []
239
+ unresolved: list[dict[str, Any]] = []
240
+ for field in baseline.get("scalarFields") or []:
241
+ if not isinstance(field, Mapping):
242
+ continue
243
+ source_path = str(field.get("sourcePath") or "")
244
+ value, has_value = _content_value(content, source_path)
245
+ if not has_value:
246
+ if field.get("required"):
247
+ unresolved.append(_unresolved(field, "missing content"))
248
+ continue
249
+ target = _analyze_anchor_target(doc, field)
250
+ if target["ok"]:
251
+ resolved.append(
252
+ {
253
+ "id": field.get("id"),
254
+ "kind": "scalar-line",
255
+ "anchor": target["anchor"],
256
+ "paragraph_index": target["paragraph_index"],
257
+ "sourcePath": source_path,
258
+ "value": _stringify_value(value),
259
+ }
260
+ )
261
+ else:
262
+ unresolved.append({**_unresolved(field, target["reason"]), **target})
263
+
264
+ for mapping in baseline.get("regionMappings") or []:
265
+ if not isinstance(mapping, Mapping):
266
+ continue
267
+ source_path = str(mapping.get("sourcePath") or "")
268
+ value, has_value = _content_value(content, source_path)
269
+ if not has_value:
270
+ if mapping.get("required"):
271
+ unresolved.append(_unresolved(mapping, "missing content"))
272
+ continue
273
+ target = _analyze_anchor_target(doc, mapping)
274
+ if target["ok"]:
275
+ resolved.append(
276
+ {
277
+ "id": mapping.get("id"),
278
+ "kind": mapping.get("kind"),
279
+ "anchor": target["anchor"],
280
+ "paragraph_index": target["paragraph_index"],
281
+ "sourcePath": source_path,
282
+ "value": copy.deepcopy(value),
283
+ "columns": list(mapping.get("columns") or []),
284
+ }
285
+ )
286
+ else:
287
+ unresolved.append({**_unresolved(mapping, target["reason"]), **target})
288
+ return resolved, unresolved
289
+
290
+
291
+ def _analyze_anchor_target(
292
+ doc: HwpxDocument, item: Mapping[str, Any]
293
+ ) -> dict[str, Any]:
294
+ raw_locator = item.get("locator")
295
+ locator: Mapping[str, Any] = (
296
+ raw_locator if isinstance(raw_locator, Mapping) else {}
297
+ )
298
+ anchor = str(item.get("anchor") or locator.get("anchor") or "").strip()
299
+ if not anchor:
300
+ return {"ok": False, "reason": "missing anchor", "anchor": anchor}
301
+ matches = _find_anchor_matches(doc, anchor)
302
+ if not matches:
303
+ return {"ok": False, "reason": "anchor not found", "anchor": anchor}
304
+ if len(matches) > 1:
305
+ return {
306
+ "ok": False,
307
+ "reason": "ambiguous anchor",
308
+ "anchor": anchor,
309
+ "candidate_count": len(matches),
310
+ "candidates": matches,
311
+ }
312
+ return {"ok": True, "anchor": anchor, **matches[0]}
313
+
314
+
315
+ def _find_anchor_matches(doc: HwpxDocument, anchor: str) -> list[dict[str, int]]:
316
+ matches: list[dict[str, int]] = []
317
+ global_index = 0
318
+ for section_index, section in enumerate(doc.sections):
319
+ for local_index, paragraph in enumerate(section.paragraphs):
320
+ if anchor in (paragraph.text or ""):
321
+ matches.append(
322
+ {
323
+ "paragraph_index": global_index,
324
+ "section_index": section_index,
325
+ "section_paragraph_index": local_index,
326
+ }
327
+ )
328
+ global_index += 1
329
+ return matches
330
+
331
+
332
+ def _content_value(content: Mapping[str, Any], source_path: str) -> tuple[Any, bool]:
333
+ if source_path in content:
334
+ value = content[source_path]
335
+ return value, _has_value(value)
336
+ path = source_path.replace("[]", "")
337
+ parts = [part for part in path.split(".") if part]
338
+ current: Any = content
339
+ for part in parts:
340
+ if isinstance(current, Mapping) and part in current:
341
+ current = current[part]
342
+ continue
343
+ return None, False
344
+ return current, _has_value(current)
345
+
346
+
347
+ def _has_value(value: Any) -> bool:
348
+ if value is None:
349
+ return False
350
+ if isinstance(value, str):
351
+ return bool(value.strip())
352
+ if isinstance(value, (list, tuple, dict)):
353
+ return bool(value)
354
+ return True
355
+
356
+
357
+ def _stringify_value(value: Any) -> str:
358
+ if isinstance(value, (list, tuple)):
359
+ return "\n".join(str(item) for item in value)
360
+ if isinstance(value, Mapping):
361
+ return "\n".join(f"{key}: {val}" for key, val in value.items())
362
+ return str(value)
363
+
364
+
365
+ def _unresolved(item: Mapping[str, Any], reason: str) -> dict[str, Any]:
366
+ return {
367
+ "id": item.get("id"),
368
+ "kind": item.get("kind") or item.get("locator", {}).get("kind"),
369
+ "anchor": item.get("anchor") or item.get("locator", {}).get("anchor"),
370
+ "sourcePath": item.get("sourcePath"),
371
+ "required": bool(item.get("required")),
372
+ "reason": reason,
373
+ "next_action": "provide content, unique anchor, or a narrower baseline locator",
374
+ }
375
+
376
+
377
+ def _apply_target(doc: HwpxDocument, target: dict[str, Any]) -> dict[str, Any]:
378
+ kind = target.get("kind")
379
+ if kind == "scalar-line":
380
+ return _apply_scalar_line(doc, target)
381
+ if kind == "section-region":
382
+ return _apply_section_region(doc, target)
383
+ if kind == "table-region":
384
+ return _apply_table_region(doc, target)
385
+ return {**target, "applied": False, "reason": f"unsupported target kind: {kind}"}
386
+
387
+
388
+ def _apply_scalar_line(doc: HwpxDocument, target: Mapping[str, Any]) -> dict[str, Any]:
389
+ match = _single_current_anchor(doc, str(target["anchor"]))
390
+ paragraph = doc.sections[match["section_index"]].paragraphs[
391
+ match["section_paragraph_index"]
392
+ ]
393
+ before = paragraph.text or ""
394
+ paragraph.text = f"{target['anchor']} {target.get('value', '')}".rstrip()
395
+ return {
396
+ **dict(target),
397
+ "applied": True,
398
+ "before_text": before,
399
+ "after_text": paragraph.text,
400
+ }
401
+
402
+
403
+ def _apply_section_region(
404
+ doc: HwpxDocument, target: Mapping[str, Any]
405
+ ) -> dict[str, Any]:
406
+ match = _single_current_anchor(doc, str(target["anchor"]))
407
+ section = doc.sections[match["section_index"]]
408
+ anchor_index = int(match["section_paragraph_index"])
409
+ paragraphs = _paragraph_values(target.get("value"))
410
+ removed = _remove_placeholder_paragraphs(section, anchor_index + 1)
411
+ template = removed[0] if removed else section.paragraphs[anchor_index].element
412
+ new_elements = [_paragraph_clone_with_text(template, text) for text in paragraphs]
413
+ section.insert_paragraphs(anchor_index + 1, new_elements)
414
+ return {
415
+ **dict(target),
416
+ "applied": True,
417
+ "inserted_paragraphs": len(new_elements),
418
+ "removed_placeholders": len(removed),
419
+ }
420
+
421
+
422
+ def _apply_table_region(doc: HwpxDocument, target: Mapping[str, Any]) -> dict[str, Any]:
423
+ match = _single_current_anchor(doc, str(target["anchor"]))
424
+ section = doc.sections[match["section_index"]]
425
+ anchor_index = int(match["section_paragraph_index"])
426
+ removed = _remove_placeholder_paragraphs(section, anchor_index + 1)
427
+ rows = _table_rows(target.get("value"))
428
+ columns = [str(column) for column in target.get("columns") or []]
429
+ if not columns and rows:
430
+ columns = list(rows[0].keys())
431
+ generated = _build_table_paragraph(doc, columns, rows)
432
+ section.insert_paragraphs(anchor_index + 1, [generated])
433
+ return {
434
+ **dict(target),
435
+ "applied": True,
436
+ "inserted_tables": 1,
437
+ "inserted_rows": len(rows),
438
+ "removed_placeholders": len(removed),
439
+ }
440
+
441
+
442
+ def _single_current_anchor(doc: HwpxDocument, anchor: str) -> dict[str, int]:
443
+ matches = _find_anchor_matches(doc, anchor)
444
+ if len(matches) != 1:
445
+ raise ValueError(f"anchor must resolve once during apply: {anchor!r}")
446
+ return matches[0]
447
+
448
+
449
+ def _paragraph_values(value: Any) -> list[str]:
450
+ if isinstance(value, str):
451
+ return [line.strip() for line in value.splitlines() if line.strip()] or [value]
452
+ if isinstance(value, Mapping):
453
+ raw = value.get("paragraphs") or value.get("body") or value.get("text") or []
454
+ return _paragraph_values(raw)
455
+ if isinstance(value, (list, tuple)):
456
+ return [str(item).strip() for item in value if str(item).strip()]
457
+ return [str(value)]
458
+
459
+
460
+ def _table_rows(value: Any) -> list[dict[str, str]]:
461
+ if isinstance(value, Mapping):
462
+ raw = value.get("rows", [])
463
+ else:
464
+ raw = value
465
+ rows: list[dict[str, str]] = []
466
+ if not isinstance(raw, (list, tuple)):
467
+ return rows
468
+ for row in raw:
469
+ if isinstance(row, Mapping):
470
+ rows.append({str(key): _stringify_value(val) for key, val in row.items()})
471
+ return rows
472
+
473
+
474
+ def _build_table_paragraph(
475
+ doc: HwpxDocument,
476
+ columns: list[str],
477
+ rows: list[dict[str, str]],
478
+ ) -> Any:
479
+ table = doc.add_table(len(rows) + 1, max(1, len(columns)))
480
+ for col, label in enumerate(columns or ["내용"]):
481
+ table.set_cell_text(0, col, label)
482
+ for row_index, row in enumerate(rows, start=1):
483
+ for col_index, column in enumerate(columns or list(row.keys()) or ["내용"]):
484
+ table.set_cell_text(row_index, col_index, row.get(column, ""))
485
+ generated_paragraph = doc.sections[-1].paragraphs[-1]
486
+ cloned = copy.deepcopy(generated_paragraph.element)
487
+ doc.sections[-1].remove_paragraph(len(doc.sections[-1].paragraphs) - 1)
488
+ return cloned
489
+
490
+
491
+ def _remove_placeholder_paragraphs(section: Any, start_index: int) -> list[Any]:
492
+ removed: list[Any] = []
493
+ while start_index < len(section.paragraphs):
494
+ paragraph = section.paragraphs[start_index]
495
+ text = (paragraph.text or "").strip()
496
+ if not _looks_like_placeholder(text):
497
+ break
498
+ removed.append(copy.deepcopy(paragraph.element))
499
+ section.remove_paragraph(start_index)
500
+ return removed
501
+
502
+
503
+ def _looks_like_placeholder(text: str) -> bool:
504
+ if not text:
505
+ return True
506
+ return any(marker in text for marker in _DEFAULT_RESIDUAL_MARKERS)
507
+
508
+
509
+ def _paragraph_clone_with_text(template: Any, text: str) -> Any:
510
+ cloned = copy.deepcopy(template)
511
+ paragraph = _ParagraphElementAdapter(cloned)
512
+ paragraph.text = text
513
+ return cloned
514
+
515
+
516
+ def _remove_layout_cache(element: Any) -> None:
517
+ for child in list(element):
518
+ if _local_name(child.tag).lower() == "linesegarray":
519
+ element.remove(child)
520
+ else:
521
+ _remove_layout_cache(child)
522
+
523
+
524
+ class _ParagraphElementAdapter:
525
+ def __init__(self, element: Any):
526
+ self.element = element
527
+
528
+ @property
529
+ def text(self) -> str:
530
+ texts = [
531
+ node.text or ""
532
+ for node in self.element.iter()
533
+ if _local_name(node.tag) == "t"
534
+ ]
535
+ return "".join(texts)
536
+
537
+ @text.setter
538
+ def text(self, value: str) -> None:
539
+ text_nodes = [
540
+ node for node in self.element.iter() if _local_name(node.tag) == "t"
541
+ ]
542
+ if text_nodes:
543
+ text_nodes[0].text = value
544
+ for node in text_nodes[1:]:
545
+ node.text = ""
546
+ _remove_layout_cache(self.element)
547
+
548
+
549
+ def _local_name(tag: Any) -> str:
550
+ return str(tag).rsplit("}", 1)[-1]
551
+
552
+
553
+ def _runtime_validation(path: Path) -> dict[str, Any]:
554
+ package_report = validate_package(path)
555
+ document_report = validate_document(path)
556
+ open_safety = validate_editor_open_safety(path)
557
+ return {
558
+ "validate_package": _report_payload(package_report, "checked_parts"),
559
+ "validate_document": _report_payload(document_report, "validated_parts"),
560
+ "openSafety": open_safety.to_dict(),
561
+ }
562
+
563
+
564
+ def _report_payload(report: Any, parts_attr: str) -> dict[str, Any]:
565
+ return {
566
+ "ok": bool(getattr(report, "ok", False)),
567
+ parts_attr: list(getattr(report, parts_attr, ())),
568
+ "issues": [
569
+ {
570
+ "part": getattr(issue, "part_name", None),
571
+ "message": getattr(issue, "message", str(issue)),
572
+ "level": getattr(issue, "level", "error"),
573
+ }
574
+ for issue in getattr(report, "issues", ())
575
+ ],
576
+ }
577
+
578
+
579
+ def _residual_markers(path: Path, policy: Mapping[str, Any]) -> dict[str, Any]:
580
+ markers = [
581
+ str(marker)
582
+ for marker in policy.get("patterns", _DEFAULT_RESIDUAL_MARKERS)
583
+ if _marker_is_actionable(str(marker))
584
+ ]
585
+ doc = HwpxDocument.open(path)
586
+ try:
587
+ full_text = doc.export_text()
588
+ finally:
589
+ doc.close()
590
+ blocking = [
591
+ {"marker": marker} for marker in markers if marker and marker in full_text
592
+ ]
593
+ return {
594
+ "blockOutsideVisualReview": bool(policy.get("blockOutsideVisualReview", True)),
595
+ "patterns": markers,
596
+ "blocking": blocking,
597
+ }
598
+
599
+
600
+ def _marker_is_actionable(marker: str) -> bool:
601
+ if marker in {"□", "empty ㅇ bullet", "empty - bullet", "※ ... 기재"}:
602
+ return False
603
+ return bool(marker.strip())
604
+
605
+
606
+ def _residual_marker_policy(baseline: Mapping[str, Any]) -> dict[str, Any]:
607
+ locator_policy = baseline.get("locatorPolicy")
608
+ if not isinstance(locator_policy, Mapping):
609
+ return {
610
+ "blockOutsideVisualReview": True,
611
+ "patterns": list(_DEFAULT_RESIDUAL_MARKERS),
612
+ }
613
+ markers = locator_policy.get("residualMarkers")
614
+ if not isinstance(markers, Mapping):
615
+ return {
616
+ "blockOutsideVisualReview": True,
617
+ "patterns": list(_DEFAULT_RESIDUAL_MARKERS),
618
+ }
619
+ return {
620
+ "blockOutsideVisualReview": bool(markers.get("blockOutsideVisualReview", True)),
621
+ "patterns": list(markers.get("patterns") or _DEFAULT_RESIDUAL_MARKERS),
622
+ }
623
+
624
+
625
+ def _anchors_from_baseline(baseline: Mapping[str, Any]) -> list[str]:
626
+ anchors: list[str] = []
627
+ for collection in ("scalarFields", "regionMappings", "visualReviewRegions"):
628
+ for item in baseline.get(collection) or []:
629
+ if not isinstance(item, Mapping):
630
+ continue
631
+ anchor = item.get("anchor")
632
+ raw_locator = item.get("locator")
633
+ locator: Mapping[str, Any] = (
634
+ raw_locator if isinstance(raw_locator, Mapping) else {}
635
+ )
636
+ value = str(anchor or locator.get("anchor") or "").strip()
637
+ if value:
638
+ anchors.append(value)
639
+ return anchors
640
+
641
+
642
+ def _visual_review_regions(baseline: Mapping[str, Any]) -> list[dict[str, Any]]:
643
+ regions = baseline.get("visualReviewRegions") or baseline.get("visualReview") or []
644
+ return [dict(region) for region in regions if isinstance(region, Mapping)]
645
+
646
+
647
+ def _table_count(doc: HwpxDocument) -> int:
648
+ return sum(len(getattr(paragraph, "tables", [])) for paragraph in doc.paragraphs)
649
+
650
+
651
+ def _sha256_file(path: Path) -> str:
652
+ digest = hashlib.sha256()
653
+ with path.open("rb") as stream:
654
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
655
+ digest.update(chunk)
656
+ return digest.hexdigest()