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,1177 @@
1
+ """High-level HWPX form-fill workflow helpers.
2
+
3
+ The public MCP surface is intentionally two-phase:
4
+ ``analyze_form_fill`` is non-mutating and ``apply_form_fill`` owns copy,
5
+ mutation, re-read, and validation evidence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import tempfile
17
+ import uuid
18
+ import zipfile
19
+ from difflib import SequenceMatcher
20
+ from pathlib import Path
21
+ from typing import Any, Literal
22
+ from xml.etree import ElementTree as ET
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field
25
+
26
+ from .office.compliance import DEFAULT_POLICY, mask_pii
27
+
28
+ try: # python-hwpx >= 2.10.3
29
+ from hwpx.tools.package_validator import validate_package
30
+ except ImportError as exc: # pragma: no cover - expected only on dependency skew
31
+ validate_package = None
32
+ _PACKAGE_VALIDATOR_IMPORT_ERROR: Exception | None = exc
33
+ else:
34
+ _PACKAGE_VALIDATOR_IMPORT_ERROR = None
35
+
36
+ try: # python-hwpx >= 2.10.3
37
+ from hwpx.tools.repair import repair_repack
38
+ except ImportError as exc: # pragma: no cover - expected only on dependency skew
39
+ repair_repack = None
40
+ _REPAIR_REPACK_IMPORT_ERROR: Exception | None = exc
41
+ else:
42
+ _REPAIR_REPACK_IMPORT_ERROR = None
43
+
44
+ from . import quality as quality_contract
45
+ from .core.content import get_table_data, get_table_map_in_doc, set_cell_text
46
+ from .core.document import open_doc
47
+ from .core.formatting import list_styles_in_doc
48
+ from .storage import build_hwpx_open_safety_report
49
+ from .upstream import repair_pathological_text_spacing, validate_document_path
50
+ from .utils.helpers import resolve_path
51
+
52
+ _FORM_FILL_SCHEMA_VERSION = "hwpx.formfill.v1"
53
+ _DOCX_W_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
54
+ _TABLE_DIRECTIONS = {"right", "down"}
55
+ _FORM_FILL_PLANS: dict[str, dict[str, Any]] = {}
56
+ _CONFIDENCE_LABEL_EXACT = "label-exact"
57
+ _CONFIDENCE_LABEL_FUZZY = "label-fuzzy"
58
+ _CONFIDENCE_POSITION_GUESS = "position-guess"
59
+ _FUZZY_MATCH_THRESHOLD = 0.72
60
+
61
+
62
+ class _FormFillModel(BaseModel):
63
+ """Typed MCP boundary while preserving forward-compatible plan metadata."""
64
+
65
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
66
+
67
+
68
+ class FormFillSourceInput(_FormFillModel):
69
+ type: str = "structured"
70
+ path: str | None = None
71
+ sha256: str | None = None
72
+
73
+
74
+ class FormFillTargetInput(_FormFillModel):
75
+ kind: Literal[
76
+ "label-path",
77
+ "cell",
78
+ "coordinate",
79
+ "form-field",
80
+ "placeholder",
81
+ "canonical-path",
82
+ "body-anchor",
83
+ ] = "label-path"
84
+ path: str | None = None
85
+ token: str | None = None
86
+ table_index: int | None = None
87
+ row: int | None = None
88
+ col: int | None = None
89
+ field_index: int | None = None
90
+ field_id: str | None = None
91
+ name: str | None = None
92
+
93
+
94
+ class FormFillFieldInput(_FormFillModel):
95
+ key: str | None = None
96
+ label: str | None = None
97
+ value: str | int | float | bool | None = ""
98
+ text: str | None = None
99
+ target: FormFillTargetInput | None = None
100
+ style_policy: str = Field(default="preserve-target", alias="stylePolicy")
101
+
102
+
103
+ class CanonicalFormFillInput(_FormFillModel):
104
+ schema_version: Literal["hwpx.formfill.v1"] = Field(
105
+ default="hwpx.formfill.v1", alias="schemaVersion"
106
+ )
107
+ source: FormFillSourceInput = Field(default_factory=FormFillSourceInput)
108
+ fields: list[FormFillFieldInput] = Field(default_factory=list)
109
+ paragraphs: list[FormFillFieldInput] = Field(default_factory=list)
110
+
111
+
112
+ class FormFillAnalyzeOptions(_FormFillModel):
113
+ require_unique_anchors: bool = Field(default=True, alias="requireUniqueAnchors")
114
+ allow_fuzzy_labels: bool = Field(default=True, alias="allowFuzzyLabels")
115
+ preserve_unmapped_parts: bool = Field(default=True, alias="preserveUnmappedParts")
116
+
117
+
118
+ class FormFillPlanFile(_FormFillModel):
119
+ filename: str | None = None
120
+ path: str | None = None
121
+ sha256: str | None = None
122
+ mtime_ns: int | None = None
123
+
124
+
125
+ class FormFillResolvedMapping(_FormFillModel):
126
+ kind: str
127
+ key: str | None = None
128
+ label: str | None = None
129
+ value: str | int | float | bool | None = None
130
+ confidence: str | None = None
131
+ confidence_grade: str | None = Field(default=None, alias="confidenceGrade")
132
+ method: str | None = None
133
+
134
+
135
+ class FormFillMappingSet(_FormFillModel):
136
+ resolved: list[FormFillResolvedMapping] = Field(default_factory=list)
137
+ unresolved: list[FormFillResolvedMapping] = Field(default_factory=list)
138
+
139
+
140
+ class FormFillPlanInput(_FormFillModel):
141
+ """Serializable output of analyze_form_fill accepted by apply_form_fill."""
142
+
143
+ plan_id: str
144
+ schema_version: Literal["hwpx.formfill.v1"] = Field(alias="schemaVersion")
145
+ source: FormFillPlanFile
146
+ destination: FormFillPlanFile
147
+ canonical_input: CanonicalFormFillInput = Field(alias="canonicalInput")
148
+ mappings: FormFillMappingSet
149
+ unresolved_count: int = 0
150
+ resolved_count: int = 0
151
+ mutated: bool = False
152
+ options: FormFillAnalyzeOptions = Field(default_factory=FormFillAnalyzeOptions)
153
+
154
+
155
+ def _typed_payload(value: BaseModel | dict[str, Any] | str | None) -> dict[str, Any] | str | None:
156
+ if isinstance(value, BaseModel):
157
+ return value.model_dump(by_alias=True, exclude_none=True)
158
+ return value
159
+
160
+
161
+ def _clear_paragraph_layout_cache(paragraph: Any) -> None:
162
+ element = getattr(paragraph, "element", None)
163
+ if element is None:
164
+ return
165
+ for child in list(element):
166
+ if child.tag.rsplit("}", 1)[-1].lower() == "linesegarray":
167
+ element.remove(child)
168
+ section = getattr(paragraph, "section", None)
169
+ if section is not None and hasattr(section, "mark_dirty"):
170
+ section.mark_dirty()
171
+
172
+
173
+ def sha256_file(path: str | Path) -> str:
174
+ digest = hashlib.sha256()
175
+ with Path(path).open("rb") as stream:
176
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
177
+ digest.update(chunk)
178
+ return digest.hexdigest()
179
+
180
+
181
+ def analyze_form_fill_workflow(
182
+ *,
183
+ source_filename: str,
184
+ input_json: CanonicalFormFillInput | dict[str, Any] | str | None = None,
185
+ input_json_path: str | None = None,
186
+ input_docx: str | None = None,
187
+ destination_filename: str | None = None,
188
+ options: FormFillAnalyzeOptions | dict[str, Any] | None = None,
189
+ ) -> dict[str, Any]:
190
+ """Build a non-mutating form-fill analysis and serializable plan."""
191
+
192
+ source_path = resolve_path(source_filename)
193
+ source = Path(source_path)
194
+ before_hash = sha256_file(source)
195
+ destination_path = resolve_path(destination_filename) if destination_filename else None
196
+
197
+ canonical_input = _load_canonical_input(
198
+ input_json=_typed_payload(input_json),
199
+ input_json_path=input_json_path,
200
+ input_docx=input_docx,
201
+ )
202
+ doc = open_doc(source_path)
203
+ table_map = get_table_map_in_doc(doc)
204
+ styles = list_styles_in_doc(doc)
205
+ outline = _document_outline(doc)
206
+ mappings = _build_mapping_analysis(doc, canonical_input)
207
+ plan_id = f"ff_{uuid.uuid4().hex[:16]}"
208
+
209
+ analysis: dict[str, Any] = {
210
+ "plan_id": plan_id,
211
+ "schemaVersion": _FORM_FILL_SCHEMA_VERSION,
212
+ "source": {
213
+ "filename": source_filename,
214
+ "path": source_path,
215
+ "sha256": before_hash,
216
+ "mtime_ns": source.stat().st_mtime_ns,
217
+ },
218
+ "destination": {
219
+ "filename": destination_filename,
220
+ "path": destination_path,
221
+ "required": bool(destination_filename),
222
+ "will_be_created_by": "apply_form_fill",
223
+ },
224
+ "canonicalInput": canonical_input,
225
+ "document": {
226
+ "info": {
227
+ "sections": len(getattr(doc, "sections", [])),
228
+ "paragraphs": len(getattr(doc, "paragraphs", [])),
229
+ "tables": len(table_map.get("tables", [])),
230
+ },
231
+ "outline": outline,
232
+ "tables": table_map.get("tables", []),
233
+ "styles": styles,
234
+ "styleCount": len(styles),
235
+ },
236
+ "formFields": mappings.get("formFields", {}),
237
+ "mappings": mappings,
238
+ "unresolved_count": len(mappings["unresolved"]),
239
+ "resolved_count": len(mappings["resolved"]),
240
+ "mutated": False,
241
+ "next_tool": "apply_form_fill",
242
+ "options": _typed_payload(options) or {},
243
+ }
244
+ _FORM_FILL_PLANS[plan_id] = copy.deepcopy(analysis)
245
+ after_hash = sha256_file(source)
246
+ analysis["source"]["unchanged_after_analysis"] = after_hash == before_hash
247
+ return analysis
248
+
249
+
250
+ def apply_form_fill_workflow(
251
+ *,
252
+ plan_id: str | None = None,
253
+ analysis: FormFillPlanInput | dict[str, Any] | None = None,
254
+ source_filename: str | None = None,
255
+ destination_filename: str | None = None,
256
+ canonical_input: CanonicalFormFillInput | dict[str, Any] | str | None = None,
257
+ confirm: bool = True,
258
+ mask: bool = True,
259
+ ) -> dict[str, Any]:
260
+ """Apply a resolved form-fill plan to a copied destination and validate it."""
261
+
262
+ if not confirm:
263
+ raise ValueError("confirm must be true to apply form-fill mutations")
264
+
265
+ plan = _resolve_analysis(plan_id=plan_id, analysis=analysis)
266
+ if canonical_input is not None:
267
+ plan["canonicalInput"] = _load_canonical_input(input_json=_typed_payload(canonical_input))
268
+ doc_for_mapping = open_doc(_source_path_from_plan(plan, source_filename))
269
+ plan["mappings"] = _build_mapping_analysis(doc_for_mapping, plan["canonicalInput"])
270
+
271
+ source_path = _source_path_from_plan(plan, source_filename)
272
+ destination_path = _destination_path_from_plan(plan, destination_filename)
273
+ if Path(source_path).resolve(strict=False) == Path(destination_path).resolve(strict=False):
274
+ raise ValueError("apply_form_fill refuses source-in-place edits; destination_filename must differ from source")
275
+
276
+ unresolved = list(plan.get("mappings", {}).get("unresolved", []))
277
+ if unresolved:
278
+ return {
279
+ "handoff_status": "blocked",
280
+ "reason": "unresolved mappings remain",
281
+ "unresolved": unresolved,
282
+ "applied": [],
283
+ "source": {"path": source_path, "sha256": sha256_file(source_path)},
284
+ "destination": {"path": destination_path},
285
+ }
286
+
287
+ source = Path(source_path)
288
+ destination = Path(destination_path)
289
+ destination.parent.mkdir(parents=True, exist_ok=True)
290
+ source_before_hash = sha256_file(source)
291
+ source_before_mtime = source.stat().st_mtime_ns
292
+ tmp_fd, tmp_name = tempfile.mkstemp(
293
+ prefix=f".{destination.stem}.",
294
+ suffix=destination.suffix or ".hwpx",
295
+ dir=str(destination.parent),
296
+ )
297
+ tmp_destination = Path(tmp_name)
298
+ os.close(tmp_fd)
299
+ try:
300
+ shutil.copy2(source, tmp_destination)
301
+ copied_hash = sha256_file(tmp_destination)
302
+
303
+ doc = open_doc(str(tmp_destination))
304
+ applied: list[dict[str, Any]] = []
305
+ for _raw_mapping in plan.get("mappings", {}).get("resolved", []):
306
+ # PII compliance: mask the merged-in value (machine set on by
307
+ # default) so neither the output doc nor the applied[] echo leaks raw PII.
308
+ mapping = dict(_raw_mapping)
309
+ if mask and mapping.get("value") is not None:
310
+ mapping["value"] = mask_pii(str(mapping["value"]), DEFAULT_POLICY)
311
+ if mapping.get("kind") == "form-field":
312
+ before_fields = _document_form_fields(doc)
313
+ before_field = _find_form_field_by_mapping(before_fields, mapping)
314
+ fill_result = doc.fill_form_field(
315
+ str(mapping.get("value", "")),
316
+ field_index=int(mapping["field_index"]),
317
+ )
318
+ _before_ff = before_field.get("current_value", "") if before_field else ""
319
+ applied.append(
320
+ {
321
+ **mapping,
322
+ "before_text": mask_pii(_before_ff, DEFAULT_POLICY) if mask else _before_ff,
323
+ "after_text": fill_result["after_value"],
324
+ "style_before": fill_result.get("style_before"),
325
+ "style_after": fill_result.get("style_after"),
326
+ "style_preserved": bool(fill_result.get("style_preserved", False)),
327
+ }
328
+ )
329
+ elif mapping.get("kind") == "cell":
330
+ table_index = int(mapping["table_index"])
331
+ row = int(mapping["row"])
332
+ col = int(mapping["col"])
333
+ before_style = _cell_style_snapshot(doc, table_index, row, col)
334
+ before_text = _cell_text(doc, table_index, row, col)
335
+ if mask:
336
+ before_text = mask_pii(before_text, DEFAULT_POLICY)
337
+ set_cell_text(doc, table_index, row, col, str(mapping.get("value", "")))
338
+ after_style = _cell_style_snapshot(doc, table_index, row, col)
339
+ applied.append(
340
+ {
341
+ **mapping,
342
+ "before_text": before_text,
343
+ "after_text": str(mapping.get("value", "")),
344
+ "style_before": before_style,
345
+ "style_after": after_style,
346
+ "style_preserved": before_style == after_style,
347
+ }
348
+ )
349
+ elif mapping.get("kind") == "placeholder":
350
+ token = str(mapping["token"])
351
+ value = str(mapping.get("value", ""))
352
+ replacements = _replace_placeholder(doc, token, value)
353
+ applied.append(
354
+ {
355
+ **mapping,
356
+ "replaced_count": sum(item["replace_count"] for item in replacements),
357
+ "replacements": replacements,
358
+ "style_preserved": all(item["style_preserved"] for item in replacements),
359
+ }
360
+ )
361
+
362
+ save_report = _save_form_fill_document(doc, tmp_destination)
363
+ repair_result = _repair_repack_destination(tmp_destination)
364
+ reread_doc = open_doc(str(tmp_destination))
365
+ touched = _reread_touched(reread_doc, applied)
366
+ validation = _runtime_validation(str(tmp_destination))
367
+ source_after_hash = sha256_file(source)
368
+ source_after_mtime = source.stat().st_mtime_ns
369
+ output_hash = sha256_file(tmp_destination)
370
+ ok = bool(
371
+ validation["validate_structure"]["ok"]
372
+ and validation["validate_package"]["ok"]
373
+ and validation["validate_document"]["ok"]
374
+ and validation["openSafety"]["ok"]
375
+ )
376
+ if ok:
377
+ os.replace(tmp_destination, destination)
378
+
379
+ return {
380
+ "handoff_status": "ready" if ok else "blocked",
381
+ "plan_id": plan.get("plan_id"),
382
+ "source": {
383
+ "path": str(source),
384
+ "sha256_before": source_before_hash,
385
+ "sha256_after": source_after_hash,
386
+ "mtime_ns_before": source_before_mtime,
387
+ "mtime_ns_after": source_after_mtime,
388
+ "preserved": source_before_hash == source_after_hash and source_before_mtime == source_after_mtime,
389
+ },
390
+ "destination": {
391
+ "path": str(destination),
392
+ "sha256_after_copy": copied_hash,
393
+ "sha256_after_apply": output_hash,
394
+ "changed": copied_hash != output_hash,
395
+ },
396
+ "repair": repair_result,
397
+ "lineage_id": _lineage_id(source_before_hash, str(destination)),
398
+ "applied": applied,
399
+ "unresolved": [],
400
+ "touched": touched,
401
+ "validation": validation,
402
+ "persisted": ok,
403
+ "visualComplete": quality_contract.visual_complete_block(save_report),
404
+ }
405
+ finally:
406
+ _cleanup_temporary_destination(tmp_destination)
407
+
408
+
409
+ def _load_canonical_input(
410
+ *,
411
+ input_json: dict[str, Any] | str | None = None,
412
+ input_json_path: str | None = None,
413
+ input_docx: str | None = None,
414
+ ) -> dict[str, Any]:
415
+ provided = [value is not None for value in (input_json, input_json_path, input_docx)].count(True)
416
+ if provided != 1:
417
+ raise ValueError("provide exactly one of input_json, input_json_path, or input_docx")
418
+
419
+ if input_json_path is not None:
420
+ payload = json.loads(Path(resolve_path(input_json_path)).read_text(encoding="utf-8"))
421
+ elif input_docx is not None:
422
+ payload = _canonical_input_from_docx(resolve_path(input_docx))
423
+ elif isinstance(input_json, str):
424
+ stripped = input_json.strip()
425
+ possible_path = Path(stripped).expanduser()
426
+ if possible_path.suffix.lower() == ".json" and possible_path.exists():
427
+ payload = json.loads(possible_path.read_text(encoding="utf-8"))
428
+ else:
429
+ payload = json.loads(stripped)
430
+ elif isinstance(input_json, dict):
431
+ payload = copy.deepcopy(input_json)
432
+ else: # pragma: no cover - defensive, provided count catches this
433
+ raise ValueError("input_json must be an object, JSON string, or JSON path")
434
+
435
+ return _normalize_canonical_input(payload)
436
+
437
+
438
+ def _normalize_canonical_input(payload: dict[str, Any]) -> dict[str, Any]:
439
+ if not isinstance(payload, dict):
440
+ raise ValueError("canonical input must be a JSON object")
441
+ normalized = copy.deepcopy(payload)
442
+ normalized.setdefault("schemaVersion", _FORM_FILL_SCHEMA_VERSION)
443
+ if normalized["schemaVersion"] != _FORM_FILL_SCHEMA_VERSION:
444
+ raise ValueError(f"unsupported form-fill schemaVersion: {normalized['schemaVersion']}")
445
+ normalized.setdefault("source", {"type": "structured"})
446
+ fields = normalized.setdefault("fields", [])
447
+ if not isinstance(fields, list):
448
+ raise ValueError("fields must be a list")
449
+ for index, field in enumerate(fields):
450
+ if not isinstance(field, dict):
451
+ raise ValueError(f"fields[{index}] must be an object")
452
+ label = str(field.get("label") or field.get("key") or "").strip()
453
+ if not label:
454
+ raise ValueError(f"fields[{index}] requires label or key")
455
+ field.setdefault("key", label)
456
+ field.setdefault("label", label)
457
+ field.setdefault("value", "")
458
+ field.setdefault("stylePolicy", "preserve-target")
459
+ field.setdefault("target", {"kind": "label-path", "path": f"{label} > right"})
460
+ paragraphs = normalized.setdefault("paragraphs", [])
461
+ if not isinstance(paragraphs, list):
462
+ raise ValueError("paragraphs must be a list")
463
+ return normalized
464
+
465
+
466
+ def _canonical_input_from_docx(path: str) -> dict[str, Any]:
467
+ docx_path = Path(path)
468
+ if docx_path.suffix.lower() != ".docx":
469
+ raise ValueError("input_docx must point to a .docx file")
470
+ if not zipfile.is_zipfile(docx_path):
471
+ raise ValueError("input_docx is not a valid DOCX zip package; provide input_json instead")
472
+ try:
473
+ with zipfile.ZipFile(docx_path) as archive:
474
+ xml = archive.read("word/document.xml")
475
+ except KeyError as exc:
476
+ raise ValueError("input_docx does not contain word/document.xml; provide input_json instead") from exc
477
+
478
+ root = ET.fromstring(xml)
479
+ body = root.find(f"{_DOCX_W_NS}body")
480
+ if body is None:
481
+ raise ValueError("input_docx does not contain a Word document body; provide input_json instead")
482
+
483
+ fields: list[dict[str, Any]] = []
484
+ for child in body:
485
+ if child.tag == f"{_DOCX_W_NS}tbl":
486
+ for row in child.iter(f"{_DOCX_W_NS}tr"):
487
+ cells = [_element_text(cell).strip() for cell in row.findall(f"{_DOCX_W_NS}tc")]
488
+ cells = [cell for cell in cells if cell]
489
+ if len(cells) >= 2:
490
+ label, value = cells[0], cells[1]
491
+ fields.append(_field_from_label_value(label, value, source="docx-table"))
492
+ elif child.tag == f"{_DOCX_W_NS}p":
493
+ text = _element_text(child).strip()
494
+ if not text or "=" not in text and ":" not in text:
495
+ continue
496
+ label, value = re.split(r"[:=]", text, maxsplit=1)
497
+ if label.strip() and value.strip():
498
+ fields.append(_field_from_label_value(label.strip(), value.strip(), source="docx-paragraph"))
499
+ if not fields:
500
+ raise ValueError("input_docx did not contain key/value fields; provide canonical input_json")
501
+ return {
502
+ "schemaVersion": _FORM_FILL_SCHEMA_VERSION,
503
+ "source": {"type": "docx", "path": path, "sha256": sha256_file(path)},
504
+ "fields": fields,
505
+ "paragraphs": [],
506
+ }
507
+
508
+
509
+ def _field_from_label_value(label: str, value: str, *, source: str) -> dict[str, Any]:
510
+ return {
511
+ "key": _slug_key(label),
512
+ "label": label,
513
+ "value": value,
514
+ "target": {"kind": "label-path", "path": f"{label} > right"},
515
+ "stylePolicy": "preserve-target",
516
+ "provenance": {"source": source},
517
+ }
518
+
519
+
520
+ def _element_text(element: ET.Element) -> str:
521
+ return "".join(node.text or "" for node in element.iter(f"{_DOCX_W_NS}t"))
522
+
523
+
524
+ def _slug_key(label: str) -> str:
525
+ slug = re.sub(r"\W+", "_", label.strip(), flags=re.UNICODE).strip("_")
526
+ return slug or "field"
527
+
528
+
529
+ def _document_outline(doc: Any) -> list[dict[str, Any]]:
530
+ try:
531
+ from .core.formatting import outline_style_levels
532
+
533
+ style_levels = outline_style_levels(doc)
534
+ except Exception:
535
+ style_levels = {}
536
+ outline = []
537
+ for index, para in enumerate(getattr(doc, "paragraphs", [])):
538
+ text = (getattr(para, "text", None) or "").strip()
539
+ if not text:
540
+ continue
541
+ level = 1 if len(text) < 80 else 0
542
+ style_ref = getattr(para, "style_id_ref", None)
543
+ if style_ref is not None and str(style_ref) in style_levels:
544
+ level = style_levels[str(style_ref)]
545
+ elif text.startswith("#"):
546
+ level = min(6, len(text) - len(text.lstrip("#")))
547
+ if level:
548
+ outline.append({"level": level, "text": text, "paragraph_index": index})
549
+ return outline
550
+
551
+
552
+ def _build_mapping_analysis(doc: Any, canonical_input: dict[str, Any]) -> dict[str, Any]:
553
+ resolved: list[dict[str, Any]] = []
554
+ unresolved: list[dict[str, Any]] = []
555
+ form_fields = _document_form_fields(doc)
556
+ form_field_strategy = {
557
+ "available": bool(form_fields),
558
+ "count": len(form_fields),
559
+ "fields": form_fields,
560
+ "fallback": None if form_fields else "table-label",
561
+ }
562
+ for field in _iter_fill_items(canonical_input):
563
+ target = field.get("target") or {}
564
+ kind = target.get("kind", "label-path")
565
+ if kind == "form-field":
566
+ mapping = _resolved_explicit_form_field_mapping(field, target, form_fields)
567
+ if mapping is None:
568
+ unresolved.append(
569
+ {
570
+ "kind": "form-field",
571
+ "key": field.get("key"),
572
+ "label": field.get("label"),
573
+ "value": str(field.get("value", "")),
574
+ "reason": "form field not found",
575
+ "candidate_count": len(form_fields),
576
+ "candidates": form_fields,
577
+ "next_action": "provide field_index, field_id, or name from list_form_fields",
578
+ }
579
+ )
580
+ else:
581
+ resolved.append(mapping)
582
+ continue
583
+ if kind in {"cell", "coordinate"} or {"table_index", "row", "col"}.issubset(target):
584
+ resolved.append(_resolved_cell_mapping(field, target, method="explicit-coordinate"))
585
+ continue
586
+ if kind == "label-path":
587
+ label, direction = _label_and_direction(field, target)
588
+ native_mapping = _resolved_form_field_mapping(field, form_fields, label)
589
+ if native_mapping is not None:
590
+ resolved.append(native_mapping)
591
+ continue
592
+ matches = doc.find_cell_by_label(label, direction=direction).get("matches", [])
593
+ if len(matches) == 1:
594
+ match = matches[0]
595
+ cell = match["target_cell"]
596
+ resolved.append(
597
+ {
598
+ "kind": "cell",
599
+ "key": field.get("key"),
600
+ "label": label,
601
+ "value": str(field.get("value", "")),
602
+ "table_index": match["table_index"],
603
+ "row": cell["row"],
604
+ "col": cell["col"],
605
+ "current_text": cell.get("text", ""),
606
+ "stylePolicy": field.get("stylePolicy", "preserve-target"),
607
+ "confidence": "high",
608
+ "confidenceGrade": _CONFIDENCE_LABEL_EXACT,
609
+ "method": "label-path",
610
+ }
611
+ )
612
+ elif not matches:
613
+ fuzzy_matches = _find_fuzzy_table_label_matches(doc, label, direction)
614
+ if len(fuzzy_matches) == 1:
615
+ match = fuzzy_matches[0]
616
+ cell = match["target_cell"]
617
+ resolved.append(
618
+ {
619
+ "kind": "cell",
620
+ "key": field.get("key"),
621
+ "label": label,
622
+ "matched_label": match["label_cell"].get("text", ""),
623
+ "match_score": match["score"],
624
+ "value": str(field.get("value", "")),
625
+ "table_index": match["table_index"],
626
+ "row": cell["row"],
627
+ "col": cell["col"],
628
+ "current_text": cell.get("text", ""),
629
+ "stylePolicy": field.get("stylePolicy", "preserve-target"),
630
+ "confidence": "medium",
631
+ "confidenceGrade": _CONFIDENCE_LABEL_FUZZY,
632
+ "method": "label-path-fuzzy",
633
+ }
634
+ )
635
+ else:
636
+ unresolved.append(
637
+ {
638
+ "kind": "cell",
639
+ "key": field.get("key"),
640
+ "label": label,
641
+ "value": str(field.get("value", "")),
642
+ "reason": "label not found",
643
+ "candidates": fuzzy_matches,
644
+ "candidate_count": len(fuzzy_matches),
645
+ "next_action": "provide explicit table_index/row/col target",
646
+ }
647
+ )
648
+ else:
649
+ unresolved.append(
650
+ {
651
+ "kind": "cell",
652
+ "key": field.get("key"),
653
+ "label": label,
654
+ "value": str(field.get("value", "")),
655
+ "reason": "label not found" if not matches else "ambiguous label",
656
+ "candidates": matches,
657
+ "candidate_count": len(matches),
658
+ "next_action": "provide explicit table_index/row/col target",
659
+ }
660
+ )
661
+ continue
662
+ if kind == "placeholder":
663
+ token = target.get("token")
664
+ if not token:
665
+ unresolved.append({"kind": "placeholder", "key": field.get("key"), "reason": "missing token"})
666
+ else:
667
+ resolved.append(
668
+ {
669
+ "kind": "placeholder",
670
+ "key": field.get("key"),
671
+ "token": str(token),
672
+ "value": str(field.get("value", "")),
673
+ "stylePolicy": field.get("stylePolicy", "preserve-placeholder"),
674
+ "confidence": "high",
675
+ "confidenceGrade": _CONFIDENCE_LABEL_EXACT,
676
+ "method": "placeholder",
677
+ }
678
+ )
679
+ continue
680
+ unresolved.append({"kind": kind, "key": field.get("key"), "reason": f"unsupported target kind: {kind}"})
681
+ return {"resolved": resolved, "unresolved": unresolved, "formFields": form_field_strategy}
682
+
683
+
684
+ def _document_form_fields(doc: Any) -> list[dict[str, Any]]:
685
+ list_fields = getattr(doc, "list_form_fields", None)
686
+ if not callable(list_fields):
687
+ return []
688
+ fields = list_fields()
689
+ if not isinstance(fields, list):
690
+ return []
691
+ return [copy.deepcopy(field) for field in fields if isinstance(field, dict)]
692
+
693
+
694
+ def _normalize_match_text(value: Any) -> str:
695
+ normalized = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
696
+ while normalized.endswith((":", ":")):
697
+ normalized = normalized[:-1].rstrip()
698
+ return normalized
699
+
700
+
701
+ def _form_field_labels(field: dict[str, Any]) -> list[str]:
702
+ labels: list[str] = []
703
+ for key in ("name", "prompt", "instruction", "field_id", "id", "fieldid"):
704
+ value = str(field.get(key) or "").strip()
705
+ if value:
706
+ labels.append(value)
707
+ for param in field.get("parameters", []) or []:
708
+ if isinstance(param, dict):
709
+ value = str(param.get("value") or "").strip()
710
+ if value:
711
+ labels.append(value)
712
+ return labels
713
+
714
+
715
+ def _resolved_form_field_mapping(
716
+ field: dict[str, Any],
717
+ form_fields: list[dict[str, Any]],
718
+ label: str,
719
+ ) -> dict[str, Any] | None:
720
+ if not form_fields:
721
+ return None
722
+ wanted = _normalize_match_text(field.get("label") or label or field.get("key"))
723
+ exact = [
724
+ item
725
+ for item in form_fields
726
+ if wanted and wanted in {_normalize_match_text(label_value) for label_value in _form_field_labels(item)}
727
+ ]
728
+ if len(exact) == 1:
729
+ return _form_field_mapping(field, exact[0], label, confidence_grade=_CONFIDENCE_LABEL_EXACT, score=1.0)
730
+ if len(exact) > 1:
731
+ return None
732
+
733
+ scored: list[tuple[float, dict[str, Any]]] = []
734
+ for item in form_fields:
735
+ scores = [
736
+ SequenceMatcher(None, wanted, _normalize_match_text(candidate)).ratio()
737
+ for candidate in _form_field_labels(item)
738
+ if _normalize_match_text(candidate)
739
+ ]
740
+ if scores:
741
+ scored.append((max(scores), item))
742
+ scored.sort(key=lambda pair: pair[0], reverse=True)
743
+ if not scored or scored[0][0] < _FUZZY_MATCH_THRESHOLD:
744
+ return None
745
+ if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) < 0.03:
746
+ return None
747
+ return _form_field_mapping(
748
+ field,
749
+ scored[0][1],
750
+ label,
751
+ confidence_grade=_CONFIDENCE_LABEL_FUZZY,
752
+ score=round(scored[0][0], 3),
753
+ )
754
+
755
+
756
+ def _resolved_explicit_form_field_mapping(
757
+ field: dict[str, Any],
758
+ target: dict[str, Any],
759
+ form_fields: list[dict[str, Any]],
760
+ ) -> dict[str, Any] | None:
761
+ if not form_fields:
762
+ return None
763
+ field_index = target.get("field_index", target.get("fieldIndex"))
764
+ if field_index is not None:
765
+ for item in form_fields:
766
+ if int(item.get("index", -1)) == int(field_index):
767
+ return _form_field_mapping(
768
+ field,
769
+ item,
770
+ str(field.get("label") or field.get("key") or ""),
771
+ confidence_grade=_CONFIDENCE_POSITION_GUESS,
772
+ score=None,
773
+ method="form-field-index",
774
+ )
775
+ return None
776
+ selector = target.get("field_id", target.get("fieldId")) or target.get("name")
777
+ if not selector:
778
+ return None
779
+ wanted = _normalize_match_text(selector)
780
+ matches = [
781
+ item
782
+ for item in form_fields
783
+ if wanted and wanted in {_normalize_match_text(label) for label in _form_field_labels(item)}
784
+ ]
785
+ if len(matches) != 1:
786
+ return None
787
+ return _form_field_mapping(
788
+ field,
789
+ matches[0],
790
+ str(field.get("label") or field.get("key") or ""),
791
+ confidence_grade=_CONFIDENCE_LABEL_EXACT,
792
+ score=1.0,
793
+ method="form-field-selector",
794
+ )
795
+
796
+
797
+ def _form_field_mapping(
798
+ field: dict[str, Any],
799
+ form_field: dict[str, Any],
800
+ label: str,
801
+ *,
802
+ confidence_grade: str,
803
+ score: float | None,
804
+ method: str = "form-field",
805
+ ) -> dict[str, Any]:
806
+ mapping = {
807
+ "kind": "form-field",
808
+ "key": field.get("key"),
809
+ "label": label,
810
+ "value": str(field.get("value", "")),
811
+ "field_index": int(form_field["index"]),
812
+ "field_id": form_field.get("field_id", ""),
813
+ "name": form_field.get("name", ""),
814
+ "prompt": form_field.get("prompt", ""),
815
+ "instruction": form_field.get("instruction", ""),
816
+ "current_text": form_field.get("current_value", ""),
817
+ "stylePolicy": field.get("stylePolicy", "preserve-target"),
818
+ "confidence": "high" if confidence_grade == _CONFIDENCE_LABEL_EXACT else "medium",
819
+ "confidenceGrade": confidence_grade,
820
+ "method": method,
821
+ }
822
+ if score is not None:
823
+ mapping["match_score"] = score
824
+ return mapping
825
+
826
+
827
+ def _find_form_field_by_mapping(
828
+ form_fields: list[dict[str, Any]],
829
+ mapping: dict[str, Any],
830
+ ) -> dict[str, Any] | None:
831
+ field_index = mapping.get("field_index")
832
+ field_id = str(mapping.get("field_id") or "")
833
+ name = _normalize_match_text(mapping.get("name"))
834
+ for field in form_fields:
835
+ if field_index is not None and int(field.get("index", -1)) == int(field_index):
836
+ return field
837
+ if field_id and field_id in {field.get("field_id"), field.get("id"), field.get("fieldid")}:
838
+ return field
839
+ if name and name == _normalize_match_text(field.get("name")):
840
+ return field
841
+ return None
842
+
843
+
844
+ def _table_cell_lookup(table: dict[str, Any], row: int, col: int) -> dict[str, Any] | None:
845
+ for cell in table.get("cells", []) or []:
846
+ if int(cell.get("row", -1)) == row and int(cell.get("col", -1)) == col:
847
+ return cell
848
+ return None
849
+
850
+
851
+ def _find_fuzzy_table_label_matches(doc: Any, label: str, direction: str) -> list[dict[str, Any]]:
852
+ wanted = _normalize_match_text(label)
853
+ if not wanted:
854
+ return []
855
+ row_delta, col_delta = (0, 1) if direction == "right" else (1, 0)
856
+ matches: list[dict[str, Any]] = []
857
+ for table in get_table_map_in_doc(doc).get("tables", []):
858
+ for cell in table.get("cells", []) or []:
859
+ cell_text = str(cell.get("text", ""))
860
+ normalized = _normalize_match_text(cell_text)
861
+ if not normalized or normalized == wanted:
862
+ continue
863
+ score = SequenceMatcher(None, wanted, normalized).ratio()
864
+ if score < _FUZZY_MATCH_THRESHOLD:
865
+ continue
866
+ target = _table_cell_lookup(
867
+ table,
868
+ int(cell.get("row", 0)) + row_delta,
869
+ int(cell.get("col", 0)) + col_delta,
870
+ )
871
+ if target is None:
872
+ continue
873
+ matches.append(
874
+ {
875
+ "table_index": table["table_index"],
876
+ "label_cell": {"row": cell["row"], "col": cell["col"], "text": cell_text},
877
+ "target_cell": {
878
+ "row": target["row"],
879
+ "col": target["col"],
880
+ "text": target.get("text", ""),
881
+ },
882
+ "score": round(score, 3),
883
+ }
884
+ )
885
+ matches.sort(key=lambda item: item["score"], reverse=True)
886
+ if len(matches) > 1 and abs(matches[0]["score"] - matches[1]["score"]) < 0.03:
887
+ return matches[:2]
888
+ return matches[:1]
889
+
890
+
891
+ def _iter_fill_items(canonical_input: dict[str, Any]) -> list[dict[str, Any]]:
892
+ items = list(canonical_input.get("fields", []))
893
+ for paragraph in canonical_input.get("paragraphs", []):
894
+ if isinstance(paragraph, dict):
895
+ items.append(
896
+ {
897
+ "key": paragraph.get("key"),
898
+ "label": paragraph.get("label") or paragraph.get("key"),
899
+ "value": paragraph.get("value", paragraph.get("text", "")),
900
+ "target": paragraph.get("target"),
901
+ "stylePolicy": paragraph.get("stylePolicy", "preserve-placeholder"),
902
+ }
903
+ )
904
+ return items
905
+
906
+
907
+ def _resolved_cell_mapping(field: dict[str, Any], target: dict[str, Any], *, method: str) -> dict[str, Any]:
908
+ return {
909
+ "kind": "cell",
910
+ "key": field.get("key"),
911
+ "label": field.get("label"),
912
+ "value": str(field.get("value", "")),
913
+ "table_index": int(target.get("table_index", target.get("tableIndex", 0))),
914
+ "row": int(target["row"]),
915
+ "col": int(target["col"]),
916
+ "stylePolicy": field.get("stylePolicy", "preserve-target"),
917
+ "confidence": "explicit",
918
+ "method": method,
919
+ }
920
+
921
+
922
+ def _label_and_direction(field: dict[str, Any], target: dict[str, Any]) -> tuple[str, str]:
923
+ path = str(target.get("path") or f"{field.get('label')} > right")
924
+ parts = [part.strip() for part in path.split(">") if part.strip()]
925
+ label = parts[0] if parts else str(field.get("label") or field.get("key"))
926
+ direction = parts[1].casefold() if len(parts) > 1 else "right"
927
+ if direction not in _TABLE_DIRECTIONS:
928
+ raise ValueError("label-path target currently supports right or down as the first direction")
929
+ return label, direction
930
+
931
+
932
+ def _resolve_analysis(
933
+ *,
934
+ plan_id: str | None,
935
+ analysis: FormFillPlanInput | dict[str, Any] | None,
936
+ ) -> dict[str, Any]:
937
+ if analysis is not None:
938
+ payload = _typed_payload(analysis)
939
+ if not isinstance(payload, dict): # pragma: no cover - type contract guard
940
+ raise ValueError("analysis must be a typed form-fill plan object")
941
+ return copy.deepcopy(payload)
942
+ if plan_id is None:
943
+ raise ValueError("provide plan_id or analysis")
944
+ try:
945
+ return copy.deepcopy(_FORM_FILL_PLANS[plan_id])
946
+ except KeyError as exc:
947
+ raise ValueError(f"unknown form-fill plan_id: {plan_id}") from exc
948
+
949
+
950
+ def _source_path_from_plan(plan: dict[str, Any], override: str | None) -> str:
951
+ if override:
952
+ return resolve_path(override)
953
+ source = plan.get("source") or {}
954
+ path = source.get("path") or source.get("filename")
955
+ if not path:
956
+ raise ValueError("source_filename is required")
957
+ return resolve_path(str(path))
958
+
959
+
960
+ def _destination_path_from_plan(plan: dict[str, Any], override: str | None) -> str:
961
+ if override:
962
+ return resolve_path(override)
963
+ destination = plan.get("destination") or {}
964
+ path = destination.get("path") or destination.get("filename")
965
+ if not path:
966
+ raise ValueError("destination_filename is required")
967
+ return resolve_path(str(path))
968
+
969
+
970
+ def _cell_style_snapshot(doc: Any, table_index: int, row: int, col: int) -> dict[str, Any]:
971
+ cell = _cell(doc, table_index, row, col)
972
+ paragraph = cell.paragraphs[0] if getattr(cell, "paragraphs", []) else None
973
+ return _paragraph_style_snapshot(paragraph)
974
+
975
+
976
+ def _paragraph_style_snapshot(paragraph: Any) -> dict[str, Any]:
977
+ run = paragraph.runs[0] if paragraph is not None and getattr(paragraph, "runs", []) else None
978
+ return {
979
+ "para_pr_id_ref": getattr(paragraph, "para_pr_id_ref", None),
980
+ "style_id_ref": getattr(paragraph, "style_id_ref", None),
981
+ "char_pr_id_ref": getattr(paragraph, "char_pr_id_ref", None),
982
+ "run_char_pr_id_ref": getattr(run, "char_pr_id_ref", None),
983
+ }
984
+
985
+
986
+ def _cell_text(doc: Any, table_index: int, row: int, col: int) -> str:
987
+ return _cell(doc, table_index, row, col).text or ""
988
+
989
+
990
+ def _cell(doc: Any, table_index: int, row: int, col: int) -> Any:
991
+ tables: list[Any] = []
992
+ for paragraph in doc.paragraphs:
993
+ tables.extend(getattr(paragraph, "tables", []))
994
+ return tables[table_index].rows[row].cells[col]
995
+
996
+
997
+ def _replace_placeholder(doc: Any, token: str, value: str) -> list[dict[str, Any]]:
998
+ replacements: list[dict[str, Any]] = []
999
+ for paragraph_index, paragraph in enumerate(doc.paragraphs):
1000
+ before_text = paragraph.text or ""
1001
+ if token not in before_text:
1002
+ continue
1003
+ before_style = _paragraph_style_snapshot(paragraph)
1004
+ replace_count = 0
1005
+ changed_runs: list[Any] = []
1006
+ for run in paragraph.runs:
1007
+ text = run.text or ""
1008
+ if token in text:
1009
+ replace_count += text.count(token)
1010
+ run.text = text.replace(token, value)
1011
+ if run.text:
1012
+ changed_runs.append(run)
1013
+ if replace_count:
1014
+ repair_pathological_text_spacing(
1015
+ doc,
1016
+ paragraph=paragraph,
1017
+ runs=changed_runs,
1018
+ )
1019
+ _clear_paragraph_layout_cache(paragraph)
1020
+ after_style = _paragraph_style_snapshot(paragraph)
1021
+ replacements.append(
1022
+ {
1023
+ "paragraph_index": paragraph_index,
1024
+ "before_text": before_text,
1025
+ "after_text": paragraph.text or "",
1026
+ "replace_count": replace_count,
1027
+ "style_before": before_style,
1028
+ "style_after": after_style,
1029
+ "style_preserved": before_style == after_style,
1030
+ }
1031
+ )
1032
+ return replacements
1033
+
1034
+
1035
+ def _reread_touched(doc: Any, applied: list[dict[str, Any]]) -> list[dict[str, Any]]:
1036
+ touched = []
1037
+ for item in applied:
1038
+ if item.get("kind") == "form-field":
1039
+ field = _find_form_field_by_mapping(_document_form_fields(doc), item)
1040
+ touched.append(
1041
+ {
1042
+ "kind": "form-field",
1043
+ "field_index": item.get("field_index"),
1044
+ "field_id": item.get("field_id"),
1045
+ "name": item.get("name"),
1046
+ "text": field.get("current_value", "") if field else "",
1047
+ "field": field or {},
1048
+ }
1049
+ )
1050
+ elif item.get("kind") == "cell":
1051
+ table_index = int(item["table_index"])
1052
+ row = int(item["row"])
1053
+ col = int(item["col"])
1054
+ data = get_table_data(doc, table_index)
1055
+ touched.append(
1056
+ {
1057
+ "kind": "cell",
1058
+ "table_index": table_index,
1059
+ "row": row,
1060
+ "col": col,
1061
+ "text": data["data"][row][col],
1062
+ "style": _cell_style_snapshot(doc, table_index, row, col),
1063
+ }
1064
+ )
1065
+ elif item.get("kind") == "placeholder":
1066
+ for replacement in item.get("replacements", []):
1067
+ paragraph_index = int(replacement["paragraph_index"])
1068
+ paragraph = doc.paragraphs[paragraph_index]
1069
+ touched.append(
1070
+ {
1071
+ "kind": "placeholder",
1072
+ "paragraph_index": paragraph_index,
1073
+ "text": paragraph.text or "",
1074
+ "style": _paragraph_style_snapshot(paragraph),
1075
+ }
1076
+ )
1077
+ return touched
1078
+
1079
+
1080
+ def _runtime_validation(path: str) -> dict[str, Any]:
1081
+ document_report = validate_document_path(path)
1082
+ structure_issues = [
1083
+ {
1084
+ "part": getattr(issue, "part_name", None),
1085
+ "message": getattr(issue, "message", str(issue)),
1086
+ }
1087
+ for issue in getattr(document_report, "issues", ())
1088
+ ]
1089
+ structure = {"ok": not structure_issues, "issues": structure_issues}
1090
+ if validate_package is None:
1091
+ package = _dependency_unavailable_report(
1092
+ "python-hwpx>=2.10.3 is required for HWPX package validation",
1093
+ _PACKAGE_VALIDATOR_IMPORT_ERROR,
1094
+ )
1095
+ else:
1096
+ package = _package_report(validate_package(path))
1097
+ open_safety = build_hwpx_open_safety_report(Path(path))
1098
+ return {
1099
+ "validate_structure": structure,
1100
+ "validate_package": package,
1101
+ "validate_document": _document_report(document_report),
1102
+ "openSafety": open_safety,
1103
+ }
1104
+
1105
+
1106
+ def _save_form_fill_document(doc: Any, destination: Path, *, quality: Any = None) -> Any:
1107
+ # Phase F: form fill funnels through the one SavePipeline gate too, and
1108
+ # returns the VisualCompleteReport so the response can carry the block.
1109
+ quality_contract.assert_write_capability()
1110
+ return quality_contract.save_through_pipeline(doc, destination, quality=quality)
1111
+
1112
+
1113
+ def _cleanup_temporary_destination(destination: Path) -> None:
1114
+ destination.unlink(missing_ok=True)
1115
+ destination.with_suffix(destination.suffix + ".bak").unlink(missing_ok=True)
1116
+
1117
+
1118
+ def _repair_repack_destination(destination: Path) -> dict[str, Any]:
1119
+ if repair_repack is None:
1120
+ detail = (
1121
+ str(_REPAIR_REPACK_IMPORT_ERROR)
1122
+ if _REPAIR_REPACK_IMPORT_ERROR is not None
1123
+ else "hwpx.tools.repair.repair_repack is unavailable"
1124
+ )
1125
+ raise RuntimeError(
1126
+ "python-hwpx>=2.10.3 is required for HWPX repair/open-safety handoff: "
1127
+ + detail
1128
+ )
1129
+ repaired = destination.with_name(f".{destination.name}.repair.hwpx")
1130
+ try:
1131
+ result = repair_repack(destination, repaired, overwrite=True)
1132
+ shutil.move(str(repaired), str(destination))
1133
+ return {
1134
+ "reordered": result.reordered,
1135
+ "crc_ok": result.crc_ok,
1136
+ "output_path": str(destination),
1137
+ "openSafety": result.open_safety,
1138
+ }
1139
+ finally:
1140
+ repaired.unlink(missing_ok=True)
1141
+
1142
+
1143
+ def _package_report(report: Any) -> dict[str, Any]:
1144
+ return {
1145
+ "ok": bool(getattr(report, "ok", False)),
1146
+ "checked_parts": list(getattr(report, "checked_parts", ())),
1147
+ "issues": [_issue_payload(issue) for issue in getattr(report, "issues", ())],
1148
+ }
1149
+
1150
+
1151
+ def _dependency_unavailable_report(message: str, error: Exception | None) -> dict[str, Any]:
1152
+ detail = f"{message}: {error}" if error is not None else message
1153
+ return {
1154
+ "ok": False,
1155
+ "checked_parts": [],
1156
+ "issues": [{"part": None, "message": detail, "level": "error"}],
1157
+ }
1158
+
1159
+
1160
+ def _document_report(report: Any) -> dict[str, Any]:
1161
+ return {
1162
+ "ok": bool(getattr(report, "ok", False)),
1163
+ "validated_parts": list(getattr(report, "validated_parts", ())),
1164
+ "issues": [_issue_payload(issue) for issue in getattr(report, "issues", ())],
1165
+ }
1166
+
1167
+
1168
+ def _issue_payload(issue: Any) -> dict[str, Any]:
1169
+ return {
1170
+ "part": getattr(issue, "part_name", None),
1171
+ "message": getattr(issue, "message", str(issue)),
1172
+ "level": getattr(issue, "level", "error"),
1173
+ }
1174
+
1175
+
1176
+ def _lineage_id(source_hash: str, destination: str) -> str:
1177
+ return hashlib.sha256(f"{source_hash}:{destination}".encode("utf-8")).hexdigest()[:16]