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,195 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from os import PathLike
6
+ from typing import Any
7
+
8
+ from hwpx.quality import VisualCompleteReport
9
+ from hwpx.tools.id_integrity import IdIntegrityReport, check_id_integrity
10
+ from hwpx.tools.idempotence import IdempotenceReport
11
+ from hwpx.tools.package_reconcile import PackageReconcileReport
12
+ from hwpx.tools.package_validator import EditorOpenSafetyReport, PackageValidationReport
13
+ from hwpx.tools.validator import ValidationReport
14
+
15
+
16
+ # Explicit scope of what the builder's automated gates prove vs. don't, so a
17
+ # green ``hard_gates`` is never mistaken for full Hancom/visual fidelity. The
18
+ # gates answer "will Hancom likely open this", NOT "did every authored element
19
+ # round-trip". Surfaced in every report's ``to_dict()``.
20
+ FIDELITY_CONTRACT: dict[str, list[str]] = {
21
+ "proves": [
22
+ "package opens as a valid HWPX (mimetype/OPC structure, required entries)",
23
+ "no dangling id references or orphan BinData (id_integrity)",
24
+ "no known editor-open breakage patterns (editor_open_safety)",
25
+ "re-saving reproduces identical part contents (idempotent serialization)",
26
+ "the document reopens with our reader (reopen)",
27
+ ],
28
+ "does_not_prove": [
29
+ "visual layout fidelity in Hancom (line/page breaks, overlap) — needs the "
30
+ "visual oracle / ComputerUse",
31
+ "every authored element round-tripped byte-for-byte: merges, shapes, BinData "
32
+ "bytes, and equation script are not value-diffed",
33
+ "macOS Hancom acceptance for untested element combinations",
34
+ ],
35
+ }
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ReopenReport:
40
+ """Result of reopening a generated document."""
41
+
42
+ ok: bool
43
+ error: str | None = None
44
+ document: Any | None = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class BuilderSaveReport:
49
+ """Validation and reopen report returned by builder saves."""
50
+
51
+ path: str | PathLike[str]
52
+ validate_package: PackageValidationReport
53
+ validate_document: ValidationReport
54
+ reopened: ReopenReport
55
+ metadata: dict[str, str] | None = None
56
+ hard_gates: dict[str, str] = field(default_factory=dict)
57
+ visual_review_required: bool = False
58
+ feature_flags: dict[str, bool] = field(default_factory=dict)
59
+ id_integrity: IdIntegrityReport | None = None
60
+ editor_open_safety: EditorOpenSafetyReport | None = None
61
+ # The uniform Phase-B report from the SavePipeline the builder save funnelled
62
+ # through (plan §2 Phase B). Additive: ``None`` only if a caller builds a
63
+ # report by hand without going through ``Document.save_to_path``.
64
+ visual_complete: VisualCompleteReport | None = None
65
+
66
+ def __post_init__(self) -> None:
67
+ hard_gates = dict(self.hard_gates)
68
+ if hard_gates.get("id_integrity") in {None, "unavailable"}:
69
+ id_integrity = None
70
+ if self.reopened.ok and self.reopened.document is not None:
71
+ id_integrity = check_id_integrity(self.reopened.document)
72
+ hard_gates["id_integrity"] = "pass" if id_integrity.ok else "fail"
73
+ else:
74
+ hard_gates["id_integrity"] = "fail"
75
+ object.__setattr__(self, "hard_gates", hard_gates)
76
+ object.__setattr__(self, "id_integrity", id_integrity)
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "path": str(self.path),
81
+ "metadata": dict(self.metadata or {}),
82
+ "hard_gates": dict(self.hard_gates),
83
+ "fidelity_contract": {
84
+ "proves": list(FIDELITY_CONTRACT["proves"]),
85
+ "does_not_prove": list(FIDELITY_CONTRACT["does_not_prove"]),
86
+ },
87
+ "visual_review_required": self.visual_review_required,
88
+ "feature_flags": dict(self.feature_flags),
89
+ "visual_complete": (
90
+ None if self.visual_complete is None else self.visual_complete.to_dict()
91
+ ),
92
+ "editor_open_safety": (
93
+ None
94
+ if self.editor_open_safety is None
95
+ else self.editor_open_safety.to_dict()
96
+ ),
97
+ "validate_package": {
98
+ "ok": self.validate_package.ok,
99
+ "checked_parts": list(self.validate_package.checked_parts),
100
+ "errors": [str(issue) for issue in self.validate_package.errors],
101
+ "warnings": [str(issue) for issue in self.validate_package.warnings],
102
+ "issues": [str(issue) for issue in self.validate_package.issues],
103
+ },
104
+ "validate_document": {
105
+ "ok": self.validate_document.ok,
106
+ "validated_parts": list(self.validate_document.validated_parts),
107
+ "errors": [str(issue) for issue in self.validate_document.errors],
108
+ "warnings": [str(issue) for issue in self.validate_document.warnings],
109
+ "issues": [str(issue) for issue in self.validate_document.issues],
110
+ },
111
+ "reopened": {
112
+ "ok": self.reopened.ok,
113
+ "error": self.reopened.error,
114
+ },
115
+ "id_integrity": (
116
+ None
117
+ if self.id_integrity is None
118
+ else {
119
+ "ok": self.id_integrity.ok,
120
+ "dangling": [str(item) for item in self.id_integrity.dangling],
121
+ "orphan_bin_data": [
122
+ {
123
+ "item_id": item.item_id,
124
+ "path": item.path,
125
+ "aliases": list(item.aliases),
126
+ "sources": list(item.sources),
127
+ "severity": item.severity,
128
+ }
129
+ for item in self.id_integrity.orphan_bin_data
130
+ ],
131
+ "ignored": [
132
+ {
133
+ "part": item.part,
134
+ "element": item.element,
135
+ "attr": item.attr,
136
+ "value": item.value,
137
+ "reason": item.reason,
138
+ }
139
+ for item in self.id_integrity.ignored
140
+ ],
141
+ }
142
+ ),
143
+ }
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class BuilderVerifyReport:
148
+ """Compact, no-disk pre-write verification signal from ``Document.verify()``.
149
+
150
+ Lowers the built document to bytes in memory and runs the same hard gates as
151
+ a real save plus a two-round idempotence check — without writing a file — so
152
+ a caller (agent, fuzz loop) can branch on ``ok`` before committing a path.
153
+ See :data:`FIDELITY_CONTRACT` for what these gates prove vs. don't.
154
+ """
155
+
156
+ ok: bool
157
+ reopen_ok: bool
158
+ package_ok: bool
159
+ document_ok: bool
160
+ editor_open_safety_ok: bool
161
+ id_integrity_ok: bool
162
+ idempotent: bool
163
+ sections_reconciled: bool = True
164
+ section_count: int = 0
165
+ paragraph_count: int = 0
166
+ byte_length: int = 0
167
+ reopen_error: str | None = None
168
+ serialize_error: str | None = None
169
+ idempotence: IdempotenceReport | None = None
170
+ reconcile: PackageReconcileReport | None = None
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ return {
174
+ "ok": self.ok,
175
+ "reopen_ok": self.reopen_ok,
176
+ "package_ok": self.package_ok,
177
+ "document_ok": self.document_ok,
178
+ "editor_open_safety_ok": self.editor_open_safety_ok,
179
+ "id_integrity_ok": self.id_integrity_ok,
180
+ "idempotent": self.idempotent,
181
+ "sections_reconciled": self.sections_reconciled,
182
+ "section_count": self.section_count,
183
+ "paragraph_count": self.paragraph_count,
184
+ "byte_length": self.byte_length,
185
+ "reopen_error": self.reopen_error,
186
+ "serialize_error": self.serialize_error,
187
+ "idempotence": (
188
+ None if self.idempotence is None else self.idempotence.to_dict()
189
+ ),
190
+ "reconcile": (None if self.reconcile is None else self.reconcile.to_dict()),
191
+ "fidelity_contract": {
192
+ "proves": list(FIDELITY_CONTRACT["proves"]),
193
+ "does_not_prove": list(FIDELITY_CONTRACT["does_not_prove"]),
194
+ },
195
+ }
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Template/Profile builder — new docs from verified Hancom saves (plan §2 E).
3
+
4
+ New documents are composed from **verified Hancom-saved templates + harvested
5
+ fragments**, never imagined XML. A :class:`~hwpx.design.profile.Profile` bundles a
6
+ body-stripped skeleton ``template.hwpx`` (real styles + page setup) with real
7
+ ``<hp:p>``/``<hp:tbl>`` fragments; :func:`~hwpx.design.composer.compose` lowers a
8
+ :class:`~hwpx.design.plan.DocumentPlan` onto it and saves through the one
9
+ SavePipeline. Production mode forbids the minimal from-scratch builder.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from .composer import ComposeResult, ProfileRequiredError, compose, compose_bytes
14
+ from .plan import Block, DocumentPlan
15
+ from .profile import Profile, available_profiles, load_profile
16
+ from .validator import StyleCoverage, style_coverage
17
+
18
+ __all__ = [
19
+ "DocumentPlan",
20
+ "Block",
21
+ "compose",
22
+ "compose_bytes",
23
+ "ComposeResult",
24
+ "ProfileRequiredError",
25
+ "Profile",
26
+ "load_profile",
27
+ "available_profiles",
28
+ "StyleCoverage",
29
+ "style_coverage",
30
+ ]
@@ -0,0 +1,144 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Shared XML helpers for the design composer/harvester (lxml element surgery).
3
+
4
+ Kept tiny and dependency-free so both :mod:`hwpx.design.harvest` (build the
5
+ skeleton + fragments once) and :mod:`hwpx.design.composer` (lower a plan) share
6
+ exactly one implementation of the delicate bits: ``secPr`` preservation, body
7
+ replacement, and lineseg-cache stripping.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from copy import deepcopy
12
+ from typing import Iterable
13
+
14
+ from lxml import etree # pyright: ignore[reportAttributeAccessIssue]
15
+
16
+ HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph"
17
+
18
+
19
+ def ln(tag: object) -> str:
20
+ return str(tag).rsplit("}", 1)[-1]
21
+
22
+
23
+ def hp(element: etree._Element, name: str) -> str:
24
+ """Namespaced tag for *name* using the element's own paragraph namespace."""
25
+
26
+ ns = element.tag.rsplit("}", 1)[0].lstrip("{") if "}" in element.tag else HP_NS
27
+ return f"{{{ns}}}{name}"
28
+
29
+
30
+ def iter_local(element: etree._Element, name: str) -> Iterable[etree._Element]:
31
+ for child in element.iter():
32
+ if ln(child.tag) == name:
33
+ yield child
34
+
35
+
36
+ def children_local(element: etree._Element, name: str) -> list[etree._Element]:
37
+ return [c for c in element if ln(c.tag) == name]
38
+
39
+
40
+ def first_run(paragraph: etree._Element) -> etree._Element | None:
41
+ for child in paragraph:
42
+ if ln(child.tag) == "run":
43
+ return child
44
+ return None
45
+
46
+
47
+ def find_secpr(section_element: etree._Element) -> etree._Element | None:
48
+ for el in section_element.iter():
49
+ if ln(el.tag) == "secPr":
50
+ return el
51
+ return None
52
+
53
+
54
+ def strip_lineseg(element: etree._Element) -> None:
55
+ """Remove every ``<hp:linesegarray>`` under *element* (edited → recompute)."""
56
+
57
+ for cache in list(iter_local(element, "linesegarray")):
58
+ parent = cache.getparent()
59
+ if parent is not None:
60
+ parent.remove(cache)
61
+
62
+
63
+ def set_paragraph_text(paragraph: etree._Element, text: str) -> None:
64
+ """Put *text* in the paragraph's first ``<hp:t>``, clear the rest, drop cache.
65
+
66
+ Preserves the run's ``charPrIDRef`` (the harvested style) — only the text
67
+ content changes.
68
+ """
69
+
70
+ t_nodes = [t for t in iter_local(paragraph, "t") if not _under_table(paragraph, t)]
71
+ if t_nodes:
72
+ primary = t_nodes[0]
73
+ primary.text = text
74
+ for child in list(primary): # drop inline children that held fragments
75
+ primary.remove(child)
76
+ for node in t_nodes[1:]:
77
+ node.text = ""
78
+ for child in list(node):
79
+ node.remove(child)
80
+ else:
81
+ run = first_run(paragraph)
82
+ if run is not None:
83
+ t = etree.SubElement(run, hp(paragraph, "t"))
84
+ t.text = text
85
+ strip_lineseg(paragraph)
86
+
87
+
88
+ def _under_table(paragraph: etree._Element, node: etree._Element) -> bool:
89
+ parent = node.getparent()
90
+ while parent is not None and parent is not paragraph:
91
+ if ln(parent.tag) in ("tbl", "tc", "subList"):
92
+ return True
93
+ parent = parent.getparent()
94
+ return False
95
+
96
+
97
+ def move_secpr_into(first_paragraph: etree._Element, secpr: etree._Element) -> None:
98
+ """Make *first_paragraph*'s first run carry *secpr* (page setup preserved).
99
+
100
+ HWPX requires ``secPr`` on the first paragraph's first run; the composer drops
101
+ the skeleton's empty carrier paragraph, so the secPr rides into the first real
102
+ fragment instead.
103
+ """
104
+
105
+ run = first_run(first_paragraph)
106
+ if run is None:
107
+ run = etree.SubElement(first_paragraph, hp(first_paragraph, "run"), {"charPrIDRef": "0"})
108
+ first_paragraph.insert(0, run)
109
+ # Remove any secPr already present, then insert the captured one first.
110
+ for existing in list(children_local(run, "secPr")):
111
+ run.remove(existing)
112
+ run.insert(0, deepcopy(secpr))
113
+
114
+
115
+ def replace_section_body(
116
+ section_element: etree._Element, paragraphs: list[etree._Element]
117
+ ) -> None:
118
+ """Replace the section's ``<hp:p>`` children with *paragraphs* (in order)."""
119
+
120
+ insert_at = None
121
+ for index, child in enumerate(list(section_element)):
122
+ if ln(child.tag) == "p":
123
+ if insert_at is None:
124
+ insert_at = index
125
+ section_element.remove(child)
126
+ if insert_at is None:
127
+ insert_at = len(list(section_element))
128
+ for offset, paragraph in enumerate(paragraphs):
129
+ section_element.insert(insert_at + offset, paragraph)
130
+
131
+
132
+ __all__ = [
133
+ "HP_NS",
134
+ "ln",
135
+ "hp",
136
+ "iter_local",
137
+ "children_local",
138
+ "first_run",
139
+ "find_secpr",
140
+ "strip_lineseg",
141
+ "set_paragraph_text",
142
+ "move_secpr_into",
143
+ "replace_section_body",
144
+ ]
@@ -0,0 +1,282 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Compose a new document from a profile + plan (plan §2 Phase E, task 2/3).
3
+
4
+ ``compose`` lowers a :class:`~hwpx.design.plan.DocumentPlan` onto a verified
5
+ Hancom-saved skeleton: it clones the harvested fragments (real ``<hp:p>``/
6
+ ``<hp:tbl>`` with real styles), fills their text/cells, preserves the template's
7
+ ``secPr``/page setup, checks ``styleCoverage``, and saves through the single
8
+ :class:`~hwpx.quality.SavePipeline`. Every byte of structure traces to a real
9
+ Hancom save — never imagined XML. Production mode forbids any minimal-XML fallback.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from copy import deepcopy
14
+ from dataclasses import dataclass, field
15
+ from os import PathLike
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+ from lxml import etree # pyright: ignore[reportAttributeAccessIssue]
20
+
21
+ from hwpx.document import HwpxDocument
22
+ from hwpx.quality import QualityPolicy
23
+
24
+ import hwpx_automation.office.authoring.design._support as S
25
+ from .plan import Block, DocumentPlan
26
+ from .profile import Profile, load_profile
27
+ from .validator import StyleCoverage, style_coverage
28
+
29
+ # Roles a paragraph block may request; unknown roles fall back to body.
30
+ _PARAGRAPH_ROLES = ("title", "heading", "subheading", "body")
31
+
32
+
33
+ class ProfileRequiredError(RuntimeError):
34
+ """Production compose could not stay on the verified template + fragments."""
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ComposeResult:
39
+ ok: bool
40
+ profile: str
41
+ output_path: str | None
42
+ visual_complete: Any
43
+ style_coverage: StyleCoverage
44
+ block_count: int
45
+ warnings: list[str] = field(default_factory=list)
46
+ errors: list[str] = field(default_factory=list)
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "ok": self.ok,
51
+ "profile": self.profile,
52
+ "outputPath": self.output_path,
53
+ "blockCount": self.block_count,
54
+ "styleCoverage": self.style_coverage.to_dict(),
55
+ "visualComplete": self.visual_complete.to_dict() if self.visual_complete else None,
56
+ "warnings": list(self.warnings),
57
+ "errors": list(self.errors),
58
+ }
59
+
60
+
61
+ def compose(
62
+ plan: DocumentPlan | dict,
63
+ *,
64
+ profile: Profile | None = None,
65
+ output_path: str | PathLike[str] | None = None,
66
+ quality: QualityPolicy | None = None,
67
+ production: bool = True,
68
+ ) -> ComposeResult:
69
+ """Lower *plan* onto its profile and save (or gate) through the SavePipeline."""
70
+
71
+ if isinstance(plan, dict):
72
+ plan = DocumentPlan.from_dict(plan)
73
+ prof = profile or load_profile(plan.profile)
74
+ warnings: list[str] = []
75
+
76
+ doc = HwpxDocument.open(prof.template_bytes)
77
+ try:
78
+ section = doc.sections[0]
79
+ secpr = S.find_secpr(section.element)
80
+ if secpr is None:
81
+ raise ProfileRequiredError(f"profile {prof.id!r} skeleton has no secPr")
82
+ secpr = deepcopy(secpr)
83
+
84
+ new_paragraphs: list[etree._Element] = []
85
+ for block in plan.iter_blocks():
86
+ frag = _fragment_for(prof, block, production, warnings)
87
+ if frag is None:
88
+ continue
89
+ new_paragraphs.append(frag)
90
+
91
+ if not new_paragraphs:
92
+ raise ProfileRequiredError(
93
+ f"plan produced no content for profile {prof.id!r}"
94
+ )
95
+
96
+ S.move_secpr_into(new_paragraphs[0], secpr)
97
+ S.replace_section_body(section.element, new_paragraphs)
98
+ _reassign_paragraph_ids(new_paragraphs)
99
+ section.mark_dirty()
100
+
101
+ coverage = style_coverage(doc, new_paragraphs)
102
+ errors: list[str] = []
103
+ if production and not coverage.meets(prof.style_coverage_threshold):
104
+ errors.append(
105
+ f"STYLE_COVERAGE_TOO_LOW: {coverage.coverage:.3f} < "
106
+ f"{prof.style_coverage_threshold} (missing {coverage.missing[:5]})"
107
+ )
108
+ return ComposeResult(
109
+ ok=False, profile=prof.id, output_path=None, visual_complete=None,
110
+ style_coverage=coverage, block_count=len(new_paragraphs),
111
+ warnings=warnings, errors=errors,
112
+ )
113
+
114
+ policy = quality or _default_policy()
115
+ try:
116
+ report = doc.save_report(output_path, quality=policy)
117
+ except ValueError as exc:
118
+ # save_report raises (not returns) when the serialized bytes fail the
119
+ # open-safety floor — convert to the structured ok=False contract.
120
+ return ComposeResult(
121
+ ok=False, profile=prof.id, output_path=None, visual_complete=None,
122
+ style_coverage=coverage, block_count=len(new_paragraphs),
123
+ warnings=warnings, errors=[f"OPEN_SAFETY_FAILED: {exc}"],
124
+ )
125
+ finally:
126
+ doc.close()
127
+
128
+ return ComposeResult(
129
+ ok=report.ok and coverage.ok,
130
+ profile=prof.id,
131
+ output_path=report.output_path,
132
+ visual_complete=report,
133
+ style_coverage=coverage,
134
+ block_count=len(new_paragraphs),
135
+ warnings=warnings + list(report.warnings),
136
+ errors=[str(e) for e in report.errors],
137
+ )
138
+
139
+
140
+ def compose_bytes(plan: DocumentPlan | dict, **kwargs: Any) -> tuple[bytes, ComposeResult]:
141
+ """Compose and return the serialized bytes (no file written).
142
+
143
+ The bytes are non-empty only when ``result.ok`` — on a gate failure the
144
+ SavePipeline withholds the output, so the returned bytes are ``b""``. Callers
145
+ MUST check ``result.ok`` before using the bytes.
146
+ """
147
+
148
+ import tempfile
149
+ from pathlib import Path
150
+
151
+ with tempfile.TemporaryDirectory() as tmp:
152
+ out = Path(tmp) / "composed.hwpx"
153
+ result = compose(plan, output_path=out, **kwargs)
154
+ data = out.read_bytes() if out.exists() else b""
155
+ return data, result
156
+
157
+
158
+ def _default_policy() -> QualityPolicy:
159
+ # Structural-tier strict (no render here; the oracle gate runs separately):
160
+ # open-safety + reference + layout lint on, render off.
161
+ return QualityPolicy(
162
+ require_open_safety=True,
163
+ require_reference_integrity=True,
164
+ require_visual_complete=False,
165
+ render_check="off",
166
+ layout_lint="strict",
167
+ overflow_policy="warn",
168
+ )
169
+
170
+
171
+ def _fragment_for(
172
+ prof: Profile, block: Block, production: bool, warnings: list[str]
173
+ ) -> etree._Element | None:
174
+ if block.type == "table":
175
+ if not prof.has_role("info_table"):
176
+ _forbid_fallback(production, f"profile {prof.id!r} has no info_table fragment", warnings)
177
+ return None
178
+ frag = prof.fragment("info_table")
179
+ _fill_table(frag, block.columns, block.rows, prof.id, warnings, production)
180
+ return frag
181
+
182
+ role = block.role if prof.has_role(block.role) else "body"
183
+ if not prof.has_role(role):
184
+ _forbid_fallback(production, f"profile {prof.id!r} has no {role!r} fragment", warnings)
185
+ return None
186
+ frag = prof.fragment(role)
187
+ S.set_paragraph_text(frag, block.text)
188
+ return frag
189
+
190
+
191
+ def _forbid_fallback(production: bool, message: str, warnings: list[str]) -> None:
192
+ if production:
193
+ raise ProfileRequiredError("PROFILE_REQUIRED: " + message)
194
+ warnings.append("debug fallback: " + message)
195
+
196
+
197
+ # --------------------------------------------------------------------------- #
198
+ # Table filling (row-fit the harvested fragment to the plan's rows).
199
+ # --------------------------------------------------------------------------- #
200
+ def _fill_table(
201
+ paragraph_frag: etree._Element,
202
+ columns: list[str],
203
+ rows: list[list[str]],
204
+ profile_id: str = "",
205
+ warnings: list[str] | None = None,
206
+ production: bool = True,
207
+ ) -> None:
208
+ warnings = warnings if warnings is not None else []
209
+ tbl = next((el for el in paragraph_frag.iter() if S.ln(el.tag) == "tbl"), None)
210
+ if tbl is None:
211
+ return
212
+ trs = [c for c in tbl if S.ln(c.tag) == "tr"]
213
+ if not trs:
214
+ return
215
+ header = trs[0]
216
+ data_template = deepcopy(trs[1]) if len(trs) > 1 else deepcopy(trs[0])
217
+ col_count = len([c for c in header if S.ln(c.tag) == "tc"])
218
+
219
+ # The harvested grid has a fixed width; a plan asking for MORE columns would
220
+ # silently lose data (fewer just leaves blank trailing cells). Surface it —
221
+ # and in production refuse the lossy case rather than drop the user's content.
222
+ want = len(columns) or max((len(r) for r in rows), default=0)
223
+ if want > col_count:
224
+ message = (
225
+ f"TABLE_COLUMN_MISMATCH: plan asks for {want} columns but the "
226
+ f"{profile_id!r} info_table grid has {col_count}; extra columns dropped"
227
+ )
228
+ if production:
229
+ raise ProfileRequiredError("PROFILE_REQUIRED: " + message)
230
+ warnings.append("debug fallback: " + message)
231
+ elif 0 < want < col_count:
232
+ warnings.append(
233
+ f"table column-count mismatch: plan has {want} columns, "
234
+ f"{profile_id!r} grid has {col_count}; trailing columns left blank"
235
+ )
236
+
237
+ # Always re-fill the header (even when columns is empty) so the harvested
238
+ # {{cell}} placeholder never ships in the output.
239
+ _fill_row(header, columns, col_count, row_addr=0)
240
+ for tr in trs[1:]:
241
+ tbl.remove(tr)
242
+
243
+ header_index = list(tbl).index(header)
244
+ data_rows = rows or [["" for _ in range(col_count)]]
245
+ for offset, row in enumerate(data_rows):
246
+ new_tr = deepcopy(data_template)
247
+ _fill_row(new_tr, row, col_count, row_addr=offset + 1)
248
+ tbl.insert(header_index + 1 + offset, new_tr)
249
+
250
+ tbl.set("rowCnt", str(1 + len(data_rows)))
251
+ S.strip_lineseg(paragraph_frag)
252
+
253
+
254
+ def _fill_row(tr: etree._Element, values: list[str], col_count: int, row_addr: int) -> None:
255
+ tcs = [c for c in tr if S.ln(c.tag) == "tc"]
256
+ for col_index, tc in enumerate(tcs):
257
+ addr = next((c for c in tc if S.ln(c.tag) == "cellAddr"), None)
258
+ if addr is not None:
259
+ addr.set("rowAddr", str(row_addr))
260
+ addr.set("colAddr", str(col_index))
261
+ value = values[col_index] if col_index < len(values) else ""
262
+ sublist = next((c for c in tc if S.ln(c.tag) == "subList"), None)
263
+ target = sublist if sublist is not None else tc
264
+ cell_paras = S.children_local(target, "p")
265
+ # Collapse the cell to a single paragraph — a source cell may hold several
266
+ # (empty) paragraphs, which Hancom flags as a damaged table after cloning.
267
+ for extra in cell_paras[1:]:
268
+ target.remove(extra)
269
+ if cell_paras:
270
+ S.set_paragraph_text(cell_paras[0], value)
271
+
272
+
273
+ def _reassign_paragraph_ids(paragraphs: list[etree._Element]) -> None:
274
+ """Give every <hp:p> a fresh id so clones don't collide (id integrity)."""
275
+
276
+ for root in paragraphs:
277
+ for para in root.iter():
278
+ if S.ln(para.tag) == "p" and para.get("id") is not None:
279
+ para.set("id", str(uuid4().int % (2**31)))
280
+
281
+
282
+ __all__ = ["compose", "compose_bytes", "ComposeResult", "ProfileRequiredError"]