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