python-hwpx-automation 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ ]