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,437 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Style profile extraction, transfer, and template registry helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from ..document_ops import inspect_mail_merge_placeholders
|
|
14
|
+
from .template_analyzer import TemplateAnalysis, analyze_template
|
|
15
|
+
|
|
16
|
+
STYLE_PROFILE_SCHEMA_VERSION = "hwpx.style-profile.v1"
|
|
17
|
+
STYLE_PROFILE_COMPARISON_SCHEMA_VERSION = "hwpx.style-profile-comparison.v1"
|
|
18
|
+
TEMPLATE_REGISTRY_SCHEMA_VERSION = "hwpx.template-registry.v1"
|
|
19
|
+
|
|
20
|
+
_HWP_UNITS_PER_MM = 7200 / 25.4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_style_profile(source: str | Path | TemplateAnalysis) -> dict[str, Any]:
|
|
24
|
+
"""Extract a compact, plan-applicable style profile from an HWPX document."""
|
|
25
|
+
|
|
26
|
+
analysis = source if isinstance(source, TemplateAnalysis) else analyze_template(source)
|
|
27
|
+
page = _page_profile(analysis)
|
|
28
|
+
tables = [_table_profile(table) for table in analysis.table_summaries]
|
|
29
|
+
return {
|
|
30
|
+
"schemaVersion": STYLE_PROFILE_SCHEMA_VERSION,
|
|
31
|
+
"sourceName": analysis.source_name,
|
|
32
|
+
"page": page,
|
|
33
|
+
"fonts": _font_profile(analysis),
|
|
34
|
+
"runStyles": _run_style_profile(analysis),
|
|
35
|
+
"tables": tables,
|
|
36
|
+
"summary": {
|
|
37
|
+
"sectionCount": len(analysis.section_layouts),
|
|
38
|
+
"tableCount": len(tables),
|
|
39
|
+
"charPropertyCount": len(analysis.char_properties),
|
|
40
|
+
"bodyWidthHwp": page.get("bodyWidthHwp"),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_style_profile_to_plan(
|
|
46
|
+
document_plan: Mapping[str, Any],
|
|
47
|
+
style_profile: Mapping[str, Any],
|
|
48
|
+
*,
|
|
49
|
+
overwrite: bool = True,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Return a copy of *document_plan* with page, margin, and table profile hints applied."""
|
|
52
|
+
|
|
53
|
+
plan = copy.deepcopy(dict(document_plan))
|
|
54
|
+
plan.setdefault("schemaVersion", "hwpx.document_plan.v2")
|
|
55
|
+
plan["styleProfile"] = {
|
|
56
|
+
"schemaVersion": style_profile.get("schemaVersion", STYLE_PROFILE_SCHEMA_VERSION),
|
|
57
|
+
"sourceName": style_profile.get("sourceName"),
|
|
58
|
+
"applied": True,
|
|
59
|
+
}
|
|
60
|
+
sections = plan.get("sections")
|
|
61
|
+
if not isinstance(sections, list) or not sections:
|
|
62
|
+
blocks = plan.pop("blocks", [])
|
|
63
|
+
sections = [{"blocks": blocks if isinstance(blocks, list) else []}]
|
|
64
|
+
plan["sections"] = sections
|
|
65
|
+
|
|
66
|
+
page = style_profile.get("page") if isinstance(style_profile.get("page"), Mapping) else {}
|
|
67
|
+
table_profiles = [table for table in style_profile.get("tables", []) if isinstance(table, Mapping)]
|
|
68
|
+
table_index = 0
|
|
69
|
+
for section in sections:
|
|
70
|
+
if not isinstance(section, dict):
|
|
71
|
+
continue
|
|
72
|
+
if page:
|
|
73
|
+
_apply_page_profile(section, page, overwrite=overwrite)
|
|
74
|
+
blocks = section.get("blocks")
|
|
75
|
+
if not isinstance(blocks, list):
|
|
76
|
+
continue
|
|
77
|
+
for block in blocks:
|
|
78
|
+
if not isinstance(block, dict) or str(block.get("type")) != "table":
|
|
79
|
+
continue
|
|
80
|
+
profile = table_profiles[min(table_index, len(table_profiles) - 1)] if table_profiles else {}
|
|
81
|
+
_apply_table_profile(block, profile, overwrite=overwrite)
|
|
82
|
+
table_index += 1
|
|
83
|
+
return plan
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compare_style_profiles(
|
|
87
|
+
reference: str | Path | Mapping[str, Any],
|
|
88
|
+
candidate: str | Path | Mapping[str, Any],
|
|
89
|
+
*,
|
|
90
|
+
margin_tolerance_mm: float = 1.0,
|
|
91
|
+
table_weight_tolerance: float = 0.10,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Compare two style profiles or HWPX files and return transfer evidence."""
|
|
94
|
+
|
|
95
|
+
ref = _profile_arg(reference)
|
|
96
|
+
got = _profile_arg(candidate)
|
|
97
|
+
checks = [
|
|
98
|
+
_check_orientation(ref, got),
|
|
99
|
+
_check_margins(ref, got, tolerance_mm=margin_tolerance_mm),
|
|
100
|
+
_check_table_weights(ref, got, tolerance=table_weight_tolerance),
|
|
101
|
+
]
|
|
102
|
+
return {
|
|
103
|
+
"schemaVersion": STYLE_PROFILE_COMPARISON_SCHEMA_VERSION,
|
|
104
|
+
"pass": all(check["pass"] for check in checks),
|
|
105
|
+
"checks": checks,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def register_template(
|
|
110
|
+
name: str,
|
|
111
|
+
source: str | Path,
|
|
112
|
+
*,
|
|
113
|
+
registry_path: str | Path | None = None,
|
|
114
|
+
description: str = "",
|
|
115
|
+
tags: Sequence[str] | None = None,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""Register a template path with its style profile and placeholder contract."""
|
|
118
|
+
|
|
119
|
+
template_name = str(name or "").strip()
|
|
120
|
+
if not template_name:
|
|
121
|
+
raise ValueError("template name must be non-empty")
|
|
122
|
+
source_path = Path(source).expanduser().resolve(strict=False)
|
|
123
|
+
registry_file = _registry_path(registry_path)
|
|
124
|
+
registry = _load_registry(registry_file)
|
|
125
|
+
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
126
|
+
placeholders = inspect_mail_merge_placeholders(source_path)
|
|
127
|
+
entry = {
|
|
128
|
+
"name": template_name,
|
|
129
|
+
"source": str(source_path),
|
|
130
|
+
"description": str(description or ""),
|
|
131
|
+
"tags": [str(tag) for tag in (tags or [])],
|
|
132
|
+
"placeholderKeys": list(placeholders["keys"]),
|
|
133
|
+
"placeholderCount": int(placeholders["placeholderCount"]),
|
|
134
|
+
"styleProfile": extract_style_profile(source_path),
|
|
135
|
+
"updatedAt": now,
|
|
136
|
+
}
|
|
137
|
+
existing = registry["templates"].get(template_name)
|
|
138
|
+
entry["createdAt"] = existing.get("createdAt", now) if isinstance(existing, Mapping) else now
|
|
139
|
+
registry["templates"][template_name] = entry
|
|
140
|
+
_write_registry(registry_file, registry)
|
|
141
|
+
return entry
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_templates(*, registry_path: str | Path | None = None) -> dict[str, Any]:
|
|
145
|
+
"""List registered templates without returning full style profiles."""
|
|
146
|
+
|
|
147
|
+
registry = _load_registry(_registry_path(registry_path))
|
|
148
|
+
templates = []
|
|
149
|
+
for entry in registry["templates"].values():
|
|
150
|
+
if not isinstance(entry, Mapping):
|
|
151
|
+
continue
|
|
152
|
+
templates.append(
|
|
153
|
+
{
|
|
154
|
+
"name": entry.get("name"),
|
|
155
|
+
"source": entry.get("source"),
|
|
156
|
+
"description": entry.get("description", ""),
|
|
157
|
+
"tags": list(entry.get("tags", [])),
|
|
158
|
+
"placeholderKeys": list(entry.get("placeholderKeys", [])),
|
|
159
|
+
"updatedAt": entry.get("updatedAt"),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
return {
|
|
163
|
+
"schemaVersion": TEMPLATE_REGISTRY_SCHEMA_VERSION,
|
|
164
|
+
"templates": sorted(templates, key=lambda item: str(item.get("name") or "")),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def describe_template(
|
|
169
|
+
name: str,
|
|
170
|
+
*,
|
|
171
|
+
registry_path: str | Path | None = None,
|
|
172
|
+
values: Mapping[str, Any] | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Return one registered template and a placeholder fill report."""
|
|
175
|
+
|
|
176
|
+
registry = _load_registry(_registry_path(registry_path))
|
|
177
|
+
entry = registry["templates"].get(str(name))
|
|
178
|
+
if not isinstance(entry, Mapping):
|
|
179
|
+
raise KeyError(f"template is not registered: {name}")
|
|
180
|
+
result = copy.deepcopy(dict(entry))
|
|
181
|
+
result["placeholderReport"] = placeholder_fill_report(entry.get("placeholderKeys", []), values=values or {})
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def placeholder_fill_report(
|
|
186
|
+
source_or_keys: str | Path | Sequence[str],
|
|
187
|
+
*,
|
|
188
|
+
values: Mapping[str, Any] | None = None,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
"""Report which standardized placeholders are unfilled by *values*."""
|
|
191
|
+
|
|
192
|
+
if isinstance(source_or_keys, (str, Path)) and Path(source_or_keys).exists():
|
|
193
|
+
keys = inspect_mail_merge_placeholders(source_or_keys)["keys"]
|
|
194
|
+
else:
|
|
195
|
+
keys = [str(key) for key in cast(Sequence[str], source_or_keys)]
|
|
196
|
+
supplied = values or {}
|
|
197
|
+
missing = [key for key in keys if supplied.get(key) in (None, "")]
|
|
198
|
+
return {
|
|
199
|
+
"schemaVersion": TEMPLATE_REGISTRY_SCHEMA_VERSION,
|
|
200
|
+
"placeholderKeys": list(keys),
|
|
201
|
+
"missingKeys": missing,
|
|
202
|
+
"unfilledCount": len(missing),
|
|
203
|
+
"ok": not missing,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _page_profile(analysis: TemplateAnalysis) -> dict[str, Any]:
|
|
208
|
+
if not analysis.section_layouts:
|
|
209
|
+
return {}
|
|
210
|
+
layout = analysis.section_layouts[0]
|
|
211
|
+
page: dict[str, Any] = {
|
|
212
|
+
"widthHwp": layout.page_width,
|
|
213
|
+
"heightHwp": layout.page_height,
|
|
214
|
+
"bodyWidthHwp": layout.computed_body_width,
|
|
215
|
+
"marginsHwp": dict(layout.margins),
|
|
216
|
+
"marginsMm": {key: _hwp_to_mm(value) for key, value in layout.margins.items()},
|
|
217
|
+
}
|
|
218
|
+
if layout.page_width is not None:
|
|
219
|
+
page["widthMm"] = _hwp_to_mm(layout.page_width)
|
|
220
|
+
if layout.page_height is not None:
|
|
221
|
+
page["heightMm"] = _hwp_to_mm(layout.page_height)
|
|
222
|
+
if layout.page_width and layout.page_height:
|
|
223
|
+
page["orientation"] = "LANDSCAPE" if layout.page_width > layout.page_height else "PORTRAIT"
|
|
224
|
+
return page
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _font_profile(analysis: TemplateAnalysis) -> dict[str, Any]:
|
|
228
|
+
faces: dict[str, list[str]] = {}
|
|
229
|
+
for font_face in analysis.font_faces:
|
|
230
|
+
values = []
|
|
231
|
+
for font in font_face.fonts:
|
|
232
|
+
face = font.get("face") or font.get("name")
|
|
233
|
+
if face and face not in values:
|
|
234
|
+
values.append(face)
|
|
235
|
+
if values:
|
|
236
|
+
faces[font_face.lang] = values
|
|
237
|
+
primary = None
|
|
238
|
+
for lang in ("hangul", "latin", "hanja", "japanese", "other"):
|
|
239
|
+
if faces.get(lang):
|
|
240
|
+
primary = faces[lang][0]
|
|
241
|
+
break
|
|
242
|
+
return {"primary": primary, "facesByLang": faces}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _run_style_profile(analysis: TemplateAnalysis) -> list[dict[str, Any]]:
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
"id": item.id,
|
|
249
|
+
"flags": dict(item.flags),
|
|
250
|
+
"fontRef": item.font_ref,
|
|
251
|
+
"humanReadable": item.human_readable,
|
|
252
|
+
"attributes": dict(item.attributes),
|
|
253
|
+
}
|
|
254
|
+
for item in analysis.char_properties
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _table_profile(table: Any) -> dict[str, Any]:
|
|
259
|
+
widths = [int(width) if width is not None else None for width in table.column_widths.widths]
|
|
260
|
+
numeric = [width or 0 for width in widths]
|
|
261
|
+
total = sum(value for value in numeric if value > 0)
|
|
262
|
+
if total > 0:
|
|
263
|
+
weights = [round((value or 0) / total, 6) if value else 0 for value in widths]
|
|
264
|
+
else:
|
|
265
|
+
weights = [round(1 / max(len(widths), 1), 6) for _ in widths]
|
|
266
|
+
margins = [cell.margin for cell in table.cells if cell.margin]
|
|
267
|
+
return {
|
|
268
|
+
"tableIndex": table.table_index,
|
|
269
|
+
"rowCount": table.row_count,
|
|
270
|
+
"columnCount": table.column_count,
|
|
271
|
+
"columnWidthsHwp": widths,
|
|
272
|
+
"columnWeights": weights,
|
|
273
|
+
"completeColumnWidths": bool(table.column_widths.complete),
|
|
274
|
+
"cellMarginHwp": _most_common_mapping(margins),
|
|
275
|
+
"verticalAlignments": sorted({cell.vert_align for cell in table.cells if cell.vert_align}),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _apply_page_profile(section: dict[str, Any], page: Mapping[str, Any], *, overwrite: bool) -> None:
|
|
280
|
+
if overwrite or "page" not in section:
|
|
281
|
+
if page.get("widthMm") and page.get("heightMm"):
|
|
282
|
+
section["page"] = {
|
|
283
|
+
"widthMm": page["widthMm"],
|
|
284
|
+
"heightMm": page["heightMm"],
|
|
285
|
+
"orientation": page.get("orientation", "PORTRAIT"),
|
|
286
|
+
}
|
|
287
|
+
if overwrite or "margins" not in section:
|
|
288
|
+
margins = page.get("marginsMm")
|
|
289
|
+
if isinstance(margins, Mapping):
|
|
290
|
+
section["margins"] = {
|
|
291
|
+
f"{key}Mm": value
|
|
292
|
+
for key, value in margins.items()
|
|
293
|
+
if key in {"left", "right", "top", "bottom", "header", "footer", "gutter"}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _apply_table_profile(block: dict[str, Any], profile: Mapping[str, Any], *, overwrite: bool) -> None:
|
|
298
|
+
weights = list(profile.get("columnWeights") or [])
|
|
299
|
+
column_count = _block_column_count(block)
|
|
300
|
+
if column_count <= 0 or not weights:
|
|
301
|
+
return
|
|
302
|
+
fitted = _fit_weights(weights, column_count)
|
|
303
|
+
rows = block.get("rows") or []
|
|
304
|
+
if "header" in block or any(isinstance(row, Sequence) and not isinstance(row, (str, bytes, bytearray)) for row in rows):
|
|
305
|
+
if overwrite or "columnWidths" not in block:
|
|
306
|
+
block["columnWidths"] = fitted
|
|
307
|
+
elif isinstance(block.get("columns"), list):
|
|
308
|
+
for column, weight in zip(block["columns"], fitted, strict=False):
|
|
309
|
+
if isinstance(column, dict) and (overwrite or "widthWeight" not in column):
|
|
310
|
+
column["widthWeight"] = max(int(round(weight * 100)), 1)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _block_column_count(block: Mapping[str, Any]) -> int:
|
|
314
|
+
if isinstance(block.get("header"), list) and block["header"]:
|
|
315
|
+
return len(block["header"])
|
|
316
|
+
if isinstance(block.get("columns"), list) and block["columns"]:
|
|
317
|
+
return len(block["columns"])
|
|
318
|
+
rows = block.get("rows") or []
|
|
319
|
+
return max((len(row) for row in rows if isinstance(row, Sequence) and not isinstance(row, (str, bytes, bytearray))), default=0)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _fit_weights(weights: Sequence[Any], count: int) -> list[float]:
|
|
323
|
+
numeric = [max(float(weight or 0), 0.0) for weight in weights]
|
|
324
|
+
if not numeric:
|
|
325
|
+
numeric = [1.0]
|
|
326
|
+
if len(numeric) < count:
|
|
327
|
+
numeric.extend(numeric[-1] or 1.0 for _ in range(count - len(numeric)))
|
|
328
|
+
numeric = numeric[:count]
|
|
329
|
+
total = sum(numeric)
|
|
330
|
+
if total <= 0:
|
|
331
|
+
return [1.0 for _ in range(count)]
|
|
332
|
+
return [round(value / total, 6) for value in numeric]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _profile_arg(value: str | Path | Mapping[str, Any]) -> dict[str, Any]:
|
|
336
|
+
if isinstance(value, Mapping):
|
|
337
|
+
return dict(value)
|
|
338
|
+
return extract_style_profile(value)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _check_orientation(reference: Mapping[str, Any], candidate: Mapping[str, Any]) -> dict[str, Any]:
|
|
342
|
+
expected = (reference.get("page") or {}).get("orientation") if isinstance(reference.get("page"), Mapping) else None
|
|
343
|
+
actual = (candidate.get("page") or {}).get("orientation") if isinstance(candidate.get("page"), Mapping) else None
|
|
344
|
+
return {"name": "page_orientation", "pass": expected == actual, "expected": expected, "actual": actual}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _check_margins(reference: Mapping[str, Any], candidate: Mapping[str, Any], *, tolerance_mm: float) -> dict[str, Any]:
|
|
348
|
+
ref_margins = _page_margins(reference)
|
|
349
|
+
got_margins = _page_margins(candidate)
|
|
350
|
+
deltas = {
|
|
351
|
+
key: abs(float(ref_margins.get(key, 0)) - float(got_margins.get(key, 0)))
|
|
352
|
+
for key in sorted(set(ref_margins) | set(got_margins))
|
|
353
|
+
}
|
|
354
|
+
return {"name": "page_margins", "pass": all(delta <= tolerance_mm for delta in deltas.values()), "toleranceMm": tolerance_mm, "deltasMm": deltas}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _check_table_weights(reference: Mapping[str, Any], candidate: Mapping[str, Any], *, tolerance: float) -> dict[str, Any]:
|
|
358
|
+
ref_tables = [table for table in reference.get("tables", []) if isinstance(table, Mapping)]
|
|
359
|
+
got_tables = [table for table in candidate.get("tables", []) if isinstance(table, Mapping)]
|
|
360
|
+
if not ref_tables and not got_tables:
|
|
361
|
+
return {"name": "table_column_weights", "pass": True, "tablesCompared": 0, "maxDelta": 0}
|
|
362
|
+
if not ref_tables or not got_tables:
|
|
363
|
+
return {"name": "table_column_weights", "pass": False, "tablesCompared": 0, "maxDelta": None}
|
|
364
|
+
ref = list(ref_tables[0].get("columnWeights") or [])
|
|
365
|
+
got = list(got_tables[0].get("columnWeights") or [])
|
|
366
|
+
count = min(len(ref), len(got))
|
|
367
|
+
deltas = [abs(float(ref[index]) - float(got[index])) for index in range(count)]
|
|
368
|
+
max_delta = max(deltas, default=0)
|
|
369
|
+
return {
|
|
370
|
+
"name": "table_column_weights",
|
|
371
|
+
"pass": len(ref) == len(got) and max_delta <= tolerance,
|
|
372
|
+
"tablesCompared": 1,
|
|
373
|
+
"maxDelta": max_delta,
|
|
374
|
+
"tolerance": tolerance,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _page_margins(profile: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
379
|
+
page = profile.get("page")
|
|
380
|
+
if not isinstance(page, Mapping):
|
|
381
|
+
return {}
|
|
382
|
+
margins = page.get("marginsMm")
|
|
383
|
+
return margins if isinstance(margins, Mapping) else {}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _registry_path(path: str | Path | None) -> Path:
|
|
387
|
+
if path is None:
|
|
388
|
+
return Path.home() / ".hwpx" / "template_registry.json"
|
|
389
|
+
return Path(path).expanduser()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _load_registry(path: Path) -> dict[str, Any]:
|
|
393
|
+
if not path.exists():
|
|
394
|
+
return {"schemaVersion": TEMPLATE_REGISTRY_SCHEMA_VERSION, "templates": {}}
|
|
395
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
396
|
+
if not isinstance(payload, dict):
|
|
397
|
+
raise ValueError("template registry must be a JSON object")
|
|
398
|
+
if not isinstance(payload.get("templates"), dict):
|
|
399
|
+
payload["templates"] = {}
|
|
400
|
+
payload["schemaVersion"] = TEMPLATE_REGISTRY_SCHEMA_VERSION
|
|
401
|
+
return payload
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _write_registry(path: Path, registry: Mapping[str, Any]) -> None:
|
|
405
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _most_common_mapping(values: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|
410
|
+
counts: dict[str, tuple[int, Mapping[str, Any]]] = {}
|
|
411
|
+
for value in values:
|
|
412
|
+
key = json.dumps(dict(value), sort_keys=True, ensure_ascii=False)
|
|
413
|
+
current = counts.get(key)
|
|
414
|
+
counts[key] = ((current[0] + 1) if current else 1, value)
|
|
415
|
+
if not counts:
|
|
416
|
+
return {}
|
|
417
|
+
return dict(max(counts.values(), key=lambda item: item[0])[1])
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _hwp_to_mm(value: int | None) -> float | None:
|
|
421
|
+
if value is None:
|
|
422
|
+
return None
|
|
423
|
+
return round(value / _HWP_UNITS_PER_MM, 3)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
__all__ = [
|
|
427
|
+
"STYLE_PROFILE_COMPARISON_SCHEMA_VERSION",
|
|
428
|
+
"STYLE_PROFILE_SCHEMA_VERSION",
|
|
429
|
+
"TEMPLATE_REGISTRY_SCHEMA_VERSION",
|
|
430
|
+
"apply_style_profile_to_plan",
|
|
431
|
+
"compare_style_profiles",
|
|
432
|
+
"describe_template",
|
|
433
|
+
"extract_style_profile",
|
|
434
|
+
"list_templates",
|
|
435
|
+
"placeholder_fill_report",
|
|
436
|
+
"register_template",
|
|
437
|
+
]
|