python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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]
|